Angular系列文章之angular路由
路由
(route)
,幾乎所有的MVC(VM)
框架都應該具有的特性,因為它是前端構建單頁面應用(SPA)
必不可少的組成部分。
那麼,對於angular
而言,它自然也有內建
的路由模組:叫做ngRoute
。
不過,大家很少用它,因為它的功能太有限,往往不能滿足開發需求!!
於是,一個基於ngRoute
開發的第三方路由模組,叫做ui.router
,受到了大家的“追捧”。
ngRoute vs ui.router
首先,無論是使用哪種路由,作為框架額外的附加功能,它們都將以模組依賴
的形式被引入,簡而言之就是:在引入路由原始檔
之後,你的程式碼應該這樣寫(以ui.router
為例):
angular.module("myApp", ["ui.router"]); // myApp為自定義模組,依賴第三方路由模組ui.router
這樣做的目的是:在程式啟動(bootstrap)的時候,載入依賴模組(如:ui.router),將所有掛載
在該模組的服務(provider)
,指令(directive)
,過濾器(filter)
等都進行註冊,那麼在後面的程式中便可以呼叫了。
說到這裡,就得看看ngRoute模組
和ui.router模組
各自都提供了哪些服務,哪些指令?
ngRoute
- $routeProvider(服務提供者) --------- 對應於下面的urlRouterProvider和stateProvider
- $route(服務) --------- 對應於下面的urlRouter和state
- $routeParams(服務) --------- 對應於下面的stateParams
- ng-view(指令) --------- 對應於下面的ui-view
ui.router
- $urlRouterProvider(服務提供者) --------- 用來配置路由重定向
- $urlRouter(服務)
- $stateProvider(服務提供者) --------- 用來配置路由
- $state(服務) --------- 用來顯示當前路由狀態資訊,以及一些路由方法(如:跳轉)
- $stateParams(服務) --------- 用來儲存路由匹配時的引數
- ui-view(指令) --------- 路由模板渲染,對應的dom相關聯
- ui-sref(指令)
- ...
(注
:服務提供者:用來提供服務例項和配置服務。)
這樣一看,其實ui.router
和ngRoute
大體的設計思路,對應的模組劃分都是一致的(畢竟是同一個團隊開發),不同的地方在於功能點的實現和增強
。
那麼問題來了:ngRoute
弱在哪些方面,ui.router
怎麼彌補了這些方面?
這裡,列舉兩個最重要的方面來說(其他細節,後面再說):
- 多檢視
- 巢狀檢視
多檢視
多檢視:頁面可以顯示多個動態變化的不同區塊。
這樣的業務場景是有的:
比如:頁面一個區塊用來顯示頁面狀態,另一個區塊用來顯示頁面主內容,當路由切換時,頁面狀態跟著變化,對應的頁面主內容也跟著變化。
首先,我們嘗試著用ngRoute
來做:
html
<div ng-view>區塊1</div>
<div ng-view>區塊2</div>
js
$routeProvider
.when('/', {
template: 'hello world'
});
我們在html中利用ng-view指令定義了兩個區塊,於是兩個div中顯示了相同的內容,這很合乎情理,但卻不是我們想要的,但是又不能為力,因為,在ngRoute中:
- 檢視沒有名字進行唯一標誌,所以它們被同等的處理。
- 路由配置只有一個模板,無法配置多個。
ok,針對上述兩個問題,我們嘗試用ui.router
來做:
html
<div ui-view></div>
<div ui-view="status"></div>
js
$stateProvider
.state('home', {
url: '/',
views: {
'': {
template: 'hello world'
},
'status': {
template: 'home page'
}
}
});
這次,結果是我們想要的,兩個區塊,分別顯示了不同的內容,原因在於,在ui.router中:
- 可以給檢視命名,如:ui-view="status"。
- 可以在路由配置中根據檢視名字(如:status),配置不同的模板(其實還有controller等)。
注
:檢視名是一個字串,不可以包含@
(原因後面會說)。
巢狀檢視
巢狀檢視:頁面某個動態變化區塊中,巢狀著另一個可以動態變化的區塊。
這樣的業務場景也是有的:
比如:頁面一個主區塊顯示主內容,主內容中的部分內容要求根據路由變化而變化,這時就需要另一個動態變化的區塊巢狀在主區塊中。
其實,巢狀檢視,在html中的最終表現就像這樣:
<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'
});
- 巧妙地,通過
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(...)
方法,它做了什麼呢?
它建立了一個rule,並存儲在rules集合裡面,之後的,每次hash值變化,路由重新查詢匹配都是通過遍歷這個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嗎?
答案是:肯定的,一般的路由器都是這麼做的,包括ngRoute。
那麼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>
當點選這個a標籤時,會直接跳轉到home state,而並不需要迴圈遍歷rules,ui.router是這樣做到的(這裡簡單說一下):
首先,ui-sref="home"指令會給對應的dom新增click事件
,然後根據state.name,直接跳轉到對應的state,程式碼像這樣:
element.bind("click", function(e) {
// ..省略若干程式碼
var transition = $timeout(function() {
// 手動跳轉到指定的state
$state.go(ref.state, params, options);
});
});
跳轉到對應的state之後,ui.router會做一個善後處理,就是改變hash,所以理所當然,會觸發’$locationChangeSuccess'事件,然後執行回撥,但是在回撥中可以通過一個判斷程式碼規避迴圈rules,像這樣:
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無非就是最好的學習例子,裡面涉及了大部分的知識點,所以接下來的程式碼講解大部分都會是這裡面的(建議下載到本地進行程式碼學習)。
為了更好的學習這個demo,我畫了一張圖來描述這個demo的contacts部分各個檢視模組,如下:
父與子
之前就說到,在ui.router中,路由就有父與子的關係(多個父與子湊起來就有了,祖先和子孫的關係),從javascript的角度來說,其實就是路由對應的state物件之間存在著某種引用
的關係。
用一張資料結構的表示下contacts部分,大概是這樣(原圖):
上面的圖看著有點亂,不過沒關係,起碼能看出各個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);
}
而ui-view
指令在進行link
的時候,在其內部就已經監聽了這一事件(訊息),來隨時更新檢視:
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>';
}]
}
}
我們會發現views物件裡面的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(覆蓋或者重寫)可能。
父路由和子路由之間就存在著檢視的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都會重新進行渲染嗎?
答案是:不會,只會從子路由對應的檢視開始區域性重新渲染。
在每次路由變化時,ui.router會記錄變化的子路由,並對子路由進行重新的預處理(包括controller,reslove等),最後區域性更新對應的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中,進行資料獲取的操作,然後返回一個promise,就像這樣:
resolve: {
'contacts': ['contacts',
function( contacts){
return contacts.all();
}]
}
上面有好多contacts,為了不混淆,我改一下程式碼:
resolve: {
'myResolve': ['contacts',
function(contacts){
return contacts.all();
}]
}
這樣就看清了,我們定義了resolve,包含了一個myResolve的key,它對應的value是一個函式,依賴注入了一個服務contacts,呼叫了contacts.all()
方法並返回了一個promise。
於是我們便可以在controller中引用myResolve,像這樣:
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
<div ui-view></div>
<div ui-view="status"></div>
javascript:
$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
結束語
以上就是我看過原始碼後,對ui.router的一些理解,以及對難懂的知識點的一些分析,如果有錯誤,還請指出,有不清楚的,歡迎留言,新浪微博 - Lovesueee。