はじめに
どうも。最近、方々で「build筋」だの「うぇぶぱっ筋」だのと発言して顰蹙を買っている @Quramy です。
さて、皆さんAngularのAhead of Time(以下AoT) Compileは利用していますか? とても良い仕組みだと思っているのですが、まとめられた記事もまだ殆ど見かけません。
僕自身は少し前に業務案件に適用させたばかりです。折角なので記憶が鮮明なうちに投稿にしておこうと思った次第です。
このエントリは下記の3章構成でお届けします。例によって結構長いので、自分が必要としているテーマに合わせて好きな部分を読んでください。
- 基本編: AoTの仕組みについてのざっくり解説. 知っている人は読み飛ばしてOK
- 実践編: 主に僕がAoTに取り組んだ際に行ったこと、ハマったポイントについての説明
- 発展編: AoT環境を手に入れた後に何をすべきかについて
対象読者
このエントリは下記の読者を想定しています。
- TypeScript でコードを記述することができる
- Angular 2.x を触ったことがある
- Angularの実案件適用を考えているが、性能に懸念を感じている
「Angular 2って何〜?」や「Angularでどうやってアプリ作ればいいの?」といった説明はしません。TodoMVCとか見たらいいんじゃないかな(適当)。
Disclaimer
この記事では各種ツール、ライブラリは以下のバージョンのものを用いています(2016年10月現在最新版)。
特にwebpackについては安定版ではない2.xを選択していますので、留意してください。
- NodeJS: 6.7.0
- TypeScript: 2.0.3
- webpack: 2.1-beta.25
- Angular本体(@angular/coreなど): 2.0.1
- @angular/router: 3.0.1
- @angular/compiler-cli: 0.6.3
- Webブラウザ(Google Chrome): バージョン53
またAngular 2.0.0がリリースされたとはいえ、 AoTや付随するAngularのツールはまだ発展途上という感想を持っています。このため、ビルド設定の書き方等はすぐに陳腐化するかもしれません。
「陳腐化したらそのときに新しい方法を学び直せばよいじゃない」くらいの気持ちで向き合うと良いかと。
基本編
タイトルにも含めている通り、この投稿では「AoT」というタームを頻繁に用います。
また対義語に「JiT」を用います。まずはこれらの意味について。
- JiT(Just in Time) Compile: Webブラウザでの実行時にAngularの Module、Componentをコンパイルすること
- AoT(Ahead of Time) Compile: ビルド時にAngularのModule、Componentをコンパイルしておくこと
「AngularのComponentをコンパイルする」というのはどういうことでしょうか。
例として次のComponentに対してAngularが何を行うかをみていきます。
@Component({
selector: "my-app",
template: "<h1>{{greeting}}</h1>"
})
export class MyAppComponent {
greeting = "Heelo, world!";
}
上記のテンプレートはAngularの内側では下記のようなコードにコンパイルされて実行されます。
createInternal(rootSelector:string):import2.AppElement {
const parentRenderNode:any = this.renderer.createViewRoot(this.declarationAppElement.nativeElement);
/* A. DOM要素の生成 */
this._el_0 = this.renderer.createElement(parentRenderNode,'h1',(null as any));
this._text_1 = this.renderer.createText(this._el_0,'',(null as any));
this._expr_0 = import7.UNINITIALIZED;
this.init([],[
this._el_0,
this._text_1
]
,[],[]);
return (null as any);
}
detectChangesInternal(throwOnChange:boolean):void {
this.detectContentChildrenChanges(throwOnChange);
/* B. 内挿の更新 */
const currVal_0:any = import4.interpolate(1,'',this.context.greeting,'');
if (import4.checkBinding(throwOnChange,this._expr_0,currVal_0)) {
this.renderer.setText(this._text_1,currVal_0);
this._expr_0 = currVal_0;
}
this.detectViewChildrenChanges(throwOnChange);
}
- A: Rendererを利用したDOM生成コード
- B: 変更が検知された際に内挿している文字列を更新する処理
が見てとれますね。
このようなコードが、JiTの場合は実行時に関数として、AoTの場合はbuild時に静的に生成されるのです。
AngularJS 1.xにおける2フェーズコンパイルをご存知の方は「Compile」と「Link」というキーワードを思い出してください。
Compileフェーズの役割はLink関数を生成してフレームワークに伝えることでした。
この流れ自体はAngular 2.xでも同様です。上記の出力例はLink関数のようなものです。
このようにAngularでは、HTMLテンプレートを事前にコンパイルして最適化されたDOM操作コード等を生成しておくことで、変更検知時のコストを抑えるようになっています。
汝の敵を知れ
さて、問題となるのは「テンプレートのコンパイル時間が馬鹿にならない」という事実です。
無視できる程度のオーダーであれば良いのですが、現実はそんなに甘くないです。
他のライブラリで例えると、React JSXのトランスパイルを実行時に毎回行っているような状態ですもの。
Angularのアプリに関わらず、パフォーマンス改善についてはまず定量的に測定する、が鉄則です。測定してみましょう。
以下の図は、僕が実案件でAoT対応をやろう、と決心したときに取得したキャプチャです。
Chromeの開発者ツールでイニシャルロード時のCPU Profileを取得しています。
見てのとおり、compile
というメソッドで1000 msec以上消費しています。内訳のほぼ全てが RuntimeCompiler._compileTemplate
というメソッド名であることからも分かる通り、これがJiTを利用している場合のオーバーヘッドです。ちなみに、この時点でのアプリケーションにおけるComponent数は60~70個程度です。もの凄く多い、という数ではないです。
JiTからAoTに切り替えるということは、このオーバーヘッドをまるっと削減できると言う意味です。
Shaking up baby
「静的にコードを出力するということはその分bundleのサイズが増大するんじゃないの?」と思うかもしれませんね。
というか、僕も最初はそう思っていました。
ところがそうでもないのです。
何故かというと、コンパイルを事前に済ませるということはAngularがコンパイルのために必要としている機能(具体的には@angular/compiler)自体が不要となるからです。
@angular/compilerはminifyしても300KBを越えます。
すなわち、"@angular/compilerのサイズ" > "出力されたコンパイル済みコード" となるケースも多いのです。
もちろん、Component数やテンプレートのボリュームが増えればこの関係は逆転するので、一律にどうという話ではないですが。
もう一点、AoT特有のbundle削減効果があります。それがTree Shakingです(Tree Shakingについてはこの記事では解説しません。ググれば色々出てくると思いますし)。
AoTでは、@angular/compiler-cliに含まれる ngc
コマンドを用いて、先程例示したようなTypeScriptソースコード(.ngfactory.ts)をHTMLテンプレートから生成します。
このソースコードは自身の描画に必要な関数のみをimportするように最適化されています。
このため、bundle作成時にTree Shakingを行うことで本当に必要としているclass, 関数だけを選択してbundleに含めることができます。
実行時まで何が必要か分からないJiT環境では、「必要になるかもしれない機能」すべてをbundleに含めなくてはならないため、Tree Shakingの旨味は味わえません。
You're so lucky
ここまで「イニシャルロードの性能改善」という側面でAoTを語ってきましたが、AoTによって得られるメリットは他にもあります。
テンプレートの静的チェック
上述したように、ngcコマンドを利用するとHTMLテンプレートから.ngfactory.tsを出力します。bundleを作成する際にはこの.ngfactory.tsをJavaScriptにトランスパイルし、モジュールバンドラでbundleファイルを作成する、というビルドフローが発生します。
言い換えると、ビルドフローの中でHTMLテンプレート(に相当するTypeScript)に対して型がチェックされるという意味になります。
従来のAngularJS 1.xでは難しかったテンプレートの静的検証が行えるため、より堅牢なビルドフローを手に入れることができます。
また、HTMLテンプレートのValidationを目的としただけの単体テストコードを書く必要も無くなります。
セキュリティ上の利点
AoT環境では@angular/compilerがbundleに含まれません。
これは、実行時にHTMLからAngularのComponentを生成する手段を失うという意味ですが、裏を返すと悪意を持った第3者が貴方のアプリケーションを破壊する方法が1つ減ったと捉えることもできます。
もちろん、これだけでXSS対策完璧!ということは全く無いのですが、Angularのruntime_compilerが実行時に存在しないということはその分攻撃のポイントを減らせるのは事実です。
実践編
Weapon of choice?
基本編で述べたようにAoTにおいてbundle.jsのサイズが削減される、というのはTree Shakingに対応したモジュールバンドラを前提としています。
Tree Shakingに対応しているモジュールバンドラには、2016年10月現在 webpack(2.x)とRollupが2大候補でしょう(他にも対応しているツールがあったら教えてください)
webpack
v2.xからはTree Shakingがデフォルトで利用可能に。angular-cli等、公式でも採用されています。ただ、「何でも出来てしまう」ツールなので、使い方を間違うとwebpackと心中する羽目になりそう。
Rollup
ES2015に最適化されたTree Shakingは魅力。webpack 2.xよりもbundleのサイズを削れるとの報告も(僕自身は未検証です)。
全ての依存モジュールをES Modulesの形式に変換しないとbunldeが作成できないという特性上、導入ハードルはwebpackよりも高く玄人向けな印象を持っています。
あと、AngularのモジュールバンドラにGoogle Closure Compilerを適用する、というプランもあるようです1。とても興味深いテーマですが、この投稿では取り扱いません、というか取り扱えるほど中身を知りません。。。
このエントリではモジュールバンドラにwebpack 2.xを用いて以降の話を進めていきます。
これがこうなってこうじゃ
環境構築が面倒な方は angular-cli を利用すると良いでしょう。
ng build --aot
というコマンドを実行するとAoT対応したbundle.jsが生成されます。
ただ、現状では angular-cliの ng new
コマンドで生成したプロジェクトには webpack.config.jsが含まれません。
何故かはともかく、自分でbuildの設定が変更しにくいのは事実ですので、僕はangular-cliには頼らずに自分で環境を構築しています。
angular-cliの場合は、「ここに.tsがあるじゃろ?」→「これがこうなってこうじゃ!」的な感じでAoTに対応したbundleが出来上がるので良いのですが、自分でやるとなると地味に大変です。
開発環境bundleの作成
ローカルでの開発時はAoTは利用していません。なぜならngcの実行が遅いから。
ちなみにngcがどれくらい時間がかかるかというと、僕のローカル環境では、およそ100程度のComponentに対して約30秒かかります。
CIで利用しているwerckerの環境でも同程度の時間を要しています。さすがにwatchに組み込めるレベルではない...
ゆくゆく、ngcが爆速になったらローカル環境でもAoTにしたいですけどね。
さて、そんな訳でローカルではJiT構成のbundleを生成するようにbuildを組んでいます。buildフローは下記のような感じ。
- gulpで
src/**/*.component.css
をbuilt/**/*.component.css
にpostcssで変換 - gulpで
src/**/*.component.html
をbuilt/**/*.component.html
へコピー - tscで
src/**/*.ts
をbuilt/**/*.js
へ変換 - webpack + babel-loader, angular2-template-loaderでbundleを作成
ディレクトリ構成で書くと、下記のイメージです。
|-- dist
| `-- budnle.js
|
|-- src/ [ソースコード置き場]
| |-- main.ts
| |-- module.ts
| |-- my-app.component.ts
| |-- my-app.component.html
| `-- my-app.component.css (postcss前)
|
|-- built/ [created]
| |-- main.js (webpackのエントリポイント)
| |-- module.js
| |-- my-app.component.js
| |-- my-app.component.html
| `-- my-app.component.css (postcss後)
|
|-- .babelrc
|-- tsconfig.json
|-- webpack.config.dev.json
`-- package.json
実際にプロジェクトで利用しているものから簡略化していますが、webpack.configはおよそ下記のようになっています。
const path = require("path");
module.exports = {
module: {
loaders: [
{test: /\.js$/, loader: ["babel-loader", "angular2-template-loader"], exclude: /node_modules/},
{test: /\.component\.html$/, loader: ["raw-loader"]},
{test: /\.componrnt\.css$/, loader: ["raw-laoder"]},
]
},
output: {
path: path.join(__dirname, "./dist"),
filename: "[name].js"
},
entry: {
"bundle": path.resolve(__dirname, "./built/main.js"),
},
};
上記で登場しているangular2-template-loaderについてです。このloaderは下記のようなAngular ComponentにおけるtemplateUrl
とstyleUrls
に反応します。
@Component({
selector: "my-app",
templateUrl: "./my-app.component.html",
styleUrls: ["./my-app.component.css"],
})
export class MyAppComponent {
}
上記のコードはangular2-template-loaderによって以下のように変換されます。
@Component({
selector: "my-app",
template: require("./my-app.component.html"),
styles: [require("./my-app.component.css")],
})
export class MyAppComponent {
}
これをraw-loaderに通すことで、.htmlや.cssがwebpackによりbundleされる仕組みです。
何故ソースコードに直接requireを記述せずにangular2-template-loaderを使うのかって?これは下記2つの理由があります。
- AoTにおいて利用するngcがrequireに対応しておらず、
templateUrl
,styleUrls
の記法でファイルパスを指定する必要がある - そもそもHTMLやCSSファイルはJavaScript(TypeScript)から直接require/importするようなものではない
AoTに向き合う上で重要なのは、1.の方ですが、AoTを利用せずともコードからwebpack依存を減らせるので、積極的に利用していきましょう。
もう一点補足. なぜpostcss-loaderを使わずに、わざわざgulpでpostcss変換をかけたか、についてです。
これはwebpack2.xとpostcss(正確にはpostcss-importというpostcssで@import
を利用するためのplugin)を組み合わせた結果、buildが盛大に壊れるというwebpack 2.xのbugを踏んだためです。
また、AoTに向き合う上で、ngcを実行する前にCSSのpre-processは実施しておく必要が生じたので、結局webpackのloaderは極力少なくする方向に持っていきました。gulpとwebpackが混ざっているのは賛否ありそうですけどね。
リリース用bundleの作成
さて、いよいよAoT用のbuildです。ngcが登場する分、複雑化します。
- ngcの準備フェーズ
- gulpで
src/**/*component.css
をsrc_aot/**/*.component.css
にpostcssで変換 - gulpで
src/**/*.component.html
をsrc_aot/**/*.component.html
へコピー - gulpで
src/**/*.ts
をsrc_aot/**/*.ts
へコピー - AoT用のエンドポイントファイルを
src_aot/main_aot.ts
として配置 - ngcの実行
-
ngc -p tsconfig.aot.json
を実行。src_aot/
配下に*.ngfactory.ts
が生成される - バンドルの作成
- tscで
src_aot/**/*.ts
をbuilt/**/*.js
へ変換 - webpack + babel-loaderでbundleを作成
ディレクトリ構成は下記のようになります。
|-- dist
| `-- budnle.js
|
|-- src/ [ソースコード置き場]
| |-- main.ts
| |-- my-module.ts
| |-- my-app.component.ts
| |-- my-app.component.html
| `-- my-app.component.css (postcss前)
|
|-- src_aot/ [ngcを実行する用の一時領域]
| |-- main_aot.ts
| |-- my-module.ts
| |-- my-module.ngfactory.ts (ngcにより生成される)
| |-- my-app.component.ts
| |-- my-app.component.ngfactory.ts (ngcにより生成される)
| |-- my-app.component.html
| |-- my-app.component.css (postcss後)
| `-- my-app.component.css.shim.ts (ngcにより生成される)
|
|-- built/ [tscで出力する]
| |-- main_aot.js (webpackのエントリポイント)
| |-- my-module.js
| |-- my-module.ngfactory.js
| |-- my-app.component.js
| |-- my-app.component.ngfactory.js
| `-- my-app.component.css.shim.js
|
|-- .babelrc
|-- tsconfig.json
|-- tsconfig_aot.json
|-- webpack.config.dev.json
|-- webpack.config.prod.json
`-- package.json
AoTでは、webpackのエントリポイントとなるbootstarpコードの記述がJiTの場合と異なる点に注意してください。
import 'core-js/es6';
import 'core-js/es7/reflect';
import 'zone.js/dist/zone';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { MyModule } from "./my-module";
platformBrowserDynamic().bootstrapModule(MyModule);
import 'core-js/es6';
import 'core-js/es7/reflect';
import 'zone.js/dist/zone';
import { platformBrowser } from '@angular/platform-browser';
import { MyModuleNgFactory } from "./my-module.ngfactory";
/* AoTではbootstrapの呼び出し方がJiTと異なる */
platformBrowser().bootstrapModuleFactory(MyModuleNgFactory);
ngcを実行するためにはtsconfig.jsonに幾つかの設定が必要です。angularCompilerOptions
というのがngc用のオプションとなります。genDir
が.ngfactory.tsの出力先ですね。それ以外のオプションはTypeScriptで利用するconfigと一緒。rootDirs
を駆使すれば、もっとディレクトリ構成はシンプルにできそう。
{
"compilerOptions": {
"module": "es2015",
"target": "es2015",
"noImplicitAny": true,
"sourceMap": false,
"outDir": ".built",
"rootDir": "src_aot",
"moduleResolution": "node",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"types": ["node"]
},
"exclude": ["node_modules", "bundle", "dist", "src"],
"angularCompilerOptions": {
"genDir": ".",
"debug": false
}
}
続いてAoT用のwebpack設定です。勿論webpack-mergeを使って共通化することは可能なのですが、説明が面倒なので全部書き出しました。
const path = require("path");
const webpack = require("webpack");
module.exports = {
module: {
loaders: [
{test: /\.js$/, loader: ["babel-loader"], exclude: /node_modules/},
]
},
output: {
path: path.join(__dirname, "./dist"),
filename: "[name].js"
},
entry: {
"bundle": path.resolve(__dirname, "./built/main.js"),
},
plugins: [
new webpack.LoaderOptionsPlugin({
minimize: true,
debug: false
}),
new webpack.optimize.UglifyJsPlugin({
compress: {
warnings: false
},
output: {
comments: false
},
sourceMap: true
})
],
devtool: "sourcemap",
};
ngcでHTML, CSSが全て.ts化された分、loaderの設定がローカル用よりすっきりします。sytleUrls, templaUrlもngc時点で解決されるため、angular2-tempalte-loaderも不要です。
なお、webpack 2.xはimport文で記述されている分にはTree Shakingを頑張るが、require関数の場合はファイル毎bundleするだけという戦略でbundleを作成していきます。
このため、TypeScript/Babelともにimport文をrequireに変換しないように注意しなくてはなりません。
具体的には、tsconfig_aot.jsonにおける"module: "es2015"
と、下記の.babelrcにおける {"modules": false}
が該当します。
{
"presets": [
["es2015", {"modules": false}]
]
}
こいつ、、動k...動かなかった orz
一気に完成系のbuild設定を書いてきた訳ですが、僕が実際にAoT対応したときは、ngcが正常終了せずにメソメソ泣いていた時間が一番長かったりします。
本当に辛いのはAoT環境が出来た後に待っていたのですが、それはさておき、実践編のハイライトは「いかにngcが怒らないソースコードを書くか」です。
正直、網羅的に「こういうケースはngcがNG」を把握している訳ではないので、トライアル&エラーでしかないのですが、ngcエラーと対処法を紹介していきましょう。
文字列だと思うな TypeScriptと心得よ
```html:my-app.component.html`
@Component({
selector: "my-app",
templateUrl: "./my-app.component.html"
})
export class MyAppComponent {
}
このコードはJiT環境では動作しますが、ngc実行時に以下のようなエラーが出力されます。
Property 'value' does not exist on type 'MyAppComponent'.
そうです。テンプレートのHTMLでvalue
というMyAppComponent classのpublicなフィールドを参照しているにも関わらず、MyAppComponentにこのフィールドが定義がされていないのが原因です。正しくは下記ですね。
@Component({
selector: "my-app",
templateUrl: "./my-app.component.html"
})
export class MyAppComponent {
/* ちゃんと変数宣言する */
value: string;
}
一見するとウザいですが、テンプレートHTMLでtypoして{{valeu}
と書いていたとしても同じエラーが出力されます。
Property名レベルでのチェックを動作前にしてくれている、と捉えましょう。
遭遇したら「ありがとうngc、エラーを教えてくれて」と心の中で感謝すべきです。洗脳されてるとか言わないでください。
似たようなケースに以下のようにPipeを宣言しておき、
@Pipe({name: 'myPipe'})
export class MyPipe implements PipeTransform {
transform(value: number, args: string[]) {
/* transformの処理 */
}
}
テンプレート中にて {{value | myPipe}}
のように引数無しの呼び出しコードがあるとやはりngcはエラーを出力します。引数無しで呼び出したければ、下記のように修正が必要です。
@Pipe({name: 'myPipe'})
export class MyPipe implements PipeTransform {
transform(value: number, args?: string[]) { /* argsは省略可能であることを明示する */
/* transformの処理 */
}
}
さらにもう一つ。Template Drive FormでNgModelが保持しているerror情報を参照する場合、下記のようなコードを書きます。これはJiTでは正常に動作します。
<form #form="ngForm">
<input #v="ngModel" required name="myName" ngModel></input>
<span *ngIf="v.errors?.required">Required!</span>
</form>
やはりAoTではダメです。 *ngIf="v.errors?.required"
の部分がNGです。NgModelの継承元であるAbstractControlDirectiveにerrorsの定義が記載されています。
export declare abstract class AbstractControlDirective {
errors: {
[key: string]: any;
};
/* 中略 */
}
TypeScriptとしては、v.errors.required
ではなく、 v.errors['required']
が正しいですね。従って下記のようにする必要があります。
<form #form="ngForm">
<input #v="ngModel" required name="myName" ngModel></input>
<span *ngIf="v.errors && v.errors['required']">Required!</span>
</form>
テンプレートから直接呼び出されているようには見えないけれど、同系統の罠に該当するパターンをもう1つ紹介します。これも上述のPipeと同じく、メソッドシグネチャ間違いです。
export class MyAppComponent implements OnChanges {
ngOnChanges() {
/* 変更時の処理 */
}
}
.ngfactoryが内部的にngOnChanges
メソッドを呼び出す際に、下記で表現される SimpleChanges
を引数にセットします。
export interface SimpleChanges {
[propName: string]: SimpleChange;
}
したがって、引数無しの ngOnChanges
を定義してしまうと型不整合が起こり、エラー扱いとなります。
ここで紹介したエラーはいずれもテンプレートがTypeScriptのコードに変換された分、雑な書き方が許されなくなっているという系統のエラーです。
.htmlファイルだからと言って、只の文字列だと思わずにTypeScriptのコードを書くつもりで記述しましょう。
tslintくらいの気軽さでngcを実行できるようになれば、保存した瞬間にエディタにエラーを即反映する、という風に開発できるようになるかもしれませんね。
動と静
次はTypeScript的にはvalidだけど、metadata(@Component
や@Module
の中身部分)を静的解析する都合上、ngcが激おこするタイプのエラーです。
これらのパターンは大概、Decoratorsの中に動的な処理を書くと発生します。
例を提示しましょう。
そんな気持ち悪いことやるかどうかは分かんないですけど、template
の事前処理的にtagged functionを使ったとしましょう。
function myTag(templ: TemplateStringsArray, ...args: string[]) {
/* ゴニョゴニョ */
}
@Component({
selector: 'my-app',
template: myTag `
<div>${hoge}</div>
`,
})
export class MainAppComponent {
}
もちろん怒られます。Angularと関係のないところで、動的にテンプレートが生成されていたら、それを静的解析で何とかしろという方が無理がありますよね。
Error encountered resolving symbol values statically. Expression form not supported
@Component
の中で全く関数の呼び出しが行えないかと言うと、そんなことはありません。
例えば、Componentにアニメーションをしかける際には、下記のように何種類かの関数を呼び出しますが、これらがエラーになる訳ではありません。
animations: [
trigger("shrinkOut", [
state("in", style({height: "*"})),
transition("* => void", [/* ... */])
])
]
また、アロー関数(または匿名関数)を記述してngcが「もう無理!」っていうケースも存在します。
@NgModule
や@Component
でファクトリ関数をproviderとしてセットしようとする場合、{ provide: Foo, useFactory: () => { ... } }
と書けるのですが、これはngcがエラー吐きます。 { provide: Foo, useFactory: setupFoo }
のようにファクトリ関数に名前を付けてあげると通るようになります。
routerのloadChildrenでも同様の現象が発生します。遅延ローディングをする際の
{ path: "/sub", loadChildren: () => require("es6-promise!../sub/sub-module.ngfactory")("SubModule") }
というコードもやはり { path: "/sub", loadChildren: loadSubModuleFn }
のように名前のついた関数にするとngcのコンパイルが通るようになります。
この小節で紹介したエラーはTypeScript的には全然validな奴等ばかりです。どちらかというと、静的解析というngcの気持ちに寄りそって「さすがにこれは無理か...」と優しい目で見てあげるしかない。
発展編
俺たちの戦いはこれからだ
さて、ようやくAoTが稼働するようになりました。貴方のアプリケーションも速度の違いが体感できるレベルで初期描画が高速化されたことでしょう!
これでやったね!とはならないのが辛いところです。
Tree Shakingまでしたのに、bundleのサイズを見てみるとgzip前でまだ数MB程度規模だったりするのではないでしょうか? きっとこう思うはずです。
「あれ、Tree Shaking出来ているはずなのに何でこんなにデカいんだ?」
汝の敵を知れ(再)
基本編では「JavaScriptの実行時間」にフォーカスして測定を行いました。発展編では「JavaScriptのサイズ」にフォーカスした戦いが待っています。
対策を練るためには、どこにサイズを消費している奴がいるかを知ることが大事です。コストパフォーマンス重要!
ということで、bundleのサイズの内訳を可視化しましょう。
source-map-explorerというツールを用います。
ざっくり説明すると、.js.mapファイルを元に、元々のファイルがminify後のbundleでどの程度のボリュームを占有してくれるかを可視化するツールです。
下の図は、僕がAoTとLazy Loadingのサンプル用に作成したbundleに対して、source-map-explorerをかけた際のキャプチャです。このアプリはcomponent数も2,3個程度のほぼHello worldに毛が生えた程度、要するにAngularの最小構成アプリと言ってよいレベルです(ちなみに、こいつですらgzip前で400KB程度の容量があります...)。
Tree Shakingが上手くいっていれば、大体似たような見た目のmapが得られるはず。
もし、手元で出力した図に@angular/compiler
とか runtime_compiler
が見えていたら、そもそもTree Shakingが出来ていないかwebpackのエントリポイントの指定が間違っている、といったレベルでbuildを正しく作れていない可能性が高いです。見直しましょう。
次の図は、僕のやっている実プロジェクトのbundleに対してsource-map-explorerをかけた際のキャプチャです。さすがに業務でつかってるやつなので、モザイクかけさせて貰っています。
正直、公開するのもどうかっつーレベルで恥ずかしいわけですが、綺麗な奴見せても面白くもなんとも無いでしょうので、恥を偲んでさらしてみました。左側を見てもらうと、誰がbundleのサイズ食っているのか一目瞭然ですね。
- highlight.js
- moment
確かにコードのハイライトを実施している部分があるので、highlight.jsを導入した覚えはあるんですけど、JavaScriptやHTML, CSSがハイライトされれば十分な要件なんですよ。
それにも関わらず、mathematicaとか読み込んじゃってる。そりゃ無駄にbundleがデカくなるわ。momentもlocaleが全部入ってしまっています。jaだけにしたつもりが対応できていなかった模様 orz。
とまぁ、こんな具合に「どこを真っ先に潰すべきか」を教えてくれるわけです。倒すべき相手が見つかったら、webpack-visualizerのようなモジュール依存関係を可視化するツールを用いて、不要なファイルを取り込まないようにしていきます。
CommonJS or ES Modules ?
実はsource-map-explorerでの測定をする前に、bundleの削減に手を出していた部分もあります。今にして思うと、測定を行わずに作業をしてしまったために得られた効果は薄かったのですが、知見が得られたという点では意味があったと思っています。
僕のプロジェクトではlodashを利用しているのですが、先述した用にwebpack 2.xはrequire
で書かれているコードはTree Shakingではなく1.xと同じようにファイルごとbundleに含めます。
npm i lodash
でインストールするとCommonJS版がダウンロードされるため、Tree Shakingはされません。npm i lodash-es
とすると、require
, module.exports=
の代わりに import
, export
で記述された版のlodashが手に入ります。 babelrcを "presets": [ ["es2015", {"modules": false}] ]
にして変換かけたようなイメージ、と言い換えてもよいかも。
ですので、webpackのaliasを使ってlodashの向き先をlodash-esに変更し、
resolve: {
alias: {
"lodash": "lodash-es"
}
},
ソースコード上では下記のように利用します。なお、このコードをtscで正常にコンパイルするためには--allowSyntheticDefaultImports
オプションが必要でした2
import cloneDeep from "lodash/cloneDeep";
何が言いたかったかというと、外部ライブラリをES Modulesとして扱ってTree Shakingするのは結構面倒臭いということです。ライブラリ毎にES Modules版を提供している/していないを調査する必要があり、さらにその提供形態もバラバラであるためです。
例えば、Angularではほぼ確実に読み込むことであろうrxjsについてはlodashと同じようにrxjs-esというNPMパッケージが存在します。しかし、その中身はlodash-esの場合と異なり、無変換のES2015 JavaScriptです。コード中に let
とか普通に出てきますからトランスパイルしないと利用できなかったりします。
また、moment.jsの場合ですとnpm i moment
で取得されるpackage.json に "jsnext:main": "./src/moment.js"
と記述されていて「Tree Shakingしたい奴はこっちを見に行けよ」と言っている状況です。ちなみに、このjsnext:main
というキーは下記の意味をもつとのこと。Rollupのwikiを引用します。
jsnext:main will point to a module that has ES2015 module syntax but otherwise only syntax features that node supports.
こんな具合に依存モジュール毎に対応方法を変えていかねばなりません。
2016年10月現在だと、NPMエコシステムでES Modulesをどう扱うかも定まっているわけではないようですし3、推移的な依存関係まで含めてどうこう出来る気がしていません。ある程度はファイル毎バンドルされるのを許容して割り切るのが現実解かと思っています。
おわりに
この投稿ではAngularのAoTをテーマに下記を述べてきました。如何でしたでしょうか?
- どのような効果が得られるのか
- どのように導入したら良いのか
- 導入した後はどうすれば良いのか
AoT導入の苦労話を色々書いてしまったため、大変そうな印象を与えてしまったかもしれませんが、得られるメリットはとても大きいと考えています。またAoT用の環境を作るのに要した時間は、実質2日もかかっていません。
しかし、JiT環境では動作していたコードがAoTでは動作しなくなるという現実がある以上、コードベースが小さい内に対応しておいた方が良いでしょう4。
支払う大変さ以上にリターンが大きいですので、是非プロダクションでのAngularの運用を考えている方は検討・実践してみてください。
このガイドが少しでも何かの役に立てば幸いです。それでは、また。
謝辞
最後になりますが、この記事を書くにあたってというか、この内容が書けるようになったのも色々な人に助けてもらえたからこそです。
特に @laco0416 には各種ツールの使い方やAngular開発環境の最新事情等、色々と助言してもらいました。
あまり資料が無いうちからAoTに体当たりしてはコンソールをコンパイルエラーで埋め尽くしながら奮闘してくれた同僚達にも感謝しています。
また、この記事を書こうと思った契機は ng-japanのslack でAoT関連発言の活性化を受けて、という所が大きいです。ハマりポイント等は、slackの会話で聞いた内容も含んでいます。
この記事では触れていない(僕が知らない)ハマりポイントもまだまだ埋まっているかもしれません。何か新しく見つけたらslackでもこの記事へのコメントでも構いませんので、是非共有頂ければと思います。