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

我們在最新的 Supercharged 直播活動中,導入了程式碼分割和路徑分塊功能。使用 HTTP/2 和原生 ES6 模組時,如要有效載入及快取指令碼資源,這些技術將成為不可或缺的要素。

本集節目提供其他提示和秘訣

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

重點摘要

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

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

程式碼分割與以路徑為基礎的區塊

程式碼分割和路徑型區塊彼此密切相關,且經常交替使用。這造成了一些混淆。因此我們將試著排除問題:

  • 程式碼分割:程式碼分割是指將程式碼分割成多個組合的過程。如果您「不」將所有 JavaScript 組合運送至用戶端,就是在進行程式碼分割。分割程式碼的其中一種具體方式是使用路徑區塊。
  • 路徑區塊化:路徑型區塊會建立與應用程式路徑相關的套件。藉由分析路徑及其依附元件,我們可以變更納入哪個模組的模組。

為什麼要分割程式碼?

可用模組不多

透過原生 ES6 模組,每個 JavaScript 模組都可以匯入其專屬的依附元件。當瀏覽器收到模組時,所有 import 陳述式都會觸發額外的擷取作業,取得執行程式碼所需的模組。不過,所有模組都有自己的依附元件。危險的在於瀏覽器最終會進行分層擷取,這類擷取作業在程式碼最終執行前最後經過多趟擷取。

郵件分類

「繫結」功能可將所有模組內嵌至單一套件,可確保瀏覽器在 1 次來回行程後擁有所有需要的程式碼,並能更快開始執行程式碼。然而,這會強制使用者下載許多不需要的程式碼,因而浪費頻寬和時間。此外,每次變更原始模組時,套件中的各項變更都會導致套件的快取版本失效。使用者必須重新下載整個內容。

程式碼分割

程式碼分割是中立基礎。我們願意投入更多來回行程,只下載所需內容,藉此提高網路效率,同時減少每個套件的模組數量,藉此提高快取效率。如果組合作業順利完成,往返總數會遠低於鬆耦合的模組。最後,我們可以利用 link[rel=preload]預先載入機制,視需要省下額外的三進位時間。

步驟 1:取得進入點清單

這只是其中一種做法,我們在這集節目中剖析了網站的 sitemap.xml 以取得網站的進入點。通常會使用列出所有進入點的專屬 JSON 檔案。

使用 Babel 處理 JavaScript

Babel 常用於「轉譯」:使用最先進的 JavaScript 程式碼,並將其轉換成舊版 JavaScript,讓更多瀏覽器執行程式碼。這裡的第一步是使用剖析器剖析新的 JavaScript (Babel 使用 babylon),將程式碼轉換為所謂的「抽象語法樹狀結構」(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 存放區中找到我們的程式碼。

下次見!