一个好的渐进式 Web 应用,不论网络状况如何都能立即加载,并且在不需要网络请求的情况下也能展示 UI (即离线时)。
再次访问 Housing.com 渐进式 Web 应用(使用 React 和 Redux 构建)能够立即加载离线缓存的 UI。
我们可以用 Service Worker 实现这一需求。Service Worker 是一个后台 worker,可以看做是可编程的代理,允许开发者控制 request 执行其他操作。使用 Service Worker,React 应用得以(部分或全部)离线工作。
你能够掌控离线时 UX 的可用程度。你可以只离线缓存应用的外壳,全部数据(就像 ReactHN 缓存 stories 一样),或者像 Housing.com 和 Flipkart 那样,提供有限但有帮助的静态旧数据。并且均通过置灰 UI 蒙层来暗示已离线,这样就能够感知“实时”价格还未同步。
Service worker 实际上依赖两个 API:Fetch (通过网络重新获取内容的标准方式) 和 Cache(应用数据的内容存储,此缓存独立于浏览器缓存和网络状态)。
注意:Service worker 能够应用于渐进式增强。尽管浏览器支持程度还有待提升,但只要网络畅通,不支持此特性的用户也能充分体验 PWA (渐进式 Web 应用程序)。
Service worker 也设计作为基础 API,让 web 应用更像 native 应用。具体包括:
每个 Service Worker 的生命周期有三步:注册,安装和激活。Jake Archibald 的这篇文章有更详细的说明
如果要安装 Service Worker,你需要在脚本里注册它。注册后会通知浏览器定位你的 Service Worker 文件,并启动后台安装。在 index.html 中的基本注册方法如下:
// Check for browser support of service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('service-worker.js')
.then(function(registration) {
// Successful registration
console.log('Hooray. Registration successful, scope is:', registration.scope);
}).catch(function(err) {
// Failed registration, service worker won’t be installed
console.log('Whoops. Service worker registration failed, error:', error);
});
}
使用 navigator.serviceWorker.register 注册,注册成功后返回一个 resolve 状态的 Promise 对象。作用域是 registration.scope。
Service Worker 的作用域由拦截请求的路径决定。默认作用域是 Service Worker 文件所在路径。如果 service-worker.js 在根目录下,则 Service Worker 将控制该域名下所有文件的访问请求。你可以通过在注册时传入其他参数来改变作用域。
navigator.serviceWorker.register('service-worker.js', {
scope: '/app/'
});
Service workers 是事件驱动的。安装和激活方法由对应的安装和激活事件触发,由 Service Worker 响应。
Service Worker 注册之后,用户第一次访问 PWA 时,install 事件触发,此时确定页面需要缓存的静态资源。当 Service Worker 被认为是新的时才会触发该事件,即要么是页面第一次加载 Service Worker 文件,要么是当前文件与之前安装的文件不同,哪怕是一个字节不同,都会被认为是新的。如果你想在有机会控制客户端之前缓存东西,那么 install 是关键所在。
我们可以使用以下代码为静态应用添加最基本的缓存:
var CACHE_NAME = 'my-pwa-cache-v1';
var urlsToCache = [
'/',
'/styles/styles.css',
'/script/webpack-bundle.js'
];
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open(CACHE_NAME)
.then(function(cache) {
// Open a cache and cache our files
return cache.addAll(urlsToCache);
})
);
});
addAll() 传入一个 URL 数组,请求并获取文件,然后添加到缓存中去。如果任一步骤获取/写入失败,整个操作失败,并且缓存回退到它的上一个状态。
拦截和缓存请求
当 Service Worker 控制页面时,它能够拦截页面发起的每个请求,并且决定如何处理。这使得它有点像后台代理。我们用它来拦截到 urlsToCache 列表的请求,接着返回资源的本地版本,而不是走网络获取资源。这通过在 fetch 事件上绑定处理方法实现:
self.addEventListener('fetch', function(event) {
console.log(event.request.url);
event.respondWith(
caches.match(event.request).then(function(response) {
return response || fetch(event.request);
})
);
});
在 fetch 监听器中(具体的说是 event.respondWith),向 caches.match() 方法传入一个 promise 对象,这个能够监听请求和从 Service Worker 创建的条目中发现缓存。如果有匹配的缓存响应,返回对应的值。
这就是 Service Worker。以下是学习 Service Worker 可用的免费资源。
如果第三方 API 想要部署他们自己的 Service Worker 来处理其他域传来的请求,Foreign Fetch 可以帮忙。这对于网络化逻辑自定义和单个缓存实例响应定义都有帮助。
探索 - 自定义离线页面
基于 React 的 mobile.twitter.com 用 Service Worker 在网络不可达时提供自定义离线页面。
为用户提供有意义的离线体验(例如:可读内容)是一个很好的目标。也就是说,在早期的 Service Worker 实验中,你会发现设置自定义离线页面是很小但正确的决定。这里有许多优秀的 案例 展示如何实现它。
Lighthouse
如果你的应用在离线时有充分的用户体验,在遇到 Lighthouse 检测的如下条件时,就会全部通过。
start_url 便于检查用户从主界面打开 PWA 时使用离线缓存的体验情况,这项检查能够发现许多的问题,所以要确保 start_url 在你的 Web 应用的 manifest 中。
Chrome 开发工具
开发工具通过应用选项卡支持 「调试 Service Worker」 和 「模拟脱机连通性」。
强烈推荐使用 3G 节流(和 Timeline 面板的 CPU 节流)开发,模拟低端硬件上应用在脱机和网络差的情况下的表现。
应用程序外壳(或者应用外壳)架构是构建可靠的和在客户机立即加载的渐进式 Web 应用的一个方法,与 native 应用类似。
应用“外壳” 是最小化的 HTML,CSS 和 JavaScript,要求为用户接口赋能(想想 toolbars,drawers 等等),确保用户重复访问时即时可靠的性能表现。这意味着应用程序外壳不需要每次都下载,只需要网络获取少量必要内容即可。
Housing.com 使用了内容占位符的应用外壳。一旦全部下载完成,立即填充占位,此举有助于提升感官性能。
对于富 JavaScript 架构的 单页应用 来说,应用外壳是首选方法。这个方法依赖外壳的缓存(利用 Service Worker)来运行程序。其次,用 JavaScript 加载每个页面的动态内容。在无网络情况下,应用外壳有助于更快的获取屏幕的起始 HTML 页面。外壳可以使用 Material UI 或是自定义风格。
注意:参考 第一个渐进式 Web 应用 学习设计和实现第一个应用外壳程序,以天气应用为样例。用应用外壳模型实现立即加载 同样探讨了这个模式。
我们利用 Cache Storage API(通过 Service Worker)离线缓存外壳,目的是当重复访问时,应用外壳能够立即加载,这样就能在无网络情况下快速获取屏幕信息,即使内容最终还是来自网络。
记住你可以使用更简单的 SSR 或者 SPA 架构开发 PWA,但它没有同样的性能优势并且更依赖全页缓存。
这里列举两个用于不同离线场景的库:sw-precache 会自动事先缓存静态资源,sw-toolbox 处理运行时缓存以及回退策略。这两个库一起使用能达到互补的效果,需要提供静态内容外壳的性能策略时,总是从缓存中直接获取,而动态的或远程的资源则通过网络请求提供,需要时回退到缓存或静态响应里。
应用外壳缓存:静态资源(HTML, JavaScript, CSS 和 images)提供 web 应用的核心外壳。Sw-precache 确保绝大多数这类静态资源都被缓存下来,并且保持更新。预缓存一个网站离线工作需要的所有资源显然是不现实的。
运行时缓存:一些过于庞大或者很少使用的资源,还有一些动态资源,像来自远程 API 或服务的响应。没有预缓存的请求并不一定要响应网络错误。sw-toolbox 让我们得以灵活实现请求的处理,这能够处理某些资源的运行时缓存和其他资源的自定义回退。
sw-toolbox 支持大多数不同缓存策略,包括网络优先(确保可用数据是最新的,而不是读取缓存),缓存优先(匹配请求与缓存列表,如果资源不存在则发起网络请求),速度优先(同时从缓存和网络请求资源,响应最快的返回结果)。了解这些方法的 优劣 十分重要。
许多网站都在各自的渐进式 Web 应用里利用 sw-toolbox 和 sw-precache 进行离线缓存,例如 Housing.com,the NFL,Flipkart,Alibaba,the Washington Post 等等。也就是说,我们能够一直关注反馈和优化方案。
利用 Service Worker 和 Cache Storage API 缓存 URL 的可访问内容能够通过以下这些不同的方式:
了解使用这些 SW 库构建一个 React 应用的讨论也是大有裨益的:
正如上文提到,offline-plugin 是另一个库,用于添加 Service Worker 缓存到页面。它设计理念是最小化配置(目标是零配置) 和 Webpack的深度整合。当 Webpack 的 publicPath 配置了,它能够自动为缓存生成 relativePaths,而不需要再指定其他配置。对静态网站来说,offline-plugin 是一个很好的 sw-precache 的替代品。如果你用的是 HtmlWebpackPlugin,offline-plugin 还能缓存 .html 页面。
module.exports = {
plugins: [
// ... other plugins
new OfflinePlugin()
]
}
我在 渐进式 Web 应用的离线缓存 中讲了其他类型数据的离线存储策略。尤其是 React,如果你正关注添加数据仓库到缓存或正使用 Redux,你会对 坚持 Redux 和 Redux 复制本地搜索 感兴趣的(后者压缩后约 8 KB)。
ReactHN 一开始是没有离线缓存的单页应用。我们按步骤添加离线缓存:
第一步:用 sw-precache 为应用 “外壳” 离线缓存静态资源。通过调用 package.json 里 script 域的 sw-precache CLI 工具,每次构建完成时产生一个 Service Worker 用于预缓存外壳
"precache": "sw-precache — root=public — config=sw-precache-config.json"
这份预缓存配置文件通过上面的命令传递,可以控制引入的文件和 helper 脚本:
{
"staticFileGlobs": [
"app/css/**.css",
"app/**.html",
"app/js/**.js",
"app/images/**.*"
],
"verbose": true,
"importScripts": [
"sw-toolbox.js",
"runtime-caching.js"
]
}
sw-precache 在输出结果中列出将离线缓存的静态资源总大小。这有利于明白多大的应用外壳和资源能够保证良好的交互体验。
注意:如果现在开始做离线缓存功能,我会只用 sw-precache-webpack-plugin 从标准 Webpack 配置中直接配置:
plugins: [
new SWPrecacheWebpackPlugin(
{
cacheId: "react-hn",
filename: "my-service-worker.js",
staticFileGlobs: [
"app/css/**.css",
"app/**.html",
"app/js/**.js",
"app/images/**.*"
],
verbose: true
}
),
第二步:我们还想缓存运行时/动态请求。为了实现这一功能,我们需要引入 sw-toolbox 和上面的运行时缓存配置。应用使用了 Google Fonts 网络字体,所以我们添加一个简单的规则,缓存所有 google.com 的 fonts 子域下的请求。
global.toolbox.router.get('/(.+)', global.toolbox.fastest, {
origin: /https?:\/\/fonts.+/
});
从 API 端点(例如一个 appspot.com 上的应用引擎)缓存数据请求,类似如下:
global.toolbox.router.get('/(.*)', global.toolbox.fastest, {
origin: /\.(?:appspot)\.com$/
})
注意:sw-toolbox 支持许多有用的选项,包括能够设置缓存条目的最大失效时长(借助 maxAgeSeconds)。要了解更多支持细节,请阅读 API docs。
第三步:仔细想一想对你的用户来说,什么是最有帮助的离线体验。每个应用都有所不同。
ReactHN 依赖服务器返回的实时新闻报道和评论数据。一番实验之后,我们发现 UX 和性能之间的一个平衡点是用 稍微 老旧的数据提供离线体验。
从其他已经发布的 PWA 上可以学到很多东西,鼓励大家尽可能地研究和分享学习成果。
一旦在你的 PWA 使用 Service Worker 提升离线体验,你的关注点就会移向别处,比如,确保 Google 分析离线可用,如果你尝试离线 GA,请求会失败,你也不能得到有用的数据状态。
IndexedDB 中的离线 Google 分析事件队列
我们可以用 离线 Google 分析库 解决这一问题(sw-offline-google-analytics)来解决这一问题。当用户离线时,入队所有 GA 请求,并且一旦网络再次可用,就尝试重连。我们今年的 Google I/O web app 就成功使用了相似的技术,鼓励大家都去试一试。
对我来说,Service Worker 最难搞的部分就是调试。但去年开始,Chrome DevTools 显著降低了调试难度。为了节约你的时间和减少稍后踩的大坑,我强烈推荐在 SW debugging codelab 上做开发。
记录你发现的技巧或者新知识也可以帮助别人。Rich Harris 就写了 Service Worker 早知道。
根据其他内容集结了资料如下:
其他资源:
最后结语!
在这个系列的第四部分,我们会重点关注使用全局渲染来渐进增强 React.js 渐进式 Web 应用。
如果你刚了解 React,Wes Bos 的 React 入门 很适合你。
感谢 Gray Norton, Sean Larkin, Sunil Pai, Max Stoiber, Simon Boudrias, Kyle Mathews, Arthur Stolyar 和 Owen Campbell-Moore 的评论。