# 同构项目 Service Worker 离线化实践

阅读本文需要相关知识储备Service Worker 生命周期 (opens new window)Workbox (opens new window)、前端同构渲染

# 背景

团队计划在产品中尝试离线化某些功能,在这之前项目已经简单引入了workbox (opens new window)来管理静态资源。因为没有缓存动态接口数据,所有页面并不支持离线访问。
目标:使高流量页面支持离线访问,让用户意识到网页(非APP)也能在无网络场景下使用。

# 方案

# 调研

在对SW工作原理有初步了解之后,分析总结我们面临的主要问题分两类。

  • 如何管理缓存资源(静态资源、接口数据)
    • 如何缓存静态资源(js, css...)
    • 如何缓存后台接口数据
    • 如何缓存动态获取的资源(如从接口获取的页面头图)
    • 如何清除失效缓存
  • 如何解决同构项目特有问题(问题较复杂,后文详细描述)

用一张图来描述大致的方案

# 缓存管理

针对缓存管理的问题,决定以workbox为基础来实现。
使用的workbox提供的 workbox-webpack-plugin (opens new window)Precache Files (opens new window)Route Requests (opens new window) 功能,然后再在其上编写动态资源、缓存清除逻辑,缓存关的问题基本上都解决了。
大致步骤如下:

  1. 集成workbox-webpack-plugin插件到构建流程,workboxPlugin.GenerateSW生成precache-manifest文件(包含所有静态资源的路径)
  2. 避免占用太多空间、节省流量等原因,只预缓存项目公共资源 workbox.precaching.precacheAndRoute(self.__precacheManifest.filter(/* 自定义规则 */)),SW每次install会增量获取有更新的资源
  3. workbox.routing.registerRoute拦截并缓存需离线访问页面的动态请求
    • 如何缓存通过接口获取的头图url?registerRoute注册一个特殊路由,获取到头图url之后发送给SW,SW发起图片请求缓存Response。
      // service worker js
      workbox.routing.registerRoute(/sw-api\/cache-static-res/, () => {
        const url = await event.request.json();
        await fetch(url).then(resp => cache.put(url, resp))));
        return new Response('success', {
          status: 200,
          statusText: 'ok',
        })
      }, 'POST')
      // page js
      fetch('/sw-api/cache-static-res', {
        body: 'http://xxxxxxxx.png',
        method: 'POST',
      })
      
  4. 非公共资源,按页面缓存到Cache Storage对应的key(根据页面生成)下,当页面达到一定数量则清除最老的页面缓存
    • 如何获取到最旧的页面缓存?可以在每个页面对应的key下创建一个时间戳缓存项,页面每次访问时都更新时间戳 cache.put('update-timestamp', new Response(Date.now())

# 同构项目特有问题

背景:不同渲染模式的差异

  • 纯Server端渲染,每访问一个页面,Server请求页面初始化接口(init request),渲染完成后直接返回一个完整的已初始化的html文档。
  • Client端渲染,当访问一个页面时,通常是由静态资源服务器(如Nginx、Apache)或CDN返回一个非常简单的骨架html文档,然后加载页面的依赖的“*.bundle.js”文件,由js请求**页面初始化接口(init request)**再生成dom挂载到html中的一个空节点上。
  • 同构渲染则结合两者的优点,用户在浏览器中第一次打开页面,由Server渲染(更快地展现第一屏、有利于SEO),之后的用户在站内跳转则使用客户端渲染(节省流量、减少服务端压力)。

假设用户先打开 页面A,再跳转到 页面B(A -> B)。
从背景描述我们可以知道对于A(html)、B(B.bundle.js + init request)两个页面,SW能截获到的内容是不一样的。
如果在离线场景下,用户是打开 页面B,再跳转 页面A。如何生成页面B的html,获取页面A的init request呢?
同构项目的特殊问题即:在离线场景下,如何保证任意页面能够以任意路径访问。

离线场景不能访问网络,所以必须完全启用Client渲染。(也可以在用户访问页面时,额外发送一个请求,然后缓存由Server返回的html,但这种方式比较浪费资源)
为了使页面在不能访问网络的情况下正确渲染,需要解决两个问题:

  1. 构建骨架html,其中引入必要的js、css。离线打开页面时返回骨架html,作为入口启动Client渲染。
  2. Server端渲染请求的init request想办法传递到Client,由SW缓存起来。

# 构建骨架html

  1. 创建一个shell.html,留下两个标记用来注入页面依赖的js、css url
    <html>
      <header>
        <!-- inject main-css -->
      </header>
      <body>
        <div id="app"></div>
        <!-- inject scripts -->
      </body>
    </html>
    
  2. 在SW install 事件中请求shell.html,注入页面依赖的js、css,然后缓存到caches中
    self.addEventListener('install', installHandler);
    
    function installHandler(event) {
      // 使用webpack打包,页面依赖的三个公共js
      const regx = /(?:runtime~main|main|vendors~main)(?:\.\w+)?\.js/;
    
      // __precacheManifest是workbox生成的SW全局变量
      const startJS = self.__precacheManifest
        .map(({ url }) => url)
        .filter(url => regx.test(url))
        .map(url => `<script src="${url}"></script>`)
        .join(' ');
    
      const mainCSSUrl = self.__precacheManifest
        .find(({ url }) => /main\.\w+\.css$/.test(url));
      const mainCSS = `<link rel="stylesheet" type="text/css" href="${ mainCSSUrl ? mainCSSUrl.url : '' }">`;
    
      event.waitUntil(
        (async () => {
          await caches.delete(`pre-cache-tpl`);
          const cache = await caches.open(`pre-cache-tpl`);
    
          const resp = await fetch('/app-shell.html');
          const txt = await resp.text();
          await cache.put(
            '/app-shell.html',
            new Response(
              txt
                .replace('<!-- inject scripts -->', startJS)
                .replace('<!-- inject main-css -->', mainCSS),
              {
                headers: { 'Content-Type': 'text/html;charset=UTF-8' },
              }
            )
          );
        })()
      );
    }
    
  3. 当一个导航请求失败时,从caches中读取shell.html返回,页面渲染完成后将dom挂载到<div id="app"></div>节点上。
    if (/* request error && */ req.mode === 'navigate') {
      return caches.match('/app-shell.html');
    }
    

# 缓存init request

  1. 拦截所有Server发送的页面必要的请求。(主流的api库都提供interceptor功能)
    const INIT_REQUESTS = []
    // data是接口返回的数据
    INIT_REQUESTS.push({
      path,
      resp: { status, statusText, data, headers }
    })
    
  2. 将请求结果序列化后放到返回的主文档中。
    <script>
    window.INIT_REQUESTS = JSON.stringify(INIT_REQUESTS)
    </script>
    
  3. Client反序列化后,发送给SW,SW重建Response缓存到cacehs中。
    cache.put(path, new Response(JSON.stringify(data), { headers, status, statusText }));
    

以上是示例代码,同构项目通常会使用状态管理工具(如Vuex)把数据从Server同步到Client,免去了手动序列化的过程,只需要在rootState中用一个key来存储接口数据

# 意外问题

以下记录一些大家可能会碰到的问题

# SW文件热更新几率性失效

// src要传全路径,传相对路径不会报错,但可能会导致热更新失效。跟webpack的缓存文件缓存有关系
workboxPlugin.InjectManifest({
  swSrc: swConfig.src,  
  swDest: 'service-worker.js',
  importWorkboxFrom: 'local',
})

# 不要拦截页面的所有Fetch请求

只关注需要缓存请求,避免出现意外情况。例如开发阶段用来热更新的接口(sockjs-node/932/vpeauunq/websocket)被拦截后,如果勾选了Update on reload刷新时会导致页面处于卡死状态。

# 无网或弱网环境及时中断请求

真机环境浏览器需要尝试很长的时间才会告诉你请求失败(连上wifi并不代表wifi可用,浏览器也不知道到底能不能正常访问网络),如果你的网页支持离线访问,建议设置超时时间提前中断请求,否则用户很可能还没看到你的离线页面就退出了。

Promise.race([
  fetch(req),
  new Promise((resolve, reject) =>
    setTimeout(() => reject(`fetch ${req.url || req} timeout`), FETCH_TIMEOUT)
  ),
]);

# preview面板可能展示与实际不符的内容

如果你将时间戳写入caches,在devtools Cache Storge 的 preview面板将展示错误的值,程序将读取到正确的值。bug地址 (opens new window)

💗 博主正处于裸辞待业状态,欢迎 商务合作 💗

相关文章

从 React 看前端 UI 代码范式革命

alt text 前言 本来打算写的主题是“我为什么讨厌 React Hooks API”,展开聊聊“小甜甜”是如何变成“牛夫人”的,没想到越写越严肃:) React 是两次前端范式革命的引领者,至今仍有繁荣的社区和旺盛的创造力; React 多次天才又激进的创新,一些想法被借鉴改良、一些引发广泛质疑,大部分是被认同和接受的; ...

WebCodecs 性能表现及优化思路

笔者开源 WebAV 已经一年半,还写了系列文章帮助初学者入门 Web 音视频。 之前一直隐隐担心在 Web 平台处理音视频与 Native APP 会有明显性能差距,因为 WebCodecs API 毕竟被浏览器代理了一层,且一些数据处理需要 js 配合,不确定有多大的性能损耗。 相信刚接触 WebCodecs 的读者也非常关心它的性能表现如何。 ...

Web 网页自集成 HTTP 代理方案

前言 大部分程序员,想必都有会一个常用的抓包代理工具; 但在座的各位,可曾见过这样一款集成在 Web 应用中的代理工具? 它是明显区别于传统代理工具的,有以下特性: 零安装,零配置,Web 点击即用、APP 扫码即用;_(不 ...

Web 文件系统(OPFS 及工具)介绍

文件系统是往往是构建大型软件的基石之一,很长一段时间 Web 平台因缺失成熟的文件系统成为构建大型软件的阻碍,如今 OPFS 可弥补这一缺憾。 本文介绍 OPFS 背景和基本使用方法、使用过程中的注意事项,及如何配合笔者开源的 opfs-tools、opfs-tools-explorer 两个项目,充分发挥 OPFS 的性能与开发效率。 Web 存储 A ...

opfs-tools (文件系统 API) 项目介绍

文件系统是许多领域程序的基石,所有通用编程语言都会内置完备的文件系统 API。 Web 很长一段时间没有提供完善的访问文件系统的规范,使得需要高频读写文件、大文件处理软件在 Web 端都会受到一些限制,比如音视频剪辑、游戏、数据库等等。 之前在浏览器中实现视频裁剪、截帧等相关功能时,发现缺少基本的操作文件的 API,比如读写、移动、复制文件。 而 [OPFS] ...

Web 终极拦截技巧(全是骚操作)

拦截的价值 > 计算机科学领域的任何问题都可以通过增加一个中间层来解决。 —— Butler Lampson 如果系统的控制权、代码完全被掌控,很容易添加中间层; 现实情况我们往往无法控制系统的所有环节,所以需要使用一些 “非常规”(拦截) 手段来增加中间层。 拦截的方法 拦截/覆写 浏览器 API 最常见的场景有通过拦截 console ...

Web 音视频(六)图像素材处理

Web 音视频目录 前序章节介绍了如何在浏览器中解析、创建视频,以及给视频添加一些自定义素材(图片、音频、文字...); 本章介绍如何给图像素材加特效、加动画,实现转场、移动水印、图像滤镜美化等功能。 你可以跳过原理介绍,直接查看 WebAV 示例 素材动画 在视频制作中实现动画跟其他场景略有不同,因为视频 ...