Angular2 AoTコンパイルでTechFeedを高速化した話

前回のエントリで、TechFeedをProgressive Web Apps化した件について書きました。今回(2016/11/8)のバージョンアップでは、もう一つ大きなトピックがあります。それはAngular2のAoTビルドに対応したことでTechFeedの起動時間を大幅に削減したことです。

Angular2のAoT(Ahead of Time)ビルドについては、ググってみても意外と日本語のいい記事がないので、ちょっとだけ解説します。

AoT概要

Angular2アプリは、コンポーネントのツリーによって構成されます。コンポーネントは初期化される時に依存しているオブジェクトを注入(Dependency Injection)されたり、コンポーネントの状態を検知してUIを書き換えたりと言った様々な処理が必要です。Angular2は、ランタイム本体がそれらを動的に行うのではなく、そうした処理をハードコーディングしたクラスを前もって生成し、そのクラスに処理を委譲します。こうすることで、コンポーネントに対する複雑な、しかし定型的な処理を(ほぼ)静的な処理へと落とし込むことができ、結果として高速に実行することができるのです。

通常こうしたコード生成処理はアプリケーションの実行中に行われる(Just in Time: JIT)のですが、これらのコードをビルド時にあらかじめ生成し、ファイルとして出力した上で結合することも出来ます。これがAoT(Ahead of Time)コンパイルと呼ばれるものです。また、AoTコンパイルによって出力されるコードはngfactoryというサフィックスが自動的に付与されるため、本稿ではAoTによって生成されるコードのことをngfactoryと呼称します。

AoTコンパイルにより、DIやChange Detectionを行うngfactoryが出力される

AoTコンパイルを行うことで、コード生成をアプリケーションの実行中に行う必要がなくなるため、アプリケーションの実行速度が向上します。コード生成処理はアプリケーションの初期化時に行われることが多いため、アプリケーションの起動速度を高速化することができるのです。

また、JITコンパイラが不要になるため、ランタイムのサイズもその分削減することが出来ます。このJITコンパイラはAngular2コードの大半を占めるため、最終的なJSバンドルのサイズをスリムにすることにもつながります。ただ、コンパイラがなくなる代わりにngfactoryがコンポーネントの数分生成されるので、最終的なコードサイズはAoTビルドのほうが大きくなることが多いでしょう。

AoTコンパイルの実際

AoTコンパイルは実際にはngcというコマンドによって行われます(Angular2をインストールすると付いてきます)。ngcはTypeScriptコンパイラ(tsc)をラップしたツールで、通常のTypeScriptコンパイルに加えて、Angular2コンポーネントを見つけると、それらの構成ファイル(TypeScript, HTML, CSS)から、ngfactoryを生成することのできるツールです。

TechFeedでは、モジュールバンドラーにWebpack2を用いていますが、Webpack2でバンドルする前にngcで全てのTypeScriptコードをビルドしています。(モジュールロード時にAoTコンパイルを行えるWebpackプラグイン「AoTPlugin」もあるようなのですが、まだ試していません)

実際にAoTコンパイルを行った結果、TechFeedの起動は圧倒的に速くなりました。以下に示す表は、iPhone6上でJIT版に比べてどれくらい起動が高速化したかを試した結果ですが、Warm Start時(※)で2.6秒、Cold Start時(※)で3.8秒と、実に最大4秒近く起動時間を削減できました。(それだけ、TechFeedが元々起動が遅かったということでもあるのですが…汗)

※Warm Start時…アプリを止めてすぐ再起動。アプリの実行コードがメモリに残っている状態での起動
※Cold Start時アプリを止めてから、他のアプリを色々起動して、アプリの実行コードがメモリから追い出されている状態での起動

苦労話

で、本来ならばここで「じゃあ実際にAoTコンパイルをするには」なんて解説が始まるところでしょうが、本稿はTechFeedで実際にやってみた体験談をシェアすることが主眼なので、いきなり苦労話です。初学者向けの解説記事とかは、リクエストがあればHTML5 Experts.jpとかで書くかもしれません。書かないかもしれません。

AoTコンパイルが通るようにするまで

AoTコンパイルの主な処理は、HTML(ライクな)テンプレートの内容を読み取ってngfactoryを作るところにあります。で、このテンプレートコンパイルが、AoTだとJIT時よりも厳密なので、それまで書いていたコードをかなり修正する必要がありました。よくあるのが、

コンポーネント内のprivateな変数をテンプレートから参照しているとエラーになる(JIT時はOK)。”Property ‘navRef’ is private and only accessible within class ‘AppComponent’.” みたいなメッセージのコンパイルエラーが出ます。

・@ViewChild, @ViewChildren などのデコレータを付与しているプロパティをprivateにしていると、上記と同様のエラーメッセージが出ます。

・テンプレートから呼び出しているコンポーネントのメソッドが、「シグネチャが合わない」などのエラーになる。よくやってしまうのが、コンポーネントのメソッドをテンプレートから呼び出す際、イベントオブジェクト($event)を渡したり渡さなかったり、いい加減にやってしまうケースです。

まあ、JIT時は許されてもAoT時は許されない、というのは、JavaScriptを対象にしているかTypeScriptを対象にしているかの違いに起因することがほとんど。AoTで厳密にテンプレートのチェックをしてもらえるのは逆にありがたいです。

が、AoTを本番ビルド時にしか行ってない(しかもJenkins任せ)という現状だと、「気付いたらJenkinsのタスクがこけてる」という状態なのは、ぼくらが悪いのですが、めんどくさくもあります。AoTコンパイル超時間かかるしなあ…

JSファイルのサイズを縮めるための(涙ぐましい)努力

これはAoTに限った問題じゃないのですが、とにかくAngular2アプリのJSファイル(Webpackによるバンドル結果)はデカイ。何気なくAoTビルドに移行して、ローカルで試している限り起動速度が上がったと言って喜んでいたら、JSファイルサイズが数メガバイトに達していて、インターネット越しだと使い物にならない…という悲劇は容易に起こり得ます。っていうかTechFeedはそうでした。ngfactoryのせいでガンガンバンドルサイズが増えていくのを、指を咥えて見ているわけにもいきません。

というわけで、バンドルサイズを縮める努力を致しましょう。以下の施策は、実際にTechFeedでやってみて効果のあったものです。

・テンプレートのHTMLをminifyする。テンプレートのHTMLを元にngfactoryが生成されるので、HTMLのminifyはかなり効果的です。実際TechFeedでは、HTMLをminifyしたおかげで100kb以上バンドルサイズを減らせています。

・Webpack2が巻き込んでしまう謎の依存モジュールを削除する。理由は不明なのですが、Webpack2自身が依存している(のか?)謎のモジュールがバンドルに含まれてしまうという現象がありました。これらだけで数100kbあったので、ふざけんなという感じです。今は以下のモジュールをWebpackでexternalsに指定し、かつIgnorePluginで無視することでバンドルから強制的に除外しています(コメントアウトしているeventsやprocessは、消したらビルドに失敗するようになったので、しょうがなくバンドルに含めています)。

/**
 * なぜかWebpackのバンドルに含まれてしまうライブラリ群を強制的に除去
 */
const EXCLUDE_LIBS = [
 ‘elliptic’,
 ‘bn.js’,
 ‘asn1.js’,
 ‘node-libs-browser’,
 ‘buffer’,
 ‘browserify-aes’,
 ‘browserify-des’,
 ‘browserify-rsa’,
 ‘browserify-sign’,
 ‘parse-asn1’,
 ‘create-hash’,
 // ‘events’,
 ‘public-encrypt’,
 ‘string_decoder’,
 ‘ripemd’,
 // ‘process’,
 ‘create-ecdh’,
 ‘sha.js’,
 ‘diffie-hellman’,
 ‘des.js’,
 ‘cipher-base’,
 ‘browserify-cipher’,
 ‘core-util-is’,
 ‘pbkdf2’,
 ‘timers-browserify’,
 ‘create-hmac’,
 ‘process-nexttick-args’,
 ‘evp-bytestokey’,
 ‘randombytes’,
 ‘util-deprecate’
];
const EXCLUDE_LIBS_REGEXP = new RegExp(`^(${EXCLUDE_LIBS.join(‘|’).replace(‘.’, ‘\\.’)})$`);

externals: EXCLUDE_LIBS,
plugins: [
 new webpack.IgnorePlugin(EXCLUDE_LIBS_REGEXP)
],

angular-compilerがちゃんと除外されていることを確認する。基本中の基本。angular-compilerは、ランタイム時にビルドするために必要なモジュールなので、AoTビルド版のアプリには不要ですが、なんかの拍子に紛れ込んでしまうことがあります。angular-compilerをきちんと取り除ければ、なんと非minify時で1MB近くもバンドルサイズを削減することが出来ます。

・momentの不要なロケールを削除する。Webpackで以下のように指定し、ja以外のロケールを強制的に除外しました。これで150kbくらい減らせました。

// moment.jsのロケールを必要なもの以外除外する設定
// バンドルサイズ削減のため
new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /ja/),

・その他、いろんなライブラリで、importを最適化する。例えばlodashを以下のように読み込むと、ライブラリ全体がバンドルに含まれてしまいます。

import * as _ from ‘lodash’;

なので、必要な関数だけを読み込むよう、以下のようにimportする。

import noop from ‘lodash/noop’;

ぼくらはIonic2のネイティブインターフェースであるionic-nativeというモジュールを使用していますが、これもかなり巨大です。以前は以下のように読み込んでいましたが、

import {Push} from ‘ionic-native’;

こんな風に書き換えました。

import {Push} from ‘ionic-native/dist/plugins/push’;

他にはRxJSもimportを最適化したいところだったのですが、こちらはRxJSの作り上、TypeScriptコンパイラの助力が得られない(コンパイルエラーで検知できない)ため、労力に見合わないとしてやめておきました。RxJSのオペレーターくらい、自由に使える開発環境でありたいですしね…

source-map-explorerで常にバンドル内容をチェックする。source-map-explorerは神ツールで、バンドルの結果をめちゃくちゃわかりやすく可視化してくれるだけでなく、ドリルダウンでモジュールの内容をさらに詳細に見ていくことも出来ます。以下はTechFeedのバンドルをsource-map-explorerで可視化したもの。

TechFeedのJSバンドルをsource-map-explorerで可視化したもの

正直このツールがなければ、バンドルサイズをどうやって削減したらいいか、途方にくれていたことでしょう。使い方も簡単で、コマンドに出力されたソースマップファイル(.mapファイル)を指定するだけです。Angular2を使うなら、絶対に使うべし。

で、結果は

こうやって頑張ってバンドルサイズを削減した結果、現在はminify後の状態で3MB弱、Gzip圧縮後は600kb弱、というサイズになりました。決して小さくはないのですが、元々に比べるとminify後で1.5Mくらい減らせてもおり、これ以上は縮められないという状態です。

まあ、このサイズのファイルにAngular2っていうフルスタックなフレームワークとIonic2っていうUIフレームワーク全体が入っていると思えば、小さい…?

しかし、バンドルの内訳としては実は、ぼくらのアプリケーションコードの方が大きくなっています(上のsource-map-explorerの出力を見てください)。生成されたngfactoryのサイズが、やはりかなりのウェイトを占めています。

これ以上縮めるとなると、ngfactoryを小さくできるよう気を付けてコードを書く(どうやるの?)か、モジュール分割して、アプリのブートストラップモジュールについてはサイズを小さく保つ、などの努力が必要そうです。

TechFeedではバンドルサイズ削減はこれくらいにしておいて、後はService Workerのキャッシュなどを有効活用してロード時間を削減する方向に切り替えました。

#でも、「ngfactoryを小さく保つコードの書き方」ってネタ面白そう…

その他のノウハウ、今後の課題など

angular2-template-loaderは使え

開発時に限った話ですが、angular2-template-loaderは使ったほうがいいです。このローダーは、コンポーネントのHTMLテンプレート指定を「templateUrl: ‘HTMLのパス’」から「template: require(‘HTMLのパス’)」に動的に書き換えてくれます。

どうせngcはtemplate: require(‘’)は受け付けてくれないのでいらないかな、と思っていたのですが、templateUrlのままで開発をしようとすると、HTMLテンプレートをフェッチするためのHTTPリクエストが大量に飛び交うことになり、開発になりません。というか、TechFeedの場合はアプリが起動すらしませんでしたw

なので、「開発時はangular2-template-loaderを使う」「本番では、ビルド時にngcがテンプレートをngfactoryに埋め込んだものを使うので問題ない」という形で進めることで事なきを得ました。

Sassは使いたい

AoT以前は、styles: [require(‘style.scss’)] のように指定することで、Webpackのsass-loaderにSassのプリコンパイルを行わせていました。ですが、AoTを行うためのngcコマンドは stylesUrl: [‘styles.css’] のように、生のCSSファイルのURLを指定することしか出来ません。

現在はこの問題を解決するため、コンポーネントに読み込ませたいSassファイルは全てGulpを使って前処理させ、CSSに変換しています。

ですが、これだといろいろ問題もあるので、できればコンポーネントのビルドはWebpackに一元化させたいところ。angular2-template-loaderがstylesUrlもrequire()に変換してくれるということを今思い出したので、これとContextReplacementPluginなどを組み合わせて、「CSSのURL→SassのURLに変換(ただし開発時に限る)」ということができないか、模索してみます。

AoTPluginを使ってみる

現在はngcで全体をコンパイル(&ngfactory生成)してからWebpackでバンドルしていますが、WebpackのバンドルとAoTコンパイルを合わせて行えるようにしてくれるAoTPluginはなんか良さげな気もします。でも絶対ハマりどころがある気もします。

考えられる利点としては、開発中も常にAoTビルドしている状態になるので、テンプレートのエラーに気づきやすいことでしょうか。あと、本番に可能な限り近い状態で開発を進められるのは、「AoT時にのみ発生する問題」を事前に潰せて良さそうです(実際そういう問題はたまにあって、修正にめちゃくちゃ時間取られてしまうので…)。

考えられる欠点としては、ビルドにめちゃくちゃ時間がかかるようになるんじゃないか、とか。。つーか、絶対そうなる。そうなるに決まってる。

モジュールを分割する

バンドルサイズのところでも書きましたが、アプリを適切にモジュール分割していくことで、起動速度をさらに改善できないかと考えています。あ、ここで言っているモジュールというのは、npmのモジュールではなくて、NgModuleの話です。

ただ、モジュール分割すると管理も面倒になりそうなのと、実際起動速度の改善がどれくらい見込めるかがわからないので、ちょっと二の足を踏んでる状態。

ビルド速度の改善

で、最後のトピックはこれです。現在、Webpackのビルドにめちゃくちゃ時間がかかるようになっていて、開発時のJITコンパイルでも1分近く、AoTコンパイルの場合は実に2分以上ビルドに時間を要します。また、開発時にコードを一行修正しただけで10秒近く待たされるという事態にもなってしまっています。

まあ、本番用のビルドを作るのに時間がかかるのは仕方ないとしても、開発時にこの遅さは致命的です。改善策としては、とにかくWebpackが読み込むモジュールが多すぎるのが問題なので、開発時はAngular2やIonic2をバンドルから外す(そしてscriptタグで読み込むようにする)などでしょうか。きっと効果あると思うんだけどな…試してみたら、結果は追ってご報告します。

謝辞

TechFeedのAoT化にあたっては、ng-japanの皆様には大変お世話になりました。Angular2を今やろうとしている方は、以下のSlackには絶対入っておいたほうがいいです。

ng-japanの皆様のお陰で、TechFeedユーザーの起動時間が4秒削減されることになりました(累積するとすごい時間的価値に…)。本当にありがとうございました。