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

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

本文做为第一部分,以 PWA-Directory__ 为例。这是一个陈列 PWA 项目的站点,同时展现项目 Lighthouse 分数及其余页面性能数据。html

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


PWA-Directory

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

App Shell 模型

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


App Shell 模型

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

预缓存

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

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

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

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

对于后端模板的修改并不复杂,例如在 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 保证内容片断的缓存时间。


SW 请求代码片断流程图

总结一下这个思路:

  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.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 中做为一个独立模块 workbox-google-analytics__ 存在,能够很方便地使用:

// service-worker.js

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

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

离线用户体验

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


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

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


参考资料

Brilliant Open Web

BOW(Brillant Open Web)团队,是一个专门的Web技术建设小组,致力于推进 Open Web 技术的发展,让Web从新成为开发者的首选。

BOW 关注前端,关注Web;剖析技术、分享实践;谈谈学习,也聊聊管理。

关注 OpenWeb开发者,点击“加群”,让咱们一块儿推进 OpenWeb技术的发展!

相关文章
相关标签/搜索