1. 程式人生 > >UI-Router:為什麼開發者都不喜歡Angular.js內建的路由

UI-Router:為什麼開發者都不喜歡Angular.js內建的路由

Angular.js 是一個用來構建“富客戶端”的神奇JavaScript框架。但是事實卻是許多開發者卻不使用其內建的路由模組。反而使用AngularUI專案的 UI-Router模組來代替之。

這是因為UI-Router有兩個重要的特性:
* 多樣化檢視
* 嵌入式檢視

這篇文章將解釋這兩個特性,和運用現實生活的例子展現這兩個特性的重要性。

為什麼你需要使用UI-Router

多樣化檢視

大多數的應用程式都可以分解為一個一個區塊。最簡單的情況,一個應用程式有頭部(header),主體內容(main content area),以及一個尾部(footer)。

通常一個應用程式會有一個額外的側邊欄(sidebar )在頁面的左邊或者右邊。

整體結構如下圖所示:

應用結構圖

大多數用例中,這些區塊將同時顯示在頁面上。Angular.js 的內建路由ngRoute只允許一個檢視(ng-view)出現在頁面上。這樣限制的情況下,人們可以使用包含頁面(ng-include)或者 其他的變通方法為應用建立一個佈局(layout)或主頁(master page)。UI-Router支援多樣化檢視,並且每一個檢視都有自己相應的控制,所以每個區塊都是封裝好,可以複用到整個應用程式需要的地方。

嵌入式檢視

常見的例子中,一個應用的嵌入式頁面一般是主頁的詳情頁面,更具體的說,就是列表的詳情頁面。許多應用程式,都有列表頁面,點選其中一個列表元素,可以進入到列表的詳情頁面。更進一步說,你點選列表中一個行的連線,進入一個 可檢視

詳情頁面或是一個 可編輯 的表單。

如下圖所示:
嵌入式檢視示意圖

如果列表頁面和詳情頁面是單獨分開的(或者他們被Angujar.js回撥),使用Angular.js的內建路由ngRoute 是非常容易完成的。然而,如果你想要保持列表不變,而詳情頁面出現在列表的右邊或者下面,這樣就變得非常具有挑戰性了。

需要澄清的是,這樣的要求可以使用ngRoute來完成。但是你需要讓兩個控制器(一個用於列表,一個用於顯示和隱藏詳情)共享一個檢視。這樣的結果不是理想的,因為我們想要列表和詳情頁面有各自的控制器和檢視,並且職責單一(顯示列表或者顯示列表專案的詳情)。封裝這些使用者介面模組到它們各自的檢視,這樣我們就有更多的“可組合UI”,允許我們將各個區塊整合到一起,或者根據需求拆分。嵌入式檢視,不僅能夠讓這些檢視同時出現,還能讓一個檢視嵌入到另一個檢視中。

歷史

Angular.js首次釋出ngRoute的時候,是有類似功能的路由存在的。這樣路由包含在Backbone.js中,以及獨立路由庫History.jsSammy.js。總是,他們對映一個路由或者是URL所需要執行的JavaScript程式碼,當URL改變時,需要將其增加到瀏覽器的歷史記錄中,防止按回退按鈕不會破壞路由。

最終獲勝的JavaScript**MV***框架,是想Ember.jsDurandal.js 這樣,創建出更健壯的路由,以支援多樣化檢視和內嵌式檢視,並且在內部使用“狀態機”設計模式。

AngularJS官方迴應稱,從1.1.6版本將ngRoute從angular.js核心中刪除(更多的說法是1.2)。ngRoute依然可以從AngularJS的官網上獲得,但是它早已不在核心之中。

AngularJS的社群認為,更受歡迎的路由庫是AngularUI 專案的UI-Router

AngularJS的幾個顧問包括Rob Eisenberg(Durandal.js 和Aurelia的建立者),正在重寫ngRoute,並聲稱最終將在某個時間點一直回來,預期的版本是Angular.js 2.0。(注1)

如果你想了解更詳細的歷史和各種路由的優缺點,你可以檢視Angular.js 2.0的路由設計公開文件。(需翻牆)

這個文件你可以點選右上方的綠色按鈕,選擇建議模式(suggesting)和檢視模式(viewing),使頁面更清晰。

建議模式

檢視模式

安裝

使用UI-Router,基於Angular.js 1.2.x或Angular.13.x,你可以通過以下一種方式獲得其JavaScript原始碼:

下載

bower install

$ bower install angular-ui-router

npm install

$ npm install angular-ui-router

引入檔案

引入angular-ui-router.jsangular-ui-router.min.js到你的index.html,必須Angualr.js核心檔案之後,如下:

<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.1.5/angular.min.js"></script><script src="js/angular-ui-router.min.js"></script>

(注2)

引入依賴

將“ui.router”依賴新增到你的主Angular.js module中。

var myApp = angular.module('myApp', ['ui.router']);

注意:是ui.router不是ui-router,後者是許多人經常犯的錯誤。

路由狀態機

UI-Router 引進了狀態機設計模式,抽象高於傳統的路由。路由成了狀態,URL就成了狀態的一個簡單屬性。

var app = angular.module('demo', ['ui.router']);

app.config(['$stateProvider', '$urlRouterProvider', function($stateProvider, $urlRouterProvider) {
    $urlRouterProvider.otherwise('/');

    $stateProvider
        .state('home', {
            url:'/',
            templateUrl: 'templates/home.html',
            controller: 'HomeController'
        })
        .state('about', {
            url:'/about',
            templateUrl: 'templates/about.html',
            controller: 'AboutController'
        })

}]);

當你想通過ui-sref建立一個連結是,使用的是狀態而不是URL。

使用:
<a ui-sref="home">Home</a>

放棄:
<a href="#/">Home</a>

在上面的例子中,ui-sref可以這樣理解:uiAngularUI專案所有指令的字首,sref是包裝了傳統HTML錨點標籤的href屬性和狀態判斷。

控制器中使用

下面例子展示的是,如果在一個控制器中重定向一個狀態。

$scope.redirectToAbout = function(){
    $state.go('about');
}

stateProviderrouteProvider

當使用UI-Router時,為Angular.js服務注入路由支援,就由routeProviderngRoutestateProvider

$urlRouterProvider

$urlRouterProvider 在這裡有兩個主要目的。一是建立一個預設路由,用於管理未知的URL(統一跳轉到某處)。

app.config(['$stateProvider', '$urlRouterProvider', function($stateProvider, $urlRouterProvider) {
    $urlRouterProvider.otherwise('/');
    ...
}]);

二是監聽瀏覽器位址列URL的變化,重定向到路由定義的狀態中。

app.config(['$stateProvider', '$urlRouterProvider', function($stateProvider, $urlRouterProvider) {
    $urlRouterProvider
        .when('/legacy-route', {
            redirectTo: '/'
        });
}]);

總之,urlRouterProviderstateProvider*沒有檢測到的情況。

現在你對UI-Router有了一個基本認識,UI-Router的這些特性,讓它比ngRoute更好。

UI-Router實踐

我們將先看到一個嵌入式檢視的例子,然後在看到一個多樣化檢視的例子。之後,我們再重頭看怎樣將二者一起應用到一個現實世界的例子。

UI-Router嵌入式檢視案例

UI-Router嵌入式檢視的列表詳情頁面。這個例子顯示的是一個電視節目的列表。
電視節目列表

如果你點選其中一行,你可以看到這行的詳情描述。
節目詳情

index.html

AngularJS的應用程式是單頁應用程式,檢視是插入到shell頁中的。這裡就是我們的shell頁——index.html:

<!doctype html>
<html id="data-ng-app" data-ng-app="demo">
    <head>
        <meta charset="utf-8">
        <title>ui router demo</title>
        <style type="text/css">
            .selected{background-color: #efefef; width:120px; }
            .detail{width: 300px;margin: 30px;border-top: 1px solid #efefef;}
        </style>
        <!-- IE8-HTML5: https://code.google.com/p/html5shiv/ -->
        <script src="js/libs/html5shiv.js"></script>

    </head>
    <body id="index">

        <!-- Angular UI Router Directive for template insertion -->
        <div id="content" ui-view></div>

        <script src="js/libs/angular.js"></script>
        <script src="js/libs/underscore.js"></script>
        <script src="js/libs/angular-ui-router.js"></script>        
        <script src="js/main.js"></script>      
    </body>
</html>

UI-Router 將第一級檢視或是父檢視(在例子中是shows.html)顯示在<div id="content" ui-view></div>這個div之中。

主頁檢視(templates/shows.html)

shows.html是列表頁面。

<ul>
    <li ui-sref-active="selected" ng-repeat="show in shows">
        <a ui-sref="shows.detail({id: show.id})">{{show.name}}</a>
    </li>
</ul>

<div class="detail" ui-view></div>

正如前面所提到的,index.html中有一個ui-view屬性指令,當相應的路由被請求時,檢視(shows.html)則會渲染在這個div中。

請注意,這裡有另一個ui-view嵌入在shows.html中。這個ui-view代表的是一個當父檢視已渲染之後,再出現的子檢視。在這個例子中,是shows-detail.html。

顯示詳情檢視(templates/shows-detail.html)

shows-detail.html是詳情頁面。

<h3>{{selectedShow.name}}</h3>
<p>
    {{selectedShow.description}}
</p>
</code>

控制器

下面是各個檢視相應的控制器。

ShowsController

ShowsControllerShowsService載入一個記憶體中的陣列顯示。

app.controller('ShowsController', ['$scope','ShowsService', function($scope, ShowsService) {
    $scope.shows = ShowsService.list();
 }]);
ShowsDetailController

ShowsDetailControllerShowsService獲取要顯示項的id,並設定給$scope.selectedShow
 

app.controller('ShowsDetailController', ['$scope','$stateParams', 'ShowsService', function($scope, $stateParams, ShowsService) {
        $scope.selectedShow = ShowsService.find($stateParams.id);
 }]);

配置

我們需要使用$stateProvider配置UI-Router

當我們按照父狀態名.子狀態名的方式定義一個狀態,UI-Router便知道子狀態是內嵌在父狀態中的。

app.config(['$stateProvider', '$urlRouterProvider', function($stateProvider, $urlRouterProvider) {
    $urlRouterProvider.otherwise('/shows');

    $stateProvider
        .state('shows', {
            url:'/shows',
            templateUrl: 'templates/shows.html',
            controller: 'ShowsController'
        })
        .state('shows.detail', {
            url: '/detail/:id',
            templateUrl: 'templates/shows-detail.html',
            controller: 'ShowsDetailController'
        });
}]);

真正偉大的是,嵌入式檢視是列表控制器實現列表和詳情相關的部分,而詳情控制器只負責顯示詳情。

演示如何解耦合,我們只需要更改如有配置,將嵌入式頁面更改為獨立的兩個虛擬頁(一個列表路由,一個詳情路由)。更具體的說,就是將shows.detail更改為detail

$stateProvider
    .state('shows', {
        url:'/shows',
        templateUrl: 'templates/shows.html',
        controller: 'ShowsController'
    })
    .state('detail', {
        url: '/detail/:id',
        templateUrl: 'templates/shows-detail.html',
        controller: 'ShowsDetailController'
    });
    ...

並且將連結狀態的地方由<a ui-sref="shows.detail({id: show.id})">{{show.name}}</a>,更改為<a ui-sref="detail({id: show.id})">{{show.name}}</a>

現在我們的例子,變成了連個獨立的頁面分別顯示。

Service

ShowsService在這個例子中,使我們的資料訪問層。它的職責就是保持一個數組在記憶體中,使用underscore.js(注3)非常容易實現這點。

app.factory('ShowsService',function(){
    var shows = [{
        id: 1,
        name: 'Walking Dead',
        description: 'The Walking Dead is an American post-apocalyptic horror drama television series developed by Frank Darabont. It is based on the comic book series of the same name by Robert Kirkman, Tony Moore, and Charlie Adlard. It stars Andrew Lincoln as sheriff\'s deputy Rick Grimes, who awakens from a coma to find a post-apocalyptic world dominated by flesh-eating zombies.'
    },
    {
        id: 2,
        name: 'Breaking Bad',
        description: 'Breaking Bad is an American crime drama television series created and produced by Vince Gilligan. The show originally aired on the AMC network for five seasons, from January 20, 2008 to September 29, 2013. The main character is Walter White (Bryan Cranston), a struggling high school chemistry teacher who is diagnosed with inoperable lung cancer at the beginning of the series.'   
    },
    {
        id: 3,
        name: '7D',
        description: 'The 7D is an American animated television series produced by Disney Television Animation, and broadcast on Disney XD starting in July 7, 2014. It is a re-imagining of the titular characters from the 1937 film Snow White and the Seven Dwarfs by Walt Disney Productions'
    }];


    return {
        list: function(){
            return shows;
        },
        find: function(id){
            return _.find(shows, function(show){return show.id == id});
        }
    }
 });

UI-Router多樣化檢視案例

下面這個例子有多個區塊在一個頁面,有headercontentfooter 。它們被UI-Router用多樣化檢視所管理。

多樣化檢視案例

這是一些主導航,和在這個應用中根據使用者導航填充的各式各樣的虛擬頁。

子頁

index.html

<!DOCTYPE html>
 <html class="no-js">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <title>Index</title>
        <meta name="description" content="">
        <meta name="viewport" content="width=device-width, initial-scale=1">


    </head>
    <body ng-app="demo">

       <div ui-view="header"></div>
       <div ui-view="content"></div>
       <div ui-view="footer"></div>

    <script src="/js/bower_components/angular/angular.js"></script>
    <script src="/js/bower_components/angular-ui-router/release/angular-ui-router.js"></script>
    <script src="/js/main.js"></script>

    </body>
</html>

請注意,這裡的ui-view屬性指令都被賦予了一個名字:headercontentfooter。這些名字是當我們在配置路由,要指定view/template和控制器需要作用於頁面上的那個區塊時引用。

Templates/Views

這些模版是直接簡單的例子。Header.html有一些導航。這些導航使用ui-sref指令導航到指定的狀態。

partials/header.html
<div class="ul">
    <li><a ng-href="/">Home</a></li>    
    <li ui-sref-active="active"><a ui-sref="dashboard">Dashboard</a></li>
    <li ui-sref-active="active"><a ui-sref="campaigns">Campaigns</a></li>
</div>
partials/content.html
<p>This is the default content.</p>
partials/footer.html
<p>This is the footer.</p>
partials/dashboard.html
<h2>Dashboard</h2>
partials/campaigns.html
<h2>Campaigns</h2>

當相應的路由被請求時,將用相應的模版,如dashboard.htmlcampaigns.html替換掉content.html

配置

像上一個例子中那樣,我們使用$stateProvider配置狀態(路由)。

下面的關鍵是注意,一個url不是一個只有一個templateUrlcontroller屬性了,而是使用views屬性,賦予它一個各自的templateUrlcontroller的集合。

所以原來這樣:

.state('home',{
        url: '/',
        templateUrl: '/templates/partials/header.html',
        controller: 'HomeController'
    })

就變成了這樣:

.state('home',{
        url: '/',
        views: {
            'header': {
                templateUrl: '/templates/partials/header.html',
                controller: 'HeaderController'
            },
            'content': {
                templateUrl: '/templates/partials/content.html',
                controller: 'ContentController'
            },
            'footer': {
                templateUrl: '/templates/partials/footer.html',
                controller: 'FooterController'
            }
        }
    })

下面是完整的程式碼。(請注意:為了保持完整性,我把不需要控制器的寫在上面)

var app = angular.module('demo', ['ui.router']);

app.config(function($stateProvider, $urlRouterProvider){

    $urlRouterProvider.otherwise('/');

    $stateProvider
    .state('home',{
        url: '/',
        views: {
            'header': {
                templateUrl: '/templates/partials/header.html'
            },
            'content': {
                templateUrl: '/templates/partials/content.html'
            },
            'footer': {
                templateUrl: '/templates/partials/footer.html'
            }
        }
    })

    .state('dashboard', {
        url: '/dashboard',
        views: {
            'header': {
                templateUrl: '/templates/partials/header.html'
            },
            'content': {
                templateUrl: 'templates/dashboard.html',
                controller: 'DashboardController'
            }
        }

    })

    .state('campaigns', {
        url: '/campaigns',
        views: {
            'content': {
                templateUrl: 'templates/campaigns.html',
                controller: 'CampaignController'
            },
            'footer': {
                templateUrl: '/templates/partials/footer.html'
            }
        }

    })
});

另外需要注意的是,如果我沒有填充一個區塊或試圖,那麼使用者導航到該路由時,將不會顯示。這是不理想的,並且違反DRY原則(注4)。所以接下來的部門,我們將看到如何使用內嵌式檢視去除冗餘。

UI-Router的內嵌式檢視和多樣化檢視案例

現在我們瞭解了這些偉大的特性(內嵌式檢視和多樣化檢視),讓我們用這些特性一起應用到一個真實世界的應用程式中。

配置

因為檢視的模板與多樣化檢視的例子相同,所用我們複用它的配置。

var app = angular.module('demo', ['ui.router']);

app.config(function($stateProvider, $urlRouterProvider){

    $urlRouterProvider.otherwise('/');

    $stateProvider
    .state('app',{
        url: '/',
        views: {
            'header': {
                templateUrl: '/templates/partials/header.html'
            },
            'content': {
                templateUrl: '/templates/partials/content.html'
            },
            'footer': {
                templateUrl: '/templates/partials/footer.html'
            }
        }
    })

    .state('app.dashboard', {
        url: 'dashboard',
        views: {
            '[email protected]': {
                templateUrl: 'templates/dashboard.html',
                controller: 'DashboardController'
            }
        }

    })

    .state('app.campaigns', {
        url: 'campaigns',
        views: {
            '[email protected]': {
                templateUrl: 'templates/campaigns.html',
                controller: 'CampaignController'
            }
        }

    })

    .state('app.subscribers', {
        url: 'subscribers',
        views: {
            '[email protected]': {
                templateUrl: 'templates/subscribers.html',
                controller: 'SubscriberController'      
            }
        }

    })
    .state('app.subscribers.detail', {
        url: '/:id',
        /*
        templateUrl: 'templates/partials/subscriber-detail.html',
        controller: 'SubscriberDetailController'
        */

        views: {
            '[email protected]': {
                templateUrl: 'templates/partials/subscriber-detail.html',
                controller: 'SubscriberDetailController'        
            }
        }

    });

});

我們在/路由上建立一個預設的狀態app。在app路由上定義預設的內容區塊,頭部區塊和尾部區塊。然後,我們想在這個應用中定義被的路由,只需要在app後使用.的語法,如:app.campaigns。請注意,我們只需要替換內容區塊(ui-view=’content’),除非我們想要改變頭部和尾部。因為這些檢視都是定義在app路由之下的。

狀態名

在上面的程式碼中,最難理解的概念是狀態名中的@語法。狀態名的這個語法可以做如下解釋:

寫一個狀態名,需要回答兩個問題:

  1. 當路由被請求時,我應該拿我的模板去替換那個區塊?更具體的說,狀態名就是ui-view屬性指令的值。下面一個例子展示了ui-view屬性指令和它對應的區塊:

    • ui-view=’content’ = content
    • ui-view=’header’ = header
    • ui-view=’footer’ = footer
  2. 哪裡可以找到ui-view所指向的區塊?

    • ui-view使用的不是直接的templateUrl,而是包含該模板的狀態
    • ui-view和檢視區塊包含在應用程式的殼模板(index.html)中時,因為index.html沒有定義任何狀態,你應該設定為空字串或者不設定。

把這兩個放在一起說,這個如同[email protected]的語法,更具體的說,其實是區塊@狀態名

所以,你需要在index.html頁面上找內容區塊時,你需要這樣寫:[email protected]

  • 這之後的兩個都是在@符後是空白的狀態名,說明他們都是在index.html上的區塊。

如果你要找到subscribers.html上的詳情內容區塊,你需要這樣寫:[email protected]

殼頁面(index.html)

在上面的例子中,index.html並沒有發生改變,只是簡單地定義了各個區塊:頭部,尾部和內容。

Views/Templates

Header (partials/header.html)

Header的更新,是通過ui-sref引用嵌入的狀態。如.campaigns不是呼叫campaigns狀態,而是根據當前狀態,推斷出.campaigns的父狀態。

<div class="ul">
    <li><a ng-href="/">Home</a></li>    
    <li ui-sref-active="active"><a ui-sref=".dashboard">Dashboard</a></li>
    <li ui-sref-active="active"><a ui-sref=".campaigns">Campaigns</a></li>
    <li ui-sref-active="active"><a ui-sref=".subscribers">Subscribers</a></li>
</div>

下面是一個新使用者的模板的例子。

partials/subscribers.html
<h2>Subscribers</h2>

<ul>
    <li ng-repeat="subscriber in subscribers">

        <a ui-sref=".detail({id: subscriber.id})" > {{subscriber.name}}</a>
        {{subscriber.email}}
    </li>
</ul>

<div ui-view="detail"></div>
partials/subscriber-detail.html
{{selected.description}}

結論

掌握這樣的狀態名語法是有些困難的。但是有一個健壯的路由的好處是,允許你封裝view/controller對組成你的使用者介面。我覺得這樣的困難是有價值的。所以,放心使用最後一個例子作為起點,構建一個神奇的,可維護的應用。讓我知道你是否使用UI-Router,或者其他什麼問題,請評論。

譯者注

  • 注1: 2.0最近釋出了,還沒去看有沒有回來
  • 注2: 由於國內網路的不可抗因素,不建議使用谷歌的cdn
  • 注3: Underscore.js 是一個javascript工具庫,提供了一整套函數語言程式設計支援。在angular中使用參考
  • 注4:DRY原則,Don’t Repeat Yourself。不要重複自身,即降調程式碼是去冗餘,同樣的程式碼只寫一次,不同地方呼叫。