Lavas 中的 Service Worker 旧文档
warn
lavas-core-vue@1.2.0 版本开始使用 workbox@3.x,模板部分和配置项发生了一定的变化。本篇文档将以最新版本进行描述,如果您还在使用 lavas-core-vue@1.1.x (即 workbox@2.x ),可以查看旧版文档
Service Worker 可以说是 PWA 中最能发挥开发者想象力和最复杂的部分。有关 Service Worker 本身的介绍可以移步 Lavas 官网的什么是 Service Worker。
大体来说,在实际项目中,Service Worker 主要完成三个工作:
静态文件预缓存 能够提前预知的用户需要缓存的内容,通常是静态文件,例如 js, css, 字体文件等等。
动态缓存 用户在运行过程中实际发送请求后再进行缓存的内容,通常是动态的接口,因为含有动态参数所以不可能全部预缓存。动态缓存通常还有各类策略,如 networkFirst, cacheFirst 等等
appshell 缓存页面的外部框架,在切换页面时先从缓存取出框架显示,再逐步渲染核心内容,从而提升加载性能和体验。这部分将在 Skeleton 和 App Shell 模型中详细讨论。
初始化生成的项目默认已经带有 Service Worker。Lavas 的 Service Worker 可以分为两部分:
配置部分
负责一些基本项的配置,如模板位置,生成位置等等。
模板部分
主要处理动态缓存和 appshell。
Service Worker 配置项
以初始项目的配置为例,打开 /lavas.config.js
能看到 serviceWorker
这一段,如下:
module.exports = {
// ...
serviceWorker: {
enable: true,
swSrc: path.join(__dirname, 'core/service-worker.js'),
swDest: path.join(BUILD_PATH, 'service-worker.js'),
appshellUrl: '/appshell'
},
// ...
};
info
这些基本都是提供给 Lavas 内置的 WorkboxWebpackPlugin 使用的配置项。WorkBox 是 Google 推出的 sw-toolbox 和 sw-precache 的升级版,封装了一些常用的 API (如预缓存,动态缓存及常用策略等),帮助开发者更简单快速地开发 Service Worker。而 WorkboxWebpackPlugin 则是 Workbox 的 webpack 插件,通过配置项和模板两部分来生成 service-worker.js。
我们来看一下例子中使用的配置项(这些配置项基本都是必选的)。其余的可以参考 Workbox 的官网
enable
是否启用 Service Worker,默认为
true
。swSrc
生成 service-worker.js 所需的模板文件所在位置,后续会详细提及
swDest
生成的 service-worker.js 的存放位置。例子中放在了整体构建目录 (
/dist
) 的下面,即/dist/service-worker.js
swPath
生成的 service-worker.js 在 sw-register.js 中默认会使用 publicPath 进行完整可访问路径拼接,如果您需要指定一个专有的 service-worker.js 文件的可访问 path,可以通过
swPath
配置指定,该配置字段默认不开启。appshellUrl
Skeleton 和 App Shell 模型文中会详细提及,这里先跳过
预缓存文件列表
workbox-webpack-plugin@3.x 会自动把 webpack 处理的 所有静态文件 列为预缓存文件。在构建完成后会单独保存在一个 precacheList 文件中,以 JSON 的格式。
如果想对这些文件进行进一步的控制(例如增加额外的,或者删除无用的)需要使用一些高级的配置项,可以查阅 workbox 官网的这篇文档
通过这些配置,WorkboxWebpackPlugin 能够根据这些静态文件的信息生成 service-worker.js
并包含符合条件的预缓存文件。如果要实现动态缓存和 appshell,还需要 Service Worker 模板来进一步实现。
Service Worker 模板
Service Worker 的模板位于 /core/service-worker.js
。观察初始状态下的代码,我们可以发现在定义动态路由和 appshell 之前还有一些内容,如下:
workbox.core.setCacheNameDetails({
prefix: 'lavas-cache',
suffix: 'v1',
precache: 'install-time',
runtime: 'run-time',
googleAnalytics: 'ga'
});
workbox.skipWaiting();
workbox.clientsClaim();
workbox.precaching.precacheAndRoute(self.__precacheManifest || []);
// doing something else ...
第一段设置一些缓存名称的配置项。相当于原先 workbox 2.x 的构造函数。您也可以从官网文档中获取更多信息
prefix
指定应用的缓存前缀,同时应用于预缓存和动态缓存的名称,拼接在最前面。
suffix
指定应用的缓存后缀,同时应用于预缓存和动态缓存的名称,拼接在最后面。
precache
指明预缓存使用的缓存名称
runtime
指定动态缓存使用的缓存名称
googleAnalytics
workbox-google-analytics
使用的缓存名称。关于 workbox 和 google analytics 之间的配合,可以查阅这里
第二段的两句 (workbox.skipWaiting();
和 workbox.clientsClaim();
) 一般共同使用,使得 Service Worker 可以在 activate 阶段让所有没被控制的页面受控,让 Service Worker 在下载完成后立即生效
第三段的 workbox.precaching.precacheAndRoute(self.__precacheManifest || []);
使用到的 self.__precacheManifest
是定义在单独的一个预缓存文件列表中。如前所述,这个列表包含 webpack 构建过程中的所有静态文件。而这里就是告诉 workbox 把这些文件预缓存起来。
在这些准备工作之后,下面就是开发者发挥的空间了。
设置动态缓存规则
// Define runtime cache.
workbox.routing.registerRoute(/^https:\/\/query\.yahooapis\.com\/v1\/public\/yql/,
workbox.strategies.networkFirst());
info
Workbox 提供的
resigerRoute
方法接受两个参数,第一个是匹配请求 URL 的正则表达式,第二个是内置的缓存策略。除了例子中的 networkFirst,Workbox 还提供了 networkOnly, cacheFirst, cacheOnly, staleWhileRevalidate等等。关于这个方法的详细情况请参见 API经过这条配置,每次请求的 URL 如果匹配这个正则(其实是雅虎天气获取接口), 在返回数据时会将数据进行缓存。如果网络连接故障,则返回缓存内容。配合预缓存了所有静态文件,站点就拥有了离线访问能力!
如果开发者对于每个缓存策略的含义还不清楚,可以参考 The Offline Cookbook 或者 workbox 上也有简述。
缓存策略的参数
上述例子中,我们直接使用了 networkFirst()
,没有参数。但实际上,每位开发者都可能会有一些个性化的配置,对策略进行更精细化的控制,例如:
- 使用一个特定的缓存 (指定一个不一样的缓存名称)
- 设置缓存失效时间或者个数上限
这些需求都可以通过缓存的参数来实现。主要有两种:
cacheName
: 指定新的缓存名称,使得符合这条正则的请求的缓存全都存到一起plugins
:指定插件的数组。插件可以实现缓存失效时间或者个数上限,也包括其他的功能,甚至可以自定义。
跨域资源的小坑
当请求的是跨域资源(不仅限于接口,也包括图片等)并且目标服务器并没有设置 CORS 时,响应类型会被设置为 'opaque'
并且 HTTP 状态码会被设置为 0
。出于安全考虑,WorkBox 对于这类资源的信任度不高,在使用 CacheFirst 策略时只缓存 HTTP 状态码为 200
的资源。所以这类资源不会被缓存,当然在离线时也无法被展现了。
如果开发者想使用跨域的资源且目标站点不支持 CORS,为了缓存下来,我们还需要额外配置合法的 HTTP 状态码。这里就需要用到上面提到的 plugins
了,如下:
workbox.routing.registerRoute(/^https:\/\/ss\d\.baidu\.com/i,
workbox.strategies.cacheFirst({
plugins: [
new workbox.cacheableResponse.Plugin({
statuses: [0, 200]
})
]
})
);
这样状态码为 0
或者 200
的资源都会被缓存,达成了我们的需求。
动态缓存的注册顺序
当注册了多个动态缓存之后,如果被注册的正则存在交集,则还存在一个匹配顺序的问题。
WorkBox 的内部使用一个数组记录所有动态缓存的正则表达式。在开发者使用 registerRoute
时,内部调用数组的 unshift
方法进行扩充。因此,越往后的路由规则将存在于数组越靠前的位置。而在匹配时,是按数组从前到后的顺序进行匹配并响应的。因此结论是:越后注册的规则将越先匹配。
举例来说
workbox.routing..registerRoute(/^https:\/\/ss\d\.baidu\.com/i,
workbox.strategies.cacheFirst({
cacheableResponse: {
statuses: [0, 200]
}
})
);
workbox.routing..registerRoute(/^https:\/\/.*\.baidu\.com/i,
workbox.strategies.networkOnly());
这种配置下,访问 *.baidu.com
的所有请求都会命中第二条规则,从而使用 networkOnly 规则,所以不会缓存任何文件。更换两者的注册顺序可以解决这个问题。
注册 Service Worker (扩展)
提示:这部分内容由 Lavas 内部处理,并不需要开发者进行参与,仅仅作为解答开发者疑问的扩展阅读存在。
Service Worker 编写完成后,还需要进行注册才能真正生效。常规的注册代码能够在各类 Service Worker 教程或文章中找到,但在实际项目中有一个不得不考虑的问题,使得我们必须对注册代码进行一些改动,那就是 Service Worker 更新 的问题。
解决思路
为了最大化利用浏览器缓存 service-worker.js
,但又保证一旦项目更新时浏览器能够及时更新之,Lavas 的解决思路是:
将注册代码单独放置在
sw-register.js
中sw-register.js
中实际注册service-worker.js
的部分,在后面添加?v=xxxx
,取值为编译时间。因此一次编译后不会修改,service-worker.js
可以 被浏览器缓存。在 HTML 中引用
sw-register.js
,同样在后面添加?v=xxxx
,但这里取值为当前时间,因此每次请求都在变化,避免 浏览器对sw-register.js
进行缓存。
这样每次浏览器都会重新请求 sw-register.js
。如果重新编译,sw-register.js
中注册的 service-worker.js?v=xxxx
的 v
会变化,迫使浏览器重新请求;如果未重新编译,那么这个 v
不会变化,浏览器可以直接使用缓存中的 service-worker.js
。
实现方式
Lavas 内部使用 webpack 进行构建,其中处理 Service Worker 的注册问题时使用一个名为 sw-register-webpack-plugin 的插件(也由 Lavas 开发组进行开发)。这款插件的作用有两个:
在生成目录(默认
/dist
) 生成sw-register.js
,用以注册 Service Worker是在编译时找到 HTML 文件,在
</body>
标签之前插入一段代码,用来引入sw-register.js
我们从这两步分别了解一下这个插件。
sw-register.js
生成的 sw-register.js
大致内容如下,其中的参数 v
以编译的时间生成时间戳,保证获取的 service-worker.js
不受浏览器缓存的影响。
if ('serviceWorker' in navigator) {
// 例如v=20171205175126
navigator.serviceWorker.register('/service-worker.js?v=xxxx').then(function(reg) {
reg.onupdatefound = function() {
var installingWorker = reg.installing;
installingWorker.onstatechange = function() {
switch (installingWorker.state) {
case 'installed':
if (navigator.serviceWorker.controller) {
var event = document.createEvent('Event');
event.initEvent('sw.update', true, true);
window.dispatchEvent(event);
}
break;
}
};
};
}).catch(function(e) {
console.error('Error during service worker registration:', e);
});
}
从这个文件内容来看,它的主要工作包括:
调用
navigator.serviceWorker.register
注册 Service Worker注册
updatefound
事件并监听 Service Worker 的更新,并在更新时分发'sw.update'
事件
补充说明:这个 'sw.update'
事件在 Lavas 项目下 /components/UpdateToast.vue
组件进行监听,并在更新时弹出提示,引导用户刷新页面。
引入 sw-register.js
上面提过,sw-register-webpack-plugin 会在 HTML 文件中寻找 </body>
标签并插入内容,因此这里需要明确,只有 SPA 模式才会生成 HTML 文件,也就是说:插件只在 SPA 模式下插入内容,SSR 因为没有独立的 HTML 文件生成,因此采用别的方案,这个将在后面讨论。
插件插入的内容大致如下:
<script>
window.onload = function () {
var script = document.createElement('script');
var firstScript = document.getElementsByTagName('script')[0];
script.type = 'text/javascript';
script.async = true;
script.src = '/sw-register.js?v=' + Date.now();
firstScript.parentNode.insertBefore(script, firstScript);
};
</script>
作用也很明显,在整个 HTML 的第一个 <script>
之前插入新的 <script>
引用 sw-register.js
,同样通过时间戳来屏蔽浏览器的缓存。但这里的 v
和 sw-register.js
里的 v
有区别,sw-register.js
中的 v
在编译一次之后就确定并写入文件(如果有兴趣你可以查看 /dist/sw-register.js
),之后不会再改变;而这里的值是 Date.now()
,所以每次都请求新的 sw-register.js
。依靠这种模式,可以以较小的代价在第一时间更新到最新的 Service Worker。
SSR 模式下引入 sw-register.js
因为 SSR 没有单独的 HTML 文件生成,因此 Lavas 需要完成插件的一部分工作,即引入 sw-register.js
。SSR 需要给 renderer 提供一个 index.html 作为服务端模板,在这个 index.html 的底部,额外加上代码即可。
最后重申一点,所有和注册 Service Worker 相关的工作都已经由 Lavas 自动完成,这里仅仅是表述内部的做法,并不需要开发者额外进行任何配置或者开发。