Angular2のRCリリースが順調に行われている中、今更ながらAngularJS記事です。
TypeScriptを使ったAngularJSアプリケーションをどのようにしてSymfony2フレームワークに組み込むかといった話が主になります。

おまけコンテンツとして、Browserifyがなかった時代に作られたAngularJSをCommonJSでどのようにコードの分離を行えば良いかも紹介します。

はじめに

弊社ではSymfony2のバンドル毎に比較的大きなJSのアプリケーションが存在しており、バンドル間でJSの依存を共有しないようにして独立性を高めに保っています。
こういった背景がありバンドル毎に独立してJSがビルドできる環境、というのが今回の記事内容になりますのでご注意ください。

ゴール

  • バンドル毎にnpmパッケージをインストールする
  • バンドル毎にJSをビルドする
  • ビルドしたJSのURLをSymfony2に出力させる

バンドル毎にnpmパッケージをインストールする

bowerの場合はsp/bower-bundleが機能を提供してくれていましたが、npmの場合はhshn/npm-bundleを使います。
hshn/npm-bundlesp/bower-bundleと比べると全くと言っていい程に機能がありませんが、バンドル毎にnpmコマンドを実行する分には十分です。

このバンドルはinstallrunコマンドをバンドル内で実行するだけの機能しかありません。(※ v0.2.0現在)
単純にやるべき事は下記の3ステップだけです。

  1. バンドル内にpackage.jsonを準備する
  2. 必要なnpmパッケージをインストールする
  3. Symfonyコンソールからhshn:npm:installを実行

詳細な使い方は公式ドキュメントを参照してください。

バンドル毎にJSをビルドする

先ほどhshn/npm-bundleの説明で述べた通り、このバンドルはnpm installnpm runの実行しか機能提供されていません。

このバンドルはinstallrunコマンドをバンドル内で実行するだけの機能しかありません。(※ v0.2.0現在)

なのでビルドシステムに、gulpwebpack, broccoliを採用しようともそれらのコマンドを直接実行できないので、 Symfonyコンソールから実行したい処理はすべてnpmスクリプトとして登録します。

{
  "scripts": {
    "build:gulp": "gulp build",
    "build:webpack": "webpack",
    "build:grunt": "grunt build"
  }
}

お馴染みですね。
あとはSymfonyコンソールからnpmスクリプトを実行すればJSのビルドが行えるといった流れになります。

$ bin/console hshn:npm:run build:webpack

TypeScriptの型ファイルはどうしたら?

型ファイルのインストールコマンドもnpmスクリプトに登録して、Symfonyコンソールから実行すれば良いですが
全てのバンドルがTypeScriptとは限りませんし、何より実行しなけらばならないコマンドが増えて大変です。

なのでnpm install後に型ファイルがインストールされるように、hookスクリプトを定義します。

{
  "scripts": {
    "postinstall": "typings install"
  }
}

これでSymfonyコンソールからnpmパッケージをインストールするだけで、型ファイルのインストールまで完了するようになります。

補足:

tsdは半年程前にdeprecatedとなりtypingsが推奨されるようになりました。

https://github.com/DefinitelyTyped/tsd/issues/269
https://github.com/typings/typings

minifyはどうしたら?

Assetic(kriswallsmith/assetic)であれば、symfony/assetic-bundleがSymfonyのenvironment毎にminifyする/しない、フィルタをかける/かけないを制御してくれますが、今回JSをビルドするnpmの世界にはSymfonyの世界の情報は伝わりません。

開発環境はminifyせずに本番環境だけminifyしたい場合は、開発環境用ビルドと本番環境用ビルドとでnpmスクリプトをそれぞれ定義し、環境毎に実行するスクリプトを変更すれば良いです。

{
  "scripts": {
    "build": "gulp build",
    "build:prod": "gulp build --production",
    "build:test": "gulp build:test"
  }
}

nodeのenvironmentを使うなり、タスクランナーのタスクを切り替えるなりお好みの方法で。

# 開発環境
$ bin/console hshn:npm:run build

# 本番環境
$ bin/console hshn:npm:run build:prod

ビルドしたJSのURLをSymfony2に出力させる

ビルドしたJSファイルをSymfony2が出力するhtmlに、<script>タグのURLとして出力しなければいけません。

ここで問題になるのはブラウザキャッシュなのですが、ファイル名が同じまま内容だけ更新しても
ブラウザが最新のJSファイルをロードしてくれるとは限らないという問題です。

多くのビルドシステムではファイルのハッシュ値などをファイル名の一部として利用できる仕組みがありますが、
Symfony2はphpなので、たとえgulpでハッシュ値を含んだファイル名で生成したとしても「生成されたファイル名」を受け取れず、どのファイルを参照して良いか把握する術がありません。

もちろんディレクトリをスキャンしたり、ダサい方法はあります。

この問題を解決する最も簡単な手段として、Symfony2ユーザーが慣れ親しんだAsseticを使います。
Asseticにはブラウザキャッシュを無効化させるCache Bustingという機能があり、Assetic経由でURLの取得さえすればJSファイルのURLにユニークなキーが含まれるようになりこの問題を解決する事ができます。

If you serve your assets from static files as just described, you can use the CacheBustingWorker to rewrite the target paths for assets. It will insert an identifier before the filename extension that is unique for a particular version of the asset

{# twigテンプレート #}
{% javascripts '/path/to/bundle.js' %}
<script src="{{ asset_url }}"></script>
{% endjavascripts %}
<!-- 出力されたhtml -->
<script src="/js/f9550d1.js"></script>

おまけコンテンツ

これでめでたくお好みのAltJSをnpmエコシステムを利用してJSにビルドできる様になりました。
おまけコンテンツとして、AngularJSアプリケーションをCommonJSの世界でどのようにしたら上手にファイルを管理できるのか紹介したいと思います。

AngularJSアプリケーションのファイルをいい感じに分離する

AngularJSはモジュール単位でのファイル分割は考慮されていますが、それ以外はあまりうまくいきません。

ディレクティブやサービスを作ったとしてもモジュールに登録しなければならず、多くは登録時に名前を必要とします。
ものによってはモジュールへの登録順序も振る舞いに影響を及ぼすものもあるため、せっかくクラス毎にファイルを分けたとしてもモジュールファイルが大きくなりがちになります。

import * as angular from 'angular';

import directiveFooFactory from './directive/foo';
import directiveBarFactory from './directive/bar';
import FooService from './service/foo';
import barFactory from './factory/bar';

// 登録するものが多ければもっと増える
export angular.module('myApp', [])
  .directive('foo', directiveFooFactory)
  .directive('bar', directiveBarFactory)
  .service('foo', FooService)
  .factory('bar', barFactory);

Providerを使う

RC1がリリースされてそこそこ日数も経つのでAngular2に触れてみたことのある方も少なくないかと思います。

Angular2でもDIコンテナはグローバルではなくツリーになりましたが健在です。
また、サービスその他はProviderによってDIコンテナへ登録する仕組みになりました

解釈によりますが厳密には異っていて「Providerクラス」は非推奨になりました。Provider自体はまだ存在します。

要はサービスその他を直接コンテナに登録するのではなく、「コンテナへの登録」を抽象化するレイヤーが挟まった感じなのだと思います。

// Angular2 Provider
import { bootstrap }      from '@angular/platform-browser-dynamic';
import { HTTP_PROVIDERS } from '@angular/http';
import { AppComponent }   from './app';

bootstrap(AppComponent, [
  HTTP_PROVIDERS,
]);

今回はこのProviderをAngularJSに持ち込んだangular-provideを使いファイルを分離する方法を紹介します。
このProviderを導入する事によって「ディレクティブやサービスの定義」と「モジュールへの登録」を分離(完全ではない)する事ができます。

AngularJSのディレクティブやサービスの解決方法が名前ベースである事や、DIコンテナがグローバルである性質上
他のディレクティブやサービスに依存している「ディレクティブやサービスの定義」はそれらの「モジュールへの登録(名前)」へ依存するため、完全には分離できない。

angular-provideを導入するとどう変わるか

ディレクティブ以外も網羅的にProviderを生成する機能が提供されていますが、ボリュームの関係上ディレクティブのみの紹介です。

ディレクティブの定義

いつも通りのディレクティブの定義です。
CommonJSになってファイルを分けようとしたら自然とこうなるので特別な事はないですね。

// ./directives/foo.ts
export function fooDirectiveFactory(): angular.IDirective {
  return {
    restrict: 'E',
    scope: {},
    template: `
      <h1>foo directive</h1>
    `
  };
}

ディレクティブのProviderを定義

先ほど作ったディレクティブの定義をfooディレクティブとして登録するProviderを作り、
それをProviderのコレクションとしてexportします。(Angular2 styleですね)

これまではモジュールに登録するまで名前を決定できませんでしたが、Provider生成時に名前を決定できるようになりました。
これによってモジュール単位で集約する以外にも、好きな単位でProviderを生成し集約する事ができるようになります。

// ./directivs/index.ts
import provide from 'angular-provide';
import { fooDirectiveFactory } from './foo';

export const DIRECTIVE_PROVIDERS = [
  provide.directive('foo', fooDirectiveFactory)
];

モジュールの生成

provide関数を利用して生成したモジュールにProviderを適用します。
Providerを定義する事によって名前が事前に決定されているため、モジュールのコードがかなりシンプルになりました。

// ./app.ts
import * as angular from 'angular';
import provide from 'angular-provide';

import { DIRECTIVE_PROVIDERS } from './directives';

provide(angular.module('myApp', []),
  ...DIRECTIVE_PROVIDERS
);

今回はDIRECTIVE_PROVIDERSとコンポーネントの種類でひとまとめにしましたが、Angular2のHTTP_PROVIDERSのように機能毎にまとめた方が取り外しもしやすく良いのではないかと思います。