一文讀懂前端緩存
大家都知道緩存的英文叫做 cache。但我發(fā)現(xiàn)一個有趣的現(xiàn)象:這個單詞在不同人的口中有不同的讀音。為了全面了解緩存,我們得先從讀音開始,這樣才能夠在和其他同事(例如 PM)交(zhuāng)流(bī)時體現(xiàn)自己的修(bī)養(yǎng)(gé)。
cache 怎么念
在國外 IT 圈和大部分國外視頻中,cache 的發(fā)音是 /kæʃ/(同 cash),這也是一個廣泛認(rèn)可的發(fā)音。但我發(fā)現(xiàn)在中國的 IT 圈還有相當(dāng)一部分程序員(比如我自己……)讀作 /kætʃ/(同 catch)。雖然不太正確,但并不妨礙互相交流。(不過為了純正,還是應(yīng)該向正確的方向靠攏)
此外還有一些小眾的讀法,例如 /keɪʃ/(同 kaysh),甚至 /kæʃeɪ/(像個法語發(fā)音,重音在后面)。這些因為太小眾了,可能會引起溝通障礙,估計只有在特定場合或者特定圈子才能順暢使用。
前端緩存/后端緩存
扯了些沒用的,我們先進(jìn)入定義環(huán)節(jié):什么是前端緩存?與之相對的什么又是后端緩存?
基本的網(wǎng)絡(luò)請求就是三個步驟:請求,處理,響應(yīng)。
后端緩存主要集中于“處理”步驟,通過保留數(shù)據(jù)庫連接,存儲處理結(jié)果等方式縮短處理時間,盡快進(jìn)入“響應(yīng)”步驟。當(dāng)然這不在本文的討論范圍之內(nèi)。
而前端緩存則可以在剩下的兩步:“請求”和“響應(yīng)”中進(jìn)行。在“請求”步驟中,瀏覽器也可以通過存儲結(jié)果的方式直接使用資源,直接省去了發(fā)送請求;而“響應(yīng)”步驟需要瀏覽器和服務(wù)器共同配合,通過減少響應(yīng)內(nèi)容來縮短傳輸時間。這些都會在下面進(jìn)行討論。
本文主要包含
- 按緩存位置分類 (memory cache, disk cache, Service Worker 等)
- 按失效策略分類 (Cache-Control, ETag 等)
- 幫助理解原理的一些案例
- 緩存的應(yīng)用模式
按緩存位置分類
我看過的大部分討論緩存的文章會直接從 HTTP 協(xié)議頭中的緩存字段開始,例如 Cache-Control, ETag, max-age 等。但偶爾也會聽到別人討論 memory cache, disk cache 等。那這兩種分類體系究竟有何關(guān)聯(lián)?是否有交叉?(我個人認(rèn)為這是本文的最大價值所在,因為在寫之前我自己也是被兩種分類體系搞的一團(tuán)糟)
實際上,HTTP 協(xié)議頭的那些字段,都屬于 disk cache 的范疇,是幾個緩存位置的其中之一。因此本著從全局到局部的原則,我們應(yīng)當(dāng)先從緩存位置開始討論。等講到 disk cache 時,才會詳細(xì)講述這些協(xié)議頭的字段及其作用。
我們可以在 Chrome 的開發(fā)者工具中,Network -> Size 一列看到一個請求最終的處理方式:如果是大小 (多少 K, 多少 M 等) 就表示是網(wǎng)絡(luò)請求,否則會列出 from memory cache, from disk cache 和 from ServiceWorker。
它們的優(yōu)先級是:(由上到下尋找,找到即返回;找不到則繼續(xù))
- Service Worker
- Memory Cache
- Disk Cache
- 網(wǎng)絡(luò)請求
memory cache
memory cache 是內(nèi)存中的緩存,(與之相對 disk cache 就是硬盤上的緩存)。按照操作系統(tǒng)的常理:先讀內(nèi)存,再讀硬盤。disk cache 將在后面介紹 (因為它的優(yōu)先級更低一些),這里先討論 memory cache。
幾乎所有的網(wǎng)絡(luò)請求資源都會被瀏覽器自動加入到 memory cache 中。但是也正因為數(shù)量很大但是瀏覽器占用的內(nèi)存不能無限擴(kuò)大這樣兩個因素,memory cache 注定只能是個“短期存儲”。常規(guī)情況下,瀏覽器的 TAB 關(guān)閉后該次瀏覽的 memory cache 便告失效 (為了給其他 TAB 騰出位置)。而如果極端情況下 (例如一個頁面的緩存就占用了超級多的內(nèi)存),那可能在 TAB 沒關(guān)閉之前,排在前面的緩存就已經(jīng)失效了。
剛才提過,幾乎所有的請求資源 都能進(jìn)入 memory cache,這里細(xì)分一下主要有兩塊:
preloader。如果你對這個機(jī)制不太了解,這里做一個簡單的介紹,詳情可以參閱這篇文章。
熟悉瀏覽器處理流程的同學(xué)們應(yīng)該了解,在瀏覽器打開網(wǎng)頁的過程中,會先請求 HTML 然后解析。之后如果瀏覽器發(fā)現(xiàn)了 js, css 等需要解析和執(zhí)行的資源時,它會使用 CPU 資源對它們進(jìn)行解析和執(zhí)行。在古老的年代(大約 2007 年以前),“請求 js/css - 解析執(zhí)行 - 請求下一個 js/css - 解析執(zhí)行下一個 js/css” 這樣的“串行”操作模式在每次打開頁面之前進(jìn)行著。很明顯在解析執(zhí)行的時候,網(wǎng)絡(luò)請求是空閑的,這就有了發(fā)揮的空間:我們能不能一邊解析執(zhí)行 js/css,一邊去請求下一個(或下一批)資源呢?
這就是 preloader 要做的事情。不過 preloader 沒有一個官方標(biāo)準(zhǔn),所以每個瀏覽器的處理都略有區(qū)別。例如有些瀏覽器還會下載 css 中的 @import 內(nèi)容或者 <video> 的 poster等。
而這些被 preloader 請求夠來的資源就會被放入 memory cache 中,供之后的解析執(zhí)行操作使用。
preload (雖然看上去和剛才的 preloader 就差了倆字母)。實際上這個大家應(yīng)該更加熟悉一些,例如 <link rel="preload">。這些顯式指定的預(yù)加載資源,也會被放入 memory cache 中。
memory cache 機(jī)制保證了一個頁面中如果有兩個相同的請求 (例如兩個 src 相同的 <img>,兩個 href 相同的 <link>)都實際只會被請求最多一次,避免浪費(fèi)。
不過在匹配緩存時,除了匹配完全相同的 URL 之外,還會比對他們的類型,CORS 中的域名規(guī)則等。因此一個作為腳本 (script) 類型被緩存的資源是不能用在圖片 (image) 類型的請求中的,即便他們 src 相等。
在從 memory cache 獲取緩存內(nèi)容時,瀏覽器會忽視例如 max-age=0, no-cache 等頭部配置。例如頁面上存在幾個相同 src 的圖片,即便它們可能被設(shè)置為不緩存,但依然會從 memory cache 中讀取。這是因為 memory cache 只是短期使用,大部分情況生命周期只有一次瀏覽而已。而 max-age=0 在語義上普遍被解讀為“不要在下次瀏覽時使用”,所以和 memory cache 并不沖突。
但如果站長是真心不想讓一個資源進(jìn)入緩存,就連短期也不行,那就需要使用 no-store。存在這個頭部配置的話,即便是 memory cache 也不會存儲,自然也不會從中讀取了。(后面的第二個示例有關(guān)于這點的體現(xiàn))
disk cache
disk cache 也叫 HTTP cache,顧名思義是存儲在硬盤上的緩存,因此它是持久存儲的,是實際存在于文件系統(tǒng)中的。而且它允許相同的資源在跨會話,甚至跨站點的情況下使用,例如兩個站點都使用了同一張圖片。
disk cache 會嚴(yán)格根據(jù) HTTP 頭信息中的各類字段來判定哪些資源可以緩存,哪些資源不可以緩存;哪些資源是仍然可用的,哪些資源是過時需要重新請求的。當(dāng)命中緩存之后,瀏覽器會從硬盤中讀取資源,雖然比起從內(nèi)存中讀取慢了一些,但比起網(wǎng)絡(luò)請求還是快了不少的。絕大部分的緩存都來自 disk cache。
關(guān)于 HTTP 的協(xié)議頭中的緩存字段,我們會在稍后進(jìn)行詳細(xì)討論。
凡是持久性存儲都會面臨容量增長的問題,disk cache 也不例外。在瀏覽器自動清理時,會有神秘的算法去把“最老的”或者“最可能過時的”資源刪除,因此是一個一個刪除的。不過每個瀏覽器識別“最老的”和“最可能過時的”資源的算法不盡相同,可能也是它們差異性的體現(xiàn)。
Service Worker
上述的緩存策略以及緩存/讀取/失效的動作都是由瀏覽器內(nèi)部判斷 & 進(jìn)行的,我們只能設(shè)置響應(yīng)頭的某些字段來告訴瀏覽器,而不能自己操作。舉個生活中去銀行存/取錢的例子來說,你只能告訴銀行職員,我要存/取多少錢,然后把由他們會經(jīng)過一系列的記錄和手續(xù)之后,把錢放到金庫中去,或者從金庫中取出錢來交給你。
但 Service Worker 的出現(xiàn),給予了我們另外一種更加靈活,更加直接的操作方式。依然以存/取錢為例,我們現(xiàn)在可以繞開銀行職員,自己走到金庫前(當(dāng)然是有別于上述金庫的一個單獨(dú)的小金庫),自己把錢放進(jìn)去或者取出來。因此我們可以選擇放哪些錢(緩存哪些文件),什么情況把錢取出來(路由匹配規(guī)則),取哪些錢出來(緩存匹配并返回)。當(dāng)然現(xiàn)實中銀行沒有給我們開放這樣的服務(wù)。
Service Worker 能夠操作的緩存是有別于瀏覽器內(nèi)部的 memory cache 或者 disk cache 的。我們可以從 Chrome 的 F12 中,Application -> Cache Storage 找到這個單獨(dú)的“小金庫”。除了位置不同之外,這個緩存是永久性的,即關(guān)閉 TAB 或者瀏覽器,下次打開依然還在(而 memory cache 不是)。有兩種情況會導(dǎo)致這個緩存中的資源被清除:手動調(diào)用 API cache.delete(resource) 或者容量超過限制,被瀏覽器全部清空。
如果 Service Worker 沒能命中緩存,一般情況會使用 fetch() 方法繼續(xù)獲取資源。這時候,瀏覽器就去 memory cache 或者 disk cache 進(jìn)行下一次找緩存的工作了。注意:經(jīng)過 Service Worker 的 fetch() 方法獲取的資源,即便它并沒有命中 Service Worker 緩存,甚至實際走了網(wǎng)絡(luò)請求,也會標(biāo)注為 from ServiceWorker。這個情況在后面的第三個示例中有所體現(xiàn)。
請求網(wǎng)絡(luò)
如果一個請求在上述 3 個位置都沒有找到緩存,那么瀏覽器會正式發(fā)送網(wǎng)絡(luò)請求去獲取內(nèi)容。之后容易想到,為了提升之后請求的緩存命中率,自然要把這個資源添加到緩存中去。具體來說:
- 根據(jù) Service Worker 中的 handler 決定是否存入 Cache Storage (額外的緩存位置)。
- 根據(jù) HTTP 頭部的相關(guān)字段(Cache-control, Pragma 等)決定是否存入 disk cache
- memory cache 保存一份資源 的引用,以備下次使用。
按失效策略分類
memory cache 是瀏覽器為了加快讀取緩存速度而進(jìn)行的自身的優(yōu)化行為,不受開發(fā)者控制,也不受 HTTP 協(xié)議頭的約束,算是一個黑盒。Service Worker 是由開發(fā)者編寫的額外的腳本,且緩存位置獨(dú)立,出現(xiàn)也較晚,使用還不算太廣泛。所以我們平時最為熟悉的其實是 disk cache,也叫 HTTP cache (因為不像 memory cache,它遵守 HTTP 協(xié)議頭中的字段)。平時所說的強(qiáng)制緩存,對比緩存,以及 Cache-Control 等,也都?xì)w于此類。
強(qiáng)制緩存 (也叫強(qiáng)緩存)
強(qiáng)制緩存的含義是,當(dāng)客戶端請求后,會先訪問緩存數(shù)據(jù)庫看緩存是否存在。如果存在則直接返回;不存在則請求真的服務(wù)器,響應(yīng)后再寫入緩存數(shù)據(jù)庫。
強(qiáng)制緩存直接減少請求數(shù),是提升最大的緩存策略。 它的優(yōu)化覆蓋了文章開頭提到過的請求數(shù)據(jù)的全部三個步驟。如果考慮使用緩存來優(yōu)化網(wǎng)頁性能的話,強(qiáng)制緩存應(yīng)該是首先被考慮的。
Expires
這是 HTTP 1.0 的字段,表示緩存到期時間,是一個絕對的時間 (當(dāng)前時間+緩存時間),如
可以造成強(qiáng)制緩存的字段是 Cache-control 和 Expires。
- Expires: Thu, 10 Nov 2017 08:45:11 GMT
在響應(yīng)消息頭中,設(shè)置這個字段之后,就可以告訴瀏覽器,在未過期之前不需要再次請求。
但是,這個字段設(shè)置時有兩個缺點:
- 由于是絕對時間,用戶可能會將客戶端本地的時間進(jìn)行修改,而導(dǎo)致瀏覽器判斷緩存失效,重新請求該資源。此外,即使不考慮自信修改,時差或者誤差等因素也可能造成客戶端與服務(wù)端的時間不一致,致使緩存失效。
- 寫法太復(fù)雜了。表示時間的字符串多個空格,少個字母,都會導(dǎo)致非法屬性從而設(shè)置失效。
Cache-control
已知Expires的缺點之后,在HTTP/1.1中,增加了一個字段Cache-control,該字段表示資源緩存的最大有效時間,在該時間內(nèi),客戶端不需要向服務(wù)器發(fā)送請求
這兩者的區(qū)別就是前者是絕對時間,而后者是相對時間。如下:
- Cache-control: max-age=2592000
下面列舉一些 Cache-control 字段常用的值:(完整的列表可以查看 MDN)
- max-age:即最大有效時間,在上面的例子中我們可以看到
- must-revalidate:如果超過了 max-age 的時間,瀏覽器必須向服務(wù)器發(fā)送請求,驗證資源是否還有效。
- no-cache:雖然字面意思是“不要緩存”,但實際上還是要求客戶端緩存內(nèi)容的,只是是否使用這個內(nèi)容由后續(xù)的對比來決定。
- no-store: 真正意義上的“不要緩存”。所有內(nèi)容都不走緩存,包括強(qiáng)制和對比。
- public:所有的內(nèi)容都可以被緩存 (包括客戶端和代理服務(wù)器, 如 CDN)
- private:所有的內(nèi)容只有客戶端才可以緩存,代理服務(wù)器不能緩存。默認(rèn)值。
這些值可以混合使用,例如 Cache-control:public, max-age=2592000。在混合使用時,它們的優(yōu)先級如下圖:
這里有一個疑問:max-age=0 和 no-cache 等價嗎?從規(guī)范的字面意思來說,max-age 到期是 應(yīng)該(SHOULD) 重新驗證,而 no-cache 是 必須(MUST) 重新驗證。但實際情況以瀏覽器實現(xiàn)為準(zhǔn),大部分情況他們倆的行為還是一致的。(如果是 max-age=0, must-revalidate 就和 no-cache 等價了)
順帶一提,在 HTTP/1.1 之前,如果想使用 no-cache,通常是使用 Pragma 字段,如 Pragma: no-cache(這也是 Pragma 字段唯一的取值)。但是這個字段只是瀏覽器約定俗成的實現(xiàn),并沒有確切規(guī)范,因此缺乏可靠性。它應(yīng)該只作為一個兼容字段出現(xiàn),在當(dāng)前的網(wǎng)絡(luò)環(huán)境下其實用處已經(jīng)很小。
總結(jié)一下,自從 HTTP/1.1 開始,Expires 逐漸被 Cache-control 取代。Cache-control 是一個相對時間,即使客戶端時間發(fā)生改變,相對時間也不會隨之改變,這樣可以保持服務(wù)器和客戶端的時間一致性。而且 Cache-control 的可配置性比較強(qiáng)大。
Cache-control 的優(yōu)先級高于 Expires,為了兼容 HTTP/1.0 和 HTTP/1.1,實際項目中兩個字段我們都會設(shè)置。
對比緩存 (也叫協(xié)商緩存)
當(dāng)強(qiáng)制緩存失效(超過規(guī)定時間)時,就需要使用對比緩存,由服務(wù)器決定緩存內(nèi)容是否失效。
流程上說,瀏覽器先請求緩存數(shù)據(jù)庫,返回一個緩存標(biāo)識。之后瀏覽器拿這個標(biāo)識和服務(wù)器通訊。如果緩存未失效,則返回 HTTP 狀態(tài)碼 304 表示繼續(xù)使用,于是客戶端繼續(xù)使用緩存;如果失效,則返回新的數(shù)據(jù)和緩存規(guī)則,瀏覽器響應(yīng)數(shù)據(jù)后,再把規(guī)則寫入到緩存數(shù)據(jù)庫。
對比緩存在請求數(shù)上和沒有緩存是一致的,但如果是 304 的話,返回的僅僅是一個狀態(tài)碼而已,并沒有實際的文件內(nèi)容,因此 在響應(yīng)體體積上的節(jié)省是它的優(yōu)化點。它的優(yōu)化覆蓋了文章開頭提到過的請求數(shù)據(jù)的三個步驟中的最后一個:“響應(yīng)”。通過減少響應(yīng)體體積,來縮短網(wǎng)絡(luò)傳輸時間。所以和強(qiáng)制緩存相比提升幅度較小,但總比沒有緩存好。
對比緩存是可以和強(qiáng)制緩存一起使用的,作為在強(qiáng)制緩存失效后的一種后備方案。實際項目中他們也的確經(jīng)常一同出現(xiàn)。
對比緩存有 2 組字段(不是兩個):
Last-Modified & If-Modified-Since
1、服務(wù)器通過 Last-Modified 字段告知客戶端,資源最后一次被修改的時間,例如
- Last-Modified: Mon, 10 Nov 2018 09:10:11 GMT
2、瀏覽器將這個值和內(nèi)容一起記錄在緩存數(shù)據(jù)庫中。
3、下一次請求相同資源時時,瀏覽器從自己的緩存中找出“不確定是否過期的”緩存。因此在請求頭中將上次的 Last-Modified 的值寫入到請求頭的 If-Modified-Since 字段
4、服務(wù)器會將 If-Modified-Since 的值與 Last-Modified 字段進(jìn)行對比。如果相等,則表示未修改,響應(yīng) 304;反之,則表示修改了,響應(yīng) 200 狀態(tài)碼,并返回數(shù)據(jù)。
但是他還是有一定缺陷的:
- 如果資源更新的速度是秒以下單位,那么該緩存是不能被使用的,因為它的時間單位最低是秒。
- 如果文件是通過服務(wù)器動態(tài)生成的,那么該方法的更新時間永遠(yuǎn)是生成的時間,盡管文件可能沒有變化,所以起不到緩存的作用。
Etag & If-None-Match
為了解決上述問題,出現(xiàn)了一組新的字段 Etag 和 If-None-Match
Etag 存儲的是文件的特殊標(biāo)識(一般都是 hash 生成的),服務(wù)器存儲著文件的 Etag 字段。之后的流程和 Last-Modified 一致,只是 Last-Modified 字段和它所表示的更新時間改變成了 Etag 字段和它所表示的文件 hash,把 If-Modified-Since 變成了 If-None-Match。服務(wù)器同樣進(jìn)行比較,命中返回 304, 不命中返回新資源和 200。
Etag 的優(yōu)先級高于 Last-Modified
緩存小結(jié)
當(dāng)瀏覽器要請求資源時
- 調(diào)用 Service Worker 的 fetch 事件響應(yīng)
- 查看 memory cache
- 查看 disk cache。這里又細(xì)分:
- 如果有強(qiáng)制緩存且未失效,則使用強(qiáng)制緩存,不請求服務(wù)器。這時的狀態(tài)碼全部是 200
- 如果有強(qiáng)制緩存但已失效,使用對比緩存,比較后確定 304 還是 200
- 發(fā)送網(wǎng)絡(luò)請求,等待網(wǎng)絡(luò)響應(yīng)
- 把響應(yīng)內(nèi)容存入 disk cache (如果 HTTP 頭信息配置可以存的話)
- 把響應(yīng)內(nèi)容 的引用 存入 memory cache (無視 HTTP 頭信息的配置)
- 把響應(yīng)內(nèi)容存入 Service Worker 的 Cache Storage (如果 Service Worker 的腳本調(diào)用了 cache.put())
一些案例
光看原理不免枯燥。我們編寫一些簡單的網(wǎng)頁,通過案例來深刻理解上面的那些原理。
1、memory cache & disk cache
我們寫一個簡單的 index.html,然后引用 3 種資源,分別是 index.js, index.css 和 mashroom.jpg。
我們給這三種資源都設(shè)置上 Cache-control: max-age=86400,表示強(qiáng)制緩存 24 小時。以下截圖全部使用 Chrome 的隱身模式。
首次請求
毫無意外的全部走網(wǎng)絡(luò)請求,因為什么緩存都還沒有。
再次請求 (F5)
第二次請求,三個請求都來自 memory cache。因為我們沒有關(guān)閉 TAB,所以瀏覽器把緩存的應(yīng)用加到了 memory cache。(耗時 0ms,也就是 1ms 以內(nèi))
關(guān)閉 TAB,打開新 TAB 并再次請求
因為關(guān)閉了 TAB,memory cache 也隨之清空。但是 disk cache 是持久的,于是所有資源來自 disk cache。(大約耗時 3ms,因為文件有點小)
而且對比 2 和 3,很明顯看到 memory cache 還是比 disk cache 快得多的。
2、no-cache & no-store
我們在 index.html 里面一些代碼,完成兩個目標(biāo):
- 每種資源都(同步)請求兩次
- 增加腳本異步請求圖片
- <!-- 把3種資源都改成請求兩次 -->
- <link rel="stylesheet" href="/static/index.css">
- <link rel="stylesheet" href="/static/index.css">
- <script src="/static/index.js"></script>
- <script src="/static/index.js"></script>
- <img src="/static/mashroom.jpg">
- <img src="/static/mashroom.jpg">
- <!-- 異步請求圖片 -->
- <script>
- setTimeout(function () {
- let img = document.createElement('img')
- img.src = '/static/mashroom.jpg'
- document.body.appendChild(img)
- }, 1000)
- </script>
當(dāng)把服務(wù)器響應(yīng)設(shè)置為 Cache-Control: no-cache 時,我們發(fā)現(xiàn)打開頁面之后,三種資源都只被請求 1 次。
這說明兩個問題:
- 同步請求方面,瀏覽器會自動把當(dāng)次 HTML 中的資源存入到緩存 (memory cache),這樣碰到相同 src 的圖片就會自動讀取緩存(但不會在 Network 中顯示出來)
- 異步請求方面,瀏覽器同樣是不發(fā)請求而直接讀取緩存返回。但同樣不會在 Network 中顯示。
總體來說,如上面原理所述,no-cache 從語義上表示下次請求不要直接使用緩存而需要比對,并不對本次請求進(jìn)行限制。因此瀏覽器在處理當(dāng)前頁面時,可以放心使用緩存。
當(dāng)把服務(wù)器響應(yīng)設(shè)置為 Cache-Control: no-store 時,情況發(fā)生了變化,三種資源都被請求了 2 次。而圖片因為還多一次異步請求,總計 3 次。(紅框中的都是那一次異步請求)
這同樣說明:
- 如之前原理所述,雖然 memory cache 是無視 HTTP 頭信息的,但是 no-store 是特別的。在這個設(shè)置下,memory cache 也不得不每次都請求資源。
- 異步請求和同步遵循相同的規(guī)則,在 no-store 情況下,依然是每次都發(fā)送請求,不進(jìn)行任何緩存。
3、Service Worker & memory (disk) cache
我們嘗試把 Service Worker 也加入進(jìn)去。我們編寫一個 serviceWorker.js,并編寫如下內(nèi)容:(主要是預(yù)緩存 3 個資源,并在實際請求時匹配緩存并返回)
- // serviceWorker.js
- self.addEventListener('install', e => {
- // 當(dāng)確定要訪問某些資源時,提前請求并添加到緩存中。
- // 這個模式叫做“預(yù)緩存”
- e.waitUntil(
- caches.open('service-worker-test-precache').then(cache => {
- return cache.addAll(['/static/index.js', '/static/index.css', '/static/mashroom.jpg'])
- })
- )
- })
- self.addEventListener('fetch', e => {
- // 緩存中能找到就返回,找不到就網(wǎng)絡(luò)請求,之后再寫入緩存并返回。
- // 這個稱為 CacheFirst 的緩存策略。
- return e.respondWith(
- caches.open('service-worker-test-precache').then(cache => {
- return cache.match(e.request).then(matchedResponse => {
- return matchedResponse || fetch(e.request).then(fetchedResponse => {
- cache.put(e.request, fetchedResponse.clone())
- return fetchedResponse
- })
- })
- })
- )
- })
注冊 SW 的代碼這里就不贅述了。此外我們還給服務(wù)器設(shè)置 Cache-Control: max-age=86400 來開啟 disk cache。我們的目的是看看兩者的優(yōu)先級。
當(dāng)我們首次訪問時,會看到常規(guī)請求之外,瀏覽器(確切地說是 Service Worker)額外發(fā)出了 3 個請求。這來自預(yù)緩存的代碼。
第二次訪問(無論關(guān)閉 TAB 重新打開,還是直接按 F5 刷新)都能看到所有的請求標(biāo)記為 from SerciceWorker。
from ServiceWorker 只表示請求通過了 Service Worker,至于到底是命中了緩存,還是繼續(xù) fetch() 方法光看這一條記錄其實無從知曉。因此我們還得配合后續(xù)的
Network 記錄來看。因為之后沒有額外的請求了,因此判定是命中了緩存。
從服務(wù)器的日志也能很明顯地看到,3 個資源都沒有被重新請求,即命中了 Service Worker 內(nèi)部的緩存。
如果修改 serviceWorker.js 的 fetch 事件監(jiān)聽代碼,改為如下:
- // 這個也叫做 NetworkOnly 的緩存策略。
- self.addEventListener('fetch', e => {
- return e.respondWith(fetch(e.request))
- })
可以發(fā)現(xiàn)在后續(xù)訪問時的效果和修改前是 完全一致的。(即 Network 僅有標(biāo)記為 from ServiceWorker 的幾個請求,而服務(wù)器也不打印 3 個資源的訪問日志)
很明顯 Service Worker 這層并沒有去讀取自己的緩存,而是直接使用 fetch() 進(jìn)行請求。所以此時其實是 Cache-Control: max-age=86400 的設(shè)置起了作用,也就是 memory/disk cache。但具體是 memory 還是 disk 這個只有瀏覽器自己知道了,因為它并沒有顯式的告訴我們。(個人猜測是 memory,因為不論從耗時 0ms 還是從不關(guān)閉 TAB 來看,都更像是 memory cache)
瀏覽器的行為
所謂瀏覽器的行為,指的就是用戶在瀏覽器如何操作時,會觸發(fā)怎樣的緩存策略。主要有 3 種:
- 打開網(wǎng)頁,地址欄輸入地址: 查找 disk cache 中是否有匹配。如有則使用;如沒有則發(fā)送網(wǎng)絡(luò)請求。
- 普通刷新 (F5):因為 TAB 并沒有關(guān)閉,因此 memory cache 是可用的,會被優(yōu)先使用(如果匹配的話)。其次才是 disk cache。
- 強(qiáng)制刷新 (Ctrl + F5):瀏覽器不使用緩存,因此發(fā)送的請求頭部均帶有 Cache-control: no-cache(為了兼容,還帶了 Pragma: no-cache)。服務(wù)器直接返回 200 和最新內(nèi)容。
緩存的應(yīng)用模式
了解了緩存的原理,我們可能更加關(guān)心如何在實際項目中使用它們,才能更好的讓用戶縮短加載時間,節(jié)約流量等。這里有幾個常用的模式,供大家參考
模式 1:不常變化的資源
- Cache-Control: max-age=31536000
通常在處理這類資源資源時,給它們的 Cache-Control 配置一個很大的 max-age=31536000 (一年),這樣瀏覽器之后請求相同的 URL 會命中強(qiáng)制緩存。而為了解決更新的問題,就需要在文件名(或者路徑)中添加 hash, 版本號等動態(tài)字符,之后更改動態(tài)字符,達(dá)到更改引用 URL 的目的,從而讓之前的強(qiáng)制緩存失效 (其實并未立即失效,只是不再使用了而已)。
在線提供的類庫 (如 jquery-3.3.1.min.js, lodash.min.js 等) 均采用這個模式。如果配置中還增加 public 的話,CDN 也可以緩存起來,效果拔群。
這個模式的一個變體是在引用 URL 后面添加參數(shù) (例如 ?v=xxx 或者 ?_=xxx),這樣就不必在文件名或者路徑中包含動態(tài)參數(shù),滿足某些完美主義者的喜好。在項目每次構(gòu)建時,更新額外的參數(shù) (例如設(shè)置為構(gòu)建時的當(dāng)前時間),則能保證每次構(gòu)建后總能讓瀏覽器請求最新的內(nèi)容。
特別注意: 在處理 Service Worker 時,對待 sw-register.js(注冊 Service Worker) 和 serviceWorker.js (Service Worker 本身) 需要格外的謹(jǐn)慎。如果這兩個文件也使用這種模式,你必須多多考慮日后可能的更新及對策。
模式 2:經(jīng)常變化的資源
- Cache-Control: no-cache
這里的資源不單單指靜態(tài)資源,也可能是網(wǎng)頁資源,例如博客文章。這類資源的特點是:URL 不能變化,但內(nèi)容可以(且經(jīng)常)變化。我們可以設(shè)置 Cache-Control: no-cache 來迫使瀏覽器每次請求都必須找服務(wù)器驗證資源是否有效。
既然提到了驗證,就必須 ETag 或者 Last-Modified 出場。這些字段都會由專門處理靜態(tài)資源的常用類庫(例如 koa-static)自動添加,無需開發(fā)者過多關(guān)心。
也正如上文中提到協(xié)商緩存那樣,這種模式下,節(jié)省的并不是請求數(shù),而是請求體的大小。所以它的優(yōu)化效果不如模式 1 來的顯著。
模式 3:非常危險的模式 1 和 2 的結(jié)合 (反例)
- Cache-Control: max-age=600, must-revalidate
不知道是否有開發(fā)者從模式 1 和 2 獲得一些啟發(fā):模式 2 中,設(shè)置了 no-cache,相當(dāng)于 max-age=0, must-revalidate。我的應(yīng)用時效性沒有那么強(qiáng),但又不想做過于長久的強(qiáng)制緩存,我能不能配置例如 max-age=600, must-revalidate 這樣折中的設(shè)置呢?
表面上看這很美好:資源可以緩存 10 分鐘,10 分鐘內(nèi)讀取緩存,10 分鐘后和服務(wù)器進(jìn)行一次驗證,集兩種模式之大成,但實際線上暗存風(fēng)險。因為上面提過,瀏覽器的緩存有自動清理機(jī)制,開發(fā)者并不能控制。
舉個例子:當(dāng)我們有 3 種資源: index.html, index.js, index.css。我們對這 3 者進(jìn)行上述配置之后,假設(shè)在某次訪問時,index.js 已經(jīng)被緩存清理而不存在,但 index.html, index.css 仍然存在于緩存中。這時候瀏覽器會向服務(wù)器請求新的 index.js,然后配上老的 index.html, index.css 展現(xiàn)給用戶。這其中的風(fēng)險顯而易見:不同版本的資源組合在一起,報錯是極有可能的結(jié)局。
除了自動清理引發(fā)問題,不同資源的請求時間不同也能導(dǎo)致問題。例如 A 頁面請求的是 A.js 和 all.css,而 B 頁面是 B.js 和 all.css。如果我們以 A -> B 的順序訪問頁面,勢必導(dǎo)致 all.css 的緩存時間早于 B.js。那么以后訪問 B 頁面就同樣存在資源版本失配的隱患。
后記
這篇文章真心有點長,但已經(jīng)囊括了前端緩存的絕大部分,包括 HTTP 協(xié)議中的緩存,Service Worker,以及 Chrome 瀏覽器的一些優(yōu)化 (Memory Cache)。希望開發(fā)者們善用緩存,因為它往往是最容易想到,提升也最大的性能優(yōu)化策略。
參考文章
A Tale of Four Caches(但這篇文章把 Service Worker 的優(yōu)先級排在 memory cache 和 disk cache 之間,跟我實驗效果并不相符。懷疑可能是 2 年來 chrome 策略的修改?)
Caching best practices & max-age gotchas