使用应用 Shell 架构即时加载 Web 应用

Addy Osmani
Addy Osmani
Matt Gaunt

应用 Shell 是驱动界面所需的最小的 HTML、CSS 和 JavaScript。应用 Shell 应该:

  • 快速加载
  • 被缓存
  • 动态显示内容

应用 Shell 是可靠且良好性能的秘诀。应用的 Shell 就像是您在构建原生应用时需要发布到应用商店的一组代码。它是让您的应用成功起步所需的负载,但可能并不完整。它会将您的界面保留在本地,并通过 API 动态提取内容。

App Shell 将 HTML、JS 和 CSS shell 与 HTML 内容分离开

背景

Alex Russell 的渐进式 Web 应用一文介绍了如何通过使用和征求用户同意来逐步改变 Web 应用,以提供更接近原生应用的体验,包括离线支持、推送通知和添加到主屏幕的功能。它在很大程度上取决于 Service Worker 的功能和性能优势及其缓存能力。这让您可以专注于速度,从而为您的 Web 应用提供与原生应用一样的即时加载和定期更新。

为了充分利用这些功能,我们需要一种新的网站思考方式:App Shell 架构

我们来深入了解如何使用 Service Worker 增强的 App Shell 架构来构建应用。我们将同时探讨客户端呈现和服务器端呈现,并分享一个端到端示例,供您立即试用。

为了强调这一点,以下示例展示了使用此架构的应用第一次加载。请注意,屏幕底部会显示“应用已可离线使用”消息框。如果稍后可用的 shell 更新,我们可以通知用户刷新以获取新版本。

在 DevTools 中针对应用 Shell 运行的 Service Worker 的图片

什么是 Service Worker?

Service Worker 是一种独立于网页在后台运行的脚本。它会响应事件(包括从它提供的页面发出的网络请求),以及从您的服务器推送通知。Service Worker 有意设置了短的生命周期。它会在收到事件时唤醒,并且只在需要处理事件时运行。

与正常浏览上下文中的 JavaScript 相比,Service Worker 还具有有限的 API 集。这是 Web 工作器的标准配置。Service Worker 无法访问 DOM,但可以访问 Cache API 等内容,并且可以使用 Fetch API 发出网络请求。IndexedDB APIpostMessage() 也可用于在 Service Worker 与其控制的页面之间进行数据持久化和消息传递。从您的服务器发送的推送事件可以调用 Notification API,以提高用户互动度。

Service Worker 可以拦截从页面发出的网络请求(这会在 Service Worker 上触发提取事件),并返回从网络检索、从本地缓存检索(甚至是以编程方式构建)的响应。实际上,它是浏览器中的一个可编程代理。有趣的一点是,无论响应来自何处,它都会查看网页,就像没有 Service Worker 参与一样。

如需详细了解 Service Worker,请参阅 Service Worker 简介

性能优势

Service Worker 功能强大,可用于离线缓存,但也为重复访问网站或 Web 应用的即时加载提供显著的性能优势。您可以将 App Shell 缓存,以便它离线工作并使用 JavaScript 填充其内容。

对于重复访问,这可以让您在没有网络的情况下在屏幕上展示有意义的像素,即使您的内容最终来自网络也无妨。您可以将它想象成立即显示工具栏和卡片,然后逐步加载其余内容。

为了在真实设备上测试此架构,我们在 WebPageTest.org 上运行了应用 Shell 示例,并显示以下结果。

测试 1使用 Chrome 开发者版使用 Nexus 5 在数据线上进行测试

应用的第一个视图必须从网络获取所有资源,并且要到 1.2 秒内才能实现有意义的绘制。得益于 Service Worker 缓存,我们的重复访问可以在 0.5 秒内完成有意义的绘制并完成加载。

电缆连接的网页测试绘制示意图

测试 2使用 Chrome 开发者版在 3G 设备上通过 Nexus 5 进行测试

我们还可以使用速度稍慢的 3G 连接来测试我们的示例。这次,在首次访问时,首次有效绘制需要 2.5 秒。页面需要 7.1 秒才能完全加载。借助 Service Worker 缓存,我们的重复访问可以在 0.8 秒内完成有意义的绘制并完全完成加载。

3G 连接的网页测试绘制图表

其他视图也讲述了类似的故事。比较一下在 App Shell 中完成首次有效绘制所需的时间 3 秒

从 Web Page Test 中绘制第一个视图的时间轴

从我们的 Service Worker 缓存加载同一页面时所需的 0.9 秒。为我们的最终用户节省了超过 2 秒的时间。

为 Web Page Test 中的重复视图绘制时间轴

使用 App Shell 架构,您自己的应用也可以获得类似且可靠的性能优势。

Service Worker 是否需要我们重新思考构建应用的方式?

Service Worker 意味着应用架构的一些细微变化。与将所有应用压缩成一个 HTML 字符串相比,采用 AJAX 样式执行某些操作会大有裨益。这里有一个 shell(始终缓存且始终可以在没有网络的情况下启动)和定期刷新并单独管理的内容。

这种拆分的影响是巨大的。首次访问时,您可以在服务器上渲染内容并在客户端上安装 Service Worker。在后续访问中,您只需请求数据即可。

那渐进式增强呢?

虽然目前并非所有浏览器都支持 Service Worker,但应用内容 Shell 架构使用渐进式增强来确保每个人都可以访问内容。以我们的示例项目为例。

您可在下方看到 Chrome、Firefox Nightly 和 Safari 中呈现的完整版本。最左侧的是 Safari 版本,其中内容是在没有 Service Worker 的情况下在服务器上呈现的。在右侧,我们可以看到由 Service Worker 提供支持的 Chrome 和 Firefox Nightly 版本。

Safari、Chrome 和 Firefox 中加载的应用 Shell 的图片

何时适合使用此架构?

App Shell 架构最适合动态应用和网站。如果您的网站较小且处于静态,那么您可能不需要 App Shell,而只需在 Service Worker oninstall 步骤中缓存整个网站。请使用最适合您的项目的方法。许多 JavaScript 框架已经鼓励将应用逻辑与内容分离,从而使此模式更易于直接应用。

是否已有使用此模式的正式版应用?

只需对整体应用界面稍作更改,即可构建 App Shell 架构。此架构已非常适用于大型网站,例如 Google 的 I/O 2015 渐进式 Web 应用和 Google 收件箱。

正在加载 Google 收件箱的图片。使用 Service Worker 展示 Inbox。

离线 App Shells 可大幅提升性能,在 Jake Archibald 的离线维基百科应用Flipkart Lite 的渐进式 Web 应用中也都展示了很好的效果。

Jake Archibald 的维基百科演示屏幕截图。

架构解释

在首次加载体验期间,您的目标是尽快将有意义的内容呈现到用户的屏幕上。

首次加载和加载其他网页

使用 App Shell 进行首次加载的示意图

一般来说,App Shell 架构将

  • 优先加载初始加载,但让 Service Worker 缓存 App Shell,这样重复访问就不需要从网络重新获取 Shell。

  • 延迟加载或后台加载所有其他内容。针对动态内容使用直读缓存是一种不错的选择。

  • 例如,使用 Service Worker 工具(如 sw-precache)可靠地缓存和更新管理静态内容的 Service Worker。(稍后会详细介绍 sw-precache。)

为此,请执行以下操作

  • 服务器将发送客户端可呈现的 HTML 内容,并使用距离现在很久的 HTTP 缓存到期标头,以便将不支持 Service Worker 的浏览器考虑在内。它将使用哈希值来提供文件名,以实现“版本控制”和轻松更新,以便在应用生命周期的后期阶段使用。

  • 页面将在文档 <head> 内的 <style> 标记中包含内嵌 CSS 样式,以便快速对 App Shell 进行首次绘制。每个页面都会异步加载当前视图所需的 JavaScript。由于 CSS 无法异步加载,因此我们可以使用 JavaScript 请求样式,因为它是异步的,而不是由解析器驱动和同步的。我们还可以利用 requestAnimationFrame(),避免出现缓存过快并最终导致样式意外成为关键渲染路径的一部分的情况。requestAnimationFrame() 会强制在加载样式之前绘制第一帧。您也可以使用 Filament Group 的 loadCSS 等项目,通过 JavaScript 异步请求 CSS。

  • Service Worker 将存储 App Shell 的缓存条目,以便在重复访问时,除非网络有可用更新,否则 Shell 可完全从 Service Worker 缓存加载。

适用于内容的 App Shell

实用实现

我们使用 App Shell 架构、适用于客户端的 vanilla ES2015 JavaScript 以及适用于服务器的 Express.js,编写了一个可正常运行的示例。当然,在客户端或服务器部分(例如 PHP、Ruby、Python)中,您完全可以自行使用堆栈。

Service Worker 生命周期

对于我们的应用 Shell 项目,我们使用提供以下 Service Worker 生命周期的 sw-precache

事件 操作
安装 缓存 App Shell 和其他单页应用资源。
激活 清除旧缓存。
提取 为网址提供单页 Web 应用,并将缓存用于资产和预定义的部分。使用网络处理其他请求。

服务器位数

在此架构中,服务器端组件(在本例中为 Express 编写)应能够分别处理内容和呈现。可以将内容添加到 HTML 布局中以静态呈现网页,也可以将内容单独投放并动态加载。

可以理解,您的服务器端设置可能与我们用于演示版应用的方式大不相同。大多数服务器设置都可以实现这种 Web 应用模式,尽管确实需要进行一些重新设计。我们发现,以下模型效果非常好:

App Shell 架构图
  • 端点是为您的应用的三个部分定义的:面向用户的网址(索引/通配符)、应用 Shell (Service Worker) 和 HTML 部分。

  • 每个端点都有一个控制器,用于提取 handlebars 布局,该布局反过来可以拉取句柄部分和视图。简而言之,部分视图就是复制到最终页面的 HTML 块。 注意: 执行更高级数据同步的 JavaScript 框架通常更容易移植到应用程序 Shell 架构。它们倾向于使用数据绑定和同步,而不是部分数据。

  • 用户最初看到的是包含内容的静态页面。此页面会注册一个 Service Worker(如果受支持),以缓存 App Shell 及其依赖的所有内容(CSS、JS 等)。

  • 然后,App Shell 会作为单页 Web 应用,在特定网址的内容中使用 JavaScript 到 XHR。系统对 /partials* 端点进行 XHR 调用,端点返回显示相应内容所需的小段 HTML、CSS 和 JS。 注意:实现这一目标的方法有很多,XHR 只是其中之一。某些应用会在初始呈现时内嵌数据(可能会使用 JSON),因此在扁平化 HTML 中呈现的不是“静态”。

  • 不支持 Service Worker 的浏览器应始终提供回退体验。在演示中,我们会回退到基本的静态服务器端渲染,但这只是众多选项之一。Service Worker 方面为您提供使用缓存应用程序 Shell 增强单页应用程序样式应用程序性能的新机会。

文件版本控制

接下来出现的一个问题是如何处理文件版本控制和更新。这仅适用于应用,可用选项包括:

  • 请先连接到网络,否则使用缓存的版本。

  • 仅连接到网络,如果离线,则会失败。

  • 缓存旧版本,稍后再更新。

对于 App Shell 本身,Service Worker 设置应采用缓存优先的方法。如果您没有缓存 App Shell,则表示您还没有正确采用架构。

工具

我们维护着多个不同的 Service Worker 帮助程序库,这些库可让您更轻松地设置预缓存应用的 Shell 或处理常见缓存模式。

Web 基础上的 Service Worker 库网站的屏幕截图

对应用 Shell 使用 sw-precache

使用 sw-precache 来缓存 App Shell 应该能处理有关文件修订版本、安装/激活问题以及 App Shell 提取场景的问题。将 sw-precache 放入应用的构建流程中,并使用可配置的通配符选取静态资源。让 sw-precache 生成一个能使用缓存优先获取处理程序安全高效地管理缓存的 Service Worker 脚本,而无需手动编写您的 Service Worker 脚本。

对应用的初始访问会触发预缓存整套所需资源。这与从应用商店安装原生应用的体验类似。当用户返回您的应用时,系统只会下载更新后的资源。在演示中,我们会在有可用的新 shell 时通知用户“App updates. 请刷新以获取新版本。”此模式是一种顺畅的方式,可以让用户知道他们可以刷新以获取最新版本。

使用 sw-toolbox 进行运行时缓存

使用 sw-toolbox 根据资源采用不同的策略进行运行时缓存:

  • cacheFirst(适用于图片)以及具有 N maxEntry 的自定义过期政策的专用已命名缓存。

  • networkFirst 或最快速度(用于 API 请求),具体取决于所需的内容新鲜度。“最快”可能没什么问题,但如果有频繁更新的特定 API Feed,请使用 networkFirst。

总结

App Shell 架构具有多种优势,但仅对某些类别的应用有意义。该模型仍处于发展阶段,有必要评估这种架构所付出的努力和整体性能优势。

在实验中,我们利用了客户端和服务器之间的模板共享,最大程度地减少了构建两个应用层的工作。这样可以确保渐进式增强仍然是核心功能。

如果您已经在考虑在应用中使用 Service Worker,请查看此架构并评估它是否适用于您自己的项目。

特此感谢审核者:Jeff Posnick、Paul Lewis、Alex Russell、Seth Thompson、Rob Dodson、Taylor Savage 和 Joe Medley。