Web緩存是可以自動保存常見文檔副本的HTTP設備。當Web請求抵達緩存時,如果本地有“已緩存的副本”,就可以從本地存儲設備而不是原始服務器中提取這個文檔。
上面是《HTTP權威指南》中對Web緩存的定義,緩存的好處主要有以下幾點:
- 減少了冗余數據的傳輸;
- 減少了客戶端的網絡請求,也降低了原始服務器的壓力;
- 降低了時延,頁面加載更快。
總結一下就是省流量,省帶寬,還賊快。那么緩存是如何工作的呢?客戶端和服務端是如何協調緩存的時效性的呢?下面我們用代碼來一步一步揭曉緩存的工作原理。
一、瀏覽器緩存
當我們在瀏覽器地址欄敲入localhost:8080/test.txt
並回車時,我們是向指定的服務端發起對text.txt
文件的請求,
服務端在接收到這個請求之后,找到了這個文件並准備返回給客戶端,並通過設置Cache-Control
和Expires
兩個response header
告訴客戶端這個文件要緩存下來,在過期之前別跟我要了。
首先我們看一下項目目錄:
|-- Cache
|-- index.js
|-- assets
|-- index.html
|-- test.txt
具體實現代碼如下:
<!-- index.html -->
...
<a href="./test.txt">test.txt</a>
...
// index.js
const http = require('http');
const path = require('path');
const fs = require('fs');
http.createServer((req, res) => {
const requestUrl = path.join(__dirname, '/assets', path.normalize(req.url));
fs.stat(requestUrl, (err, stats) => {
if (err || !stats.isFile) {
res.writeHead(404, 'Not Found');
res.end();
} else {
const readStream = fs.createReadStream(requestUrl);
const maxAge = 10;
const expireDate = new Date(
new Date().getTime() + maxAge * 1000
).toUTCString();
res.setHeader('Cache-Control', `max-age=${maxAge}, public`);
res.setHeader('Expires', expireDate);
readStream.pipe(res);
}
});
}).listen(8080);
那Cache-Control
和Expires
這個兩個response header又代表什么意思呢?Cache-Control:max-age=500
表示設置緩存存儲的最大周期為500秒,超過這個時間緩存被認為過期。Expires:Tue, 23 Feb 2021 01:23:48 GMT
表示在Tue, 23 Feb 2021 01:23:48 GMT
這個日期之后文檔過期。
啟動server后,在瀏覽器訪問localhost:8080/index.html
,這時是第一次訪問,沒有緩存,所以服務器返回完整的資源。
我們點擊超鏈接訪問test.txt
:
因為是第一次訪問,所以沒有緩存,這個時候我們點擊返回按鈕回到index.html
:
發現不同了嗎?這個時候NetWork中Size已經變成了disk cache
,說明命中了瀏覽器緩存,也就是強緩存,這個時候再點擊超鏈接訪問test.txt
,如果在設置的過期時間10s以內,就能看到命中瀏覽器緩存,如果超過10s,就會重新從服務器獲取資源。
這里說明一點,瀏覽器的前進后退按鈕會一直從緩存中讀取資源,而忽略設置的緩存規則。也就是說剛才如果我從localhost:8080/test.txt
頁面通過瀏覽器返回按鈕回到localhost:8080/index.html
頁面,會發現不管過多久Network都是disk cache
,同樣再點擊瀏覽器前進按鈕進入localhost:8080/test.txt
頁面,哪怕超過設置的過期時間也還是from disk cache。
注意:
Cache-Control
的優先級大於Expires
,因為時差原因還有服務端時間和客戶端時間可能不一致會導致Expires
判斷緩存有效性不准確。但是Expires
兼容http1.0,Cache-Control
兼容到http1.1,所以一般還是兩個都設置。
二、協商緩存
上面我們設置過緩存時限后,如果緩存過期了怎么辦呢?你可能會說,過期了就重新從服務端獲取資源啊。但是也有可能緩存時間過期了,但是資源並沒有變化,所以我們還要引入其他的策略來處理這種情況,那就是協商緩存也就是弱緩存。
我們梳理一下協商緩存的流程:

當服務端第一次返回資源時,除了設置Cache-Control
和Expires
響應頭之外,還會設置Last-Modified
(資源更新時間)和ETag
(資源摘要或資源版本)兩個響應頭,分別代表資源的最近一次變更時間和實體標簽。當客戶端沒有命中強緩存時,會重新像服務端發起請求,並攜帶If-modified-Since
和If-None-Match
兩個請求頭,服務端拿到這兩個請求頭會跟之前設置的Last-Modified
和ETag
作比較,如果不匹配,說明緩存不可用,重新返回資源,反之說明緩存有效,返回304
響應碼,告知緩存可以繼續使用,並更新緩存有效時間。
下面我們看一下具體代碼實現:
const http = require('http');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
// 生成entity digest
function generateDigest(requestUrl) {
let hash = '2jmj7l5rSw0yVb/vlWAYkK/YBwk';
let len = 0;
fs.readFile(requestUrl, (err, data) => {
if (err) {
console.error(error);
throw new Error(err);
} else {
len = Buffer.byteLength(data, 'utf8');
hash = crypto
.createHash('sha1')
.update(data, 'utf-8')
.digest('base64')
.substring(0, 27);
}
});
return '"' + len.toString(16) + '-' + hash + '"';
}
// 響應文件
function responseFile(requestUrl, stats, res) {
const readStream = fs.createReadStream(requestUrl);
const maxAge = 10;
const expireDate = new Date(
new Date().getTime() + maxAge * 1000
).toUTCString();
res.setHeader('Cache-Control', `max-age=${maxAge}, public`);
res.setHeader('Expires', expireDate);
res.setHeader('Last-Modified', stats.mtime);
res.setHeader('ETag', generateDigest(requestUrl));
readStream.pipe(res);
}
// 判斷新鮮度
function isFresh(requestUrl, stats, req) {
const ifModifiedSince = req.headers['if-modified-since'];
const ifNoneMatch = req.headers['if-none-match'];
if (!ifModifiedSince && !ifNoneMatch) {
//如果沒有相應的請求頭,應該返回全新的資源
return false;
} else if (ifNoneMatch && ifNoneMatch !== generateDigest(requestUrl)) {
//如果ETag不匹配(資源內容發生改變),表示緩存不新鮮
return false;
} else if (ifModifiedSince && ifModifiedSince !== stats.mtime.toString()) {
//如果資源更新時間不匹配,表示緩存不新鮮
return false;
}
return true;
}
http.createServer((req, res) => {
const requestUrl = path.join(__dirname, '/assets', path.normalize(req.url));
fs.stat(requestUrl, (err, stats) => {
if (err || !stats.isFile) {
res.writeHead(404, 'Not Found');
res.end();
} else {
if (isFresh(requestUrl, stats, req)) {
// 緩存新鮮,告知客戶端沒有緩存可用,不返回響應實體
res.writeHead(304, 'Not Modified');
res.end();
} else {
// 緩存不新鮮,重新返回資源
responseFile(requestUrl, stats, res);
}
}
});
}).listen(8080);
從代碼中可以看到ETag
和Last-Modified
都是用於協商緩存的校驗的,ETag
基於實體標簽,一般可以通過版本號,或者資源摘要來指定;Last-Modified
則是基於資源的最后修改時間。
這時訪問localhost:8080/test.txt
文件,當命中強緩存后,等待10s鍾,再次訪問,服務器返回304
,而非200
,表明協商緩存生效。
此時修改test.txt文件,再次訪問,服務器返回200
,頁面展示最新的test.txt
文件內容。
總結一下:
ETag
能更精確地判斷資源到底有沒有變化,且優先級高於Last-Modified
;- 基於摘要實現的
ETag
相對較慢,更占資源; Last-Modified
精確到秒,對亞秒級的資源更新的緩存新鮮度判斷無能為力;ETag
兼容到http1.1
,Last-Modified
兼容到http1.0
。
注意:本文中通過超鏈接訪問
test.txt
是因為,如果直接在地址欄訪問該資源,瀏覽器會在request headers
中設置cache-control:max-age=0
,這樣永遠不會命中瀏覽器緩存。本文測試瀏覽器:Chrome 版本 88.0.4324.192