我们来聊聊…架构?
我将介绍一个重要但可能被误解的主题:您用于 Web 应用的架构,具体而言,在构建渐进式 Web 应用时,您的架构决策如何发挥作用。
“架构”一词可能听起来有些模糊,您可能无法立即明白其重要性。思考架构的一种方式是问自己以下问题:当用户访问我网站上的某个网页时,会加载哪些 HTML?然后,当他们访问另一个网页时,会加载什么?
这些问题的答案并不总是显而易见的,一旦您开始考虑渐进式 Web 应用,它们可能会变得更加复杂。 因此,我的目标是向您介绍一种我认为有效的架构。在本文中,我将把我做出的决策标记为构建渐进式 Web 应用的“我的方法”。
在构建自己的 PWA 时,您可以随意使用我的方法,但同时,始终有其他有效的替代方案。我希望,了解所有这些部分如何协同工作能为您带来启发,并让您能够根据自己的需求进行自定义。
Stack Overflow PWA
为了配合本文,我构建了一个 Stack Overflow PWA。我花了很多时间阅读 Stack Overflow 上的内容并为其贡献内容,因此我想构建一个 Web 应用,以便轻松浏览给定主题的常见问题。它基于公共 Stack Exchange API 构建。它是开源的,您可以访问 GitHub 项目了解详情。
多页应用 (MPA)
在深入探讨具体细节之前,我们先来定义一些术语,并解释一些底层技术。首先,我将介绍我喜欢称之为“多页应用”或“MPA”的内容。
MPA 是自网络诞生以来就使用的传统架构的别称。每次用户导航到新网址时,浏览器都会逐步呈现该网页特有的 HTML。系统不会尝试在导航之间保留页面的状态或内容。每次访问新页面时,您都是从头开始。
这与用于构建 Web 应用的单页应用 (SPA) 模型相反,在 SPA 模型中,当用户访问新版块时,浏览器会运行 JavaScript 代码来更新现有网页。SPA 和 MPA 都是同样有效的模型,但对于本文,我想在多页应用的上下文中探索 PWA 概念。
可靠快速
您已经听过我(以及无数其他人)使用“渐进式 Web 应用”或 PWA 这个词。您可能已经熟悉本网站其他位置的一些背景资料。
您可以将 PWA 视为一种 Web 应用,它可提供一流的用户体验,并真正赢得用户主屏幕上的一席之地。首字母缩写词“FIRE”代表 Fast(快速)、Integrated(集成)、Reliable(可靠)和 Engaging(吸引人),总结了构建 PWA 时需要考虑的所有属性。
在本文中,我将重点介绍这些属性的一个子集:快速和可靠。
快速:虽然“快速”在不同的语境中具有不同的含义,但我将介绍尽可能少地从网络加载内容的速度优势。
可靠: 但仅有速度还不够。为了让用户感觉像 PWA,您的 Web 应用应该可靠。它需要具有足够的弹性,以便始终加载某些内容,即使只是自定义错误页面,无论网络状态如何。
始终快速:最后,我将稍微改写一下 PWA 的定义,并探讨如何构建始终快速的 PWA。仅在低延迟网络中实现快速可靠还不够。可靠快速意味着无论底层网络条件如何,Web 应用的速度都保持一致。
赋能技术:Service Worker + Cache Storage API
PWA 对速度和弹性提出了很高的要求。幸运的是,Web 平台提供了一些构建块,可实现这种性能。 我指的是 Service Worker 和 Cache Storage API。
您可以构建一个 Service Worker,用于监听传入的请求,将一些请求传递给网络,并通过 Cache Storage API 存储响应的副本以供将来使用。
下次 Web 应用发出相同请求时,其 Service Worker 可以检查其缓存,并直接返回之前缓存的响应。
尽可能避免使用网络是提供可靠快速性能的关键。
“同构”JavaScript
我想介绍的另一个概念是,有时被称为“同构”或“通用”JavaScript。简单来说,就是可以在不同的运行时环境之间共享相同的 JavaScript 代码。在构建 PWA 时,我想在后端服务器和 Service Worker 之间共享 JavaScript 代码。
通过这种方式共享代码有许多有效的方法,但我的方法是使用 ES 模块作为权威源代码。然后,我使用 Babel 和 Rollup 的组合,为服务器和服务工作线程转译并打包了这些模块。在我的项目中,文件扩展名为 .mjs 的文件是位于 ES 模块中的代码。
服务器
牢记这些概念和术语,让我们深入了解我如何实际构建 Stack Overflow PWA。我将首先介绍后端服务器,并说明它如何融入整体架构。
我当时在寻找一种将动态后端与静态托管相结合的解决方案,我的方法是使用 Firebase 平台。
Firebase Cloud Functions 会在收到传入请求时自动启动一个基于 Node 的环境,并与我熟悉的 Express HTTP 框架集成。它还为我网站的所有静态资源提供开箱即用的托管服务。我们来看看服务器如何处理请求。
当浏览器向我们的服务器发出导航请求时,会经历以下流程:
服务器会根据网址路由请求,并使用模板逻辑创建完整的 HTML 文档。我结合使用了 Stack Exchange API 中的数据以及服务器在本地存储的部分 HTML 片段。 一旦 Service Worker 知道如何响应,它就可以开始将 HTML 流式传输回我们的 Web 应用。
此图中有两个部分值得更详细地探讨:路由和模板。
路由
在路由方面,我的方法是使用 Express 框架的原生路由语法。它足够灵活,可以匹配简单的网址前缀,以及将参数作为路径一部分的网址。在这里,我创建了路由名称与要匹配的基础 Express 模式之间的映射。
const routes = new Map([
['about', '/about'],
['questions', '/questions/:questionId'],
['index', '/'],
]);
export default routes;
然后,我可以直接从服务器的代码中引用此映射。 当与给定的 Express 模式匹配时,适当的处理程序会使用特定于匹配路由的模板逻辑进行响应。
import routes from './lib/routes.mjs';
app.get(routes.get('index'), async (req, res) => {
// Templating logic.
});
服务器端模板
这种模板逻辑是什么样的?我采用的方法是按顺序将部分 HTML 片段拼接在一起。此模型非常适合流式传输。
服务器会立即发回一些初始 HTML 样板,浏览器能够立即呈现该部分网页。当服务器将其他数据源拼凑在一起时,它会将这些数据源流式传输到浏览器,直到文档完成。
为了说明我的意思,请查看我们其中一个路由的 Express 代码:
app.get(routes.get('index'), async (req, res) => {
res.write(headPartial + navbarPartial);
const tag = req.query.tag || DEFAULT_TAG;
const data = await requestData(...);
res.write(templates.index(tag, data.items));
res.write(footPartial);
res.end();
});
通过使用 response 对象的 write() 方法并引用本地存储的部分模板,我能够立即启动响应流,而不会阻塞任何外部数据源。浏览器会获取此初始 HTML,并立即呈现有意义的界面和加载消息。
我们页面的下一部分使用来自 Stack Exchange API 的数据。获取这些数据意味着我们的服务器需要发出网络请求。在收到并处理响应之前,Web 应用无法呈现任何其他内容,但至少用户在等待时不会盯着空白屏幕。
Web 应用收到来自 Stack Exchange API 的响应后,会调用自定义模板函数,将来自 API 的数据转换为相应的 HTML。
模板语言
模板化可能是一个极具争议的话题,而我采用的只是众多方法中的一种。您可能需要替换自己的解决方案,尤其是当您与现有模板框架有旧版关联时。
对于我的用例,合理的做法是仅依赖 JavaScript 的模板字面量,并将一些逻辑拆分为辅助函数。构建 MPA 的一个好处是,您无需跟踪状态更新并重新渲染 HTML,因此生成静态 HTML 的基本方法对我来说很有效。
以下示例展示了如何为 Web 应用索引的动态 HTML 部分创建模板。与我的路由一样,模板逻辑存储在 ES 模块中,该模块可以导入到服务器和服务工作器中。
export function index(tag, items) {
const title = `<h3>Top "${escape(tag)}" Questions</h3>`;
const form = `<form method="GET">...</form>`;
const questionCards = items
.map(item =>
questionCard({
id: item.question_id,
title: item.title,
})
)
.join('');
const questions = `<div id="questions">${questionCards}</div>`;
return title + form + questions;
}
这些模板函数是纯 JavaScript,在适当的时候将逻辑拆分为较小的辅助函数会很有用。在这里,我将 API 响应中返回的每个项传递给一个这样的函数,该函数会创建一个设置了所有适当属性的标准 HTML 元素。
function questionCard({id, title}) {
return `<a class="card"
href="/questions/${id}"
data-cache-url="${questionUrl(id)}">${title}</a>`;
}
特别值得注意的是,我为每个链接添加了一个数据属性 data-cache-url,并将其设置为我需要用来显示相应问题的 Stack Exchange API 网址。请谨记这一点。我稍后再来。
回到我的路由处理程序,模板化完成后,我会将网页 HTML 的最后一部分流式传输到浏览器,然后结束流。这是向浏览器发出的渐进式渲染已完成的提示。
app.get(routes.get('index'), async (req, res) => {
res.write(headPartial + navbarPartial);
const tag = req.query.tag || DEFAULT_TAG;
const data = await requestData(...);
res.write(templates.index(tag, data.items));
res.write(footPartial);
res.end();
});
以上就是我的服务器设置的简要介绍。首次访问我的 Web 应用的用户始终会收到来自服务器的响应,但当访问者再次访问我的 Web 应用时,我的 Service Worker 将开始响应。我们来深入探讨一下。
Service Worker
此图表应该看起来很熟悉,我之前介绍过的许多相同部分都在这里,只是排列方式略有不同。我们来了解一下请求流程,同时考虑 Service Worker。
我们的 Service Worker 会处理针对给定网址的传入导航请求,并且就像我的服务器一样,它会结合使用路由和模板逻辑来确定如何响应。
方法与之前相同,但使用了不同的低级基元,例如 fetch() 和 Cache Storage API。我使用这些数据源来构建 HTML 响应,Service Worker 会将其传回 Web 应用。
Workbox
我不会从头开始使用低级基元,而是会在一组名为 Workbox 的高级库之上构建 Service Worker。它为任何 Service Worker 的缓存、路由和响应生成逻辑提供了坚实的基础。
路由
与服务器端代码一样,Service Worker 需要知道如何将传入请求与适当的响应逻辑进行匹配。
我的方法是使用一个名为 regexparam 的实用库,将每个 Express 路由转换为相应的正则表达式。完成该转换后,我就可以利用 Workbox 对正则表达式路由的内置支持。
导入包含正则表达式的模块后,我将每个正则表达式注册到 Workbox 的路由器。在每个路由中,我都可以提供自定义模板逻辑来生成响应。在 Service Worker 中使用模板比在后端服务器中要复杂一些,但 Workbox 可以帮助完成许多繁重的工作。
import regExpRoutes from './regexp-routes.mjs';
workbox.routing.registerRoute(
regExpRoutes.get('index')
// Templating logic.
);
静态资源缓存
模板化故事的一个关键部分是确保我的部分 HTML 模板可通过 Cache Storage API 在本地提供,并在我将更改部署到 Web 应用时保持最新状态。手动执行缓存维护时很容易出错,因此我求助于 Workbox,在构建过程中处理预缓存。
我使用配置文件告诉 Workbox 要预缓存哪些网址,该文件指向包含我所有本地资源的目录,以及一组要匹配的模式。每次我重新构建网站时,Workbox 的 CLI 都会自动读取此文件,该 CLI 会运行。
module.exports = {
globDirectory: 'build',
globPatterns: ['**/*.{html,js,svg}'],
// Other options...
};
Workbox 会为每个文件的内容创建快照,并自动将该网址和修订版本列表注入到我的最终 Service Worker 文件中。Workbox 现在拥有使预缓存文件始终可用并保持最新状态所需的一切。结果是一个 service-worker.js 文件,其中包含类似于以下内容的内容:
workbox.precaching.precacheAndRoute([
{
url: 'partials/about.html',
revision: '518747aad9d7e',
},
{
url: 'partials/foot.html',
revision: '69bf746a9ecc6',
},
// etc.
]);
对于使用更复杂构建流程的用户,Workbox 除了提供命令行界面之外,还提供 webpack 插件和通用节点模块。
流式
接下来,我希望 Service Worker 立即将预缓存的部分 HTML 流式传输回 Web 应用。这是实现“可靠快速”的关键部分,我总是能立即在屏幕上看到有意义的内容。 幸运的是,在 Service Worker 中使用 Streams API 可以实现这一点。
您之前可能听说过 Streams API。我的同事 Jake Archibald 多年来一直对其赞不绝口。他曾大胆预测,2016 年将是网络直播之年。Streams API 如今和两年前一样出色,但有一个关键的区别。
虽然当时只有 Chrome 支持 Streams,但 Streams API 现在得到了更广泛的支持。总体情况是积极的,只要有适当的回退代码,您现在就可以在 Service Worker 中使用流。
不过,可能有一个问题会阻碍您,那就是理解 Streams API 的实际工作原理。它提供了一组非常强大的基元,熟悉它的开发者可以创建复杂的数据流,如下所示:
const stream = new ReadableStream({
pull(controller) {
return sources[0]
.then(r => r.read())
.then(result => {
if (result.done) {
sources.shift();
if (sources.length === 0) return controller.close();
return this.pull(controller);
} else {
controller.enqueue(result.value);
}
});
},
});
但并非所有人都能完全理解这段代码的含义。我们不解析此逻辑,而是讨论我的 Service Worker 流式传输方法。
我使用的是一个全新的高级封装容器 workbox-streams。
这样,我就可以在混合流式传输来源中传递它,这些来源既有来自缓存的数据,也有可能来自网络的运行时数据。Workbox 负责协调各个来源,并将其拼接成一个流式响应。
此外,Workbox 会自动检测是否支持 Streams API,如果不支持,则会创建等效的非流式响应。 这意味着,随着浏览器对流的支持率越来越接近 100%,您无需担心编写后备方案。
运行时缓存
我们来看看我的 Service Worker 如何处理来自 Stack Exchange API 的运行时数据。我正在利用 Workbox 对过时而重新验证缓存策略的内置支持,以及过期时间,以确保 Web 应用的存储空间不会无限增长。
我在 Workbox 中设置了两种策略,用于处理构成流式响应的不同来源。只需进行一些函数调用和配置,Workbox 就能让我们完成原本需要数百行手写代码才能完成的工作。
const cacheStrategy = workbox.strategies.cacheFirst({
cacheName: workbox.core.cacheNames.precache,
});
const apiStrategy = workbox.strategies.staleWhileRevalidate({
cacheName: API_CACHE_NAME,
plugins: [new workbox.expiration.Plugin({maxEntries: 50})],
});
第一种策略会读取已预缓存的数据,例如我们的部分 HTML 模板。
另一种策略则实现 stale-while-revalidate 缓存逻辑,并在达到 50 个条目后采用最近最少使用缓存过期机制。
现在,我已经制定了这些策略,接下来只需告诉 Workbox 如何使用这些策略来构建完整的流式响应。我传入一个函数数组作为来源,每个函数都会立即执行。Workbox 会从每个来源获取结果,并按顺序将其流式传输到 Web 应用,仅当数组中的下一个函数尚未完成时才会延迟。
workbox.streams.strategy([
() => cacheStrategy.makeRequest({request: '/head.html'}),
() => cacheStrategy.makeRequest({request: '/navbar.html'}),
async ({event, url}) => {
const tag = url.searchParams.get('tag') || DEFAULT_TAG;
const listResponse = await apiStrategy.makeRequest(...);
const data = await listResponse.json();
return templates.index(tag, data.items);
},
() => cacheStrategy.makeRequest({request: '/foot.html'}),
]);
前两个来源是直接从 Cache Storage API 读取的预缓存部分模板,因此它们始终立即可用。这可确保我们的 Service Worker 实现在响应请求时能够像我的服务器端代码一样可靠、快速。
下一个源函数会从 Stack Exchange API 提取数据,并将响应处理为 Web 应用所需的 HTML。
stale-while-revalidate 策略意味着,如果我之前缓存了此 API 调用的响应,我将能够立即将其流式传输到页面,同时“在后台”更新缓存条目,以便下次请求时使用。
最后,我流式传输页脚的缓存副本,并关闭最终的 HTML 标记,以完成响应。
共享代码可让内容保持同步
您会发现,Service Worker 代码的某些部分看起来很熟悉。我的 Service Worker 使用的部分 HTML 和模板逻辑与我的服务器端处理程序使用的逻辑完全相同。这种代码共享可确保用户获得一致的体验,无论他们是首次访问我的 Web 应用,还是返回由 Service Worker 呈现的页面。这就是同构 JavaScript 的魅力所在。
动态、渐进增强
我已经介绍了 PWA 的服务器和服务工作线程,但还有最后一点逻辑需要说明:在每个网页完全流式传输后,都会运行少量 JavaScript。
此代码会逐步提升用户体验,但并非至关重要,即使不运行,Web 应用仍可正常运行。
页面元数据
我的应用使用客户端 JavaScript 根据 API 响应更新网页的元数据。由于我为每个网页使用相同的初始缓存 HTML,因此 Web 应用最终会在文档的 head 中使用通用标记。但通过在模板和客户端代码之间进行协调,我可以使用网页级元数据更新窗口的标题。
作为模板代码的一部分,我的方法是添加一个包含经过适当转义的字符串的脚本标记。
const metadataScript = `<script>
self._title = '${escape(item.title)}';
</script>`;
然后,在页面加载后,我读取该字符串并更新文档标题。
if (self._title) {
document.title = unescape(self._title);
}
如果您想在自己的 Web 应用中更新其他网页级元数据,可以采用相同的方法。
离线用户体验
我添加的另一项渐进增强功能用于吸引用户关注我们的离线功能。我构建了一个可靠的 PWA,希望用户知道,即使在离线状态下,他们仍然可以加载之前访问过的页面。
首先,我使用 Cache Storage API 获取之前缓存的所有 API 请求的列表,并将其转换为网址列表。
还记得我之前提到的那些特殊数据属性吗?每个属性都包含显示问题所需的 API 请求网址。我可以将这些数据属性与缓存的网址列表进行交叉对比,并创建一个包含所有不匹配的问题链接的数组。
当浏览器进入离线状态时,我会循环遍历未缓存的链接列表,并将无法使用的链接调暗。请注意,这只是向用户提供视觉提示,让他们知道这些页面会发生什么,我实际上并没有停用链接,也没有阻止用户导航。
const apiCache = await caches.open(API_CACHE_NAME);
const cachedRequests = await apiCache.keys();
const cachedUrls = cachedRequests.map(request => request.url);
const cards = document.querySelectorAll('.card');
const uncachedCards = [...cards].filter(card => {
return !cachedUrls.includes(card.dataset.cacheUrl);
});
const offlineHandler = () => {
for (const uncachedCard of uncachedCards) {
uncachedCard.style.opacity = '0.3';
}
};
const onlineHandler = () => {
for (const uncachedCard of uncachedCards) {
uncachedCard.style.opacity = '1.0';
}
};
window.addEventListener('online', onlineHandler);
window.addEventListener('offline', offlineHandler);
常见误区
现在,我已经介绍了构建多页 PWA 的方法。 在制定自己的方法时,您必须考虑许多因素,最终可能会做出与我不同的选择。这种灵活性是 Web 开发的一大优势。
在自行做出架构决策时,您可能会遇到一些常见陷阱,我想帮您避免这些问题。
不缓存完整 HTML
我们不建议您在缓存中存储完整的 HTML 文档。首先,这会浪费空间。如果您的 Web 应用在每个网页上都使用相同的基本 HTML 结构,您最终会一次又一次地存储相同标记的副本。
更重要的是,如果您对网站的共享 HTML 结构进行了更改,那么之前缓存的每个网页仍会停留在旧布局中。 想象一下,当回访者看到新旧页面混杂在一起时,会感到多么沮丧。
服务器/Service Worker 偏移
另一个需要避免的陷阱是服务器和 Service Worker 不同步。我的方法是使用同构 JavaScript,以便在两个位置运行相同的代码。根据您现有的服务器架构, 这并非总是可行。
无论您做出何种架构决策,都应制定一些策略,以便在服务器和 Service Worker 中运行等效的路由和模板代码。
最坏情况
布局/设计不一致
如果您忽略这些陷阱,会发生什么情况?各种故障都有可能发生,但最糟糕的情况是,回访用户访问的缓存页面布局非常陈旧,例如标题文本过时,或者使用的 CSS 类名不再有效。
最坏情况:路由损坏
或者,用户可能会遇到由您的服务器(而非 Service Worker)处理的网址。一个充满僵尸布局和死胡同的网站不是一个可靠的 PWA。
成功秘诀
但您并非孤军奋战!以下提示可帮助您避免这些陷阱:
使用具有多语言实现的模板和路由库
尝试使用具有 JavaScript 实现的模板和路由库。现在,我知道并非每个开发者都有幸能够 从当前的 Web 服务器和模板语言迁移。
不过,许多热门的模板和路由框架都有多种语言的实现。如果您能找到一个既能与 JavaScript 搭配使用,又能与当前服务器语言搭配使用的模板引擎,那么您就离让 Service Worker 和服务器保持同步更近了一步。
首选依序模板,而非嵌套模板
接下来,我建议使用一系列可依次流式传输的依序模板。只要您能尽快流式传输 HTML 的初始部分,即使网页的后续部分使用更复杂的模板逻辑也没关系。
在 Service Worker 中同时缓存静态和动态内容
为实现最佳性能,您应预缓存网站的所有关键静态资源。您还应设置运行时缓存逻辑,以处理动态内容(例如 API 请求)。使用 Workbox 意味着,您可以基于经过充分测试、可用于生产用途的策略进行构建,而无需从头开始实现所有内容。
仅在绝对必要时才在网络上屏蔽
与此相关的是,只有在无法从缓存流式传输响应时,您才应在网络上阻塞。与等待新数据相比,立即显示缓存的 API 响应通常可以带来更好的用户体验。