AngularJSをTypeScriptで書くときのあれこれ

  • 212
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

AngularJSのようなクライアントMVCフレームワークを採用すると、クライアントサイドの規模が大きくなってくるので、できればJavaScriptじゃなくて型のあるプログラミング言語で開発したいですよね。

AngularJSは独自のクラスシステムを持っていないし、モデルやコントローラを実装するためにベースクラスを継承したりする必要もないので、altJSとの相性がよくて組み合わせやすいです。
altJSと言ってもたくさん種類がありますが、今回はTypeScriptを使ってAngularJSアプリを書くときのTipsやコツなどを紹介したいと思います。

ベースとなるプロジェクトを作る

AngularJSのコードを書くとき、JavaScriptであればおもむろに書き始めることも可能ですが、altJSを使う場合はコンパイルなどの手順が必要になるので、Gruntを使ったプロジェクトを作る必要があります。
Gruntfileを1から書いてファイルの配置などを考えるのはちょっと面倒なので、yeomanを使うか、どこかからひな形を持ってくるのが楽です。

yeomanを使う

yeomanを使うならangular-generator-typescriptというのがあります。

でもこれ9ヶ月間もメンテされてないので、TypeScriptのコンパイル通らないし、fork元で新しく追加されたテンプレートに追随できていないんですよね。

じゃあこれをforkして修正するか、もしくはfork元のgenerator-angularにTypeScript用のオプションを追加すればいいんじゃないか?とも思ったのですが、すでにそんなpull requestがありました。

開発者が忙しくて放置されていたようですが、近いうちに取り込まれそうな雰囲気なので待つのがよさそう。

ひな形を使う

ひな形を持ってくるならこちらがおすすめ。いろいろと参考にさせてもらいました。

この記事の内容をまとめたプロジェクトも用意しました。テストの設定がなかったりしてちょっと物足りないかもしれませんが、よければ参考にしてください。

IDEを使う

TypeScriptでの開発のメリットを存分に享受したいのであれば、Visual Studioや、IntelliJ IDEA/WebStormなどのIDEの利用は必須と言ってもいいでしょう。

IntelliJ IDEAから使う場合は、メニューから[File] -> [New Project...]を開き、プロジェクトのタイプにWebを選んで、前述したプロジェクトの含まれるディレクトリを指定するだけです。

これだけで、AngularJSのAPIもこんなふうに補完されます。

idea.png

IntelliJ IDEAには、AngularJS用のプラグインも用意されていて(WebStormには標準搭載)、HTMLの編集中にdirectiveの補完が効くので便利です。($scopeのメンバも補完してくれるともっと便利なんですけどね)

なお、IntelliJ IDEA 12はTypeScriptのGenericsに対応していない(ver.0.8相当)ので、IntelliJ IDEA 13の利用をおすすめします。

Gruntfileをカスタマイズする

yeomanで生成したりひな形に含まれているGruntfileはそのまま使ってもいいんですが、カスタマイズすることでさらに便利になります。

型定義ファイル

TypeScriptでは、既存のJavaScriptライブラリを利用するために型定義ファイル(拡張子が.d.tsのファイル)を用意する必要があります。
型定義ファイルを扱うリポジトリはDefinitelyTypedがメジャーです。AngularJSの型定義ファイルももちろん用意されています。

angular-generatorで生成したプロジェクトではDefinitelyTypedを丸ごと落としてきているのですが、これはちょっとおおげさな気がします。
そこで、型定義ファイルを取得するためのツールとしてtsdを使います。
(最近までtsdでangular-route.d.tsが取れなかったので、pull requestして取れるようにしてもらいました。)

Gruntからtsdを呼び出すには、将来的にはgrunt-tsdってのが提供されるようですが、現状ではgrunt-execを使います。

grunt-execをインストールします。

npm install grunt-exec --save-dev

あとは、Gruntfileでこんなタスクを用意するだけです。ファイルの置き場所はtsd-config.jsonで制御します。

Gruntfile.js
exec: {
    tsd: {
        cmd: function () {
            return "tsd install jquery angular angular-resource angular-route";
        }
    }
}

TypeScriptコンパイル

GruntでTypeScriptのコンパイルをするにはgrunt-typescriptを使い、Gruntfileに次のようなタスクを用意します。

Gruntfile.js
typescript: {
    main: {
        src: ['dist/scripts/**/*.ts'],
        dest: 'dist/scripts/App.js',
        options: {
            target: 'es5',
            sourcemap: true,
            declaration: false
        }
    }
}

ここで、srcとdestの指定方法に少しコツがあります。
まず、srcを指定するには次のような2つの方法があります。

  • 1ファイルだけ指定して、そのファイル内に他のファイルへの参照(/// <reference path="xxx.ts" />)を書く。
  • 配列で全ファイルを列挙する、またはワイルドカードで全ファイルを指定する。

これはどちらでも好みで選んでいいと思います。

destの指定方法も2種類あります。

  • ディレクトリを指定すると、TypeScriptのソースと同じツリー構造でJavaScriptとmapファイルが生成される。
  • ファイルを指定すると、すべてのTypeScriptのコンパイル結果が1つのJavaScriptとmapファイルにまとめられる。

次のsourcemapのところで説明しますが、destは1つのファイルにまとめておくのがおすすめです。

sourcemap

TypeScriptでデバッグするときには、やっぱりsourcemapを使いたい。

TypeScriptのコンパイル結果をそのままHTMLから参照するのであれば特に問題ないのですが、TypeScriptでコンパイルした結果をminifyする場合は、2段階のsourcemapの解決が必要になるのでちょっとややこしくなります。

Gruntfileの設定例はこんなかんじになります。

Gruntfile.js
copy: {
    scripts: {
        files: [
            {
                expand: true,
                flatten: false,
                cwd: 'app/scripts',
                src: ['**/*.ts'],
                dest: 'dist/scripts'
            },
            {
                expand: true, 
                flatten: false, 
                cwd: 'app/d.ts', 
                src: ['**/*.d.ts'],
                dest: 'dist/d.ts'
            }
        ]
    }
},
typescript: {
    main: {
        src: ['dist/scripts/**/*.ts'],
        dest: 'dist/scripts/App.js',
        options: {
            target: 'es5',
            sourcemap: true,
            declaration: false
        }
    }
},
uglify: {
    dev: {
        options: {
            report: 'min',
            beautify:true,
            mangle: false,
            preserveComments: 'some',

            sourceMap: 'dist/scripts/App.min.js.map',
            sourceMapRoot: '',
            sourceMappingURL: 'App.min.js.map',
            sourceMapIn: 'dist/scripts/App.js.map',
            sourceMapPrefix: 1
        },
        files: {
            'dist/scripts/App.min.js': [
                'dist/scripts/libs/angular/*.js',
                'dist/scripts/libs/angular-route/*.js',
                'dist/scripts/libs/angular-resource/*.js',
                'dist/scripts/App.js'
            ]
        }
    }
}

それぞれのタスクの説明をします。

  • copy:scripts
    • TypeScriptのファイルをWebサーバから見える場所に置きます。(sourcemapで解決するときに使うので)
  • typescript:main
    • TypeScriptのコンパイルをして、App.jsとApp.js.mapを生成します。
  • uglify:dev
    • App.jsとAngularJSやその他ライブラリのファイルを結合して、App.min.jsを生成します。
    • sourceMapInオプションでApp.js.mapを指定し、App.min.jsからTypeScriptへのマッピングを解決するためのApp.min.js.mapを生成します。

uglifyのsourceMapInオプションは1ファイルしか指定できない(functionを指定すれば指定できる?)ようなので、TypeScriptのコンパイル結果は1つにまとめておくのがよいです。

これでChromeやIDEを使って、TypeScriptのソースにブレイクポイントを張ったりできるようになりました!

chrome_breakpoint.png

コードの書き方

TypeScriptでAngularJSの基本的なコードの書き方は、generator-angularで生成されるコードが参考になります。
生成コードのテンプレートはこちら。

基本は上記のコードを見れば分かると思うので、ここでは応用的な書き方について紹介したいと思います。

ScopeとController

まずは、ScopeとControllerの書き方を3種類紹介したいと思います。
なお、説明のためにController内にビジネスロジック的なものを書いていますが、ちゃんとしたコードを書くときはビジネスロジックはModelに書くようにしてください。

基本的な書き方

IScopeを継承した新しいinterfaceと、Controllerクラスを定義します。
JavaScriptと比べるとScopeの定義を書くのが面倒に感じるかもしれませんが、型チェックで得られるメリットは大きいので我慢して書きます。

MainController.ts
export interface MainScope extends ng.IScope {
    content: string;
    items: app.models.Item[];
    add(item: string): void;
}
export class MainController {
    constructor(private $scope:MainScope) {
        $scope.items = []
        $scope.add = (item:string) => {
            $scope.items.push(new app.models.Item(item));
        }
    }
}

Viewに公開するメンバ関数はコンストラクタの中で定義するので、コンストラクタは長くなりがちです。
なお、Viewに公開したくないメンバ変数やメンバ関数は、Controllerクラスのprivateメンバとして用意します。

angular.bindを使う

Controllerのメンバ関数をangular.bindを使ってScopeにバインドする方法です。

MainController.ts
export interface MainScope extends ng.IScope {
    content: string;
    items: app.models.Item[];
    add: Function;
}
export class MainController {
    constructor(private $scope:MainScope) {
        $scope.items = []
        $scope.add = angular.bind(this, this.add)
    }
    add(item:string): void {
        this.$scope.items.push(new app.models.Item(item));
    }
}

bindを書く手間はありますが、コンストラクタがだらだらと長くなることはなくなりました。

Controller As を使う

最後にAngularJS 1.2.0の"Controller As" Syntaxを使った方法です。

MainController.ts
export class MainController {
    content: string;
    items: app.models.Item[];
    constructor(private $scope:ng.IScope) {
        this.items = []
    }
    add(item:string): void {
        this.items.push(new app.models.Item(item));
    }
}

ScopeとControllerがまとまって、すっきりと書くことができました。ただし、HTML側の書き方が少し変わるので注意が必要です。

この書き方は、ScopeとControllerの役割分担ができないとか、ViewからControllerの中がすべて見えてしまう(privateつけても呼び出せる)とか、問題点を指摘されたりもしているので、採用する際には少し検討したほうがよさそうです。

Directive

Directiveの実装は、JavaScriptの場合と同じように連想配列を返す書き方もできますが、IDirective interfaceを実装することもできます。
interfaceを使ったほうが型のサポートがあるので安心ですね。
なお、IDirectiveのメンバ関数やメンバ変数には?がついているので、必要のないメンバ関数・メンバ変数は実装しなくても大丈夫です。

MyDirective.ts
module app.directive {
    export class MyDirective implements ng.IDirective {
        restrict: string = "E";
        templateUrl: string = "/views/xxx.html";
        transclude: any = false;
        replace: boolean = false;
        scope: any = false;
        link(scope: ng.IScope, elem: any, attrs: ng.IAttributes, ctrl: any) {
          /* 処理を書く */
        }
    }
}
angular.module("app.directive")
    .directive('myDirective', () => new app.directive.MyDirective());

$resourceへのメンバ関数の追加

サーバーサイドと通信するための$resourceサービスは、標準でGET、POST、DELETEのHTTPメソッドが使えるようになっています。
それ以外のHTTPメソッド(PUTなど)を利用したい時は、自分で新しいメンバ関数を追加することになります。
しかし、TypeScriptでは新しいメンバ関数を追加しても、呼びだそうとすると「そんな関数知らないよ」とコンパイル時に怒られてしまいます。

そこでまず、IResourceClassを継承して、新しいメンバ関数を追加したinterfaceを用意します。

module app.service {
    export interface IUpdatableResourceClass extends ng.resource.IResourceClass  {
        update(params: any, data: any, success?: Function, error?: Function): ng.resource.IResource;
    }
}

続いて、PUTメソッドを追加した$resourceを返すサービスを用意します。

angular.module('app.service', ['ngResource'])
    .factory('Items', function ($resource: ng.resource.IResourceService) {
        return $resource("/api/items/:itemId", {}, {update: {method: 'PUT'}});
    });

コントローラにそのサービスを渡してあげます。

angular.module('app.controller').controller("MainController", ["$scope", "Items",
    ($scope: ng.IScope, Items: app.service.IUpdatableResourceClass):app.controller.MainController => {
        return new app.controller.MainController($scope, Items)
    }]);

これで、PUTメソッドを追加した$resourceを、Controllerから使えるようになりました。

でも$resourceのメンバ関数はany型の引数を渡せるようになっているのでちょっと頼りないですね。
より厳密に型チェックをしたいのであれば、ちゃんと引数や戻り値の型を定義したクラスを別途用意して$resourceをラップしてしまいましょう。

まとめ

一度TypeScriptで書き始めると、もう生JavaScriptは書きたくなくなります。ぜひお試しあれ。

この投稿は AngularJS Startup Advent Calendar 201320日目の記事です。