超強直播網誌 - 程式碼分割

在最近的超級充電直播中,我們實作了程式碼分割和路線分割。有了 HTTP/2 和原生 ES6 模組,這些技巧將成為讓您能有效載入及快取指令碼資源的必要條件。

本集的其他提示與秘訣

  • asyncFunction().catch()error.stack9:55
  • <script> 標記上的模組和 nomodule 屬性:7:30
  • 節點 8 中的 promisify()17:20

TL;DR

如何透過路徑為基礎的區塊處理方式進行程式碼分割:

  1. 取得進入點清單。
  2. 擷取所有這些進入點的模組依附元件。
  3. 找出所有進入點之間的共用依附元件。
  4. 封裝共用依附元件。
  5. 重寫進入點。

程式碼分割與路徑型區塊

程式碼分割和路徑型分塊密切相關,且通常可以交錯使用。這會造成一些混淆。為瞭解決這個難題,建議你採取以下做法:

  • 程式碼分割:程式碼分割是將程式碼分割成多個套件。如果您將包含所有 JavaScript 的大型套件傳送至用戶端,就會執行程式碼分割作業。使用路徑為基礎的區塊劃分法,是一種具體的程式碼拆分方式。
  • 路徑型分塊:路徑型區塊建立與應用程式路徑相關的套件。我們會分析路徑及其依附元件,藉此變更哪些模組會納入哪些套件。

為什麼要進行程式碼分割?

鬆散模組

使用原生 ES6 模組後,每個 JavaScript 模組都能匯入自己的依附元件。瀏覽器收到模組時,所有 import 陳述式都會觸發額外的擷取作業,以取得執行程式碼所需的模組。不過,所有這些模組都有自己的依附元件。危險之處在於瀏覽器最終會產生一連串的擷取作業,這些作業會持續進行多次往返作業,然後才可執行程式碼。

郵件分類

將所有模組內嵌到單一套件中的封裝作業,可確保瀏覽器在 1 次往返後取得所需的所有程式碼,並能更快開始執行程式碼。不過,這會迫使使用者下載許多不需要的程式碼,因此浪費頻寬和時間。此外,對原始模組的每項變更都會導致套件的變更,並使套件的任何快取版本失效。使用者必須重新下載整個內容。

程式碼分割

程式碼分割是中間區域。我們願意投入額外的往返次數,只下載所需內容來提升網路效率,並透過減少每個套件中的模組數量來提升快取效率。如果綁定作業執行得當,往返次數的總數會比使用鬆散模組時低得多。最後,我們可以使用 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:封裝共用依附元件

如要封裝共用依附元件組合,只需串連所有模組檔案即可。使用這種方法時會發生兩個問題:第一個問題是套件仍會包含 import 陳述式,這會讓瀏覽器嘗試擷取資源。第二個問題是依附元件的依附元件並未綁定。因為我們之前已完成這項操作,所以要編寫「另一個」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 存放區中找到我們的程式碼。

下次見!