2
头图

开篇

PWA 简介

PWA(Progressive Web Apps)不是特指某一项技术,而是应用多项技术来改善用户体验的 Web App,为 Web App 提供类似 Native App 的用户体验。
其核心技术包括 Web App Manifest,Web Push,Service Worker 和 Cache Api 等,用户体验才是 PWA 的核心。
PWA 主要特点如下:

  • 可靠 - 即使在网络不稳定甚至断网的环境下,也能瞬间加载并展现
  • 用户体验 - 快速响应,具有平滑的过渡动画及用户操作的反馈
  • 用户黏性 - 和 Native App 一样,可以被添加到桌面,能接受离线通知,具有沉浸式的用户体验

写在前面

  1. 文章不具体讲解 PWA 技术细节,如果对 PWA 技术感兴趣,文末准备了一些资料,可以参考学习
  2. 此次调研目的并非为网站完整接入 PWA 技术,而是利用其缓存机制提升网站性能
  3. 主要用到的技术为 Service Worker + Cache Api

下面开始:

前置知识

前端多级缓存模型

当浏览器想要获取远程的数据时,我们并不会立即动身(发送请求),在计算机领域,很多性能问题都会通过增加缓存来解决,前端也不例外。
和许多后端服务一样,前端缓存也是多级的。

  1. 本地读取阶段,这个阶段我们不会发起任何 HTTP 请求,只在本地读取数据作为响应

    • 通过结合本地存储,可以在业务代码侧实现缓存。对于一些请求,我们可以直接在业务代码侧进行缓存处理。缓存方式包括 localStoragesessionStorageindexedDB。把这块加入缓存的讨论也许会有争议,但利用好它确实能在程序侧达到一些类似缓存的能力。
    • 内存缓存并无明确的标准规定,它与 HTTP 语义下的缓存关联性不大,算是浏览器帮我们实现的优化,很多时候其实我们意识不到。
    • Service Worker 与 Cache API 是一个功能非常强大的组合,能够实现堆业务的透明,这层的缓存没有规定说该缓存什么、什么情况下需要缓存,它只是提供给了客户端构建请求缓存机制的能力。
  2. HTTP request 阶段,这个阶段我们发起了 HTTP 请求,但是数据依然是从本地读取。目前为止,我们可能还没有发出一个真正的请求。这也意味着,在缓存检查阶段我们就会有很多机会将后续的性能问题扼杀在摇篮之中

    • 这个时候出现的就是我们所熟知的 HTTP 缓存规范。HTTP 有一系列的规范来规定哪些情况下需要缓存请求信息、缓存多久,而哪些情况下不能进行信息的缓存。我们可以通过相关的 HTTP 请求头来实现缓存。HTTP 缓存大致可以分为强缓存与协商缓存。
    • 假如很不幸,以上这些缓存你都没有命中,那么你将会碰到最后一个缓存检查 —— Push Cache。Push Cache 是 HTTP/2 的 Push 功能所带来的。
  3. 真正请求阶段,如果很不幸本地没有任何有效数据,这时候才会发起真正的请求

前端多级缓存详细流程图如下:
image.png

有了 HTTP 缓存为什么还需要 Service Worker?

Service worker除了针对PWA(推送和消息)以外,对普通web来说,在缓存方面,能比http缓存带来一些额外的好处。
可以理解为,SW就是浏览器把缓存管理开放一层接口给开发者。
优势如下:
1、改写默认行为。
例如,浏览器默认在刷新时,会对所有资源都重新发起请求,即使缓存还是有效期内,而使用了SW,就可以改写这个行为,直接返回缓存。
2、缓存和更新并存。
要让网页离线使用,就需要整站使用长缓存,包括HTML。而HTML使用了长缓存,就无法及时更新(浏览器没有开放接口直接删除某个html缓存)。而使用SW就可以,每次先使用缓存部分,然后再发起SW js的请求,这个请求我们可以实施变更,修改HTML版本,重新缓存一份。那么用户下次打开就可以看到新版本了。
3、无侵入式。
无侵入式版本控制。最优的版本控制,一般是HTML中记录所有js css的文件名(HASH),然后按需发起请求。每个资源都长缓存。而这个过程,就需要改变了项目结构,至少多一个js或者一段js控制版本号,发起请求时还需要url中注入冬天的文件名。使用了SW,就可以把这部分非业务逻辑整合到sw js中。
无侵入式请求统计。例如缓存比例统计、图片404统计。
4、额外缓存。
HTTP缓存空间有限,容易被冲掉。虽然部分浏览器实现SW的存储也有淘汰机制,但多一层缓存,命中的概率就要更高了。
5、离线处理。
当监测到离线,而且又没有缓存某个图片时,可以做特殊处理,返回离线的提示。又或者做一个纯前端的404/断网页面。类似Chrome的小恐龙页面。
6、预加载资源。
这个类似prefetch标签。
7、前置处理。
例如校验html/JS是否被运营商劫持?js文件到了UI进程执行后,就无法删除恶意代码,而在SW中,我们可以当作文本一样,轻松解决。当然,在HTTPS环境下出现劫持的概率是极低的。
来源:https://www.cnblogs.com/kenko...

Service Worker

简介

Service Worker 的初衷是极致优化用户体验,带来丝滑般流畅的离线应用。
但同时也可以用作站点缓存使用,这也是这篇文章的重点
它本身类似于一个介于浏览器和服务端之间的网络代理,可以拦截请求并操作响应内容。
Service Worker 在 Web Worker 的基础上加上了持久离线缓存能力,可以通过自身的生命周期特性保证复杂的工作只处理一次,并持久缓存处理结果,直到修改了 Service Worker 的内在的处理逻辑。
特点总结如下:

  • 一个特殊的 worker 线程,独立于当前网页主线程,有自己的执行上下文
  • 一旦被安装,就永远存在,除非显示取消注册
  • 使用到的时候浏览器会自动唤醒,不用的时候自动休眠
  • 可拦截并代理请求和处理返回,可以操作本地缓存,如 CacheStorage,IndexedDB 等
  • 离线内容开发者可控
  • 能接受服务器推送的离线消息
  • 异步实现,内部接口异步化基本是通过 Promise 实现
  • 不能直接操作 DOM
  • 必须在 HTTPS 环境下才能工作

    使用者

    有很多团队也是启用该工具来实现 Service Worker 的缓存,比如说:

  • 淘宝首页
  • 网易新闻 wap 文章页
  • 百度的 Lavas
  • fullstory
    ... ...

兼容性

如下图所示,除了 IE 和 Opera Mini 不支持,大部分现代浏览器都没有问题,兼容度超过 96%

安全性

Service Worker 是一种独立于浏览器主线程的工作线程,与当前的浏览器主线程是完全隔离的,并有自己独立的执行上下文(context)。由于 Service Worker 线程是独立于主线程的工作线程,所以在 Service Worker 中的任何操作都不会影响到主线程。
因此,在浏览器不支持 Service Worker、Service Worker 挂掉和 Service Worker 出错等等情况下,主体网站都不会受到影响,因此从网站故障角度讲是 100% 安全的。
其可能出现问题的地方在于数据的准确性,这涉及到缓存策略和淘汰算法等技术,也是配置 Service Worker 的重点。

作用域

Service Worker 注册会有意想不到的作用域污染问题
SPA 在工程架构上只有一个 index.html 的入口,站点的内容都是异步请求数据之后在前端渲染的,应用中的页面切换都是在前端路由控制的。
通常会将这个 index.html 部署到 https://somehost ,SPA 的 Service Worker 只需要在 index.html 中注册一次。所以一般会将 sw.js 直接放在站点的根目录保证可访问,也就是说 Service Worker 的作用域通常就是 /,这样 Service Worker 能够控制 index.html,从而控制整个 SPA 的缓存。

代码如下:

  var sp = window.location.protocol + '//' + window.location.host + '/';
  if ('serviceWorker' in navigator) {
    // 为了防止作用域污染,将安装前注销所有已生效的 Service Worker
    navigator.serviceWorker.getRegistrations().then(regs => {
      for (let reg of regs) {
        reg.unregister();
      }
      navigator.serviceWorker
        .register(sp + 'service-worker.js', {
          scope: sp,
        })
        .then(reg => {
          console.log('set scope: ', sp, 'service worker instance: ', reg)
        });
    });
  }

更新

在执行 navigator.serviceWorker.register() 方法注册 Service Worker 的时候,浏览器通过自身 diff 算法能够检测 sw.js 的更新包含两种方式:

  • Service Worker 文件 URL 的更新
  • Service Worker 文件内容的更新

在实际项目中,在 Web App 新上线的时候,通常是在注册 Service Worker 的时候,通过修改 Service Worker 文件的 URL 来进行 Service Worker 的更新,这部分工作可以通过 webpack 插件实现

缓存策略

预缓存

静态资源具有确定性,因此可以主动获取所需缓存的资源列表,并且在 Service Worker 安装阶段就主动发起静态资源请求并缓存,这样一旦新的 Service Worker 被激活之后,缓存就直接能投入使用了。这是一个资源预取的过程,因此静态资源的缓存方案也称为预缓存方案。关于预缓存更多细节可以参考预缓存方案

动态缓存

在 Service Worker 环境下,可以通过 Fetch API 发送网络请求获取资源,也可以通过 Cache API、IndexedDB 等本地缓存中获取缓存资源,甚至可以在 Service Worker 直接生成一个 Response 对象,以上这些都属于资源响应的来源。资源请求响应策略的作用,就是用来解决响应的资源从哪里来的问题。更多请求响应策略参考这里

一些建议

  1. HTML,如果你想让页面离线可以访问,使用 NetworkFirst,如果不需要离线访问,使用 NetworkOnly,其他策略均不建议对 HTML 使用
  2. CSS 和 JS,情况比较复杂,因为一般站点的 CSS,JS 都在 CDN 上,SW 并没有办法判断从 CDN 上请求下来的资源是否正确(HTTP 200),如果缓存了失败的结果,问题就大了。这种我建议使用 Stale-While-Revalidate 策略,既保证了页面速度,即便失败,用户刷新一下就更新了
  3. 如果你的 CSS,JS 与站点在同一个域下,并且文件名中带了 Hash 版本号,那可以直接使用 Cache First 策略
  4. 图片建议使用 Cache First,并设置一定的失效事件,请求一次就不会再变动了
  5. 所有接口类缓存都建议 Stale-While-Revalidate 策略
  6. 对于不在同一域下的任何资源,绝对不能使用 Cache only 和 Cache first。

更多缓存策略相关,可以在下面文章查看:
PWA之Workbox缓存策略分析
Service Worker 开发工具

生命周期

关于 Service Worker 生命周期相关的,主要是涉及 Service Worker 自身的更新和在什么阶段缓存对应的资源。更多信息点击这里

Cache API

离线存储方案对比

前端主流离线存储方案对比如下所示:
image.png
Cache API 是为资源请求与响应的存储量身定做的,它采用了键值对的数据模型存储格式,以请求对象为键、响应对象为值,正好对应了发起网络资源请求时请求与响应一一对应的关系。因此 Cache API 适用于请求响应的本地存储。

IndexedDB 则是一种非关系型(NoSQL)数据库,它的存储对象主要是数据,比如数字、字符串、Plain Objects、Array 等,以及少量特殊对象比如 Date、RegExp、Map、Set 等等,对于 Request、Response 这些是无法直接被 IndexedDB 存储的。

可以看到,Cache API 和 IndexedDB 在功能上是互补的。在设计本地资源缓存方案时通常以 Cache API 为主,但在一些复杂的场景下,Cache API 这种请求与响应一一对应的形式存在着局限性,因此需要结合上功能上更为灵活的 IndexedDB,通过 IndexedDB 存取一些关键的数据信息,辅助 Cache API 进行资源管理。

兼容性


总结

通过上述对比,我们可以使用 IndexedDB 及 CacheStorage 来为 Service Worker 的离线存储提供底层服务,根据社区的经验,它们各自的适用场景为:

  • 对于网址可寻址的(比如脚本、样式、图片、HTML 等)资源使用 CacheStorage
  • 其他资源则使用 IndexedDB

Workbox

简介

在页面线程中,虽然可以直接使用底层 API 来处理 Service Worker 的注册、更新与通信,但在较为复杂的应用场景下(比如,页面中不同窗口注册不同的 Service Worker),我们往往会因为要处理各种情况而逐步陷入复杂、混乱的深渊,并且,在出现运行结果与预期结果不一致时,我们往往不知所措、不知如何进行排查。正是因为这些原因,Google Chrome 团队推出的一套 PWA 的解决方案 Workbox ,这套解决方案当中包含了核心库和构建工具,因此我们可以利用 Workbox 实现 Service Worker 的快速开发。

webpack 插件

官方提供 workbox-webpack-plugin 插件为我们进一步节省开发成本(版本v6.4.2)

为什么需要这个 webpack 插件?

  • 给预缓存打hash,开发的时候动态更新 hash
  • 更方便的接口去动态缓存配置方式,自动生成和更新 sw

接入代码

const { InjectManifest } = require('workbox-webpack-plugin');
 // 注入模式
new InjectManifest({
  swSrc: path.resolve(__dirname, 'src/service-worker.js'), // 已有 SW 路径
  swDest: 'service-worker.js', // 目标文件名(打包后)
  maximumFileSizeToCacheInBytes: 1024000 * 4, // 只缓存 4M 以下的文件
  include: [/.*.(png|jpg|jpeg|svg|ico|webp)$/, 'beautify.js'], // 仅包含图片和beautify.js
}),

代码示例

service-worker.js 完整代码

// 基础配置
import { setCacheNameDetails, skipWaiting, clientsClaim } from 'workbox-core';
// 缓存相关
import { precacheAndRoute } from 'workbox-precaching/precacheAndRoute';
import { registerRoute, setDefaultHandler } from 'workbox-routing';
import {
  NetworkFirst,
  StaleWhileRevalidate,
  CacheFirst,
  NetworkOnly,
} from 'workbox-strategies';
// 插件
import { CacheableResponsePlugin } from 'workbox-cacheable-response/CacheableResponsePlugin';
import { ExpirationPlugin } from 'workbox-expiration/ExpirationPlugin';
// 内置方案
import { pageCache, offlineFallback } from 'workbox-recipes';

import { getCacheKeyWillBeUsed, blockStaticSource, blockImageSource } from '@/utils-ts/service-worker'



self.__WB_DISABLE_DEV_LOGS = true;

setCacheNameDetails({
  prefix: 'sw-tools',
  suffix: 'v1',
  precache: 'precache',
  runtime: 'runtime-cache',
});

skipWaiting();
clientsClaim();

/*
 通常当用户访问 / 时,对应的访问的页面 HTML 文件是 /index.html,默认情况下,precache 路由机制会在任何 URL 的结尾的 / 后加上 index.html,这就以为着你预缓存的任何 index.html 都可以通过 /index.html 或者 / 访问到。当然,你也可以通过 directoryIndex 参数禁用掉这个默认行为
 */
precacheAndRoute(self.__WB_MANIFEST, {
  ignoreUrlParametersMatching: [/.*/],
  directoryIndex: null,
});

// URL navigation 缓存
pageCache();

// html 的缓存
// HTML,如果你想让页面离线可以访问,使用 NetworkFirst,如果不需要离线访问,使用 NetworkOnly,其他策略均不建议对 HTML 使用。
registerRoute(new RegExp(/.*\.html/), new NetworkFirst());

// 静态资源的缓存
//CSS 和 JS,情况比较复杂,因为一般站点的 CSS,JS 都在 CDN 上,SW 并没有办法判断从 CDN 上请求下来的资源是否正确(HTTP 200),如果缓存了失败的结果,问题就大了。这种我建议使用 Stale-While-Revalidate 策略,既保证了页面速度,即便失败,用户刷新一下就更新了。如果你的 CSS,JS 与站点在同一个域下,并且文件名中带了 Hash 版本号,那可以直接使用 Cache First 策略。
registerRoute(
  blockStaticSource,
  new StaleWhileRevalidate({
    cacheName: 'static-resources',
    plugins: [
      new CacheableResponsePlugin({
        statuses: [0, 200],
      }),
      new ExpirationPlugin({
        maxEntries: 500,
        maxAgeSeconds: 30 * 24 * 60 * 60,
      }),
    ],
  }),
);

// 图片的缓存
// 图片建议使用 Cache First,并设置一定的失效事件,请求一次就不会再变动了。
registerRoute(
  blockImageSource,
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      { cacheKeyWillBeUsed: getCacheKeyWillBeUsed('all') },
      new CacheableResponsePlugin({
        statuses: [0, 200],
      }),
      new ExpirationPlugin({
        maxEntries: 200,
        maxAgeSeconds: 30 * 24 * 60 * 60,
      }),
    ],
  }),
);

// xxxx 接口的缓存
registerRoute(
  /^http(s)?:\/\/((dev\.)|(test\.)|(testing\.))?xxxx.net\/api\/v\d+\/(.*)?\/xxxx.*/,
  new StaleWhileRevalidate({
    cacheName: 'xxxx_cache',
    plugins: [
      { cacheKeyWillBeUsed: getCacheKeyWillBeUsed('t') },
      new CacheableResponsePlugin({
        statuses: [0, 200],
      }),
      new ExpirationPlugin({
        maxEntries: 2000,
        maxAgeSeconds: 7 * 24 * 60 * 60,
      }),
    ],
  }),
);

// yyyy 接口
registerRoute(
  /^http(s)?:\/\/((dev\.)|(test\.)|(testing\.))?xxxx.net\/api\/v\d+\/(.*)?\/yyyy.*/,
  new StaleWhileRevalidate({
    cacheName: 'yyyy_cache',
    plugins: [
      { cacheKeyWillBeUsed: getCacheKeyWillBeUsed('t') },
      new CacheableResponsePlugin({
        statuses: [0, 200],
      }),
      new ExpirationPlugin({
        maxEntries: 10000,
        maxAgeSeconds: 20 * 60, // 20分钟
      }),
    ],
  }),
);

service-worker 常用方法类和插件示例:

import type { cacheSourceTypes, BlockRules } from '#/service-worker';
import { CacheRequestParams, } from '#/service-worker';
import blockConfig from './block-config';
import { isLocalEnv } from '@/utils-ts/env';

// 注册 service worker
const installSW = () => {
  const urlInstance = new URL(location.href);
  const scope = `${urlInstance.origin}/mobile/`;
  const localScriptStr =
    `if ('serviceWorker' in navigator) {
      // 为了防止作用域污染,将安装前注销所有已生效的 Service Worker
      navigator.serviceWorker.getRegistrations()
        .then(function(registrations) {
          for(let registration of registrations) {
            registration.unregister();
          }
        });
    }`;
  const scriptStr =
  `if ('serviceWorker' in navigator) {
    // 为了防止作用域污染,将安装前注销所有已生效的 Service Worker
    navigator.serviceWorker.getRegistrations()
      .then(function(registrations) {
        for(let registration of registrations) {
          // 注销掉不是当前作用域的所有的 Service Worker
          if (registration.scope !== '${scope}') {
            registration.unregister();
          }
        }
        // 注销掉污染 Service Worker 之后再重新注册自己作用域的 Service Worker
        navigator.serviceWorker
          .register('${scope}' + 'service-worker.js', {
            scope: '${scope}',
          })
          .then(reg => {
            console.log('set scope: ', '${scope}', 'service worker instance: ', reg);
          });
      });
  }`;
  const script = document.createElement('script');
  script.type = 'text/javascript';
  script.innerHTML = isLocalEnv() ? localScriptStr : scriptStr;
  document.body.appendChild(script);
}

/**
 * @description: 自定义去掉请求参数插件
 * @param {string | string[]} params 单个参数或者多个参数
 * @return {*}
 */
const getCacheKeyWillBeUsed = (params: string | string[]) =>{
  return async function ({ request }: CacheRequestParams) {
    const url = new URL(request.url);
    const searchParams = url.searchParams;
    if (typeof params === 'string') {
      if (params === 'all') {
        const params = Array.from(searchParams.keys());
        for (const param of params) {
          searchParams.delete(param);
        }
        return url.href;
      }
      url.searchParams.delete(params);
      return url.href;
    }
    if (Array.isArray(params)) {
      for (const param of params) {
        url.searchParams.delete(param);
      }
      return url.href;
    }

    // Return the full remaining href.
    return url.href;
  };
}

/**
 * @description: 屏蔽资源
 * @param {cacheSourceTypes} source
 * @param {URL} url
 * @return {*}
 */
const blockSource = (source: cacheSourceTypes, url: URL) => {
  const blockRules: BlockRules = blockConfig[source]; // blockConfig 为屏蔽规则配置
  const pathRules = blockRules.path;
  const inPathBlacklist = pathRules.includes(url.pathname);
  if (inPathBlacklist) return true;
  const hostRules = blockRules.host;
  const inHostBlacklist = hostRules.includes(url.host);
  if (inHostBlacklist) return true;
  return false;
}

/**
 * @description: 静态资源屏蔽策略
 * @param {CacheRequestParams} param1
 * @return {*}
 */
const blockStaticSource = ({ request }: CacheRequestParams) => {
  if (blockSource('static', new URL(request.url))) return false;
  return (
    request.destination === 'style' ||
    request.destination === 'script' ||
    request.destination === 'worker'
  );
}

/**
 * @description: 图片资源屏蔽策略
 * @param {CacheRequestParams} param1
 * @return {*}
 */
const blockImageSource = ({ request }: CacheRequestParams) => {
  if (blockSource('image', new URL(request.url))) return false;
  return request.destination === 'image';
};

export {
  installSW,
  getCacheKeyWillBeUsed,
  blockStaticSource,
  blockImageSource,
}

屏蔽配置文件示例

export default {
  static: { // 静态资源规则
    path: [ // 按 path 屏蔽
      '/sdk/record.js',
      '/latest.js'
    ],
    host: [], // 按 host 屏蔽
  },
  image: {
    path: [
      '/api/v1/user_agent',
      '/api/v1/xxx/tracking'
    ],
    host: [
      'oss-cn-beijing.aliyuncs.com',
    ],
  },
}

示例图

图 1

图 2

相关资料

  1. 前端性能优化 - 缓存
  2. 【MDN】IndexedDB 浏览器存储限制和清理标准
  3. 网易云课堂 Service Worker 运用与实践
  4. workBox 官方
  5. 使用 Workbox
  6. workbox缓存常用范例
  7. workbox路由请求
  8. 淘宝前端 Workbox 应用
  9. 神奇的 Workbox 3.0
  10. 饿了么的 PWA 升级实践
  11. 百度 Web 生态团队《PWA 应用实战》
  12. 深入浅出 PWA
  13. workbox-webpack-plugin 插件相关

小磊
352 声望884 粉丝

以一颗更加开放,更加多元,更加包容的心走进别人的世界