95

前端静态资源缓存最优解以及max-age的陷阱

合理的使用缓存可以极大地提高网站的性能优势,还可以节约带宽从而降低服务器成本。但是很多站点有只弄对了一半或者一半都没有,如果是这样,就完全没有发挥出缓存的优势。很大程度上产生会由于静态资源的竞争关系而导致依赖的静态资源不同步。

以下为两个最佳静态资源缓存实践的例子。

一、资源内容不变 + 设置长时间max-age

// 设置缓存时间为1年
Cache-Control: max-age=31536000
  • 资源的内容不会更改,所以。。。
  • 浏览器/CDN可以缓存一年时间,在这期间资源不会出现问题。
  • 可以在不请求服务器的请情况下,一年内都使用缓存内容。
第一天

浏览器请求了/index-v1.js/base-v1.css以及/dog-v1.png这三个资源。

图片描述

第二天

这次浏览器请求了/index-v2.js/base-v2.css以及/dog-v1.png这三个资源。

此处注意:index.jsbase.css与第一天请求的版本号不同。

图片描述

过了一年

在一年的时间里,浏览器再也没有请求过/index-v1.js/base-v1.css以及/dog-v1.png这三个资源,浏览器缓存就会把它们给删掉。

图片描述

所以在这个例子中,为了让缓存发挥最大效率,你要做的并不是更改文件的内容,而是应该更改资源的URL:

<script src="/index-v3.js"></script>
<link rel="stylesheet" href="/base-v3.css">
<img src="/dog-v3.jpg" alt="…">

每一个静态资源URL都应该跟随其内容的修改而改变。例如示例index-v1.js中的v1,你对它的命名不需要有任何限制。它可以是一个版本号最后修改的日期,或者根据内容计算出来的散列值

绝大多数服务器端的框架都提供了工具来实现这一点,同样的在nodejs中有很多优秀的库来实现这个功能,比如gulp-revwebpackfis3


二、对于经常修改的内容,始终需要进行服务器认证

Cache-Control: no-cache
  • 该URL下资源的内容可能经常修改,所以。。。
  • 没有服务器的确认,任何本地缓存的版本内容都是不可信的。
第一天

图片描述

第二天

图片描述

注意:
no-cache并不意味着不缓存。它的意思是在使用缓存资源之前,它必须经过服务器的检查(revalidate也可以实现这个功能)。
no-store才是告诉浏览器不要缓存它。此外,must-revalidate并不意味着必须重新认证,它的前提是资源还在max-age的缓存期内,否则必须重新认证。

在此模式下 ,你也可以将ETag(你选择的版本ID)或者Last-modified日期添加到响应首部中。客户端下次获取资源时,他会分别通过If-None-Match(与ETage对应)和If-Modified-Since(与Last-Mofied对应)两个请求首部将值发送给服务器。如果服务器发现两次值都是对等的,就是返回一个HTTP 304

如果没有发送ETagLast-Modified,那么服务器将始终返回完整的资源内容。

但是这种方法有个缺点,就是它每次都会去服务器做一次验证,涉及到了网络提取,所以它不如第一个例子那样可以完全绕过网络。


三、在经常修改内容的静态资源上使用max-age是个错误的选择

这种情况并不少见,例如它就实实在在地发生在了github的页面上。

想象一下 :

  • /article/
  • /styles.css
  • /script.js

它们全部使用的是:

// 十分钟内不需要重新认证,超过十分钟就需要重新认证
Cache-Control: must-revalidate, max-age=600
  • 随着内容的修改,URLs发生改变
  • 在十分钟内,浏览器将会一直使用缓存住的内容,而不会去服务器请求最新的资源 。
  • 超过十分钟,在可用的前提下使用If-Modified-SinceIf-None-Match重新进行服务器认证。

第一次请求:

图片描述

六七分钟过后:

图片描述

最终:

图片描述

这种情况在测试中经常出现。但是想象一下,在线上环境你永远不知道浏览器前面坐着的是什么样的人,他很有可能无意中胡乱地用鼠标点点点,就打乱了浏览器的静态资源缓存机制,导致页面发生了错乱,而且真的很难追踪。

在上面的例子中,服务器实际上已经更新了HTML、CSS和JS,但是页面最后使用的是缓存中旧的HTML和JS,以及刚从服务器下载的最新的CSS。多个静态资源版本之间不匹配的问题随之出现。

通常,当我们对HTML进行重大修改时,我们可能会更改CSS文件来适配新的DOM结构,并且更新JS来配置样式和DOM的修改。这些资源都是相互依赖的,但携带缓存信息的HTTP首部可不管你这些有的没的。最终,用户很有可能会得到一个/两个静态资源新版本,而其他资源都是旧版本。

max-age是相对于服务器响应时间的,所以如果所有上述资源都在同一时间请求,即便它们都被设置为了相同的max-age时长,它们仍然存在很小的竞争可能性(毕竟有的资源先返回有的资源后返回)。如果你的某些页面不包含JS,或者包含不同的CSS,它们的缓存失效时间就有可能会不同步。更恶心的是,浏览器始终会从缓存中删除和获取资源,它并不知道这些资源中哪个是相互依赖的,只要过了缓存时间它就会毫不犹豫地删掉一个,并不会删掉这个过期文件所依赖的其他资源。把上面的种种可能性加在一起,就会大概率出现静态资源版本不匹配的问题。

不过还好,我们还有法子来解决这个问题:

强制刷新浏览器或者清除缓存

在强制刷新浏览器或者清除缓存后,请求的页面以及页面内的所有资源会忽略之前的max-age,去服务器做重新认证。因此,如果用户由于max-age出现问题之后,只需要强制刷新或者清缓存就可以修复问题。当然,强迫用户这样做只会让它们降低对你网站的信任度,认为你的网站不靠谱。。。

使用serviceWorker减少这种错误的出现几率

service Worker的执行时机:

图片描述

注册serviceWorker:

if (navigator.serviceWorker) {
  navigator.serviceWorker.register('/serviceworker.js', {
    scope: '/'
  });
}

执行serviceworker.js:

const version = '2';

self.addEventListener('install', event => {
  // 由于系统会随时睡眠SW,所以,为了防止执行中断,就需要使用 event.waitUntil 进行捕获
  event.waitUntil(
    caches.open(`static-${version}`)
    .then(cache => cache.addAll(
        // 不稳定文件或者大文件加载
        //...
      ), cache.addAll([
      // 稳定文件或小文件加载
      '/styles.css',
      '/script.js'
    ]));
  );
});

self.addEventListener('activate', event => {
  // …delete old caches…
});

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
    .then(response => response || fetch(event.request))
  );
});
  • 将script和styles缓存起来。
  • 如果有匹配到的缓存就从缓存中获取,如果没有就从服务器获取。

如果我们修改了JS/CSS,只需修改version就可以让service worker触发更新。

你也可以在service worker中跳过缓存:

self.addEventListener('install', event => {
    event.waitUntil(
        caches.open(`static-${version}`)
        .then(cache => cache.addAll([
            new Request('/styles.css', {
                cache: 'no-cache'
            }),
            new Request('/script.js', {
                cache: 'no-cache'
            })
        ]))
    );
});

不过很不巧的是,cache选项在和safari和opera中都不支持 ,只有firefox和chrome最近才开始支持。但是你可以这样做:

self.addEventListener('install', event => {
    event.waitUntil(
        caches.open(`static-${version}`)
        .then(cache => Promise.all(
            [
                '/styles.css',
                '/script.js'
            ].map(url => {
                // cache-bust using a random query string
                return fetch(`${url}?${Math.random()}`).then(response => {
                    // fail on 404, 500 etc
                    if (!response.ok) throw Error('Not ok');
                    return cache.put(url, response);
                })
            })
        ))
    );
});

你可以使用上面代码中的随机字符串,也可以使用散列值。这有点像在javascript中实现文章刚开始第一小节的方法,不过仅仅是在server worker中使用。


四、service worker和HTTP cache也可以很好的共存

通过上个的例子,你可以看到service worker可以很好的处理一些糟糕的缓存情况。但是仅仅是做一些hack处理而已,最重要的是再根源上解决问题。正确的使用缓存不仅可以更好地使用service worker,还可以很好地在那些不支持service worker的浏览器(IE/Safari/Opera)上提高网站的性能。除此之外,对你的CDN也是大有益处。

正确的使用缓存,可以大量简化service worker的代码:

const version = '23';

self.addEventListener('install', event => {
    event.waitUntil(
        caches.open(`static-${version}`)
        .then(cache => cache.addAll([
            '/',
            '/script-v3.js',
            '/styles-v3.css',
            '/dog-v3.jpg'
        ]))
    );
});

所以,我们可以使用第二小节的方法(服务器重新认证)来缓存根HTML页面。并使用第一小节的方法(不同的内容使用不同的URL)来缓存其他资源。每次service worker更新世都会去请求网站的根HTML页面,其他资源只有在更改URL时才会去下载,从而提高网站的性能。

虽然service worker擅长提高网站的性能,但它并不是一个完整的解决方案。因此要和HTTP cache配合使用才可以显著地提高性能。


五、max-age和『内容经常修改但是URL不变的静态资源』搭配使用

在内容经常修改但是URL不变的静态资源上使用max-age在通常意义上来说不是一个好点子,但事实却不总是如此。

假如一个页面的max-age为三分钟,并且在这个页面上不需要考虑静态资源的竞争关系(静态资源之间存在相互依赖,见第三小节),所以在这个页面上不存在任何的静态资源依赖。在这种情况下就可以尽情使用max-age。不过这也意味着网站的修改要再三分钟之后才可以被看到。

不过要是页面存在静态资源竞争关系的话,这种法子不好用了,比如我现在有两个文章A和B,我现在文章A中添加一个新的章节,然后在文章B中增加了一个指向文章A新增章节的超链接。然后我从文章B中访问这个链接,假如文章A的max-age没有过期,那么我访问到的文章A里将会发现文章并没有那个新增的章节。此时只能等max-age过期或者强制刷新浏览器,再或者清除缓存了。所以,一定要谨慎使用这种方法。

正确使用缓存可以代理巨大的性能收益并且有效节省服务器带宽。既支持版本号类型的静态资源缓存方式也支持服务器重新认证(no-cache、304)的方式。如果你觉得自己很勇敢,那么大可混合使用max-age和『内容经常修改但是URL不变的静态资源』,但是前提你得确定自己的HTML中没有静态资源竞争关系。


张亚涛
5.3k 声望2.8k 粉丝

人首先应该接受现实,承认问题的存在,并且反思它。而不是首先就跳出来回避这个问题,找比我们更差的。质疑甚至抨击提出问题的人,并且一杆子打死。