Thứ Năm, 21 tháng 7, 2016

UI-Router: Why many developers don’t use AngularJS’s built-in router

AngularJS is an amazing JavaScript framework for building rich client-side applications but the truth is many developers don’t use the built-in router. They instead use the AngularUI project’s UI-Router because it has two important features: multiple views and nested views. This article explains these features and why they are important and shows you a real-world example of using these features together.

Why You Should Use UI-Router

Multiple Views

Most applications can be broken up into regions. At a minimum, applications usually have a header, a main content area, and a footer.
Commonly, applications may have an additional sidebar on the left or right side of the page as shown below.
multiple-views-sketch
In most use cases, all of these regions (views) are shown on the page at the same time. With the built-in AngularJS router, ngRoute, only one view (ng-view) is allowed per page. This limitation causes people to use includes (ng-include) or other workarounds to create a layout or master page for their application. UI-Router supports multiple views and each can have it’s own corresponding Controller so that each of these regions can be encapsulated and reused throughout the application if needed.

Nested Views

The common example of a nested view in applications is a master/detail or, more specifically, a list/detail page. Many applications show a list of items then when you click on an item you see the detail for that item. Taking this example further, you might then click an edit link when viewing the item’s details that takes you to an editable form for the item (see the diagram below to visualize).
master-detail-sketch
This scenario is easily achieved with the built-in AngularJS router, ngRoute, if the list and detail are on separate pages (or views as they are called in AngularJS). However, if you want the list to remain on the page while you show the detail to the right or below the list this becomes more challenging. To be clear, this requirement can be achieved with ngRoute by sharing a single view with two controllers: one for the list and one for the detail and hiding and showing the detail as needed. The result is not ideal because we would like the list and detail to each have their own controller and view with only one responsibility (showing a list or showing item details). By encapsulating these user interface areas in their own view we can have a more composable UI that allows us to bring the pieces together or break them apart as needed to meet requirements. Nested views enable us to not only bring these views together at the same time but also to nest a view inside another view as is done in the list/detail example.

History

When AngularJS was first released ngRoute had a similar feature set as other routers at the time, such as the router included in the Backbone.js library as well as the stand-alone routing libraries History.js and Sammy.js. In summary, they mapped a route or URL to JavaScript code that needed to be run when the URL changes and added entries to the browsers history appropriately so that the back button didn’t break.
Eventually competing JavaScript MV* frameworks like Ember.js and Durandal.js innovated and came out with more robust routers that supported multiple views and nested views and implemented the state machine design pattern internally.
AngularJS responded to this by removing ngRoute out of the core angular.js download in version 1.1.6 (most people just say version 1.2). It is still available for download from the AngularJS site but is no longer in the core.
The AngularJS community responded and the most popular library that emerged was the AngularUI project’s UI-Router.
The AngularJS team which for several months (as a consultant) included Rob Eisenberg, the Durandal.js and Aurelia (nextgen Durandal) creator, has been working on a rewrite of the Router for the future version of AngularJS 2.0 and have stated it will eventually be ported back into a point release of AngularJS version 1.3 which was just recently released.
If you want more details about the history and the pros and cons of various routers the public design document for the AngularJS 2.0 router is available here Note that you’ll need to click the green button labeled “suggesting” and choose “viewing” as shown below to make it legible.
angularjs-router-design-document-1
angularjs-router-design-document-2

Install

To use the UI-Router with version 1.2.x or 1.3.x of AngularJS you can do one of the following to get the javascript source code:

download

download the release or minified versions

bower install

$ bower install angular-ui-router

npm install

 $ npm install angular-ui-router

Include script tag

Include angular-ui-router.js or angular-ui-router.min.js in your index.html, after the Angular script tag (see below)
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.1.5/angular.min.js"></script>
<script src="js/angular-ui-router.min.js"></script>

Include Dependency

Include the ‘ui.router’ dependency in your main AngularJS module.
var myApp = angular.module('myApp', ['ui.router']);
Notice the module name is ui.router not ui-router (using the hyphen is a common mistake).

Router as State Machine

UI-Router introduces a state machine design pattern abstraction on top of a traditional router. Routes are referred to as states and the URL becomes simply a property of the state.
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'
        })
 
}]);
When you create links you can simply refer to the state name inside a ui-sref directive instead of using the URL.
So this:
 <a ui-sref="home">Home</a>
Renders:
 <a href="#/">Home</a>
In the example above ui-sref can be understood as follows: ui is the directive prefix for all AngularUI project directives and sref is a play on the traditionalhref of an HTML anchor tag and stands for state ref.

In the Controller

Here is an example of how to do a redirect in a Controller to a state.
$scope.redirectToAbout = function(){
    $state.go('about'); 
}

$routeProvider becomes $stateProvider

The AngularJS service injected to provide routing which is $routeProviderwith ngRoute becomes $stateProvider when using the UI-Router.

$urlRouterProvider

The $urlRouterProvider is there for two main purposes. To establish a default route for URLs that don’t have a specific route.
app.config(['$stateProvider', '$urlRouterProvider', function($stateProvider, $urlRouterProvider) {
    $urlRouterProvider.otherwise('/');
    ...
}]);
To allow developers to listen for a window location change and redirect to a route that has a state defined.
app.config(['$stateProvider', '$urlRouterProvider', function($stateProvider, $urlRouterProvider) {
    $urlRouterProvider
        .when('/legacy-route', {
            redirectTo: '/'
        });
}]);
In summary, $urlRouterProvider lets you handle cases where the state machine abstraction of the $stateProvider doesn’t make sense.
So now that you’ve got the basics of the UI-Router lets get back to those features that make it better than ngRoute.

UI-Router in Action

We will look at an example of nested views first then we’ll look an example of multiple views. After we get our heads around each we’ll put the two features together to show how they can be used in a real-world application.

Nested Views with UI-Router

Here is a list/detail example of nested views using UI-Router. The example displays a list of TV shows.
angularjs-ui-router-nested-views-1
If you click on a row you’ll see a detailed description for the selected show.
angularjs-ui-router-nested-views-2

Application Shell (index.html)

AngularJS applications are single-page application where views are inserted into a shell page. Here is our shell page 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>
The <div id="content" ui-view></div> will have the first level or parent view (in our example shows.html) placed inside of it by the UI-Router.

Home page view (templates/shows.html)

The list view is 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>
As mentioned before the index.html page has a ui-view attribute directive into which this view (shows.html) is rendered when the corresponding route is requested.
Notice how there is another ui-view nested inside this shows.html view. Thisui-view is where a child view of the parent ‘shows’ view is rendered. In this example shows-detail.html is the child view.

Shows detail view (templates/shows-detail.html)

The detail view is shows-detail.html
<h3>{{selectedShow.name}}</h3>
<p>
    {{selectedShow.description}}
</p>
</code>

Controllers

And there is a corresponding controller for each view.

ShowsController

The ShowsController loads an in-memory array of shows from theShowsService.
app.controller('ShowsController', ['$scope','ShowsService', function($scope, ShowsService) {
    $scope.shows = ShowsService.list();
 }]);

ShowsDetailController

The ShowsDetailController looks up the show by id using theShowsService and sets it as the selectedShow on the $scope.
app.controller('ShowsDetailController', ['$scope','$stateParams', 'ShowsService', function($scope, $stateParams, ShowsService) {
        $scope.selectedShow = ShowsService.find($stateParams.id);
 }]);

Configuration

We need to configure the UI-Router using the $stateProvider.
When we define a state as 'parentstatename.childstatename' the convention of simply adding the period when defining the state name tells UI-Router that the child state is nested under the parent state.
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'
        });
}]);
What is really great about nested views is that the list controller just has implementation details regarding the list and the detail controller is only concerned with showing details.
To show how decoupled these are we only need to change the routing configuration to not nest the details and we have two separate virtual pages (one for list and one for details). More specifically, we’ll change the state name'shows.detail' to 'detail'.
$stateProvider
    .state('shows', {
        url:'/shows',
        templateUrl: 'templates/shows.html',
        controller: 'ShowsController'
    })
    .state('detail', {
        url: '/detail/:id',
        templateUrl: 'templates/shows-detail.html',
        controller: 'ShowsDetailController'
    });
    ...
And change the link to the state from <a ui-sref="shows.detail({id: show.id})">{{show.name}}</a> to <a ui-sref="detail({id: show.id})">{{show.name}}</a>
Now our example will display the views separately as if they are two separate pages.

Service

The ShowsService is our data access layer in this example and just keeps an in-memory array and uses underscore.js to easily work with the collection.
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});
        }
    }
 });

Multiple Views with UI-Router

Below is an example of several regions on a page including a header, content, and footer being managed on a page with UI-Router using multiple views.
angularjs-ui-router-multiple-views-1
There is some primary navigation and various content is filled into the regions depending on the virtual page the user navigates to in the application.
angularjs-ui-router-multiple-views-2

Application Shell (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>
Notice the ui-view attribute directives have names assigned to them: header, content, and footer. These names are referenced when we configure the router to say which view/template and controller to use for each region of the page.

Templates/Views

The templates are straight-forward to keep the example simple. Header.html has some navigation that uses the ui-sref directive to navigate to various application states/routes.

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>
The dashboard and campaigns view templates are used to replace the default content in content.html when the corresponding routes are requested.

Configuration

As in the previous example we use the $stateProvider to configure the states (routes).
The key takeaway below is to notice that instead of having one templateUrland controller per URL you instead have a collection of views each with its own templateUrl and controller.
So this:
.state('home',{
        url: '/',
        templateUrl: '/templates/partials/header.html',
        controller: 'HomeController'
    })
Becomes:
.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'
            }
        }
    })
Here is the full code for the example (note: controllers are not needed for the example I just put them in above for completeness).
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'
            }
        }
 
    })
});
The other thing to notice is that if I don’t fill in a region with a view then it will not be shown when the user navigates to that route. This is not ideal and makes us repeat ourselves quite a bit so in the next section we will look at how to remove this repetition by using the nested views we saw earlier.

Multiple Views and Nested View with UI-Router

Now that we understand each of these powerful features lets bring them together to give you a better idea of how you might want to set-up a real-world application.

Configuration

We’ll start with the configuration since the view templates are the same as in the multiple views example.
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: {
            'content@': {
                templateUrl: 'templates/dashboard.html',
                controller: 'DashboardController'
            }
        }
 
    })
 
    .state('app.campaigns', {
        url: 'campaigns',
        views: {
            'content@': {
                templateUrl: 'templates/campaigns.html',
                controller: 'CampaignController'
            }
        }
 
    })
 
    .state('app.subscribers', {
        url: 'subscribers',
        views: {
            'content@': {
                templateUrl: 'templates/subscribers.html',
                controller: 'SubscriberController'      
            }
        }
 
    })
    .state('app.subscribers.detail', {
        url: '/:id',
        /*
        templateUrl: 'templates/partials/subscriber-detail.html',
        controller: 'SubscriberDetailController'
        */
 
        views: {
            'detail@app.subscribers': {
                templateUrl: 'templates/partials/subscriber-detail.html',
                controller: 'SubscriberDetailController'        
            }
        }
 
    });
 
});
We create a default state (route) at / named app. In this app state we can define the default content for our header and footer regions. Then if we make every other page in the application a nested view under the app by using the dot syntax for example app.campaigns. Notice we only need to replace thecontent region (ui-view='content') unless we need to change the header or footer because these views are nested under the default app state.

State names

The most difficult concept to grasp in the code above is the state
name syntax with the @ in the middle. The syntax for the state name can be explained as follows:
Two questions need to be answered when writing a state-name:
  1. What is the name of the view (region) I want to replace with my template when this route is requested: view-name? More specifically, this is the value of the ui-view attribute directives. Here are some examples of ui-view directives and their corresponding view-name:
    • ui-view='content' = content
    • ui-view='header' = header
    • ui-view='footer' = footer
  2. Where can I find the ui-view with that view-name?
    • this location is not expressed as a templateUrl but instead as the state that contains that template
    • when the template ui-view with the view-name is in the application shell template (index.html) because index.html is not defined in any state you should leave name the state as empty string (”) or nothing
Putting this together, the syntax is question1@question2 or more specificallyview-name@state-name.
So if you need to find the content view-name in index.html it would be:
'content@'
  • See the second bullet under #2 about leaving the state blank when the view-name is in the shell page (index.html).
If you need to find the content view-name in subscribers.html it would be:
'detail@app.subscribers'

Application Shell (index.html)

The shell page doesn’t change from the previous example and simply defines named views for each region of the page: header, footer, and content.

Views/Templates

Header (partials/header.html)

The header has updated ui-sref references to the nested states for example.campaigns with the period not campaigns. Note the parent state is inferred when we say .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>
Below are the other new subscriber templates in the example.

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

Conclusion

The syntax for the state names is difficult to grasp but the benefits of having a robust router which allows you to compose your user interface from well encapsulated view/controller pairs is worth it in my mind. So please feel free to take the last example as a starting point for the shell of your application and run with it to build an amazing and much more maintainable application. Let me know if you are or are not already using the UI-Router and what other questions you have about it.

Code

The code examples referenced in this post are available here on Github.
https://github.com/craigmckeachie/ui-router-examples
http://www.funnyant.com/angularjs-ui-router/