緩存可以減少網絡 IO 消耗,提高訪問速度。瀏覽器緩存是一種操作簡單、效果顯著的前端性能優化手段。對于這個操作的必要性,Chrome 官方給出的解釋似乎更有說服力一些:
> 通過網絡獲取內容既速度緩慢又開銷巨大。較大的響應需要在客戶端與服務器之間進行多次往返通信,這會延遲瀏覽器獲得和處理內容的時間,還會增加訪問者的流量費用。因此,緩存并重復利用之前獲取的資源的能力成為性能優化的一個關鍵方面。
很多時候,大家傾向于將瀏覽器緩存簡單地理解為“HTTP 緩存”。但事實上,瀏覽器緩存機制有四個方面,它們按照獲取資源時請求的優先級依次排列如下:
1. Memory Cache
2. Service Worker Cache
3. HTTP Cache
4. Push Cache
大家對 HTTP Cache(即 Cache-Control、expires 等字段控制的緩存)應該比較熟悉,如果對其它幾種緩存可能還沒什么概念,我們可以先來看一張線上網站的 Network 面板截圖:

我們給 size 這一欄一個特寫:

大家注意一下非數字——即形如“(from xxx)”這樣的描述——對應的資源,這些資源就是我們通過緩存獲取到的。其中,“from memory cache”對標到 Memory Cache 類型,“from ServiceWorker”對標到 Service Worker Cache 類型。至于 Push Cache,這個比較特殊,是 HTTP2 的新特性。
本節將會針對這四個方面各個擊破。考慮到 HTTP 緩存是最主要、最具有代表性的緩存策略,也是每一位前端工程師都應該深刻理解掌握的性能優化知識點,我們下面優先針對 HTTP 緩存機制進行剖析。
## HTTP 緩存機制探秘
HTTP 緩存是我們日常開發中最為熟悉的一種緩存機制。它又分為**強緩存**和**協商緩存**。優先級較高的是強緩存,在命中強緩存失敗的情況下,才會走協商緩存。
### 強緩存的特征
強緩存是利用 http 頭中的 Expires 和 Cache-Control 兩個字段來控制的。強緩存中,當請求再次發出時,瀏覽器會根據其中的 expires 和 cache-control 判斷目標資源是否“命中”強緩存,若命中則直接從緩存中獲取資源,**不會再與服務端發生通信。**
命中強緩存的情況下,返回的 HTTP 狀態碼為 200 (如下圖)。

### 強緩存的實現:從 expires 到 cache-control
實現強緩存,過去我們一直用 `expires`。
當服務器返回響應時,在 Response Headers 中將過期時間寫入 expires 字段。像這樣:

我們給 expires 一個特寫:
```
expires: Wed, 11 Sep 2019 16:12:18 GMT
```
可以看到,expires 是一個時間戳,接下來如果我們試圖再次向服務器請求資源,瀏覽器就會先對比本地時間和 expires 的時間戳,如果本地時間小于 expires 設定的過期時間,那么就直接去緩存中取這個資源。
從這樣的描述中大家也不難猜測,expires 是有問題的,它最大的問題在于對“本地時間”的依賴。如果服務端和客戶端的時間設置可能不同,或者我直接手動去把客戶端的時間改掉,那么 expires 將無法達到我們的預期。
考慮到 expires 的局限性,HTTP1.1 新增了 `Cache-Control` 字段來完成 expires 的任務。
expires 能做的事情,Cache-Control 都能做;expires 完成不了的事情,Cache-Control 也能做。因此,Cache-Control 可以視作是 expires 的**完全替代方案**。在當下的前端實踐里,我們繼續使用 expires 的唯一目的就是**向下兼容**。
現在我們給 Cache-Control 字段一個特寫:
```
cache-control: max-age=31536000
```
如大家所見,在 Cache-Control 中,我們通過 `max-age` 來控制資源的有效期。max-age 不是一個時間戳,而是一個時間長度。在本例中,max-age 是 31536000 秒,它意味著該資源在 31536000 秒以內都是有效的,完美地規避了時間戳帶來的潛在問題。
**Cache-Control 相對于 expires 更加準確,它的優先級也更高。當 Cache-Control 與 expires 同時出現時,我們以 Cache-Control 為準。**
### Cache-Control 應用分析
Cache-Control 的神通,可不止于這一個小小的 max-age。如下的用法也非常常見:
```
cache-control: max-age=3600, s-maxage=31536000
```
**s-maxage 優先級高于 max-age,兩者同時出現時,優先考慮 s-maxage。如果 s-maxage 未過期,則向代理服務器請求其緩存內容。**
這個 s-maxage 不像 max-age 一樣為大家所熟知。的確,在項目不是特別大的場景下,max-age 足夠用了。但在依賴各種**代理**的大型架構中,我們不得不考慮**代理服務器**的緩存問題。s-maxage 就是用于表示 cache 服務器上(比如 cache CDN)的緩存的有效時間的,并只對 public 緩存有效。
(10.24晚更新。感謝評論區@敖天羽的補充,此處應注意這樣一個細節:s-maxage僅在代理服務器中生效,客戶端中我們只考慮max-age。)
那么什么是 public 緩存呢?說到這里,Cache-Control 中有一些適合放在一起理解的知識點,我們集中梳理一下:
#### public 與 private
public 與 private 是針對資源是否能夠被代理服務緩存而存在的一組對立概念。
如果我們為資源設置了 public,那么它既可以被瀏覽器緩存,也可以被代理服務器緩存;如果我們設置了 private,則該資源只能被瀏覽器緩存。private 為**默認值**。但多數情況下,public 并不需要我們手動設置,比如有很多線上網站的 cache-control 是這樣的:

設置了 s-maxage,沒設置 public,那么 CDN 還可以緩存這個資源嗎?答案是肯定的。因為明確的緩存信息(例如“max-age”)已表示響應是可以緩存的。
#### no-store與no-cache
no-cache 繞開了瀏覽器:我們為資源設置了 no-cache 后,每一次發起請求都不會再去詢問瀏覽器的緩存情況,而是直接向服務端去確認該資源是否過期(即走我們下文即將講解的協商緩存的路線)。
no-store 比較絕情,顧名思義就是不使用任何緩存策略。在 no-cache 的基礎上,它連服務端的緩存確認也繞開了,只允許你直接向服務端發送請求、并下載完整的響應。
### 協商緩存:瀏覽器與服務器合作之下的緩存策略
協商緩存依賴于服務端與瀏覽器之間的通信。
協商緩存機制下,瀏覽器需要向服務器去詢問緩存的相關信息,進而判斷是重新發起請求、下載完整的響應,還是從本地獲取緩存的資源。
如果服務端提示緩存資源未改動(Not Modified),資源會被**重定向**到瀏覽器緩存,**這種情況下網絡請求對應的狀態碼是 304**(如下圖)。

### 協商緩存的實現:從 Last-Modified 到 Etag
Last-Modified 是一個時間戳,如果我們啟用了協商緩存,它會在首次請求時隨著 Response Headers 返回:
```
Last-Modified: Fri, 27 Oct 2017 06:35:57 GMT
```
隨后我們每次請求時,會帶上一個叫 If-Modified-Since 的時間戳字段,它的值正是上一次 response 返回給它的 last-modified 值:
```
If-Modified-Since: Fri, 27 Oct 2017 06:35:57 GMT
```
服務器接收到這個時間戳后,會比對該時間戳和資源在服務器上的最后修改時間是否一致,從而判斷資源是否發生了變化。如果發生了變化,就會返回一個完整的響應內容,并在 Response Headers 中添加新的 Last-Modified 值;否則,返回如上圖的 304 響應,Response Headers 不會再添加 Last-Modified 字段。
使用 Last-Modified 存在一些弊端,這其中最常見的就是這樣兩個場景:
* 我們編輯了文件,但文件的內容沒有改變。服務端并不清楚我們是否真正改變了文件,它仍然通過最后編輯時間進行判斷。因此這個資源在再次被請求時,會被當做新資源,進而引發一次完整的響應——不該重新請求的時候,也會重新請求。
* 當我們修改文件的速度過快時(比如花了 100ms 完成了改動),由于 If-Modified-Since 只能檢查到以秒為最小計量單位的時間差,所以它是感知不到這個改動的——該重新請求的時候,反而沒有重新請求了。
這兩個場景其實指向了同一個 bug——服務器并沒有正確感知文件的變化。為了解決這樣的問題,Etag 作為 Last-Modified 的補充出現了。
Etag 是由服務器為每個資源生成的唯一的**標識字符串**,這個標識字符串是基于文件內容編碼的,只要文件內容不同,它們對應的 Etag 就是不同的,反之亦然。因此 Etag 能夠精準地感知文件的變化。
Etag 和 Last-Modified 類似,當首次請求時,我們會在響應頭里獲取到一個最初的標識符字符串,舉個??,它可以是這樣的:
```
ETag: W/"2a3b-1602480f459"
```
那么下一次請求時,請求頭里就會帶上一個值相同的、名為 if-None-Match 的字符串供服務端比對了:
```
If-None-Match: W/"2a3b-1602480f459"
```
Etag 的生成過程需要服務器額外付出開銷,會影響服務端的性能,這是它的弊端。因此啟用 Etag 需要我們審時度勢。正如我們剛剛所提到的——Etag 并不能替代 Last-Modified,它只能作為 Last-Modified 的補充和強化存在。 **Etag 在感知文件變化上比 Last-Modified 更加準確,優先級也更高。當 Etag 和 Last-Modified 同時存在時,以 Etag 為準。**
## HTTP 緩存決策指南
行文至此,當代 HTTP 緩存技術用到的知識點,我們已經從頭到尾挖掘了一遍了。那么在面對一個具體的緩存需求時,我們到底該怎么決策呢?
走到決策建議這一步,我本來想給大家重新畫一個流程圖。但是畫來畫去終究不如 Chrome 官方給出的這張清晰、權威:

我們現在一起解讀一下這張流程圖:
當我們的資源內容不可復用時,直接為 Cache-Control 設置 no-store,拒絕一切形式的緩存;否則考慮是否每次都需要向服務器進行緩存有效確認,如果需要,那么設 Cache-Control 的值為 no-cache;否則考慮該資源是否可以被代理服務器緩存,根據其結果決定是設置為 private 還是 public;然后考慮該資源的過期時間,設置對應的 max-age 和 s-maxage 值;最后,配置協商緩存需要用到的 Etag、Last-Modified 等參數。
我個人非常推崇這張流程圖給出的決策建議,也強烈推薦大家在理解以上知識點的基礎上,將這張圖保存下來、在日常開發中用用看,它的可行度非常高。
OK,走到這里,本節最大的一座山已經被大家翻過去了。接下來的內容會相對比較輕松,大家放松心情,我們繼續前行!
## MemoryCache
MemoryCache,是指存在內存中的緩存。從優先級上來說,它是瀏覽器最先嘗試去命中的一種緩存。從效率上來說,它是響應速度最快的一種緩存。
內存緩存是快的,也是“短命”的。它和渲染進程“生死相依”,當進程結束后,也就是 tab 關閉以后,內存里的數據也將不復存在。
那么哪些文件會被放入內存呢?
事實上,這個劃分規則,一直以來是沒有定論的。不過想想也可以理解,內存是有限的,很多時候需要先考慮即時呈現的內存余量,再根據具體的情況決定分配給內存和磁盤的資源量的比重——資源存放的位置具有一定的隨機性。
雖然劃分規則沒有定論,但根據日常開發中觀察的結果,包括我們開篇給大家展示的 Network 截圖,我們至少可以總結出這樣的規律:資源存不存內存,瀏覽器秉承的是“節約原則”。我們發現,Base64 格式的圖片,幾乎永遠可以被塞進 memory cache,這可以視作瀏覽器為節省渲染開銷的“自保行為”;此外,體積不大的 JS、CSS 文件,也有較大地被寫入內存的幾率——相比之下,較大的 JS、CSS 文件就沒有這個待遇了,內存資源是有限的,它們往往被直接甩進磁盤。
## Service Worker Cache
Service Worker 是一種獨立于主線程之外的 Javascript 線程。它脫離于瀏覽器窗體,因此無法直接訪問 DOM。這樣獨立的個性使得 Service Worker 的“個人行為”無法干擾頁面的性能,這個“幕后工作者”可以幫我們實現離線緩存、消息推送和網絡代理等功能。我們借助 Service worker 實現的離線緩存就稱為 Service Worker Cache。
Service Worker 的生命周期包括 install、active、working 三個階段。一旦 Service Worker 被 install,它將始終存在,只會在 active 與 working 之間切換,除非我們主動終止它。這是它可以用來實現離線存儲的重要先決條件。
下面我們就通過實戰的方式,一起見識一下 Service Worker 如何為我們實現離線緩存(注意看注釋): 我們首先在入口文件中插入這樣一段 JS 代碼,用以判斷和引入 Service Worker:
```
window.navigator.serviceWorker.register('/test.js').then(
function () {
console.log('注冊成功')
}).catch(err => {
console.error("注冊失敗")
})
```
在 test.js 中,我們進行緩存的處理。假設我們需要緩存的文件分別是 test.html,test.css 和 test.js:
```
// Service Worker會監聽 install事件,我們在其對應的回調里可以實現初始化的邏輯
self.addEventListener('install', event => {
event.waitUntil(
// 考慮到緩存也需要更新,open內傳入的參數為緩存的版本號
caches.open('test-v1').then(cache => {
return cache.addAll([
// 此處傳入指定的需緩存的文件名
'/test.html',
'/test.css',
'/test.js'
])
})
)
})
// Service Worker會監聽所有的網絡請求,網絡請求的產生觸發的是fetch事件,我們可以在其對應的監聽函數中實現對請求的攔截,進而判斷是否有對應到該請求的緩存,實現從Service Worker中取到緩存的目的
self.addEventListener('fetch', event => {
event.respondWith(
// 嘗試匹配該請求對應的緩存值
caches.match(event.request).then(res => {
// 如果匹配到了,調用Server Worker緩存
if (res) {
return res;
}
// 如果沒匹配到,向服務端發起這個資源請求
return fetch(event.request).then(response => {
if (!response || response.status !== 200) {
return response;
}
// 請求成功的話,將請求緩存起來。
caches.open('test-v1').then(function(cache) {
cache.put(event.request, response);
});
return response.clone();
});
})
);
});
```
**PS**:大家注意 Server Worker 對協議是有要求的,必須以 https 協議為前提。
## Push Cache
> 預告:本小節定位為基礎科普向,對 Push Cache 有深入挖掘興趣的同學,強烈推薦拓展閱讀 Chrome 工程師 Jake Archibald 的這篇 [HTTP/2 push is tougher than I thought](https://jakearchibald.com/2017/h2-push-tougher-than-i-thought/)。
Push Cache 是指 HTTP2 在 server push 階段存在的緩存。這塊的知識比較新,應用也還處于萌芽階段,我找了好幾個網站也沒找到一個合適的案例來給大家做具體的介紹。但應用范圍有限不代表不重要——HTTP2 是趨勢、是未來。在它還未被推而廣之的此時此刻,我仍希望大家能對 Push Cache 的關鍵特性有所了解:
* Push Cache 是緩存的最后一道防線。瀏覽器只有在 Memory Cache、HTTP Cache 和 Service Worker Cache 均未命中的情況下才會去詢問 Push Cache。
* Push Cache 是一種存在于會話階段的緩存,當 session 終止時,緩存也隨之釋放。
* 不同的頁面只要共享了同一個 HTTP2 連接,那么它們就可以共享同一個 Push Cache。
更多的特性和應用,期待大家可以在日后的開發過程中去挖掘和實踐。
## 小結
小建議!很多同學在學習緩存這塊知識的時候可能多少會有這樣的感覺:對瀏覽器緩存,只能描述個大致,卻說不上深層原理;好不容易記住了每個字段怎么用,過幾天又給忘了。這是因為緩存部分的知識,具有“細碎、迭代快”的特點。對于這樣的知識,我們應該嘗試先劃分出層次和重點,歸納出完整的體系,然后針對每個知識點去各個擊破。
終于結束了對緩存世界的探索,不知道大家有沒有一種意猶未盡的感覺。開篇我們談過,緩存非常重要,它幾乎是我們性能優化的首選方案。
但頁面的數據存儲方案除了緩存,還有本地存儲。在下一節中,我們就將圍繞本地存儲展開探索。
- 開篇:知識體系與小冊格局
- 網絡篇 1:webpack 性能調優與 Gzip 原理
- 網絡篇 2:圖片優化——質量與性能的博弈
- 存儲篇 1:瀏覽器緩存機制介紹與緩存策略剖析
- 存儲篇 2:本地存儲——從 Cookie 到 Web Storage、IndexDB
- 彩蛋篇:CDN 的緩存與回源機制解析
- 渲染篇 1:服務端渲染的探索與實踐
- 渲染篇 2:知己知彼——解鎖瀏覽器背后的運行機制
- 渲染篇 3:對癥下藥——DOM 優化原理與基本實踐
- 渲染篇 4:千方百計——Event Loop 與異步更新策略
- 渲染篇 5:最后一擊——回流(Reflow)與重繪(Repaint)
- 應用篇 1:優化首屏體驗——Lazy-Load 初探
- 應用篇 2:事件的節流(throttle)與防抖(debounce)
- 性能監測篇:Performance、LightHouse 與性能 API
- 前方的路:希望成為你的起點