先日公開されたAngular2 rc.6から、RouterのLazy Module Loading機能が強化されています。
AngularJS 1.xでは、oc-lazy-loadなどの3rd Party製の仕組みはあったものの、公式なLazy Load機能は用意されていなかったため、個人的には期待度の高い機能でした。
そこで今日はwebpack環境でのAngular2 のLazy Loadingについて書いてみようと思います。
ちまちま書いたので若干長くなってしまったので、先にレポジトリのURL貼っておきます。
なお、上記のレポジトリではwebpackは1.xではなく、2.x(2016.09.03現在beta版)を利用しています。AoTを使わないのであれば、1.xでも問題ありません。
Lazy Loadingって?
webpackに限らず何らかのモジュールバンドラを利用している場合、アプリケーションの機能が増えるのに伴って、bundleするJavaScriptコードのサイズも増大していきます。
Routingにおいて、とあるパス(ここでは"/sub/*"
としましょう)配下の機能は、必ずしもユーザー全員が利用する訳では無いケースを考えてみます。
このようなケースにおいては、/sub
配下の機能に相当する.jsは、ユーザーが必要としたタイミングで、すなわち /sub
へのルーティングが発生したタイミングで取得すれば十分です。
このように、機能(の一部)を必要に応じて後から取得する方法をLazy Loading(遅延ロード)と呼びます。
Step 1 nested routing
まずはAngular2のRouterモジュールのおさらいを兼ねて、/sub
の下にルーティングをネストさせる例から見ていきましょう。
import { Routes, RouterModule } from "@angular/router";
import { MainHomeComponent } from "./main-home.component";
import { MainAboutComponent } from "./main-about.component";
import { SubAppComponent } from "../sub/sub-app.component";
import { SubHomeComponent } from "../sub/sub-home.component";
export const appRoutes: Routes = [
{path: "", component: MainHomeComponent},
{path: "about", component: MainAboutComponent },
{
path: "sub",
component: SubAppComponent,
children: [
{path: "", component: SubHomeComponent}
]
}
];
export const routing = RouterModule.forRoot(appRoutes, { useHash: true});
appRoutes
の定義を読めば明白ですが、上記のroutingは下記のようにパスとComponentをマッピングします。
-
/
: MainHomeComponent -
/about
: MainAboutComponent -
/sub
: SubAppComponent (<router-outlet>
が書いてある) -
/
: SubHomeComponent
このルーティングは、下記のMainModuleにimportして使います。
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';
import { MainAppComponent } from './main-app.component';
import { routing } from "./main.routing";
import { MainHomeComponent } from "./main-home.component";
import { MainAboutComponent } from "./main-about.component";
import { SubHomeComponent } from "../sub/sub-home.component";
import { SubAppComponent } from "../sub/sub-app.component";
@NgModule({
imports: [
BrowserModule,
routing,
],
declarations: [
MainAppComponent,
MainHomeComponent,
MainAboutComponent,
SubAppComponent,
SubHomeComponent,
],
bootstrap: [ MainAppComponent ],
})
export class MainModule { }
上記コード中で登場している MainAppComponent は、そのテンプレート中に<router-outlet>
が記述されていると思ってください。
そして、MainModule を bootstrapModule に食わせれば起動できます。
import 'ts-helpers';
import 'core-js/es6';
import 'core-js/es7/reflect';
import 'zone.js/dist/zone';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { MainModule } from "./main/main.module";
platformBrowserDynamic().bootstrapModule(MainModule);
なお、ディレクトリ構造は下記を想定しています。
|-- src
| |-- index.ts
| |-- main
| | |-- main-about.component.ts
| | |-- main-app.component.ts
| | |-- main-home.component.ts
| | |-- main.module.ts
| | `-- main.routing.ts
| `-- sub
| |-- sub-app.component.ts
| `-- sub-home.component.ts
|
|-- package.json
|-- tsconfig.json
`-- webpack.config.js
webpackの設定は下記の通りです(awesome-typescript-loaderを使ってますが、ts-loaderでも構いません)。
const webpack = require("webpack");
const path = require("path");
const ForkCheckerPlugin = require('awesome-typescript-loader').ForkCheckerPlugin;
module.exports = {
resolve: {
extensions: ["", ".ts", ".js"]
},
module: {
loaders: [
{
test: /\.ts$/,
loader: ["awesome-typescript-loader"],
exclude: /node_modules/
}
],
noParse: [
path.join(__dirname, "node_modules/zone.js/dist"),
]
},
entry: {
bundle: "./src/index",
},
output: {
path: path.resolve(__dirname, "dist"),
filename: "[name].js",
},
plugins: [
new ForkCheckerPlugin()
]
};
Step 2 lazy module loading
Step 1の状態は、Routingのネストは実現できていますが、bundleは単一のままです。
/sub
を利用しないユーザーに対しても、SubHomeComponentやSubAppComponentが含まれたbundle.jsが配布されている状態です。
ここから、/sub
配下の機能をbundle.jsから切り離し、遅延ロードで取得するようにしていきます。
Step 2-1 separete sub module and using loadChildren
Angular2 Routerでは遅延ロード対象は別途Moduleに切り出す必要があります。
まずは/sub
配下のrouting定義を別ファイルに切り出します。
import { Routes, RouterModule } from "@angular/router";
import { SubAppComponent } from "./sub-app.component";
import { SubHomeComponent } from "./sub-home.component";
export const subRoutes: Routes = [
{
path: "",
component: SubAppComponent,
children: [
{ path: "", component: SubHomeComponent }
]
},
];
export const subRouting = RouterModule.forChild(subRoutes);
続いて、上記のsubRoutingをimportしたmoduleを作成します。
import { NgModule } from "@angular/core";
import { subRouting } from "./sub.routing";
import { SubAppComponent } from "./sub-app.component";
import { SubHomeComponent } from "./sub-home.component";
@NgModule({
imports: [
subRouting,
],
declarations: [
SubAppComponent,
SubHomeComponent,
],
})
export class SubModule {
}
元々のmain routingの定義を書き換えます。
import { NgModuleFactoryLoader } from "@angular/core";
import { Routes, RouterModule } from "@angular/router";
import { MainHomeComponent } from "../main/main-home.component";
import { MainAboutComponent } from "../main/main-about.component";
import { SubModule } from "../sub/sub.module";
function loadSubModule(): any {
return SubModule;
}
export const appRoutes: Routes = [
{path: "", component: MainHomeComponent},
{path: "about", component: MainAboutComponent },
{path: "sub", loadChildren: loadSubModule},
];
export const routing = RouterModule.forRoot(appRoutes, { useHash: true});
{path: "sub", loadChildren: loadSubModule},
の部分が、「/sub
配下はSubModuleの中身に従ってね」という意味になります。
最後にsrc/main/main.module.ts
の declarationsから SubAppComponentとSubHomeComponentを除外しておきます。
これで、ソースコード上、Main -> Subの依存は main.routingに記載したSubModuleを介した状態になりました。
Step 2-2 asynchronous loading with ES6 promise loader
まだ遅延ロードは実現できていません。
SubModuleを切り出したものの、main.routing.tsには依然 import { SubModule }
が記載されているため、webpackがbundle作成時にSubModuleも含めてしまいます。
ところで、loadChildrenの実体である loadSubModule 関数はModuleをresolveするPromiseにしても動作します。
非同期Module読み込みがサポートされていないと遅延ロードなんて実現できませんよね。
すなわち、下記のように書くことが出来るわけです。
function loadSubModule(): any {
return Promise.resolve(require("../sub/sub.module")["SubModule"]);
}
このコードでも、requireが残っている以上、webpackにbundleされてしまう訳ですが、es6-promise-loader を使うと「require対象をwebpackの別chunkに切り出して、そのモジュールをresolveするようなPromiseのコードに置き換える」ことができます。
要するに、下記のように loadSubModule関数の中で sub.moduleのrequireにes6-promise-loaderを引っ掛けることができます。
function loadSubModule(): any {
return require("es6-promise!../sub/sub.module")("SubModule");
}
webpack.config.jsは、CommonsChunkPluginを使う時と同様、chunkFilename
とpublicPath
を設定しておきます。
output: {
path: path.resolve(__dirname, "dist"),
publicPath: "http://localhost:3000/dist/",
filename: "[name].js",
chunkFilename: "[id].chunk.js",
},
これで、/sub
へのルーティングが発生したときに初めて、SubModuleに相当するchunk.jsをloadするようにできました。
Lazy Loading の完成です。
Step 3 lazy loading and offline pre-compilation
ここまでで当初の目的は達成できているのですが、折角なのでLazy Loading + AoT(offline compile)を試してみたいと思います。
AoTはAngular2のコンポーネントのテンプレートを静的にcompileしておいて、実行時のオーバヘッドを削減する仕組みです。
また、tree shakingと組み合わせると、ファイルサイズまで大幅に削減できるという機構です。
詳しくは http://blog.mgechev.com/2016/08/14/ahead-of-time-compilation-angular-offline-precompilation/ とかを読んでみてください。「AoTは省エネにも繋がるので地球に優しい」みたいなノリの事が書いてあって、単純に読みものとしても面白いです。
ngc
を使って、コンポーネントやmoduleから、.ngfactory.tsを作成してみましょう。詳細な方法は割愛します。
https://github.com/angular/angular/blob/master/modules/%40angular/compiler-cli/README.md を読んでください。
なお、ngc
を実行する前に loadSubModule 関数に export句を付与しておいてください。これをしないとngc
コマンドがコケます。
export function loadSubModule(): any {
return require("es6-promise!../sub/sub.module")("SubModule");
}
さて、ngcの実行が終わると、src/main/main.module.ngfactory.ts
等、.ngfactory.tsが生成されたかと思います。
生成された.ngfactory.module.tsをbootstrapするように書き換えればAoT対応はお終いです。
import 'ts-helpers';
import 'core-js/es6';
import 'core-js/es7/reflect';
import 'zone.js/dist/zone';
import { platformBrowser } from '@angular/platform-browser';
import { MainModuleNgFactory } from "./main/main.module.ngfactory";
platformBrowser().bootstrapModuleFactory(MainModuleNgFactory);
はい、嘘です。
実行してみれば分かりますが、/sub
配下に対するLazy Loadingが動作しなくなっている筈です。
確かにmain.routingの部分は下記のようになる訳ですが、
// 中略
if ((this.__ROUTES_35 == (null as any))) { (this.__ROUTES_35 = [[
{
path: '',
component: import32.MainHomeComponent
}
,
{
path: 'about',
component: import33.MainAboutComponent
}
,
{
path: 'sub',
loadChildren: import34.loadSubModule
}
// 中略
loadSubModuleの実体は下記のままです。
export function loadSubModule(): any {
return require("es6-promise!../sub/sub.module")("SubModule");
}
実装を覗くとわかるのですが、loadChildren はJiTで動いているときは Moduleを、AoTで動作しているときはModuleFactoryを要求するようになっています。
すなわち、AoTを利用している場合は下記のようにSubModuleNgFactoryを利用する必要があります。
export function loadSubModule(): any {
return require("es6-promise!../sub/sub.module.ngfactory")("SubModuleNgFactory");
}
勿論、上記を手でちまちま書き換えればきちんと動作するようになるのですが、自動生成されたコードを手動で編集するとか愚の骨頂です。
そんなことしなくても良いようにする必要があります。
この問題を解決するために、 angular2-load-children-loader というwebpackのloaderを作成しました。
仕組みは至って単純で下記の書式で文字列で書いた loadChildrenに対して、
{path: "sub", loadChildren: "es6-promise!../sub/sub.module#SubModule" },
JiT/AoT の場合に応じて出力を出し分けているだけです(JiT/AoTのコンテキストは、そのコードが.ngfactory.tsに書いてあるかどうかで判断するようにしています)。
- JiT
{path: "sub", loadChildren: () => require("es6-promise!../sub/sub.module)("SubModule") },
- AoT
{path: "sub", loadChildren: () => require("es6-promise!../sub/sub.module.ngfactory)("SubModuleNgFactory") },
webpackの設定は最終的に下記のようになります。
const webpack = require("webpack");
const path = require("path");
const ForkCheckerPlugin = require('awesome-typescript-loader').ForkCheckerPlugin;
module.exports = {
resolve: {
extensions: ["", ".ts", ".js"]
},
module: {
loaders: [
{
test: /\.ts$/,
loader: ["awesome-typescript-loader", "angular2-load-children-loader"],
exclude: /node_modules/
}
],
noParse: [
path.join(__dirname, "node_modules/zone.js/dist"),
]
},
entry: {
bundle: "./src/index"
},
output: {
path: path.resolve(__dirname, "dist"),
publicPath: "http://localhost:3000/dist/",
filename: "[name].js",
chunkFilename: "[id].chunk.js",
},
plugins: [
new ForkCheckerPlugin()
]
};
RC5のころからも、loadChildrenには文字列が設定できるようになっているため、loadChildren: "../sub/sub.module#SubModule"
はRoutesの定義としても特に問題の無いコードです。
今回の例では、説明を簡単にするために、es6-promise-loaderの呼び出しをソースコード側に直接記入しましたが、.modules.tsにのみ反応するようにloader設定をすることも可能です。
まとめ
今回のエントリでは、Angular2 + WebpackでLazy Loadingをする方法について述べてきましたが如何でしたでしょうか。
AoTの例も取り上げましたが、何も最初からAoTに対応したコードを書くべき、と思っているわけではありません。
が、アプリケーションのスパゲティ化が進んで、bundleのサイズが巨大になってから、後付けでモジュール分割とLazy Loadに対応するのはきっと至難の業だと思います。
今回、敢えてLazy Loadの対応ステップを順序立てて説明したのはStep 2-1を書いておきたかったからです。
すなわち「Lazy Loadの利用可否に限らず、機能のサブモジュール分割しておいた方がいいんじゃね?」って思ってるんです。
Step 2-1は、Lazy Loadではないものの、「sub配下のroutingを別モジュールに切り分けている」という状態です。
設計としてもそんなに悪い状態ではないはずですし、解説してきた通り、2-1からLazy Loadの手順は、routing部分の微修正で済むので、後からでも対応できるレベルだと思います。