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

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

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

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

要約

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

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

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

コード分割とルートベースのチャンクは密接に関連しており、しばしば同じ意味で使用されます。これが混乱を引き起こしましたこのことを明確にしてみましょう。

  • コード分割: コード分割とは、コードを複数のバンドルに分割するプロセスです。すべての 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 リポジトリにあります。

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