应用 shell 是用于为界面提供支持的最少 HTML、CSS 和 JavaScript。应用 shell 应:
- 快速加载
- 被缓存
- 动态显示内容
应用 shell 是实现可靠出色性能的秘诀。您可以将应用的 shell 视为构建原生应用时发布到应用商店的代码 bundle。它是应用启动所需的载荷,但可能不是全部。它会将界面保留在本地,并通过 API 动态提取内容。
背景
Alex Russell 的 Progressive Web Apps 一文介绍了 Web 应用如何在使用和征得用户同意后逐步发生变化,从而提供更接近原生应用的体验,包括离线支持、推送通知和可添加到主屏幕的功能。这在很大程度上取决于服务工件的功能和性能优势以及其缓存能力。这样,您就可以专注于速度,让您的 Web 应用拥有您在原生应用中常见的即时加载和定期更新功能。
为了充分利用这些功能,我们需要以一种新的方式思考网站:应用壳架构。
我们来深入了解如何使用增强型 Service Worker 应用 shell 架构构建应用。我们将介绍客户端和服务器端渲染,并分享一个您今天就可以试用的端到端示例。
为突出这一点,下例展示了使用此架构的应用的首次加载。请注意屏幕底部的“应用可以离线使用了”消息框。如果稍后有 Shell 更新可用,我们可以通知用户刷新以获取新版本。
什么是服务工件?
Service Worker 是一种在后台运行的脚本,与网页分开。它会响应事件,包括通过其提供的网页发出的网络请求以及来自服务器的推送通知。服务工件的生命周期是故意设为较短的。它会在收到事件时唤醒,并且仅在需要处理事件时运行。
与常规浏览环境中的 JavaScript 相比,服务工件还提供一组有限的 API。这是 Web 上工作器的标准配置。服务工件无法访问 DOM,但可以访问 Cache API 等内容,并且可以使用 Fetch API 发出网络请求。IndexedDB API 和 postMessage() 也可用于在服务工件及其控制的页面之间进行数据持久化和消息传递。从服务器发送的推送事件可以调用 Notification API,以提高用户互动度。
服务工件可以拦截从网页发出的网络请求(这会触发服务工件上的提取事件),并返回从网络检索的响应,或者从本地缓存检索的响应,甚至是程序化构建的响应。实际上,它是浏览器中的可编程代理。有趣的是,无论响应来自何处,对网页而言,都好像没有 Service Worker 的参与一样。
如需深入了解 Service Worker,请参阅 Service Worker 简介。
性能优势
服务工件在离线缓存方面非常强大,但它们还能以即时加载的方式显著提升性能,让用户能够反复访问您的网站或 Web 应用。您可以缓存应用 shell,以便其在离线状态下运行并使用 JavaScript 填充其内容。
这样一来,即使您的内容最终来自广告联盟,您也可以在用户重复访问时在屏幕上获得有意义的像素,而无需广告联盟。您可以将其视为立即显示工具栏和卡片,然后逐步加载其余内容。
为了在真实设备上测试此架构,我们在 WebPageTest.org 上运行了应用 shell 示例,并在下方显示了结果。
测试 1:使用 Chrome 开发者版在 Nexus 5 上通过有线连接进行测试
应用的第一个视图必须从网络提取所有资源,并且在 1.2 秒后才能实现有意义的绘制。得益于服务工件缓存,我们的重复访问在 0.5 秒内实现了有意义的绘制并完全加载完毕。
测试 2:使用 Chrome 开发者版在 Nexus 5 上通过 3G 网络进行测试
我们还可以使用速度略慢的 3G 连接来测试我们的示例。这次,首次访问的首次有效渲染用时为 2.5 秒。网页需要 7.1 秒才能完全加载完毕。借助服务工件缓存,我们的重复访问在 0.8 秒内实现了有意义的绘制并完全加载完毕。
其他视图也显示了类似的情况。比较应用 shell 中首次有效绘制所需的 3 秒时间:
从我们的 Service Worker 缓存加载同一页面所需的时间为 0.9 秒。为最终用户节省了超过 2 秒的时间。
使用应用 shell 架构,您可以为自己的应用实现类似且可靠的性能提升。
服务工件是否要求我们重新思考应用的结构?
Service Worker 会导致应用架构发生一些细微变化。与将所有应用压缩到 HTML 字符串中相比,采用 AJAX 方式执行操作可能更有益。您可以在该层级中使用 shell(始终缓存,并且即使没有网络也能启动),以及定期刷新且单独管理的内容。
这种分离的影响非常大。在首次访问时,您可以在服务器上呈现内容,并在客户端上安装 Service Worker。在后续访问中,您只需请求数据即可。
渐进增强又如何?
虽然目前并非所有浏览器都支持服务工件,但应用内容 shell 架构使用渐进增强来确保所有人都能访问内容。例如,请参考我们的示例项目。
下方显示的是 Chrome、Firefox Nightly 和 Safari 中呈现的完整版本。在最左侧,您可以看到 Safari 版本,其中内容在服务器上呈现,但没有使用服务工件。在右侧,我们看到了由服务工件提供支持的 Chrome 和 Firefox 夜间版。
何时应使用此架构?
应用 Shell 架构最适合动态应用和网站。如果您的网站较小且是静态网站,您可能不需要应用 shell,而只需在 Service Worker oninstall
步骤中缓存整个网站。请使用最适合您的项目的方法。许多 JavaScript 框架都建议将应用逻辑与内容分离,以便更轻松地应用此模式。
是否已有正式版应用在使用此模式?
只需对应用的整体界面进行少量更改,即可实现应用 shell 架构,这种架构非常适用于 Google 的 2015 年 I/O 大会渐进式 Web 应用和 Google 的收件箱等大型网站。
离线应用 shell 可显著提升性能,Jake Archibald 的 离线 Wikipedia 应用和 Flipkart Lite 的渐进式 Web 应用也很好地证明了这一点。
介绍架构
在首次加载体验期间,您的目标是尽快将有意义的内容呈现到用户屏幕上。
首次加载和加载其他网页
通常,应用 Shell 架构将:
优先加载初始内容,但让 Service Worker 缓存应用 shell,以便在用户重复访问时无需从网络重新提取 shell。
延迟加载或在后台加载所有其他内容。一个不错的做法是,对动态内容使用读取-穿透缓存。
例如,使用服务工工具(例如 sw-precache),可可靠地缓存和更新用于管理静态内容的服务工。(稍后会详细介绍 sw-precache。)
具体方法如下:
服务器将发送客户端可以呈现的 HTML 内容,并使用远期 HTTP 缓存到期标头来考虑不支持服务工件的浏览器。它将使用哈希来提供文件名,以便在应用生命周期的后续阶段实现“版本控制”和轻松更新。
页面将在文档
<head>
内的<style>
标记中包含内嵌 CSS 样式,以便快速完成应用 shell 的首次绘制。每个网页都会异步加载当前视图所需的 JavaScript。由于 CSS 无法异步加载,因此我们可以使用 JavaScript 请求样式,因为它是异步的,而不是由解析器驱动的同步请求。我们还可以利用requestAnimationFrame()
来避免快速命中缓存的情况,以免样式意外成为关键渲染路径的一部分。requestAnimationFrame()
会强制在加载样式之前绘制第一帧。另一种方法是使用 Filament Group 的 loadCSS 等项目,使用 JavaScript 异步请求 CSS。服务工件将存储应用 shell 的缓存条目,以便在用户重复访问时,除非网络上有可用的更新,否则可以完全从服务工件缓存加载 shell。
实际实现
我们使用应用 shell 架构编写了一个完全可用的示例,其中客户端使用的是纯 ES2015 JavaScript,服务器使用的是 Express.js。当然,您可以使用自己的堆栈来构建客户端或服务器部分(例如 PHP、Ruby、Python)。
Service Worker 生命周期
对于应用 shell 项目,我们使用 sw-precache,它提供以下服务工件生命周期:
事件 | 操作 |
---|---|
安装 | 缓存应用 shell 和其他单页应用资源。 |
激活 | 清除旧缓存。 |
抓取 | 为网址提供单页 Web 应用,并使用缓存来存储资源和预定义的部分。使用网络进行其他请求。 |
服务器位
在此架构中,服务器端组件(在本例中,使用 Express 编写)应能够分别处理内容和呈现方式。您可以将内容添加到 HTML 布局中,以便静态呈现网页,也可以单独提供并动态加载内容。
可以理解,您的服务器端设置可能与我们为演示版应用使用的设置截然不同。大多数服务器设置都可以实现这种 Web 应用模式,但确实需要进行一些重构。我们发现以下模型非常有效:
端点针对应用的三个部分进行定义:面向用户的网址(索引/通配符)、应用 shell(服务工件)和 HTML 部分。
每个端点都有一个控制器,用于拉入 handlebars 布局,该布局可以拉入 handlebars 部分和视图。简单来说,部分是指复制到最终网页中的 HTML 代码块。 注意:执行更高级数据同步的 JavaScript 框架通常更容易移植到应用 Shell 架构。它们通常使用数据绑定和同步,而不是部分。
系统最初会向用户提供包含内容的静态页面。此页面会注册 Service Worker(如果受支持),用于缓存应用 shell 及其依赖的所有内容(CSS、JS 等)。
然后,应用 shell 将充当单页 Web 应用,使用 JavaScript 在内容中对特定网址执行 XHR。XHR 调用会发送到 /partials* 端点,该端点会返回显示相应内容所需的一小部分 HTML、CSS 和 JS。 注意:实现此目的的方法有很多,XHR 只是其中之一。某些应用会在初始呈现时内嵌其数据(可能使用 JSON),因此在扁平化 HTML 的意义上不是“静态”的。
不支持 Service Worker 的浏览器应始终提供后备体验。在我们的演示中,我们回退到基本静态服务器端渲染,但这只是众多选项之一。借助服务工件方面,您可以利用缓存的应用 shell 获得新的机会来提升单页应用样式应用的性能。
文件版本控制
随之而来的一个问题是如何处理文件版本控制和更新。这因应用而异,选项如下:
优先使用网络,否则使用缓存的版本。
仅限网络,如果离线则失败。
缓存旧版本,稍后再更新。
对于应用 shell 本身,应采用缓存优先的方法来设置 Service Worker。如果您未缓存应用 shell,则表示您未正确采用该架构。
工具
我们维护着许多不同的服务工件辅助库,这些库可让您更轻松地预缓存应用的 shell 或处理常见的缓存模式。
为应用 shell 使用 sw-precache
使用 sw-precache 缓存应用 shell 应该可以解决与文件修订、安装/激活问题和应用 shell 提取场景相关的问题。将 sw-precache 添加到应用的构建流程中,并使用可配置的通配符来提取静态资源。您可以让 sw-precache 生成服务工件脚本,而不是手动编写,这样一来,该脚本便会使用缓存优先的提取处理脚本安全高效地管理缓存。
用户首次访问您的应用会触发预缓存一组完整的所需资源。这与从应用商店安装原生应用的体验类似。当用户返回您的应用时,系统只会下载更新后的资源。在我们的演示中,当有新 shell 可用时,我们会显示“App updates. 刷新以获取新版本。”这种模式是一种低摩擦的方式,可让用户知道他们可以刷新以获取最新版本。
使用 sw-toolbox 进行运行时缓存
使用 sw-toolbox 进行运行时缓存,并根据资源采用不同的策略:
针对图片使用 cacheFirst,以及一个自定义过期政策为 N maxEntries 的专用命名缓存。
对于 API 请求,请使用 networkFirst 或 fastest,具体取决于所需的内容新鲜度。使用“最快”可能没问题,但如果有特定的 API Feed 会频繁更新,请使用“networkFirst”。
总结
应用 shell 架构具有多种优势,但仅适用于某些类别的应用。该模型还处于起步阶段,因此值得评估采用这种架构的努力程度和总体性能优势。
在实验中,我们利用了客户端和服务器之间的模板共享功能,尽可能减少构建两个应用层的工作量。这确保渐进增强仍是一项核心功能。
如果您已经在考虑在应用中使用 Service Worker,请查看该架构,并评估它是否适合您自己的项目。
感谢审核者:Jeff Posnick、Paul Lewis、Alex Russell、Seth Thompson、Rob Dodson、Taylor Savage 和 Joe Medley。