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

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

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

原文做者 Leszek Swirski 給那些但願經過充分利用代碼緩存來提高網站啓動效率的 JS 開發者們提供了幾條建議,這些建議側重於 Chrome/V8 中的代碼緩存實現,其中的大多數原理也一樣適用於其餘瀏覽器的代碼緩存實現,也具有較高的參考價值,但願對你們能有所啓發,內容翻譯以下:css

代碼緩存概述

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

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

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

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

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

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

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

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

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

    總結以下圖:        代碼緩存能夠被分爲冷運行、暖運行和熱運行,暖運行發生在內存緩存中,熱運行發生在硬盤緩存中。

基於上述內容,咱們就能夠提供幾條建議來提升網站對代碼緩存的利用率。

建議 1:什麼都不作

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

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

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

不要改變代碼

這也許是顯而易見的,可是仍是值得討論 —— 每當你添加了一行新代碼,那麼新代碼就尚未被緩存。每當瀏覽器經過 HTTP 請求一個腳本 URL 的時候,它能夠包含上一次請求該 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 (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 追蹤信息,其生成的可視化追蹤結果以下:

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。



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

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


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


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


總結

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

相關文章
相關標籤/搜索