【AngularJS x TypeScript デザインパターン】 Custom Directive 篇 – AngularJS + TypeScript #3

wakamsha
75

前置き

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

そんなわけで、今回は Custom Directive ( カスタムディレクティブ ) についてです。

関連エントリ

前提知識

  • 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月 ) まで続いております。

#0. クラス化しないパターン

おさらいということで、TypeScript でクラス化せずにカスタムディレクティブを定義する書き方を確認しておきます。とは言っても Pure JavaScript との違いは殆どありません。

app.directive('dropdownMenu', ['$document', ($socument) => {
    return {
        restrict: 'E',
        template: '/components/dropdown-menu.html',
        controller: ['$scope', ($scope) => {
            // 必要に応じて処理を定義…
        }],
        link: (scope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes) => {
            var close = ($event) => {
                scope.$apply(() => {
                    scope.isActive = false;
                    $document.unbind('click', close);
                });
            };

            scope.isActive = false;
            scope.clickButtonHandler = ($event) => {
                $event.stopPropagation();
                scope.isActive = !scope.isActive;
            };

            scope.$watch('isActive', (newValue) => {
                newValue && $document.bind('click', close);
            });
        }
    }
}]);
ビューテンプレート
.dropdown
    button.btn.btn-link.navbar-btn(ng-click="clickButtonHandler($event)")
        i.icon-gear
    ng-transclude(ng-show="isActive")

カスタムディレクティブ作成に必要な項目を連想配列に入れて丸ごと返すだけのシンプルなパターンです。素直といえば素直ですが、カスタムディレクティブの定義が app.directive() メソッド内に全て直書きされているためにファイルの分割などが難しく、ディレクティブ数が増えてくると途端に可読性が低下してしまいます。ごく小規模のアプリケーションであればこれでもまかなえるでしょうが、少しでも規模のあるモノを作るのであれば、迷わずクラス化することをお勧めします。

#1. クラス化しつつ DI 出来るようにするパターン

namespace app.directives {
    'use strict';

    export class DropdownMenu implements ng.IDirective {
        public restrict: string;
        public replace: boolean;
        public templateUrl: string;
        public transclude: boolean;
        public scope;
        public controller;
        public link: Function;

        /**
         * constructor
         *
         * @param $document 画面に対するキーイベント取得に必要
         */
        constructor($document) {
            this.restrict = 'E';
            this.replace = true;
            this.transclude = true;
            this.scope = true;
            this.controller = ['$scope', ($scope) => {
                // 必要に応じて処理を定義…
            }],
            this.link = (scope) => {
                var close = ($event) => {
                    scope.$apply(() => {
                        scope.isActive = false;
                        $document.unbind('click', close);
                    });
                };

                scope.isActive = false;
                scope.clickButtonHandler = ($event) => {
                    $event.stopPropagation();
                    scope.isActive = !scope.isActive;
                };

                scope.$watch('isActive', (newValue) => {
                    newValue && $document.bind('click', close);
                });
            };
            this.templateUrl = '/components/dropdown-menu.html';
        }

        /**
         * instance生成
         *
         * @returns {function(): DropdownMenu}
         * @constructor
         */
        public static Factory(): ng.IDirectiveFactory {
            var directive = ($document) => {
                return new DropdownMenu($document);
            };
            directive.$inject = ['$document'];
            return directive;
        }
    }
}

カスタムディレクティブ作成に必要な項目を全てクラスのメンバー変数として定義し、コンストラクタ内で実装します。private hoge() {}といったクラスのメンバーメソッドを定義することも出来ますが、あまりメリットは無いので controllerlink の中で定義してしまうのが良いでしょう。

DI する方法ですが、当ディレクティブのインスタンスを生成する静的メソッド ( Factory() ) の中で $inject に配列形式でアノテーションを渡すことで実現されます。渡された DI 要素はコンストラクタ時の引数として受け取ることができ、以降は普通に link() 内で使うことが出来ます。作成したカスタムディレクティブの実行方法は以下のとおりです。

namespace app {
    'use strict';

    var app = angular.module('app', []);
    
    app.directive('dropdownMenu', app.directives.DropdownMenu.Factory());
}

Factory() を呼び出すことでディレクティブに必要なモジュールが DI され、インスタンスが生成されてリターンされます。後はビューテンプレートから dropdown-menu とハイフン区切りで普通に利用することが出来ます。

なお、カスタムサービスやカスタムフィルターも link や controller など特別なメンバーが無いだけで、基本的にはこのパターンで作成することが出来ます

#2. 親子関係にあるディレクティブのパターン

例えばリスト要素のようなカスタムディレクティブを作成する場合、リストアイテムとなる要素にそれなりの機能を持たせることもあるでしょう。そんな時は子要素として別ディレクティブに分離するのがベターかと思います。しかし、リストからアイテムを選択した時に実行する処理 ( 選択したアイテムを活性化させてそれ以外を非活性にする処理とか ) などは親要素にひとつあれば良いことになります。

ここではそのような場合における子ディレクティブを親ディレクティブに依存させるというパターンを紹介します。

親ディレクティブ
module app.directives {
    'use strict';

    export class MemberList implements ng.IDirective {
        public restrict: string;
        public templateUrl: string;
        public controller: Object;
        public scope: Object;
        public replace: boolean;

        /**
         * constructor
         * */
        constructor() {
            this.replace = true;
            this.restrict = 'E';
            this.templateUrl = 'components/member-list.html';
            this.scope = {
                'items'         : '=',
                'selectedIndex' : '=',
                'isDisabled'    : '=',
                'select'        : '&'
            };
            this.controller = ['$scope', function($scope) {
                this.chooseItem = (index) => {
                    if ($scope.isDisabled) return;
                    $scope.selectedIndex = index;
                    angular.isFunction($scope.select) && $scope.select($scope);
                };
            }];
        }

        public static Factory():ng.IDirectiveFactory {
            var directive = () => {
                return new MemberList();
            };
            directive.$inject = [];
            return directive;
        }
    }
}
ul.member-list(ng-class="{'member-list--disabled': isDisabled}")
    li.member-list__item(ng-repeat="item in items" item-index="$index" item="item")

MemberList は items というプロパティを持ち、ここに渡された値を ngRepeat でイテレーションすることで MemberListItem の一覧を生成します ( member-list.jade 1行目 )。コントローラにはchooseItem()というメソッドが定義されており、渡された index を scopeプロパティのselectedIndexに代入して scope プロパティの select に指定されたメソッドを実行するという実装となっています。

子ディレクティブ
module egs.directives {

    'use strict';

    export class MemberListItem implements ng.IDirective {
        public restrict: string;
        public templateUrl: string;
        public link: Function;
        public scope: Object;
        public require: string;
        public replace: boolean;

        constructor() {
            this.restrict = 'E';
            this.replace = true;
            this.templateUrl = 'components/member-list-item.html';
            this.require = '^memberList';
            this.scope = {
                'item'      : '=',
                'itemIndex' : '='
            };
            this.link = (scope, elem, attr, memberListController) => {
                scope.onClick = () => {
                    memberListController.chooseItem(scope.itemIndex);
                };
            };
        }

        public static Factory():ng.IDirectiveFactory {
            var directive = () => {
                return new AnswerListItem();
            };
            directive.$inject = [];
            return directive;
        }
    }
}
li.member-list__item
    a(ng-click="onClick()") {{::item}}

親ディレクティブにコントローラが定義されていると、子ディレクティブは requireプロパティを通じて親ディレクティブのコントローラを参照することが出来るようになります。このコントローラはlink()の第四引数として受け取ることで使うことが出来ます ( 引数なので命名は何でも OK です )。そして子ディレクティブである MemberListItem にはクリックイベントが定義されており、ユーザーが任意のアイテムをクリックすると scope プロパティであるitemIndexをパラメータにして memberListController に定義された chooseItem()を呼び出します。

#3. クラス化しつつメンバー変数を定義しないで丸ごとリターンするパターン

#1#2で紹介したパターンはいかにもクラスらしい構成で可読性が高いのがメリットですが、生成されるインスタンスの数だけメンバー変数分のメモリが消費されることになります。インスタンスはビューテンプレート側から呼び出された数だけ生成されるので、たとえ個々のメモリ消費量は少なくともアプリの規模によっては結構なモノになりかねません3)リストの子要素なんかはループ処理で大量に生成されることも珍しくないので、塵も積もればなんとやら。

そこで#0で紹介したパターンを一部取り入れた書き方をしてみるとします。

namespace app.directives {

    'use strict';
    
    export class DropDownMenu implements angular.IDirective {

        public static link(scope, element, $document) {
            var close = ($event) => {
                scope.$apply(() => {
                    scope.isActive = false;
                    $document.unbind('click', close);
                });
            };

            scope.isActive = false;
            scope.clickButtonHandler = ($event) => {
                $event.stopPropagation();
                scope.isActive = !scope.isActive;
            };

            scope.$watch('isActive', (newValue) => {
                newValue && $document.bind('click', close);
            });
        }

        public static Factory = ['$document', ($document) => {
            return {
                restrict: 'E',
                replace: true,
                transclude: true,
                templateUrl: '/components/dropdown-menu.html',
                link: (scope, element, attrs) => DropdownMenu.link(scope, element, $document),
                scope: true
            }
        }];
    }
}

link() を直書きせずにクラスの静的関数として定義したものを渡すようにしました。DI したい時は、静的変数である Factory にアノテーションする形で渡し、それを DropdownMenu.link()のパラメータとして渡すことで使うことが出来るようになります。

単にコードの見た目を変えただけで根本的には何も違いはないのですが、個人的にはクラスっぽい見た目が保たれているのでアリかなと思います。メンバー変数も無いので多少はメモリの節約にもなります。

エンドポイント ( Main.ts ) から以下のようにして実行すれば、これまで通りビューテンプレートから使うことが出来ます。

app.directive('dropdownMenu', app.directives.DropdownMenu.Factory);

Factoryが関数から変数扱いに変わったので、()を付けない書き方になりました。

ディレクティブ内にコントローラを定義する場合

少々強引ですが DropdownMenu クラス自体をコントローラと見立ててしまいます。

namespace app.directives {
    'use strict';

    export class DropdownMenu implements ng.IDirective {
        
        constructor(public $scope) {
            // 必要に応じて処理を追加
        }

        public hoge() {
            console.log('hoge');
        }

        public static link(scope, element, dropdownMenu, $document) {
            var close = ($event) => {
                scope.$apply(() => {
                    scope.isActive = false;
                    $document.unbind('click', close);
                });
            };

            scope.isActive = false;
            scope.clickButtonHandler = ($event) => {
                $event.stopPropagation();
                scope.isActive = !scope.isActive;
                dropdownMenu.hoge();
            };

            scope.$watch('isActive', (newValue) => {
                newValue && $document.bind('click', close);
            });
        }

        public static Factory = ['$document', ($document) => {
            return {
                restrict: 'E',
                replace: true,
                transclude: true,
                templateUrl: '/components/dropdown-menu.html',
                controller: ['$scope', DropdownMenu],
                controllerAs: 'c',
                require: 'dropdownMenu',
                link: (scope, element, attrs, dropdownMenu) => DropdownMenu.link(scope, element, dropdownMenu, $document),
                scope: true
            }
        }];
    }
}

return オブジェクト内の controllerプロパティに自分自身のクラスを指定します。$scope など何か DI したい時は上記のようにアノテーション形式を記述することで、コンストラクタ時の引数として受け取ることが出来ます。link に DI したいものは Factory にアノテーションする形で渡す必要がありましたが、controller にしか DI しない場合は Factory に渡す必要はなく、上記の書き方で受け取ることが出来ます。

controller を link 内で使うには requireプロパティに controller 名 ( アノテーションなので命名は何でも OK )を指定することで、link の第四引数から受け取ることが出来ます。あとはそれを静的関数の方の link() に渡してあげれば OK です。

締め

全3パターン +α を紹介しましたが、いかがだったでしょうか。#3は少々トリッキーですが、こういうパターンもあるということで嗜んでみるのも一興です。当エントリが用途に応じて手段をうまく使い分けるための参考になれば幸いです。

脚注

脚注
1 リリース直前まで最新バージョンを追いかけたかったのですが、途中からすっかり忘れてしましました。頃合いを見計らってアップデート対応をする予定です。
2 ライブラリを作るわけではないので、わざわざ内部モジュール化して Require するメリットは少ないと判断しました。
3 リストの子要素なんかはループ処理で大量に生成されることも珍しくないので、塵も積もればなんとやら。