Web性能优化-缓存优化(HTTP缓存和ServiceWorker离线缓存)(五)

1,784 阅读10分钟

http缓存

浏览器兼容性

实际上并没有一个称为HTTP缓存的API。它是Web平台API集合的通用名称。所有浏览器均支持这些API:

  • Cache-Control
  • ETag
  • Last-Modified

http缓存如何工作

HTTP缓存的行为由请求标头和 响应标头的组合控制 。在理想情况下,可以控制Web应用程序的代码(将确定请求标头)和Web服务器的配置(将确定响应标头)。 常见的 HTTP 缓存只能存储 GET 响应,对于其他类型的响应则无能为力。缓存的关键主要包括request method和目标URI(一般只有GET请求才会被缓存)。

由于HTTP是C/S模式的协议,服务器更新一个资源时,不可能直接通知客户端更新缓存,所以双方必须为该资源约定一个过期时间,在该过期时间之前,该资源(缓存副本)就是新鲜的,当过了过期时间后,该资源(缓存副本)则变为陈旧的。

协商算法用于将陈旧的资源(缓存副本)替换为新鲜的,注意,一个陈旧的资源(缓存副本)是不会直接被清除或忽略的,当客户端发起一个请求时,缓存检索到已有一个对应的陈旧资源(缓存副本),则缓存会先将此请求附加一个If-None-Match头,然后发给目标服务器,以此来检查该资源副本是否还是算新鲜的,若服务器返回了 304 (Not Modified)(该响应不会有带有实体信息),则表示此资源副本是新鲜的,这样一来,可以节省一些带宽。若服务器通过 If-None-Match 或 If-Modified-Since判断后发现已过期,那么会带有该资源的实体内容返回。

请求头:坚持使用默认值

浏览器发送请求时总是会默认设置关于缓存的请求头,请求标头会影响检查是否新鲜,例如If-None-Match和 If-Modified-Since

响应头

  • Cache-Control。服务器可以返回一个Cache-Control指令,以指定浏览器和其他中间缓存应如何缓存个体响应以及将其缓存多长时间。
  • ETag。当浏览器找到过期的缓存响应时,它可以向服务器发送一个小的令牌(通常是文件内容的哈希值),以检查文件是否已更改。如果服务器返回相同的令牌,则文件相同,因此无需重新下载它。
  • Last-Modified。该标头的用途与相同ETag,但使用基于时间的策略来确定资源是否已更改,而不是基于内容的策略ETag。

注意:省略Cache-Control响应头不会禁用HTTP缓存!相反,浏览器可以有效地猜测 哪种类型的缓存行为对于给定类型的内容最有意义。

版本化的URL长期缓存

当响应对包含"hash"或版本信息且内容绝不希望更改的URL的请求时,添加 Cache-Control: max-age=31536000到响应中。设置此值将告诉浏览器,在未来一年中(31,536,000秒;最大支持值)中加载相同的URL时,它可以立即使用HTTP缓存中的值,而无需发送请求。 像webpack这样的构建工具可以自动将哈希指纹分配给资产URL的过程。

未受版本控制的URL,请求服务器重新验证

例如每个web应用程序的HTML文件,永远不会包含版本信息。

Cache-Control可以取以下值:

  • no-cache:指示浏览器每次使用URL的缓存版本之前,都必须与服务器重新验证。
  • no-store:指示浏览器和其他中间缓存(如CDN)从不存储该文件的任何版本。
  • private:浏览器可以缓存文件,但是中间缓存不能。
  • public:响应可以由任何缓存存储。

Cache-Control可以接受逗号分隔的指令列表。例如:public, max-age=31536000

ETag和Last-Modified

通过设置ETag或Last-Modified可以和请求头If-None-Match和If-Modified-Since进行匹配。判断浏览器的HTTP缓存中已经具有的资源版本是否与Web服务器上的最新版本匹配,如果匹配,服务器可以使用304 Not Modified进行响应。

Last-Modified和If-Modified-Since流程与上图一致。

Cache-Control流程图

Cache-Control设置例子

Cache-Control 值 说明
max-age=86400 响应可以由浏览器和中间缓存最长缓存1天(60秒x 60分钟x 24小时)
private, max-age=600 响应可以由浏览器缓存(但不能由中间缓存)缓存长达10分钟(60秒x 10分钟)。
public, max-age=31536000 响应可以由任何缓存存储1年。
no-store 不允许缓存响应,必须在每个请求中完整获取该响应。

离线缓存(ServiceWorker)

可以通过 ServiceWorker 控制缓存和处理请求的方式。 通过 ServiceWorker 独立地从缓存处理请求,我们来单独看一下它们。 首先,应在什么时候进行缓存?

何时缓存

安装时 - 以依赖项形式

ServiceWorker 为您提供一个 install 事件。可以执行在处理其他事件之前必须完成的操作。 在进行这些操作时,任何以前版本的 ServiceWorker 仍在运行和提供页面,因此在此处进行的操作一定不能干扰它们。

适合于: CSS、图像、字体、JS、模板等,基本上囊括了网站的静态内容的任何对象。 如果未能获取上述对象,将导致网站完全无法运行。

self.addEventListener('install', function(event) {
  event.waitUntil(
    caches.open('mysite-static-v3').then(function(cache) {
      return cache.addAll([
        '/css/whatever-v3.css',
        '/css/imgs/sprites-v6.png',
        '/css/fonts/whatever-v8.woff',
        '/js/all-min-v4.js'
        // etc
      ]);
    })
  );
});

event.waitUntil 参数为一个 promise 以定义安装时长和安装是否成功。 如果 promise 拒绝,则安装被视为失败,并舍弃这个 ServiceWorker (如果一个较旧的版本正在运行,它将保持不变)。caches.open 和 cache.addAll 将返回 promise。如果其中有任一资源获取失败,则 cache.addAll 调用将拒绝。

安装时 - 不是以依赖项的形式

适合于: 不是即刻需要的大型资源,如用于游戏较高级别的资源。

self.addEventListener('install', function(event) {
  event.waitUntil(
    caches.open('mygame-core-v1').then(function(cache) {
      cache.addAll(
        // 低优先级
      );
      return cache.addAll(
        // 核心资源获高优先级
      );
    })
  );
});

我们不会将低优先级 的 cache.addAll 的 promise 传递回 event.waitUntil,因此,即使它失败,应用在离线状态下仍然可用。必须考虑到可能缺少这些级别的情况,并且如果缺少,则重新尝试缓存它们。

激活时

适合于: 清理和迁移。

在新的 ServiceWorker 已安装并且未使用以前版本的情况下,新 ServiceWorker 将激活,将激活 activate 事件。 由于旧版本退出,此时非常适合处理 IndexedDB 中的架构迁移和删除未使用的缓存。

self.addEventListener('activate', function(event) {
  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.filter(function(cacheName) {
          // 过滤要删除的缓存
        }).map(function(cacheName) {
          return caches.delete(cacheName);
        })
      );
    })
  );
});

在激活期间,fetch 等其他事件会放置在一个队列中,因此长时间激活可能会阻止页面加载。

用户交互时

适合于: 如果整个网站无法离线工作,您可以允许用户选择他们需要离线可用的内容。

当点击该按钮时,从网络获取需要的内容并将其置于缓存中。

document.querySelector('.cache-button').addEventListener('click', function(event) {
  event.preventDefault();

  var id = this.dataset.articleId;
  caches.open('mysite-article-' + id).then(function(cache) {
    fetch('/get-article-urls?id=' + id).then(function(response) {
    // 获取所有依赖的url,并且缓存
      return response.json();
    }).then(function(urls) {
      cache.addAll(urls);
    });
  });
});
网络响应时

适合于: 频繁更新诸如用户收件箱或文章内容等资源。 同时适用于不重要的资源,如头像,但需要谨慎处理。

如果请求的资源与缓存中的任何资源均不匹配,则从网络中获取,将其发送到页面同时添加到缓存中。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function(cache) {
      return cache.match(event.request).then(function (response) {
        return response || fetch(event.request).then(function(response) {
          cache.put(event.request, response.clone());
          return response;
        });
      });
    })
  );
});

为留出充足的内存使用空间,每次只能读取一个响应/请求的正文。 在上面的代码中,.clone() 用于创建可单独读取的额外副本。

重新验证时

适合于: 频繁更新最新版本并非必需的资源。 头像属于此类别。

如果有可用的缓存版本,则使用该版本,但下次会获取更新。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function(cache) {
      return cache.match(event.request).then(function(response) {
        var fetchPromise = fetch(event.request).then(function(networkResponse) {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        })
        return response || fetchPromise;
      })
    })
  );
});

缓存模式

无论您缓存多少内容 ServiceWorker 都不会使用缓存,除非您指示它在何时使用缓存以及如何使用。 以下是用于处理请求的几个模式:

仅缓存

适合于: 获取网站静态内容的任何资源。应在install事件中缓存这些资源,以便可以使用它们。

self.addEventListener('fetch', function(event) {
  // 如果缓存中没有找到,响应看起来和connection错误一样
  event.respondWith(caches.match(event.request));
});
仅网络

适合于: 没有相应离线资源的对象,如 analytics pings、non-GET 请求。

self.addEventListener('fetch', function(event) {
  event.respondWith(fetch(event.request));
});
缓存、回退到网络

适合于: 以离线优先的方式进行构建,这是处理大多数请求的方式。 根据传入请求而定,其他模式会有例外。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request).then(function(response) {
      return response || fetch(event.request);
    })
  );
});
缓存和网络竞态

适合于: 小型资源,可用于改善磁盘访问缓慢的设备的性能。

在硬盘较旧、具有病毒扫描程序且互联网连接很快这几种情形相结合的情况下,从网络获取资源比访问磁盘更快。

// 不要使用Promise.race,因为在fulfilling之前有一个reject,整体就会reject。
function promiseAny(promises) {
  return new Promise((resolve, reject) => {
    // 确认promises都是Promise对象
    promises = promises.map(p => Promise.resolve(p));
    // 使用最快的promise resolve
    promises.forEach(p => p.then(resolve));
    // 判断是否所有的reject
    promises.reduce((a, b) => a.catch(() => b))
      .catch(() => reject(Error("All failed")));
  });
};

self.addEventListener('fetch', function(event) {
  event.respondWith(
    promiseAny([
      caches.match(event.request),
      fetch(event.request)
    ])
  );
});
网络回退到缓存

适合于: 快速修复频繁更新的资源。 例如,文章、头像、社交媒体时间表、游戏排行榜。

意味着为在线用户提供最新内容,但离线用户会获得较旧的缓存版本。如果网络请求成功,可能需要更新缓存。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    fetch(event.request).catch(function() {
      return caches.match(event.request);
    })
  );
});

注意:此方法存在缺陷。如果用户的网络时断时续或很慢,这需要花很长的时间。

先缓存然后访问网络

适合于: 频繁更新的内容。例如,文章、社交媒体时间表、游戏排行榜。

需要页面进行两次请求,一次是请求缓存,另一次是请求访问网络。 该操作是首先显示缓存的数据,然后在网络数据到达时更新页面。

var networkDataReceived = false;

// 请求网络数据
var networkUpdate = fetch('/data.json').then(function(response) {
  return response.json();
}).then(function(data) {
  networkDataReceived = true;
  updatePage();
});

// 请求缓存数据
caches.match('/data.json').then(function(response) {
  if (!response) throw Error("No data");
  return response.json();
}).then(function(data) {
  // 防止覆盖缓存数据
  if (!networkDataReceived) {
    updatePage(data);
  }
}).catch(function() {
  // 为获取缓存数据,则请求网络
  return networkUpdate;
})

ServiceWorker 中的代码:始终访问网络并随时更新缓存

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function(cache) {
      return fetch(event.request).then(function(response) {
        cache.put(event.request, response.clone());
        return response;
      });
    })
  );
});
常规回退

适合于: 次要图像,如头像、失败的 POST 请求、“Unavailable while offline”页面。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request).then(function(response) {
      return response || fetch(event.request);
    }).catch(function() {
      return caches.match('/offline.html');
    })
  );
});
ServiceWorker端模板渲染

适合于: 无法缓存其服务器响应的页面(SSR)。 对于服务端渲染,如果缓存失败,可以转而选择请求 JSON 数据和一个模板,并进行渲染。

importScripts('templating-engine.js');

self.addEventListener('fetch', function(event) {
  var requestURL = new URL(event.request);

  event.respondWith(
    Promise.all([
      caches.match('/article-template.html').then(function(response) {
        return response.text();
      }),
      caches.match(requestURL.path + '.json').then(function(response) {
        return response.json();
      })
    ]).then(function(responses) {
      var template = responses[0];
      var data = responses[1];

      return new Response(renderTemplate(template, data), {
        headers: {
          'Content-Type': 'text/html'
        }
      });
    })
  );
});

混合使用

可能会根据请求网址使用其中的多个方法。

self.addEventListener('fetch', function(event) {
  var requestURL = new URL(event.request.url);

  // 判断host,统一使用特定模式
  if (requestURL.hostname == 'api.example.com') {
    event.respondWith(/* some combination of patterns */);
    return;
  }
  
  if (requestURL.origin == location.origin) {
    // 文本
    if (/^\/article\//.test(requestURL.pathname)) {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    // 图片
    if (/\.webp$/.test(requestURL.pathname)) {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (request.method == 'POST') {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (/cheese/.test(requestURL.pathname)) {
      event.respondWith(
        new Response("Flagrant cheese error", {
          status: 512
        })
      );
      return;
    }
  }

  // 默认处理模式
  event.respondWith(
    caches.match(event.request).then(function(response) {
      return response || fetch(event.request);
    })
  );
});