做者:陈达孚javascript
香港中文大学研究生,《移动Web前端高效开发实战》做者之一,《前端开发者指南2017》译者之一,在中国前端开发者大会,中生代技术大会等技术会议发表过主题演讲, 专一于新技术的调研和使用.css
本文为原创文章,转载请注明做者及出处html
本文主要分析经过workbox(基于1.x和2.x版本,将来3.x版本会有新的结构)生成Service-Worker的缓存策略,workbox是GoogleChrome团队对原来sw-precache和sw-toolbox的封装,而且提供了Webpack和Gulp插件方便开发者快速生成sw.js文件。前端
首先看一下 workbox 提供的 Webpack 插件 workboxPlugin 的三个最主要参数:java
其中 globDirectory
和 staticFileGlobs
会决定须要缓存的静态文件,这两个参数也存在默认值,插件会从compilation参数中获取开发者在 Webpack 配置的 output.path
做为 globDirectory 的默认值,staticFileGlobs
的默认配置是 html,js,css 文件,若是须要缓存一些界面必须的图片,这个地方须要本身配置。node
以后 Webpack 插件会将配置做为参数传递给 workbox-build 模块,workbox-build 模块中会根据 globDirectory 和 staticFileGlobs 读取文件生成一份配置信息,交给 precache 处理。须要注意的是,precache里不要存太多的文件,workbox-build 对文件会有一个过滤, 该模块会读取利用 node 的 fs 模块读取文件,若是文件大于2M则不会加入配置中(能够经过配置 maximumFileSize 修改),同时会根据文件的 buffer 生成一个 hash 值,也就是说就算开发者不改变文件名,只要文件内容修改了,也会生成一个新的配置内容,让浏览器更新缓存。web
那么说了那么多,precache 到底干了什么,看一下生成的sw文件:正则表达式
const fileManifest = [
{
'url': 'main.js',
'revision': '0e438282dc400829497725a6931f66e3'
},
{
'url': 'main.css',
'revision': '02ba19bb320adb687e08dded3e71408d'
}
];
const workboxSW = new self.WorkboxSW();
workboxSW.precache(fileManifest);
复制代码
那仍是须要看一下 precache 的代码:json
precache(revisionedFiles) {
this._revisionedCacheManager.addToCacheList({
revisionedFiles,
})
}
复制代码
是的,workbox会提供一个对象 revisionedCacheManager
来管理全部的缓存,先无论里面具体怎么处理的,往下看有个 registerInstallActivateEvents
。后端
_registerInstallActivateEvents(skipWating, clientsClaim) {
self.addEventListener('install', (event) => {
const cachedUrls = this._revisionedCacheManager.getCachedUrls();
event.waitUntil(
this._revisionedCacheManager.install().then(() => {
if (skipWaiting) {
return self.skipWaiting();
}
})
)
}
复制代码
这里能够看出,全部的 precache 都会在 service worker 的 install 事件中完成。event.waitUntil
会根据内部promise的结果来肯定安装是否完成。若是安装失败,则会舍弃这个ServiceWorker。
如今看一下 _revisionedCacheManager.install
里干了什么,首先 revisionedFiles
会被放在一个 Map 中,固然这个 revisionedFiles
是已经被处理过了, 在通过 addToCacheList
->_addEntries
-> _parseEntry
的过程后,会返回:
{
entryID,
revision,
request: new Request(url),
cacheBust
}
复制代码
entryID 不主动传入能够视为用户传入的url,将用来做为IndexDB中的key存储revision,而request则用来提供给以后的fetch请求,cacheBust默认为true,功能等会再分析。
Map 的set 过程在 _addEntries
的 _addEntryToInstallList
函数中,这里只需注意由于 fileManifest 中不能存放具备相同 url (或者说entryID)的值,否则会被警告。
如今回来看install,install是一个async函数,返回一个包含一系列Promise请求的Promise.all,符合waitUntil的要求。每个须要缓存的文件会到 cacheEntry 函数中处理:
async _cacheEntry(precacheEntry) {
const isCached = await this._isAlreadyCached(precacheEntry);
const precacheDetails = {
url: precacheEntry.request.url,
revision: precacheEntry.revision,
wasUpdated: !isCached,
};
if (isCached) {
return precacheDetails;
}
try {
await this._requestWrapper.fetchAndCache({
request: precacheEntry.getNetworkRequest(),
waitOnCache: true,
cacheKey: precacheEntry.request,
cleanRedirects: true,
});
await this._onEntryCached(precacheEntry);
return precacheDetails;
} catch (err) {
throw new WorkboxError('request-not-cached', {
url: precacheEntry.request.url,
error: err,
});
}
}
复制代码
对于每个请求会去经过 _isAlreadyCached
方法访问indexDB 得知是否被缓存过。这里可能有读者会疑惑,咱们不是不能在 fileManifest 中不容许存储一样的url,为何还要查是否缓存过,这是由于当你sw文件更新后,原来的缓存仍是存在的,它们或许持有相同的url,若是它们的revision也相同,就不用获取了。
在 _cacheEntry 内部,还有两个异步操做,一个是经过包装后的 requestWrapper
的 fetchAndCache
请求并缓存数据,一个是经过 _onEntryCached
方法更新indexDB,能够看到虽然catch了错误,但依旧会throw出来,意味着任何一个precache的文件请求失败,都会终止这次install。
这里另外一个须要注意的地方是 _requestWrapper.fetchAndCache
,全部请求最后都会在 requestWrapper
中处理,这里调用的实例方法是 fetchAndCache
,说明此次请求会涉及到网络请求和缓存处理两部分。在发出请求后,首先会判断请求结果是否须要加入缓存中:
const effectiveCacheableResponsePlugin =
this._userSpecifiedCachableResponsePlugin ||
cacheResponsePlugin ||
this.getDefaultCacheableResponsePlugin();
复制代码
若是没有插件配置,会使用 getDefaultCacheableResponsePlugin()
来取得默认配置,即缓存返回状态为200的请求。
在上面的代码中能够看到在 precache 环境下,会有两个参数为 true, 一个是 waitOnCache,另外一个是cleanRedirects。waitOnCache保证在须要缓存的状况下返回网络结果时必须完成缓存的处理,cleanRedirects则会从新包装一下请求重定向的结果。
最后用_onEntryCached把缓存的路径凭证信息存在indexDB中。
在activate阶段,会对precache在cache里的内容进行clean,由于前面只作了更新,若是是新的precache没有的资源地址,在这里会删除。
因此 precache 就是在 service-worker 的 install 事件下完成一次对配置资源的网络请求,并在请求结果返回时完成对结果的缓存。
在了解 runtimecache 前,先看下 workbox-sw 的实例化过程当中比较重要的部分:
this._runtimeCacheName = getDefaultCacheName({cacheId});
this._revisionedCacheManager = new RevisionedCacheManager({
cacheId,
plugins,
});
this._strategies = new Strategies({
cacheId,
});
this._router = new Router(
this._revisionedCacheManager.getCacheName(),
handleFetch
);
this._registerInstallActivateEvents(skipWaiting, clientsClaim);
this._registerDefaultRoutes(ignoreUrlParametersMatching, directoryIndex);
复制代码
因此看出 workbox-sw 实例化的过程主要有生成缓存对应空间名,缓存空间,挂载缓存策略,挂载路由方法(用于处理对应路径的缓存策略),注册安装激活方法,注册默认路由。
precache 对应的就是 runtimecache,runtimecache 顾名思义就是处理全部运行时的缓存,runtimecache 每每应对着各类类型的资源,对于不一样类型的资源每每也有不一样的缓存策略,因此在 workbox 中使用 runtimecache 须要调用方法,workbox.router.registerRoute
也是说明 runtimecache 须要路由层面的细致划分。
看到最后一步的 _registerDefaultRoutes
,看一下其中的代码,能够发现 workbox 有一个最基本的cache,这个 cache 其实处理的就是前面的 precache,这个 cache 听从着 cacheFirst 原则:
const cacheFirstHandler = this.strategies.cacheFirst({
cacheName: this._revisionedCacheManager.getCacheName(),
plugins,
excludeCacheId: true,
});
const capture = ({url}) => {
url.hash = '';
const cachedUrls = this._revisionedCacheManager.getCachedUrls();
if (cachedUrls.indexOf(url.href) !== -1) {
return true;
}
let strippedUrl =
this._removeIgnoreUrlParams(url.href, ignoreUrlParametersMatching);
if (cachedUrls.indexOf(strippedUrl.href) !== -1) {
return true;
}
if (directoryIndex && strippedUrl.pathname.endsWith('/')) {
strippedUrl.pathname += directoryIndex;
return cachedUrls.indexOf(strippedUrl.href) !== -1;
}
return false;
};
this._precacheRouter.registerRoute(capture, cacheFirstHandler);
复制代码
简单的说,若是你一个路径能直接在 precache 中能够找到,或者在去除了部分查询参数后符合,或者去处部分查询参数添加后缀后符合,就会直接返回缓存,至于请求过来怎么处理的,稍后再看。
咱们能够这么认为 precache 就是添加了 cache,至于真实请求时如何处理仍是和 runtimecache 在一个地方处理,如今看来,在 workbox 初始化的时候就有了第一个 router.registerRoute()
,以后的就须要手动注册了。
在写本身注册的策略以前,考虑下,注册了 route 后,又怎么处理呢?在实例化 Router 的时候,咱们就会添加一个 self.addEventListener('fetch', (event) => {...})
,除非你手动传入一个handleFetch参数为false。
在注册路由的时候,registerRoute(capture, handler, method)
在类中接受一个捕获条件和一个句柄函数,这个捕获条件能够是字符串,正则表达式或者是直接的Route对象,固然最终都会变成 Route 对象(分别经过 ExpressRoute 和 RegExpRoute),Route对象包含匹配,处理方法,和方法(默认为 GET)。而后在注册时会使用一个 Map,以每一个使用到的方法为 Key,值为包含全部Route对象的数组,在遍历时也只会遍历相应方法的值。因此你也能够给不一样的方法定义一样的捕获路径。
这里使用了 unshift 操做,因此每一个新的配置会被压入堆栈的顶部,在遍历时则会被优先遍历到。由于 workbox 实例化是在 registerRoute 以前,因此默认配置优先级最低,配置后面的注册会优先于前面的。
因此最终在页面上,你的每次请求都会被监听,到相应的请求方法数组里找有没有匹配的,若是没有匹配的话,也可使用 setDefaultHandler
,setDefaultHandler
不是前面的 _registerDefaultRoutes
,它须要开发者本身定义,并决定策略,若是定义了,全部没被匹配的请求就会被这个策略处理。请求还支持设置在,在请求被匹配却没有正确被方法处理状况下的错误处理,最终 event 会用处理方法(策略)处理这个请求,不然就正常请求。这些请求就是 workbox下的 runtimecache。
如今来看看 Workbox 提供的缓存策略,主要有这几种:cache-first
,cache-only
,network-first
,network-only
,stale-while-revalidate
。
在前面看到,实例化的时候会给 workbox 挂载一个 Strategies 的实例。提供上面一系列的缓存策略,但在实际调用中,使用的是 _getCachingMechanism
,而后把整个策略类放到一参中,二参则提供了配置项,在每一个策略类中都有 handle 方法的实现,最终也会调用 handle方法。那既然如此还搞个 _getCachingMechanism
干吗,直接返回策略类就得了,这个等下看。
先看下各个策略,这里就简单说下,能够参考离线指南,虽然会有一点不同。
第一个 Cache-First, 它的 handle 方法:
const cachedResponse = await this.requestWrapper.match({
request: event.request,
});
return cachedResponse || await this.requestWrapper.fetchAndCache({
request: event.request,
waitOnCache: this.waitOnCache,
});
复制代码
Cache-First策略会在有缓存的时候返回缓存,没有缓存才会去请求而且把请求结果缓存,这也是咱们对于precache的策略。
而后是 Cache-only,它只会去缓存里拿数据,没有就失败了。
network-first 是一个比较复杂的策略,它接受 networkTimeoutSeconds 参数,若是没有传这个参数,请求将会发出,成功的话就返回结果添加到缓存中,若是失败则返回当即缓存。这种网络回退到缓存的方式虽然利于那些频繁更新的资源,可是在网络状况比较差的状况(无网会直接返回缓存)下,等待会比较久,这时候 networkTimeoutSeconds 就提供了做用,若是设置了,会生成一个setTimeout后被resolve的缓存调用,再把它和请求放倒一个 Promise.race 中,那么请求超时后就会返回缓存。
network-only,也比较简单,只请求,不读写缓存。
最后提供的策略是 StaleWhileRevalidate,这种策略比较接近 cache-first,代码以下:
const fetchAndCacheResponse = this.requestWrapper.fetchAndCache({
request: event.request,
waitOnCache: this.waitOnCache,
cacheResponsePlugin: this._cacheablePlugin,
}).catch(() => Response.error());
const cachedResponse = await this.requestWrapper.match({
request: event.request,
});
return cachedResponse || await fetchAndCacheResponse;
复制代码
他们的区别在于就算有缓存,它仍然会发出请求,请求的结果会用来更新缓存,也就是说你的下一次访问的若是时间足够请求返回的话,你就能拿到最新的数据了。
能够看到离线指南中还提供了缓存而后访问网络再更新页面的方法,但这种须要配合主进程代码的修改,WorkBox 没有提供这种模式。
回到在缓存策略里提到的,讲讲 _getCachingMechanism
和缓存策略的参数。默认支持5个参数:'cacheExpiration', 'broadcastCacheUpdate', 'cacheableResponse', 'cacheName', 'plugins',(固然你会发现还有几个参数不在这里处理,好比你能够传一个自定义的 requestWrapper, 前面提到的 waitOnCache 和 NetworkFirst 支持的 networkTimeoutSeconds),先看一个完整的示例:
const workboxSW = new WorkboxSW();
const cacheFirstStrategy = workboxSW.strategies.cacheFirst({
cacheName: 'example-cache',
cacheExpiration: {
maxEntries: 10,
maxAgeSeconds: 7 * 24 * 60 * 60
},
broadcastCacheUpdate: {
channelName: 'example-channel-name'
},
cacheableResponse: {
stses: [0, 200, 404],
headers: {
'Example-Header-1': 'Header-Value-1',
'Example-Header-2': 'Header-Value-2'
}
}
plugins: [
// Additional Plugins
]
});
复制代码
大体能够认定的是 cacheExpiration 会用来处理缓存失效,cacheName 决定了 cache 的索引名,cacheableResponse 则决定了什么请求返回能够被缓存。
那么插件究竟是怎么被处理,如今能够看_getCachingMechanism
函数了,_getCachingMechanism
函数处理了什么,它其实就是把 cacheExpiration
,broadcastCacheUpdate
,cacheabelResponse
里的参数找到对应方法,传入参数实例化,而后挂在在封装后的wrapperOptions的plugins参数里,可是只是实例化了有什么用呢?这里有关键的一步:
options.requestWrapper = new RequestWrapper(wrapperOptions);
复制代码
因此最终这些插件仍是会在 RequestWrapper 里处理,这里的一些操做是咱们以前没有提到的,来看下 RequestWrapper 里怎么处理的。
看下 RequestWrapper 的构造函数,取其中涉及到 plugins 的部分:
constructor({cacheName, cacheId, plugins, fetchOptions, matchOptions} = {}) {
this.plugins = new Map();
if (plugins) {
isArrayOfType({plugins}, 'object');
plugins.forEach((plugin) => {
for (let callbackName of pluginCallbacks) {
if (typeof plugin[callbackName] === 'function') {
if (!this.plugins.has(callbackName)) {
this.plugins.set(callbackName, []);
} else if (callbackName === 'cacheWillUpdate') {
throw ErrorFactory.createError(
'multiple-cache-will-update-plugins');
} else if (callbackName === 'cachedResponseWillBeUsed') {
throw ErrorFactory.createError(
'multiple-cached-response-will-be-used-plugins');
}
this.plugins.get(callbackName).push(plugin);
}
}
});
}
}
复制代码
plugins是一个Map,默认支持如下几种Key:cacheDidUpdate
, cacheWillUpdate
, fetchDidFail
, requestWillFetch
, cachedResponseWillBeUsed
。能够理解为 requestWrapper 提供了一些hooks或者生命周期,而插件就是在 hook 上进行一些处理。
这里举个缓存失效的例子看看怎么处理:
首先咱们须要实例化CacheExpirationPlugin,CacheExpirationPlugin没有构造函数,实例化的是CacheExpiration,而后在this上添加maxEntries,maxAgeSeconds。全部的 hook 方法实现都放在了 CacheExpirationPlugin,提供了两个 hook: cachedResponseWillBeUsed 和 cacheDidUpdate,cachedResponseWillBeUsed 会在 RequestWrapper的match中执行,cacheDidUpdate 在 fetchAndCache中 执行。
这里能够看出,每一个plugin其实就是对hook或者生命周期调用的具体实现,在把response扔到cache里以后,调用了插件的cacheDidUpdate方法,看下CacheExpirationPlugin中的cacheDidUpdate:
async cacheDidUpdate({cacheName, newResponse, url, now} = {}) {
isType({cacheName}, 'string');
isInstance({newResponse}, Response);
if (typeof now === 'undefined') {
now = Date.now();
}
await this.updateTimestamp({cacheName, url, now});
await this.expireEntries({cacheName, now});
}
复制代码
那么关键就是更新时间戳和失效条数,若是设置了更新时间戳会怎么样呢,在请求的时候,runtimecache也会添加到IndexedDB,值存入的是一个对象,包含了一个url和时间戳。
这个时间戳怎么生效,CacheExpirationPlugin提供了另一个方法,cachedResponseWillBeUsed:
cachedResponseWillBeUsed({cachedResponse, cachedResponse, now} = {}) {
if (this.isResponseFresh({cachedResponse, now})) {
return cachedResponse;
}
return null;
}
复制代码
RequestWrapper中的match方法会默认从cache里取,取到的是当时的完整 response, 在cache的 response 里的 headers 里取到 date,而后把当时的date加上 maxAgeSecond 和 如今的时间比, 若是小于了就返回 false,那么天然会去发起请求了。
CacheableResponsePlugin用来控制 fetchAndCache 里的 cacheable,它设置了一个 cacheWillUpdate,能够设置哪些 http status 或者 headers 的 response 要缓存,作到更精细的缓存操做。
离线指南已经提供了一些缓存方式,在 workbox 中,能够大体认为,有一些资源会直接影响整个应用的框架可否显示的(开发应用的 JS,CSS 和部分图片)能够作 precache,这些资源通常不存在“异步”的加载,它们若是不显示整个页面没法正常加载。
那他们的更新策略也很简单,通常这些资源的更新须要发版,而在这里用更新sw文件更新。
对于大部分无状态(注意无状态)数据请求,推荐StaleWhileRevalidate方式或者缓存回退,在某些后端数据变化比较快的状况下,添加失效时间也是能够的,对于其它(业务图片)需求,cache-first比较适用。
最后须要讨论的是页面和有状态的请求,页面是一个比较复杂的状况,页面若是是纯静态的,那么能够放入precache。但要注意,若是咱们的页面不是打包工具生成的,页面文件极可能不在dist目录下,那么怎么追踪变化呢,这里推荐一种方式,咱们的页面每每有一个模版,和一个json串配置hash变量,那么你能够添加这种模式:
templatedUrls: {
path: [
'.../xxx.html',
'.../xxx.json'
]
}
复制代码
若是没有json,就须要关联全部可能影响生成页面的数据了,那么这些文件的变化都会改变最后生成的sw文件。
若是你在页面上有一些动态信息(好比用户信息等等),那就比较麻烦了,推荐使用 network-first 配合一个合适的失败时间,毕竟你们都不但愿用户登陆了另外一个帐号,显示的仍是上一个帐号,这一样适用于那些使用cookie(有状态)的请求,这些请求也推荐你添加失效策略,和失败状态。
永远记住你的目标,让用户可以更快的看到页面,但不要给用户一个错误的页面。
在目前的网络环境下,service worker 的推送服务并不能获得很好的利用,因此使用 service worker 很大程度就是利用其强大的缓存能力给用户在弱网和无网环境的优化,甚至能够经过判断网络环境进行一些预下载,丰富页面的交互。可是一个错误的缓存策略可能会使用户得不到最新的内容,每个致力于使用 service worker 或者 PWA 的开发者都须要了解其缓存的处理。Google 提供了一系列的工具可以快速生成优质的sw文件,可是配套文档过度简单和无本地化让这些配置如同一个黑盒,使开发者很难肯定正确的配置方案。但愿可以阅读本文,解决读者这方面的困惑。
2019年,iKcamp原创新书《Koa与Node.js开发实战》已在京东、天猫、亚马逊、当当开售啦!