最新の Supercharged ライブ配信では、コード分割とルートベースのチャンクを実装しました。 HTTP/2 モジュールとネイティブ ES6 モジュールでは、スクリプト リソースの効率的な読み込みとキャッシュを可能にするために、これらの手法が不可欠になります。
このエピソードのその他のヒントとコツ
asyncFunction().catch()
、error.stack
: 9:55<script>
タグのモジュールとnomodule
属性: 7:30- ノード 8 の
promisify()
: 17:20
要約
ルートベースのチャンキングによってコード分割を行う方法:
- エントリ ポイントのリストを取得します。
- これらすべてのエントリ ポイントのモジュール依存関係を抽出します。
- すべてのエントリ ポイント間で共有されている依存関係を見つけます。
- 共有依存関係をバンドルする。
- エントリ ポイントを書き換えます。
コード分割とルートベースのチャンク
コード分割とルートベースのチャンクは密接に関連しており、しばしば同じ意味で使用されます。これが混乱を引き起こしましたこのことを明確にしてみましょう。
- コード分割: コード分割とは、コードを複数のバンドルに分割するプロセスです。すべての JavaScript を含む 1 つの大きなバンドルをクライアントに配信していない場合は、コード分割を行います。コードを分割する特定の方法の一つは、ルートベースのチャンクを使用することです。
- ルートベースのチャンク: ルートベースのチャンクでは、アプリのルートに関連するバンドルを作成します。ルートとその依存関係を分析することで、どのモジュールをどのバンドルに含めるかを変更できます。
コード分割を行う理由
ルーズ モジュール
ネイティブ ES6 モジュールを使用すると、すべての JavaScript モジュールで独自の依存関係をインポートできます。ブラウザがモジュールを受信すると、すべての import
ステートメントで追加のフェッチがトリガーされ、コードの実行に必要なモジュールが取得されます。ただし、これらのモジュールはすべて独自の依存関係を持つことができます。その危険性は、最終的にコードを実行できる前に、ブラウザがフェッチのカスケードを複数回にわたって行うことになり、
分類
すべてのモジュールを 1 つのバンドルにインライン化するバンドルにより、1 ラウンド トリップ後にブラウザに必要なコードがすべて揃うようになり、コードの実行をより迅速に開始できます。ただし、この場合、ユーザーは不要なコードを大量にダウンロードしなければならず、帯域幅と時間が無駄になってしまいます。また、元のモジュールのいずれかが変更されるたびにバンドルが変更され、キャッシュに保存されたバンドルのバージョンが無効になります。ユーザーは全体を再ダウンロードする必要があります。
コード分割
コード分割は中間点です。Google では、必要なデータのみをダウンロードすることでネットワークの効率性を高め、バンドルあたりのモジュール数を大幅に減らすことでキャッシュ効率を高めるために、追加のラウンド トリップへの投資を前向きに検討しています。適切にバンドルすると、ラウンド トリップの総数はルーズなモジュールの場合よりもはるかに少なくなります。最後に、link[rel=preload]
などのプリロード メカニズムを利用して、必要に応じて追加のラウンド トリオ時間を短縮できます。
ステップ 1: エントリ ポイントのリストを取得する
これは数あるアプローチの 1 つにすぎませんが、このエピソードでは、ウェブサイトの sitemap.xml
を解析してウェブサイトへのエントリ ポイントを取得しました。通常、すべてのエントリ ポイントをリストした専用の JSON ファイルが使用されます。
Babel を使用して JavaScript を処理する
Babel は一般的に「トランスパイル」に使用されます。つまり、テスト中の JavaScript コードを古いバージョンの JavaScript に変換し、より多くのブラウザでコードを実行できるようにします。ここではまず、新しい JavaScript をパーサー(Babel では babylon を使用)で解析します。このパーサーは、コードをいわゆる「抽象構文ツリー」(AST)に変換します。AST が生成されると、一連のプラグインが AST を分析してマングリングします。
ここでは、JavaScript モジュールのインポートを検出(後で操作)するために、babel を多用します。正規表現に頼りたくなるかもしれませんが、正規表現は言語を適切に解析するほど強力ではなく、メンテナンスが困難です。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 プラグインを記述します。
コードは最初のプラグインとほぼ同じですが、import を抽出するだけでなく、インポートを削除して、インポートしたファイルのバンドル バージョンを挿入します。
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 リポジトリにあります。
それではまたお会いしましょう。