Service worker 指南

Service Worker 已经不是新鲜的东西的,早在 2014 年,W3C 就公布了 Service Worker 的草案,而当下十分流行的 PWA (Prograssive Web Apps) 就借助于 Service Worker 实现更可靠的用户体验,而且目前 Firefox 和 Chrome 都已经支持 Service Worker 了。

Service Worker 的概念与我们之前接触到的前端概念不太一样,它不是库,也不是一个新的 HTML 元素,也不是一种 Javascript 新语法,一开始接触的时候,会让人有点困惑。那么它的具体概念是什么呢?

传统上,Web App 通过 HTTP 协议与外部世界相联系,虽然 HTTP 协议很棒,但是这一些都需要基于假设网络是可访问的。但是用户的网络状况并不是一直都是稳定快速的,一旦 Web App 无法通过 HTTP 与外部网络连接,那么用户能看到的只能是一只来自侏罗纪的恐龙。

Service Worker 能帮助解决这个问题

Service Worker 被设计为浏览器和网络中中间的代理。它让浏览器不再是直接向网络请求,而且先把请求提交给 Service Worker,并可以要求它为你做一些工作,它可以被理解为连接浏览器和网络的一条管道。

关于 Service Worker 你需要了解的三件事

  1. Service Worker 只会在你呼唤它的时候工作,除非你呼唤它,否则它就不会出现,一旦你呼唤它来帮助你,它就会一直待在那里,直到它认为自己没有事情可做了,没有像 Web Worker 一样的 .terminate() 方法。
  2. Service Worker 运行在 worker 上下文,因此不能访问 DOM。相对于驱动应用的主 JavaScript 线程,它运行在其他线程中,所以不会造成阻塞。
  3. 首次访问 Service Worker 控制的网站或页面时,Service Worker 会立即被下载,之后至少每 24 小时它会被下载一次。它可能被更频繁地下载,不过每 24 小时一定会被下载一次,以避免不良脚本长时间生效。

那么可以让 Service Worker 做什么?

1. 与缓存交互

你可以让 Service Worder 作为监控 fetch 事件的中间人,让 Service Worker 缓存特定的资源。当缓存的资源被请求时,Service Worker 可以直接返回缓存的数据而不需要发送额外的 HTTP 请求。一旦资源被缓存了,浏览器也能在没有网络连接的情况下显示内容。

2. 发送推送通知

由于在浏览器窗口关闭后,Service Worker 仍然可以处于激活状态的神奇特性,可以用它来实现推送通知的功能

3. 同步后端数据

浏览器未打开时仍处于活跃意味着 Service Worker 可以在用户不在访问的情况在后台工作。假设你在浏览器离线时发送了一些文件,Service Worker 可以在网络连接可以用的时将其上传的外部服务器。

Service Worker 生命周期

Installing

这个阶段标志着注册的开始,它旨在允许设置 worker 特定的离线缓存等资源

install 事件

  • 使用 event.waitUntil() 传递一个 promise 来扩展 installing 阶段,直到这个 promise 得到解决
  • 在激活前的任何事件都可以使用 self.skipWaiting() 来跳过 installed 阶段,不等待当前客户关闭控制直接进入 activating 阶段

Installed

service worker 完成了它的设置并等待客户使用的其他 service worker 被关闭。

Activating

这个时候客户已经不会被其他 worker 控制了。这个阶段的目的是允许 worker 完成设置或清理其他 worker 的相关资源,例如移除缓存。

activate 事件

  • 使用 event.waitUntil() 传递一个 promise 来扩展 activating 阶段,直到这个 promise 得到解决
  • 在激活处理程序中使用 self.clients.claim() 开始控制所有打开的客户端,而不是重新加载它们。

Activated

这个阶段 service worker 能够处理 functional event 了。

Redundant

这个阶段表明该 service worker 被另一个 service worker 代替了。

service worker 所有支持的事件:

缓存机器 — 什么时候储存资源

既然已经知道 Service Worker 能够处理请求的缓存,那么应该在什么时候处理缓存。

1. 在 install 的时候 — 作为依赖

Service Worker 提供了 install 事件,告诉你它已经准备好了,你可以处理其他事情了。

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 来决定 install 事件是否成功,如果 promise 返回 reject,那么就可以认为 install 事件失败了同时 Service Worker 也被终止使用(如果这个时候已经有一个旧版本正在运行,它会保持原样)。caches.opencache.addAll 返回 promise,如果任何资源获取失败,cache.addAll 就会返回 reject。

2. 在 install 的时候 — 不作为依赖

与上面类似,但是不会延迟安装完成,如果缓存失败,不会导致安装失败。

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 & levels 1-10
      );
    })
  );
});

我们没有传递 cache.addAll levels 11-20 的 promise 结果给 event.waitUntil。所以即使它失败了也不会影响到 install 的完成。

为了避免在下载 levels 11-20 的时候,Service Worker 被关闭,未来我们可以计划添加一个后台下载 API 来处理这种情况,以及更大的下载。

3. 在激活的时候

适用场景:清理和迁移。

一旦新的 Service Worker 被安装好同时之前的版本已经没有被使用了,新的 Service Worker 就会被激活,并且会获得一个激活事件。由于旧的版本已经过时了,这个时候是处理 indexedDB 中的 schema 迁移的好时机,同时还要删除未使用的缓存。

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 会被放入队列中,所以长时间激活可能会阻止页面加载。保持激活尽可能精简,只用于旧版本激活时无法执行的操作。

4. 在用户交互时

适用场景:如果整个网站不能离线,那么你可以允许用户选择他们想要缓存的内容。例如 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);
    });
  });
});

缓存 API 可以从页面处像 Service Worker 一样获得,这意味着您不需要涉及到 Service Worker 就能将内容添加到缓存中。

5. 在网络响应时

适用场景:频繁更新资源,如用户的收件箱或文章内容。对非必要的内容(比如头像)也很有用,但需要注意是必要的。

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

如果你这样使用在一系列的 URL 上,例如头像,你需要小心不要让原来的存储空间膨胀——如果用户需要回收磁盘空间而你不想成为主要的被清理候选对象。确保抛弃掉那些你不再需要的缓存。

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

为了有效利用内存,只能读取一次 response/request 主体,在上面的代码中,.clone() 用于创建可以单独读取的副本。

6. 陈旧 — 重新验证

适用场景:不需要经常更新最新版本的地方,例如头像。

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

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

7. 在推送消息时

Push API 是 Service Worker 之上的另一个功能。这使得 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/');
  }
});

8. 在后台同步时

后台同步是 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');
      })
    );
  }
});

持久缓存

系统会给你一定的可用空间,这个可用空间在所有原始存储之间共享:LocalStorage,IndexedDB,Filesystem,当然还有 Caches。你能分到的数额并不是确定的,它会根据设备和存储条件而有所不同。可以通过以下方式了解你能得到空间:

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

但是,像所有浏览器存储一样,如果设备处于压力之下,浏览器可以自由将其丢掉。不幸的是,浏览器无法分辨你哪个是你想要保留的电影,哪个是你不想保留的游戏。

要解决这个问题,有一个推荐的 API,requestPersistent:

// From a page:
navigator.storage.requestPersistent().then(function(granted) {
  if (granted) {
    // Hurrah, your data is here to stay!
  }
});

当然,这需要用户授权权限。让用户也成为流程中的一部分非常重要,因为我们现在可以指望用户来控制删除。如果用户的设备受到压力,并且光清除不重要数据还不能解决问题,用户可以判断哪些项目要保留或者删除。

响应和请求

不管你做了多少缓存,Service Worker 都不会自己去使用缓存,除非你告诉它何时何地去使用缓存,下面是一些处理请求的模式:

1. 仅使用缓存

适用场景:任何你认为“静态”的网站版本。可以在 install 事件里缓存这些,这样你就可以依赖这些缓存。

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

虽然一般来说你不需要用到这个场景,因为“使用缓存,如果没有则请求网络”已经涵盖了它。

2. 仅使用网络

适用场景:没有需要脱机的内容,比如分析 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 behaviour
});

一般来说你也不需要用到这个场景,因为“使用缓存,如果没有则请求网络”也涵盖了它。

3. 使用缓存,如果没有则请求网络

适用场景:如果你在建立离线优先模式,这就是如何处理大部分请求的方式,其他请求模式将根据传入的请求而进行例外的处理。

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

这种模式为已经缓存的内容提供了 “仅使用缓存” 模式,为未缓存的内容(包括非 GET 请求,因为它们不能被缓存)提供了“仅适用网络”模式。

4. 缓存和网络竞赛

适用场景:在慢速的磁盘上需要追求小型资源的访问速度。

在一些旧式硬盘上,经过病毒扫描程序,这个时候如果有较快的网络连接,从网络获取资源比从磁盘上读取缓存更快。

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

5. 优先网络,回退到缓存

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

这意味着你可以为在线用户提供最新的内容,但离线用户将获得较旧的缓存版本。如果网络请求成功,则很有很能要更新缓存条目。

但是这个模式有个缺陷,如果用户的网络是断断续续的或者缓慢的,他们可能要等到网络请求失败才能从本地拿到资源。这有可能会需要很长的时间并消磨用户的忍耐度。所以来看下一个更好的解决模式—“缓存然后网络”。

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

6. 缓存然后网络

适用场景:经常更新的内容。例如。文章,社交媒体,游戏排行榜。

这需要页面发出两个请求,一个到缓存,一个到网络。这个想法是首先显示缓存的数据,然后在网络数据到达时更新页面。

有时您可以在新数据到达时替换当前的数据(例如游戏排行榜),但是这可能会对较大的内容产生干扰。基本上,不要“消除掉”用户可能阅读或与之交互的内容。

Twitter 将新内容添加到旧内容的上方并调整滚动位置,以便用户不会被打断。这是可能的,因为 Twitter 主要保留内容的大部分线性顺序。将这种模式复制到了尽可能快的速度上,以便尽可能快地获得内容,一旦网络数据到达,仍然会显示最新的内容。

页面上的代码

var networkDataReceived = false;

startSpinner();

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

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

7. 通用回退

如果您无法从缓存和/或网络提供某些内容,那么你可能需要提供一个通用回退。

适用场景:辅助图片例如头像,失败的 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 & headers.
      // Eg, a fallback silhouette image for avatars.
    })
  );
});

你所回退到的缓存应该是一个 intall 依赖项。

如果你的页面正在发送电子邮件,那么 Service Worker 可能会回退到将电子邮件储存在 IDB 的“发件箱”中,并回复让页面知道发送失败,但是数据已经成功保留。

8. ServiceWorker 端模板

适用场景:无法缓存服务器响应的页面

服务端渲染页面可以让事情变得很快,但是这可能意味这缓存中会有一些无意义的数据,比如“登录中…”。如果你的页面是由 Service Worker 控制的,就可以选择请求 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'
        }
      });
    })
  );
});

放在一起使用

你不必只选择这些方法之一,根据请求 URL,你可以使用其中的很多方法。

  • install 时缓存,适用于静态的 UI 界面和行为
  • 网络响应时缓存,适用于类似于 Flicker 图片和数据
  • 优先请求缓存,回退到网络,适用于大部分的请求
  • 优先请求缓存,然后再请求网络,适用于搜索结果
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);
    })
  );
});

results matching ""

    No results matching ""