如何在SSR架构中实现离线可用?(一)

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

作者:潘与其

前言

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

本文作为第一部分,以 PWA-Directory 为例。这是一个陈列 PWA 项目的站点,同时展示项目 Lighthouse 分数及其他页面性能数据。

在下一篇文章中,我们将顺着这一思路,结合 Vue SSR,在项目中进行实际应用(关注OpenWeb开发者,及时获取文章)。

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

App Shell 模型

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

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

预缓存

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

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

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

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

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

// 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 页面进行预缓存,这里使用了 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') {
       // 请求 HTML 代码片段
       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 保证内容片段的缓存时间。

总结一下这个思路:

  1. 改造后端模板以支持返回完整页面和内容片段

  2. 服务端增加一条针对 App Shell 的路由规则,返回仅包含 App Shell 的 HTML 页面

  3. 预缓存 App Shell 页面

  4. SW 拦截所有 HTML 请求,统一返回缓存的 App Shell 页面

  5. 前端路由负责代码片段的填充,完成前端渲染

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

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

数据统计

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

  • 当弹出添加到桌面的 banner 时,用户选择同意或是拒绝

  • 当前的操作是否是来自添加到桌面之后

  • 当前的操作是否发生在离线状态下

通过 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.jsonstarturl 中添加参数,很容易标识出当前的用户访问来自添加后的桌面快捷方式。例如使用 GA Custom campaigns_:

// manifest.json

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

最后,使用 navigator.onLine__ 就能够判断当前是否处于离线状态。但是要注意,返回 true 时不代表真的可以访问互联网。

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

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

// service-worker.js

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

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

离线用户体验

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

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

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

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

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 的代码__,同时结合开发者的几篇技术文章:

  • 结合 App Shell 优化内容加载速度__

  • PWA-Directory 的设计思路__

在下一 Part 中,我们将使用 Vue SSR 结合 Workbox 在项目中实践这一思路:)。

参考资料