概述
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.jsimportScripts("//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.tsimport { 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 - 数据收集