閱讀原文
前言
HTTP 快取機制作為 Web 應用效能優化的重要手段,對於從事 Web 開發的同學們來說,應該是知識體系的基礎環節,也是想要成為前端架構的必備技能。
快取的作用
我們為什麼使用快取,是因為快取可以給我們的 Web 專案帶來以下好處,以提高效能和使用者體驗。
- 加快了瀏覽器載入網頁的速度;
- 減少了冗餘的資料傳輸,節省網路流量和頻寬;
- 減少伺服器的負擔,大大提高了網站的效能。
由於從本地快取讀取靜態資源,加快瀏覽器的網頁載入速度是一定的,也確實的減少了資料傳輸,就提高網站效能來說,可能一兩個使用者的訪問對於減小伺服器的負擔沒有明顯效果,但如果這個網站在高併發的情況下,使用快取對於減小伺服器壓力和整個網站的效能都會發生質的變化。
快取規則簡介
為了方便理解,我們認為瀏覽器存在一個快取資料庫,用於儲存快取資訊(實際上靜態資源是被快取到了記憶體和磁碟中),在瀏覽器第一次請求資料時,此時快取資料庫沒有對應的快取資料,則需要請求伺服器,伺服器會將快取規則和資料返回,瀏覽器將快取規則和資料儲存進快取資料庫。
當瀏覽器位址列輸入地址後請求的 index.html
是不會被快取的,但 index.html
內部請求的其他資源會遵循快取策略,HTTP 快取有多種規則,根據是否需要向伺服器傳送請求主要分為兩大類,強制快取和協商快取。
強制快取
1、強制快取流程
強制快取是第一次訪問伺服器獲取資料後,在有效時間內不會再請求伺服器,而是直接使用快取資料,強制快取的流程如下。
2、強制快取判斷到期時間
那麼如何判斷快取是否到期呢?其實還是根據第一次訪問時伺服器的響應頭來實現的,在 HTTP 1.0
版本和 HTTP 1.1
版本有所不同。
在 HTTP 1.0
版本,伺服器使用的響應頭欄位為 Expires
,值為未來的絕對時間(時間戳),瀏覽器請求時的當前時間超過了 Expires
設定的時間,代表快取失效,需要再次向伺服器傳送請求,否則都會直接從快取資料庫中獲取資料。
在 HTTP 1.1
版本,伺服器使用的響應頭欄位為 Cache-Control
,有多個值,意義各不相同。
- private:客戶端可以快取;
- public:客戶端和代理伺服器都可以快取(對於前端而言,可以認為與
private
效果相同); - max-age=xxx:快取的內容將在
xxx
秒後過期(相對時間,秒為單位); - no-cache:需要使用協商快取(後面介紹)來驗證資料是否過期;
- no-store:所有內容都不會快取,強制快取和協商快取都不會觸發。
Cache-Control
的值中最常用的為 max-age=xxx
,快取本身就是為了資料傳輸的優化和效能而存在的,所以 no-store
幾乎不會使用。
注意:在 HTTP 1.0
版本中,Expires
欄位的絕對時間是從伺服器獲取的,由於請求需要時間,所以瀏覽器的請求時間與伺服器接收到請求所獲取的時間是存在誤差的,這也導致了快取命中的誤差,在 HTTP 1.1
版本中,因為 Cache-Control
的值 max-age=xxx
中的 xxx
是以秒為單位的相對時間,所以在瀏覽器接收到資源後開始倒數計時,規避了 HTTP 1.0
中快取命中存在誤差的缺點,為了相容低版本 HTTP 協議,正常開發中兩種響應頭會同時使用,HTTP 1.1
版本的實現優先順序高於 HTTP 1.0
。
3、通過 Network 檢視強制快取
我們通過 Chrome 瀏覽器的開發者工具,開啟 NetWork 檢視強制快取的相關資訊。
上面是百度網站 Logo 圖片的響應,我們可以清楚的看到,其中相容了 HTTP 1.0
和 HTTP 1.1
版本,並使用強制快取儲存了 10
年。
下面看一看通過快取取出的資料在 Network 中與其他資源的區別。
其實快取的儲存是記憶體和磁碟兩個位置,由當前瀏覽器本身的策略決定,比較隨機,從記憶體的快取中取出的資料會顯示 (from memory cache)
,從磁碟的快取中取出的資料會顯示 (from disk cache)
。
4、NodeJS 伺服器實現強制快取
// 強制快取
const http = require("http");
const url = require("url");
const path = require("path");
const mime = require("mime");
const fs = require("fs");
let server = http.createServer((req, res) => {
let { pathname } = url.parse(req.url, true);
pathname = pathname !== "/" ? pathname : "/index.html";
// 獲取讀取檔案的絕對路徑
let p = path.join(__dirname, pathname);
// 檢視路徑是否合法
fs.access(p, err => {
// 路徑不合法則直接中斷連線
if (err) return res.end("Not Found");
// 設定強制快取
res.setHeader("Expires", new Date(Date.now() + 30000).toGMTString());
res.setHeader("Cache-Control", "max-age=30");
// 設定檔案型別並響應給瀏覽器
res.setHeader("Content-Type", `${mime.getType(p)};charset=utf8`);
fs.createReadStream(p).pipe(res);
});
});
server.listen(3000, () => {
console.log("server start 3000");
});
複製程式碼
上面 mime
模組的 getType
方法可以成功返回傳入路徑下檔案對應的檔案型別,如 text/html
和 application/javascript
等,是第三方模組,使用之前需要安裝。
npm install mime
協商快取
1、協商快取流程
協商快取又叫對比快取,設定協商快取後,第一次訪問伺服器獲取資料時,伺服器會將資料和快取標識一起返回給瀏覽器,客戶端會將資料和標識存入快取資料庫中,下一次請求時,會先去快取中取出快取標識傳送給伺服器進行詢問,當伺服器資料更改時會更新標識,所以伺服器拿到瀏覽器發來的標識進行對比,相同代表資料未更改,響應瀏覽器通知資料未更改,瀏覽器會去快取中獲取資料,如果標識不同,代表伺服器更改過資料,所以會將新的資料和新的標識返回瀏覽器,瀏覽器會將新的資料和標識存入快取中,協商快取的流程如下。
協商快取和強制快取不同的是,協商快取每次請求都需要跟伺服器通訊,而且命中快取伺服器返回狀態碼不再是 200
,而是 304
。
2、協商快取判斷標識
強制快取是通過過期時間來控制是否訪問伺服器,而協商快取每次都要與伺服器互動對比快取標識,同樣的,對於協商快取的實現在 HTTP 1.0
版本和 HTTP 1.1
版本也有所不同。
在 HTTP 1.0
版本中,伺服器通過 Last-Modified
響應頭來設定快取標識,通常取請求資料的最後修改時間(絕對時間)作為值,而瀏覽器將接收到返回的資料和標識存入快取,再次請求會自動傳送 If-Modified-Since
請求頭,值為之前返回的最後修改時間(標識),伺服器取出 If-Modified-Since
的值與資料的上次修改時間對比,如果上次修改時間大於了 If-Modified-Since
的值,說明被修改過,則通過 Last-Modified
響應頭返回新的最後修改時間和新的資料,否則未被修改,返回狀態碼 304
通知瀏覽器命中快取。
在 HTTP 1.1
版本中,伺服器通過 Etag
響應頭來設定快取標識(唯一標識,像一個指紋一樣,生成規則由伺服器來決定),瀏覽器接收到資料和唯一標識後存入快取,下次請求時,通過 If-None-Match
請求頭將唯一標識帶給伺服器,伺服器取出唯一標識與之前的標識對比,不同,說明修改過,返回新標識和資料,相同,則返回狀態碼 304
通知瀏覽器命中快取。
HTTP 協商快取策略流程圖如下:
注意:使用協商快取時 HTTP 1.0
版本還是不太靠譜,假設一個檔案增加了一個字元後又刪除了,檔案相當於沒更改,但是最後修改時間變了,會被當作修改處理,本應該命中快取,伺服器卻重新傳送了資料,因此 HTTP 1.1
中使用的 Etag
唯一標識是根據檔案內容或摘要生成的,保證了只要檔案內容不變,則一定會命中快取,為了相容低版本 HTTP 協議,開發中兩種響應頭也會同時使用,同樣 HTTP 1.1
版本的實現優先順序高於 HTTP 1.0
。
3、通過 Network 檢視協商快取
我們同樣通過 Chrome 瀏覽器的開發者工具,開啟 NetWork 檢視協商快取的相關資訊。
再次請求伺服器的請求頭資訊:
命中協商快取的響應頭資訊:
下面看一看通過協商快取取出的資料在 Network 中與第一次載入的區別。
第一次請求:
快取後請求:
通過兩圖的對比,我們可以發現,協商快取生效時的狀態碼為 304
,並且報文大小和請求時間大大減少,原因是服務端在進行標識比對後只返回了 header
部分,通過狀態碼來通知瀏覽器使用快取,不再需要將報文主體部分一起返回給瀏覽器。
4、NodeJS 伺服器實現協商快取
// 協商快取
const http = require("http");
const url = require("url");
const path = require("path");
const mime = require("mime");
const fs = require("fs");0
const crytpo = require("crytpo");
let server = http.createServer((req, res) => {
let { pathname } = url.parse(req.url, true);
pathname = pathname !== "/" ? pathname : "/index.html";
// 獲取讀取檔案的絕對路徑
let p = path.join(__dirname, pathname);
// 檢視路徑是否合法
fs.stat(p, (err, statObj) => {
// 路徑不合法則直接中斷連線
if (err) return res.end("Not Found");
let md5 = crypto.createHash("md5"); // 建立加密的轉換流
let rs = fs.createReadStream(p); // 建立可讀流
// 讀取檔案內容並加密
rs.on("data", data => md5.update(data));
rs.on("end", () => {
let ctime = statObj.ctime.toGMTString(); // 獲取檔案最後修改時間
let flag = md5.digest("hex"); // 獲取加密後的唯一標識
// 獲取協商快取的請求頭
let ifModifiedSince = req.headers["if-modified-since"];
let ifNoneMatch = req.headers["if-none-match"];
if (ifModifiedSince === ctime || ifNoneMatch === flag) {
res.statusCode = 304;
res.end();
} else {
// 設定協商快取
res.setHeader("Last-Modified", ctime);
res.setHeader("Etag", flag);
// 設定檔案型別並響應給瀏覽器
res.setHeader("Content-Type", `${mime.getType(p)};charset=utf8`);
rs.pipe(res);
}
});
});
});
server.listen(3000, () => {
console.log("server start 3000");
});
複製程式碼
在上面的程式碼中是通過可讀流讀取檔案內容,並通過 crypto
模組進行了 md5
加密後的結果作為了唯一標識,這樣就能保證只要檔案內容不變,就會命中快取,其中相容了 HTTP 1.0
和 HTTP 1.1
兩個版本,只要滿足一個則直接返回 304
通知瀏覽器命中快取。
注意:其實讀取檔案內容加密這種做法並不可取,假如讀取的是大檔案,在讀取檔案內容和進行 md5
加密這個過程會非常消耗時間,所以在開發中要針對業務的實際情況選擇可以保證伺服器效能的方式生成唯一標識,比如根據檔案的摘要。
總結
為了使快取策略更加健壯、靈活,HTTP 1.0
版本 和 HTTP 1.1
版本的快取策略會同時使用,甚至強制快取和協商快取也會同時使用,對於強制快取,伺服器通知瀏覽器一個快取時間,在快取時間內,下次請求,直接使用快取,超出有效時間,執行協商快取策略,對於協商快取,將快取資訊中的 Etag
和 Last-Modified
通過請求頭 If-None-Match
和 If-Modified-Since
傳送給伺服器,由伺服器校驗同時設定新的強制快取,校驗通過並返回 304
狀態碼時,瀏覽器直接使用快取,如果協商快取也未命中,則伺服器重新設定協商快取的標識。