乐趣区

关于javascript:如何构建可控可靠可扩展的-PWA-应用

概述

PWA(Progressive Web App)指的是应用指定技术和规范模式来开发的 Web 利用,让 Web 利用具备原生利用的个性和体验。比方咱们感觉本地利用应用便捷,响应速度更放慢等。

PWA 由 Google 于 2016 年提出,于 2017 年正式技术落地,并在 2018 年迎来重大突破,寰球顶级的浏览器厂商,Google、Microsoft、Apple 曾经全数发表反对 PWA 技术。

PWA 的关键技术有两个:

  1. Manifest:浏览器容许你提供一个清单文件,从而实现 A2HS
  2. ServiceWorker:通过对网络申请的代理,从而实现资源缓存、站点减速、离线利用等场景。

这两个是目前绝大部分开发者构建 PWA 利用所应用的最多的技术。

其次还有诸如:音讯推送、WebStream、Web 蓝牙、Web 分享、硬件拜访等 API。出于浏览器厂商的反对不一,遍及度还不高。

不论怎么样,应用 ServiceWorker 来优化用户体验,曾经成为 Web 前端优化的支流技术。

工具与框架

2018 年之前,支流的工具是:

  1. google/sw-toolbox: 提供了一套工具,用于不便的构建 ServiceWorker。
  2. google/sw-precache: 提供在构建阶段,注入资源清单到 ServiceWorker 中,从而实现预缓存性能。
  3. baidu/Lavas: 百度开发的基于 Vue 的 PWA 集成解决方案。

起初因为 Google 开发了更加优良的工具集 Workbox,sw-toolboxsw-precache 得以退出舞台。

而 Lavas 因为团队遣散,次要作者到职,已处于进行保护状态。

痛点

Workbox 提供了一套工具汇合,用以帮忙咱们治理 ServiceWorker,它对 CacheStorage 的封装,也得以让咱们更轻松的去治理资源。

然而在构建理论的 PWA 利用的时候,咱们还须要关怀很多问题:

  1. 如何组织工程和代码?
  2. 如何进行单元测试?
  3. 如何解决 MPA (Multiple Page Application) 利用间的 ServiceWorker 作用域抵触问题?
  4. 如何近程管制咱们的 ServiceWorker?
  5. 最优的资源缓存计划?
  6. 如何监控咱们的 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(...);
});

模块化能缩小主文件的代码量,同时也肯定水平上对性能进行理解耦,然而这种形式还存在一些问题:

  1. 复用艰难 :当要应用一个模块的性能时,要在多个事件中去正确的调用模块的接口。同样,要去掉一个模块事,也要多个事件中去批改。
  2. 应用老本高 :模块裸露各种接口,使用者必须理解透彻模块的运行形式,以及接口的应用,能力很好的应用。
  3. 解耦无限 :如果模块更多,甚至要解决同域名下多个前端利用的命名空间抵触问题,就会显得顾此失彼。

要达到咱们目标:「渐进式」,咱们须要对代码的组织再优化一下。

插件化实现

咱们能够把 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 的作用域有两个要害个性:

  1. 默认的作用域是注册时候的 Path。
  2. 同个门路下同工夫只能有一个 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

app1app2 别离由不同的团队保护。

如果咱们在根目录 '/' 注册了 root-service-worker.js,去实现一些通用的性能,例如:「日志收集」、「动态资源缓存」等。

而后 app1 团队利用 ServiceWorker 的能力开发了一些特定的性能须要,例如 app1 的「离线化性能」。

他们在 app1/index.html 目录注册了 app1-service-worker.js

这时候,拜访 app1/* 下的所有页面,ServiceWorker 控制权会交给 app1-service-worker.js,也就是只有 app1 的「离线化性能」在工作,而原来的「日志收集」、「动态缓存」等性能会生效。

显然这种状况是咱们不心愿看到的,并且在理论的开发中产生的概率会很大。

解决这个问题有两种计划:

  1. 封装「日志收集」、「动态资源缓存」性能,app1-service-worker.js 引入并应用这些性能。
  2. 把「离线化性能」整合到 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 进行灵便的缓存资源,从而达到优化站点的加载速度、弱网拜访、离线利用等。

对于动态资源有五种罕用的缓存策略:

  1. stale-while-revalidate
    该模式容许您应用缓存(如果可用)尽快响应申请,如果没有缓存则回退到网络申请,而后应用网络申请来更新缓存,它是一种比拟平安的缓存策略。
  2. cache-first
    离线 Web 应用程序将重大依赖缓存,但对于非关键且能够逐步缓存的资源,「缓存优先」 是最佳抉择。
    如果缓存中有响应,则将应用缓存的响应来满足申请,并且基本不会应用网络。
    如果没有缓存响应,则申请将由网络申请实现,而后响应会被缓存,以便下次间接从缓存中提供下一个申请。
  3. network-first
    对于频繁更新的申请,「网络优先」 策略是现实的解决方案。
    默认状况下,它会尝试从网络获取最新响应。如果申请胜利,它会将响应放入缓存中。如果网络未能返回响应,则将应用缓存的响应。
  4. network-only
    如果您须要从网络满足特定申请,network-only 模式会将资源申请进行透传到网络。
  5. 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 的装置是有时延的,再加上有些团队从批改代码到公布的流程,这个反射弧就很长了。咱们有什么方法能缩短对于线上问题的反射弧呢?

咱们能够在近程存储一个配置,针对可预感的场景,进行「近程管制」

那么咱们怎么去获取配置呢?

计划一 ,如果咱们在主线程中获取配置:

  1. 须要用户被动刷新页面才会失效。
  2. 做不到轻量的性能敞开,什么意思呢,咱们会有开关的场景,主线程只能通过卸载或者清理缓存去实现「敞开」,这个太重了。

计划二 ,如果咱们在 ServiceWorker 线程去获取配置:

  1. 能够实现轻量性能敞开,透传申请就行了。
  2. 然而如果遇到要洁净的清理用户环境的须要,去卸载 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 内置了五个常见的数据事件:

  1. ServiceWorker 注册:SW_REGISTER
  2. ServiceWorker 装置胜利:SW_INSTALLED
  3. ServiceWorker 管制中:SW_CONTROLLED
  4. 命中 onFetch 事件:SW_FETCH
  5. 命中浏览器缓存:CACHE_HIT of CacheFrom.Window
  6. 命中 CacheAPI 缓存:CACHE_HIT of CacheFrom.SW

基于以上数据的收集,咱们就能够失去一些常见的通用指标:

  1. ServiceWorker 装置率 = SW_REGISTER / SW_INSTALLED
  2. ServiceWorker 控制率 = SW_REGISTER / SW_CONTROLLED
  3. 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 利用」所面临的的理论的痛点。

于是在三个「可」给出了一些实践性的倡议:

  1. 通过「数据收集」、「近程管制」保障咱们对已公布的 PWA 利用的 「可控性」
  2. 通过「单元测试」、「集成测试」去保障咱们 PWA 利用的 「可靠性」
  3. 通过「多维洋葱插件模型」反对插件化和 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 – 数据收集
退出移动版