离线实战宝典

Jake Archibald
Jake Archibald

对于 Service Worker,我们放弃了尝试解决离线问题,并为开发者提供了灵活组件,让他们自行解决此问题。借助它,您可以控制缓存以及请求的处理方式。这意味着您可以创建自己的模式。我们来看几个在隔离时可能出现的模式,但在实践中,您可能会根据网址和上下文串联使用其中的许多模式。

如需查看其中一些模式的实战演示,请参阅 Trained-to-thrill(训练到惊险刺激)和此视频,了解性能影响。

缓存计算机 - 何时存储资源

借助 Service Worker,您可以独立于缓存处理请求,所以我将单独演示。首先,应在何时进行缓存?

安装时 - 作为依赖项

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

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 拒绝,则安装被视为失败,此 Service Worker 将被舍弃(如果运行的是较旧版本,其将保持不变)。caches.open()cache.addAll() 会返回 promise。 如果有任何资源未能提取,cache.addAll() 调用会拒绝。

training-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 将处理此类情况以及较大型的下载内容,如电影。目前,只有 Chromium 分支支持该 API。

激活时

启用时。
启用时。

适合于:清理和迁移。

安装了新的 Service Worker 且未使用以前的版本后,新的 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 等其他事件会被放入队列中,因此长时间激活可能会导致页面加载中断。尽可能使您的激活函数尽可能精简,并仅将其用于无法在旧版本处于活动状态时无法执行的操作。

train-to-thrill 上,我用它来移除旧缓存

用户互动时

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

适用情形:当整个网站无法离线访问,并且您选择允许用户选择要离线访问的内容时。例如,YouTube 上的某个视频、维基百科上的某篇文章、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 可通过页面以及 Service Worker 使用,这意味着您可以直接从页面将缓存添加到缓存。

网络响应时

收到网络响应时。
网络响应时。

适用情形:频繁更新用户收件箱或文章内容等资源。还适用于头像等非必要内容,但需要谨慎处理。

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

如果您针对一系列网址(例如头像)执行此操作,则需要小心谨慎,以免源站存储空间过大。如果用户需要回收磁盘空间,您不会希望成为主要候选者。请务必删除缓存中不再需要的内容。

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

training-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,以响应来自操作系统消息传递服务的消息。即使用户没有为您的网站打开标签页,也会出现这种情况。只有 Service Worker 被唤醒。您从页面请求执行此操作的权限,系统会提示用户。

适用对象:与通知相关的内容,例如聊天消息、重大新闻报道或电子邮件。此外,还会不常更改受益于立即同步的内容,例如待办事项列表更新或更改日历。

常见的最终结果是出现一个通知,点按该通知后会打开/聚焦一个相关页面,但在此之前更新缓存至关重要。extremely很明显,用户在收到推送通知时处于在线状态,但当他们最终与通知互动时可能并非处于在线状态,因此使此内容可供离线访问非常重要。

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

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/');
  }
});

后台同步时

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

后台同步是基于 Service Worker 构建的另一项功能。它允许您一次性或按(非常具有启发性的)间隔请求后台数据同步。即使用户没有为您的网站打开标签页,也会如此。仅唤醒 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文件系统访问,当然还有缓存

您收到的金额不符合规范。存储空间因设备和存储条件而异。您可以通过以下方式了解您已获得多少用量:

navigator.storageQuota.queryInfo('temporary').then(function (info) {
  console.log(info.quota);
  // Result: <quota in bytes>
  console.log(info.usage);
  // Result: <used data in 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。

让用户参与此流程非常重要,因为我们现在可以预计用户会控制删除。如果用户的设备出现存储压力,并且清除非必要的数据并不能解决问题,用户可以判断要保留和移除哪些内容。

为此,它需要操作系统将“持久耐用”源等同于平台专用应用的存储空间用量细分,而不是将浏览器报告为单个项。

投放建议 — 响应请求

无论您执行多少缓存操作,Service Worker 都不会使用缓存,除非您指示其使用缓存的时间和方式。以下是处理请求的几种模式:

仅缓存

仅限缓存。
仅缓存。

适用对象:您认为在网站的特定“版本”中属于静态内容的任何对象。您应在安装事件中缓存这些变量,以便依赖其存储。

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

...尽管您通常不需要专门处理这种情况,但缓存、回退到网络可以做到这一点。

仅限网络

仅限网络。
仅限网络。

适用情形:没有相应离线资源的对象,例如分析 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 通常会保持大致线性的内容顺序。我为 training-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;
      });
    }),
  );
});

training-to-thrill 中,我解决了此问题,方法是使用 XHR 而不是提取,并滥用 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.
      }),
  );
});

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

如果您的网页正在发布电子邮件,您的 Service Worker 可能会回退到将电子邮件存储在 IndexedDB“发件箱”中,并做出响应,告知网页发送失败,但数据已成功保留。

Service Worker 端模板

ServiceWorker 端模板。
ServiceWorker 端模板。

适用情形:无法缓存服务器响应的网页。

在服务器上渲染网页可以提高速度,但这可能意味着包含在缓存中可能没有意义的状态数据,例如“Logged in as...”。如果您的页面由 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',
        },
      });
    }),
  );
});

归纳总结

您不仅可以使用这些方法,事实上,您可能会使用其中许多方法,具体取决于请求网址。例如,training-to-thrill 使用:

只需查看请求并决定要执行的操作:

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

...您将得到图片。

赠金

...可爱的图标:

同时感谢 Jeff Posnick 在我点击“发布”之前就发现了大量错误。

深入阅读