經過 koa2 服務器實踐探究瀏覽器HTTP緩存機制

瀏覽器 HTTP 緩存是一種常見的 web 性能優化的手段,也是在前端面試中常常被考察的一個知識點。本文經過配置 koa2 服務器,在實踐中帶你探究瀏覽器的 HTTP 緩存機制。javascript

先來直觀認識一下瀏覽器 HTTP 緩存:css

v2EX首頁首次加載

上面是打開瀏覽器後直接訪問 V2EX 首頁後的截圖,矩形圈起來的那塊也就是 size 部分顯示的都是 from disk cached,說明這些資源命中了強緩存,強緩存的狀態碼都是 200。html

再來看看我直接訪問上面箭頭指向的那張圖片是什麼狀況:前端

演示協商緩存
能夠看到返回碼是 304,而且請求的時候帶上了協商緩存用於協商的兩個請求頭 if-modified-since 和 if-none-match,命中了協商緩存。可能有部份讀者看到這裏不太理解我前面提到的強緩存和協商緩存是什麼鬼,不要緊,看到最後再回過頭來看,你就天然能清晰的看懂我上面圈起來的東西和提到的一些不懂術語。

緩存判斷規則是怎麼實現的

其實全部的網絡協議都是一套規範,客戶端和服務器端是怎麼只是按照規範來實現而已。瀏覽器 HTTP 緩存也是如此,瀏覽器在開發的時候便按照 HTTP 緩存規範來開發,咱們開發的 HTTP 服務器也應該遵照其規範。固然了,服務器是你本身寫的,你徹底能夠不按規範來,可是瀏覽器不知道你在搞什麼名堂啊,HTTP 緩存確定不會正常工做了。java

咱們知道瀏覽器和服務器進行交互的時候會發送一些請求數據和響應數據,咱們稱之爲HTTP報文。git

HTTP報文結構
與緩存相關的規則信息就包含在報文首部中。下面是 chrome network 面板中的信息:

network報文結構說明

瀏覽器的 HTTP 緩存協議本質上就經過請求響應過程當中在首部中攜帶那些和緩存相關的字段來實現的。github

瀏覽器 HTTP 緩存的分類

瀏覽器 HTTP 緩存分兩鍾:web

  1. 強緩存
  2. 協商緩存

強緩存指的是瀏覽器在本地斷定緩存有無過時,未過時直接從內存或磁盤讀取緩存,整個過程不須要和服務器通訊。面試

協商緩存須要向服務器發送一次協商請求,請求時帶上和協商緩存相關的請求頭,由服務器判斷緩存是否過時,未過時就返回狀態碼 304,瀏覽器當發現響應的返回碼是 304,也直接是讀取本地緩存,若是服務器斷定過時就直接返回請求資源和 last-modified,狀態碼爲 200。算法

瀏覽器請求資源時斷定緩存的簡略流程以下圖:

瀏覽器 HTTP 緩存判斷過程

文字解釋一下:當瀏覽器請求一個資源時,瀏覽器會先從內存中或者磁盤中查看是否有該資源的緩存。若是沒有緩存,可能瀏覽器以前沒訪問過這個資源或者緩存被清除了那隻能向服務器請求該資源。

若是有緩存,那麼就先判斷有沒有命中強緩存。若是命中了強緩存則直接使用本地緩存。若是沒有命中強緩存可是上次請求該資源時返回了和協商緩存相關的響應頭如 last-modified 那麼就帶上和協商緩存相關的請求頭髮送請求給服務器,根據服務器返回的狀態碼來斷定是否命中了協商緩存,命中了的話是用本地緩存,沒有命中則使用請求返回的內容。

強緩存和協商緩存的區別

  1. 命中時狀態碼不一樣。強緩存返回 200,協商緩存返回 304。
  2. 優先級不一樣。先斷定強緩存,強緩存判斷失敗再斷定協商緩存。
  3. 強緩存的收益高於協商緩存,由於協商緩存相對於強緩存多了一次協商請求。

演示服務器說明

整個 koa2 演示服務器在這:koa2-browser-HTTP-cache。總共就幾個文件,index.js 入口文件,index.html 首頁源代碼,sunset.jpg 和 style.css 是 index.html 用到的圖片和樣式。

目錄結構

服務器代碼代碼很簡單,使用 koa-router 配了三個路由,目前尚未寫緩存相關的代碼。

// src/index.js
const Koa = require('koa');
const Router = require('koa-router');
const mime = require('mime');
const fs = require('fs-extra');
const Path = require('path');

const app = new Koa();
const router = new Router();

// 處理首頁
router.get(/(^\/index(.html)?$)|(^\/$)/, async (ctx, next) => {
    ctx.type = mime.getType('.html');

    const content = await fs.readFile(Path.resolve(__dirname, './index.html'), 'UTF-8');
    ctx.body = content;

    await next();
});

// 處理圖片
router.get(/\S*\.(jpe?g|png)$/, async (ctx, next) => {
    const { path } = ctx;
    ctx.type = mime.getType(path);

    const imageBuffer = await fs.readFile(Path.resolve(__dirname, `.${path}`));
    ctx.body = imageBuffer;

    await next();
});

// 處理 css 文件
router.get(/\S*\.css$/, async (ctx, next) => {
    const { path } = ctx;
    ctx.type = mime.getType(path);

    const content = await fs.readFile(Path.resolve(__dirname, `.${path}`), 'UTF-8');
    ctx.body = content;

    await next();
});

app
    .use(router.routes())
    .use(router.allowedMethods());


app.listen(3000);
process.on('unhandledRejection', (err) => {
    console.error('有 promise 沒有 catch', err);
});
複製代碼

訪問首頁後頁面長這樣的:

未配置緩存訪問首頁

當前服務器沒有配置緩存,能夠看到 size 部分顯示了資源的大小,若是是命強緩存就會顯示 from memory cache 或者 from disk cache

強緩存

強緩存是 web 性能優化中收益很是高的一種手段。

強緩存相關的頭部字段

前面說過緩存協議本質上是經過請求響應頭來實現的。和強緩存相關的頭部字段有如下這些:

pragma

progma 是 HTTP1.0 時期的產物,和後面要說的 cache-control 做用差很少,它的值只能設置爲 no-cache。與 Cache-Control: no-cache 效果一致,即禁用強緩存,只能使用協商緩存。

expires

響應頭字段,包含日期/時間, 表示資源的過時時間。例如 Thu, 31 Dec 2037 23:55:55 GMT。無效的日期,好比 0,表明着過去的日期,都代表該資源已通過期。

若是在Cache-Control響應頭設置了 "max-age" 或者 "s-max-age" 指令,那麼 Expires 頭會被忽略,也就是說優先級 cacahe-control 大於 expires。

由於 expires 是一個時間值,若是服務器和客戶端是系統時間差較大,就會引發緩存混亂。

cache-control

HTTP 1.1 中增長的字段,被設計用來替代 pragma。cache-control 這個頭部字段既能夠用在請求頭也能夠用在響應頭中。

咱們都知道在 chrome 中 shift + F5 或者在 network 面板勾選了 disable cache 時瀏覽器每次加載資源時都會請求最新的資源而不是使用緩存。仔細觀察 network 面板的 request headers,發現當禁用緩存後,瀏覽器每次請求資源時會帶上 cache-control: no-cache 來告訴服務器我不須要協商緩存,你直接把最新的資源返回。

下面是我勾選了禁用緩存以後請求配置了協商緩存的圖片的截圖:

禁用緩存

cache-control 做爲響應頭字段實際上是對 expires 作了改進,cache-control 其中的一種值形式爲 cache-control: max-age=seconds,例如:cache-control: max-age=315360000。seconds 是一個時間差而不是固定的時間,由於是時間差因此不存在上面提到的 expires 的客戶端和服務器端時間不一樣步致使緩存混亂的問題。

強緩存相關的首部字段的優先級

pragma > cache-control > expires。

強緩存的具體流程

前面講過瀏覽器 HTTP 緩存的簡略流程,這裏具體講講強緩存的斷定過程。

首先,當瀏覽器發現了內存或者磁盤有你請求的資源緩存時,此時瀏覽器還會檢查該資源上一次請求時的有沒有返回上面敘述的和強緩存相關的響應頭。根據上面說的和強緩存相關的首部字段的優先級,一步一步判斷。可能有些字段服務器不會返回,好比 pragma,那就直接日後判斷。具體就是:若是 pragma: no-cache,那強緩存直接就判斷失敗了,只能走協商緩存。若是沒有 pragma,可是有 cache-control: no-cache,這就和 pragma: no-cache 同樣,強緩存判斷失敗。若是 cache-control: max-age=seconds,那麼此時就根據瀏覽器上次請求該資源的時間和 seconds 算出過時時間,若是早於過時時間也就是未過時,那麼命中強緩存,若是過時了強緩存斷定失敗。前面說過了若是 control-control 值爲 max-age 或者 s-max-age 那麼 expires 直接就無效了。當 cache-control 值不是那兩個或則沒有時,還要根據 expires 值也就是過時時間來斷定有沒有過時,沒有過時就命中強緩存,不然,緩存失效,向服務器請求最新資源。

使用 expires 配置強緩存

修改 src/index.js 中處理圖片的路由,其實就是在響應頭中加上了 expires 字段,過時時間爲 2 分鐘後。

// 處理圖片
router.get(/\S*\.(jpe?g|png)$/, async (ctx, next) => {
    const { response, path } = ctx;
    ctx.type = mime.getType(path);

    // 添加 expires 字段到響應頭,過時時間 2 分鐘
+ response.set('expires', new Date(Date.now() + 2 * 60 * 1000).toString());

    const imageBuffer = await fs.readFile(Path.resolve(__dirname, `.${path}`));
    ctx.body = imageBuffer;

    await next();
});
複製代碼

第一次訪問:

expires配置強緩存第一次訪問

注意我上面的箭頭指向的地方,鼠標左鍵長按加載按鈕會彈出三個不一樣的加載選項,尤爲是最後一個在開發時頗有用,能夠清除頁面緩存。

而後當即刷新頁面:

expires 配置強緩存生效

2 分鐘後再次刷新又是和第一張圖同樣,我就不放截圖了。能夠看出,其實配置強緩存很簡單,就是按照協議約定配置響應頭。

測試 pragram,cache-control, expires 優先級

響應頭添加 cache-control: no-cache,即不容許使用強緩存。

// 處理圖片
router.get(/\S*\.(jpe?g|png)$/, async (ctx, next) => {
    const { response, path } = ctx;
    ctx.type = mime.getType(path);

+ response.set('cache-control', 'no-cache');
    // 添加 expires 字段到響應頭,過時時間 2 分鐘
    response.set('expires', new Date(Date.now() + 2 * 60 * 1000).toString());

    const imageBuffer = await fs.readFile(Path.resolve(__dirname, `.${path}`));
    ctx.body = imageBuffer;

    await next();
});

// 處理 css 文件
router.get(/\S*\.css$/, async (ctx, next) => {
    const { path } = ctx;
    ctx.type = mime.getType(path);

    const content = await fs.readFile(Path.resolve(__dirname, `.${path}`), 'UTF-8');
    ctx.body = content;

    await next();
});
複製代碼

設置了 cache-control: no-cache 後,每次刷新都是下面截圖同樣,瀏覽器再也不使用緩存,若是使用了緩存就會像上面的那張截圖同樣在 Status Code 部分有說明。得出結論 cache-control 確實優先級比 expires 高。

設置 cache-control: max-age=60,理論上效果應該是緩存1分鐘後失效,事實證實確實如此。

注意觀察下面兩張圖的 expires 時間,第一張截圖顯示 21:33 失效。然而,因爲 cache-control 優先級更高,因此會提早一分鐘過時,因此結果就像第二張圖 21:22 分鐘緩存就失效了。

max-age1

max-age2

再來測試一下 pragma。

// 處理圖片
router.get(/\S*\.(jpe?g|png)$/, async (ctx, next) => {
    const { response, path } = ctx;
    ctx.type = mime.getType(path);

+ response.set('pragma', 'no-cache');
    // max-age 值是精確到秒,設置過時時間爲 1 分鐘
    response.set('cache-control', `max-age=${1 * 60}`);
    // 添加 expires 字段到響應頭,過時時間 2 分鐘
    response.set('expires', new Date(Date.now() + 2 * 60 * 1000).toString());

    const imageBuffer = await fs.readFile(Path.resolve(__dirname, `.${path}`));
    ctx.body = imageBuffer;

    await next();
});

// 處理 css 文件
router.get(/\S*\.css$/, async (ctx, next) => {
    const { path } = ctx;
    ctx.type = mime.getType(path);

    const content = await fs.readFile(Path.resolve(__dirname, `.${path}`), 'UTF-8');
    ctx.body = content;

    await next();
});
複製代碼

結果和設置 cache-control: no-cache 效果同樣,永遠不會使用本地緩存。因此結論就是:

pragma > cache-control > expires。

協商緩存

協商緩存因爲須要向服務器發送一次請求,因此相比於強緩存來收收益更低,緩存資源體積越大,收益越高。

和協商緩存相關的首部字段

協商緩存中那幾個首部字段是配對使用的,即:

  • 請求頭 if-modified-since 和響應頭 last-modified
  • 請求頭 if-none-match 和響應頭 etag
if-modified-sincelast-modified

它倆的值都是 GMT 格式的精確到秒的時間值。從字面上就很好理解它們的含義:自從某某時間有沒有修改過?最後一次修改時間爲某某時間

他倆有啥關係呢?其實本次請求頭 if-modified-since的值應該爲上一次請求該資源的響應頭中 last-modified 的值

當瀏覽器發起資源請求並攜帶 if-modified-since 字段,服務器會將請求頭中的 if-modified-since 值和請求資源的 最後修改時間進行比較,若是資源最後修改時間比 if-modified-since 時間晚,那麼資源過時,狀態碼爲 200,響應體爲請求資源,響應頭中加入最新的 last-modified 的值。沒過時就返回狀態碼 304,命中協商緩存,響應體爲空,響應頭不須要 last-modified 值。

if-none-match 和響應頭 etag

上面兩個是 HTTP 1.0 中處理協商緩存的首部字段,這兩個是 HTTP 1.1 纔出現的。

這裏我總結下 MDN 中對 if-none-match 的描述的幾個重點(這裏只討論GET請求資源):

  1. 當且僅當服務器上沒有任何資源的 ETag 屬性值與這個首部中列出的相匹配的時候,服務器端會才返回所請求的資源,響應碼爲 200。
  2. 服務器端在生成狀態碼爲 304 的響應的時候,會存在於對應的 200 響應中的首部:Cache-Control、Content-Location、Date、ETag、Expires 和 Vary 。
  3. If-none-match 優先級比 if-modified-since 優先級高。

etag 常見的樣子是 etag: "54984c2b-44e"。和上面那對同樣,本次請求頭 if-none-match 的值爲上一次請求該資源的響應頭中 etag 的值

有人可能看到這裏會問:etag 究竟是個啥玩意?若是你瞭解哈希摘要我以爲就很好理解了,etag 表示的是所請求資源的惟一標識符,簡單來實現就是給請求的資源經過某種 hash 算法取個摘要字符串而後用雙引號包起來就行了。

MDN 上對 etag 的描述是:

它們是位於雙引號之間的ASCII字符串(如「675af34563dc-tr34」)。 沒有明確指定生成ETag值的方法。 一般,使用內容的散列,最後修改時間戳的哈希值,或簡單地使用版本號。 例如,MDN使用wiki內容的十六進制數字的哈希值。

使用 if-none-match/etag 這對頭部字段來處理協商緩存的過程和 if-modified-since/etag 實際上是差很少。只不過比較的是 hash 值而不是日期。

爲何有了 last-modified 還須要 etag ?

  1. 資源在 1 秒內更新,而且在該一秒內訪問,使用 last-modified 處理協商緩存沒法獲取最新資源。本質上的緣由仍是由於 last-modified 是精確到秒的,沒法反映在 1 秒內的變化。
  2. 當資源屢次被修改後內容不變,使用 last-modified 來處理有點浪費。屢次修改資源,其 last-modified 值確定是會變的,可是若是內容不變咱們其實不須要服務器返回最新資源,直接使用本地緩存。使用 etag 就沒這個問題,由於同一個資源屢次修改,內容同樣, hash 值也同樣。
  3. 使用 etag 更加靈活,由於 etag 並不必定是我說的就用 hash 值,etag 採用的是弱比較算法,即兩個文件除了每一個比特都相同外,內容一致也能夠認爲是相同的。例如,若是兩個頁面僅僅在頁腳的生成時間有所不一樣,就能夠認爲兩者是相同的。

協商緩存首部字段優先級

if-none-match > if-modified-since

當服務器收到的請求中同時包含 if-modified-since 和 if-none-match 時,服務器會忽略 if-modified-since。

測試 last-modified 配置協商緩存

寫到這裏時,筆者稍微重構了下服務器代碼並使用 last-modified 配置了協商緩存:

const Koa = require('koa');
const Router = require('koa-router');
const mime = require('mime');
const fs = require('fs-extra');
const Path = require('path');

const app = new Koa();
const router = new Router();

const responseFile = async (path, context, encoding) => {
    const fileContent = await fs.readFile(path, encoding);
    context.type = mime.getType(path);
    context.body = fileContent;
};

// 處理首頁
router.get(/(^\/index(.html)?$)|(^\/$)/, async (ctx, next) => {
    await responseFile(Path.resolve(__dirname, './index.html'), ctx, 'UTF-8');
    await next();
});

// 處理圖片
router.get(/\S*\.(jpe?g|png)$/, async (ctx, next) => {
    const { request, response, path } = ctx;
    response.set('pragma', 'no-cache');

    // max-age 值是精確到秒,設置過時時間爲 1 分鐘
    // response.set('cache-control', `max-age=${1 * 60}`);
    // 添加 expires 字段到響應頭,過時時間 2 分鐘
    // response.set('expires', new Date(Date.now() + 2 * 60 * 1000).toString());

    const imagePath = Path.resolve(__dirname, `.${path}`);
    const ifModifiedSince = request.headers['if-modified-since'];
    const imageStatus = await fs.stat(imagePath);
    const lastModified = imageStatus.mtime.toGMTString();
    if (ifModifiedSince === lastModified) {
        response.status = 304;
    } else {
        response.lastModified = lastModified;
        await responseFile(imagePath, ctx);
    }

    await next();
});

// 處理 css 文件
router.get(/\S*\.css$/, async (ctx, next) => {
    const { path } = ctx;
    await responseFile(Path.resolve(__dirname, `.${path}`), ctx, 'UTF-8');
    await next();
});

app
    .use(router.routes())
    .use(router.allowedMethods());


app.listen(3000);
process.on('unhandledRejection', (err) => {
    console.error('有 promise 沒有 catch', err);
});
複製代碼

首先是禁用緩存狀況下首次訪問,能夠看到請求頭中沒有 if-modified-since,服務器返回了 last-modified。

關閉 disable cache 後再次訪問圖片時,發現帶上了 if-modified-since 請求頭,值就是上次請求響應的 last-modified 值,由於圖片最後修改時間不變,因此 304 Not Modified,。其實上面的代碼有點小毛病,在 if-modified-since 不等於 last-modified 時沒有設置 content-type,不過這些細節不影響咱們探討協商緩存核心知識。

last-modified

當我把 sunset.jpg 這張圖替換成另一張圖後,圖片最後修改時間改變了,因此返回了新的圖片而且響應頭中還加入了最新的 last-modified,下次請求帶上的 if-modified-since 就是此次返回後的 last-modified 了。

測試使用 etag 配置協商緩存

修改處理圖片的路由:

// 處理圖片
router.get(/\S*\.(jpe?g|png)$/, async (ctx, next) => {
    const { request, response, path } = ctx;
    ctx.type = mime.getType(path);
    response.set('pragma', 'no-cache');

    const ifNoneMatch = request.headers['if-none-match'];
    const imagePath = Path.resolve(__dirname, `.${path}`);
    const hash = crypto.createHash('md5');
    const imageBuffer = await fs.readFile(imagePath);
    hash.update(imageBuffer);
    const etag = `"${hash.digest('hex')}"`;
    if (ifNoneMatch === etag) {
        response.status = 304;
    } else {
        response.set('etag', etag);
        ctx.body = imageBuffer;
    }

    await next();
});
複製代碼

這裏就不放圖了,效果和使用 last-modified 差很少。

注意:我這裏的代碼只是爲了達到演示的目的,若是是真正要配置一個用於生產的緩存機制,是會對資源的 last-modified 和 etag 值創建索引緩存的,而不是像我代碼中那樣每次都去訪問文件狀態和讀取文件經過 hash 算法取 hash 值。

怎樣更新配置了強緩存的資源?

以前面春招實習的時候我在面試騰訊的時候某次2面被問過這個問題,而後答不上上來,被掛了。那次面試感受難度挺大的,影響比較深入,當時還問了怎樣作 dns 優化,也答的很差,往後抽空必定會寫一篇怎麼作 dns 優化的文章。

更新強緩存這個問題,要是之前沒研究過,忽然問你,確實有點難。其實想一想看,你要更新強緩存,若是請求的是同一個 url,瀏覽器確定在沒過時的狀況下會直接返回緩存了。因此解決辦法就是頁面中須要更新強緩存的地方對他們的 url 作文章,也就是須要更新強緩存的時候更新 url 就能夠了。由於須要能夠更新 url,因此當前頁面那麼就不能使用強緩存了,否則咋更新 url。具體咋更新 url 有不少形式好比使用版本號: "/v1-0-0/sunset.jpg",還能夠在 url 中插入資源內容的 hash 值: "/5bed2702-557a-sunset.jpg"。

舉個實例:

第一次訪問首頁 index.html 中 img 標籤的 src 爲 "/v1-0-0/sunset.jpg",當服務器上修改了 sunset.jpg 爲另一張圖片時。

再次訪問 index.html。因爲 index.html 自己這個 html 文件沒有使用強緩存,每次訪問都須要請求服務器,頁面中 src 被修改成了 "/v1-0-1/sunset.jpg",這張配置強緩存的圖片就更新過來了。

最後放一張盜來的圖,本人實在不擅長畫圖(。>︿<)_θ,這張圖比較詳細的展現了強緩存和協商緩存的斷定流程。

summary

感謝您的閱讀,若是對你有所幫助不妨加個關注,點個贊支持一下O(∩_∩)O,若是文中有什麼錯誤,歡迎在評論區指出。

本文爲原創內容,首發於我的博客,轉載請註明出處。

參考資源:

  1. 淺談HTTP緩存
  2. 面試精選之http緩存
  3. 理解http瀏覽器的協商緩存和強制緩存
相關文章
相關標籤/搜索