[譯]寫給JavaScript開發者的代碼緩存指南

NO IMAGE

原文鏈接:v8.dev/blog/code-c…

代碼緩存(也稱字節碼緩存)是瀏覽器中非常重要的優化手段,通過將「解析+編譯」的結果進行緩存,可以減少常訪問網站的啟動時間。大多數主流瀏覽器也都以某種形式實現了代碼緩存,Chrome 自然也不例外。而且圍繞 「Chrome 和V8 如何緩存編譯過的代碼」這個主題,我們曾寫過一些文章,也做過相應的演講,感興趣的同學可以點擊進行查看。

原文作者 Leszek Swirski 給那些希望通過充分利用代碼緩存來提升網站啟動效率的 JS 開發者們提供了幾條建議,這些建議側重於 Chrome/V8 中的代碼緩存實現,其中的大多數原理也同樣適用於其他瀏覽器的代碼緩存實現,也具備較高的參考價值,希望對大家能有所啟發,內容翻譯如下:

代碼緩存概述

雖然已經有很多博客和專題都闡述了很多關於代碼緩存實現的細節,但還是有必要先來簡單說明一下代碼緩存的工作原理。Chrome 為 V8 編譯的代碼(包括經典腳本和模塊腳本)提供了兩級緩存:由 V8 維護低成本的內存緩存,即隔離緩存(Isolate Cache),以及完整的序列化硬盤緩存。

隔離緩存對在同一 V8 隔離區中編譯的腳本進行操作(即同一進程,簡單說就是 「導航到同一個Tab的同一個網頁」), 隔離緩存以犧牲潛在的低命中率和跨進程的緩存為代價,來換取儘可能快且小地使用已可用的數據,從這個意義上講,隔離緩存是「盡了最大的努力」。

  1. 當 V8 編譯一段腳本時,已編譯過的字節碼會被存儲在一個散列表中(hashtable,在 V8 的堆上),並以腳本的源碼作為鍵。

  2. 當 Chrome 要求 V8 去編譯另一段腳本時,V8 首先在散列表中檢查腳本的源碼是否能匹配到對應的字節碼,如果匹配成功,就直接返回已經存在的字節碼。

隔離緩存快速且高效,目前檢測結果顯示,在真實情況中它的命中率高達 80% 。

硬盤緩存是由 Chrome (確切地說是 Blink 引擎)來進行管理,隔離緩存不能在進程之間以及多個 Chrome 會話之間共享代碼,而硬盤緩存則填補了這個空白。硬盤緩存利用現有的 HTTP 資源緩存,HTTP 緩存負責管理從 Web 接收的緩存以及即將失效的數據。

  1. 當一個 JS 文件被請求的時候(即:冷運行),Chrome 將其下載下來並交給 V8 來編譯,同時文件也被存儲在瀏覽器的硬盤緩存中。

  2. 當這個 JS 文件第二次被請求的時候(即:暖運行),Chrome 從瀏覽器緩存中提取文件,並再次交給 V8 來編譯。但是這次編譯的代碼被序列化,並作為元數據附加到緩存的腳本文件。

  3. 當 JS 文件第三次被請求到的時候,Chrome 從瀏覽器緩存中,同時提取到文件和文件的元數據,並且把兩者都交給 V8。V8 對元數據進行反序列化,就可以跳過編譯過程。

    總結如下圖:[譯]寫給JavaScript開發者的代碼緩存指南        代碼緩存可以被分為冷運行、暖運行和熱運行,暖運行發生在內存緩存中,熱運行發生在硬盤緩存中。

基於上述內容,我們就可以提供幾條建議來提高網站對代碼緩存的利用率。

建議 1:什麼都不做

在理想情況下,為了提搞代碼緩存,作為 JS 開發者能做的最好事情就是「什麼都不做」。這實際上代表 2 層含義:「被迫什麼都不做」和「主動選擇什麼都不做」。

代碼緩存終究是瀏覽器的實現細節,是一種基於啟發式的數據與空間權衡的優化,其實現和啟發式方法可以經常發生變化。作為 V8 工程師,我們會盡己所能地使這些啟發式方法適用於不同 Web 發展階段中的每個開發者,在幾個版本發佈之後,對現有代碼緩存實現細節的過度優化,也可能會引起大家的失望。此外,另一些 JavaScript 引擎在它們的代碼緩存實現中可能使用了不一樣的啟發式方法。所以,從各個方面來講,我們對獲取緩存代碼的最佳建議,就如同對編寫 JS 代碼的建議一樣:書寫整潔且符合語言習慣的代碼,我們會替你努力來優化代碼緩存。

除了「被迫什麼都不做」,你也應該盡力嘗試主動地選擇什麼都不做,任何形式的緩存本質上都依賴於不變的東西。因此,「選擇什麼都不做」是允許緩存數據保持緩存狀態的最佳辦法。下面是一些可以主動選擇什麼都不做的方法。

不要改變代碼

這也許是顯而易見的,但是還是值得討論 —— 每當你添加了一行新代碼,那麼新代碼就還沒有被緩存。每當瀏覽器通過 HTTP 請求一個腳本 URL 的時候,它可以包含上一次請求該 URL 返回的數據,並且如果服務器知道文件沒有發生變化的話,服務器便可以返回一個 304 Not Modified 的響應,使得代碼緩存保持熱運行。否則,200 OK 的響應會更新緩存資源,清除代碼緩存,使緩存恢復到冷運行狀態。

[譯]寫給JavaScript開發者的代碼緩存指南

服務端總是立即推送你最新的代碼更改,當你想要衡量某次更改的影響的時候。但是對於緩存來說,最好的策略就是保持代碼不變,或是儘可能地減少更新代碼。可以考慮限制每週上線部署的最大次數 x ,而 x 的值則取決於你選擇優先緩存代碼還是優先更新代碼。

不要改變 URL

代碼緩存(目前)與腳本的 URL 存在關聯,目的是為了方便查找且無需讀取腳本實際的內容。這就意味著,若改變腳本的 URL(包括查詢參數)就會在資源緩存中創建一個新的資源入口,並伴隨一個新的冷緩存入口。

這麼做當然也可以用於強制清理緩存,或許在未來的某一天,當我們決定用源文件的文本代替源文件的 URL 來關聯緩存時,這條建議就不再管用了。

不要改變執行行為

有一個我們近期用來優化代碼緩存實現的辦法是:僅在編譯過的代碼執行結束後再對其進行序列化。這麼做是為了嘗試捕獲延遲編譯的函數,這些函數僅在執行期間編譯,而不是在初始編譯期間編譯。

當腳本每次執行都執行相同的代碼或至少執行相同的函數時,這種優化效果最好。如果有類似 A/B 測試這種取決於運行時決定的需求時,可能會出現問題:

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

在上面的例子中,A() 和 B() 只會有一個在暖運行中被編譯和執行,並進入到代碼緩存中,但它們都可以在隨後的運行中執行。所以,還是儘量保證執行的確定性,從而讓執行保持在緩存路徑上比較好。

建議2:做些事情

當然,上面「啥都不做」的建議,無論是主動還是被動,都不是很讓人滿意。除此之外,鑑於我們目前的啟發式方法和實現,還是可以做些事情的。但是請注意,因為啟發式方法和實現會發生改變,那麼相應的建議也可能會變化,並且沒有替代分析。

[譯]寫給JavaScript開發者的代碼緩存指南

將庫從使用代碼中分離

代碼在每個腳本中粗粒度地完成緩存,這就意味著腳本中任何一部分的改動,都會破壞整個腳本的緩存。如果你同時將穩定代碼和經常變動的代碼(比如庫和業務邏輯)放在一個腳本中,那麼業務邏輯代碼的變化會破壞庫代碼的緩存。

相反,我們可以將庫代碼分離成為獨立的腳本,並且獨立地引用庫。如此一來,庫代碼就可以只緩存一次,並在業務邏輯代碼變化時依舊保持緩存。

如果腳本庫在不同頁面之間進行共享,上述做法還會帶來額外的收益:由於代碼緩存附加到腳本,因此庫的代碼也可以在頁面之間共享。

合併庫文件到使用它們的代碼中

代碼會在每個腳本執行結束後完成緩存,意味著一個腳本的代碼緩存包含了當腳本執行完編譯後代碼中的函數。這對庫代碼來說有兩個重要意義:

  1. 代碼緩存不會包含早期腳本里的函數。

  2. 代碼緩存不會包含後續腳本調用的延遲編譯的函數。

特別地,如果庫完全由延遲編譯的函數組成,那麼這些函數即使稍後被調用,也不會被緩存。

對於這種情況,一種解決方案是,將庫文件以及它們依賴的文件合併為一個單獨的腳本文件,這樣代碼緩存就可以「觀察到」庫的哪些部分被使用了。可惜的是,這會與上一條建議相違背,總之,沒有一勞永逸的辦法。

一般情況下,我們不建議將所有把的 JS 腳本文件合併成一個巨大的文件,而是將其分成多個較小的腳本往往對除代碼緩存之外的其他情況更有益處(如多個網絡請求、流編譯、頁面交互等)。

利用 IIFE

只有腳本完成執行時才會把被編譯過的函數加入到代碼緩存中,所以有很多種類的函數,儘管在稍後的時間裡執行,也不會被緩存。事件處理程序(甚至是 onload)、promise 鏈、未使用的庫函數以及其他一些在執行到結束標籤 </script> 時仍沒有被調用的延遲編譯函數,所有的這類函數都會保持延遲且不會被緩存。

強制將這些函數加入緩存的一個辦法是:強制函數被編譯,而我們通常使用 IIFE 來進行強制編譯。IIFE (immediately-invoked function expressions,立即調用函數表達式)是一種函數創建時就立即調用的設計模式。

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

因為 IIFE 被立即調用,為了避免完全編譯後的延遲成本,多數 JavaScript 引擎會嘗試探測 IIFE 並立即編譯 IIFE。有各種探索型的做法可以在函數被解析之前,儘早地探測出 IIFE 表達式,最常用的是通過 function關鍵字之前的左括號 (。

由於這種探索型的做法在早期被應用,所以即使函數實際不是立即執行也會被編譯:

const foo = function() {
// Lazily skipped
};
const bar = (function() {
// Eagerly compiled
});

這就表示,通過用括號將函數包裹起來,可以使其強制加入緩存中。但是,如果使用不正確,可能會對網頁啟動時間產生影響,通常來說這有點濫用探索型的做法。因此,除非真的有必要,不建議這麼做。

將小文件組合在一起

Chrome 有對代碼緩存最小體積的限制,目前是 1KB 。這表示非常小的文件根本不可能被緩存,因為我們認為緩存小文件的開銷遠大於獲得的收益。

如果站點內含有很多小的腳本文件,開銷計算可能不再適用於同樣的方式。應該考慮將小文件合併成為超過最小代碼體積限制的文件,並用常規手段來獲得減少開銷的收益。

避免使用內聯腳本

HTML 中的內聯腳本沒有關聯外部的源文件,因此不能被上述機制所緩存。Chrome 嘗試通過將它們附加 HTML 文檔資源緩存,但是這些緩存依賴於整個 HTML 文檔的穩定,且不能在頁面間進行共享。

因此,對於需要被緩存的重要腳本,請避免將它們內聯到 HTML 中,推薦的做法是:將腳本作為外部文件來引用。

使用 Service Worker 緩存

Service Worker 是一種在頁面中用來攔截資源網絡請求的機制。特別的是,它可以構建本地資源緩存,並在你請求資源時提供緩存資源。這個特性在構建離線應用時尤其有用,比如 PWA。

一個典型的例子,網站使用 Service Worker,並在主腳本中註冊 :

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

下面是 Service Worker 添加安裝事件(創建緩存)和 fetch 事件(提供緩存裡的資源)的處理函數:

// 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 資源。但是,因為我們期望 Service Worker 緩存主要用於 PWA 應用,所以它與 Chrome 的「自動」緩存的啟發式略有不同。首先,當 JS 資源被添加到緩存中時,它們立即創建了一個代碼緩存,這就意味著代碼緩存在第二次加載時已經是可用的了(而不是像普通緩存一樣僅在第三次加載時可用)。第二,我們為這些腳本生成了「全量的」代碼緩存,不再延遲編譯函數,而是編譯所有腳本並把它們放到緩存中。這具有快速且可預測性能的優點,沒有執行順序依賴性,但卻是以增加的內存使用為代價。請注意,此啟發式僅適用於 Service Worker 緩存,而不適用於 Cache API 的其他用途。實際上,當在 Service Worker 外面使用時,現在的 Cache API 不會執行代碼緩存。

追蹤信息

上述的所有建議,都不能保證能提升 Web App 的速度。不幸的是,代碼緩存信息目前也沒有在 DevTool 暴露,所以查找你的 Web App 到底緩存了哪些腳本,最保險的做法是,使用稍微低級的 chrome://tracing。

chrome://tracing 記錄了一段時間內的 Chrome 追蹤信息,其生成的可視化追蹤結果如下:[譯]寫給JavaScript開發者的代碼緩存指南

chrome://tracing 記錄了整個瀏覽器的行為,包括其他標籤頁、窗口以及擴展插件。因此在禁用擴展插件、關閉所有其他的標籤頁的場景下,我們可以得到最佳的跟蹤信息。

# Start a new Chrome browser session with a clean user profile and extensions disabled
google-chrome --user-data-dir="$(mktemp -d)" --disable-extensions

當收集跟蹤信息時,你需要選擇想要跟蹤的類別。在大多數情況下,可以簡單地選擇 Web developer 類別,也可以手動選擇類別,代碼追蹤的重要類別是 v8。

[譯]寫給JavaScript開發者的代碼緩存指南

[譯]寫給JavaScript開發者的代碼緩存指南

當完成記錄一段 v8 的跟蹤信息後,查找 v8.compile 部分(或者可以通過在 UI 的搜索框中搜索 v8.compile 來進入)。這裡列出了被編譯過的文件,以及已經編譯的元數據。

在腳本冷運行時,是沒有代碼緩存信息的,這表示腳本不參與生成或使用緩存數據。

[譯]寫給JavaScript開發者的代碼緩存指南

在腳本暖運行時,每個腳本有2個 v8.compile 入口:一個是表示實際編譯的,另一個是表示(在執行後)是產生緩存的。可以通過它是否有 cacheProduceOptions 和 producedCacheSize 兩個元數據字段來判斷。

[譯]寫給JavaScript開發者的代碼緩存指南

在腳本熱運行時,可以看到一個用於消費緩存的 v8.compile 入口,有 cacheConsumeOptions 和 consumedCacheSize 兩個元數據字段,所有大小都以字節表示。

[譯]寫給JavaScript開發者的代碼緩存指南

總結

對於大多數開發者而言,代碼緩存應該是「啥都不用我管,緩存自己工作就好了」。當代碼沒有發生任何變化時,代碼緩存應該像其他類型的緩存一樣工作的很好,並且在版本迭代後,通過一系列啟發式方法進行工作。儘管如此,代碼緩存也同樣含有可供開發者使用的行為、可避免的限制以及用於分析的 chrome://tracing 工具,這些都可以幫助我們調整和優化 Web App 對緩存的使用。

相關文章

vscode配置使vue項目支持斷點調試

小程序自定義導航欄,兼容適配所有機型(附完整案例)

微信小程序初始化項目架構

不懂Webpack4的前端不是好工程師(進階篇)