admin 管理员组

文章数量: 1087652

【angular5】浅谈angular5与serviceWorker——(2)

  上一篇文章介绍了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自己的versionevent.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发送一个任务,请求checkupdatethis.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。

//Driverawait 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;}})); 
// AppVersionasync initializeFully(updateFrom) {try {//依次排排坐等着每一个group updateawait 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进行细分处理。

// onFetchconst 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();});}// 获取对应的appversionconst 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来判断该请求/相应是否需要更新。有三种策略需要考虑

  1. 如果请求包括cache-control头部,则需要check请求的age,
  2. 如果请求有expires 头,则需要查看timestamp
  3. 如果没有任何跟缓存相关的头,那么是不可用的,直接返回true。

如果缓存不可用,都通过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策略进行处理。具体情况懒得写了。。下次再见。

本文标签: angular5浅谈angular5与serviceWorker(2)