在 PWA 中提升性能和用户感知体验

在构建 PWA 应用时,使用 App Shell 模型能够在视觉和首屏加载速度方面带来用户体验的提升。另外,在配合 Service Worker 离线缓存之后,用户在后续访问中将得到快速可靠的浏览体验。 在实践过程中,借助流行框架与构建工具提供的众多特性,我们能够在项目中便捷地实现 App Shell 模型及其缓存方案。最后,在常见的 SPA 项目中,我们试图使用 Skeleton 方案进一步提升用户的感知体验。

App Shell 模型

相比 Native App,PWA 有以下优势:

我们都很熟悉 Native App 中常见的 Shell 展示效果,通常快速加载应用的简单 UI (顶部导航条,侧边栏,Loading 动画等)并缓存,后续访问甚至是离线状态仍能立即展示,而页面实际内容动态加载。PWA 在保持以上优势的基础上,也可以借鉴这一方案以提升性能和用户感知体验,这就是 App Shell 模型。

App Shell 模型

App Shell 模型

我们对于 PWA 中的 App Shell 模型的大致总结:

那么在具体项目中应该如何应用这一模型呢?或者说,对于已有项目的改造成本有多大呢?

我们熟悉的 Web 项目的架构大致如下:

所以两者结合可以得到最好的效果,首屏由 SSR 渲染,后续由 CSR 动态渲染页面中部分内容,类似 SPA 的效果。 借助构建工具例如 Webpack 和前端框架(React Vue)提供的服务端渲染特性,同一套代码在编译后可以同时运行在双端,这就是 Universal/Isomorphic 同构应用的思想。

在上述架构下都可以应用 App Shell 模型。首先我们来看在 SPA 中的应用。

SPA 中的应用

SPA 中的内容全部由 JS 在前端渲染。为了实现 App Shell 的特性,在具体实现或者对于已有项目的改造时,我们可以应用 PRPL 模式。

PRPL 模式

PRPL 模式是 Google 提出的,包含以下特性:

前面说过,App Shell 在内容上是由 HTML CSS 和 JS 组成的资源集合。为了保证这些资源的加载速度,必须精简。 在这一思路下,它将包含:

为了实现全部或者部分特性,我们需要依赖以下技术:

所幸现有的很多优秀工具和框架已经能帮助解决大部分问题,下面我们来看具体实现。

代码分割

为了保证 App Shell 包含资源的精简,需要将初始路由内容与后续路由内容分开。 在编译时需要构建工具进行分割打包操作。在编写代码时,有两种做法:

对于第一种做法,我们以 Polymer 为例。由于使用了 HTML imports,需要分割的代码天然就是物理分割,包含在各自 HTML 中的。 首先让我们看一下 Polymer SPA 的项目结构:

Polymer 项目结构

Polymer 项目结构

entrypoint 即项目的入口文件,应该足够精简,仅包含特性检测之后引入的 polyfill,App Shell。 App Shell 包含了前端路由,全局的导航 UI 等等,以及需要实现动态加载 fragment 的逻辑。 fragment 类似异步路由组件。

在构建时,配套的构建工具会读取自身的配置文件 polymer.json,其中显式指明了这三部分内容:

{
    "entrypoint": "index.html",
    "shell": "src/my-app.html",
    "fragments": [
        "src/my-view1.html",
        "src/my-view2.html",
        "src/my-view3.html",
        "src/my-view404.html"
    ]
}

而对于第二种做法,最熟悉的例子就是 Webpack 了。 引入 babel-plugin-syntax-dynamic-import 插件,开发者就可以使用 dynamic-import 语法:

import(/* webpackChunkName: "my-view1" */ './my-view1')
    .then((myView1) => {
        //...
    });

现在我们已经将初始路由内容与后续路由内容分开了,渲染内容将由路由负责。

路由支持

除了负责初始路由的渲染,还需要支持后续动态加载并添加剩余路由。

Polymer 提供了异步引入的 API,供配套的路由使用。 这样就能实现异步加载,并在出错时跳转到 404 页面:

var resolvedPageUrl = this.resolveUrl('my-view1.html');
this.importHref(resolvedPageUrl,
    null,
    this._importFailedCallback,
    true
);

而在 Vue 中,由于框架本身就支持异步组件,在 vue-router 中很容易实现路由的懒加载:

import Index from './Index.vue';
const MyView1 = () => import('./MyView1.vue');
const router = new VueRouter({
    routes: [
        { path: '/', component: Index }
        { path: '/my-view1', component: MyView1 }
    ]
});

React 也是一样:

import Loadable from 'react-loadable';
import Loading from './Loading';

const LoadableComponent = Loadable({
    loader: () => import('./MyView1.jsx'),
    loading: Loading,
})

这样结合之前的代码分割,我们就完成了初始路由的渲染,以及后续剩余路由的按需加载。

Service Worker 预缓存

虽然说实现了路由内容的按需加载,但毕竟要等到实际路由切换时才会请求相应代码并执行。 如果能提前告知浏览器预取这部分资源,就可以提前完成掉网络开销。

首先能想到的一个方案是 <prefetch>,浏览器在空闲时会去请求这些资源放入 HTTP 缓存:

<link rel="prefetch" href="image.png">

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

预缓存 App Shell 包含的 HTML JS 和 CSS,以及懒加载需要的路由 JS。

var cacheName = 'app-shell';
var filesToCache = [
    '/index.html’,
    '/js/main.js',
    '/js/my-view1.js',
    '/js/my-view2.js',
    '/css/main.css'
];

self.addEventListener('install', function(e) {
    e.waitUntil(
        caches.open(cacheName).then(function(cache) {
            return cache.addAll(filesToCache);
        })
    );
});

借助 Workbox 提供的命令行工具以及构建工具配套的插件,开发者能轻松地通过配置生成预缓存列表甚至是整个 Service Worker 文件,缓存的更新交给 Workbox 完成。除了预缓存,Workbox 还提供了一系列 API 帮助开发者管理动态缓存,使用默认离线页面等等。

importScripts('./workbox-sw.prod.js');
importScripts('./precache-manifest.js');

workbox.skipWaiting();
workbox.clientsClaim();

workbox.precaching.precacheAndRoute(self.__precacheManifest);

推送关键资源

我们知道 HTTP/2 中,服务端在返回 HTML 的同时,可以向浏览器推送所需的静态资源,这样在浏览器解析 HTML 遇到相应的资源时,它们已经在 HTTP 缓存中了。所以针对这一特性,过去打包所有静态资源以减少网络请求数的考量就没有必要了,反而拆分成多个 bundle 更有利于不同页面间共享的缓存。

例如 twitter 的 mobile 站点,注意下载 HTML 和首屏需要的 JS 几乎是同时进行的:

twitter HTTP/2

twitter HTTP/2

但是对于不支持 HTTP/2 的浏览器,还有 <preload> 这种方式,考虑兼容性两者可以同时使用。

twitter preload

twitter preload

在 Polymer 中,可以在 polymer.json 中配置不同的打包策略:

"builds": [
    {
       "preset": "es5-bundled"
    },
    {
       "preset": "es6-bundled"
    },
    {
       "preset": "es6-unbundled"
    }
]

Polymer 打包策略

Polymer 打包策略

SSR 中的应用

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

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

通用思路

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

传统后端模版项目

以传统的后端模版项目为例,在不支持 Service Worker 的情况下,根据 URL 使用默认 Layout + 对应视图模版响应。 如果支持 Service Worker

后端模版改造

后端模版改造

同构项目

对于采用同构模式的 React Vue 项目,只需要加上 /appshell 路由。

App Shell 性能

另外值得一提的是,除了后续路由,其他不需要出现在初始 bundle 中的模块(例如消息通知,一些 SDK 代码等等)也可以进行懒加载,这样可以大幅减少初始路由内容的大小。

我们以 Vue hackernews 2.0 这个同构项目为例,在没有使用代码分割的情况下,所有的业务逻辑全在 app.js 中。

                       Asset       Size  Chunks                    Chunk Names   vendor.46d9cce6b27c5475b6ce.js     338 kB       0  [emitted]  [big]  vendor
 app.1a5e83f4accca4450b49.js     253 kB       1  [emitted]  [big]  app manifest.54e67e9f17df71efc259.js  804 bytes       2  [emitted]         manifest  common.1a5e83f4accca4450b49.css    5.59 kB       1  [emitted]         app

在 3G 环境下,首屏加载时间约为 2.9s

原始状态

原始状态

使用代码分割后,首屏不需要的业务逻辑从 app.js 中移动到了异步加载文件中。

                       Asset     Size  Chunks                    Chunk Names
   0.81cb3a55e9127846bdf2.js  6.89 kB       0  [emitted]
   1.49213d00f0a6085fcf87.js   241 kB       1  [emitted]
   2.7c9d08f57b816c494d40.js  1.79 kB       2  [emitted]   vendor.a906363048c601379d00.js   338 kB       3  [emitted]  [big]  vendor
 app.ded3229099da620999f3.js   9.8 kB       4  [emitted]         app manifest.c7c3afdf0ac4db474116.js  1.45 kB       5  [emitted]         manifest  common.ded3229099da620999f3.css  1.37 kB       4  [emitted]         app

首屏加载时间约为 1.2s

路由级别的 Code Splitting

路由级别的 Code Splitting

使用 Service Worker 预缓存之后,再次访问速度极快 0.2s

使用 Service Worker 后,再次访问

使用 Service Worker 后,再次访问

还有优化空间?

Skeleton 方案

在 SPA 中,在实际内容由 JS 渲染完成之前,会存在一段白屏时间。参考 Native App 中的通常做法,可以展示 Skeleton 骨架屏,相比一个简单的 Loading 动画,更能让用户感觉内容就快加载出来了。但需要注意在本质上,这和 Loading 是没有区别的,也并不能减少白屏时间,仅仅是提高了一些用户的感知体验。

下面我们将从生成方式,不同路由间的差异性问题以及优化展现速度这三方面展开。

生成方式

从骨架屏包含的内容上看,与 Loading 一样,都是由内联在 HTML 中的样式和 DOM 结构片段组成。 我们希望在构建阶段自动将这些内容注入 HTML 中,在生成方式上有两种:

首先来看第一种,Skeleton 也可以视为一种组件,在编写时与其他组件开发体验一致。但不同于其他组件在运行时前端渲染,Skeleton 组件需要在构建时,也就是 Node.js 环境中渲染。借助框架的 SSR 方案,我们很容易配合构建工具实现。

插件大致实现如下:

  1. 在 Webpack 当前编译环境中创建一个 childCompiler,继承编译上下文。这样可以保证 Skeleton 组件和项目其他组件使用同样的配置编译,例如 loaders。
  2. 使用框架提供的 SSR 方案渲染 Skeleton 组件,得到对应的 HTML 片段
  3. 使用插件分离样式,得到 CSS
  4. 注入 HTML 中

这种方案存在两个问题:

  1. 由于依赖框架的 SSR 方案,针对不同的框架需要开发不同的插件。目前我开发了 vue-skeleton-webpack-pluginreact-skeleton-webpack-plugin
  2. 需要手动编写 Skeleton 组件。

而在第二种方案中,不需要开发者编写额外的 Skeleton 组件,既然骨架屏是要反映页面内容的大致框架,完全可以在真实页面基础上,将内容替换成占位元素得到最终效果。Eleme 团队的 page-skeleton-webpack-plugin 就是这样一款优秀的插件。

插件大致实现如下:

  1. 使用 puppeteer 提供的 API 在 Node.js 环境中运行 headless Chrome
  2. 打开需要生成 Skeleton 的页面
  3. 注入样式,将不同的元素替换成占位符
  4. 获取页面样式和 HTML 片段
  5. 注入 HTML 中

根据路由展示

以上两种生成方式都会面临同样的一个问题,那就是 SPA 中如果只生成一份 Skeleton,如何能保证匹配不同的路由页面呢? 在试图用一个 Skeleton 匹配多个差别极大的路由页面时,往往就退化成了 Loading 方案。

所以我们可以在构建时,为几个重要的路由页面生成各自的骨架屏,在 HTML 中注入一小段 JS,根据当前路由路径控制展示某一个。 大致思路如下:

<div id="skeleton1" style="display:none">...</div>
<div id="skeleton2" style="display:none">...</div>
<script>
    // 根据路由展示对应 skeleton
</script>

优化展现速度

虽然说 Skeleton 本身并不能减少 First meaningful page 也就是真实页面内容出现的时间,但是本身的展示也存在白屏时间。

让我们先来看一下时间线,打开 Chrome Devtool 中性能面板:

不难发现,在 HTML 下载完毕之后,浏览器仍然需要等待样式(index.css)下载完毕才开始渲染骨架屏。 这是由于浏览器构建渲染树需要 DOM 和 CSSOM,因此 HTML 和 CSS 都是会阻塞渲染的资源。这在大部分场景下都是合情合理的,毕竟让用户看到内容在样式加载前后闪烁(FOUC)是需要避免的。

但是骨架屏所需的样式已经内联在 HTML 中,供前端渲染内容使用的 CSS 显然不应该阻塞骨架屏的渲染。有没有办法改变这个特性呢?

首先想到的办法是,将 <link><head> 中挪到 <body> 中,HTML 规范允许这样做:

A <link> tag can occur either in the head element or in the body element (or both), depending on whether it has a link type that is body-ok. For example, the stylesheet link type is body-ok, and therefore a <link rel="stylesheet"> is permitted in the body.

这样 CSS 只会阻塞后续内容,骨架屏可以不受影响地被渲染。

<head>
    <style>Skeleton CSS</style>
</head>
<body>
    <div>Skeleton DOM</div>
    <link rel='stylesheet' href='index.css'>
    <div id='app'>...</div>
</body>

但是在 Chrome 中测试后会发现 CSS 依然阻塞渲染,在 Chrome 的关于这个问题的一个讨论中,能看到由于针对这种情况的渲染策略并没有严格的规范,不同浏览器下出现了不同的表现:

loadCSS 为异步加载样式表提供了两种方式:

  1. <link ref='preload'>
  2. 提供全局 loadCSS 方法,动态加载指定样式表 我们将使用第一种方法,也是 loadCSS 推荐的方式。

<link ref='preload'> 让浏览器仅仅请求下载样式表,但完成后并不会应用样式,也就不会阻塞浏览器渲染了。如果想在下载完成后应用样式,可以在 onload 回调函数中修改 rel 的值为 stylesheet,像正常阻塞样式表一样应用。 另外,由于浏览器支持度问题,loadCSS 提供了 polyfill (使用 media 属性),以及在不支持 JS 情况下降级。完整例子如下:

<link rel="preload" href="path/to/mystylesheet.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="path/to/mystylesheet.css"></noscript>
<script>
/*! loadCSS rel=preload polyfill. [c]2017 Filament Group, Inc. MIT License */
(function(){ ... }());
</script>

我们以 Vue 项目为例,首先必须要保证 Vue 实例在异步样式表加载完毕后进行挂载,如果此时样式还没有完成,我们把挂载方法放到全局,等到样式加载完成后再调用:

app = new App();
window.mountApp = () => {
    app.$mount('#app');
};
if (window.STYLE_READY) {
    window.mountApp();
}

然后使用 <link ref='preload'>,当加载完成时,如果发现全局有 mountApp,就执行挂载:

<link rel='preload' href='index.css' as='style' onload='this.onload=null;this.rel='stylesheet';window.STYLE_READY=1;window.mountApp&&window.mountApp();'>

最终效果如下: image

参考资料