概述
PWA(Progressive Web App)指的是应用指定技术和规范模式来开发的 Web 利用,让 Web 利用具备原生利用的个性和体验。比方咱们感觉本地利用应用便捷,响应速度更放慢等。
PWA 由 Google 于 2016 年提出,于 2017 年正式技术落地,并在 2018 年迎来重大突破,寰球顶级的浏览器厂商,Google、Microsoft、Apple 曾经全数发表反对 PWA 技术。
PWA 的关键技术有两个:
- Manifest:浏览器容许你提供一个清单文件,从而实现 A2HS
- ServiceWorker:通过对网络申请的代理,从而实现资源缓存、站点减速、离线利用等场景。
这两个是目前绝大部分开发者构建 PWA 利用所应用的最多的技术。
其次还有诸如:音讯推送、WebStream、Web 蓝牙、Web 分享、硬件拜访等 API。出于浏览器厂商的反对不一,遍及度还不高。
不论怎么样,应用 ServiceWorker 来优化用户体验,曾经成为 Web 前端优化的支流技术。
工具与框架
2018 年之前,支流的工具是:
- google/sw-toolbox: 提供了一套工具,用于不便的构建 ServiceWorker。
- google/sw-precache: 提供在构建阶段,注入资源清单到 ServiceWorker 中,从而实现预缓存性能。
- baidu/Lavas: 百度开发的基于 Vue 的 PWA 集成解决方案。
起初因为 Google 开发了更加优良的工具集 Workbox,sw-toolbox
和 sw-precache
得以退出舞台。
而 Lavas 因为团队遣散,次要作者到职,已处于进行保护状态。
痛点
Workbox 提供了一套工具汇合,用以帮忙咱们治理 ServiceWorker,它对 CacheStorage 的封装,也得以让咱们更轻松的去治理资源。
然而在构建理论的 PWA 利用的时候,咱们还须要关怀很多问题:
- 如何组织工程和代码?
- 如何进行单元测试?
- 如何解决 MPA (Multiple Page Application) 利用间的 ServiceWorker 作用域抵触问题?
- 如何近程管制咱们的 ServiceWorker?
- 最优的资源缓存计划?
- 如何监控咱们的 ServiceWorker,收集数据?
因为 Workbox 的定位是 「Library」,而咱们须要一个 「Framework」 去为这些通用问题提供对立的解决方案。
并且,咱们心愿它是渐进式(Progressive)的,就犹如 PWA 所提倡的那样。
代码解耦
是什么问题?
当咱们的 ServiceWorker 程序代码越来越多的时候,会造成代码臃肿,管理混乱,复用艰难。
同时一些常见的实现,如:近程管制、过程通信、数据上报等,心愿能实现按需插拔式的复用,这样能力达到「渐进式」的目标。
咱们都晓得,ServiceWorker 在运行时提供了一系列事件,罕用的有:
self.addEventListener('install', event => {});
self.addEventListener('activate', event => {});
self.addEventListener("fetch", event => {});
self.addEventListener('message', event => {});
当咱们有多个性能实现都要监听雷同的事件,就会导致同个文件的代码越来越臃肿:
self.addEventListener('install', event => {
// 近程管制模块 - 配置初始化
...
// 资源预缓存模块 - 缓存资源
...
// 数据上报模块 - 收集事件
...
});
self.addEventListener('activate', event => {
// 近程管制模块 - 刷新配置
...
// 数据上报模块 - 收集事件
...
});
self.addEventListener("fetch", event => {
// 近程管制模块 - 心跳查看
...
// 资源缓存模块 - 缓存匹配
...
// 数据上报模块 - 收集事件
...
});
self.addEventListener('message', event => {
// 数据上报模块 - 收集事件
...
});
你可能会说能够进行「模块化」:
import remoteController from './remoete-controller.ts'; // 近程管制模块
import assetsCache from './assets-cache.ts'; // 资源缓存模块
import collector from './collector.ts'; // 数据收集模块
import precache from './pre-cache.ts'; // 资源预缓存模块
self.addEventListener('install', event => {
// 近程管制模块 - 配置初始化
remoteController.init(...);
// 资源预缓存模块 - 缓存资源
assetsCache.store(...);
// 数据上报模块 - 收集事件
collector.log(...);
});
self.addEventListener('activate', event => {
// 近程管制模块 - 刷新配置
remoteController.refresh(..);
// 数据上报模块 - 收集事件
collector.log(...);
});
self.addEventListener("fetch", event => {
// 近程管制模块 - 心跳查看
remoteController.heartbeat(...);
// 资源缓存模块 - 缓存匹配
assetsCache.match(...);
// 数据上报模块 - 收集事件
collector.log(...);
});
self.addEventListener('message', event => {
// 数据上报模块 - 收集事件
collector.log(...);
});
模块化能缩小主文件的代码量,同时也肯定水平上对性能进行理解耦,然而这种形式还存在一些问题:
- 复用艰难 :当要应用一个模块的性能时,要在多个事件中去正确的调用模块的接口。同样,要去掉一个模块事,也要多个事件中去批改。
- 应用老本高 :模块裸露各种接口,使用者必须理解透彻模块的运行形式,以及接口的应用,能力很好的应用。
- 解耦无限 :如果模块更多,甚至要解决同域名下多个前端利用的命名空间抵触问题,就会显得顾此失彼。
要达到咱们目标:「渐进式」,咱们须要对代码的组织再优化一下。
插件化实现
咱们能够把 ServiceWorker 的一系列事件的控制权交出去,各模块通过插件的形式来应用这些事件。
咱们晓得 Koa.js 驰名的洋葱模型:
洋葱模型是「插件化」的很好的思维,然而它是 「一维」 的,Koa 实现一次网络申请的应答,各个中间件只须要监听一个事件。
而在 ServiceWorker 中,除了下面提及到的罕用四个事件,他还有更多事件,如:SyncEvent
, NotificationEvent
。
所以,咱们还要多弄几个「洋葱」去满足更多的事件。
同时因为 PWA 利用的代码个别会运行在两个线程:主线程、ServiceWorker 线程。
最初,咱们去封装原生的事件,去提供插件化反对,从而有了:「多维洋葱插件零碎」:
对原生事件和生命周期进行封装之后,咱们为每一个插件提供更优雅的生命周期钩子函数:
咱们基于 GlacierJS 的话,能够很容易做到模块的插件化。
在 ServiceWorker 线程的主文件中注册插件:
import {GlacierSW} from '@glacierjs/sw';
import RemoteController from './remoete-controller.ts'; // 近程管制模块
import AssetsCache from './assets-cache.ts'; // 资源缓存模块
import Collector from './collector.ts'; // 数据收集模块
import Precache from './pre-cache.ts'; // 资源预缓存模块
import MyPluginSW from './my-plugin.ts'
const glacier = new GlacierSW();
glacier.use(new Log(...));
glacier.use(new RemoteController(...));
glacier.use(new AssetsCache(...));
glacier.use(new Collector(...));
glacier.use(new Precache(...));
glacier.listen();
而在插件中,咱们能够通过监听事件去收归一个独立模块的逻辑:
import {ServiceWorkerPlugin} from '@glacierjs/sw';
import type {FetchContext, UseContext} from '@glacierjs/sw';
export class MyPluginSW implements ServiceWorkerPlugin {constructor() {...}
public async onUse(context: UseContext) {...}
public async onInstall(event) {...}
public async onActivate() {...}
public async onFetch(context: FetchContext) {...}
public async onMessage(event) {...}
public async onUninstall() {...}
}
作用域抵触
咱们都晓得对于 ServiceWorker 的作用域有两个要害个性:
- 默认的作用域是注册时候的 Path。
- 同个门路下同工夫只能有一个 ServiceWorker 失去控制权。
作用域放大与扩充
对于第一个个性,例如注册 Service Worker 文件为 /a/b/sw.js
,则 scope 默认为 /a/b/
:
if (navigator.serviceWorker) {navigator.serviceWorker.register('/a/b/sw.js').then(function (reg) {console.log(reg.scope);
// scope => https://yourhost/a/b/
});
}
当然咱们能够在注册的的时候指定 scope
去向下放大作用域,例如:
if (navigator.serviceWorker) {navigator.serviceWorker.register('/a/b/sw.js', {scope: '/a/b/c/'})
.then(function (reg) {console.log(reg.scope);
// scope => https://yourhost/a/b/c/
});
}
也能够通过服务器对 ServiceWorker 文件的响应设置 Service-Worker-Allowed
头部,去扩充作用域。
例如 Google Docs 在作用域 https://docs.google.com/document/u/0/
注册了一个来自于 https://docs.google.com/document/offline/serviceworker.js
的 ServiceWorker
MPA 下的 ServiceWorker 治理
古代 Web App 我的项目次要有两种架构模式存在:SPA(Single Page Application) 和 MPA(Multiple Page Application)
MPA 这种架构的模式在现如今的大型 Web App 十分常见,这种 Web App 相比拟于 SPA 可能接受更重的业务体量,并且利于大型 Web App 的前期保护和扩大,它往往会有多个团队去保护。
假如咱们有一个 MPA 的站点:
.
|-- app1
| |-- app1-service-worker.js
| `-- index.html
|-- app2
| `-- index.html
|-- index.html
`-- root-service-worker.js
app1 和 app2 别离由不同的团队保护。
如果咱们在根目录 '/'
注册了 root-service-worker.js
,去实现一些通用的性能,例如:「日志收集」、「动态资源缓存」等。
而后 app1 团队利用 ServiceWorker 的能力开发了一些特定的性能须要,例如 app1 的「离线化性能」。
他们在 app1/index.html
目录注册了 app1-service-worker.js
。
这时候,拜访 app1/*
下的所有页面,ServiceWorker 控制权会交给 app1-service-worker.js
,也就是只有 app1 的「离线化性能」在工作,而原来的「日志收集」、「动态缓存」等性能会生效。
显然这种状况是咱们不心愿看到的,并且在理论的开发中产生的概率会很大。
解决这个问题有两种计划:
- 封装「日志收集」、「动态资源缓存」性能,
app1-service-worker.js
引入并应用这些性能。 - 把「离线化性能」整合到
root-service-worker.js
,只容许注册该 ServiceWorker。
对于计划一,封装通用性能这是正确的,然而主域下的性能可能齐全没方法一一拆解,并且后续主域的 ServiceWorker 更新了新性能,子域下的 ServiceWorker 还须要被动去更新和降级。
对于计划二,显然能够解决方案一的问题,然而其余利用,例如 app2 可能不须要「离线化性能」。
基于此,咱们引入计划三:性能整合到主域,反对性能的组合依照作用域隔离。
基于 GlacierJS 的话代码上可能会是这样的:
const mainPlugins = [new Collector(); // 日志收集性能
new AssetsCache(); // 动态资源缓存性能];
glacier.use('/', mainPlugins);glacier.use('/app1', [
...mainPlugins,
new Offiline(), // 离线化性能]);
资源缓存
ServiceWorker 一个很外围的能力就是能联合 CacheAPI 进行灵便的缓存资源,从而达到优化站点的加载速度、弱网拜访、离线利用等。
对于动态资源有五种罕用的缓存策略:
- stale-while-revalidate
该模式容许您应用缓存(如果可用)尽快响应申请,如果没有缓存则回退到网络申请,而后应用网络申请来更新缓存,它是一种比拟平安的缓存策略。 - cache-first
离线 Web 应用程序将重大依赖缓存,但对于非关键且能够逐步缓存的资源,「缓存优先」 是最佳抉择。
如果缓存中有响应,则将应用缓存的响应来满足申请,并且基本不会应用网络。
如果没有缓存响应,则申请将由网络申请实现,而后响应会被缓存,以便下次间接从缓存中提供下一个申请。 - network-first
对于频繁更新的申请,「网络优先」 策略是现实的解决方案。
默认状况下,它会尝试从网络获取最新响应。如果申请胜利,它会将响应放入缓存中。如果网络未能返回响应,则将应用缓存的响应。 - network-only
如果您须要从网络满足特定申请,network-only 模式会将资源申请进行透传到网络。 - cache-only
该策略确保从缓存中获取响应。这种场景不太常见,它个别匹配着「预缓存」策略会比拟有用。
那这些策略中,咱们应该应用哪种呢?答案是依据资源的品种具体抉择。
例如一些资源如果只是在 Web 利用公布的时候才会更新,咱们就能够应用 cache-first 策略,例如一些 JS、款式、图片等。
而 index.html 作为页面的加载的主入口,更加合适应用 stale-while-revalidate 策略。
咱们以 GlacierJS 的缓存插件(@glacierjs/plugin-assets-cache)为例:
// in service-worker.js
importScripts("//cdn.jsdelivr.net/npm/@glacierjs/core/dist/index.min.js");
importScripts('//cdn.jsdelivr.net/npm/@glacierjs/sw/dist/index.min.js');
importScripts('//cdn.jsdelivr.net/npm/@glacierjs/plugin-assets-cache/dist/index.min.js');
const {GlacierSW} = self['@glacierjs/sw'];
const {AssetsCacheSW, Strategy} = self['@glacierjs/plugin-assets-cache'];
const glacierSW = new GlacierSW();
glacierSW.use(new AssetsCacheSW({
routes: [{
// capture as string: store index.html with stale-while-revalidate strategy.
capture: 'https://mysite.com/index.html',
strategy: Strategy.STALE_WHILE_REVALIDATE,
}, {
// capture as RegExp: store all images with cache-first strategy
capture: /\.(png|jpg)$/,
strategy: Strategy.CACHE_FIRST
}, {
// capture as function: store all stylesheet with cache-first strategy
capture: ({request}) => request.destination === 'style',
strategy: Strategy.CACHE_FIRST
}],
}));
近程管制
基于 ServiceWorker 的原理,一旦在浏览器装置上了,如果遇到紧急线上问题,唯有公布新的 ServiceWorker 能力解决问题。然而 ServiceWorker 的装置是有时延的,再加上有些团队从批改代码到公布的流程,这个反射弧就很长了。咱们有什么方法能缩短对于线上问题的反射弧呢?
咱们能够在近程存储一个配置,针对可预感的场景,进行「近程管制」:
那么咱们怎么去获取配置呢?
计划一 ,如果咱们在主线程中获取配置:
- 须要用户被动刷新页面才会失效。
- 做不到轻量的性能敞开,什么意思呢,咱们会有开关的场景,主线程只能通过卸载或者清理缓存去实现「敞开」,这个太重了。
计划二 ,如果咱们在 ServiceWorker 线程去获取配置:
- 能够实现轻量性能敞开,透传申请就行了。
- 然而如果遇到要洁净的清理用户环境的须要,去卸载 ServiceWorker 的时候,就会导致主过程每次注册,到了 ServiceWorker 就卸载,造成频繁装置卸载。
所以咱们的 最初计划 是 「基于双线程的实时配置获取」。
主线程也要获取配置,而后配置后面要加上防抖爱护,避免 onFetch 事件短时间并发的问题。
代码上,咱们应用 Glacier 的插件 @glacierjs/plugin-remote-controller 能够轻松实现近程管制:
// in ./remote-controller-sw.ts
import {RemoteControllerSW} from '@glacierjs/plugin-remote-controller';
import {GlacierSW} from '@glacierjs/sw';
import {options} from './options';
const glacierSW = new GlacierSW();
glacierSW.use(new RemoteControllerSW({fetchConfig: () => getMyRemoteConfig();}));
// 其中 getMyRemoteConfig 用于获取你存在远端的配置,返回的格局规定如下:const getMyRemoteConfig = async () => {
const config: RemoteConfig = {
// 全局敞开,卸载 ServiceWorker
switch: true,
// 缓存性能开关
assetsEnable: true,
// 精密管制特定缓存
assetsCacheRoutes: [{
capture: 'https://mysite.com/index.html',
strategy: Strategy.STALE_WHILE_REVALIDATE,
}],
},
}
数据收集
ServiceWorker 公布之后,咱们须要放弃对线上状况的把控。对于一些必要的统计指标,咱们可能须要进行上统计和上报。
@glacierjs/plugin-collector 内置了五个常见的数据事件:
- ServiceWorker 注册:SW_REGISTER
- ServiceWorker 装置胜利:SW_INSTALLED
- ServiceWorker 管制中:SW_CONTROLLED
- 命中 onFetch 事件:SW_FETCH
- 命中浏览器缓存:CACHE_HIT of CacheFrom.Window
- 命中 CacheAPI 缓存:CACHE_HIT of CacheFrom.SW
基于以上数据的收集,咱们就能够失去一些常见的通用指标:
- ServiceWorker 装置率 = SW_REGISTER / SW_INSTALLED
- ServiceWorker 控制率 = SW_REGISTER / SW_CONTROLLED
- ServiceWorker 缓存命中率 = SW_FETCH / CACHE_HIT (of CacheFrom.SW)
首先咱们在 ServiceWorker 线程中注册 plugin-collector:
import {AssetsCacheSW} from '@glacierjs/plugin-assets-cache';
import {CollectorSW} from '@glacierjs/plugin-collector';
import {GlacierSW} from '@glacierjs/sw';
const glacierSW = new GlacierSW();
// should use plugin-assets-cache first in order to make CollectedDataType.CACHE_HIT work.
glacierSW.use(new AssetsCacheSW({...}));
glacierSW.use(new CollectorSW());
而后在主线程中注册 plugin-collector,并且监听数据事件,进行数据上报:
import {
CollectorWindow,
CollectedData,
CollectedDataType,
} from '@glacierjs/plugin-collector';
import {CacheFrom} from '@glacierjs/plugin-assets-cache';
import {GlacierWindow} from '@glacierjs/window';
const glacierWindow = new GlacierWindow('./service-worker.js');
glacierWindow.use(new CollectorWindow({send(data: CollectedData) {const { type, data} = data;
switch (type) {
case CollectedDataType.SW_REGISTER:
myReporter.event('sw-register-count');
break;
case CollectedDataType.SW_INSTALLED:
myReporter.event('sw-installed-count');
break;
case CollectedDataType.SW_CONTROLLED:
myReporter.event('sw-controlled-count');
break;
case CollectedDataType.SW_FETCH:
myReporter.event('sw-fetch-count');
break;
case CollectedDataType.CACHE_HIT:
// hit service worker cache
if (data?.from === CacheFrom.SW) {myReporter.event(`sw-assets-count:hit-sw-${data?.url}`);
}
// hit browser cache or network
if (data?.from === CacheFrom.Window) {myReporter.event(`sw-assets-count:hit-window-${data?.url}`);
}
break;
}
},
}));
其中 myReporter.event
是你可能会实现的数据上报库。
单元测试
ServiceWorker 测试能够合成为常见的测试组。
在顶层的是 「集成测试」,在这一层,咱们查看整体的行为,例如:测试页面可加载,ServiceWorker 注册,离线性能等。集成测试是最慢的,然而也是最靠近现实情况的。
再往下一层的是 「浏览器单元测试」,因为 ServiceWorker 的生命周期,以及一些 API 只有在浏览器环境下能力有,所以咱们应用浏览器去进行单元测试,会缩小很多环境的问题。
接着是 「ServiceWorker 单元测试」,这种测试也是在浏览器环境中注册了测试用的 ServiceWorker 为前提进行的单元测试。
最初一种是 「模仿 ServiceWorker」,这种测试粒度会更加精密,精密到某个类某个办法,只检测入参和返回。这意味着没有了浏览器启动老本,并且最终是一种可预测的形式测试代码的形式。
然而模仿 ServiceWorker 是一件艰难的事件,如果 mock 的 API 外表不正确,则在集成测试或者浏览器单元测试之前问题不会被发现。咱们能够应用 service-worker-mock 或者 MSW 在 NodeJS 环境中进行 ServiceWorker 的单元测试。
因为篇幅无限,后续我另开专题来讲讲 ServiceWorker 单元测试的实际。
总结
本文开篇形容了对于 PWA 的基本概念,而后介绍了一些当初社区优良的工具,以及要去构建一个「可控、牢靠、可扩大的 PWA 利用」所面临的的理论的痛点。
于是在三个「可」给出了一些实践性的倡议:
- 通过「数据收集」、「近程管制」保障咱们对已公布的 PWA 利用的 「可控性」
- 通过「单元测试」、「集成测试」去保障咱们 PWA 利用的 「可靠性」
- 通过「多维洋葱插件模型」反对插件化和 MPA 利用,以及整合多个插件,从而达到 PWA 利用的 「可扩展性」。
参考
- 《PWA 实战:面向下一代的 Progressive Web APP》
- Service Worker 注册
- Two HTTP headers related to Service Workers you never may have heard of
- 如何优雅的为 PWA 注册 Service Worker
- Workbox
- GlacierJS – 多维洋葱插件零碎
- GlacierJS – 资源缓存
- GlacierJS – 近程管制
- GlacierJS – 数据收集