上一篇文章介绍了serviceWorker是什么以及如何在项目中使用serviceWorker,这一篇着重分析ngsw-worker.js的结构,具体的缓存策略是如何实现的。
一切要从一个中介者开始。ngsw-worker定义了一个Driver,负责worker的初始化,版本更新管理,事件的监听和任务调度。
源头是一个driver
class Driver{
constructor(scope, adapter, db) {
this.scope = scope; // 作用域
this.adapter = adapter; //网络事件适配器
this.db = db; // 缓存db
//如果drive在初始化的时候,或者其他时候发生了错误,比如说获取不到需要的资源,则会进入safe_mode状态,此时不对数据缓存做多余处理。初始为normal状态
this.state = DriverReadyState.NORMAL;
this.stateMessage = '(nominal)';
//尚未初始化
this.initialized = null;
//版本管理,如果有新的config,或者静态资源数据有改变,则会生成一个新的version。之后的缓存和数据则以新version为准
this.clientVersionMap = new Map();
this.versions = new Map();
this.latestHash = null;
this.lastUpdateCheck = null;
this.scheduledNavUpdateCheck = false;
// 开启一个任务调度器,在空闲的时候调度各种任务。
this.idle = new IdleScheduler(this.adapter, IDLE_THRESHOLD, this.debugger);
}
}
接下来driver定义了事件监听,主要对应service-worker的生命周期,以及关键的网络请求事件和push事件管理。
this.scope.addEventListener('install', (event) => {
// 由于sw的更新和app本身的更新没有太大的关联,所以在install阶段直接越过app自身的检查更新,直接去更新sw自己的version
event.waitUntil(this.scope.skipWaiting());
});
// 当新版本的sw第一次激活的时候,触发该事件
this.scope.addEventListener('activate', (event) => {
// 由于有新版本了,所以先越过旧版本。
event.waitUntil(this.scope.clients.claim());
if (this.scope.registration.active !== null) {
this.scope.registration.active.postMessage({ action: 'INITIALIZE' });
}
});
// 处理fetch,message和push事件
this.scope.addEventListener('fetch', (event) => this.onFetch(event));
this.scope.addEventListener('message', (event) => this.onMessage(event));
this.scope.addEventListener('push', (event) => this.onPush(event));
这里值得注意的是,sw在激活的时候,通过一个postmessage进行schedule的的初始化。选取这个时机,而不是在第一次fetch data的时候进行初始化是因为。第一次fetch事件可能发生在下一次application load的时候。因此在active的这个时机去触发是合适的。但是如果在这个时候进行初始化,那么watiUntil可能会block掉fetch事件,因此这里巧妙的给自己发送一个postmessage事件,在下一个task里面再执行初始化。
一切都要等initialize之后才生效:
initialize做的事情很简单。首先读indexDB,找最近的config获取config数据——》从网络拉取新的config.json,对比需不需要更新数据。如果第一步读取数据库失败了,则表示是第一次访问网站,或者db被wipe了。那么sw回去获取新的manifestconfig,生成一个hash,将其保存在db里面。在获取到需要的manifestconfig之后,sw自己会将数据存储在一个AppVersion对象中,以供后续使用。如下所示:
try {
// 读取数据库
[manifests, assignments, latest] = await Promise.all([
table.read('manifests'),
table.read('assignments'),
table.read('latest'),
]);
// 如果读取到了,那么给idle发送一个任务,请求checkupdate
this.idle.schedule('init post-load (update, cleanup)', async () => {
await this.checkForUpdate();
try {
await this.cleanupCaches();
}
catch (err) {
// Nothing to do - cleanup failed. Just log it.
this.debugger.log(err, 'cleanupCaches @ init post-load');
}
});
}
catch (_) {
// 如果糟了,那么从新创建一个manifest版本,创建新的hash,存在数据库里面。
const manifest = await this.fetchLatestManifest();
const hash = hashManifest(manifest);
manifests = {};
manifests[hash] = manifest;
assignments = {};
latest = { latest: hash };
// Save the initial state to the DB.
await Promise.all([
table.write('manifests', manifests),
table.write('assignments', assignments),
table.write('latest', latest),
]);
}
无论从那里得到数据,在这里已经得到最新的数据了,然后将其按照hash,存到appVersion map里面。由于config里面保存的新的静态资源,会在schedule中插入一个任务,通知assetGroups进行update。
//Driver
await Promise.all(Object.keys(manifests).map(async (hash) => {
try {
//尝试初始化最新的这个version。如果失败,则整个initialize失败
await this.scheduleInitialization(this.versions.get(hash), this.latestHash === hash);
}
catch (err) {
this.debugger.log(err, `initialize: schedule init of ${hash}`);
return false;
}
}));
// AppVersion
async initializeFully(updateFrom) {
try {
//依次排排坐等着每一个group update
await this.assetGroups.reduce(async (previous, group) => {
// 同步执行,线性关系,如果前面有失败的,则整个流程失败。
await previous;
// Initialize this group.
return group.initializeFully(updateFrom);
}, Promise.resolve());
}
catch (err) {
this._okay = false;
throw err;
}
}
等待assetsGroup更新完毕之后,可以认为初始化工作已完成。
当有网络请求的时候会做什么?
ngsw-worker.js hook住了所有的网络请求,当监听到有请求发生时,在请求发出去之前,调用onFetch进行相应的操作。可以看到onFetch先做一步粗略的过滤,将一些显性会失败的请求过滤掉,然后剩下的丢给handleFetch进行细分处理。
// onFetch
const req = event.request;
if (this.adapter.parseUrl(req.url, this.scope.registration.scope).path === '/ngsw/state') {
// debugger 可以handle一切网络请求,但是不会对sw的状态有任何影响
event.respondWith(this.debugger.handleFetch(req));
return;
}
// 如果当前sw处在unsafe的状态,那么直接将请求降级到网络上。在这里直接跳过,而不是使用responseWith,是因为后者会表明该请求被sw处理过。实际上并没有处理。
if (this.state === DriverReadyState.SAFE_MODE) {
// 通知idle schedule进行更新检查等操作。
event.waitUntil(this.idle.trigger());
return;
}
// 如果cache头为only-if-cached,而request mode不是same-origin,那么该请求一定会失败,所以这里记录以下错误原因,然后直接返回。
if (req.cache === 'only-if-cached' && req.mode !== 'same-origin') {
// Log the incident only the first time it happens, to avoid spamming the logs.
if (!this.loggedInvalidOnlyIfCachedRequest) {
this.loggedInvalidOnlyIfCachedRequest = true;
this.debugger.log(`Ignoring invalid request: 'only-if-cached' can be set only with 'same-origin' mode`, `Driver.fetch(${req.url}, cache: ${req.cache}, mode: ${req.mode})`);
}
return;
}
// 经过handleFetch处理请求。
event.respondWith(this.handleFetch(event));
在handleFetch里面,如果在还没有初始化完成的时候,就受到了网络请求,handleFetch会先手动加初始化。然后获取当前appversion,用对应的appVersion中的handle策略来处理该请求。如果获取appversion失败,则降级到safeFetch。最后完成之后,通知idle执行后台操作。具体操作如下所示:
function handleFetch
// 如果还没有initialize,则先进行初始化
if (this.initialized === null) {
this.initialized = this.initialize();
}
try {
// Wait for initialization.
await this.initialized;
}
catch (e) {
// 初始化失败,则进入safe_mode,所有的请求不做任何缓存,直接走网络。
this.state = DriverReadyState.SAFE_MODE;
this.stateMessage = `Initialization failed due to error: ${errorToString(e)}`;
// 通知后台进行后台操作。
event.waitUntil(this.idle.trigger());
return this.safeFetch(event.request);
}
// 如果是navigation请求,需要先检查更新
if (event.request.mode === 'navigate' && !this.scheduledNavUpdateCheck) {
this.scheduledNavUpdateCheck = true;
this.idle.schedule('check-updates-on-navigation', async () => {
this.scheduledNavUpdateCheck = false;
await this.checkForUpdate();
});
}
// 获取对应的appversion
const appVersion = await this.assignVersion(event);
if (appVersion === null) {
event.waitUntil(this.idle.trigger());
return this.safeFetch(event.request);
}
let res = null;
try {
// 先根据请求的类型,执行对应的缓存操作等,如果这个工作失败了,那么再降级到网络上
res = await appVersion.handleFetch(event.request, event);
}
catch (err) {
if (err.isCritical) {
// Something went wrong with the activation of this version.
await this.versionFailed(appVersion, err, this.latestHash === appVersion.manifestHash);
event.waitUntil(this.idle.trigger());
return this.safeFetch(event.request);
}
throw err;
}
// 如果失败了,则执行后台操作,并且通过网络执行请求。
if (res === null) {
event.waitUntil(this.idle.trigger());
return this.safeFetch(event.request);
}
// 执行请求,把结果返回到上层。到这里,相应的缓存已经完成了
event.waitUntil(this.idle.trigger());
// The AppVersion returned a usable response, so return it.
return res;
safeFetch是一个包装器。如果result.code 不为200,或者请求发生了错误,则构造一个假的504的请求,返回给上层。
实现文件缓存由AssetsGroup类完成。
AssetsGroup的核心内容为根据请求的内容,查看是否由缓存,如果已有缓存并且缓存可用,则返回给上层,否则通过网络获取数据,然后将结果保存在缓存里。核心方法是handleFetch方法,中心思想如下图所示:
const url = this.getConfigUrl(req.url);
//首先判断请求的url是不是config里面指定的url,或者符合pattern里面约定的需要缓存的正则规则
if (this.config.urls.indexOf(url) !== -1 || this.patterns.some(pattern => pattern.test(url))) {
// 打开缓存,检查该请求是否已经被缓存过,还是需要通过网络获取。
const cache = await this.cache;
const cachedResponse = await cache.match(req);
if (cachedResponse !== undefined) {
// 先判断下该请求是不是有hash,如果有,说明缓存是可以用的,直接返回。否则需要看看这个请求是多久之前发生的,缓存是不是还可以用。
if (this.hashes.has(url)) {
return cachedResponse;
}
else {
if (await this.needToRevalidate(req, cachedResponse)) {
this.idle.schedule(`revalidate(${this.prefix}, ${this.config.name}): ${req.url}`, async () => { await this.fetchAndCacheOnce(req); });
}
// In either case (revalidation or not), the cached response must be good.
return cachedResponse;
}
}
// 没有可用的缓存,通过网络获取,并将结果保存在缓存里面。
const res = await this.fetchAndCacheOnce(this.adapter.newRequest(req.url));
// 需要将结果clone一下,因为结果可能要供多个请求使用。
return res.clone();
}
else {
return null;
}
这里需要注意的是有缓存,但是没有hash的情况。说明这个缓存是由http cache header决定的。需要通过needToRevalidate来判断该请求/相应是否需要更新。有三种策略需要考虑
如果缓存不可用,都通过fetchAndCacheOnce来处理。
// inFlightRequest 里面存的是需要经过网络获取,已经通过网络请求,但是结果还没有回来的请求们。首先需要check现在要发出的请求是不是这一类,如果是的话,不应该再重复发送请求,直接等待上一个的返回结果。
if (this.inFlightRequests.has(req.url)) {
return this.inFlightRequests.get(req.url);
}
// 没有针对该请求的缓存操作正在执行,从这里开始出来,从网络获取并处理。
const fetchOp = this.fetchFromNetwork(req);
// 要先把请求放到inFlightRequest里面,表示该url的请求由此处理。
this.inFlightRequests.set(req.url, fetchOp);
try {
// 等待网络结果
const res = await fetchOp;
// 需要确保只有正确返回的结果才会被缓存起来,否则不对的结果会block掉应用
if (!res.ok) {
throw new Error(`Response not Ok (fetchAndCacheOnce): request for ${req.url} returned response ${res.status} ${res.statusText}`);
}
// 正确的结果,放到缓存里面
const cache = await this.scope.caches.open(`${this.prefix}:${this.config.name}:cache`);
await cache.put(req, res.clone());
// 如果该请求还没有hash,则更新它的hash值
if (!this.hashes.has(req.url)) {
// Metadata is tracked for requests that are unhashed.
const meta = { ts: this.adapter.time, used };
const metaTable = await this.metadata;
await metaTable.write(req.url, meta);
}
return res;
}
finally {
// 无论结果是否被正确返回,都将该请求从inFlightRequest里面删掉。表示这一次网络获取结束。
this.inFlightRequests.delete(req.url);
}
DataGroup呢?
DataGroup的思想跟AssetGroup差不多,区别在于Datagroup是针对的是网络上的请求,所以缓存的数据会很多。如果都无脑的放在cache里面,cache可能会哭出来。所以针对这种请求,DataGroup采用LRU策略进行处理。具体情况懒得写了。。下次再见。