作者:Leszek Swirski

譯者:Aaron Lee

代碼緩存(也被稱爲字節碼緩存)是瀏覽器的一個重要優化。它通過緩存解析+編譯後的結果來提升高頻訪問網站的啓動速度。大多主流瀏覽器都實現了代碼緩存,Chrome 也不例外。事實上,關於 Chrome 和 V8 緩存編譯後代碼的實現,之前我們已經寫文章也做過演講。

在這篇文章中,我們將爲那些想要更好的利用代碼緩存來提高網站啓動速度的 JS 開發者提供一些建議。這些建議集中在 Chrome/V8 的代碼緩存實現上,但是其他大多數瀏覽器實現原理基本也是這樣的。

代碼緩存回顧

雖然其他文章和演講已經提供代碼緩存實現的詳細信息,但是我們仍然要快速回顧下它是如何工作的,對於 V8 編譯後的代碼 Chrome 有兩級緩存:一個是由 V8(Isolate緩存) 維護的低成本的“盡力而爲”內存緩存和一個完整序列化的硬盤緩存。

Isolate 緩存操作發生在同一個 V8 Isolate 中編譯的腳本(即同一個進程,簡單來說就是“在同一個 tab 頁中導航的相同頁面” )。它是“盡力而爲”,因爲它試圖儘可能快而小地使用已經可用的數據,以犧牲潛在的低命中率和跨進程的緩存爲代價。

  1. 當 V8 編譯腳本時,編譯後的腳本以源碼爲鍵被存儲在一個 hashtable 中(在 V8 的堆中)。

  2. 當 Chrome 要求 V8 變異其他腳本的時候,V8 首先檢查腳本的源碼是否能匹配 hashtable 中的值。如果是,則返回已經存在的字節碼。

Isolate 緩存是快速且有效的,目前我們檢測到在真實情況中它的命中率達到 80% 。

硬盤代碼緩存是由 Chrome 管理(準確來說是由 Blink ),它填充了 Isolate 緩存不能在多個進程或多個 Chrome 會話間共享代碼緩存的空白。

綜上,

代碼緩存被分爲冷運行、暖運行和熱運行,在內存緩存發生在暖運行,硬盤緩存發生在熱運行

基於這段描述,我們可以提供最好的建議來提高你的網站對代碼緩存的利用。

提示 1:什麼都不要做

理想情況見,做爲 JS 開發者爲了提高代碼的緩存能做的最好的事情就是“什麼也不做”。這實際上有兩層含義,一是被動的不做,二是主動的不做。

代碼緩存終究是瀏覽器實現的細節。基於啓發式的數據與空間的權衡性能優化,它的實現和啓發式可能定期變化。做爲 V8 工程師,我們會盡我們所能使啓發式適用於在不斷髮展的 Web 中的每一個人,而且對當前代碼緩存實現細節的過度的優化可能會在一些版本發佈後,當這些細節改變後引起失望。另外,其他的一些 JavaScript 引擎可能使用了不同的啓發式實現代碼緩存。因此從各方面來說,對於使用代碼緩存我們最好的建議是:書寫整潔且符合習慣的代碼,而且我們會儘可能的優化它。

除了被動不做什麼,你應該儘可能地主動不做什麼。任何形式的緩存內在都依賴於事物沒有改變,因此什麼都不做是允許緩存數據保持緩存的最佳方式。這兒有幾個你什麼都不做的方法:

不要改變代碼

這也許是顯而易見的事情,但是仍然值得明確說明———當你上線一份新的代碼的時候,代碼還沒有被緩存。當瀏覽器通過 HTTP 請求一個腳本 URL 的時候,它包含了上次請求 URL 的時間,如果服務器知道文件沒有改變,它返回 304NotModified 響應,維持我們的代碼緩存熱運行狀態。否則,返回 200OK 響應更新緩存資源,並且清除代碼緩存,恢復到冷運行狀態。

它總是立即推送你最新的代碼更改,特使是如果你想要衡量某次更改的影響的時候,但是對於緩存來說,最好是保留代碼或儘可能地減少更新。可以考慮限制每週的上線次數小於 xx 是你調整權衡緩存與陳舊性的滑塊。

不要改變 URLs

代碼緩存與腳本的 URL 存在關聯,這是爲了便於檢查而無需查看實際的腳本內容。這意味着改變腳本的 URL(包括改變請求查詢參數) 會在我們的緩存資源中創建一個新的資源入口,並伴隨着一個冷緩存入口。

當然,這可以被用來強制清除緩存,儘管那也是一個實現細節。也許有一天我們會使用源文件內容關聯緩存而不是源文件的 URL,那麼這個建議將不在有效。

不要改變代碼執行行爲

對代碼緩存實現的最新優化之一是僅在編譯後的代碼執行後對其進行序列化。 這是爲了嘗試捕獲延遲編譯的函數,這些函數僅在執行期間編譯,而不是在初始編譯期間編譯。

當每次執行腳本執行相同的代碼或至少相同的函數時,這個優化最有效。 如果你進行 A/B 測試,且測試取決於運行時決策,這樣做可能會有問題。

  1. if (Math.random() > 0.5) {

  2. A();

  3. } else {

  4. B();

  5. }

在這個例子中,僅 A()B() 被編譯或執行在熱運行時,並進入到代碼緩存,另外一個可能會在後續的代碼運行中被執行。相反,保持運行時的確定性,以保持其在緩存路徑上。

提示 2: 做一些事情

當然無論是被動還是主動“什麼都不做”的建議都不能讓人滿意。因此除了“什麼都不做”,鑑於我們目前的啓發式和實現,你可以做一些事情。請記住,啓發式和建議都可能改變,且沒有一個代替分析。

將庫從使用代碼中分離

代碼緩存粗略的在每個腳本上完成,意味着腳本的每一部分改動都會導致整個腳本的緩存失效。如果你將穩定的部分和經常變動的部分放在一個腳本文件中,例如:庫和業務邏輯,業務邏輯代碼的改變會使庫代碼的緩存也無效。

因此,你可以分離穩定的庫代碼到一個單獨的腳本,且單獨的加載它。這樣庫代碼一旦被緩存,並在業務邏輯代碼改變的時候保持緩存。

如果你的庫在你網站的不同的頁面被共享,這樣做還有其他的收益:由於代碼緩存附加到腳本,因此庫的代碼換在也在頁面之間共享。

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

代碼緩存在每個腳本執行後完成,意味着一個腳本的代碼緩存包含了當腳本執行完編譯後的那些函數。這對庫代碼有幾個重要意義:

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

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

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

對此一個解決方案是合併庫和使用它們的代碼到單個腳本中,以至於代碼緩存可以“發現”庫的那些部分被使用。不幸的是,這與上一條建議相違背,因爲沒有銀彈。通常來說,我們不建議將所有 JS 腳本合併到一個大的 bundle 中,將其分成多個較小腳本往往更有利於除代碼緩存之外的其他原因(如:多個網絡請求、流編譯、頁面交互等)。

利用 IIFE 啓發式

只有在代碼執行完成時編譯的代碼纔會被加入到代碼緩存,因此有許多類型的函數儘管稍後執行,但不會被緩存。事件處理程序(甚至是 onload)、promise 鏈、未使用的庫函數和其他一些延遲編譯而沒有在執行到 </script> 之前被調用的,都會保持延遲而不會被執行。

一種方法強制這些函數被緩存就是強制它們被編譯,且一個常用的強制編譯方法是使用 IIFE 啓發式。IIFE(立即執行函數表達式)是一種創建函數後立即點用函數的模式。

  1. (function foo() {

  2. // …

  3. })();

因爲 IIFE 表達式會被立即調用,爲了避免支付延遲編譯的成本,大多數 JavaScript 引擎會嘗試探測它們並立即編譯,然後進行完全編譯。有各種啓發式可以儘早探測出 IIFE 表達式(在函數被解析之前),最常用的是通過 function 關鍵字之前的 (

因爲這個啓發式在早期被應用,所以即使函數實際不是立即執行也會被編譯:

  1. const foo = function() {

  2. // Lazily skipped

  3. };

  4. const bar = (function() {

  5. // Eagerly compiled

  6. });

這意味着可以通過將那些應該被緩存的函數包裹在括號裏強制加入到緩存中。但是,如果不正確的使用,可能會對網頁啓動時間產生影響,通常來說這有點濫用啓發式,因此除非真的有必要,我們不建議這麼做。

合併小文件

Chrome 有個代碼緩存的最小文件大小限制,現在是 1 Kib 。這意味着小於 1 Kib 的腳本不能被緩存,因爲我們認爲開銷大於收益。

如果你的網站有很多小的腳本,則開銷計算可能不在以相同的方式進行。你應該考慮合併小文件使它們超出最小代碼大小,並從常規的減少腳本開銷的方式受益。

避免使用內聯腳本

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

使用 service worker 緩存

service worker 是一種讓你的代碼可以攔截你頁面中的網絡資源請求的一種機制。特別是,它們可以讓你構建本地資源緩存,當你發送請求的時候,會從本地緩存提供資源。如果你想構建離線應用這點特別有用,例如:PWA 應用。

一個典型的栗子,網站使用 service worker 在主腳本中註冊 service worker:

  1. // main.mjs

  2. navigator.serviceWorker.register('/sw.js');

service worker 爲安裝(創建資源)和獲取(從潛在的緩存提供資源)事件添加處理程序。

  1. // sw.js

  2. self.addEventListener('install', (event) => {

  3. async function buildCache() {

  4. const cache = await caches.open(cacheName);

  5. return cache.addAll([

  6. '/main.css',

  7. '/main.mjs',

  8. '/offline.html',

  9. ]);

  10. }

  11. event.waitUntil(buildCache());

  12. });


  13. self.addEventListener('fetch', (event) => {

  14. async function cachedFetch(event) {

  15. const cache = await caches.open(cacheName);

  16. let response = await cache.match(event.request);

  17. if (response) return response;

  18. response = await fetch(event.request);

  19. cache.put(event.request, response.clone());

  20. return response;

  21. }

  22. event.respondWith(cachedFetch(event));

  23. });

這些緩存包括 JS 資源緩存。然而,因爲我們希望 service worker 的緩存主要用於 PWA,所以它與 Chrome 的“自動”緩存的啓發式有略微不同。首先,當 JS 資源被添加到緩存的時候,它們立即創建代碼緩存,這意味着在第二次加載的時候代碼緩存是可用的(而不是像普通緩存一樣僅在第三次加載的時可用)。其次,我們爲這些腳本生成了“全量”代碼緩存,不在有延遲編譯,而是全部編譯好放到緩存中。這具有快速且可預測的性能的優點,沒有執行順序依賴性,但是以增加的內存使用爲代價。請注意,此啓發式僅適用於 service worker 緩存,而不適用於 Cache API 的其他用途。實際上,當在 service worker外面使用時,現在的 Cache API 不會執行代碼緩存。

Tracing

上面的那些建議都不能保證提升你 web 應用的速度。不幸的是,代碼緩存信息現在還沒有暴露到 Devtool 中,因此最可靠的方式去查看你 web 應用的腳本緩存是使用 chrome://tracing

chrome://tracing 記錄了一段時間內的 Chrome 追蹤信息,它生成的追蹤結果可視化如下:

chrome://tracing 記錄的暖緩存 UI

Tracing 記錄着整個瀏覽器的行爲,包含其他 tab、窗口和擴展程序,因此最好在乾淨的用戶配置——沒有其他擴展程序安裝且沒有其他 tab 頁打開的時候,完成分析:

  1. # 開始一次乾淨的用戶配置的 Chrome 瀏覽會話

  2. google-chrome --user-data-dir="$(mktemp -d)"

當收集追蹤信息時,你需要選中追蹤類別。在大多數情況下,你可以簡單的選中 "web developer" 這個類別,但你也可以手動選擇類別。代碼追蹤的重要類別是 v8 。

當記錄了一次 v8 類別的追蹤時,在追蹤結果中查看 v8.compile 片段(或者你可以都搜索框中輸入 v8.compile)。它會列出編譯後的文件,已經編譯的元數據。

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

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

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


總結

對於大多數開發人員來說,代碼緩存應該“正常工作”。當事物保持不變時,它就像任何緩存一樣工作得最好,並且它工作在不同版本可以發生變化的啓發式方法上。 儘管如此,代碼緩存確實具有可以使用的行爲,可以避免的限制以及使用 chrome://tracing 的仔細分析可以幫助你調整和優化 Web 應用程序對緩存的使用。

原文:https://v8.dev/blog/code-caching-for-devs

推薦閱讀:

異步(async)函數和 promise 性能優化

Orinoco: V8的垃圾回收器

V8 v7.4 重大更新:支持無 JIT 模式

V8 發佈 v7.4


請關注我們的公衆號


相關文章