# 浏览器缓存一探究竟~

先看一张经典的流程图,结合理解

吃了它

img

# 1. 缓存作用

  • 减少了冗余的数据传输,节省了网费。
  • 减少了服务器的负担, 大大提高了网站的性能
  • 加快了客户端加载网页的速度

# 2. 缓存分类

# 2.1 DNS 缓存

主要就是在浏览器本地把对应的 IP 和域名关联起来,这样在进行 DNS 解析的时候就很快。

# 2.2 MemoryCache

是指存在内存中的缓存。从优先级上来说,它是浏览器最先尝试去命中的一种缓存。从效率上来说,它是响应速度最快的一种缓存。 内存缓存是快的,也是“短命”的。它和渲染进程“生死相依”,当进程结束后,也就是 tab 关闭以后,内存里的数据也将不复存在。

# 2.3 浏览器缓存

浏览器缓存,也称Http 缓存,分为强缓存协商缓存。优先级较高的是强缓存,在命中强缓存失败的情况下,才会走协商缓存

# 2.3.1 强缓存

强缓存是利用 http 头中的 ExpiresCache-Control 两个字段来控制的。强缓存中,当请求再次发出时,浏览器会根据其中的 Expirescache-control 判断目标资源是否“命中”强缓存,若命中则直接从缓存中获取资源,不会再与服务端发生通信。

# Expires

实现强缓存,过去我们一直用Expires。当服务器返回响应时,在 Response Headers 中将过期时间写入 Expires 字段。像这样

expires: Wed, 12 Sep 2019 06:12:18 GMT

可以看到,expires 是一个时间戳,接下来如果我们试图再次向服务器请求资源,浏览器就会先对比本地时间和 expires 的时间戳,如果本地时间小于 expires 设定的过期时间,那么就直接去缓存中取这个资源。

从这样的描述中大家也不难猜测,expires 是有问题的,它最大的问题在于对本地时间的依赖。如果服务端和客户端的时间设置可能不同,或者我直接手动去把客户端的时间改掉,那么 expires 将无法达到我们的预期。

# Cache-Control

考虑到 expires 的局限性,HTTP1.1 新增了Cache-Control字段来完成 expires 的任务。expires 能做的事情,Cache-Control 都能做;expires 完成不了的事情,Cache-Control 也能做。因此,Cache-Control 可以视作是 expires 的完全替代方案。在当下的前端实践里,我们继续使用 expires 的唯一目的就是向下兼容。

Cache-Control 中,我们通过max-age来控制资源的有效期。max-age 不是一个时间戳,而是一个时间长度。在本例中,max-age31536000 秒,它意味着该资源在 31536000 秒以内都是有效的,完美地规避了时间戳带来的潜在问题。

Cache-Control 相对于 expires 更加准确,它的优先级也更高。当 Cache-Control 与 expires 同时出现时,我们以 Cache-Control 为准。

可以参考下下面两张图:

img

# 2.3.2 协商缓存(对比缓存)

协商缓存依赖于服务端与浏览器之间的通信。协商缓存机制下,浏览器需要向服务器去询问缓存的相关信息,进而判断是重新发起请求、下载完整的响应,还是从本地获取缓存的资源。如果服务端提示缓存资源未改动(Not Modified),资源会被重定向到浏览器缓存,这种情况下网络请求对应的状态码是 304。

协商缓存的实现,从 Last-ModifiedEtag,Last-Modified 是一个时间戳,如果我们启用了协商缓存,它会在首次请求时随着 Response Headers 返回:

# Last-Modified

Last-Modified: Fri, 27 Oct 2017 06:35:57 GMT

随后我们每次请求时,浏览器的请求头 headers 会带上一个叫 If-Modified-Since 的时间戳字段,它的值正是上一次 response 返回给它的 Last-Modified 值:

If-Modified-Since: Fri, 27 Oct 2017 06:35:57 GMT

服务器接收到这个时间戳后,会比对该时间戳和资源在服务器上的最后修改时间是否一致,从而判断资源是否发生了变化。如果发生了变化,就会返回一个完整的响应内容,并在 Response Headers 中添加新的 Last-Modified 值;否则,返回 304 响应,Response Headers 不会再添加 Last-Modified 字段。

如下图:

img

通过最后修改时间来判断缓存是否可用

  • Last-Modified:响应时告诉客户端此资源的最后修改时间
  • If-Modified-Since:当资源过期时(使用 Cache-Control 标识的 max-age),发现资源具有 Last-Modified 声明,则再次向服务器请求时带上头 If-Modified-Since
  • 服务器收到请求后发现有头 If-Modified-Since 则与被请求资源的最后修改时间进行比对。若最后修改时间较新,说明资源又被改动过,则响应最新的资源内容并返回 200 状态码;
  • 若最后修改时间和 If-Modified-Since 一样,说明资源没有修改,则响应 304 表示未更新,告知浏览器继续使用所保存的缓存文件。

看个实例代码:

let http = require('http');
let fs = require('fs');
let path = require('path');
let mime = require('mime');
http.createServer(function (req, res) {
    let file = path.join(__dirname, req.url);
    fs.stat(file, (err, stat) => {
        if (err) {
            sendError(err, req, res, file, stat);
        } else {
            let ifModifiedSince = req.headers['if-modified-since'];
            if (ifModifiedSince) {
                if (ifModifiedSince == stat.ctime.toGMTString()) {
                    res.writeHead(304);
                    res.end();
                } else {
                    send(req, res, file, stat);
                }
            } else {
                send(req, res, file, stat);
            }
        }
    });
}).listen(8080);
function send(req, res, file, stat) {
    res.setHeader('Last-Modified', stat.ctime.toGMTString());
    res.writeHead(200, { 'Content-Type': mime.getType(file) });
    fs.createReadStream(file).pipe(res);
}
function sendError(err, req, res, file, stat) {
    res.writeHead(400, { "Content-Type": 'text/html' });
    res.end(err ? err.toString() : "Not Found");

使用 Last-Modified 存在一些弊端,这其中最常见的就是这样几个场景 1. 某些服务器不能精确得到文件的最后修改时间, 这样就无法通过最后修改时间来判断文件是否更新了。 2. 我们编辑了文件,但文件的内容没有改变。服务端并不清楚我们是否真正改变了文件,它仍然通过最后编辑时间进行判断。因此这个资源在再次被请求时,会被当做新资源,进而引发一次完整的响应——不该重新请求的时候,也会重新请求。 3. 当我们修改文件的速度过快时(比如花了 100ms 完成了改动),由于 If-Modified-Since 只能检查到以为最小计量单位的时间差,所以它是感知不到这个改动的——该重新请求的时候,反而没有重新请求了。 4. 如果同样的一个文件位于多个 CDN 服务器上的时候内容虽然一样,修改时间不一样。

第二和第三这两个场景其实指向了同一个 bug——服务器并没有正确感知文件的变化。为了解决这样的问题,Etag 作为 Last-Modified 的补充出现了。

# Etag

这个是协商缓存中的另外一种

Etag 是由服务器为每个资源生成的唯一的标识字符串(指纹),这个标识字符串可以是基于文件内容编码的,只要文件内容不同,它们对应的 Etag 就是不同的,反之亦然。因此 Etag 能够精准地感知文件的变化。

Etag是 Web 服务端产生的,然后发给浏览器客户端。生成过程需要服务器额外付出开销,会影响服务端的性能,这是它的弊端。因此启用 Etag 需要我们审时度势。正如我们刚刚所提到的——Etag 并不能替代 Last-Modified,它只能作为 Last-Modified 的补充和强化存在。

执行流程是这样的: 1. 客户端想判断缓存是否可用可以先获取缓存中文档的ETag,然后通过If-None-Match发送请求给 Web 服务器询问此缓存是否可用。 2. 服务器收到请求,将服务器的中此文件的ETag,跟请求头中的If-None-Match相比较,如果值是一样的,说明缓存还是最新的,Web 服务器将发送304 Not Modified响应码给客户端表示缓存未修改过,可以使用。 3. 如果不一样则 Web 服务器将发送该文档的最新版本给浏览器客户端

看如下实例代码:

let http = require('http');
let fs = require('fs');
let path = require('path');
let mime = require('mime');
let crypto = require('crypto');
http
  .createServer(function(req, res) {
    let file = path.join(__dirname, req.url);
    fs.stat(file, (err, stat) => {
      if (err) {
        sendError(err, req, res, file, stat);
      } else {
        let ifNoneMatch = req.headers['if-none-match'];
        let etag = crypto
          .createHash('sha1')
          .update(stat.ctime.toGMTString() + stat.size)
          .digest('hex');
        if (ifNoneMatch) {
          if (ifNoneMatch == etag) {
            res.writeHead(304);
            res.end();
          } else {
            send(req, res, file, etag);
          }
        } else {
          send(req, res, file, etag);
        }
      }
    });
  })
  .listen(8080);
function send(req, res, file, etag) {
  res.setHeader('ETag', etag);
  res.writeHead(200, { 'Content-Type': mime.lookup(file) });
  fs.createReadStream(file).pipe(res);
}
function sendError(err, req, res, file, etag) {
  res.writeHead(400, { 'Content-Type': 'text/html' });
  res.end(err ? err.toString() : 'Not Found');
}

# 强缓存和协商缓存比较

优先级:

Etag 在感知文件变化上比 Last-Modified 更加准确,优先级也更高。当 EtagLast-Modified 同时存在时,以 Etag 为准。

对比:

  • 强制缓存如果生效,不需要再和服务器发生交互,而对比缓存不管是否生效,都需要与服务端发生交互
  • 两类缓存规则可以同时存在,强制缓存优先级高于对比缓存,也就是说,当执行强制缓存的规则时,如果缓存生效,直接使用缓存,不再执行对比缓存规则

# 2.4 Service Worker Cache

Service Worker 是一种独立于主线程之外的 Javascript 线程。它脱离于浏览器窗体,因此无法直接访问 DOM。这样独立的个性使得 Service Worker 的“个人行为”无法干扰页面的性能,这个幕后工作者可以帮我们实现离线缓存消息推送网络代理等功能。我们借助 Service worker 实现的离线缓存就称为 Service Worker Cache。

Service Worker 的生命周期包括 install、activited、working 三个阶段。一旦 Service Worker 被 install,它将始终存在,只会在 active 与 working 之间切换,除非我们主动终止它。这是它可以用来实现离线存储的重要先决条件.

它就在浏览器开发工具(F12) Application 标签页中

# 2.5 Push Cache

Push Cache 是指 HTTP2server push 阶段存在的缓存, 即推送缓存。这块的知识比较新,应用也还处于萌芽阶段,应用范围有限不代表不重要——HTTP2 是趋势、是未来。在它还未被推而广之的此时此刻,仍希望大家能对 Push Cache 的关键特性有所了解:

  • Push Cache 是缓存的最后一道防线。浏览器只有在 Memory CacheHTTP CacheService Worker Cache 均未命中的情况下才会去询问 Push Cache
  • Push Cache 是一种存在于会话阶段的缓存,当 session 终止时,缓存也随之释放。
  • 不同的页面只要共享了同一个 HTTP2 连接,那么它们就可以共享同一个 Push Cache

大家可以参考这篇扩展文章

# 3. 请求流程

# 3.1 第一次请求

img

# 3.2 第二次请求

走上面的缓存机制

# 4. 如何干脆不发请求

  • 浏览器会将文件缓存到Cache目录,第二次请求时浏览器会先检查Cache目录下是否含有该文件,如果有,并且还没到Expires设置的时间,即文件还没有过期,那么此时浏览器将直接从 Cache 目录中读取文件,而不再发送请求
  • Expires是服务器响应消息头字段,在响应 http 请求时告诉浏览器在过期时间前浏览器可以直接从浏览器缓存取数据,而无需再次请求,这是HTTP1.0的内容,现在浏览器均默认使用HTTP1.1,所以基本可以忽略
  • Cache-ControlExpires的作用一致,都是指明当前资源的有效期,控制浏览器是否直接从浏览器缓存取数据还是重新发请求到服务器取数据,如果同时设置的话,其优先级高于Expires

# 5.1 使用 Cache-Control

  • private 客户端可以缓存
  • public 客户端和代理服务器都可以缓存
  • max-age=60 缓存内容将在 60 秒后失效
  • no-cache 需要使用对比缓存验证数据,强制向源服务器再次验证
  • no-store 所有内容都不会缓存,强制缓存对比缓存都不会触发
  • Cache-Control:private, max-age=60, no-cache
let http = require('http');
let fs = require('fs');
let path = require('path');
let mime = require('mime');
let crypto = require('crypto');
http
  .createServer(function(req, res) {
    let file = path.join(__dirname, req.url);
    console.log(file);

    fs.stat(file, (err, stat) => {
      if (err) {
        sendError(err, req, res, file, stat);
      } else {
        send(req, res, file);
      }
    });
  })
  .listen(8080);
function send(req, res, file) {
  let expires = new Date(Date.now() + 60 * 1000);
  res.setHeader('Expires', expires.toUTCString());
  res.setHeader('Cache-Control', 'max-age=60');
  res.writeHead(200, { 'Content-Type': mime.lookup(file) });
  fs.createReadStream(file).pipe(res);
}
function sendError(err, req, res, file, etag) {
  res.writeHead(400, { 'Content-Type': 'text/html' });
  res.end(err ? err.toString() : 'Not Found');
}

# 缓存位置

前面我们已经提到,当强缓存命中或者协商缓存中服务器返回304的时候,我们直接从缓存中获取资源。那这些资源究竟缓存在什么位置呢?

浏览器中的缓存位置一共有四种,按优先级从高到低排列分别是:

  • Service Worker
  • Memory Cache
  • Disk Cache
  • Push Cache

# Service Worker

Service Worker 借鉴了 Web Worker 的 思路,即让 JS 运行在主线程之外,由于它脱离了浏览器的窗体,因此无法直接访问 DOM。虽然如此,但它仍然能帮助我们完成很多有用的功能,比如离线缓存、消息推送和网络代理等功能。其中的离线缓存就是 Service Worker Cache

Service Worker 同时也是 PWA 的重要实现机制,关于它的细节和特性,我们将会在后面的 PWA 的分享中详细介绍。

# Memory Cache 和 Disk Cache

Memory Cache指的是内存缓存,从效率上讲它是最快的。但是从存活时间来讲又是最短的,当渲染进程结束后,内存缓存也就不存在了。

Disk Cache就是存储在磁盘中的缓存(硬盘),从存取效率上讲是比内存缓存的,但是他的优势在于存储容量存储时长。稍微有些计算机基础的应该很好理解,就不展开了。

好,现在问题来了,既然两者各有优劣,那浏览器如何决定将资源放进内存还是硬盘呢?主要策略如下:

  • 比较大的 JS、CSS 文件会直接被丢进磁盘,反之丢进内存
  • 内存使用率比较高的时候,文件优先进入磁盘

# 总结

对浏览器的缓存机制来做个简要的总结:

首先通过 Cache-Control 验证强缓存是否可用

  • 如果强缓存可用,直接使用
  • 否则进入协商缓存即发送 HTTP 请求,服务器通过请求头中的If-Modified-Since或者If-None-Match这些条件请求字段检查资源是否更新
    • 若资源更新,返回资源和200状态码
    • 否则,返回304,告诉浏览器直接从缓存获取资源

# 参考资料

谈谈浏览器缓存

# 最后

文中若有不准确或错误的地方,欢迎指出,有兴趣可以的关注下Github,一起学习呀~~

Last Updated: 2020/9/7 下午8:45:37