HTTP 緩存與前端工程化的思考

什麼是 HTTP Cache

  • 咱們知道經過網絡獲取資源緩慢且耗時,須要三次握手等協議與遠程服務器創建通訊,對於大點的數據須要屢次往返通訊大大增長了時間開銷,而且當今流量依舊沒有理想的快速與便宜。對於開發者來講,長久緩存複用重複不變的資源是性能優化的重要組成部分。
  • HTTP 緩存機制就是,配置服務器響應頭來告訴瀏覽器是否應該緩存資源、是否強制校驗緩存、緩存多長時間;瀏覽器非首次請求根據響應頭是否應該取緩存、緩存過時發送請求頭驗證緩存是否可用仍是從新獲取資源的過程。下面咱們就來結合簡單的 node 服務器代碼(文末)來介紹其中原理。

關鍵字

響應頭 (經常使用)值 說明
Cache-Control no-cache, no-store, must-revalidate, max-age, public, private 控制瀏覽器是否能夠緩存資源、強制緩存校驗、緩存時間
ETag 文件指紋(hash碼、時間戳等能夠標識文件是否更新) 強校驗,根據文件內容生成精確
Last-Modified 請求的資源最近更新時間 弱校驗, 根據文件修改時間,可能內容未變,不精確
Expires 資源緩存過時時間 與響應頭中的 Date 對比
請求頭 說明
If-None-Match 緩存響應頭中的 ETag 值 發送給服務器比對文件是否更新(精確)
If-Modified-Since 緩存響應頭中的 Last-Modified 值 發送給服務器比對文件是否更新(不精確)

簡單流程圖

這裏寫圖片描述

代碼準備

  • index.htmljavascript

  • img.pngphp

  • server.jscss

    爲了避免影響閱讀代碼貼在頁尾,注意須要自行安裝 mime npm包。html

不設置

  • 不設置響應頭,則瀏覽器並不能知道是否應該緩存資源,而是每次都發送新的請求,接受新的資源。
// strategy['no-cache'](req, res, filePath, stat);
// strategy['no-store'](req, res, filePath, stat);
// strategy['cache'](req, res, filePath, stat);
strategy['nothing'](req, res, filePath, stat);
複製代碼
node server.js
複製代碼

瀏覽器裏輸入:localhost:8080/index.html前端

  • 首次加載

這裏寫圖片描述

  • 刷新,每次和上面同樣的效果,都是從新獲取資源。

明確禁止緩存

  • 設置響應頭
Cache-Control: no-store
或 
Cache-Control: no-cache, no-store, must-revalidate
複製代碼
strategy['no-store'](req, res, filePath, stat);
複製代碼

這裏寫圖片描述

效果和不設置同樣,只是明確告訴瀏覽器禁止緩存資源。html5

private與public

在這裏插入圖片描述

  • Cache-Control: public 表示一些中間代理、CDN等能夠緩存資源,即使是帶有一些敏感 HTTP 驗證身份信息甚至響應狀態代碼一般沒法緩存的也能夠緩存。一般 public 是非必須的,由於響應頭 max-age 信息已經明確告知能夠緩存了。
  • Cache-Control: private 明確告知此資源只能單個用戶能夠緩存,其餘中間代理不能緩存。原始發起的瀏覽器能夠緩存,中間代理不能緩存。例如:百度搜索時,特定搜索信息只能被髮起請求的瀏覽器緩存。

這裏寫圖片描述

緩存過時策略

通常緩存機制只做用於 get 請求java

一、三種方式設置服務器告知瀏覽器緩存過時時間

設置響應頭(注意瀏覽器有本身的緩存替換策略,即使資源過時,不必定被瀏覽器刪除。一樣資源未過時,可能因爲緩存空間不足而被其餘網頁新的緩存資源所替換而被刪除。):node

  1. 設置 Cache-Control: max-age=1000 響應頭中的 Date 通過 1000s 過時
  2. 設置 Expires 此時間與本地時間(響應頭中的 Date )對比,小於本地時間表示過時,因爲本地時鐘與服務器時鐘沒法保持一致,致使比較不精確
  3. 若是以上均未設置,卻設置了 Last-Modified ,瀏覽器隱式的設置資源過時時間爲 (Date - Last-Modified) * 10% 緩存過時時間。

二、兩種方式校驗資源過時

設置請求頭:jquery

  1. If-None-Match 若是緩存資源過時,瀏覽器發起請求會自動把原來緩存響應頭裏的 ETag 值設置爲請求頭 If-None-Match 的值發送給服務器用於比較。通常設置爲文件的 hash 碼或其餘標識可以精確判斷文件是否被更新,爲強校驗。
  2. If-Modified-Since 一樣對應緩存響應頭裏的 Last-Modified 的值。此值可能取得 ctime 的值,該值可能被修改但文件內容未變,致使對比不許確,爲弱校驗。

下面以經常使用設置了 Cache-Control: max-age=100If-None-Match 的圖示說明:webpack

這裏寫圖片描述

  • 一、(如下便於測試,未準確設置爲 100s 。)瀏覽器首次發起請求,緩存爲空,服務器響應:

這裏寫圖片描述

瀏覽器緩存此響應,緩存壽命爲接收到此響應開始計時 100s 。

  • 二、10s 事後,瀏覽器再次發起請求,檢測緩存未過時,瀏覽器計算 Age: 10 ,而後直接使用緩存,這裏是直接去內存中的緩存,from disk 是取磁盤上的緩存。(這裏不清楚爲何,一樣的配置,index.html 文件即使有緩存也 304。

這裏寫圖片描述

  • 三、100s 事後,瀏覽器再次發起請求,檢測緩存過時,向服務器發起驗證緩存請求。若是服務器對比文件已發生改變,則如 1;不然不返回文件數據報文,直接返回 304。返回 304 時設置 Age: 0 與不設置效果同樣, 猜想是瀏覽器會自動維護。

這裏寫圖片描述

強制校驗緩存

有時咱們既想享受緩存帶來的性能優點,可有時又不確認資源內容的更新頻度或是其餘資源的入口,咱們想此服務器資源一旦更新能立馬更新瀏覽器的緩存,這時咱們能夠設置

Cache-Control: no-cache

這裏寫圖片描述

再次發起請求,不管緩存資源有沒有過時都發起驗證請求,未更新返回 304,不然返回新資源。

性能優化

如今一些單頁面技術,構建工具十分流行。通常一個 html 文件,每次打包構建工具都會動態默認把衆多腳本樣式文件打包成一個 bundle.hashxxx.js 。雖然一個 js 文件看似減小了 HTTP 請求數量,但對於有些三方庫資源等長期不變的資源能夠拆分出來,並設置長期緩存,充分利用緩存性能優點。這時咱們徹底能夠對常常變更的 html 設置 Cache-Control: no-cahce 實時驗證是否更新。而對於連接在 html 文件的資源名稱均帶上惟一的文件指紋(時間戳、版本號、文件hash等),設置 max-age 足夠大。資源一旦變更即標識碼也會變更,做爲入口的 html 文件外鏈改變,html 變更驗證返回全新的資源,拉取最新的外鏈資源,達到及時更新的效果。老的資源會被瀏覽器緩存替換機制清除。流程以下:

這裏寫圖片描述

期中總結:HTTP 緩存性能檢查清單

  • 確保網址惟一:通常瀏覽器以 Request URL 爲鍵值(區分大小寫)緩存資源,不一樣的網址提供相同的內容會致使屢次獲取緩存相同的資源。ps:常見的更新緩存的方式:在網址後面來加個 v=1,例如 https://xxx.com?v=1 來更新新的資源,可是這樣的更新方式有極大的弊端。
  • 確保服務器提供了驗證令牌 ETag :提供資源對比機制。ps:服務器每次驗證文件的話,太耗性能,現代前端構建工具都能自動更新文件hash,不須要設置Tag了,直接設置長緩存時間。
  • 肯定中間代理能夠緩存哪些資源:對於我的隱私信息能夠設置 private,對於公共資源例如 CDN 資源能夠設置 public
  • 爲每一個資源設置最佳的緩存壽命:max-age 或 Expires,對於不常常變更或不變的資源設置儘量大的緩存時間,充分利用緩存性能。
  • 確認網站的層次機構:例如單頁面技術,對於頻繁更新的主入口 index.html 文件設置較短的緩存週期或 no-cache 強制緩存驗證,以確保外鏈資源的及時更新。
  • 最大限度減小文件攪動:對於要打包合併的文件,應該儘可能區分頻繁、不頻繁變更的文件,避免頻繁變更的內容致使大文件的文件指紋變更(後臺服務器計算文件 hash 很耗性能),儘可能頻繁變更的文件小而美,不常常變更例如第三方庫資源能夠合併減小HTTP請求數量。

前端工程化

在這裏插入圖片描述

  • 曾經,前端的概念只是編寫一些頁面、樣式、簡單腳本,而後丟給服務器就能夠了,真是簡單有趣...

在這裏插入圖片描述

  • 進步青年想,對於不變的資源能不能利用緩存呢,因而有:

在這裏插入圖片描述

  • 304有時也以爲浪費,這個請求我也想省了,未過時就不發請求:

在這裏插入圖片描述

  • 完美!那麼問題來了,有新資源我如何實時發佈更新呢?
  • 方案一:查詢字符加版本號

在這裏插入圖片描述

  • 更新資源只要更新版本號

在這裏插入圖片描述

  • 問題來了:我可能每次只有一兩個文件修改了,我得更新多有文件版本號?!

在這裏插入圖片描述

  • 弊端:更新若干資源必須所有文件版本升級,未變更資源緩存也不能利用了。
  • 方案二:查詢字符加文件哈希

在這裏插入圖片描述

  • 大點的公司服務器確定不止一個,靜態資源須要作集羣部署、CDN等。

在這裏插入圖片描述

  • 問題來了:我是先發動態頁面,仍是先發靜態資源?

在這裏插入圖片描述

  • 答案是:都不行!

弊端:

  1. 先發頁面,這時用戶正好打開了新頁面,此時新資源未更新,拉取老的靜態資源,並緩存,致使錯誤。
  2. 先發資源,這時新用戶正好打開老的頁面,拉取新資源,致使出錯。 (熟悉的聲音:是你緩存有問題吧,清下緩存...)。 這就是爲何要等到三更半夜,等用戶休息了,先發靜態資源,再發動態頁面。
  • 方案三:根據文件內容生成文件名,這就完美解決了文件更新的問題:

在這裏插入圖片描述

  • 非覆蓋式更新,改變某文件,生成新的文件並更新頁面引用連接一併上傳服務新文件,不影響之前用戶,又能實時更新文件,完美!

  • 問題來了,那我怎麼寫代碼,圖片、CSS、JS等靜態資源怎麼去維護,修改了生成新的文件,更新新的外鏈。。。這就不是人力所能爲了。

前端工程化議題應運而生,歡迎補玉。

參考

  1. mozilla:HTTP 緩存
  2. 谷歌有關性能的文字:HTTP 緩存
  3. 大公司裏怎樣開發和部署前端代碼
  4. node中的緩存機制
  5. w3c Header定義
  6. 完全弄懂 Http 緩存機制 - 基於緩存策略三要素分解法
  7. 據說你用webpack處理文件名的hash?那麼建議你看看你生成的hash對不對

附代碼

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>HTTP Cache</title>
</head>
<body>
    <img src="img.png" alt="流程圖">
    <!-- <script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script> -->
</body>
</html>
複製代碼

server.js

let http = require('http');
let url = require('url');
let path = require('path');
let fs = require('fs');
let mime = require('mime');// 非 node 內核包,需 npm install
let crypto = require('crypto');

// 緩存策略
const strategy = {
  'nothing': (req, res, filePath) => {
    fs.createReadStream(filePath).pipe(res);
  },
  'no-store': (req, res, filePath, stat) => {
    // 禁止緩存
    res.setHeader('Cache-Control', 'no-store');
    // res.setHeader('Cache-Control', ['no-cache', 'no-store', 'must-revalidate']);
    // res.setHeader('Expires', new Date(Date.now() + 30 * 1000).toUTCString());
    // res.setHeader('Last-Modified', stat.ctime.toGMTString());

    fs.createReadStream(filePath).pipe(res);
  },
  'no-cache': (req, res, filePath, stat) => {
    // 強制確認緩存
    // res.setHeader('Cache-Control', 'no-cache');
    strategy['cache'](req, res, filePath, stat, true);
    // fs.createReadStream(filePath).pipe(res);
  },
  'cache': async (req, res, filePath, stat, revalidate) => {
    let ifNoneMatch = req.headers['if-none-match'];
    let ifModifiedSince = req.headers['if-modified-since'];
    let LastModified = stat.ctime.toGMTString();
    let maxAge = 30;

    let etag = await new Promise((resolve, reject) => {
      // 生成文件 hash
      let out = fs.createReadStream(filePath);
      let md5 = crypto.createHash('md5');
      out.on('data', function (data) {
        md5.update(data)
      });
      out.on('end', function () {
        resolve(md5.digest('hex'));
      });
    });
    console.log(etag);
    if (ifNoneMatch) {
      if (ifNoneMatch == etag) {
        console.log('304');
        // res.setHeader('Cache-Control', 'max-age=' + maxAge);
        // res.setHeader('Age', 0);
        res.writeHead('304');
        res.end();
      } else {
        // 設置緩存壽命
        res.setHeader('Cache-Control', 'max-age=' + maxAge);
        res.setHeader('Etag', etag);
        fs.createReadStream(filePath).pipe(res);
      }
    }
    /*else if ( ifModifiedSince ) { if (ifModifiedSince == LastModified) { res.writeHead('304'); res.end(); } else { res.setHeader('Last-Modified', stat.ctime.toGMTString()); fs.createReadStream(filePath).pipe(res); } }*/
    else {
      // 設置緩存壽命
      // console.log('首次響應!');
      res.setHeader('Cache-Control', 'max-age=' + maxAge);
      res.setHeader('Etag', etag);
      // res.setHeader('Last-Modified', stat.ctime.toGMTString());

      revalidate && res.setHeader('Cache-Control', [
        'max-age=' + maxAge,
        'no-cache'
      ]);
      fs.createReadStream(filePath).pipe(res);
    }
  }

};

http.createServer((req, res) => {
  console.log(new Date().toLocaleTimeString() + ':收到請求')
  let {pathname} = url.parse(req.url, true);
  let filePath = path.join(__dirname, pathname);
  // console.log(filePath);
  fs.stat(filePath, (err, stat) => {
    if (err) {
      res.setHeader('Content-Type', 'text/html');
      res.setHeader('404', 'Not Found');
      res.end('404 Not Found');
    } else {
      res.setHeader('Content-Type', mime.getType(filePath));

      // strategy['no-cache'](req, res, filePath, stat);
      // strategy['no-store'](req, res, filePath, stat);
      strategy['cache'](req, res, filePath, stat);
      // strategy['nothing'](req, res, filePath, stat);
    }
  });
})
  .on('clientError', (err, socket) => {
    socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
  })
  .listen(8080);
複製代碼

標準配置

'use strict';

/** deps */
var path = require('path'),
  express = require('express'),
  mime = require('express/lib/express').mime,

  /** cache values */
  ONE_HOUR = 60 * 60,
  ONE_WEEK = ONE_HOUR * 24 * 7,
  ONE_MONTH = ONE_WEEK * 4,
  ONE_YEAR = ONE_MONTH * 12,

  /** mime type regexps */
  RE_MIME_IMAGE = /^image/,
  RE_MIME_FONT = /^(?:application\/(?:font-woff|x-font-ttf|vnd\.ms-fontobject)|font\/opentype)$/,
  RE_MIME_DATA = /^(?:text\/(?:cache-manifest|html|xml)|application\/(?:(?:rdf\+)?xml|json))/,
  RE_MIME_FEED = /^application\/(?:rss|atom)\+xml$/,
  RE_MIME_FAVICON = /^image\/x-icon$/,
  RE_MIME_MEDIA = /(image|video|audio|text\/x-component|application\/(?:font-woff|x-font-ttf|vnd\.ms-fontobject)|font\/opentype)/,
  RE_MIME_CSSJS = /^(?:text\/(?:css|x-component)|application\/javascript)/,

  /** misc regexps */
  RE_WWW = /^www\./,
  RE_MSIE = /MSIE/,
  RE_HIDDEN = /(^|\/)\./,
  RE_SRCBAK = /\.(?:bak|config|sql|fla|psd|ini|log|sh|inc|swp|dist)|~/;

// load additional node mime types
mime.load(path.join(__dirname, 'node.types'));

// apply `ServerResponse` patch
require('../patch');

/** * Configures headers layer. * @type {Function} */
module.exports = function (options) {
  /** * The actual headers layer, invoked for each request hit. * Applies all h5bp goodness relative to response headers. */
  return function headersLayer(req, res, next) {
    var url = req.url,
      pathname = req.path || '/',
      host = req.headers.host,
      ua = req.headers['user-agent'],
      cc = '',
      type;

    // Block access to "hidden" directories or files whose names begin with a
    // period. This includes directories used by version control systems such as
    // Subversion or Git.

    // 隱藏文件,403拒絕訪問
    if (!options.dotfiles && RE_HIDDEN.test(pathname)) {
      next(403);
      return;
    }

    // Block access to backup and source files. These files may be left by some
    // text/html editors and pose a great security danger, when anyone can access
    // them.

    // 備份、源文件,403拒絕訪問
    if (RE_SRCBAK.test(pathname)) {
      next(403);
      return;
    }

    /** * Suppress or force the "www." at the beginning of URLs */

    // The same content should never be available under two different URLs -
    // especially not with and without "www." at the beginning, since this can cause
    // SEO problems (duplicate content). That's why you should choose one of the
    // alternatives and redirect the other one.

    // By default option 1 (no "www.") is activated.
    // no-www.org/faq.php?q=class_b

    // If you'd prefer to use option 2, just comment out all option 1 lines
    // and uncomment option 2.

    // ----------------------------------------------------------------------

    // Option 1:
    // Rewrite "www.example.com -> example.com".

    // 重定向
    if (false === options.www && RE_WWW.test(host)) {
      res.setHeader('Location', '//' + host.replace(RE_WWW, '') + url);
      next(301);
      return;
    }

    // ----------------------------------------------------------------------

    // Option 2:
    // Rewrite "example.com -> www.example.com".
    // Be aware that the following rule might not be a good idea if you use "real"
    // subdomains for certain parts of your website.

    if (true === options.www && !RE_WWW.test(host)) {
      res.setHeader('Location', '//www.' + host.replace(RE_WWW, '') + url);
      next(301);
      return;
    }

    /** * Built-in filename-based cache busting */

    // If you're not using the build script to manage your filename version revving,
    // you might want to consider enabling this, which will route requests for
    // /css/style.20110203.css to /css/style.css

    // To understand why this is important and a better idea than all.css?v1231,
    // read: github.com/h5bp/html5-boilerplate/wiki/cachebusting

    req.baseUrl = req.url;
    req.url = req.url.replace(/^(.+)\.(\d+)\.(js|css|png|jpg|gif)$/, '$1.$3');

    // Headers stuff!!
    // Subscribes to the `header` event in order to:
    // - let content generator middlewares set the appropriate content-type.
    // - "ensures" that `h5bp` is the last to write headers.

    res.on('header', function () {
      /** * Proper MIME type for all files */

      // Here we delegate it to `node-mime` which already does that for us and maintain a list of fresh
      // content types.
      // https://github.com/broofa/node-mime

      type = res.getHeader('Content-Type');
      // normalize unknown types to empty string
      if (!type || !mime.extension(type.split(';')[0])) {
        type = '';
      }

      /** * Better website experience for IE users */

      // Force the latest IE version, in various cases when it may fall back to IE7 mode
      // github.com/rails/rails/commit/123eb25#commitcomment-118920
      // https://www.cnblogs.com/menyiin/p/6527339.html

      // chrome IE殼
      if (RE_MSIE.test(ua) && ~type.indexOf('text/html')) {
        res.setHeader('X-UA-Compatible', 'IE=Edge,chrome=1');
      }

      /** * Cross-domain AJAX requests */

      // Serve cross-domain Ajax requests, disabled by default.
      // enable-cors.org
      // code.google.com/p/html5security/wiki/CrossOriginRequestSecurity

      // cors 跨域
      if (options.cors) {
        res.setHeader('Access-Control-Allow-Origin', '*');
      }

      /** * CORS-enabled images (@crossorigin) */

      // Send CORS headers if browsers request them; enabled by default for images.
      // developer.mozilla.org/en/CORS_Enabled_Image
      // blog.chromium.org/2011/07/using-cross-domain-images-in-webgl-and.html
      // hacks.mozilla.org/2011/11/using-cors-to-load-webgl-textures-from-cross-domain-images/
      // wiki.mozilla.org/Security/Reviews/crossoriginAttribute

      // 圖片可跨域
      if (RE_MIME_IMAGE.test(type)) {
        res.setHeader('Access-Control-Allow-Origin', '*');
      }

      /** * Webfont access */

      // Allow access from all domains for webfonts.
      // Alternatively you could only whitelist your
      // subdomains like "subdomain.example.com".

      // 字體可跨域
      if (RE_MIME_FONT.test(type) || '/font.css' == pathname) {
        res.setHeader('Access-Control-Allow-Origin', '*');
      }

      /** * Expires headers (for better cache control) */

      // These are pretty far-future expires headers.
      // They assume you control versioning with filename-based cache busting
      // Additionally, consider that outdated proxies may miscache
      // www.stevesouders.com/blog/2008/08/23/revving-filenames-dont-use-querystring/

      // If you don't use filenames to version, lower the CSS and JS to something like
      // "access plus 1 week".

      // note: we don't use express.static maxAge feature because it does not allow fine tune

      // Perhaps better to whitelist expires rules? Perhaps.

      // cache.appcache needs re-requests in FF 3.6 (thanks Remy ~Introducing HTML5)
      // Your document html
      // Data
      if (!type || RE_MIME_DATA.test(type)) {
        cc = 'public,max-age=0';
      }
      // Feed
      else if (RE_MIME_FEED.test(type)) {
        cc = 'public,max-age=' + ONE_HOUR;
      }
      // Favicon (cannot be renamed)
      else if (RE_MIME_FAVICON.test(type)) {
        cc = 'public,max-age=' + ONE_WEEK;
      }
      // Media: images, video, audio
      // HTC files (css3pie)
      // Webfonts
      else if (RE_MIME_MEDIA.test(type)) {
        cc = 'public,max-age=' + ONE_MONTH;
      }
      // CSS and JavaScript
      else if (RE_MIME_CSSJS.test(type)) {
        cc = 'public,max-age=' + ONE_YEAR;
      }
      // Misc
      else {
        cc = 'public,max-age=' + ONE_MONTH;
      }

      /** * Prevent mobile network providers from modifying your site */

      // The following header prevents modification of your code over 3G on some
      // European providers.
      // This is the official 'bypass' suggested by O2 in the UK.

      //no-siteapp
      // 禁止網站轉碼
      cc += (cc ? ',' : '') + 'no-transform';
      res.setHeader('Cache-Control', cc);

      /** * ETag removal */

      // Since we're sending far-future expires, we don't need ETags for
      // static content.
      // developer.yahoo.com/performance/rules.html#etags

      // 幹掉Tag,避免浪費服務資源,良好的緩存機制既能作到實時正確更新又能儘量利用緩存優點
      res.removeHeader('ETag');

      /** * Stop screen flicker in IE on CSS rollovers */

      // The following directives stop screen flicker in IE on CSS rollovers - in
      // combination with the "ExpiresByType" rules for images (see above).

      // TODO

      /** * Set Keep-Alive Header */

      // Keep-Alive allows the server to send multiple requests through one
      // TCP-expression. Be aware of possible disadvantages of this setting. Turn on
      // if you serve a lot of static content.

      // 保持長聯,減小多回三次握手帶來的性能損失,但要有可靠的超時機制
      res.setHeader('Connection', 'keep-alive');

      /** * Cookie setting from iframes */

      // Allow cookies to be set from iframes (for IE only)
      // If needed, specify a path or regex in the Location directive.

      // TODO

      /** * A little more security */

      // do we want to advertise what kind of server we're running?

      if ('express' == options.server) {
        res.removeHeader('X-Powered-By');
      }
    });

    next(null, req, res);
  };
};
複製代碼
相關文章
相關標籤/搜索