离线实战宝典

Jake Archibald
Jake Archibald

借助服务工件,我们放弃了尝试离线解决问题的做法,而是为开发者提供了可自行解决问题的移动组件。您可以通过它控制缓存和请求的处理方式。也就是说,您可以创建自己的图案。我们先单独来看几个可能的模式,但在实践中,您可能会根据网址和上下文同时使用其中的许多模式。

如需查看其中一些模式的实际演示,请参阅训练有素,以及此视频,了解性能影响。

缓存机 - 何时存储资源

借助 Service Worker,您可以独立于缓存处理请求,因此我将分别演示这两者。首先,缓存应该在什么时候进行?

安装时 - 作为依赖项

安装时 - 作为依赖项。
安装时 - 作为依赖项。

服务工件会向您发送 install 事件。您可以使用此方法准备一些内容,这些内容必须在处理其他事件之前准备就绪。在此期间,您的任何旧版 Service Worker 仍在运行和提供网页,因此您在此处执行的操作不得干扰此过程。

适合: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 被拒绝,系统会将安装视为失败,并放弃此服务工件(如果有较低版本正在运行,则会保持不变)。caches.open()cache.addAll() 会返回 Promise。如果无法提取任何资源,cache.addAll() 调用将被拒绝。

trained-to-thrill 中,我使用它来缓存静态资源

在安装时(而非作为依赖项)

在安装时 - 而非作为依赖项。
安装时 - 不是作为依赖项。

这与上述方法类似,但不会延迟安装完成,也不会在缓存失败时导致安装失败。

适合:不立即需要的较大资源,例如游戏后续关卡的资源。

self.addEventListener('install', function (event) {
  event.waitUntil(
    caches.open('mygame-core-v1').then(function (cache) {
      cache
        .addAll
        // levels 11–20
        ();
      return cache
        .addAll
        // core assets and levels 1–10
        ();
    }),
  );
});

上面的示例不会将第 11-20 关的 cache.addAll Promise 传回给 event.waitUntil,因此即使失败,游戏仍可离线玩。当然,您必须考虑到这些级别可能不存在的情况,并在缺失时重新尝试缓存它们。

在第 11 到第 20 关下载期间,由于 Service Worker 已完成事件处理,因此可能会被终止,这意味着这些关卡不会被缓存。未来,Web Periodic Background Synchronization API 将处理此类情况以及电影等较大的下载内容。该 API 目前仅在 Chromium 分支上受支持。

激活时

激活后。
启用时。

适合:清理和迁移。

安装新版 Service Worker 后,如果旧版不再使用,新版就会激活,并且您会收到 activate 事件。由于旧版本已被弃用,因此现在是处理 IndexedDB 中的架构迁移以及删除未使用的缓存的好时机。

self.addEventListener('activate', function (event) {
  event.waitUntil(
    caches.keys().then(function (cacheNames) {
      return Promise.all(
        cacheNames
          .filter(function (cacheName) {
            // Return true if you want to remove this cache,
            // but remember that caches are shared across
            // the whole origin
          })
          .map(function (cacheName) {
            return caches.delete(cacheName);
          }),
      );
    }),
  );
});

在激活期间,其他事件(例如 fetch)会被放入队列,因此激活时间过长可能会阻止网页加载。尽可能精简激活步骤,仅将其用于旧版处于有效状态时无法执行的操作。

trained-to-thrill 中,我使用此命令来移除旧缓存

用户互动时

在用户互动时。
用户互动时。

适合以下情况:当整个网站无法离线访问时,您选择允许用户选择要离线访问的内容。例如,YouTube 上的视频、Wikipedia 上的文章、Flickr 上的特定图库。

为用户提供“稍后阅读”或“保存以供离线使用”按钮。点击该按钮后,从网络提取所需内容并将其弹出到缓存中。

document.querySelector('.cache-article').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) {
        // /get-article-urls returns a JSON-encoded array of
        // resource URLs that a given article depends on
        return response.json();
      })
      .then(function (urls) {
        cache.addAll(urls);
      });
  });
});

caches API 可从网页和服务工件中使用,这意味着您可以直接从网页中添加到缓存。

网络响应时

在网络响应中。
网络响应时。

适合:频繁更新的资源,例如用户的收件箱或文章内容。也适用于头像等非必需内容,但需要注意。

如果请求与缓存中的任何内容都不匹配,则从网络获取该请求,将其发送到网页,并同时将其添加到缓存中。

如果您要对一系列网址(例如头像)执行此操作,则需要注意不要让来源的存储空间膨胀。如果用户需要回收磁盘空间,则不应将您设置为主要候选人。请务必清除缓存中不再需要的项。

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() 创建了可单独读取的其他副本。

trained-to-thrill 中,我使用它来缓存 Flickr 图片

Stale-while-revalidate

在重新验证时提供过时内容。
在重新验证期间过时。

适合:频繁更新的资源,其中最新版本不是必需的。头像就属于此类。

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

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;
      });
    }),
  );
});

这与 HTTP 的 stale-while-revalidate 非常相似。

在收到推送消息时

收到推送消息时。
收到推送消息时。

Push API 是另一项基于 Service Worker 构建的功能。这样,系统就可以在收到来自操作系统消息服务的消息时唤醒 Service Worker。即使用户未打开指向您网站的标签页,也会发生这种情况。只有服务工件会被唤醒。您可以在某个页面上请求执行此操作,系统会向用户显示提示。

适合:与通知相关的内容,例如聊天消息、突发新闻报道或电子邮件。此外,不经常更改受益于即时同步的内容,例如更新待办事项列表或更改日历。

常见的最终结果是,用户点按通知后,系统会打开/聚焦相关页面,但在此之前更新缓存非常重要。用户在收到推送消息时显然处于在线状态,但在最终与通知互动时可能处于离线状态,因此请务必让此类内容可供离线使用。

以下代码会在显示通知之前更新缓存:

self.addEventListener('push', function (event) {
  if (event.data.text() == 'new-email') {
    event.waitUntil(
      caches
        .open('mysite-dynamic')
        .then(function (cache) {
          return fetch('/inbox.json').then(function (response) {
            cache.put('/inbox.json', response.clone());
            return response.json();
          });
        })
        .then(function (emails) {
          registration.showNotification('New email', {
            body: 'From ' + emails[0].from.name,
            tag: 'new-email',
          });
        }),
    );
  }
});

self.addEventListener('notificationclick', function (event) {
  if (event.notification.tag == 'new-email') {
    // Assume that all of the resources needed to render
    // /inbox/ have previously been cached, e.g. as part
    // of the install handler.
    new WindowClient('/inbox/');
  }
});

在 background-sync 上

在后台同步中。
在后台同步时。

后台同步是另一项基于 Service Worker 构建的功能。借助它,您可以请求一次性或按(极其启发性的)间隔同步后台数据。即使用户未打开指向您网站的标签页,也会发生这种情况。只有服务工件会被唤醒。您可以通过某个页面请求执行此操作,系统会向用户显示提示。

最适合:非紧急更新,尤其是更新频率非常高,如果每次更新都发送推送消息,会让用户感到频繁的更新,例如社交时间轴或新闻报道。

self.addEventListener('sync', function (event) {
  if (event.id == 'update-leaderboard') {
    event.waitUntil(
      caches.open('mygame-dynamic').then(function (cache) {
        return cache.add('/leaderboard.json');
      }),
    );
  }
});

缓存持久性

您的来源会获得一定数量的可用空间,以便执行所需的操作。该可用空间在所有来源存储空间之间共享:(本地)存储空间IndexedDB文件系统访问,当然还有缓存

您获得的金额未指定。具体时间因设备和存储条件而异。您可以通过以下方式查看自己获得的积分:

if (navigator.storage && navigator.storage.estimate) {
  const quota = await navigator.storage.estimate();
  // quota.usage -> Number of bytes used.
  // quota.quota -> Maximum number of bytes available.
  const percentageUsed = (quota.usage / quota.quota) * 100;
  console.log(`You've used ${percentageUsed}% of the available storage.`);
  const remaining = quota.quota - quota.usage;
  console.log(`You can write up to ${remaining} more bytes.`);
}

不过,与所有浏览器存储空间一样,如果设备存储空间不足,浏览器可以随意丢弃您的数据。很遗憾,浏览器无法区分您不惜一切代价也要保留的电影与您不太在意的游戏。

如需解决此问题,请使用 StorageManager 接口:

// From a page:
navigator.storage.persist()
.then(function(persisted) {
  if (persisted) {
    // Hurrah, your data is here to stay!
  } else {
   // So sad, your data may get chucked. Sorry.
});

当然,用户必须授予权限。为此,请使用 Permissions API。

让用户参与此流程非常重要,因为我们现在希望用户能够控制删除操作。如果用户设备的存储空间不足,并且清除非必需数据无法解决问题,用户可以自行判断要保留和移除哪些内容。

为此,操作系统需要在存储用量明细中将“持久性”来源视为平台专用应用,而不是将浏览器报告为单个项目。

投放建议 - 响应请求

无论您缓存了多少内容,除非您告知服务工作器何时以及如何使用缓存,否则它不会使用缓存。下面是处理请求的一些模式:

仅缓存

仅缓存。
仅缓存。

适合:您认为对网站的特定“版本”而言是静态的任何内容。您应该已在安装事件中缓存这些数据,因此可以依赖于它们的存在。

self.addEventListener('fetch', function (event) {
  // If a match isn't found in the cache, the response
  // will look like a connection error
  event.respondWith(caches.match(event.request));
});

…虽然您通常不需要专门处理这种情况,但缓存,回退到网络会对其进行介绍。

仅限网络

仅限影音平台。
仅限网络。

适合:没有离线等效项的事物,例如 Google Analytics ping、非 GET 请求。

self.addEventListener('fetch', function (event) {
  event.respondWith(fetch(event.request));
  // or simply don't call event.respondWith, which
  // will result in default browser behavior
});

…虽然您通常不需要专门处理这种情况,但缓存,回退到网络会对其进行介绍。

缓存,回退到网络

缓存,回退到网络。
缓存,回退到网络。

适合:构建离线优先应用。在这种情况下,您将以这种方式处理大多数请求。其他模式将视传入请求而异。

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

这样,您就可以针对缓存中的内容采用“仅缓存”行为,针对未缓存的任何内容(包括所有非 GET 请求,因为它们无法缓存)采用“仅网络”行为。

缓存和网络争用

缓存和网络争用。
缓存和网络争用。

适合:在磁盘访问速度缓慢的设备上追求性能的小型素材资源。

在某些情况下,如果结合使用旧款硬盘、病毒扫描程序和更快的互联网连接,从网络获取资源的速度可能会比从磁盘获取更快。不过,如果用户设备上已有相应内容,那么连接到网络可能会浪费流量,请注意这一点。

// Promise.race is no good to us because it rejects if
// a promise rejects before fulfilling. Let's make a proper
// race function:
function promiseAny(promises) {
  return new Promise((resolve, reject) => {
    // make sure promises are all promises
    promises = promises.map((p) => Promise.resolve(p));
    // resolve this promise as soon as one resolves
    promises.forEach((p) => p.then(resolve));
    // reject if all promises 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);
    }),
  );
});

先缓存,然后网络

先缓存,然后网络。
先缓存,然后网络。

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

这需要网页发出两个请求,一个请求发送到缓存,另一个请求发送到网络。其基本思路是先显示缓存的数据,然后在网络数据到达时更新页面。

有时,您可以直接在有新数据到达时替换当前数据(例如游戏排行榜),但对于较大的内容,这可能会造成干扰。基本上,请勿让用户正在阅读或与之互动的内容“消失”。

Twitter 会在旧内容上方添加新内容,并调整滚动位置,以便用户不受干扰。之所以能够做到这一点,是因为 Twitter 的内容大多保持线性顺序。我为 trained-to-thrill 复制了此模式,以便尽快在屏幕上显示内容,同时在有最新内容时立即显示。

页面中的代码

var networkDataReceived = false;

startSpinner();

// fetch fresh data
var networkUpdate = fetch('/data.json')
  .then(function (response) {
    return response.json();
  })
  .then(function (data) {
    networkDataReceived = true;
    updatePage(data);
  });

// fetch cached data
caches
  .match('/data.json')
  .then(function (response) {
    if (!response) throw Error('No data');
    return response.json();
  })
  .then(function (data) {
    // don't overwrite newer network data
    if (!networkDataReceived) {
      updatePage(data);
    }
  })
  .catch(function () {
    // we didn't get cached data, the network is our last hope:
    return networkUpdate;
  })
  .catch(showErrorMessage)
  .then(stopSpinner);

Service Worker 中的代码

您应始终访问网络并随时更新缓存。

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;
      });
    }),
  );
});

trained-to-thrill 中,我通过使用 XHR 而非 fetch 来解决此问题,并滥用 Accept 标头来告知 Service Worker 从何处获取结果(网页代码Service Worker 代码)。

通用回退

通用回退。
通用回退。

如果您无法从缓存和/或网络中提供内容,则可能需要提供通用回退。

最适合:头像、失败的 POST 请求和“离线时无法使用”页面等次要图像。

self.addEventListener('fetch', function (event) {
  event.respondWith(
    // Try the cache
    caches
      .match(event.request)
      .then(function (response) {
        // Fall back to network
        return response || fetch(event.request);
      })
      .catch(function () {
        // If both fail, show a generic fallback:
        return caches.match('/offline.html');
        // However, in reality you'd have many different
        // fallbacks, depending on URL and headers.
        // Eg, a fallback silhouette image for avatars.
      }),
  );
});

您回退到的项可能是安装依赖项

如果您的网页要发送电子邮件,您的服务工作器可能会回退到将电子邮件存储在 IndexedDB“收件箱”中,并通过告知网页发送失败但数据已成功保留来做出响应。

Service Worker 端模板

ServiceWorker 端模板。
ServiceWorker 端模板。

最适合:无法缓存服务器响应的网页。

在服务器上呈现网页可以加快速度,但这可能意味着包含在缓存中可能没有意义的状态数据,例如“已登录为…”。如果您的网页由 Service Worker 控制,您可以改为请求 JSON 数据和模板,并改为呈现该模板。

importScripts('templating-engine.js');

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

  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) {
  // Parse the URL:
  var 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 (/\.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;
    }
  }

  // A sensible default pattern
  event.respondWith(
    caches.match(event.request).then(function (response) {
      return response || fetch(event.request);
    }),
  );
});

…您明白了。

赠金

…为可爱的图标:

  • Code by buzzyrobot
  • 日历(作者:Scott Lewis)
  • 《Network》(影音平台)- Ben Rizzo
  • 由 Thomas Le Bas 创作的 SD
  • CPU(由 iconsmind.com 提供)
  • 回收站(作者:trasnik)
  • @daosme 发送的通知
  • 布局 - Mister Pixel
  • (作者:P.J. Onori)

感谢 Jeff Posnick 在我点击“发布”之前发现了许多严重错误。

深入阅读