前端快取最佳實踐

黑金團隊發表於2019-03-03

前言

快取,這是一個老生常談的話題,也常被作為前端面試的一個知識點。

本文,重點在與探討在實際專案中,如何進行快取的設定,並給出一個較為合理的方案。

強快取和協商快取

在介紹快取的時候,我們習慣將快取分為強快取和協商快取兩種。兩者的主要區別是使用本地快取的時候,是否需要向伺服器驗證本地快取是否依舊有效。顧名思義,協商快取,就是需要和伺服器進行協商,最終確定是否使用本地快取。

前端快取最佳實踐

兩種快取方案的問題點

強快取

我們知道,強快取主要是通過http請求頭中的Cache-Control和Expire兩個欄位控制。Expire是HTTP1.0標準下的欄位,在這裡我們可以忽略。我們重點來討論的Cache-Control這個欄位。

一般,我們會設定Cache-Control的值為“public, max-age=xxx”,表示在xxx秒內再次訪問該資源,均使用本地的快取,不再向伺服器發起請求。

顯而易見,如果在xxx秒內,伺服器上面的資源更新了,客戶端在沒有強制重新整理的情況下,看到的內容還是舊的。如果說你不著急,可以接受這樣的,那是不是完美?然而,很多時候不是你想的那麼簡單的,如果釋出新版本的時候,後臺介面也同步更新了,那就gg了。有快取的使用者還在使用舊介面,而那個介面已經被後臺幹掉了。怎麼辦?

協商快取

協商快取最大的問題就是每次都要向伺服器驗證一下快取的有效性,似乎看起來很省事,不管那麼多,你都要問一下我是否有效。但是,對於一個有追求的碼農,這是不能接受的。每次都去請求伺服器,那要快取還有什麼意義。

最佳實踐

快取的意義就在於減少請求,更多地使用本地的資源,給使用者更好的體驗的同時,也減輕伺服器壓力。所以,最佳實踐,就應該是儘可能命中強快取,同時,能在更新版本的時候讓客戶端的快取失效。

在更新版本之後,如何讓使用者第一時間使用最新的資原始檔呢?機智的前端們想出了一個方法,在更新版本的時候,順便把靜態資源的路徑改了,這樣,就相當於第一次訪問這些資源,就不會存在快取的問題了。

前端快取最佳實踐

偉大的webpack可以讓我們在打包的時候,在檔案的命名上帶上hash值。

entry:{
    main: path.join(__dirname,`./main.js`),
    vendor: [`react`, `antd`]
},
output:{
    path:path.join(__dirname,`./dist`),
    publicPath: `/dist/`,
    filname: `bundle.[chunkhash].js`
}
複製程式碼

綜上所述,我們可以得出一個較為合理的快取方案:

  • HTML:使用協商快取。
  • CSS&JS&圖片:使用強快取,檔案命名帶上hash值。

雜湊也有講究

webpack給我們提供了三種雜湊值計算方式,分別是hash、chunkhash和contenthash。那麼這三者有什麼區別呢?

  • hash:跟整個專案的構建相關,構建生成的檔案hash值都是一樣的,只要專案裡有檔案更改,整個專案構建的hash值都會更改。
  • chunkhash:根據不同的入口檔案(Entry)進行依賴檔案解析、構建對應的chunk,生成對應的hash值。
  • contenthash:由檔案內容產生的hash值,內容不同產生的contenthash值也不一樣。

顯然,我們是不會使用第一種的。改了一個檔案,打包之後,其他檔案的hash都變了,快取自然都失效了。這不是我們想要的。

那chunkhash和contenthash的主要應用場景是什麼呢?在實際在專案中,我們一般會把專案中的css都抽離出對應的css檔案來加以引用。如果我們使用chunkhash,當我們改了css程式碼之後,會發現css檔案hash值改變的同時,js檔案的hash值也會改變。這時候,contenthash就派上用場了。

ETag計算

Nginx

Nginx官方預設的ETag計算方式是為”檔案最後修改時間16進位制-檔案長度16進位制”。例:ETag: “59e72c84-2404”

Express

Express框架使用了serve-static中介軟體來配置快取方案,其中,使用了一個叫etag的npm包來實現etag計算。從其原始碼可以看出,有兩種計算方式:

  • 方式一:使用檔案大小和修改時間
function stattag (stat) {
  var mtime = stat.mtime.getTime().toString(16)
  var size = stat.size.toString(16)

  return `"` + size + `-` + mtime + `"`
}
複製程式碼
  • 方式二:使用檔案內容的hash值和內容長度
function entitytag (entity) {
  if (entity.length === 0) {
    // fast-path empty
    return `"0-2jmj7l5rSw0yVb/vlWAYkK/YBwk"`
  }

  // compute hash of entity
  var hash = crypto
    .createHash(`sha1`)
    .update(entity, `utf8`)
    .digest(`base64`)
    .substring(0, 27)

  // compute length of entity
  var len = typeof entity === `string`
    ? Buffer.byteLength(entity, `utf8`)
    : entity.length

  return `"` + len.toString(16) + `-` + hash + `"`
}
複製程式碼

ETag與Last-Modified誰優先

協商快取,有ETag和Last-Modified兩個欄位。那當這兩個欄位同時存在的時候,會優先以哪個為準呢?

在Express中,使用了fresh這個包來判斷是否是最新的資源。主要原始碼如下:

function fresh (reqHeaders, resHeaders) {
  // fields
  var modifiedSince = reqHeaders[`if-modified-since`]
  var noneMatch = reqHeaders[`if-none-match`]

  // unconditional request
  if (!modifiedSince && !noneMatch) {
    return false
  }

  // Always return stale when Cache-Control: no-cache
  // to support end-to-end reload requests
  // https://tools.ietf.org/html/rfc2616#section-14.9.4
  var cacheControl = reqHeaders[`cache-control`]
  if (cacheControl && CACHE_CONTROL_NO_CACHE_REGEXP.test(cacheControl)) {
    return false
  }

  // if-none-match
  if (noneMatch && noneMatch !== `*`) {
    var etag = resHeaders[`etag`]

    if (!etag) {
      return false
    }

    var etagStale = true
    var matches = parseTokenList(noneMatch)
    for (var i = 0; i < matches.length; i++) {
      var match = matches[i]
      if (match === etag || match === `W/` + etag || `W/` + match === etag) {
        etagStale = false
        break
      }
    }

    if (etagStale) {
      return false
    }
  }

  // if-modified-since
  if (modifiedSince) {
    var lastModified = resHeaders[`last-modified`]
    var modifiedStale = !lastModified || !(parseHttpDate(lastModified) <= parseHttpDate(modifiedSince))

    if (modifiedStale) {
      return false
    }
  }

  return true
}
複製程式碼

我們可以看到,如果不是強制重新整理,而且請求頭帶上了if-modified-since和if-none-match兩個欄位,則先判斷etag,再判斷last-modified。當然,如果你不喜歡這種策略,也可以自己實現一個。

補充:後端需要怎麼設定

上文主要說的是前端如何進行打包,那後端怎麼做呢?
我們知道,瀏覽器是根據響應頭的相關欄位來決定快取的方案的。所以,後端的關鍵就在於,根據不同的請求返回對應的快取欄位。
以nodesj為例,如果需要瀏覽器強快取,我們可以這樣設定:

res.setHeader(`Cache-Control`, `public, max-age=xxx`);
複製程式碼

如果需要協商快取,則可以這樣設定:

res.setHeader(`Cache-Control`, `public, max-age=0`);
res.setHeader(`Last-Modified`, xxx);
res.setHeader(`ETag`, xxx);
複製程式碼

當然,現在已經有很多現成的庫可以讓我們很方便地去配置這些東西。
寫了一個簡單的demo,方便有需要的朋友去了解其中的原理,有興趣的可以閱讀原始碼

總結

在做前端快取時,我們儘可能設定長時間的強快取,通過檔名加hash的方式來做版本更新。在程式碼分包的時候,應該將一些不常變的公共庫獨立打包出來,使其能夠更持久的快取。

以上,如有錯漏,歡迎指正!

@Author: TDGarden

相關文章