跳至主要内容

JavaScript 開發者的程式碼快取

· 閱讀時間約 15 分鐘
[Leszek Swirski](https://twitter.com/leszekswirski),快取擊碎者

程式碼快取(也稱為 位元碼快取)是瀏覽器中的一項重要優化。它透過快取解析及編譯的結果,減少常訪網站的啟動時間。大多數 流行瀏覽器 都實現了某種形式的程式碼快取,Chrome 也不例外。事實上,我們曾經 撰文 並且 談論 過 Chrome 和 V8 如何快取已編譯的程式碼。

在此部落格文章中,我們為希望利用程式碼快取來改善其網站啟動速度的 JS 開發者提供一些建議。這些建議聚焦於 Chrome/V8 的快取實作,但大部分內容也可能適用於其他瀏覽器的程式碼快取實作。

程式碼快取回顧

儘管其他部落格文章和演講提供了關於我們程式碼快取實作的更多細節,但快速回顧一下運作方式仍然值得。Chrome 對 V8 編譯程式碼(包括經典腳本及模組腳本)有兩個層級的快取:由 V8 維護的低成本“盡力而為”記憶體快取(Isolate 快取)以及完整的序列化磁碟快取。

Isolate 快取針對在相同 V8 隔離執行的腳本(即相同進程,大致相同網站的頁面在同一個標籤中導航時)。它是“盡力而為”的,意思是它試圖盡量快速且簡化使用我們已經擁有的數據,以代價可能降低命中率並無法跨進程快取。

  1. 當 V8 編譯一份腳本時,編譯的位元碼會存儲在一個雜湊表中(位於 V8 堆內),鍵值為腳本的源代碼。
  2. 當 Chrome 要求 V8 編譯另一份腳本時,V8 首先檢查該腳本的源代碼是否與此雜湊表中的任何內容匹配。如果匹配,我們僅返回現有位元碼。

這個快取速度非常快且幾乎免費,我們觀察到它在實際使用中達到了 80% 的命中率。

磁碟上的程式碼快取由 Chrome(具體為 Blink)管理,補足 Isolate 快取未能覆盖的部分:在進程之間以及多個 Chrome 執行期間共享程式碼快取。它利用了現有的 HTTP 資源快取,該快取管理從網路接收數據的快取和到期。

  1. 當首次請求 JavaScript 文件時(即 冷執行),Chrome 會下載該文件並提供給 V8 編譯。同時將文件存儲於瀏覽器的磁碟快取中。
  2. 當第二次請求 JavaScript 文件時(即 暖執行),Chrome 從瀏覽器快取中取出該文件並再次提供給 V8 編譯。不過這次,編譯的程式碼會被序列化,並作為元數據附加到快取的腳本文件。
  3. 第三次請求(即 熱執行),Chrome 從快取中取出文件和文件的元數據,並將兩者提供給 V8。V8 反序列化元數據並跳過編譯。

總結如下:

程式碼快取分為冷執行、暖執行和熱執行,在暖執行中使用記憶體快取,在熱執行中使用磁碟快取。

根據以上描述,我們提供了最佳建議以改善網站使用程式碼快取。

建議 1:什麼都不做

理想情況下,作為 JS 開發者,您可以做的最好事情是“什麼都不做”。這實際上有兩層意思:被動地什麼都不做,以及主動地什麼都不做。

程式碼快取歸根結底是瀏覽器實作細節;基於啟發式的數據/空間取捨性能優化,其實作和啟發式方法可能(並且確實會)經常變化。我們作為 V8 工程師,盡力讓這些啟發式方法適應不斷演化的網路環境,而過度優化當前程式碼快取實作細節可能會在若干版本後當細節發生改變時令人失望。此外,其他 JavaScript 引擎可能有不同的程式碼快取實作啟發式方法。因此,許多方面,我們對獲得程式碼快取的最佳建議類似於我們對撰寫 JavaScript 的建議:撰寫乾淨的慣用代碼,我們將盡力優化快取方式。

除了被動地什麼都不做,你還應該嘗試積極地什麼都不做。任何形式的緩存本質上都依賴於事物不發生變化,因此什麼都不做是允許緩存數據保持緩存的最佳方式。有幾種方式可以積極地什麼都不做。

不要修改程式碼

這可能很明顯,但值得明確說明——每當你部署新程式碼時,這些程式碼尚未被緩存。每當瀏覽器對某個腳本 URL 發起 HTTP 請求時,它可以包括上次提取該 URL 的日期,如果伺服器知道該檔案沒有改變,它可以返回 304 Not Modified 回應,保持我們的程式碼緩存熱度。否則,200 OK 回應會更新我們的緩存資源,並清除程式碼緩存,使其回到冷啟動狀態。

立即推送最新程式碼改動的誘惑很大,特別是在你希望衡量某個改動的影響時,但對於緩存,最好是讓程式碼保持不變,或者至少儘可能少地更新它。可以考慮每週部屬的次數限制為 ≤ x,其中 x 是可以調整的滑塊,用以權衡緩存與過時性。

不要更改 URL

程式碼緩存(目前)是與腳本的 URL 關聯的,因為這使它們在不需要讀取實際腳本內容的情況下容易查找。這意味著更改腳本的 URL(包括任何查詢參數!)會在我們的資源緩存中創建一個新的資源條目,並隨之創建一個新的冷緩存條目。

當然,這也可以用於強制清除緩存,但這也是一個實現細節;我們可能有一天會決定將緩存與源文本而不是源 URL 關聯,此建議將不再有效。

不要更改執行行為

我們的程式碼緩存實現的一個較新優化是只在編譯的程式碼執行後進行序列化。這是為了嘗試捕捉懶惰編譯的函數,它們只在執行期間而不是初始編譯期間進行編譯。

此優化在每次腳本執行執行相同程式碼,或至少相同函數時效果最佳。如果你例如有依賴運行時決策的 A/B 測試,這可能會出現問題:

if (Math.random() > 0.5) {
A();
} else {
B();
}

在這種情況下,只有 A()B() 在熱啟動運行時編譯並執行,並進入程式碼緩存,但兩者可以在後續運行中執行。相反,嘗試保持執行行為確定性,以便保持在緩存路徑上。

小提示 2:做點什麼

無論是被動還是積極地「什麼都不做」的建議肯定不夠令人滿意。因此,除了「什麼都不做」,根據我們當前的啟發式方法和實現,你還可以做一些事情。但請記住,啟發式方法可能會改變,此建議可能會改變,且性能分析沒有替代品。

分離出函式庫與使用程式碼

程式碼緩存是以粗粒度、每腳本的方式完成的,這意味著對腳本任何部分的更改都會使整個腳本的緩存失效。如果你的發佈程式碼中既包含穩定部分又包含變化部分,例如函式庫和業務邏輯,那麼對業務邏輯程式碼的更改會使函式庫程式碼的緩存失效。

相反,你可以將穩定的函式庫程式碼分離到單獨的腳本中,並單獨包含它。這樣,函式庫程式碼可以被緩存一次,並在業務邏輯改變時保持緩存狀態。

如果函式庫在網站的不同頁面之間共享,這還有額外的好處:由於程式碼緩存是附在腳本上的,因此頁面之間也共享函式庫的程式碼緩存。

合併函式庫與使用程式碼

程式碼緩存在每個腳本執行後進行,這意味著腳本的程式碼緩存將包含該腳本執行結束時編譯的精確函數。這對於函式庫程式碼有幾個重要的影響:

  1. 程式碼緩存不會包含來自早期腳本的函數。
  2. 程式碼緩存不會包含由後續腳本調用的懶編譯函數。

特別是,如果函式庫完全由懶編譯函數組成,那麼即使這些函數後續被使用,它們也不會被緩存。

一個解決方法是將庫和它們的用法合併到單一的腳本中,這樣程式碼快取能夠「看到」使用了庫的哪些部分。不幸的是,這與上述建議完全相反,因為沒有萬能的解決方案。一般來說,我們不推薦將所有的 JS 腳本合併為一個大的包;將它劃分成多個較小的腳本通常會因為非快取相關的原因(例如多個網絡請求、流式編譯、頁面互動性等)而更加有利。

善用 IIFE 的啟發法則

只有在腳本執行完成時編譯的函數會被計入程式碼快取,因此有許多類型的函數即使稍後被執行也不會被快取。事件處理程序(甚至是 onload)、Promise 鏈、未使用的庫函數,以及其他所有在 </script> 被看到時尚未調用且延遲編譯的內容,都保持懶惰狀態且不會被快取。

迫使這些函數進入快取的一種方法是強制它們編譯,而強制編譯的一種常見方法是使用 IIFE 的啟發法則。IIFE(立即執行函數表達式)是一種函數在被創建後立即調用的模式:

(function foo() {
// …
})();

由於 IIFE 被立即調用,大多數 JavaScript 引擎嘗試檢測它們並立即編譯,以避免支付延遲編譯後的完整編譯成本。有各種早期檢測 IIFE 的啟發規則(在函數必須被解析之前),最常見的是在 function 關鍵字前的 (

由於此啟發規則早期應用,即使函數未被真正立即調用也會觸發編譯:

const foo = function() {
// 延遲跳過
};
const bar = (function() {
// 急切編譯
});

這意味著應該進入程式碼快取的函數可以通過將它們包裹在括號中來強制進入快取。然而,如果提示錯誤應用,這可能會導致啟動時間受損,且一般來說這在某種程度上是濫用啟發規則,因此我們的建議是除非必要,避免這麼做。

將小文件組合在一起

Chrome 對程式碼快取有最小大小限制,目前設定為 1 KiB 的程式碼。這意味著較小的腳本根本不會被快取,因為我們認為開銷大於好處。

如果您的網站有許多這樣的小腳本,開銷計算可能不再以同樣的方式適用。您可以考慮將它們合併在一起,以超過最低程式碼大小,並從減少腳本開銷中受益。

避免內聯腳本

HTML 中內聯的腳本標籤沒有與之關聯的外部來源文件,因此無法通過上述機制進行快取。Chrome 確實嘗試快取內聯腳本,通過將它們的快取附加到 HTML 文檔的資源上,但這些快取變得依賴於整個 HTML 文檔不變,且無法在頁面之間共享。

因此,對於非簡單的腳本,若能從程式碼快取中受益,請避免將它們內聯到 HTML 中,並將其作為外部文件包含。

使用服務工作者快取

服務工作者是一種機制,使您的程式碼能夠攔截網絡對頁面資源的請求。特別是,它們使您可以構建某些資源的本地快取,並在請求它們時從快取中提供資源。這對於希望在離線時繼續工作的頁面(例如 PWA)特別有用。

一個使用服務工作者的典型網站示例是在某個主腳本文件中註冊服務工作者:

// main.mjs
navigator.serviceWorker.register('/sw.js');

服務工作者添加了事件處理程序,用於安裝(創建快取)和獲取(提供資源,可能來自快取)。

// sw.js
self.addEventListener('install', (event) => {
async function buildCache() {
const cache = await caches.open(cacheName);
return cache.addAll([
'/main.css',
'/main.mjs',
'/offline.html',
]);
}
event.waitUntil(buildCache());
});

self.addEventListener('fetch', (event) => {
async function cachedFetch(event) {
const cache = await caches.open(cacheName);
let response = await cache.match(event.request);
if (response) return response;
response = await fetch(event.request);
cache.put(event.request, response.clone());
return response;
}
event.respondWith(cachedFetch(event));
});

這些快取可以包括快取的 JS 資源。然而,由於可以做出不同的假設,我們對它們有稍微不同的啟發規則。由於服務工作者快取遵循配額管理存儲的規則,它更可能被長期保留,快取的好處更大。此外,在加載前預快取時,我們還能額外推斷資源的重要性。

最大的啟發式差異發生在資源在服務工作者的安裝事件期間被添加到服務工作者緩存時。上面的範例演示了這樣的用例。在此情況下,當資源被放入服務工作者緩存時,代碼緩存會立即生成。此外,我們為這些腳本生成了“完整”代碼緩存——我們不再延遲編譯函數,而是編譯_所有內容_並將其放入緩存中。這樣的優勢是具有快速且可預測的性能,並且不會有執行順序依賴性,但代價是增加了記憶體使用量。

如果透過 Cache API 存儲 JS 資源,但不在服務工作者的安裝事件期間完成,那麼代碼緩存不會立即生成。而是,如果一個服務工作者通過緩存中的響應進行回應,那麼在第一次加載時將生成“普通”的代碼緩存。這個代碼緩存將可以在第二次加載時使用,比典型的代碼緩存情景快了一次加載的時間。在抓取事件中“逐步”緩存資源,或者從主窗口(而不是服務工作者)更新 Cache API 時,資源可能會在安裝事件之外存儲於 Cache API。

請注意,預先緩存的“完整”代碼緩存假設包含腳本的頁面將使用 UTF-8 編碼。如果最終頁面使用不同的編碼,那麼代碼緩存將被丟棄,並被“普通”代碼緩存取代。

此外,預先緩存的“完整”代碼緩存假設頁面將作為經典 JS 腳本加載腳本。如果最終頁面將其作為 ES 模組加載,則代碼緩存將被丟棄,並被“普通”代碼緩存取代。

跟蹤

以上建議都不能保證一定能加速您的網頁應用程序。不幸的是,代碼緩存資訊當前尚未在開發者工具中展示,因此,要查明您的網頁應用中的哪些腳本已進行代碼緩存,最可靠的方法是使用較低層級的 chrome://tracing

chrome://tracing 會在一段時間內記錄 Chrome 的檢測追蹤,生成的追蹤可視化結果看起來像這樣:

chrome://tracing 的界面,一次溫緩存運行的記錄

追蹤會記錄整個瀏覽器的行為,包括其他標籤頁、窗口和擴展。因此,它在乾淨的用戶配置檔案中執行效果最佳,並且應禁用擴展程序,且無其他瀏覽器標籤頁開啟:

# 開啟一個基於乾淨用戶配置檔案和禁用擴展的新的 Chrome 瀏覽器會話
google-chrome --user-data-dir="$(mktemp -d)" --disable-extensions

在收集追蹤數據時,您需要選擇要追蹤的類別。在大多數情況下,您可以簡單地選擇“Web developer”類別集,但也可以手動選擇類別。對於代碼緩存來說,重要的類別是 v8

記錄含有 v8 類別的追蹤後,在追蹤中查找 v8.compile 條目。(或者,您可以在追蹤界面的搜索框中輸入 v8.compile)。這些條目列出被編譯的檔案以及有關編譯的一些元數據。

在腳本的冷啟動過程中,沒有有關代碼緩存的資訊——這意味著該腳本未參與產生或消耗緩存數據。

在溫啟動過程中,每個腳本有兩個 v8.compile 條目:一個是實際編譯(如圖所示),另一個是執行後產生緩存。您可以識別後者,因為它包含 cacheProduceOptionsproducedCacheSize 元數據字段。

在熱啟動過程中,您將看到一個用於消耗緩存的 v8.compile 條目,其元數據字段包括 cacheConsumeOptionsconsumedCacheSize。所有大小都以字節為單位表示。

結論

對於大多數開發者而言,代碼緩存應該“自動運行”。就像任何緩存一樣,它在狀態不變時效果最佳,並基於版本之間可能改變的啟發式規則運行。不過,代碼緩存具有可以利用的行為以及可以避免的限制,而通過 chrome://tracing 進行仔細分析可以幫助您調整和優化您的網頁應用對緩存的使用。