Case Study: PWA-Directory

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

PWA-Directory 是一个陈列 PWA 的站点,同时展示项目 Lighthouse 分数及其他页面性能数据。

PWA-Directory

PWA-Directory

本文假设读者对 PWA 相关技术尤其是 Service Worker 的基础知识已有一定了解。

App Shell 模型

App Shell 是支持用户界面所需的最小的 HTML、CSS 和 JavaScript。对其进行离线缓存,可确保在用户重复访问时提供即时、可靠的良好性能。这意味着并不是每次用户访问时都要从网络加载 App Shell。 只需要从网络中加载必要的内容。

App Shell 模型

App Shell 模型

PWA-Directory 包括我们后续的讨论都基于 App Shell 模型。下面我们需要了解一下缓存的细节。

预缓存

Service Worker 最重要的功能便是控制缓存。这里先简单介绍下预缓存或者说 sw-precache 插件的基本工作原理。

在项目构建阶段,将静态资源列表(数组形式)及本次构建版本号注入 Service Worker 代码中。在 SW 运行时(Install 阶段)依次发送请求获取静态资源列表中的资源(JS,CSS,HTML,IMG,FONT…),成功后放入缓存并进入下一阶段(Activated)。这个在实际请求之前进行缓存的过程就是预缓存。

在 SPA/MPA 架构的应用中,App Shell 通常包含在 HTML 页面中,连同页面一并被预缓存,保证了离线可访问。但是在 SSR 架构场景下,情况就不一样了。所有页面首屏均是服务端渲染,预缓存的页面不再是有限且固定的。如果预缓存全部页面,SW 需要发送大量请求不说,每个页面都包含的 App Shell 部分都被重复缓存,也造成了缓存空间的浪费。

既然针对全部页面的预缓存行不通,我们能不能将 App Shell 剥离出来,单独缓存仅包含这个空壳的页面呢?要实现这一点,就需要对后端模板进行修改,通过传入参数控制返回包含 App Shell 的完整页面 OR 代码片段。这样首屏使用完整页面,而后续页面切换交给前端路由完成,请求代码片段进行填充。这也是基于 React、Vue 等技术实现的同构项目的基本思路。

对于模板的修改并不复杂,例如在 PWA-Directory 中,使用 Handlebars 作为后端模板,通过自定义的 contentOnly 参数就能适应首屏和后续 HTML 片段两种请求。

// list.hbs

{{#unless contentOnly}}
<!DOCTYPE html>
<html lang="en">
  <head>
    {{> head}}
  </head>
  <body>
    {{> header}}
    <div class="page-holder">
      <main class="page">
{{/unless}}
... 页面具体内容
{{#unless contentOnly}}
      </main>
      <div class='page-loader'>
      </div>
    </div>
    {{> footer}}
  </body>
</html>
{{/unless}}

然后在 SW 中我们需要对 App Shell 页面和 Offline 页面进行预缓存,这里使用了 sw-toolbox 。同时后端需要增加返回 App Shell 的路由规则,这里是/.app/shell

// service-worker.js

const SHELL_URL = '/.app/shell';
const ASSETS = [
   SHELL_URL,
   '/favicons/android-chrome-72x72.png',
   '/manifest.json',
   ...
];
// 使用 sw-toolbox 缓存静态资源
toolbox.precache(ASSETS);

最后我们拦截掉所有 HTML 请求,请求目标页面的内容片段而非完整代码(getContentOnlyUrl 执行了 contentOnly 参数拼接工作),返回之前缓存的 App Shell 页面。

// service-worker.js

toolbox.router.default = (request, values, options) => {
    // 拦截 HTML 请求
    if (request.mode === 'navigate') {
        // 请求代码片段
        toolbox.cacheFirst(new Request(getContentOnlyUrl(request.url)), values, options);
        // 返回 App Shell 页面
        return getFromCache(SHELL_URL)
            .then(response => response || gulliverHandler(request, values, options));
    }
    return gulliverHandler(request, values, options);
};

有一点值得注意,通常请求目标页面内容片段是放在前端路由中完成的,而这里放在了 SW 中,有什么好处呢?这一点 PWA-Directory 开发者有一篇文章进行了专门讨论,这里就直接使用文中的图片进行说明了。 先看看之前的做法,也就是在前端路由中:

前端路由请求代码片段流程图

前端路由请求代码片段流程图

可以看出,app.js加载并执行时才会发出 HTML 代码片段请求,然后等待服务端响应。整个过程中 SW 处于空闲状态,而事实上第一次拦截到 HTML 请求时,SW 就完全可以先请求代码片段了(拼上参数),拿到响应后放入缓存中。这样当app.js前端路由执行发出请求时,浏览器发现已经在缓存中,就可以直接使用。当然为了实现这一点,需要在服务端通过设置响应头Cache-Control: max-age保证内容片段的缓存时间。

SW 请求代码片段流程图

SW 请求代码片段流程图

总结一下这个思路:

  1. 改造后端模板以支持返回完整页面和内容片段
  2. 服务端增加一条针对 App Shell 的路由规则,返回仅包含 App Shell 的 HTML 页面
  3. 预缓存 App Shell 页面
  4. SW 拦截所有 HTML 请求,统一返回缓存的 App Shell 页面
  5. 前端路由负责代码片段的填充,完成前端渲染

实际效果是,用户第一次访问应用站点时,首屏由服务端渲染,随后 SW 安装成功后,后续的路由切换包括刷新页面都将由前端渲染完成,服务端将只负责提供 HTML 代码片段的响应。

解决了预缓存问题,下面我们需要关注另外一个离线可用目标中涉及的关键问题。

数据统计

在衡量 PWA 效果时,至少有以下几个指标可以考量:

通过beforeinstallprompt事件,可以轻易获取用户对添加到桌面 banner 的反应:

window.addEventListener('beforeinstallprompt', e => {
    console.log(e.platforms); // e.g., ["web", "android", "windows"]
    e.userChoice.then(outcome => {
        console.log(outcome); // either "installed", "dismissed", etc.
    }, handleError);
});

通过在manifest.jsonstart_url中添加参数,很容易标识出当前的用户访问来自添加后的桌面快捷方式。例如使用GA Custom campaigns

// manifest.json

{
    "start_url": "/?utm_source=homescreen"
}

判断当前是否处于离线状态,navigator.onLine可以实现。但是要注意,返回true时不代表真的可以访问互联网。

现在我们有了这些统计指标,接下来的问题就是如何保证离线状态下产生的统计数据不丢失。一个很自然的想法是,在 SW 中拦截所有统计请求,离线时将统计数据存储在本地 LocalStorage 或者 IndexedDB 中,上线后再进行数据的同步。

Google 之前针对 GA 开发了 sw-offline-google-analytics 类库实现了这一功能,现在已经移到了 Workbox 相关模块中,可以很方便地使用:

// service-worker.js

importScripts('path/to/offline-google-analytics-import.js');
workbox.googleAnalytics.initialize();

这样离线统计的问题就解决了。以上部分代码以 GA 为例,不过其他统计脚本思路也是一致的。

离线体验

最后说说这个项目在离线用户体验上的亮点。PWA 中的离线用户体验绝不仅仅只是展示离线页面代替浏览器“恐龙”而已。离线时,“我究竟能使用哪些功能?”往往是用户最关心的。让我们来看看 PWA-Directory 在这一点上是怎么做的。

离线体验 离线体验

在离线时,可以弹出 Toast(图中下方红色部分)给予用户提示。要实现这一点并不难,通过监听online/offline事件就能做到,接下来才是亮点。

前面说过,离线时用户很关心能访问哪些内容,如果能通过样式显式标注就再好不过了。在上图中,我访问过第一个 Tab “New” 下列表中的第一个项目,所以此时离线时,页面中其余部分都被置灰且不可点击,只有缓存过的内容被保留了下来,用户将不再有四处点击遇到同样离线页面的挫败感。

要实现这一点可以从两方面入手,首先从全局样式上,离线时给body或者具体页面容器加个自定义属性,关心离线功能的组件在这个规则下定义自己的离线样式就行了。

// 监听 offline
window.addEventListener('offline', () => {
    // 给容器加上自定义属性
    document.body.setAttribute('offline', 'true');
});

另外具体到某些特定组件,例如这个项目中的列表项,点击每个 PWA 项目的链接都将进入对应的详情页,首次访问会被加入 runtimeCache,因此只需要在缓存中按链接地址进行查询,就能知道这个列表项是否应该置灰。

// 判断链接是否访问过
isAvailable(href) {
    if (!href || this.window.navigator.onLine) return Promise.resolve(true);
    return caches.match(href)
        .then(response => response.status === 200)
        .catch(() => false);
}

总之,离线用户体验是需要根据实际项目情况进行精心设计的。

总结

从 PWA 特性尤其是离线缓存来看,对于 SSR 架构的项目,进行 App Shell 的分离是很有必要的。相比 SPA/MPA 的预缓存方案,SSR 需要对后端模板,前端路由进行一些改造。另外,对于 PWA 相关数据的统计和离线同步,可以借鉴应用 Google 的 Workbox 方案。最后,离线用户体验也是需要仔细考量的。

如果感兴趣,可以深入了解一下 PWA-Directory 的代码,同时结合开发者的几篇技术文章:

下面我们将继续研究一个 WordPress 主题项目。

参考资料