不再局限于 SPA - PWA 的替代架构

我们来谈谈... 架构?

我将介绍一个重要但可能会遭到误解的主题:您用于 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) 模型相反,在该模型中,当用户访问新的部分时,浏览器运行 JavaScript 代码以更新现有页面。SPA 和 MPA 都是同样有效的模型,但在这篇博文中,我想在多页面应用的背景下探索 PWA 概念。

快速稳定

您听说我(以及无数其他人)使用“渐进式 Web 应用”(简称 PWA)短语。您可能已经在此网站的其他地方了解了一些背景资料。

您可以将 PWA 视为提供一流用户体验的 Web 应用,它确实在用户主屏幕上占据了一席之地。首字母缩写词“FIRE”(表示 F、Integrated、Reliable 和 Engaging)汇总了构建 PWA 时需要考虑的所有属性。

在本文中,我将重点介绍这两个属性中的一部分:快速可靠

快速:虽然“快速”在不同的上下文中的含义也不尽相同,但我将介绍尽可能减少从网络加载的速度优势。

可靠:但原始速度是不够的。为了感觉像 PWA,您的 Web 应用应该是可靠的。它需要具有足够的弹性以始终加载内容,即使只是自定义的错误页面,而不管网络的状态如何。

快速可靠:最后,我将稍微改写 PWA 的定义,看看如何构建快速可靠的应用。仅在使用低延迟网络时,仅仅保证快速可靠是不够的。稳定的快速意味着您的 Web 应用的速度一致,无论底层网络条件如何。

启用技术:Service Worker + Cache Storage API

PWA 对速度和弹性提出了高标准。幸运的是,该 Web 平台提供了一些基础组件,可以将这种性能变为现实。我指的是 Service WorkerCache Storage API

您可以构建一个 Service Worker,它通过 Cache Storage API 监听传入请求、将一些请求传递到网络上,并存储响应的副本以备将来使用。

使用 Cache Storage API 保存网络响应副本的 Service Worker。

下次 Web 应用发出相同的请求时,其 Service Worker 可以检查其缓存,然后仅返回之前缓存的响应。

使用 Cache Storage API 的 Service Worker 绕过网络进行响应。

尽可能避免访问网络是提供可靠快速的性能的关键一环。

“同态”JavaScript

我想介绍的另一个概念是有时所谓的“同态”或“通用”JavaScript。简单来说,就是可以在不同的运行时环境之间共享同一 JavaScript 代码。在构建 PWA 时,我想在后端服务器和 Service Worker 之间共享 JavaScript 代码。

以这种方式共享代码的有效方法有很多,但我的方法是使用 ES 模块作为权威源代码。然后,我结合使用 BabelRollup 为服务器和 Service Worker 转译和捆绑了这些模块。在我的项目中,文件扩展名为 .mjs 的文件是位于 ES 模块中的代码。

服务器

在牢记这些概念和术语,我们来深入了解一下我如何实际构建 Stack Overflow PWA。首先,我会介绍我们的后端服务器,并说明它如何融入整个架构。

我一直在寻找动态后端和静态托管的组合,我的方法是使用 Firebase 平台。

收到传入请求时,Firebase Cloud Functions 会自动启动基于节点的环境,并与我已熟悉的热门 Express HTTP 框架集成。它还为网站的所有静态资源提供开箱即用的 hosting。我们来看看服务器如何处理请求。

当浏览器向我们的服务器发出导航请求时,它将经历以下流程:

简要介绍如何生成服务器端导航响应。

服务器根据网址路由请求,并使用模板逻辑创建完整的 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 模块中,该模块可以导入到服务器和 Service Worker 中。

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 考虑在内。

我们的 Service Worker 会处理针对给定网址的传入导航请求,就像我的服务器一样,它会结合使用路由和模板逻辑来确定如何响应。

方法与之前相同,但使用不同的低级别基元,例如 fetch()Cache Storage API。我使用这些数据源构建 HTML 响应,然后 Service Worker 将其传回网页应用。

Workbox

我将基于一组名为 Workbox 的高级别库构建 Service Worker,而不是从低级别基元开始。它为任何 Service Worker 的缓存、路由和响应生成逻辑奠定了坚实的基础。

路线

与我的服务器端代码一样,我的 Service Worker 需要知道如何将传入请求与相应的响应逻辑相匹配。

我的方法是将每个 Express 路线转换为相应的正则表达式,以便利用一个名为 regexparam 的实用库。执行转换后,我可以利用 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 会自动读取此文件,每当我重新构建网站时,它都会run

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 模板。

另一种策略实现了过时时重新验证缓存逻辑,并在条目达到 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 的服务器和 Service Worker,但有最后一点逻辑需要注意:我的每个页面在完全流式传输后,会运行少量 JavaScript

这段代码会逐步增强用户体验,但并不重要 - Web 应用即使不运行也仍可正常工作。

页面元数据

我的应用使用客户端 JavaScipt 根据 API 响应更新页面的元数据。因为我会为每个网页使用相同的初始缓存 HTML 位,所以 Web 应用最终会在文档头部使用通用标记。但是,通过在模板代码与客户端代码之间协调一致,我可以使用特定页面的元数据更新窗口的标题。

作为模板代码的一部分,我的方法是添加一个包含正确转义字符串的脚本标记。

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 中运行等效的路由和模板代码。

最糟糕的情况

布局 / 设计不一致

如果忽略这些隐患,会发生什么情况?可能会出现各种失败的情况,但最糟糕的情况是,回访用户

最糟糕的情况:路由损坏

或者,用户可能会遇到由您的服务器(而不是您的 Service Worker)处理的网址。满是僵尸布局和死胡同的网站并不可靠的 PWA。

成功秘诀

但您并不孤单!以下提示可帮助您避免这些误区:

使用具有多语言实现的模板和路由库

尝试使用具有 JavaScript 实现的模板和路由库。现在,我知道并非每个开发者都能从您当前的网络服务器和模板语言迁移。

不过,许多流行的模板和路由框架都有多种语言的实现。如果您能够找到与 JavaScript 以及当前服务器所用语言均兼容的模型,您就距离 Service Worker 与服务器保持同步更近了一步。

首选依序(而非嵌套)模板

接下来,我建议使用一系列可接连流式传输的依序模板。网页后续部分可以使用更复杂的模板逻辑,但前提是您能尽快流式传输到 HTML 的初始部分。

在 Service Worker 中同时缓存静态和动态内容

为获得最佳性能,您应该预缓存网站的所有关键静态资源。您还应设置运行时缓存逻辑来处理动态内容,例如 API 请求。使用 Workbox 意味着,您可以在经过良好测试、可直接用于生产环境的策略的基础上进行构建,而无需从头开始实现。

仅在绝对必要时才会在网络上屏蔽

与此相关的是,只有当无法从缓存流式传输响应时,才应在网络上进行屏蔽。与等待新数据相比,立即显示缓存的 API 响应通常可以带来更好的用户体验。

资源