天天看點

AngularJS—路由插件ui.router源碼解析

路由

(route)

,幾乎所有的

MVC(VM)

架構都應該具有的特性,因為它是前端建構單頁面應用

(SPA)

必不可少的組成部分。

那麼,對于

angular

而言,它自然也有

内置

的路由子產品:叫做

ngRoute

不過,大家很少用它,因為它的功能太有限,往往不能滿足開發需求!!

于是,一個基于

ngRoute

開發的第三方路由子產品,叫做

ui.router

,受到了大家的“追捧”。

ngRoute vs ui.router

首先,無論是使用哪種路由,作為架構額外的附加功能,它們都将以

子產品依賴

的形式被引入,簡而言之就是:在引入路由

源檔案

之後,你的代碼應該這樣寫(以

ui.router

為例):

這樣做的目的是:在程式啟動(bootstrap)的時候,加載依賴子產品(如:ui.router),将所有

挂載

在該子產品的

服務(provider)

指令(directive)

過濾器(filter)

等都進行注冊,那麼在後面的程式中便可以調用了。

說到這裡,就得看看

ngRoute子產品

ui.router子產品

各自都提供了哪些服務,哪些指令?

  1. ngRoute
    • $routeProvider(服務提供者) --------- 對應于下面的urlRouterProvider和stateProvider
    • $route(服務) --------- 對應于下面的urlRouter和state
    • $routeParams(服務) --------- 對應于下面的stateParams
    • ng-view(指令) --------- 對應于下面的ui-view
  2. ui.router
    • $urlRouterProvider(服務提供者) --------- 用來配置路由重定向
    • $urlRouter(服務)
    • $stateProvider(服務提供者) --------- 用來配置路由
    • $state(服務) --------- 用來顯示目前路由狀态資訊,以及一些路由方法(如:跳轉)
    • $stateParams(服務) --------- 用來存儲路由比對時的參數
    • ui-view(指令) --------- 路由模闆渲染,對應的dom相關聯
    • ui-sref(指令)
    • ...

(

:服務提供者:用來提供服務執行個體和配置服務。)

這樣一看,其實

ui.router

ngRoute

大體的設計思路,對應的子產品劃分都是一緻的(畢竟是同一個團隊開發),不同的地方在于功能點的實作和

增強

那麼問題來了:

ngRoute

弱在哪些方面,

ui.router

怎麼彌補了這些方面?

這裡,列舉兩個最重要的方面來說(其他細節,後面再說):

  1. 多視圖
  2. 嵌套視圖
多視圖
多視圖:頁面可以顯示多個動态變化的不同區塊。

這樣的業務場景是有的:

比如:頁面一個區塊用來顯示頁面狀态,另一個區塊用來顯示頁面主内容,當路由切換時,頁面狀态跟着變化,對應的頁面主内容也跟着變化。

首先,我們嘗試着用

ngRoute

來做:

html

<div ng-view>區塊1</div>
<div ng-view>區塊2</div>
           

js

$routeProvider
    .when('/', {
        template: 'hello world'
    });
           

我們在html中利用ng-view指令定義了兩個區塊,于是兩個div中顯示了相同的内容,這很合乎情理,但卻不是我們想要的,但是又不能為力,因為,在ngRoute中:

  1. 視圖沒有名字進行唯一标志,是以它們被同等的處理。
  2. 路由配置隻有一個模闆,無法配置多個。

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中:

  1. 可以給視圖命名,如:ui-view="status"。
  2. 可以在路由配置中根據視圖名字(如: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'
    });
           
  1. 巧妙地,通過

    parent

    parent.child

    來确定路由的

    父子關系

    ,進而解決無限遞歸問題。
  2. 另外子路由的模闆最終也将被插入到父路由模闆的div[ui-view]中去,進而達到視圖嵌套的效果。

ui.router工作原理

路由,大緻可以了解為:一個

查找比對

的過程。

對于前端

MVC(VM)

而言,就是将

hash值

(#xxx)與一系列的

路由規則

進行查找比對,比對出一個符合條件的規則,然後根據這個規則,進行資料的擷取,以及頁面的渲染。

是以,接下來:

  • 第一步,學會如何建立路由規則?
  • 第二步,了解路由查找比對原理?
路由的建立

首先,看一個簡單的例子:

$stateProvider
    .state('home', {
        url: '/abc',
        template: 'hello world'
    });
           

上面,我們通過調用

$stateProvider.state(...)

方法,建立了一個簡單路由規則,通過參數,可以容易了解到:

  1. 規則名:'home'
  2. 比對的url:'/abc'
  3. 對應的模闆:'hello world'

意思就是說:當我們通路

http://xxxx#/abc

的時候,這個路由規則被比對到,對應的模闆會被填到某個

div[ui-view]

中。

看上去似乎很簡單,那是因為我們還沒有深究具體的一些路由配置參數(我們後面再說)。

這裡需要深入的是:

$stateProvider.state(...)

方法,它做了些什麼工作?

  1. 首先,建立并存儲一個state對象,裡面包含着該路由規則的所有配置資訊。
  2. 然後,調用

    $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,

  1. 當程式比對到某一個子路由時,我們就認為這個子路由state被激活,同時,它對應的父路由state也将被激活。
  2. 我們還可以手動的激活某一個state,就像上面寫的那樣,

    $state.transitionTo(state, ...);

    ,這樣的話,它的父state會被激活(如果還沒有激活的話),它的子state會被銷毀(如果已經激活的話)。

ok,回到之前的路由注冊,調用了

$urlRouterProvider.when(...)

方法,它做了什麼呢?

它建立了一個rule,并存儲在rules集合裡面,之後的,每次hash值變化,路由重新查找比對都是通過周遊這個

rules

集合進行的。

路由的查找比對

有了之前,路由的建立和注冊,接下來,自然會想到路由是如何查找比對的?

恐怕,這得從頁面加載完畢說起:

  1. angular 在剛開始的 digest時,‘ rootScope

    會觸發

    locationChangeSuccess‘事件(angular在每次浏覽器hashchange的時候也會觸發‘ locationChangeSuccess`事件)
  2. ui.router 監聽了

    $locationChangeSuccess

    事件,于是開始通過周遊一系列rules,進行路由查找比對
  3. 當比對到路由後,就通過

    $state.transitionTo(state,...)

    ,跳轉激活對應的state
  4. 最後,完成資料請求和模闆的渲染

可以從下面這段源代碼看到,看到查找比對的起始和過程:

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 = ; 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在建立路由時:

  1. 會執行個體化一個對應的state對象,并存儲起來(states集合裡面)
  2. 每一個state對象都有一個state.name進行唯一辨別(如:'home')

根據以上兩點,于是ui.router提供了另一個指令叫做:

ui-sref指令

,來解決這個問題,比如這樣:

當點選這個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" target="_blank" rel="external nofollow" 來改變hash

,然後跳轉到對應state(雖然也是可以的),因為這樣做會多了一步rules循環周遊,浪費性能,就像下面這樣:

路由詳解

這裡詳細地介紹ui.router的參數配置和一些深層次用法。

不過,在這之前,需要一個demo,ui.router的官網demo無非就是最好的學習例子,裡面涉及了大部分的知識點,是以接下來的代碼講解大部分都會是這裡面的(建議下載下傳到本地進行代碼學習)。

為了更好的學習這個demo,我畫了一張圖來描述這個demo的contacts部分各個視圖子產品,如下:

AngularJS—路由插件ui.router源碼解析
父與子

之前就說到,在ui.router中,路由就有父與子的關系(多個父與子湊起來就有了,祖先和子孫的關系),從javascript的角度來說,其實就是路由對應的state對象之間存在着某種

引用

的關系。

用一張資料結構的表示下contacts部分,大概是這樣(原圖):

AngularJS—路由插件ui.router源碼解析

上面的圖看着有點亂,不過沒關系,起碼能看出各個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',
            },
            '[email protected]': {
                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>';
                }]
            }
        }
    });
           

這裡我們使用了另外兩種方式設定模闆:

  1. template

    :直接指定模闆内容,另外也可以是函數傳回模闆内容
  2. 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視圖下
    '[email protected]': {
        template: 'This is contacts.detail populating the "hint" ui-view'
    },
    // 模闆内容會被安插在父路由(contacts)模闆的名為menuTip視圖下
    '[email protected]': {
            templateProvider: ['$stateParams', function($stateParams) {
                return '<hr><small class="muted">Contact ID: ' + $stateParams.contactId + '</small>';
            }]
    }
}
           

我們會發現views對象裡面的

key

變化了,最明顯的是出現了一個

@

符号,其實這樣的key值是ui.router的一個設計,它的原型是:

viewName + '@' + stateName

,解釋下:

  1. viewName

    • 指的是

      ui-view="status"

      中的'status'
    • 也可以是''(空字元串),因為會有匿名的

      ui-view

      或者

      ui-view=""

  2. 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: {
            '[email protected]': {
              template: 'This is contacts.detail populating the "hint" ui-view'
            }       
        }
    });
    
$stateProvider
    .state('contacts.detail.item', {
        url: '/item/:itemId',
        views: {
            '[email protected]': {
              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是可以進行

依賴注入

的,它注入的對象有兩種:

  1. 已經注冊的服務(service),如:

    $state

    utils

  2. 上面的

    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;
}]
           

這樣做的目的:

  1. 簡化了controller的操作,将資料的擷取放在resolve中進行,這在多個視圖多個controller需要相同資料時,有一定的作用。
  2. 隻有當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');
                }, );
                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');
                }, );
                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);
                }]
            }
        }
    });
           

總結一下:

  1. 路由的controller除了可以依賴注入正常的service,也可以依賴注入resolve
  2. 子路由的resolve可以依賴注入父路由的resolve,也可以重寫父路由的resolve供controller調用
  3. 路由可以有單獨的state.resolve之外,還可以在views視圖中單獨配置resolve,視圖resolve是可以依賴注入自身state.resolve甚至是父路由的state.resolve