在最近的超級充電直播中,我們實作了程式碼分割和路線分割。有了 HTTP/2 和原生 ES6 模組,這些技巧將成為讓您能有效載入及快取指令碼資源的必要條件。
本集的其他提示與秘訣
asyncFunction().catch()
與error.stack
:9:55<script>
標記上的模組和nomodule
屬性:7:30- 節點 8 中的
promisify()
:17:20
TL;DR
如何透過路徑為基礎的區塊處理方式進行程式碼分割:
- 取得進入點清單。
- 擷取所有這些進入點的模組依附元件。
- 找出所有進入點之間的共用依附元件。
- 封裝共用依附元件。
- 重寫進入點。
程式碼分割與路徑型區塊
程式碼分割和路徑型分塊密切相關,且通常可以交錯使用。這會造成一些混淆。為瞭解決這個難題,建議你採取以下做法:
- 程式碼分割:程式碼分割是將程式碼分割成多個套件。如果您不將包含所有 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 存放區中找到我們的程式碼。
下次見!