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-bundle
はsp/bower-bundle
と比べると全くと言っていい程に機能がありませんが、バンドル毎にnpmコマンドを実行する分には十分です。
このバンドルはinstall
とrun
コマンドをバンドル内で実行するだけの機能しかありません。(※ v0.2.0
現在)
単純にやるべき事は下記の3ステップだけです。
- バンドル内に
package.json
を準備する - 必要なnpmパッケージをインストールする
- Symfonyコンソールから
hshn:npm:install
を実行
詳細な使い方は公式ドキュメントを参照してください。
バンドル毎にJSをビルドする
先ほどhshn/npm-bundle
の説明で述べた通り、このバンドルはnpm install
とnpm run
の実行しか機能提供されていません。
このバンドルは
install
とrun
コマンドをバンドル内で実行するだけの機能しかありません。(※v0.2.0
現在)
なのでビルドシステムに、gulp
やwebpack
, 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
のように機能毎にまとめた方が取り外しもしやすく良いのではないかと思います。