ライブ配信ブログの強化 - コード分割

最新の Supercharged ライブ配信では、コード分割とルートベースのチャンキングを実装しました。HTTP/2 とネイティブ ES6 モジュールでは、これらの手法は、スクリプト リソースの効率的な読み込みとキャッシュに不可欠になります。

このエピソードのその他のヒントとコツ

  • asyncFunction().catch()error.stack: 9:55
  • モジュールと <script> タグの nomodule 属性: 7:30
  • ノード 8 の promisify(): 17:20

要約

ルートベースのチャンキングによるコード分割を行う方法:

  1. エントリ ポイントのリストを取得します。
  2. これらすべてのエントリ ポイントのモジュール依存関係を抽出します。
  3. すべてのエントリ ポイント間で共有される依存関係を見つけます。
  4. 共有依存関係をバンドルします。
  5. エントリ ポイントを書き換えます。

コードの分割とルートベースのチャンクの比較

コード分割とルートベースのチャンキングは密接に関連しており、多くの場合、同じ意味で使用されます。このため、混乱が生じています。この点を明確にしてみましょう。

  • コード分割: コード分割とは、コードを複数のバンドルに分割するプロセスです。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 リポジトリでご覧いただけます。

それではまた、お会いしましょう。