[译]前端离线指南(下)

1,085 阅读8分钟

[译]前端离线指南(上)

原文链接:The offline cookbook 作者:Jake Archibald

缓存持久化

为您的站点提供一定量的可用空间来执行其所需的操作。该可用空间可在站点中所有存储之间共享:LocalStorage、IndexedDB、Filesystem,当然也包含Caches。

您能获取到的空间容量是不一定的,同时由于设备和存储条件的差异也会有所不同。您可以通过下面的代码来查看您已获得的空间容量:

navigator.storageQuota.queryInfo("temporary").then((info) => {
  console.log(info.quota);
  // Result: <quota in bytes>
  console.log(info.usage);
  // Result: <used data in bytes>
});

然而,与所有浏览器存储一样,如果设备面临存储压力,浏览器就会随时舍弃这些存储内容。但遗憾的是,浏览器无法区分您珍藏的电影,和您没啥兴趣的游戏之间有啥区别。

为解决此问题,建议使用 requestPersistent API:

// 在页面中运行
navigator.storage.requestPersistent().then((granted) => {
  if (granted) {
    // 啊哈,数据保存在这里呢
  }
});

当然,用户必须要授予权限。让用户参与进这个流程是很有必要的,因为我们可以预期用户会控制删除。如果用户手中的设备面临存储压力,而且清除不重要的数据还没能解决问题,那么用户就需要根据自己的判断来决定删除哪些项目以及保留哪些项目。

为了实现此目的,需要操作系统将“持久化”源等同于其存储使用空间细分中的本机应用,而不是作为单个项目报告给浏览器。

缓存建议-响应请求

无论您打算缓存多少内容,除非您告诉ServiceWorker应当在何时以及如何去缓存内容,ServiceWorker不会去主动使用缓存。下面是几种用于处理请求的策略。

仅缓存(cache only)

适用于: 您认为在站点的“该版本”中属于静态内容的任何资源。您应当在install事件中就缓存这些资源,以便您可以在处理请求的时候依靠它们。

self.addEventListener('fetch', (event) => {
  // 如果某个匹配到的资源在缓存中找不到,
  // 则响应结果看起来就会像一个连接错误。
  event.respondWith(caches.match(event.request));
});

...尽管您一般不需要通过特殊的方式来处理这种情况,但“缓存,回退到网络”涵盖了这种策略。

仅网络(network only)

适用于: 没有相应的离线资源的对象,比如analytics pings,非GET请求。

self.addEventListener('fetch', (event) => {
  event.respondWith(fetch(event.request));
  // 或者简单地不再调用event.respondWith,这样就会
  // 导致默认的浏览器行为
});

...尽管您一般不需要通过特殊的方式来处理这种情况,但“缓存,回退到网络”涵盖了这种策略。

缓存优先,若无缓存则回退到网络(Cache, falling back to network)

适用于: 如果您以缓存优先的方式构建,那么这种策略就是您处理大多数请求时所用的策略 根据传入请求而定,其他策略会有例外。

self.addEventListener('fetch', (event) => {
  event.respondWith(async function() {
    const response = await caches.match(event.request);
    return response || fetch(event.request);
  }());
});

其中,针对已缓存的资源提供“Cache only”的行为,针对未缓存的资源(包含所有非GET请求,因为它们根本无法被缓存)提供“Network only”的行为。

缓存与网络竞争

适用于: 存储在读取速度慢的硬盘中的小型资源。

在老旧硬盘、病毒扫描程序、和较快网速这几种因素都存在的情况下,从网络中获取资源可能比从硬盘中获取的速度更快。不过,通过网络获取已经在用户设备中保存过的内容,是一种浪费流量的行为,所以请牢记这一点。

// Promise.race 对我们来说并不太好,因为若当其中一个promise在
// fulfilling之前reject了,那么整个Promise.race就会返回reject。
// 我们来写一个更好的race函数:
function promiseAny(promises) {
  return new Promise((resolve, reject) => {
    // 确保promises代表所有的promise对象。
    promises = promises.map(p => Promise.resolve(p));
    // 只要当其中一个promise对象调用了resolve,就让此promise对象变成resolve的
    promises.forEach(p => p.then(resolve));
    // 如果传入的所有promise都reject了,就让此promise对象变成resject的
    promises.reduce((a, b) => a.catch(() => b))
      .catch(() => reject(Error("All failed")));
  });
};

self.addEventListener('fetch', (event) => {
  event.respondWith(
    promiseAny([
      caches.match(event.request),
      fetch(event.request)
    ])
  );
});

通过网络获取失败回退到缓存(Network falling back to cache)

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

这就意味着您可以为在线用户提供最新内容,但是离线用户获取到的是较老的缓存版本。如果网络请求成功,您可能需要更新缓存。

不过,这种方法存在缺陷。如果用户的网络断断续续,或者网速超慢,则用户可能会在从自己设备中获取更好的、可接受的内容之前,花很长一段时间去等待网络请求失败。这样的用户体验是非常糟糕的。请查看下一个更好的解决方案:“缓存然后访问网络”。

self.addEventListener('fetch', (event) => {
  event.respondWith(async function() {
    try {
      return await fetch(event.request);
    } catch (err) {
      return caches.match(event.request);
    }
  }());
});

缓存然后访问网络

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

这种策略需要页面发起两个请求,一个是请求缓存,一个是请求网络。首先展示缓存数据,然后当网络数据到达的时候,更新页面。

有时候,您可以在获取到新的数据的时候,只替换当前数据(比如:游戏排行榜),但是具有较大的内容时将导致数据中断。基本上讲,不要在用户可能正在阅读或正在操作的内容突然“消失”。

Twitter在旧内容上添加新内容,并调整滚动的位置,以便让用户感知不到。这是可能的,因为 Twitter 通常会保持使内容最具线性特性的顺序。 我为 trained-to-thrill 复制了此模式,以尽快获取屏幕上的内容,但当它出现时仍会显示最新内容。 页面中的代码

async function update() {
  // 尽可能地发起网络请求
  const networkPromise = fetch('/data.json');

  startSpinner();

  const cachedResponse = await caches.match('/data.json');
  if (cachedResponse) await displayUpdate(cachedResponse);

  try {
    const networkResponse = await networkPromise;
    const cache = await caches.open('mysite-dynamic');
    cache.put('/data.json', networkResponse.clone());
    await displayUpdate(networkResponse);
  } catch (err) {
   
  }

  stopSpinner();

  const networkResponse = await networkPromise;

}

async function displayUpdate(response) {
  const data = await response.json();
  updatePage(data);
}

常规回退

如果您未能从网络和缓存中提供某些资源,您可能需要一个常规回退策略。

适用于: 次要的图片,比如头像,失败的POST请求,“离线时不可用”的页面。

self.addEventListener('fetch', (event) => {
  event.respondWith(async function() {
    // 尝试从缓存中匹配
    const cachedResponse = await caches.match(event.request);
    if (cachedResponse) return cachedResponse;

    try {
      // 回退到网络
      return await fetch(event.request);
    } catch (err) {
      // 如果都失败了,启用常规回退:
      return caches.match('/offline.html');
      // 不过,事实上您需要根据URL和Headers,准备多个不同回退方案
      // 例如:头像的兜底图
    }
  }());
});

您回退到的项目可能是一个“安装依赖项”(见《前端离线指南(上)》中的“安装时——以依赖的形式”小节)。

ServiceWorker-side templating

适用于: 无法缓存其服务器响应的页面。

在服务器上渲染页面可提高速度,但这意味着会包括在缓存中没有意义的状态数据,例如,“Logged in as…”。如果您的页面由 ServiceWorker 控制,您可能会转而选择请求 JSON 数据和一个模板,并进行渲染。

importScripts('templating-engine.js');

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

  event.responseWith(async function() {
    const [template, data] = await Promise.all([
      caches.match('/article-template.html').then(r => r.text()),
      caches.match(requestURL.path + '.json').then(r => r.json()),
    ]);

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

总结

您不必只选择其中的一种方法,您可以根据请求URL选择使用多种方法。比如,在trained-to-thrill中使用了:

只需要根据请求,就能决定要做什么:

self.addEventListener('fetch', (event) => {
  // Parse the URL:
  const requestURL = new URL(event.request.url);

  // Handle requests to a particular host specifically
  if (requestURL.hostname == 'api.example.com') {
    event.respondWith(/* some combination of patterns */);
    return;
  }
  // Routing for local URLs
  if (requestURL.origin == location.origin) {
    // Handle article URLs
    if (/^\/article\//.test(requestURL.pathname)) {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (requestURL.pathname.endsWith('.webp')) {
      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;
    }
  }

  // A sensible default pattern
  event.respondWith(async function() {
    const cachedResponse = await caches.match(event.request);
    return cachedResponse || fetch(event.request);
  }());
});

鸣谢

感谢下列诸君为本文提供那些可爱的图标:

同时感谢 Jeff Posnick 在我点击“发布”按钮之前,为我找到多处明显错误。

扩展阅读