最新の Supercharged ライブ配信では、コード分割とルートベースのチャンキングを実装しました。HTTP/2 とネイティブ ES6 モジュールでは、これらの手法は、スクリプト リソースの効率的な読み込みとキャッシュに不可欠になります。
このエピソードのその他のヒントとコツ
asyncFunction().catch()
とerror.stack
: 9:55- モジュールと
<script>
タグのnomodule
属性: 7:30 - ノード 8 の
promisify()
: 17:20
要約
ルートベースのチャンキングによるコード分割を行う方法:
- エントリ ポイントのリストを取得します。
- これらすべてのエントリ ポイントのモジュール依存関係を抽出します。
- すべてのエントリ ポイント間で共有される依存関係を見つけます。
- 共有依存関係をバンドルします。
- エントリ ポイントを書き換えます。
コードの分割とルートベースのチャンクの比較
コード分割とルートベースのチャンキングは密接に関連しており、多くの場合、同じ意味で使用されます。このため、混乱が生じています。この点を明確にしてみましょう。
- コード分割: コード分割とは、コードを複数のバンドルに分割するプロセスです。JavaScript をすべて含む大きなバンドルをクライアントに送信しない場合は、コード分割を行っています。コードを分割する方法の一つとして、ルートベースのチャンキングがあります。
- ルートベースのチャンク処理: ルートベースのチャンク処理では、アプリのルートに関連するバンドルが作成されます。ルートとその依存関係を分析することで、どのモジュールをどのバンドルに含めるかを変更できます。
コード分割を行う理由
緩みのあるモジュール
ネイティブ ES6 モジュールでは、すべての JavaScript モジュールが独自の依存関係をインポートできます。ブラウザがモジュールを受信すると、すべての import
ステートメントが追加の取得をトリガーし、コードの実行に必要なモジュールを取得します。ただし、これらのモジュールにはすべて独自の依存関係があります。リスクは、コードの実行が可能になる前に、ブラウザで取得が複数回連続して繰り返されることです。
バンドル
バンドル(すべてのモジュールを 1 つのバンドルにインライン化する)を使用すると、ブラウザは 1 回のラウンドトリップで必要なすべてのコードを取得し、コードの実行をより迅速に開始できます。ところが、ユーザーは不要なコードを大量にダウンロードせざるを得なくなり、帯域幅と時間が無駄になってしまいます。また、元のモジュールを変更するたびにバンドルが変更され、バンドルのキャッシュに保存されているバージョンが無効になります。ユーザーはすべてを再ダウンロードする必要があります。
コード分割
コード分割は中間的な方法です。Google は、必要なものだけをダウンロードすることでネットワーク効率を高め、バンドルあたりのモジュール数を大幅に減らしてキャッシュ効率を高めるために、ラウンドトリップを追加で投資することをいとわない。バンドルを適切に行うことで、モジュールを個別に配送する場合よりも往復の合計回数が大幅に減ります。最後に、link[rel=preload]
などのプリロード メカニズムを使用して、必要に応じて追加のラウンド トリオ時間を節約できます。
ステップ 1: エントリ ポイントのリストを取得する
これは多くのアプローチの一つにすぎませんが、このエピソードでは、ウェブサイトの sitemap.xml
を解析してウェブサイトへのエントリ ポイントを取得しました。通常、すべてのエントリ ポイントをリストした専用の JSON ファイルが使用されます。
Babel を使用して JavaScript を処理する
Babel は通常、「トランスパイル」に使用されます。最新の JavaScript コードを使用して、より多くのブラウザでコードを実行できるように、古いバージョンの JavaScript に変換します。ここでの最初のステップは、パーサー(Babel は babylon を使用)を使用して新しい JavaScript を解析し、コードをいわゆる「抽象構文木」(AST)に変換することです。AST が生成されると、一連のプラグインが AST を分析して変更します。
babel を多用して、JavaScript モジュールのインポートを検出し(後で操作します)、正規表現を使用したいと思うかもしれませんが、正規表現は言語を適切に解析できるほど強力ではなく、メンテナンスが困難です。Babel などの実績のあるツールを使用すると、多くの問題を回避できます。
カスタム プラグインを使用して Babel を実行する簡単な例を次に示します。
const plugin = {
visitor: {
ImportDeclaration(decl) {
/* ... */
}
}
}
const {code} = babel.transform(inputCode, {plugins: [plugin]});
プラグインは visitor
オブジェクトを提供できます。ビジターには、プラグインが処理する任意のノードタイプの関数が含まれています。AST の走査中にそのタイプのノードが見つかると、visitor
オブジェクト内の対応する関数が、そのノードをパラメータとして呼び出されます。上記の例では、ファイル内の import
宣言ごとに ImportDeclaration()
メソッドが呼び出されます。ノードタイプと AST の詳細については、astexplorer.net をご覧ください。
ステップ 2: モジュールの依存関係を抽出する
モジュールの依存関係ツリーを構築するには、そのモジュールを解析し、インポートするすべてのモジュールのリストを作成します。これらの依存関係にも依存関係がある可能性があるため、それらも解析する必要があります。再帰の典型的なケースです。
async function buildDependencyTree(file) {
let code = await readFile(file);
code = code.toString('utf-8');
// `dep` will collect all dependencies of `file`
let dep = [];
const plugin = {
visitor: {
ImportDeclaration(decl) {
const importedFile = decl.node.source.value;
// Recursion: Push an array of the dependency’s dependencies onto the list
dep.push((async function() {
return await buildDependencyTree(`./app/${importedFile}`);
})());
// Push the dependency itself onto the list
dep.push(importedFile);
}
}
}
// Run the plugin
babel.transform(code, {plugins: [plugin]});
// Wait for all promises to resolve and then flatten the array
return flatten(await Promise.all(dep));
}
ステップ 3: すべてのエントリ ポイント間で共有される依存関係を見つける
一連の依存関係ツリー(必要な場合は依存関係フォレスト)があるので、すべてのツリーに出現するノードを探すことで共有依存関係を見つけることができます。フォレストをフラット化して重複を排除し、すべてのツリーに表示される要素のみを保持するようにフィルタします。
function findCommonDeps(depTrees) {
const depSet = new Set();
// Flatten
depTrees.forEach(depTree => {
depTree.forEach(dep => depSet.add(dep));
});
// Filter
return Array.from(depSet)
.filter(dep => depTrees.every(depTree => depTree.includes(dep)));
}
ステップ 4: 共有依存関係をバンドルする
共有依存関係のセットをバンドルするには、すべてのモジュール ファイルを連結します。このアプローチを使用すると、2 つの問題が発生します。1 つ目の問題は、バンドルに import
ステートメントが引き続き含まれ、ブラウザがリソースの取得を試行することです。2 つ目の問題は、依存関係の依存関係がバンドルされていないことです。すでに実行済みなので、これから別の babel プラグインを作成します。
コードは最初のプラグインとかなり似ていますが、インポートを抽出するだけでなく、インポートを削除して、インポートされたファイルのバンドル バージョンを挿入します。
async function bundle(oldCode) {
// `newCode` will be filled with code fragments that eventually form the bundle.
let newCode = [];
const plugin = {
visitor: {
ImportDeclaration(decl) {
const importedFile = decl.node.source.value;
newCode.push((async function() {
// Bundle the imported file and add it to the output.
return await bundle(await readFile(`./app/${importedFile}`));
})());
// Remove the import declaration from the AST.
decl.remove();
}
}
};
// Save the stringified, transformed AST. This code is the same as `oldCode`
// but without any import statements.
const {code} = babel.transform(oldCode, {plugins: [plugin]});
newCode.push(code);
// `newCode` contains all the bundled dependencies as well as the
// import-less version of the code itself. Concatenate to generate the code
// for the bundle.
return flatten(await Promise.all(newCode)).join('\n');
}
ステップ 5: エントリポイントを書き換える
最後のステップとして、別の Babel プラグインを作成します。このジョブは、共有バンドルに含まれるモジュールのインポートをすべて削除します。
async function rewrite(section, sharedBundle) {
let oldCode = await readFile(`./app/static/${section}.js`);
oldCode = oldCode.toString('utf-8');
const plugin = {
visitor: {
ImportDeclaration(decl) {
const importedFile = decl.node.source.value;
// If this import statement imports a file that is in the shared bundle, remove it.
if(sharedBundle.includes(importedFile))
decl.remove();
}
}
};
let {code} = babel.transform(oldCode, {plugins: [plugin]});
// Prepend an import statement for the shared bundle.
code = `import '/static/_shared.js';\n${code}`;
await writeFile(`./app/static/_${section}.js`, code);
}
終了
大変な道のりでしたね。このエピソードの目標は、コードの分割について説明し、わかりやすく説明することでした。結果は機能しますが、これはデモサイトに固有のものであり、一般的なケースではひどく失敗します。本番環境では、WebPack、RollUp などの既存のツールを使用することをおすすめします。
コードは GitHub リポジトリでご覧いただけます。
それではまた、お会いしましょう。