controller と directive を TypeScript のクラスとして定義する方法 - AngularJS + TypeScript #1

はじめに

imgres-e1426356973644

TypeScript と相性が良いということで、この連休中に AngularJS の学習を始めました。とりあえず Controller や Directive の基本的な書き方は覚えたものの、せっかく TypeScript を使うのであればよりそれっぽいお作法で書けるようになりたいものです。そんな訳で、controller と directive を TypeScript のクラスとして定義する方法をまとめておくとします。

前提条件

  • Mac OS X Yosemite
  • TypeScript v.1.5.0-alpha
  • AngularJS v.1.3.15
  • Jade v.1.0.0

サンプルコードはこちらからどうぞ。

Controller

クラス化しない書き方

まずはクラス化しない従来の書き方でコントローラを定義してみます。

var app = angular.module('app', []);
app.controller('SampleController', ['$scope', ($scope) => {
  $scope.firstName = '';
  $scope.lastName = '';
  $scope.getFullName = (): string => {
    return $scope.firstName + ' ' + $scope.lastName;
  };
}]);
div(ng-controller="SampleController")
  ul
    li
      input(ng-model="firstName")
      span {{firstName}}
    li
      input(ng-model="lastName")
      span {{lastName}}
  p {{getFullName()}}

この内容を元にしてコントローラをクラス化してみるとします。

クラス化の第一歩

コードの良し悪しは置いておいて、とりあえずクラス化してみます。

module SampleModule {
    export interface MainScope extends ng.IScope {
        firstName:      string;
        lastName:       string;
        getFullName:    Function;
    }
    export class SampleController {  // (1) class を宣言
      constructor(private $scope: MainScope0) {  // (2) 引数に $scope を受け取る
          $scope.firstName = 'Naoki';
          $scope.lastName = 'WAKAMSHA';
          $scope.getFullName = ()=> {
              return $scope.firstName + ' ' + $scope.lastName;
          }
      }
    }
}
var app = angular.module('app', []);
// (3) class のコンストラクタが渡る
app.controller('SampleController', ['$scope', SampleModule.SampleController]);

SampleController を上記のようにクラス化しました。constructor()の引数に$scopeオブジェクトを受け取るようにします。$scopeのプロパティは interface で定義します。少々面倒ですが、こうすることで TypeScript の型定義の恩恵が受けられるので、サボらずにキッチリと定義しましょう。

とりあえず一応のクラス化は出来ましたが、変数やメソッドが constructor() 内にで定義されてしまっており、お世辞にも良い書き方とは言えません。

Angular.bind() を使う

とりあえずコンストラクタ内で定義されているメソッドをもう少し外出しして分割するとします。

module SampleModule {
  export class SampleController {
    constructor(public $scope) {
      this.init();
    }
    private init() {
      this.$scope.firstName = '';
      this.$scope.lastName = '';
    }
    public getFullName():string {
      return this.$scope.firstName + ' ' + this.$scope.lastName;
    }
  }
}

初期化用メソッド init()とフルネームを取得する getFulName() を constructor()から外に出しました。しかしよく見てみると getFullName() が $scope の紐付け(バインド)から漏れてしまっています。このままでは View から呼び出すことが出来ません。そこで以下のようにコードを書き足すことで外出ししたメソッドをバインドすることが出来ます。

export interface IMainScope extends ng.IScope {
    firstName:    string;
    lastName:     string;
    init:         Function;
    getFullName:  Function;
}
export class SampleController1 {
    constructor(private $scope: IMainScope) {
        $scope.init         = angular.bind(this, this.init);
        $scope.getFullName  = angular.bind(this, this.getFullName);
        $scope.init();
    }
    private init() {
        this.$scope.firstName = 'Naoki';
        this.$scope.lastName = 'YAMADA';
    }
    public getFullName(): string {
        return this.$scope.firstName + ' ' + this.$scope.lastName;
    }
}

これで getFullName() も正常に動作するようになりました。全てのメソッドに対してこのように書くというのはやや冗長ですが、コンストラクタの肥大化をある程度抑えることは出来ます。

Controller As を使う

もう一つの方法として Constructor As を使うというのがあります。早い話がコントローラ関数をスコープオブジェクトとして利用することで$scopeを介さすにメソッドを呼び出すことが出来るというものです。

メソッドの呼び出し方が変わるので、 Jade 側を以下のように書き換えます。

div(ng-controller="SampleController as c")
  ul
    li
      input(ng-model="c.firstName")
      span {{c.firstName}}
    li
      input(ng-model="c.lastName")
      span {{c.lastName}}
  p {{c.getFullName()}}

コントローラ名 as 別名と指定します。プロパティやメソッド にアクセスする際には、別名.valueといったように記述すればOKです。
そしてコントローラの定義ですが、引数に $scope を受け取らず、テンプレートに公開するプロパティやメソッドをコントローラ自身 (this)に登録します。書き方としては以下のようになります。

module SampleModule {
  export class SampleController {
    // (1) this に登録されるプロパティとなるので、型を明記する
    public firstName: string;
    public lastName: string;
    constructor() {  // (2) $scope は不要
      this.init();
    }
    private init() {
      this.firstName = '';
      this.lastName = '';
    }
    public getFullName():string {
      return this.firstName + ' ' + this.lastName;
    }
  }
}
var app = angular.module('app', []);
// (3) $scope 不要
app.controller('SampleController', [SampleModule.SampleController]);

これでより class らしい記述になりました。コードが綺麗になる Controller As ですが、メリットとデメリットを持っています。

メリット

通常コントローラをネストすると、子コントローラ(スコープ)から親コントローラ(スコープ)の値を参照することは出来ても変更することは出来ません。これは子コントローラが親コントローラの値を変更しようとすると、そのタイミングで自身の中に同名のプロパティを生成してそちらを参照するようになるからです。これはプロパティ継承というJavaScriptの仕様なのですが、Controller As を導入することで子コントローラから親コントローラの値を変更することが出来ます。

Controller As を使わなくても、AngularJS の $scope.$parent を使えば子コントローラから親コントローラの値を変更することも可能です。この辺りはAngularJS リファレンスの 6-2 スコープの適用範囲とインスタンスという節で詳しく解説されています。

デメリット

private メソッドも View から呼び出せるようになってしまうため、使いドコロについては要検討かと思います。
また、これはデメリットというほどではないですが、コントローラを階層化してもスコープが自動的に継承されるわけではないので、そこはしっかりと意識しておくことが大事です。

Directive

クラス化しない書き方

app.directive('sampleDirective', () => {
  return {
    restrict: 'E',
    template: '<div>'+
                '<input type="text" ng-model="message">'+
                '<button ng-click="clear($event)">Clear</button>'+
                '<p>{{message}}</p>'+
              '</div>',
    link: (scope:any, element: ng.IAugmentedJQuery, attrs: ng.IAttributes) => {
      scope.clear = ($event) => {
        scope.message = '';
      };
    }
  }
});

クラス化の第一歩

コードの良し悪しは置いておいて、とりあえずクラス化してみます。

module SampleModule {
    export class SampleDirective {
        constructor() {
            return this.CreateDirective();
        }
        private CreateDirective():any {
            return {
                restrict: 'E',
                template: '<div>' +
                            '<input type="text" ng-model="message">' +
                            '<button ng-click="clear($event)">Clear</button>' +
                            '<p><em>{{prefix}}</em> - {{message}}</p>' +
                          '</div>',
                scope: {
                    prefix: '@'
                },
                link: (scope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes)=> {
                    scope.clear = ($event) => {
                        scope.message = '';
                    };
                }
            };
        }
    }
}
var app = angular.module('app', []);
app.directive('sampleDirective', () => new SampleModule.SampleDirective());

とりあえずはクラス化出来ました。といってもクラス化する前と同じく定義した内容を連想配列で丸ごと返しているだけです。やはり全ての定義が CreateDirective()の中に記述されているため、可読性がよろしくありません。

Interface を実装

せっかく TypeScript を使うのだから、 ng.IDirective という Interface を実装して型のサポートを受けるのがオススメです。

module SampleModule {
    export interface IDirectiveScope extends ng.IScope {
        message:    string;
        clear:      Function;
    }
    export class SampleDirective implements ng.IDirective {
        public restrict:    string;
        public replace:     boolean;
        public template:    string;
        public scope:       any;
        public link:        (scope: ng.IScope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes) => void;
        constructor() {
            this.init();
        }
        public static Factory(): ng.IDirectiveFactory {
            var directive = () => {
                return new SampleDirective();
            };
            directive.$inject = [];
            return directive;
        }
        private init() {
            this.restrict = 'E';
            this.replace = true;
            this.scope = {
                prefix: '@'
            };
            this.link = (scope: IDirectiveScope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes) => {
                scope.clear = ($event) => {
                    scope.message = '';
                };
            };
            this.template = '<div>' +
                                '<input type="text" ng-model="message">' +
                                '<button ng-click="clear($event)">Clear</button>' +
                                '<p><em>{{prefix}}</em> - {{message}}</p>' +
                            '</div>';
        }
    }
}
var app = angular.module('app', []);
app.directive('sampleDirective', SampleModule.SampleDirective.Factory());

全体のコード量はかなり増えますが、それぞれが役割ごとに分類されるので可読性は向上するかと思います。

参考