Case Study: PWA-Directory
本系列文章将以两个实际项目作为研究对象,探讨离线可用这个 PWA 的重要特性在 SSR 架构中的应用思路,最后结合 Vue SSR 进行实际应用。
- 本文是第一部分,以PWA-Directory为例。
- 第二部分将研究一个 WordPress 主题项目。
- 第三部分将使用 Vue SSR 实践这一思路。
PWA-Directory 是一个陈列 PWA 的站点,同时展示项目 Lighthouse 分数及其他页面性能数据。
本文假设读者对 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)。这个在实际请求之前进行缓存的过程就是预缓存。
在 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
保证内容片段的缓存时间。
总结一下这个思路:
- 改造后端模板以支持返回完整页面和内容片段
- 服务端增加一条针对 App Shell 的路由规则,返回仅包含 App Shell 的 HTML 页面
- 预缓存 App Shell 页面
- SW 拦截所有 HTML 请求,统一返回缓存的 App Shell 页面
- 前端路由负责代码片段的填充,完成前端渲染
实际效果是,用户第一次访问应用站点时,首屏由服务端渲染,随后 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.json
的start_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 主题项目。