实践:Vue + Workbox

本系列文章将以两个实际项目作为研究对象,探讨离线可用这个 PWA 的重要特性在 SSR 架构中的应用思路,最后结合 Vue SSR 进行实际应用。

思路回顾

我们之前参考 PWA-Directory 的实现,提供了一种 SSR 架构下通用的离线缓存思路:

  1. 改造后端模板以支持返回完整页面和内容片段( contentOnly )
  2. 服务端增加一条针对 App Shell 的路由规则,返回仅包含 App Shell 的 HTML 页面( shell.html )
  3. 预缓存 App Shell 页面
  4. Service Worker 拦截所有 HTML 请求,统一返回缓存的 App Shell 页面。同时向服务端请求当前页面需要的内容片段并写入缓存
  5. 前端路由( app.js )向服务端请求内容片段,发现缓存中已存在,将其填充进 App Shell 中,完成前端渲染

SW 请求代码片段流程图

SW 请求代码片段流程图

将这一思路应用到 Vue SSR 项目中时,需要做一些改变,另外有些步骤框架已经解决,也不需要我们再实现。

后端模板和代码片段

首先在 Vue 同构项目中,同一套代码在 Node 端和浏览器端都能运行,是没有“后端模板”的概念的,同样对于“内容片段”也是如此。在首屏使用服务端渲染直出 HTML 后,浏览器端进行 Hydrate(混合),绑定事件使页面真正可响应,除非刷新页面,否则后续的路由切换都将由前端路由器完成。

Vue SSR

Vue SSR

得益于 Webpack 的Code Splitting和 Vue 的异步组件,我们能够实现路由组件的按需加载

// router.js

// 定义切割点
const Home = () => import('./Home.vue');
const router = new VueRouter({
    routes: [
        {
            path: '/home',
            component: Home // 使用异步组件
        }
    ]
});

在 Vue SSR 项目中的实际效果就是首屏由服务端渲染,随后当路由切换时,客户端会请求对应的路由组件代码。所以之前思路中的第5步“前端路由向服务端请求内容片段渲染内容”实际上已经完成了,对于开发者并没有额外的工作要做。

当然,在切换路由时才去请求路由组件代码显然并不够好,因为这样需要等待下载并解析完成,路由才能完成切换。常见的做法是将这些 async chunk 放在<link rel="preload">中指示浏览器预取。这样当用户切换路由时,对应的组件代码已经在缓存中,不需要再发送请求。实际上 vue-ssr-renderer 在生成最终 HTML 时也是这么做的

由于我们使用了 ServiceWorker,在构建时同样会把这些分割后的路由组件代码加入预缓存列表,在安装阶段会请求并缓存。那么问题来了,同时使用<link rel="preload">和 ServiceWorker 时,同样的资源会被请求两次吗?

根据 Google 的Preload, Prefetch And Priorities in Chrome这篇文章,在 Chrome 中存在四种缓存:HTTP cache、memory cache、Service Worker cache 和 Push cache,而 preload 资源存储在 HTTP cache 中。

…the resource won’t be refetched from the network unless it has expired from the HTTP cache or the Service Worker intentionally refetches it.

所以不用担心,另外文章中也列举了一些会造成重复请求问题的场景,感兴趣可以深入阅读一下。

缓存 App Shell 页面

已经解决了路由和代码片段的问题,也就是思路中的第1、5步。下面我们来看第2、3步, 即如何获得仅包含 App Shell 的 HTML 页面供 ServiceWorker 缓存。

在使用 vue-router 时,典型的页面模板结构如下:

// App.vue

<template>
    <div id="app">
        <app-header></app-header>
        <router-view></router-view>
        <app-footer></app-footer>
        <sidebar></sidebar>
        <loading></loading>
    </div>
</template>

这就是一个典型的基于 App Shell 模型的应用,其中<router-view>作为一个动态组件,会将匹配到的路由组件渲染在这里。很自然的想到,要想获得“仅包含 App Shell 的 HTML 页面”,只要渲染一个模板为空的路由组件就行了:

// AppShell.vue

<template>
// 空的模板
</template>
<script>
export default {
    metaInfo: {
        title: 'MySite',
        meta: []
    }
};
</script>

配置好路由对象,就可通过类似/appshell这样的路由路径访问了。当然,这个路由用户是不会访问的,随后我们会把/appshell加入 ServiceWorker 的预缓存列表,让它和其他静态资源一样,在安装阶段被请求并缓存。

使用 Workbox 管理预缓存

这里简单介绍一下 ServiceWorker 的预缓存功能:

可见要实现这部分功能,在构建阶段的注入,缓存资源的版本控制都需要编写代码实现。这里必须要介绍一下Workbox,这是 Google 开发的工具集,能够方便地集成到 Webpack、Gulp 等构建流程中,帮助开发者生成或者注入部分 ServiceWorker 相关代码,实现了很多通用功能供开发者使用。管理预缓存正是其中之一。

以 Webpack 为例,配合 workbox-webpack-plugin,我们只需要定义一行注入点,剩下的只需要通过配置指示插件注入资源列表就行了:

// service-worker.js
importScripts('/node_modules/workbox-sw/build/workbox-sw.vX.X.X.prod.js');

const workboxSW = new WorkboxSW();
// 供 workbox-webpack-plugin 使用的注入点
workboxSW.precache([]);

让我们来看下关键的配置,我们通过templatedUrls/appshell也加入预缓存列表中:

// 传入 workbox-webpack-plugin 的配置
{
    swSrc: 'service-worker.js', // 包含了注入点的 service-worker.js
    swDest: 'service-worker.js', // 目标路径
    globDirectory: 'dist', // 静态资源文件夹
    globPatterns: [ // 匹配合适的静态资源
        '**/*.{html,js,css,eot,svg,ttf,woff}'
    ],
    templatedUrls: [
        '/appshell': [...] // 依赖的文件列表 or 具体版本字符串
    ],
    dontCacheBustUrlsMatching: /\.\w{8}\./
}

根据这样的配置,插件会向最终生成的 ServiceWorker 中注入预缓存列表。默认情况下,插件会根据列表中的资源内容生成一个版本号revision。但是使用 Webpack 构建时,通常都会使用output.path指定静态资源的文件名格式,例如[name].[hash:8].js,实际上[hash]的作用和revision一样,就没必要让 Workbox 再为这些文件生成版本号了。通过配置中的dontCacheBustUrlsMatching,我们指示插件把这些文件的文件名作为版本号。至于/appshell这样的非静态资源的版本号是如何生成的,我们将在最后一节“缓存资源更新问题”中介绍。

// service-worker.js
workboxSW.precache([
  {
    "url": "/static/css/main.bb31e95c.css"
  },
  {
    "url": "/static/js/main.dd298875.js"
  },
  //...
  {
    "url": "/appshell",
    "revision": "7a63fa62f1370a8752bd29f4f88b7104"
  }
]);

在这个预缓存场景中应该能体会到 Workbox 带来的便利,下面我们还会使用到 Workbox 帮助拦截请求。

拦截 HTML 请求

现在 ServiceWorker 已经能够请求/appshell并缓存 App Shell 页面了,下面我们需要实现思路中的第4步,即 让 ServiceWorker 拦截 HTML 请求并返回之前已经缓存的 App Shell 页面。

拦截请求作为 ServiceWorker 的重要功能,通过在fetch事件中判断请求类型,如果是 HTML 类型就读取缓存并返回。我们当然可以自己编写这段逻辑,实际上也不麻烦,但是 Workbox 提供了功能更加强大的 API:

// service-worker.js
workboxSW.router.registerNavigationRoute('/appshell');

这样当 ServiceWorker 安装完成后,每次用户发起 HTML 请求,例如刷新当前页面,都不会到达服务端,而是由 ServiceWorker 返回缓存的 App Shell 页面,然后交给前端代码渲染具体内容。

前端渲染

在前端渲染时,有一个问题很重要,那就是获取数据。

在通常的 Vue SSR 场景中,由于首屏是服务端渲染的,所需的数据请求自然也是在服务端发送,成功后在渲染时通过向 HTML 模板写入window.INITIAL_STATE的方式将状态同步给客户端。因此到了客户端进行 Hydrate(客户端混合) 时,不需要重复请求数据,只需要根据当前状态渲染页面组件。要实现这一点,通常在客户端页面入口中,我们会在路由 ready 之后也就是router.onReady回调中才挂载包含数据请求的钩子,这样只有在后续前端路由切换时才会发送请求。

// entry-client.js
app = new App();
router.onReady(() => {
    handleAsyncData();
    app.$mount('#app');
});

在通常的 Vue SSR 项目里这样做是没有问题的,而一旦页面请求被 ServiceWorker 拦截,并返回 App Shell 页面,情况就变得不一样了。由于此时整个应用的状态停留在初始状态,数据请求是需要客户端页面入口来发送的。这就要求客户端页面入口需要在运行时知道当前页面是否是 App Shell。

一个很自然的想法是在渲染的 App Shell 页面中加入标记,在 Vue SSR 项目中我们常常使用 Vue-meta 设置页面标题和meta标签,这里借助它也很容易实现在body上加上标记属性:

// AppShell.vue

<template>
</template>
<script>
export default {
    metaInfo: {
        title: 'MySite',
        meta: [],
        bodyAttrs: {
            'appshell': undefined
        }
    }
};
</script>

这样在客户端入口运行时,如果检测到此时页面是服务端直出的(第一次访问站点,此时 ServiceWorker 未安装),就不发送数据请求,而如果发现此时是 ServiceWorker 拦截后返回的,就发送请求。

// entry-client.js
let usingAppshell = document.body.hasAttribute('appshell');
if (usingAppshell) {
    handleAsyncData();
    app = new App().$mount('#app');
}
else {
    app = new App();
    router.onReady(() => {
        handleAsyncData();
        app.$mount('#app');
    });
}

至此,基于 Vue SSR 和 Workbox,我们已经实现了开始的思路,总结一下当前的效果是:

最后我们来关注一下缓存的更新问题。

缓存资源更新问题

我们的站点不可能一成不变,当 ServiceWorker 检测到代码发生更新时,需要引导用户手动刷新当前页面来使用最新的资源。这里不会介绍如何保证 ServiceWorker 本身是最新的,不被缓存,感兴趣的可以阅读这篇文章。下面的讨论将基于 ServiceWorker 不被缓存的前提。

由于缓存中的 App Shell 页面包含了对静态资源的引用,这意味着当这些静态资源发生了修改,/appshell对应的版本号也需要改变,这样 ServiceWorker 才会请求新的 App Shell 并缓存。

这里需要一下介绍/appshell的版本号是如何生成的。Workbox 提供了templatedUrls配置项为这类非静态资源生成版本号,接受两类值:

// workbox-webpack-plugin 配置对象
{
    // 省略其他配置
    templatedUrls: [
        '/appshell': [...] // 依赖的文件列表 or 具体版本字符串
    ]
}

现在我们解决了 App Shell 的更新问题,剩下的就是给予用户视觉反馈,提示手动刷新页面。这里给出一种参考实现,在安装完成,也就是全部资源的预缓存完成之后触发一个自定义事件,在 UI 组件中监听这个事件展现提示信息就行了。

navigator.serviceWorker.register('/service-worker.js').then(function(reg) {
    reg.onupdatefound = function() {
        var installingWorker = reg.installing;
        installingWorker.onstatechange = function() {
            switch (installingWorker.state) {
                case 'installed':
                    if (navigator.serviceWorker.controller) {
                        var event = document.createEvent('Event');
                        event.initEvent('sw.update', true, true);
                        window.dispatchEvent(event);
                    }
                    break;
            }
        };
    };

以上就是使用 Vue SSR 和 Workbox 实现离线可用的实践,其他细节可以参考这个简单的demo

参考资料