【AngularJS x TypeScript デザインパターン】 Controller と Routing 篇 - AngularJS + TypeScript #4

この記事は RECRUIT MARKETING PARTNERS Advent Calendar 2015 の投稿記事です。

前置き

2015年11月30日にリリースした 英語サプリ Web 版は完全 SPA ( Single Page Application ) であり、AngularJS と TypeScript を主要テクノロジーにおいた作りとなっています。このプロダクトでは実装開始からリリースの前日までの間に幾度となくアーキテクチャの見直しを図ってきました。当エントリでは、そんな試行錯誤の中で生まれた ( 開発に取り入れられた ) 数種類のデザインパターンについてご紹介します。

そんなわけで、今回は Controller ( コントローラ ) と Routing ( ルーティング ) についてです。

関連エントリ

前提知識

  • AngularJS 1.4.7 ~
  • AltJS として TypeScript を使用
    • バージョンは 1.4x を採用1)リリース直前まで最新バージョンを追いかけたかったのですが、途中からすっかり忘れてしましました。頃合いを見計らってアップデート対応をする予定です。
  • 原則として全てクラス化する
  • 各クラスファイルは外部モジュールとして分割する2)ライブラリを作るわけではないので、わざわざ内部モジュール化して Require するメリットは少ないと判断しました。
  • ビューテンプレートは全て Jade で記述し、外部ファイル化する

アプリケーション全体の基本的な構成 ( ストラクチャー )

今回のアプリケーションにおけるディレクトリやファイル構成は以下になります。

.
├── README.md
├── app
│   ├── icons/
│   ├── images/
│   ├── scripts/
│   │   ├── Main.ts        # エンドポイント
│   │   ├── constants/     # URLや設定値といったアプリケーション全体で使われる定数など
│   │   ├── controllers/   # コントローラ
│   │   ├── declares/      # 自前で作成した型定義ファイル
│   │   ├── directives/    # カスタムディレクティブ
│   │   ├── enums/         # 列挙型
│   │   ├── filters/       # カスタムフィルター
│   │   ├── models/        # モデルクラス
│   │   ├── reference.ts   # 全てのリファレンスタグをここに記述
│   │   ├── services/      # カスタムサービス
│   │   └── utils/         # Array や String などの Prototype 拡張
│   ├── styles/
│   ├── templates/
│   │   ├── index.jade
│   │   └── pages/
│   └── vendors/
├── bower.json
├── bower_components/
├── dtsm.json
├── gulp/
│   ├── config.js
│   └── tasks/
├── gulpfile.js
├── node_modules/
└── typings/

Main.tsをアプリケーション全体のエンドポイントとします。ここで全てのコントローラ、カスタムディレクティブ、カスタムサービス、カスタムフィルターを appモジュールに渡して実行したり、ルーティングの定義をしています。この構成は比較的初期の頃から固まっており、現在 ( 2015年12月 ) まで続いております。

#1. ngRoute を使ったパターン

angular-route (ngRoute) を使ってルーティングする時のパターンです。全てはここから始まりました。

ルーティング
/// <reference path="./reference" />
namespace app {
    'use strict';
    var main = angular.module('app', ['ngRoute']);
    // Router setting
    main.config(['$routeProvider', ($routeProvider) => {
        $routeProvider
            .when('/', {
                templateUrl  : 'index.html',
                controller   : 'topController',
                controllerAs : 'c'
            })
            .when('/chat', {
                templateUrl  : 'pages/chats/index.html'
                controller   : 'chatController',
                controllerAs : 'c'
            })
            .when('/forms', {
                templateUrl  : 'pages/forms/index.html'
                controller   : 'formController',
                controllerAs : 'c'
            })
            .otherwise({
                redirectTo: '/'
            });
    }]);
    // Controller
    main.controller('topController' , controllers.Top);
    main.controller('chatController', controllers.Chat);
    main.controller('formController', controllers.Form);
}
コントローラ
namespace app.controllers {
    'use strict';
    export class Chat {
        public static $inject = ['$location', '$http'];
        public posts;
        public users;
        constructor(private $location: ng.ILocationService,
                    private $http: ng.IHttpService,) {
            // 初期化処理
            // Posts データと Users データをAPIから取得するなど
        }
        /**
         * 別ページへ遷移する
         */
        public nextHandler() {
            this.$location.path('/別ページ');
        }
        /**
         * ユーザーIDから該当するユーザーの画像URLを返す
         * @param userId
         * @return 画像URL
         */
        public getUserImageUrl(userId: number): string {
            var url;
            // ...
            return url;
        }
        /**
         * ユーザーIDから該当するユーザーの名前を返す
         * @param userId
         * @return 画像URL
         */
        public getUserImageUrl(userId: number): string {
            var name;
            // ...
            return name;
        }
    }
}
ビューテンプレート
.container
    h1.page-title
        |Chat&nbsp;
        small Ready-to-use client-side application
    section.widget
        h2.widget__title Chat room
        .widget__body
            ol.chat-messages
                li.chat-message(ng-repeat="post in ::c.posts")
                    img(src="{{::c.getUserImageUrl(post.userId)}}")
                    span{{::c.getUserName(post.userId)}}
                    p {{::post.body}}
    //- ...
    button.btn.btn-default(ng-click="c.nextHandler()") Next >>

$routeProviderを使って URL に対応したコントローラとビューテンプレートを指定します。そしてコントローラとビューテンプレートの関連付けは ControllerAsを使って実現しており、コントローラ内に定義したメソッドは ControllerAs名.メソッド名()としてビューテンプレートから呼び出すことが出来ます。

比較的画面数が少なく、ページングもシンプルなアプリケーションであればこれでも充分にカバーできることでしょう。

ControllerAs を使う時の注意点としてprivate メソッドもビューから呼び出せてしまうというのがあります。「だったらアクセス修飾子なんか付けないで全部 public にしてしまえば良いんじゃね?」という声が聞こえてきそうですが、メソッド数が増えてくるにつれてどれがビューから呼ばれるものなのか、コントローラ内でのみ使われているものなのかの判断が付けられなくなるので、僕はキッチリ public と private を使い分けるようにしています。

#2. UIRouter を使った基本パターン

少し規模の大きなアプリケーションになってくると途端に ngRoute では苦しくなってきます。そんな時は UIRouter に乗り換えることで格段に実装コストを抑えることが出来、同時に設計のパターンも広がります。

UIRouter の詳しい使い方については以下の公式ドキュメントを参照するのが一番です。

ルーティング
/// <reference path="./reference" />
namespace app {
    'use strict';
    var main = angular.module('app', ['ui.router']);
    // Router setting
    main.config(['$stateProvider', '$urlRouterProvider', ($stateProvider, $urlRouterProvider) => {
        $urlRouterProvider.otherwise('/');
        $stateProvider
        .state('top', {
            url: '/',
            templateUrl: 'index.html',
            controller: 'topController',
            controllerAs: 'c'
        })
        .state('chat', {
            url: '/chat',
            templateUrl: 'pages/chats/index.html',
            controller: 'chatController',
            controllerAs: 'c'
        })
        .state('forms', {
            abstract: true,
            url: '/forms',
            templateUrl: 'pages/forms/index.html',
            controller: 'formController',
            controllerAs: 'c'
        })
            .state('forms.yourDetails', {
                url: '/your-details',
                templateUrl: 'pages/forms/your-details.html',
                controller: 'formYourDetailsController',
                controllerAs: 'c'
            });
    }]);
    // Controller
    main.controller('topController'            , controllers.Top);
    main.controller('chatController'           , app.controllers.Chat);
    main.controller('formController'           , app.controllers.Form);
    main.controller('formYourDetailsController', app.controllers.Form.YourDetails);
}
コントローラ
namespace app.controllers {
    'use strict';
    export class Form {
        public static $inject = ['$scope', '$state'];
        public formWizardItems;
        constructor($scope,
                    $state: angular.ui.IStateService) {
            // 初期化処理
            // APIから必要なデータを取得するなど
        }
    }
    export namespace Form {
        export class YourDetails {
            public static $inject = ['$scope', '$state'];
            constructor($scope,
                        $state: angular.ui.IStateService) {
                this.forms.wizardProgress = 1;
            }
        }
    }
}
ビューテンプレート
h1.page-title
    |Form Wizard&nbsp;
    small Form validation
.row
    .col-lg-7
        widget(
            title="Wizard"
            title-small="tunable widget"
            title-icon-name="windows"
            description="An example of complete wizard form in widget.")
            form-wizard-navigation(wizard-items="form.formWizardItems" wizard-progress="form.forms.wizardProgress")
            .ui-view
.panel
    form.form-horizontal
        fieldset
            .form-group
                label.control-label.col-md-3(for="uername") Username
                .col-md-7
                    input.form-control#username(name="uername")
                    span.help-block Username can contain any letters or numbers, without spaces
            .form-group
                label.control-label.col-md-3(for="email") Email
                .col-md-7
                    input.form-control#email(type="email" name="email")
                    span.help-block Please provide your E-mail
            .form-group
                label.control-label.col-md-3(for="address") Address
                .col-md-7
                    input.form-control#address(name="address")
                    span.help-block Prease provie your address
    form-wizard-pager(next-state="forms.shipping")

url ではなく state でルーティングを管理する

ngRoute では アプリケーション内のルーティングをurlの値で管理していましたが、UIRouter ではルーティングをstateという値で階層的に管理するようになり、URL はブラウザの URL 欄に表示するだけの役割に徹するようになります。これにより URL の変更要件が発生しても url の値を変更するだけでルーティング自体への影響は発生しなくなるというメリットがあります。また、プログラム上では画面遷移するけれど URL 自体は変更したくない or State に定義したURL値のうち都合の良いものだけをブラウザに表示させるいったことも、url プロパティを指定しないなどすることで可能となります。

抽象化 ( abstract ) した state を活用することでビューのネストが可能となる

上のコードをよく見るとforms state にabstractというプロパティが設定されています。このプロパティを trueにするとその state の段階では画面が表示されず、その子要素の state が指定 ( 表示 ) された段階で画面の表示とコントローラの処理が行われます。

abstract: true の state が表示されることはない
abstract: true の state が表示されることはない

これの利点は、抽象化した state の子 state は必ず親 state の処理を経由してから表示・実行されるところにあります。つまり子 state が複数あった場合は、API 疎通やポストするデータの保持といった共通化出来そうな処理を親 state に持たせて、各子 state がそれを必要に応じて参照するなどといった事が可能となるわけです。

抽象化された state は自分を経由した子 state が表示されるタイミングで表示される
抽象化された state は自分を経由した子 state が表示されるタイミングで表示される

#3. Resolve でコントローラ実行時に必要なオブジェクトを予め生成しておくパターン

UIRouterのstate()にはresolveという Promise をラッピングしたオプションが用意されています。これを使うとそのコントローラに DI したいオブジェクトをコントローラ実行前に予め全て生成することが出来ます。つまり、コンストラクタ内で API を叩いてその結果が返ってきてから画面を描画するという厄介な作業から解放されるというわけです。全てのオブジェクトが生成完了するまでコントローラは確実に実行を待ってくれるため、ややこしいエラーハンドリングやコールバック地獄に悩まされることもありません。考えた人は天才ですね。

以下の例は、チャット画面を開くのに必要なポストデータとユーザーデータをそれぞれ API から取得し、両方のオブジェクトが生成完了してからコントローラを実行するというものです。

ルーティング
namespace app {
    'use strict';
    var main = angular.module('app', ['ui.router']);
    // Router Setting
    main.config(['$stateProvider', '$urlRouterProvider', ($stateProvider, $urlRouterProvider) => {
        $urlRouterProvider.otherwise('/');
        $stateProvider
        .state('top', {
            url: '/',
            templateUrl: 'pages/tops/index.html'
        })
        .state('chat', {
            url: '/chat',
            templateUrl: 'pages/chats/index.html',
            controller: 'chatController',
            controllerAs: 'c',
            resolve: {
                posts: ['APIEndpoint', (APIEndpoint: app.services.APIEndpoint) => {
                    return APIEndpoint.request(app.services.APIEndpointSet.posts);
                }],
                users: ['APIEndpoint', (APIEndpoint: app.services.APIEndpoint) => {
                    return APIEndpoint.request(app.services.APIEndpointSet.users);
                }]
            }
        })
        // 中略…
    }]);
    // Controller
    main.controller('topController'            , controllers.Top);
    main.controller('chatController'           , app.controllers.Chat);
    main.controller('formController'           , app.controllers.Form);
    main.controller('formYourDetailsController', app.controllers.Form.YourDetails);
}

postusersの二つのオブジェクトを API から取得したデータから生成しているというものです。それぞれ Ajax で非同期にAPIを叩いているので、通常であれば両方の処理がいつ終わるのかを自前でハンドリングしなくてはなりませんが、それも全て UIRouter がお世話してくれます。

そしてコントローラにはその〈二つのオブジェクトを DI する〉と定義します。こうすることで確実に必要なデータが揃ったうえでコンストラクタを実行することが出来るというわけです。

コントローラ
namespace app.controllers {
    'use strict';
    export class Chat {
        public static $inject = ['$scope', '$state', 'posts', 'users'];
        public currentUser: models.User;
        constructor($scope,
                    $state: angular.ui.IStateService,
                    public posts: models.Posts,
                    private users: models.Users) {
            var currentUserId = 1 + Math.floor(Math.random() * (this.users.users.length + 1));
            this.currentUser = this.users.users[currentUserId];
            this.posts.posts.shuffle();
        }
    }
}

#4. state()のオプションをコントローラ内に定義するパターン

$stateProvider.state();の第二引数に渡すオプションですが、次のように書くことでコントローラ内に定義することが出来ます。

コントローラ
namespace app.controllers {
    'use strict';
    export class Chat {
        public static get state() {
            return {
                url: '/chat',
                templateUrl: 'pages/chats/index.html',
                controller: 'chatController',
                controllerAs: 'c',
                resolve: {
                    posts: ['APIEndpoint', (APIEndpoint: app.services.APIEndpoint) => {
                        return APIEndpoint.request(app.services.APIEndpointSet.posts);
                    }],
                    users: ['APIEndpoint', (APIEndpoint: app.services.APIEndpoint) => {
                        return APIEndpoint.request(app.services.APIEndpointSet.users);
                    }]
                }
            };
        }
        public static $inject = ['$scope', '$state', 'posts', 'users'];
        constructor($scope,
                    $state: angular.ui.IStateService,
                    public posts: models.Posts,
                    private users: models.Users) {
            // 以下略…
        }
    }
}

コントローラに関する情報なのだから Main.ts 側に定義せずコントローラ内に書いてしまえというものです。静的関数として定義したので Main.ts からは以下のように呼び出すことが出来ます。

ルーティング
namespace app {
    'use strict';
    var main = angular.module('app', ['ui.router']);
    // Router Setting
    main.config(['$stateProvider', '$urlRouterProvider', ($stateProvider, $urlRouterProvider) => {
        $urlRouterProvider.otherwise('/');
        $stateProvider
        .state('top',  controllers.Top.state)
        .state('chat', controllers.Chat.state)
        // 中略…
    }]);
    // 中略…
}

Main.ts のコード量がグッと減りました。当然ですが画面数が増えてくるとそれだけ Main.ts に記述する$stateProvider.state();の数も多くなってきます。そうすると先ほどの記述ではだいぶ厳しくなってきますが、このように書くことで相当見通しが良くなります。しかもコントローラに関する定義を丸ごとコントローラ自身の中に押しこむことが出来たので、より疎結合な関係になりました。

#5. state()オプションのcontrollerにクラスそのものを返すパターン

これまではstate()オプションの controllerプロパティにコントローラ名を文字列で指定していましたが、ここにコントローラの実態として直接関数を書くことも出来ます。そして TypeScript の特性を活かして、以下のようにクラスそのものを渡すことが出来ます。

コントローラ
public static get state() {
    return {
        url: '/chat',
        templateUrl: 'pages/chats/index.html',
        controller: Chat,
        controllerAs: 'c',
        resolve: {
            posts: ['APIEndpoint', (APIEndpoint: app.services.APIEndpoint) => {
                return APIEndpoint.request(app.services.APIEndpointSet.posts);
            }],
            users: ['APIEndpoint', (APIEndpoint: app.services.APIEndpoint) => {
                return APIEndpoint.request(app.services.APIEndpointSet.users);
           }]
        }
    };
}

すると Main.ts 側でcontroller()を実行する必要が無くなります。

ルーティング
namespace app {
    'use strict';
    var main = angular.module('app', ['ui.router']);
    // Router Setting
    main.config(['$stateProvider', '$urlRouterProvider', ($stateProvider, $urlRouterProvider) => {
        $urlRouterProvider.otherwise('/');
        $stateProvider
        .state('top',  controllers.Top.state)
        .state('chat', controllers.Chat.state)
        // 中略…
    }]);
    // Controller
    // main.controller('topController'            , controllers.Top);
    // main.controller('chatController'           , app.controllers.Chat);
    // main.controller('formController'           , app.controllers.Form);
    // main.controller('formYourDetailsController', app.controllers.Form.YourDetails);
}

Main.ts のコード量が更に削減されました。これでもちゃんと動作します。最終的にこのデザインパターンに落ち着きました。

締め

全5パターンを紹介しましたが、いかがだったでしょうか。#1#2はよくある正攻法ですが、#4#5は少々珍しかったのではないかと思います。当エントリが用途に応じて手段をうまく使い分けるための参考になれば幸いです。

脚注

脚注
1 リリース直前まで最新バージョンを追いかけたかったのですが、途中からすっかり忘れてしましました。頃合いを見計らってアップデート対応をする予定です。
2 ライブラリを作るわけではないので、わざわざ内部モジュール化して Require するメリットは少ないと判断しました。