angular.js的ui-router总结
我们的布局/模板文件 index.html
我们通过建立一个主文件来引入我们所需要的所有资源以开始我们的项目 ,这里我们使用 index.html 文件作为主文件
现在,我们加载我们所需的资源(AngularJS, ngAnimate, Ui Router, 以及其他脚本和样式表)并且设定一个 ui-view用来告知 UI Router 我们的视图需要显示到哪里。这里我们使用 Bootstrap 来快速应用样式。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
<!-- index.html --> <! DOCTYPE html> < html > < head > < meta charset = "utf-8" > <!-- CSS --> < link rel = "stylesheet" href = "//netdna.bootstrapcdn.com/bootswatch/3.1.1/darkly/bootstrap.min.css" > < link rel = "stylesheet" href = "style.css" > <!-- JS --> <!-- load angular, nganimate, and ui-router --> < script src = "//ajax.googleapis.com/ajax/libs/angularjs/1.2.16/angular.min.js" ></ script > < script src = "//cdnjs.cloudflare.com/ajax/libs/angular-ui-router/0.2.10/angular-ui-router.min.js" ></ script > < script src = "//ajax.googleapis.com/ajax/libs/angularjs/1.2.16/angular-animate.min.js" ></ script > < script src = "app.js" ></ script > </ head > <!-- apply our angular app --> < body ng-app = "formApp" > < div class = "container" > <!-- views will be injected here --> < div ui-view></ div > </ div > </ body > </ html > |
创建我们的Angular App app.js
现在我们来创建应用和路由。 在一个大型应用中, 你肯定希望把你的Angular应用、路由、控制器分布到它们各自的模块中,但是为了完成我们的简单用例,我们将把它们都放到app.js这个欢乐的大家庭中。
我们在html中利用ng-view指令定义了两个区块,于是两个div中显示了相同的内容,这很合乎情理,但却不是我们想要的,但是又不能为力,因为,在ngRoute中:
- 视图没有名字进行唯一标志,所以它们被同等的处理。路由配置只有一个模板,无法配置多个。
ui.router
来做:html
<div ui-view></div>
<div ui-view="status"></div>
$stateProvider .state('home', { url: '/', views: { '': { template: 'hello world' }, 'status': { template: 'home page' } } });
这次,结果是我们想要的,两个区块,分别显示了不同的内容,原因在于,在ui.router中:
- 可以给视图命名,如:ui-view=”status”。可以在路由配置中根据视图名字(如:status),配置不同的模板(其实还有controller等)。
注
:视图名是一个字符串,不可以包含@
(原因后面会说)。嵌套视图:页面某个动态变化区块中,嵌套着另一个可以动态变化的区块。
这样的业务场景也是有的:
比如:页面一个主区块显示主内容,主内容中的部分内容要求根据路由变化而变化,这时就需要另一个动态变化的区块嵌套在主区块中。
<div ng-view>
I am parent
<div ng-view>I am child</div>
</div>
转成javascript,我们会在程序里这样写:
$routeProvider
.when('/', {
template: 'I am parent <div ng-view>I am child</div>'
});
ngRoute
这样写,你会发现浏览器崩溃了,因为在ng-view指令link的过程中,代码会无限递归下去。那么造成这种现象的最根本原因:路由没有明确的父子层级关系!
看看ui.router
是如何解决这一问题的?
$stateProvider
.state('parent', {
abstract: true,
url: '/',
template: 'I am parent <div ui-view></div>'
})
.state('parent.child', {
url: '',
template: 'I am child'
});
- 1234567891011
- 巧妙地,通过
parent
与parent.child
来确定路由的父子关系
,从而解决无限递归问题。另外子路由的模板最终也将被插入到父路由模板的div[ui-view]中去,从而达到视图嵌套的效果。
ui.router工作原理
路由,大致可以理解为:一个
查找匹配
的过程。
对于前端MVC(VM)
而言,就是将hash值
(#xxx)与一系列的路由规则
进行查找匹配,匹配出一个符合条件的规则,然后根据这个规则,进行数据的获取,以及页面的渲染。
所以,接下来:
- 第一步,学会如何创建路由规则?第二步,了解路由查找匹配原理?
路由的创建
首先,看一个简单的例子:
$stateProvider
.state('home', {
url: '/abc',
template: 'hello world'
});
上面,我们通过调用$stateProvider.state(...)
方法,创建了一个简单路由规则,通过参数,可以容易理解到:
- 规则名:’home’匹配的url:’/abc’对应的模板:’hello world’
http://xxxx#/abc
的时候,这个路由规则被匹配到,对应的模板会被填到某个div[ui-view]
中。看上去似乎很简单,那是因为我们还没有深究具体的一些路由配置参数(我们后面再说)。
这里需要深入的是:$stateProvider.state(...)
方法,它做了些什么工作?
- 首先,创建并存储一个state对象,里面包含着该路由规则的所有配置信息。然后,调用
$urlRouterProvider.when(...)
方法,进行路由的注册
(之前是路由的创建),代码里是这样写的:
$urlRouterProvider.when(state.url, ['$match', '$stateParams', function ($match, $stateParams) {
// 判断是否是同一个state || 当前匹配参数是否相同
if ($state.$current.navigable != state || !equalForKeys($match, $stateParams)) {
$state.transitionTo(state, $match, { inherit: true, location: false });
}
}]);
hash值
与state.url
相匹配时,就执行后面那段回调,回调函数里面进行了两个条件判断之后,决定是否需要跳转到该state?这里就插入了一个话题:为什么说 “跳转到该state,而不是该url”?
其实这个问题跟大家一直说的:“ui.router是基于state(状态)的,而不是url
”是同一个问题。
我的理解是这样的:之前就说过,路由存在着明确的父子关系
,每一个路由可以理解为一个state,
- 当程序匹配到某一个子路由时,我们就认为这个子路由state被激活,同时,它对应的父路由state也将被激活。我们还可以手动的激活某一个state,就像上面写的那样,
$state.transitionTo(state, ...);
,这样的话,它的父state会被激活(如果还没有激活的话),它的子state会被销毁(如果已经激活的话)。
ok,回到之前的路由注册,调用了$urlRouterProvider.when(...)
方法,它做了什么呢?
rules
集合进行的。路由的查找匹配
有了之前,路由的创建和注册,接下来,自然会想到路由是如何查找匹配的?
恐怕,这得从页面加载完毕说起:
- angular 在刚开始的$digest时,
$rootScope
会触发$locationChangeSuccess
事件(angular在每次浏览器hash change的时候也会触发$locationChangeSuccess
事件)ui.router 监听了$locationChangeSuccess
事件,于是开始通过遍历一系列rules,进行路由查找匹配当匹配到路由后,就通过$state.transitionTo(state,...)
,跳转激活对应的state最后,完成数据请求和模板的渲染
可以从下面这段源代码看到,看到查找匹配的起始和过程:
function update(evt) { // ...省略 function check(rule) { var handled = rule($injector, $location); // handled可以是返回: // 1. 新的的url,用于重定向 // 2. false,不匹配 // 3. true,匹配 if (!handled) return false; if (isString(handled)) $location.replace().url(handled); return true; } var n = rules.length, i; // 渲染遍历rules,匹配到路由,就停止循环 for (i = 0; i < n; i++) { if (check(rules[i])) return; } // 如果都匹配不到路由,使用otherwise路由(如果设置了的话) if (otherwise) check(otherwise); } function listen() { // 监听$locationChangeSuccess,开始路由的查找匹配 listener = listener || $rootScope.$on('$locationChangeSuccess', update); return listener; } if (!interceptDeferred) listen();
那么,问题来了:难道每次路由变化(hash变化),由于监听了’$locationChangeSuccess’事件,都要进行rules的遍历
来查找匹配路由,然后跳转到对应的state吗?
那么ui.router对于这样的问题,会怎么进行优化
呢?
回归到问题:我们之所以要循环遍历rules,是因为要查找匹配到对应的路由(state),然后跳转过去,倘若不循环,能直接找到对应的state吗?
答案是:可以的。
还记得前面说过,在用ui.router在创建路由时:
- 会实例化一个对应的state对象,并存储起来(states集合里面)每一个state对象都有一个state.name进行唯一标识(如:’home’)
根据以上两点,于是ui.router提供了另一个指令叫做:ui-sref指令
,来解决这个问题,比如这样:
<a ui-sref="home">通过ui-sref跳转到home state</a>
首先,ui-sref=”home”指令会给对应的dom添加click事件
,然后根据state.name,直接跳转到对应的state,代码像这样:
element.bind("click", function(e) {
// ..省略若干代码
var transition = $timeout(function() {
// 手动跳转到指定的state
$state.go(ref.state, params, options);
});
});
function update(evt) {
var ignoreUpdate = lastPushedUrl && $location.url() === lastPushedUrl;
// 手动调用$state.go(...)时,直接return避免下面的循环
if (ignoreUpdate) return true;
// 省略下面的循环ruls代码
}
说了那么多,其实就是想说,我们不建议直接使用href="#/xxx"来改变hash
,然后跳转到对应state(虽然也是可以的),因为这样做会多了一步rules循环遍历,浪费性能,就像下面这样:
<a href="#/abc">通过href跳转到home state</a>
这里详细地介绍ui.router的参数配置和一些深层次用法。
不过,在这之前,需要一个demo,ui.router的官网demo无非就是最好的学习例子,里面涉及了大部分的知识点,所以接下来的代码讲解大部分都会是这里面的(建议下载到本地进行代码学习)。
父与子
之前就说到,在ui.router中,路由就有父与子的关系(多个父与子凑起来就有了,祖先和子孙的关系),从javascript的角度来说,其实就是路由对应的state对象之间存在着某种引用
的关系。
parent
字段维系了这样一个父与子
的关系(粉红色的线)。ok,接下来就看下是如何定义路由的父子关系的?
假设有一个父路由,如下:
$stateProvider
.state('contacts', {});
ui.router提供了几种方法来定义它的子路由:
1.点标记法(推荐
)
$stateProvider
.state('contacts.list', {});
通过状态名
简单明了地来确定父子路由关系,如:状态名为’a.b.c’的路由,对应的父路由就是状态名为’a.b’路由。
2.parent
属性
$stateProvider
.state({
name: 'list', // 状态名也可以直接在配置里指定
parent: 'contacts' // 父路由的状态名
});
或者:
$stateProvider
.state({
name: 'list', // 状态名也可以直接在配置里指定
parent: { // parent也可以是一个父路由配置对象(指定路由的状态名即可)
name: 'contacts'
}
});
parent
直接指定父路由,可以是父路由的状态名(字符串),也可以是一个包含状态名的父路由配置(对象)。竟然路由有了父与子
的关系,那么它们的注册顺序有要求嘛?
答案是:没有要求,我们可以在父路由存在之前,创建子路由(不过,不是很推荐),因为ui.router在遇到这种情况时,在内部会帮我们先缓存
子路由的信息,等待它的父路由注册完毕后,再进行子路由的注册。
模板渲染
当路由成功跳转到指定的state时,ui.router会触发'$stateChangeSuccess'
事件通知所有的ui-view
进行模板重新渲染。
代码是这样的:
if (options.notify) {
$rootScope.$broadcast('$stateChangeSuccess', to.self, toParams, from.self, fromParams);
}
scope.$on('$stateChangeSuccess', function() {
updateView(false);
});
大体的模板渲染过程就是这样的,这里遇到一个问题,就是:每一个 div[ui-view]
在重新渲染的时候如何获取到对应视图模板的呢?
要想知道这个答案,
首先,我们得先看一下模板如何设置?
单视图
的时候,我们会这样做:$stateProvider
.state('contacts', {
abstract: true,
url: '/contacts',
templateUrl: 'app/contacts/contacts.html'
});
在配置对象里面,我们用templateUrl
指定模板路径即可。
如果我们需要设置多视图
,就需要用到views字段
,像这样:
$stateProvider .state('contacts.detail', { url: '/{contactId:[0-9]{1,4}}', views: { '' : { templateUrl: 'app/contacts/contacts.detail.html', }, 'hint@': { template: 'This is contacts.detail populating the "hint" ui-view' }, 'menuTip': { templateProvider: ['$stateParams', function($stateParams) { return '<hr><small class="muted">Contact ID: ' + $stateParams.contactId + '</small>'; }] } } });
这里我们使用了另外两种方式设置模板:
template
:直接指定模板内容,另外也可以是函数返回模板内容templateProvider
:通过依赖注入的调用函数的方式返回模板内容
单视图
和多视图
模板的方式,其实最终它们在ui.router内部都会被统一格式化成的views
的形式,且它们的key值会做特殊变化:上述的单视图
会变成这样:
views: {
// 模板内容会被安插在根路由模板(index.html)的匿名视图下
'@': {
abstract: true,
url: '/contacts',
templateUrl: 'app/contacts/contacts.html'
}
}
多视图
会变成这样:
views: { // 模板内容会被安插在父路由(contacts)模板的匿名视图下 '@contacts': { templateUrl: 'app/contacts/contacts.detail.html', }, // 模板内容会被安插在根路由(index.html)模板的名为hint视图下 'hint@': { template: 'This is contacts.detail populating the "hint" ui-view' }, // 模板内容会被安插在父路由(contacts)模板的名为menuTip视图下 'menuTip@contacts': { templateProvider: ['$stateParams', function($stateParams) { return '<hr><small class="muted">Contact ID: ' + $stateParams.contactId + '</small>'; }] } }
key
变化了,最明显的是出现了一个@
符号,其实这样的key值是ui.router的一个设计,它的原型是:viewName + '@' + stateName
,解释下:viewName
- 指的是
ui-view="status"
中的’status’也可以是”(空字符串),因为会有匿名的ui-view
或者ui-view=""
- 指的是
stateName
- 默认情况下是父路由的
state.name
,因为子路由模板一般都安插在父路由的ui-view
中也可以是”(空字符串),表示最顶层rootState还可以是任意的祖先state.name
- 默认情况下是父路由的
这样原型的意思是,表示该模板将会被安插在名为stateName路由对应模板的viewName视图下(可以看看上面代码中的注释理解下)。
其实这也解释了之前我说的:“为什么state.name里面不能存在@
符号”?因为@
在这里被用于特殊含义了。
所以,到这里,我们就知道在ui-view
重新进行模板渲染时,是根据viewName + '@' + stateName
来获取对应的视图模板内容(其实还有controller等)的。
其实,由于路由有了父与子
的关系,某种程度上就有了override(覆盖或者重写)可能。
$stateProvider .state('contacts.detail', { url: '/{contactId:[0-9]{1,4}}', views: { 'hint@': { template: 'This is contacts.detail populating the "hint" ui-view' } } }); $stateProvider .state('contacts.detail.item', { url: '/item/:itemId', views: { 'hint@': { template: ' This is contacts.detail.item overriding the "hint" ui-view' } } });
上面两个路由(state)存在着父与子
的关系,且他们都对@hint
定义了视图,那么当子路由被激活时(它的父路由也会被激活),我们应该选择哪个视图配置呢?
答案是:子路由的配置。
具体的,ui.router是如何实现这样的视图override的呢?
简单地回答就是:通过javascript原型链实现的,你可以在每次路由切换成功后,尝试着打印出$state.current.locals
这个变量一看究竟。
还有一个很重要的问题,关乎性能:当我们子路由变化时,页面中所有的ui-view都会重新进行渲染吗?
答案是:不会,只会从子路由对应的视图开始局部重新渲染。
controller控制器
有了模板之后,必然不可缺少controller向模板对应的作用域(scope)中填写数据,这样才可以渲染出动态数据。
我们可以为每一个视图添加不同的controller,就像下面这样:
$stateProvider .state('contacts', { abstract: true, url: '/contacts', templateUrl: 'app/contacts/contacts.html', resolve: { 'contacts': ['contacts', function( contacts){ return contacts.all(); }] }, controller: ['$scope', '$state', 'contacts', 'utils', function ($scope, $state, contacts, utils) { // 向作用域写数据 $scope.contacts = contacts; }] });
注意:controller是可以进行依赖注入
的,它注入的对象有两种:
- 已经注册的服务(service),如:
$state
,utils
上面的reslove
定义的解决项(这个后面来说),如:contacts
reslove解决项
resolve在state配置参数中,是一个对象(key-value),每一个value都是一个可以依赖注入的函数,并且返回的是一个promise(当然也可以是值,resloved defer)。
resolve: {
'myResolve': ['contacts',
function(contacts){
return contacts.all();
}]
}
这样就看清了,我们定义了resolve,包含了一个myResolve的key,它对应的value是一个函数,依赖注入了一个服务contacts,调用了contacts.all()
方法并返回了一个promise。
controller: ['$scope', '$state', 'myResolve', 'utils',
function ($scope, $state, contacts, utils) {
// 向作用域写数据
$scope.contacts = contacts;
}]
这样做的目的:
- 简化了controller的操作,将数据的获取放在resolve中进行,这在多个视图多个controller需要相同数据时,有一定的作用。只有当reslove中的promise全部resolved(即数据获取成功)后,才会触发
'$stateChangeSuccess'
切换路由,进而实例化controller,然后更新模板。
另外,子路由的resolve或者controller都是可以依赖注入父路由的resolve提供的数据服务,就像这样:
$stateProvider .state('parent', { url: '', resolve: { parent: ['$q', '$timeout', function ($q, $timeout) { var defer = $q.defer(); $timeout(function () { defer.resolve('parent'); }, 1000); return defer.promise; }] }, template: 'I am parent <div ui-view></div>' }) .state('parent.child', { url: '/child', resolve: { child: ['parent', function (parent) { // 调用父路由的解决项 return parent + ' and child'; }] }, controller: ['child', 'parent', function (child, parent) { // 调用自身的解决项,以及父路由的解决项 console.log(child, parent); }], template: 'I am child' });
另外每一个视图也可以单独定义自己的resolve和controller,它们也是可以依赖注入自身的state.resolve,或者view下的resolve,或者父路由的reslove,就像这样:
html
$stateProvider .state('home', { url: '/home', resolve: { common: ['$q', '$timeout', function ($q, $timeout) { // 公共的resolve var defer = $q.defer(); $timeout(function () { defer.resolve('common data'); }, 1000); return defer.promise; }], }, views: { '': { resolve: { special: ['common', function (common) { // 访问state.resolve console.log(common); }] } }, 'status': { resolve: { common: function () { // 重写state.resolve return 'override common data' } }, controller: ['common', function (common) { // 访问视图自身的resolve console.log(common); }] } } });
总结一下:
- 路由的controller除了可以依赖注入正常的service,也可以依赖注入resolve子路由的resolve可以依赖注入父路由的resolve,也可以重写父路由的resolve供controller调用路由可以有单独的state.resolve之外,还可以在views视图中单独配置resolve,视图resolve是可以依赖注入自身state.resolve甚至是父路由的state.resolve