Remix 全栈 Web 开发(三)
在本章中,您了解到 Remix 支持不同的数据获取策略。在从缓慢的端点获取数据时,可以通过延迟加载数据来利用 Remix 解决 Remix 应用的性能瓶颈。Remix 的defer函数检测加载数据中的未解决承诺,并在解决后将其流式传输到客户端。ReactSuspense和 Remix 的Await组件用于在 React 中管理延迟加载数据。您还了解到使用defer需要回退 UI 来传达加载状态。现
原文:
zh.annas-archive.org/md5/01f9f3d7f1c9ae14a772b9c9cf48d6f3
译者:飞龙
第十三章:延迟加载器数据
在服务器上执行数据加载可以加快初始页面加载时间并提高核心 Web 性能指标,如 最大内容渲染(LCP)。然而,如果请求特别慢,服务器端数据获取也可能成为瓶颈。对于这种情况,Remix 提供了一种替代的数据获取方法。
在本章中,我们将使用 Remix 的 defer
函数,并学习如何利用 HTTP 和 React 流式传输、React Suspense
以及 Remix 的 Await
组件来延迟慢速加载器数据请求。本章分为两个部分:
-
向客户端流式传输数据
-
延迟加载器数据
首先,我们将讨论服务器端数据获取的权衡,并回顾使用 Remix 的 defer
函数的要求。接下来,我们将在 BeeRich 中使用 Remix 的 defer
函数,并练习使用 React Suspense
和 Remix 的 Await
组件。
在阅读本章之后,您将了解如何使用 defer
来提高您的 Remix 应用程序的性能。您还将学习与 HTTP 和 React 流式传输一起工作的要求。最后,您将理解延迟加载器数据的权衡,并知道何时在 Remix 中使用 defer
。
技术要求
在我们开始本章之前,我们需要更新一些代码。请在继续之前遵循 GitHub 上的本章文件夹中 README.md
文件中的步骤。您可以在以下位置找到本章的代码:github.com/PacktPublishing/Full-Stack-Web-Development-with-Remix/tree/main/13-deferring-loader-data
。
向客户端流式传输数据
存在几种不同的数据获取策略。我们可以使用客户端 fetch
请求在客户端启动数据获取,或者执行服务器端数据获取以利用服务器端渲染。我们甚至可以在构建时获取数据以用于静态站点生成。在本节中,我们将讨论服务器端数据获取的权衡,并回顾 HTTP 流式传输的要求。
促使服务器端数据获取和流式传输
Remix 推崇使用 loader
函数在每个路由上从服务器获取数据,而不是在组件级别获取数据。在初始页面加载期间,loader
函数在 React 在服务器上渲染之前被调用。这保证了加载器数据可用于服务器端渲染步骤,消除了客户端数据获取逻辑和加载状态的需求。
在客户端初始化数据获取时,我们首先需要加载 HTML 文档,然后等待 JavaScript 包下载并执行,之后才能执行所需的获取请求。这导致在 LCP 最终确定之前,需要进行三次客户端到服务器的往返。相比之下,我们可以通过服务器端数据获取和渲染,在客户端到服务器的单次往返后绘制 LCP。减少客户端到服务器的往返次数几乎总是会导致更快的响应时间和改进的核心 Web Vitals。
让我们通过一个示例来了解服务器端数据获取如何提高初始页面加载的 LCP。假设我们维护一个电子商务网页,展示产品的图片和一些关于产品的附加信息,如产品名称和价格。
首先,让我们假设我们只操作客户端 SPA。当用户访问我们的网页时会发生什么?
图 13.1 – 客户端数据获取瀑布图
如图 13.1所示,以下请求是从浏览器执行的:
-
浏览器请求 HTML 文档。
-
浏览器请求文档中引用的脚本和其他资源。
-
React 应用正在运行并获取产品信息。浏览器执行获取请求。
-
React 应用使用获取的数据重新渲染,浏览器请求 HTML 中链接的资产,如产品图片。下载的资产用于绘制 LCP。
我们执行了四个后续请求来显示产品图片并最终确定 LCP,每个请求都增加了请求瀑布并延迟了 LCP。
现在,让我们假设我们使用 Remix 来渲染产品页面。需要多少个客户端请求才能最终确定 LCP?
图 13.2 – 服务器端数据获取瀑布图
如图 13.2所示,以下请求是从浏览器执行的:
-
浏览器请求 HTML 文档。接收到的文档已经包含了产品信息和图像 HTML 元素。
-
浏览器请求产品图片以及其他链接资源。下载的资源用于绘制 LCP。
使用服务器端数据获取,我们只需要两个客户端到服务器的往返来渲染产品页面。这是一个显著的改进。
发生了什么变化?Remix 通过将数据获取移动到服务器来简化请求瀑布。这样,图像和其他资源可以与 JavaScript 包并行加载。
不幸的是,当在loader
函数中执行特别慢的请求时,这种模型可能不起作用。由于我们在服务器端渲染我们的 React 应用之前等待所有loader
函数完成,慢速请求可能成为我们应用的瓶颈并减慢初始页面加载。在这种情况下,我们可能需要寻找替代方法。
一种可能的解决方案是在从服务器下载初始页面之后从客户端获取慢速请求。然而,这会导致前面概述的请求瀑布效应——进一步延迟慢速数据响应。幸运的是,Remix 提供了一套简单的原语来延迟获取承诺,并改为将响应流式传输到客户端。
流允许我们在完整响应尚未最终确定的情况下向客户端发送字节。React 提供了将服务器端渲染的内容流式传输到客户端的实用工具。React 将在等待其他部分的同时开始向客户端发送渲染内容的片段。使用 Suspense
,React 可以挂起组件子树直到承诺解决。Remix 通过使用 defer
函数和 Await
组件构建在 React Suspense
之上,以延迟特定的 loader 数据请求。
Remix 的 loader
函数在路由级别获取数据以避免网络瀑布效应。如果一个请求特别慢,有成为瓶颈的危险,我们可以拉另一个杠杆来延迟该请求。这是通过 HTTP 流和 Web 流 API 实现的。在下一节中,我们将讨论使用 Remix 利用 HTTP 流的要求。
理解 HTTP 流的要求
由于 Remix 的 defer
函数使用 HTTP 和 React 流,我们只能在支持 HTTP 流式响应的服务器环境中使用它。在本节中,我们将讨论 HTTP 流和 defer
的要求。
在 第三章,部署目标、适配器和堆栈,我们学习了 Remix 如何利用适配器在不同的 JavaScript 运行时和服务器环境中运行。某些环境,例如传统的无服务器环境,可能不支持流式响应。在评估托管提供商和运行时时要记住这一点。
幸运的是,越来越多的环境支持 HTTP 流,并且默认情况下,Remix 已配置为使用 React 流。即使不使用 defer
,这也是一件好事,因为它可以加快初始文档请求的速度。使用 HTTP 流,客户端可以在不需要等待完整响应最终确定的情况下开始接收响应的部分。
要确定你的 Remix 项目是否已配置为使用 React 流,你可以检查 Remix 项目中的 app/entry.server.tsx
文件。搜索 renderToPipeableStream
函数。如果正在使用,你可以确信 React 流已配置。否则,你可以遵循 Remix 的 defer
指南来设置 React 流:remix.run/docs/en/2/guides/streaming
(如果你的运行时和托管环境支持的话)。
如果您找不到 app/entry.server.tsx
文件,可能是因为您正在使用 Remix 的默认实现,并且需要通过执行 npx remix reveal
命令来揭示它。您可以在 第二章 “创建新的 Remix 项目” 或 Remix 文档中了解更多关于 entry.server.tsx
文件的信息:remix.run/docs/en/2/file-conventions/entry.server
。
现在您已经了解了 Remix 如何使用 HTTP 和 React 流,让我们在 BeeRich 中尝试一下。在下一节中,我们将练习使用 Remix 的 defer
函数。
延迟加载器数据
并非所有有效载荷对用户来说都同等重要。某些数据可能只出现在页面下方,并且对用户来说不是立即可见的。其他信息可能不是页面的主要内容,但会减慢初始页面加载速度。例如,我们可能希望尽可能快地显示电子商务网站的产品信息。然而,我们可能对延迟加载评论部分以加快初始页面加载时间持开放态度。为此,Remix 提供了 defer
和 Await
原语。在本节中,我们将利用 Remix 的原语与 React Suspense
在 BeeRich 中延迟特定的加载器数据。
如果您还没有查看,请查阅 GitHub 上的此章节的 README.md
文件:github.com/PacktPublishing/Full-Stack-Web-Development-with-Remix/tree/main/13-deferring-loader-data/bee-rich/README.md
。此文件将指导您设置新的费用和发票变更日志。现在,让我们允许用户查看他们费用和发票的所有变更的完整历史记录:
-
让我们从在
dashboard.expenses.$id._index.tsx
路由模块的loader
函数中获取变更日志数据开始:const userId = await requireUserId(request);const { id } = params;if (!id) throw Error('id route parameter must be defined');const expense = await db.expense.findUnique({ where: { id_userId: { id, userId } } });if (!expense) throw new Response('Not found', { status: 404 });expenseLogs array.Note that the current implementation blocks the expense logs query until the expense has been fetched. This increases the initial page load time as we introduce a subsequent database query, something we will fix later.
-
接下来,创建一个
ExpenseLogs
组件:ExpenseLog type from Prisma for the component’s prop type. We wrap it with SerializeFrom as loader data is fetched from the server and serialized as JSON while sent over the network.
-
更新路由模块组件中的
useLoaderData
调用以访问expenseLog
数组:const H3 component:
导入
{ H2, ExpenseLogs }
组件位于编辑费用表单下方:<section className="my-5 w-full m-auto lg:max-w-3xl flex flex-col items-center justify-center gap-5"> <H3>Expense History</H3> localhost to test it out. Execute npm run dev and open an expense details page in a browser window.The new change history is great, but also not the most important aspect of the page. We render the history below the expense and invoice details on the nested detail routes. Most likely, the information will be rendered below the page’s fold.
-
为了避免延迟初始页面加载,使用 Remix 的
defer
函数:import { json helper in the loader function with a defer call:
return defer
在调用具有解析数据时表现得就像 json 一样。魔法只有在我们将未解析的 Promise 延迟时才开始发生。 -
在
expenseLog.findMany
调用之前删除await
关键字:const expenseLogs = Promise to defer changes the behavior of the loader function. The function now returns without awaiting the expenseLog query, and defer will make sure to stream the data to the client once resolved.
-
注意,我们在查询的末尾也链式调用了
then
调用。这是一个技巧,将PrismaPromise
(由findMany
返回)映射到实际的Promise
对象,因为 Remix 的defer
函数需要Promise
实例。 -
嘭!我们破坏了页面,因为
expenseLogs
现在是Promise
类型。我们需要更新我们的 React 代码,使其能够与延迟加载的数据一起工作。首先,从 React 中导入Suspense
和从 Remix 中导入Await
:import { ExpenseLogs component with Suspense and Await:
<expenseLogs 请求。为了通知 Remix 我们正在等待哪个承诺,我们必须将 expenseLogs 加载器数据传递给 Await。我们还可以向 Await 传递一个错误元素组件,以防承诺被拒绝。我们将回调函数作为
Await
的子组件传递。一旦承诺解决,Await
将使用解决的数据调用回调。这确保了ExpenseLogs
组件可以访问解决的expenseLogs
数据。或者,我们可以在子组件中使用 Remix 的useDeferredValue
钩子来访问解决的数据。 -
在本地运行 BeeRich 并注意初始页面加载不包括
expenseLogs
数据。注意,你可能需要延迟
expenseLogs
查询以获得更好的可见性。否则,在本地主机上延迟加载可能太快而无法捕捉。 -
更新
loader
函数中expenseLogs
查询的then
语句:const expenseLogs = db.expenseLog .findMany({ orderBy: { createdAt: 'desc' }, where: { expenseId: id, userId }, }) .then((expense) => setTimeout.
-
现在,检查 UI 中的延迟数据加载和
expenseLogs
数据。相反,渲染了 suspense 回退字符串。一旦expenseLogs
承诺解决,页面将使用expenseLogs
数据重新渲染。注意,
defer
在 UI 中引入了一个待处理状态。重要的是要理解这会影响用户体验。引入加载旋转器应被视为延迟加载器数据的权衡。一旦数据解决,我们可能会引入布局变化,这会影响 SEO,因为网络爬虫现在可以解析回退 UI。 -
接下来,优化
loader
函数中的调用顺序。将费用日志查询移动到费用查询之上:const userId = await requireUserId(request);const { id } = params;if (!id) throw Error('id route parameter must be defined');// Start expense logs query first before we await the expense querysetTimeout call. Make sure you throttle the network and re-add the setTimeout call if necessary to better investigate the experience.
从这个例子中,我们可以总结出 Remix 为我们提供了一种按请求延迟加载器数据的方法。我们可以在loader
函数中为每个请求决定是否要等待或延迟。
记住,我们在添加Await
和Suspense
之前破坏了页面。在loader
函数中返回带有defer
的承诺之前,首先将Await
和Suspense
组件添加到页面中是一种良好的实践。这将帮助你在实现Await
和Suspense
时避免错误。
通过将相同的更改应用到收入路由来练习使用defer
。复制粘贴并将ExpenseLogs
组件适应到dashboard.income.$id._index.tsx
路由模块中。利用该组件并实现本章中练习的相同defer
、Suspense
和Await
流程。使用setTimeout
来测试用户体验。
如果你想更多练习,添加defer
和乐观 UI,如果你需要更多指导。
Remix 提供了杠杆
Remix 提供了杠杆,使我们能够根据我们的应用需求优化用户体验。在考虑defer
时,重要的是要记住,延迟数据加载也可能通过添加待处理 UI 和引入加载旋转器来降低用户体验。
在本节中,您练习了使用 Remix 的defer
和Await
原语进行操作。您现在知道如何使用延迟响应数据流来优化缓慢或次要数据请求,但您也意识到defer
是一个通过引入挂起 UI 来影响用户体验的杠杆。
摘要
在本章中,您了解到 Remix 支持不同的数据获取策略。在从缓慢的端点获取数据时,可以通过延迟加载数据来利用 Remix 解决 Remix 应用的性能瓶颈。Remix 的defer
函数检测加载数据中的未解决承诺,并在解决后将其流式传输到客户端。React Suspense
和 Remix 的Await
组件用于在 React 中管理延迟加载数据。
您还了解到使用defer
需要回退 UI 来传达加载状态。现在您理解了使用defer
会带来影响用户体验的权衡。一方面,延迟加载数据可以加快初始文档请求。另一方面,使用defer
会创建加载 UI,这会导致不同的用户体验。
阅读本章后,您了解到 Remix 使用 React 流来加速文档请求。然而,React 和 HTTP 流并不支持所有服务器运行时和环境。最终,并非所有 Remix 适配器都支持 React 流。由于 Remix 的defer
函数利用了 React Suspense
和 React 流,因此只有当 React 流被支持并设置时,延迟加载数据才有效。
最后,您通过在 BeeRich 中实现支出变更日志来练习了延迟加载数据。
在下一章中,我们将扩展变更日志实现,并添加使用服务器发送事件(SSE)的实时数据响应。
进一步阅读
您可以通过 MDN Web Docs 了解有关 Streams API 的更多信息:developer.mozilla.org/en-US/docs/Web/API/Streams_API
.
Remix 文档包括关于流式传输和defer
的指南:remix.run/docs/en/2/guides/streaming
.
defer
函数的文档可以在以下位置找到:remix.run/docs/en/2/utils/defer
.
在本章中,我们讨论了核心网页关键指标。您可以在以下链接中了解更多关于核心网页关键指标的信息,例如 LCP:web.dev/vitals/
.
第十四章:实时与 Remix
网络平台提供了发送实时数据的标准和能力。通过实时技术,我们可以实现聊天功能和多人 UI,以实现实时协作和交互。我们已经看到几个具有实时功能的 App 在受欢迎程度上增长,并重新定义了它们的产品类别,例如 Google Docs 和 Figma。在本章中,我们将学习使用 Remix 的实时 UI。
本章分为两个部分:
-
与实时技术一起工作
-
使用 Remix 构建实时 UI
首先,我们将比较实时技术并讨论托管提供商和服务器环境的要求。接下来,我们将概述使用 Remix 的实现。最后,我们将通过利用服务器发送事件(SSE)在 BeeRich 中实现一个简单的实时 UI。
阅读本章后,您将了解在 Remix 中与实时技术一起工作的要求。您还将能够命名轮询、SSE 和 WebSocket 之间的区别。最后,您将了解如何在 Remix 中使用 SSE。
技术要求
您可以在此处找到本章的代码:github.com/PacktPublishing/Full-Stack-Web-Development-with-Remix/tree/main/14-real-time-with-remix
。本章不需要进行额外的设置。
与实时技术一起工作
网络平台为实时通信提供了不同的协议和标准。在本节中,我们将回顾不同的技术和方法,并讨论使用 Remix 利用它们的要求。我们将讨论轮询,了解 SSE,并回顾 WebSocket API。首先,让我们看看轮询技术。
理解轮询
轮询是一种客户端拉取技术,其中客户端从服务器请求数据。而不是依赖服务器推送更新,轮询通过间隔来检查服务器上的最新数据。
我们可以区分短轮询和长轮询。短轮询在基于时间的间隔内向服务器发送请求。服务器立即响应,要么提供新数据,要么表示没有变化。长轮询中,服务器只有在有新数据可用时才响应,在此期间保持请求未回答。一旦服务器响应或请求超时,客户端就会发送新的请求。
轮询的优势在于它不需要服务器环境和托管提供商支持 WebSocket、HTTP/2 或长时间运行的流式响应。当与不支持实时协议和标准的服务器环境和托管提供商一起工作时,轮询可以是一个很好的折衷方案。它也更容易实现,并且可能是一个很好的原型设计工具。
轮询的缺点是资源消耗浪费和延迟的实时行为。短轮询会产生许多不必要的请求,而长轮询则迫使服务器处理空闲请求,直到接收到新数据。短轮询还可能基于间隔重试时间延迟实时更新。
接下来,让我们看看 SSE。
理解 SSE
SSE 是一种基于 HTTP 的服务器推送标准,是 HTML5 规范的一部分。SSE 需要客户端和服务器。客户端使用 EventSource
API 请求连接;服务器实现一个端点,返回带有 text/event-stream
媒体类型的流式响应。
流式响应从服务器到客户端创建了一条单向通信线路。这允许服务器向客户端发送事件,而无需客户端使用轮询。
SSE 的优势在于减少了资源消耗。客户端不需要向服务器发送不必要的请求。相反,服务器只在有更新可用时向客户端发送事件。
SSE 的缺点是长时间运行的 HTTP 连接,需要服务器维护。此外,SSE 只提供单向通信线路。客户端无法向服务器发送消息。最后,HTTP/1 只允许服务器同时维护六个并发连接。幸运的是,大多数服务器环境支持 HTTP/2,但 HTTP/1 的限制可能仍然相关,具体取决于您的托管提供商。
理解 WebSocket
WebSocket 是一种通过 Web 的 WebSocket API 实现的通信协议,它创建了一个持久的双向通信线路。与 SSE 和轮询解决方案不同,WebSocket 直接在 TCP 上操作,而不是 HTTP。
一旦建立了 WebSocket 连接,双方(例如,浏览器和服务器)可以同时发送和接收消息。由于该协议在 TCP 上运行,它可以传输不仅 UTF-8 编码的数据,还可以传输二进制数据,使其成为一种性能强大的低级协议。
WebSocket 连接的优势在于其双向通信通道和直接使用 TCP 的性能提升。然而,WebSocket 连接并不被所有托管提供商和 JavaScript 运行时支持,因为它们需要长时间运行的服务器。WebSocket API 也是最复杂实现和利用的,需要设置 WebSocket 服务器。
这三种技术使我们能够实现实时功能。轮询允许我们构建多人 UI,即使我们的服务器环境不支持流式响应或设置 WebSocket 服务器。SSE 为服务器向客户端发送事件和数据提供了一种更简单的方式。WebSocket API 是一种低级协议,允许我们创建双向通信通道,从而创建性能强大且可扩展的多人 UI。
现在您已经了解了轮询、SSE 和 WebSocket API 之间的区别,我们可以在 BeeRich 中实现实时 UI。在下一节中,我们将这样做。
使用 Remix 构建实时 UI
BeeRich 使用 Remix 的 Express.js 适配器,并在一个长时间运行的服务器上运行。因此,BeeRich 可以利用轮询、SSE 和 WebSocket API 来实现实时功能。
短轮询设置简单。我们可以在 Remix 中通过使用 Remix 的 useRevalidator
钩子来实现短轮询:
import { useEffect } from 'react';import { useRevalidator } from '@remix-run/react';
function Component() {
const { revalidate } = useRevalidator();
useEffect(() => {
const interval = setInterval(revalidate, 4000);
return () => {
clearInterval(interval);
};
}, [revalidate]);
}
useRevalidator
钩子的 revalidate
函数会触发 loader
的重新验证。这允许我们重新获取所有 loader 数据,类似于 Remix 在执行 action
函数后重新获取所有 loader 数据的方式。
由于 WebSocket 协议是基于 TCP 的,我们需要在 Remix 应用程序之外使用项目根目录下的 server.js
文件或使用完全不同的服务器环境来创建 WebSocket 服务器和端点。这是可行的,但超出了本书的范围。相反,我们将回顾如何使用 SSE 与 Remix 一起使用。
Remix 的 loader
和 action
函数可以创建基于 HTTP 的资源路由。我们可以在长时间运行的服务器环境中通过使用 loader
函数返回带有 text/event-stream
媒体类型的流响应来实现 SSE 端点。
我们的目标是通知所有使用相同用户登录的设备和打开的浏览器标签页,告知支出和发票数据的变化。我们还希望在检测到此类更改时重新验证 UI。让我们开始吧:
-
首先,在
app/modules/
中创建一个新的server-sent-events
文件夹。 -
接下来,在新建的文件夹中创建一个
events.server.ts
文件,并添加以下代码:import { EventEmitter } from 'events';declare global { // eslint-disable-next-line no-var var emitter: EventEmitter;}global.emitter = global.emitter || new EventEmitter();export const emitter = global.emitter;
我们使用 Node.js 的
EventEmitter
API 并为我们的服务器环境声明一个全局可访问的事件发射器。EventEmitter
对象可用于监听和发出事件。我们将在action
函数中使用emit
函数将数据更改通知给服务器发送事件连接处理代码。注意,
EventEmitter
API 与 SSE 没有关系,但它为我们的 Node.js 服务器环境提供了一种基于事件的通信的便捷方式。 -
现在,在
events.server.ts
中实现一个eventStream
辅助函数:export type SendEvent = (event: string, data: string) => void;export type OnSetup = (send: SendEvent) => OnClose;export type OnClose = () => void;export function eventStream(request: Request, onSetup: OnSetup) { eventStream function creates a new ReadableStream object. The stream object contains a start function. In start, we define the send function, which is responsible for adding events to the stream that will be sent to the client. The code also includes logic to correctly close the stream. Finally, the function returns an event stream Response using the ReadableStream object as the response body.
-
接下来,实现负责提供事件流响应的端点。在
/routes
文件夹中创建一个新的/sse.tsx
路由:import type { LoaderFunctionArgs } from '@remix-run/node';import type { OnSetup } from '~/modules/server-sent-events/events.server';import { emitter, eventStream } from '~/modules/server-sent-events/events.server';import { requireUserId } from '~/modules/session/session.server';export async function loader({ request }: LoaderFunctionArgs) { requireUserId). Next, we implement a helper function that we will pass to eventStream. This helper function uses our EventEmitter object to listen to events on the server. emitter listens for events that match the userId property of the authenticated user and triggers a new sever-sent event using the event stream once such event is received.
-
接下来,将全局
emitter
对象添加到所有支出和发票action
函数中。每当操作成功时,我们希望在服务器上发出server-change
事件并触发对所有连接客户端的新事件:emitter.emit(userId);
-
例如,在
dashboard.expenses._index.tsx
路由模块的action
函数中,在返回重定向之前添加事件发射器调用。这确保了事件仅在操作成功且数据库已更新后发出:emitter object on the global object and can access it on the server without importing it. However, you can also import it if you like:
import { emitter } from ‘~/modules/server-sent-events/events.server’;
-
在将
emit
函数调用添加到费用和发票创建操作之后,将emit
函数调用添加到dashboard.expenses.$id._index.tsx
路由模块中的handleDelete
、handleUpdate
和handleRemoveAttachment
函数。再次强调,在数据修改成功后调用emit
以避免竞争条件。 -
确保你也将相同的更改应用到发票的
action
函数中。你总是可以在本章的解决方案文件夹中查看最终的实现。 -
让我们把注意力转向客户端环境。在
app/modules/server-sent-events
文件夹中添加一个新的event-source.tsx
文件,并实现事件流连接请求:import { revalidate function to revalidate all loader data once a server-sent event is received. The hook uses the EventSource API to connect to the /sse route, where we implemented our event stream loader function. The hook then adds an event listener to listen for server-change events – an arbitrary event name we specified in the loader code.
-
最后,在
dashboard.tsx
路由模块中导入新的钩子,并在路由模块组件中调用该钩子:import { EventSource API and SSE standard.Whenever the user navigates to a dashboard route, we now initiate a request to the `/sse` endpoint. The endpoint authenticates the user and returns a streaming response. The server further listens for events from `action` functions using the `EventEmitter` API. Once the same user calls an `action` function (for example, by submitting a form), the `action` function emits an event that is then handled by the `loader` code of the streaming response. The `handler` function is executed on the server and sends a `server-change` event to all connected clients of the same user. The clients receive the event and initiate a loader revalidation.
-
在终端中通过调用
npm run dev
来本地运行应用程序。 -
通过在两个或更多标签页中打开 BeeRich 来测试实现。你还可以在几个浏览器中运行 BeeRich 以调查实时行为。以相同用户身份登录并更新和删除发票和费用。你能看到费用历史记录随着每次实时变化而增长吗?你能看到不同窗口和标签页中的 UI 更新吗?
干得好!就这样,我们可以在 Remix 中实现实时 UI。然而,当前的实现有一个显著的问题:调用action
函数的客户端会重新验证其 UI 两次——一次是使用 Remix 的内置重新验证步骤,另一次是在接收到服务器发送的事件后。这给服务器和用户的网络带宽带来了额外的负担。你有没有想法如何避免双重重新验证?
考虑自己实现它以练习在 Remix 中使用 SSE!也许你可以使用一个唯一的连接标识符来避免在修改数据的浏览器标签页中重新验证服务器发送的事件。你可以在服务器发送事件的有效负载中添加该标识符,并与客户端上存储的本地版本进行比较。或者,你可以使用 Remix 的shouldRevalidate
路由模块 API 来避免在触发服务器发送事件的action
函数调用后 Remix 内置的重新验证。有关shouldRevalidate
函数的更多信息,请参阅 Remix 文档:remix.run/docs/en/2/route/should-revalidate#shouldrevalidate
。
在本节中,你在 BeeRich 中实现了一个 SSE 端点,以便在用户在不同标签页、浏览器和设备上的action
函数中修改数据时重新验证加载器数据。
摘要
在本章中,你学习了关于实时技术和技巧,以及如何在 Remix 中使用它们。
首先,我们讨论了轮询、SSE 和 WebSocket API,并比较了它们的优缺点。轮询最容易设置。简单的轮询实现不需要在服务器上进行更改。SSE 使用 HTTP 协议提供单向通信线路,而 WebSocket 连接使用 TCP,是双向的。
其次,你了解了 SSE 和 WebSocket 的服务器要求。你现在明白 SSE 需要支持流式响应,而 WebSocket 服务器只能在长时间运行的服务器上运行。
最后,我们在 BeeRich 中通过利用 SEE 实现了一个实时用户界面。我们使用 EventSource
API 实现了一个新的端点和相关的 React 钩子。由于 Remix 的 loader
函数返回 HTTP Response
对象,我们可以使用资源路由在 Remix 中实现服务器端发送事件端点。
在下一章中,我们将学习更多关于会话管理的内容,并讨论 Remix 的高级会话管理模式。
进一步阅读
你可以在 MDN Web 文档中了解更多关于 SSE 和 WebSocket 的信息:
这里是 Remix Conf 2023 上关于 SSE 的一个精彩演讲,由 Alex Anderson 演讲:www.youtube.com/watch?v=cAYHw_dP-Lc
.
Sergio Xalambrí 撰写了一篇关于如何使用 socket.io 配置 Remix 来创建 WebSocket 连接的文章:sergiodxa.com/articles/use-remix-with-socket-io
.
第十五章:高级会话管理
会话管理对于构建良好的用户体验至关重要。通过记住用户设置、选择和偏好,持久化会话数据可以提高用户体验和生产力。
我们在第八章,会话管理中学习了如何管理用户会话。在本章中,我们将研究高级会话管理模式。
本章分为两个部分:
-
管理访客会话
-
实现分页
首先,我们将实现访客会话,并使用 Remix 的 cookie 辅助函数在登录或注册后重定向用户到正确的页面。接下来,我们将学习如何使用 Remix 和 Prisma 添加分页。我们将通过将分页应用于 BeeRich 中的费用和发票列表来练习分页。
在阅读本章后,您将了解如何使用 cookie 在 Remix 中持久化任意会话数据。您还将理解 Remix 的会话 cookie 和 cookie 辅助函数之间的区别。此外,您还将学习何时将会话数据存储在 cookie 中,而不是数据库中。最后,您将了解如何使用 Remix 实现分页。
技术要求
您可以在此处找到本章的代码:github.com/PacktPublishing/Full-Stack-Web-Development-with-Remix/tree/main/15-advanced-session-management
。本章不需要额外的设置。
管理访客会话
在第八章,会话管理中,我们使用了 Remix 的会话 cookie 辅助函数来实现登录和注册流程。在本节中,我们将使用 Remix 的 cookie 辅助函数来持久化额外的会话数据。
您可能还记得在第八章,会话管理中,cookie 是通过Set-Cookie头在服务器上添加到 HTTP 响应中的。一旦收到,浏览器会使用Cookie头将 cookie 附加到所有后续的 HTTP 请求中。
在 Remix 中,我们可以在loader
和action
函数中访问传入的 HTTP 请求。在我们的加载器和操作中,我们可以使用 Remix 的 cookie 辅助函数来解析请求头中的 cookie 数据,并使用它来提升用户体验。
在 BeeRich 中,我们已利用 cookie 来处理用户的身份验证。然而,cookie 还有许多其他用例。
考虑以下高级用例:我们旨在向访客提供我们应用程序功能的一瞥,而不需要账户。访客应能够直接与内容互动。在某个时刻,访客决定创建账户。现在,我们想要确保与访客关联的数据被转移到新的用户账户。我们如何实现这一点?
根据使用场景,会话数据可以使用本地存储、cookies、内存或数据库进行持久化。我们可以直接将所有生成数据存储在本地存储或 cookie 中,并在用户账户创建后一次性提交到数据库。然而,这种方法仅适用于数据不打算对其他用户可见的情况。
如果我们希望将访客生成的内容视为任何其他用户的内容?首先,我们必须为访客分配一个唯一的标识符,以便在不同页面转换之间进行跟踪。每当访客触发一个变更时,我们将持久化数据与唯一标识符关联。一旦访客注册,我们将与访客标识符关联的所有数据迁移到新的用户账户。
在处理会话时生成一个唯一的会话标识符是一个常见的模式,将其存储在 cookie 中是确保我们可以在服务器上访问标识符的好方法。这个例子说明了 cookie 有多么强大。cookie 可以用来实现复杂用户界面和功能。然而,cookie 也可以用来持久化短暂会话数据。
让我们通过在 BeeRich 中实现登录和注册后的重定向流程来练习使用 Remix 的 cookie 助手。如果一个用户未经授权尝试访问仪表板页面,我们目前将其重定向到登录页面。一旦用户登录或注册,我们将用户导航到/dashboard
。现在我们想要更新这个逻辑,并将用户导航到最初请求的仪表板页面。
我们将首先创建一个访客 cookie:
-
在
app/modules
中创建一个visitors.server.ts
文件。 -
接下来,从 Remix 导入
createCookie
并创建一个visitorCookie
对象:import { createCookie } from '@remix-run/node';const visitorCookie = createCookie('visitor-cookie', { maxAge: 60 * 5, // 5 minutes});
createCookie
函数接收一个 cookie 名称参数和一个配置对象。可以在 Remix 文档中找到可能的配置选项列表:remix.run/docs/en/2/utils/cookies#createcookie
。记住,Remix 提供了 cookie 助手实用工具和会话 cookie 助手实用工具。参考
session.server.ts
,我们在这里使用了 Remix 的createCookieSessionStorage
函数。createCookieSessionStorage
提供了三个函数:-
getSession
-
commitSession
-
destroySession
相比之下,Remix 的
createCookie
函数只提供了两个函数:parse
和serialize
。会话 cookie 是 Remix 会话抽象的多种实现之一。另一方面,
createCookie
提供了一个简单的助手来读取(parse
)和写入(serialize
)cookie 到和从 cookie 头。我们使用 Remix 的会话助手来实现用户会话流程,而
createCookie
是一个用于读写 cookie 的实用工具。 -
-
接下来,定义我们将存储在访客 cookie 中的数据类型:
type VisitorCookieData = { redirectUrl?: string;};
我们的目标是在将访客重定向到登录页面之前,持久化访客想要访问的 URL。
例如,想象一个用户登录并正在使用 BeeRich 的仪表板。几天后,该用户想继续使用 BeeRich 管理他们的财务。由于会话已过期,BeeRich 将用户重定向到登录页面。到目前为止,我们在用户成功登录后已将其导航回仪表板,但我们不记得用户确切地停在了哪里。让我们改变这一点!
-
在
visitors.server.ts
中创建一个函数来从请求中获取 cookie 数据:export async function getVisitorCookieData(request: Request): Promise<VisitorCookieData> { const cookieHeader = request.headers.get('Cookie'); const cookie = await visitorCookie.parse(cookieHeader); return cookie && cookie.redirectUrl ? cookie : { redirectUrl: undefined };}
我们使用 cookie 对象来解析
Cookie
头并提取访客 cookie 数据。 -
类似地,创建一个函数将访客 cookie 数据写入
Set-Cookie
头:export async function setVisitorCookieData(data: VisitorCookieData, headers = new Headers()): Promise<Headers> { const cookie = await visitorCookie.serialize(data); headers.append('Set-Cookie', cookie); return headers;}
-
有这些实用工具在位,我们可以读取传入请求的 cookie 数据,并在用户被重定向到登录时将 cookie 写入响应。
-
在
app/modules/session/session.server.ts
中导入setVisitorCookieData
:import { setVisitorCookieData } from '~/modules/session/session.server.ts';
-
接下来,更新
requireUserId
函数,在重定向到登录时添加访客 cookie:export async function requireUserId(request: Request) { const session = await getUserSession(request); const userId = session.get('userId'); if (!userId || typeof userId !== 'string') {url property on the Request object to access the URL the user wanted to visit before the redirect.
-
接下来,打开
_layout.login.tsx
路由模块并导入getVisitorCookieData
函数:import { getVisitorCookieData } from '~/modules/visitors.server';
-
更新
_layout.login.tsx
路由模块的action
函数,使其从访客 cookie 中读取redirectUrl
:try { const user = await loginUser({ email, password }); /dashboard instead.
-
通过在本地运行 BeeRich 来测试实现。
-
首先,登录并访问仪表板上的一个路由。例如,导航到支出详情页面。从地址栏复制 URL 以方便访问,然后从 BeeRich 注销。
-
现在,将复制的 URL 输入到地址栏中。由于我们已注销,我们被重定向到登录页面。
-
接下来,登录到您的账户并注意重定向回请求的仪表板页面。
-
在实现上稍作尝试。注意,无论你离开登录页面多少次,关闭浏览器标签或重新加载它,都不会影响结果。在 cookie 过期前的五分钟内,cookie 会持续存在并记住用户最新的请求 URL。
干得好!同样的流程也适用于注册吗?在 BeeRich 中,并不适用,因为所有仪表板 URL 都是针对特定账户的。然而,想象一个你可以邀请同事协作的应用程序。你可能可以分享一个项目的邀请链接。第一次加入的同事将被重定向到登录页面,但会导航到注册页面以创建新账户。从那里,我们可以利用访客 cookie 来读取邀请 URL 并将新用户导航到协作项目。
通过在注册页面上实现相同的流程来练习使用访客 cookie。遵循_layout.login.tsx action
函数的实现,并在_layout.signup.tsx action
函数中读取访客 cookie 数据,以便相应地引导用户。
在本节中,你练习了使用 Remix 的createCookie
辅助函数,并了解了高级会话管理实现。你现在知道 Remix 的会话 cookie 和 cookie 实用工具之间的区别。接下来,我们将使用 Remix 实现分页。
实现分页
分页是在处理大型和用户生成对象列表时的重要模式。分页将内容分成单独的页面,从而限制了给定页面必须加载的对象数量。分页旨在减少加载时间并提高性能。
在本节中,我们将为 BeeRich 中的费用和发票实现分页:
-
首先,打开
dashboard.expenses.tsx
路由模块,并定义一个页面大小的常量:const PAGE_SIZE = 10;
页面大小定义了我们在费用概览列表中一次显示的费用数量。要查看更多费用,用户必须导航到下一页。
-
更新
dashboard.expenses.tsx
中的loader
函数,并访问一个名为page
的新搜索参数:const userId = await requireUserId(request);const url = new URL(request.url);const searchString = url.searchParams.get('q');@prisma/client`.
import type { Prisma } from ‘@prisma/client’;
-
现在,更新费用数据库查询,使其只为当前页面查询总共 10 项费用,跳过所有之前的页面:
const where: Prisma.ExpenseWhereInput = { userId, title: { contains: searchString ? searchString : '', },};const [count, expenses] = $transaction utility instead of Promise.all for the additional performance benefit of making one big call to the database instead of two.
-
更新
loader
函数的返回语句,使其返回费用列表和计数:return json({ count, expenses });
-
更新
useLoaderData
调用,使其读取更新的加载器数据:const useSearchParams hook to read the page query parameter:
const [searchParams] = useSearchParams();const searchQuery = searchParams.get(‘q’) || ‘’;showPagination 用于显示或隐藏分页按钮。
-
在费用列表(
<ul>…</ul>
)下方添加以下表单:{showPagination && ( <Form expense-search feature.The `page` search parameter. Since the form uses GET, the route’s `loader` function is called (not its `action`).We could also use anchor tags instead of a form. Both initiate an HTTP GET request. The reason we decided to use a form here is so that we can utilize HTML button elements. We want to show the `disabled` attribute.Note that we include a hidden input field for the expense search filter parameter called `q`. This is necessary as we would otherwise reset the search filter when navigating between the different pages. By persisting the filter, the pagination works together with the search functionality and allows us to navigate between different pages of the filtered expenses list.
-
最后,更新搜索表单,以便在搜索过滤器更改时重置分页:
<Form method="get" action={location.pathname}> <input type="hidden" name="page" value={1} /> <SearchInput name="q" type="search" label="Search by title" defaultValue={searchQuery} key={searchQuery} /></Form>
在更新搜索过滤器时,费用数量可能会发生变化。因此,我们需要重置分页。
-
在本地运行 BeeRich 并尝试实现。注意,在页面之间导航时,URL 会更新。
如果创建或删除费用会发生什么?
Remix 在每次突变后都会重新验证所有加载器数据。当我们添加费用时,会调用loader
函数,并更新count
加载器数据。这确保了如果费用超过第一页,将添加分页按钮。结果证明,加载器重新验证几乎解决了本书几乎每个章节中的陈旧数据问题!
类似地,在删除时更新计数值。然而,由于我们在删除后会将用户重定向回他们当前页面,用户可能仍然停留在没有费用的页面上。例如,如果我们有 11 项费用,并在第二页删除最后剩下的费用,用户最终会停留在空页面上。
如果我们在页面上保留分页上一页按钮,以便用户可以导航到上一页,这是可以的。我们通过始终显示分页按钮来确保这一点,如果用户当前不在第一页:
const isOnFirstPage = pageNumber === 1;const showPagination = count > PAGE_SIZE || !isOnFirstPage;
看起来我们已经涵盖了所有边缘情况!做得好!
让我们花点时间反思一下我们与 BeeRich 一起的开发之旅。自从我们开始 BeeRich 的工作以来,我们已经走了很长的路。从头开始,我们构建了一个功能丰富的功能集,包括:
-
带有嵌套路由的路由层次结构
-
与多个模式的 SQLite 数据库集成
-
管理费用和收入的表单
-
用户登录、注册和注销流程
-
服务器端访问授权
-
文件上传功能
-
待定、乐观和实时 UI
-
各种缓存技术
-
使用 React streaming 和 Remix 的
defer
进行延迟数据加载 -
费用和发票列表的分页
恭喜你完成 BeeRich,这是一个充分利用 Remix 和 Web 平台的全栈 Web 应用程序。现在是时候接管 BeeRich 并继续练习了。你可以从为更多链接和重定向添加两个搜索参数 q
和 page
开始,以在不同用户操作和导航中持久化它们。或者,也许你已经有一段时间想要改变某些内容了?现在是时候了!
并且,一如既往地,通过在发票列表的收入路由上实现相同的分页逻辑来练习本章学到的内容。如果遇到困难,请参考 Prisma 和 Remix 文档。如果你需要更多指导,请参考
到实施在费用路由上。
在本节中,我们使用 URL 搜索参数在 Remix 中实现了分页。你学习了如何在不同的表单提交之间传递搜索参数,并在 Remix 中练习了高级会话管理。
摘要
在本章中,你学习了使用 Remix 的高级会话管理模式,并完成了 BeeRich 的工作。
Remix 提供了一个 createCookie
辅助函数来处理 cookie 数据。该函数返回一个 cookie 抽象,用于将 cookie 数据解析和序列化到请求头中。
在阅读本章之后,你了解了如何使用 createCookie
在 cookie 中存储和访问任意用户会话数据。你通过在 BeeRich 的登录和注册流程中添加访客 cookie 来练习与 cookie 一起工作,该 cookie 持续保存访客想要访问的 URL。
你还学会了如何使用 Remix 和 Prisma 实现简单的分页功能。分页是一种可以提高性能并避免处理数据列表时长时间加载时间的模式。利用分页可以限制每次页面加载需要获取的数据量。
在下一章中,我们将学习更多关于在边缘部署 Remix 应用程序的内容。
进一步阅读
你可以在 MDN Web Docs 中找到更多关于通过搜索参数工作的信息:developer.mozilla.org/en-US/docs/Web/API/URL/searchParams
。
你可以通过参考 Remix 文档来了解更多关于 createCookie
辅助函数的信息:remix.run/docs/en/2/utils/cookies
。
你可以在 Prisma 文档中了解更多关于分页的信息:www.prisma.io/docs/concepts/components/prisma-client/pagination
。
第十六章:为边缘开发
边缘是一个多面性的术语,在不同的上下文中可能意味着不同的事物。它可能表示一个位置、一个运行时或一种计算范式。您可能还记得从第三章,部署目标、适配器和堆栈,中了解到 Remix 可以被部署到各种服务器环境,包括边缘环境。在本章中,我们将深入探讨为边缘开发,并探讨开发在边缘环境中运行的 Remix 应用程序的含义。
本章分为两个部分:
-
在边缘生活
-
理解边缘的优势和限制
首先,我们将讨论边缘计算并定义相关概念。接下来,我们将考虑在边缘托管 Remix 的好处和限制。
在阅读本章之后,您将了解部署到边缘的含义,并了解在与边缘环境中的 Remix 一起工作时需要考虑什么。此外,您还将了解流行的边缘提供商,并能够讨论边缘作为位置和运行时的优点、缺点和限制。
在边缘生活
边缘计算是一种已经存在多年的范式,但随着物联网(IoT)的兴起而备受关注。当 CDN 开始提供新的 JavaScript 运行时,以便在边缘托管 Web 应用程序时,该术语在 Web 开发中也找到了新的含义。在本节中,我们将定义在边缘运行网站的含义,并了解使用 Remix 进行边缘开发的样子。首先,让我们退一步,理解“边缘”一词的不同含义。
边缘计算
边缘计算是一种与云计算相对的计算机科学范式。它描述了一种系统架构,其中计算位于其利用点尽可能近的位置。虽然云计算发生在远程数据中心,但边缘计算旨在将计算定位在给定网络的边缘。这就是为什么我们经常使用“边缘”一词来描述与云的庞大数据中心形成对比的位置。
边缘计算的目标是通过将服务器移至用户附近来减少客户端到服务器的往返时间。除了其他方面,边缘计算得益于计算可用性的增加和成本的降低。如果计算能力可用,为什么不更靠近用户进行计算呢?
想象一下一款设计用来自动检测运动并在发现可疑活动时触发警报的安全摄像头。在基于云的设置中,摄像头将视频流发送到数据中心进行分析。如果检测到运动,中央系统会触发建筑物的警报。另一方面,使用基于边缘的架构,摄像头可能直接在设备上处理视频流。如果它检测到运动,摄像头本身会向建筑物的中央服务器发送警报,然后激活警报系统。
边缘计算需要在网络边缘有可用的计算能力,而云计算则利用了集中式数据中心的计算能力。通过边缘计算,我们可以通过避免往返云端的行程来减少响应时间和网络带宽。然而,所需的计算能力必须是可用的。有时,我们可能需要重新思考我们的应用程序及其运行时,使其更轻量级且适合边缘。这就是为什么我们可能会使用“边缘”一词来描述针对边缘优化的运行时环境。
为了明确,我们不会尝试在安全摄像头上部署和运行 Remix。边缘计算是一种分布式计算范式,可以应用于许多用例,例如物联网。物联网是边缘计算的一个例子,其中智能设备在边缘网络中通信,无需将收集到的数据直接流式传输到云端进行处理。
在网络开发中,边缘计算发生在高度地理分布的数据中心,与传统的云计算提供的集中式数据中心相比,大大增加了与用户的接近程度。接下来,让我们回顾一下今天针对 Web 应用的边缘服务。
在边缘运行 Web 应用
CDN 已经为互联网边缘提供了数十年的内容。总之,边缘计算在 Web 开发中不是一个新概念。真正前沿的(有意为之)是能够在边缘托管动态 Web 应用的能力。
传统上,CDN 用于交付静态内容,包括网页资源(HTML、CSS 和 JavaScript 文件)和媒体文件(图像和视频)。CDN 在尽可能多的地点维护地理分布式的数据中心,并针对可靠性、可扩展性和性能进行了优化。这使得 CDN 不仅适合缓存和提供静态内容,还适合作为边缘计算提供商。
近年来,CDN 已经扩大了其范围,以处理动态内容并提供 Web 应用托管服务。提供边缘运行时的流行 CDN 包括 Cloudflare 和 Fastly。此外,越来越多的托管提供商,如 Netlify 和 Vercel,与 CDN 合作,通过他们的托管平台提供边缘环境。
Remix 是第一个支持在边缘部署和运行的 Web 框架之一。正如您从本书的前几章所知,Remix 是在考虑到各种运行时环境需求的情况下开发的。在下一节中,我们将了解更多关于今天的边缘托管提供商。
边缘计算中的 Remix
理论上,Remix 可以在任何可以执行 JavaScript 的服务器上运行。这是可能的,因为 Remix 利用了适配器架构。Remix 使用适配器在本地服务器运行时和 Remix 之间转换请求和响应。这使得 Remix 可以与各种 Web 服务器库和运行时协同工作。在本节中,我们将回顾如何在边缘部署 Remix。
Remix 为许多流行的部署目标维护官方适配器,但适配器架构也允许社区为任何环境构建适配器。在撰写本文时,以下边缘和类似边缘的部署目标有 Remix 模板:
-
Cloudflare Pages
-
Cloudflare Workers
-
Deno Deploy
-
Fastly Compute@Edge
-
Netlify Edge Functions
-
Fly.io
-
Vercel Edge Functions
将 Remix 部署到边缘就像选择一个边缘模板并将其部署到相关服务提供商一样简单。通过运行以下命令尝试一下:
npx create-remix@2 --template remix-run/remix/templates/cloudflare-workers
按照create-remix
脚本的说明操作,然后打开引导的README.md
文件。README.md
将指导您将应用程序部署到 Cloudflare Workers。就像那样,您使用 Remix 将应用程序部署到了边缘。
注意,列出的边缘和类似边缘的部署目标之间存在差异。CDN 使用与 Node.js 不兼容的轻量级 JavaScript 运行时。类似边缘的部署目标,如 Deno Deploy 和 Fly.io 提供区域分布,但可能比它们的 CDN 对应物提供更少的邻近性。您可以参考第三章,部署目标、适配器和堆栈,了解更多关于 Remix 的不同部署目标及其运行时和环境。
在本节中,您学习了边缘计算是一种分布式计算范式,并了解了它与云计算的区别。您还回顾了 Remix 可用的边缘部署目标,并将 Remix 应用程序部署到了边缘。您还了解了从边缘提供服务如何提高响应时间。也许您想知道为什么我们没有构建 BeeRich 在边缘上运行。在下一节中,我们将考虑在边缘运行的局限性,并进一步讨论利弊。
理解边缘计算的优势和局限性
在上一节中,您了解到边缘计算关乎性能。通过将计算更靠近用户,我们可以减少响应时间,从而提高用户体验。让我们深入了解,了解更多关于边缘的优缺点。
边缘环境遵循无服务器编程模型。每个传入请求都会启动一个新的边缘函数。该函数运行 Web 应用程序(我们的 Remix 应用程序)以满足请求,然后关闭。
无服务器执行避免了在空闲应用程序上浪费计算能力。然而,无服务器也减少了 Web 应用程序的能力,将其缩短为处理传入请求后关闭的短期函数。例如,无服务器函数不能用于长时间运行的任务,如维护服务器发送事件端点或 WebSocket 服务器。
与大多数无服务器环境一样,边缘函数也无法访问文件系统,无法读写文件。这要求我们利用远程服务来存储文件。此外,边缘函数不提供可以在不同请求之间共享的长久应用状态。这阻止了我们缓存数据或在内存中管理用户会话。
边缘提供商使用轻量级运行时,使 Web 应用程序的计算密集度降低。今天的大多数基于 CDN 的边缘运行时都在 V8 隔离器上运行,这是 V8 引擎中的隔离上下文。启动 V8 隔离器比启动容器或虚拟机更快。这使得边缘应用程序能够在毫秒内处理请求。大多数传统无服务器函数在一段时间休眠后启动时,会遭受数百毫秒的冷启动时间。边缘函数则不会遇到同样的冷启动问题。
大多数边缘原生运行时,如 Cloudflare 的 workerd,都是考虑到 Web 标准设计的,但它们不支持执行 Node.js 标准库。这使得它们与 Node.js 不兼容。最终,我们只能使用不内部使用 Node.js 标准库的 npm 包。这可能会或可能不会成为问题,具体取决于应用程序的使用情况,但确实是一个需要考虑的点。
边缘相对于传统 Web 托管的一个大优势是全球分布。大多数服务器和无服务器环境不会自动在不同区域之间分发应用程序,至少不是没有额外的配置开销和成本。边缘计算使我们能够以最小的配置努力和显著降低的价格点在全球范围内分发 Web 应用程序。然而,地理分布也增加了相关系统架构的复杂性。
区域分布只有在减少请求背后的总往返时间时才会降低响应时间。请参考第十三章中的图 13.2,延迟加载器数据,其中我们说明了 Remix 如何通过移除客户端-服务器往返来减少响应时间。我们可以在文档请求上执行loader
函数并查询附近数据库,而不是从客户端向服务器发起 fetch 请求。注意,在图 13.2中,从服务器到数据库的往返时间非常小。我们假设数据库靠近服务器 – 例如,在同一个云区域、数据中心,甚至同一地点。图 16.1说明了如果数据库远离服务器,响应时间可能会增加:
图 16.1 – 带远程数据库的边缘响应瀑布图
通过将服务器靠近用户,我们可能能够减少客户端-服务器往返次数。然而,每次客户端-服务器往返可能会触发几次服务器-数据库往返。如果这些往返由于服务器和数据库之间的距离而增加,我们可能会降低整体性能。
图 16*.1* 假设我们进行两次独立的数据库查询以满足文档请求。正如我们所见,我们进一步假设我们可以并行执行这两个数据库请求。然而,有时我们可能需要执行后续请求。注意这些请求将如何进一步延迟响应时间。
今天网络应用程序的性能很大程度上取决于服务器和数据库之间的距离。在云数据中心和区域中,数据库通常靠近网络服务器。然而,为了在边缘环境中实现服务器和数据库的邻近性,我们必须分布我们的数据库。
地理上分布的数据库服务存在,CDN 也开始提供分布式键值和 SQL 数据库,但考虑全球分布式系统架构的成本和复杂性是很重要的。
你可能会注意到这里有一个模式。边缘函数提供了计算和地理上的可扩展性,但引入了额外的复杂性。在评估项目中的边缘时,你必须仔细权衡所讨论的利益和考虑因素。
让我们通过进行一个简短的思想实验来结束。BeeRich 需要哪些部分进行重新设计才能在边缘环境中运行?对于类似 Fly.io 或 Deno Deploy 这样的边缘环境,不多。然而,对于像 Cloudflare Workers 和 Pages 这样的真正边缘环境,我们需要进行重大的更改:
-
SQLite 数据库在同一台机器上运行并需要文件系统访问。SQLite 不被边缘运行时支持。我们需要使用不同的数据库。
-
费用和发票附件文件上传功能需要重新设计。我们目前使用服务器的文件系统。我们需要使用第三方文件存储服务或构建一个自定义的存储服务。
-
实时更新功能需要重新设计。我们目前使用服务器发送事件端点来更新客户端关于数据变化的信息。服务器发送事件需要长期运行的连接,而这些连接不被边缘运行时支持。我们必须将服务器发送事件端点部署到不同的长期运行服务器上。
这个例子说明了长期运行的服务器支持更简单的应用程序设计,而无服务器边缘运行时由于其可扩展性和性能驱动的特性而引入了限制。
在本节中,你了解了边缘的好处和局限性。我们还讨论了在边缘环境中不可能实现的事情。有了这些考虑,你现在可以评估将项目迁移到边缘是否值得。
摘要
在本章中,你了解了边缘作为一种计算范式、一个位置和运行时。你现在明白边缘计算与云计算形成对比,旨在将计算尽可能靠近用户以减少响应时间。
你进一步了解到 CDN 可以作为互联网的边缘。在边缘运行 Remix 将 Web 服务器移动到比云的区域集中数据中心更接近用户的位置。
Remix 为多个边缘部署目标提供了适配器,你通过使用 Remix 的create-remix
脚本来练习部署到边缘。你现在明白在边缘设置 Remix 应用程序是多么容易。
我们讨论了边缘作为部署目标的好处和局限性。你现在明白边缘遵循无服务器编程模型,这使得它具有高度的可扩展性,但也引入了复杂性。边缘运行时使用轻量级容器技术来优化地理分布和性能。地理分布引入了额外的考虑因素,例如到数据库的距离。
最后,你学习了在边缘无法完成的事情,例如访问文件系统、在请求之间在内存中共享应用程序状态,以及处理长时间运行的任务和连接。
在下一章和最后一章中,我们将回顾我们已经学到的内容。我们将进一步涉及一些最终话题,例如迁移策略和 Remix 的版本控制。
进一步阅读
查阅 Remix 文档以获取官方和社区适配器的列表:remix.run/docs/en/2/other-api/adapter
。
你可以在这里找到有关 Fastly 的 Remix 适配器的更多信息:www.fastly.com/blog/host-your-remix-app-on-fastly-compute-edge
。
参考这篇文章了解如何将你的 Remix 应用程序部署到 Netlify 的边缘函数:www.netlify.com/blog/how-to-use-remix-framework-with-edge-functions/
。
如果你想了解更多关于边缘环境的信息,请查看 Cloudflare 的学习资源:developers.cloudflare.com/workers/learning/how-workers-works/
。
第十七章:迁移和升级策略
在本书中,我们探讨了使用 Remix 的许多网络开发方面。您学习了如何使用 Remix 来释放网络平台的全部潜力,并通过构建 BeeRich 完成了全栈应用程序的开发实践。在本章的最后,我们将讨论迁移和升级策略。
本章分为两个部分:
-
迁移到 Remix
-
保持 Remix 应用程序更新
首先,我们将讨论如何迁移到 Remix。不同的应用程序可能需要不同的迁移策略,工作量也各不相同。我们将查看非 React、React 和 React Router 应用程序,并为每个创建一个迁移策略。接下来,我们将学习 Remix 中主要版本升级的推出方式。我们将向您介绍 Remix 的未来标志,并讨论未来标志如何使我们能够逐步升级 Remix 应用程序。
在阅读本章之后,您将了解 Remix 的不同迁移策略。您将了解如何与现有的遗留应用程序并行运行 Remix,以及如何使用 React Router 为迁移准备代码库。此外,您将了解 Remix 如何集成到更广泛的系统架构中。最后,您将学习如何通过未来的标志逐步升级您的 Remix 应用程序。
迁移到 Remix
迁移永远不会容易。将现有代码库迁移到新框架会带来困难,可能涉及大量的重构。Remix 也不例外,但某些策略可能会根据现有应用程序架构使迁移不那么痛苦。在本节中,我们将讨论 Remix 的不同迁移策略。让我们首先回顾从非 React 应用程序迁移的案例。
将非 React 应用程序迁移到 Remix
从非 React 应用程序迁移到 Remix 是一项具有挑战性的任务,可能非常耗时,具体取决于现有应用程序的大小。迁移的复杂性通常随着持续的功能开发而增加。大多数时候,我们可能无法在迁移时冻结功能开发和错误修复。这导致在迁移现有代码和功能的同时,还必须在旧的和新的应用程序中实现新功能。
一种解决方案可能是并行运行新旧应用程序。通过这样做,我们可以在提高 Remix 应用程序的同时保持我们的遗留应用程序活跃。逐步地,我们可能能够将越来越多的代码迁移到 Remix。
例如,我们可以在子域上托管新的 Remix 应用程序,并在 Remix 中实现新的页面和流程。使用子域,我们可以在两个应用程序之间共享现有的 cookie。
迁移过程可能看起来像这样:
-
创建一个新的 Remix 应用程序。
-
在子域上注册 Remix 应用程序以共享 cookie。
-
在 React 中重新实现可重用组件。
-
在 Remix 中重新创建页面布局、页脚和导航栏。
-
在 Remix 中开发新的页面和流程。
-
将现有页面逐步迁移到 Remix。
通过在 Remix 中开发新页面,我们避免了在旧应用和新应用中实现新功能的需求。相反,我们可以通过两个应用之间路由用户来回。我们可以使用 cookie 和 URL 来共享应用程序状态。
同时运行两个应用仍需要我们在前期做一些工作,例如在 React 中重新实现可重用组件和页面布局,但我们可以避免在能够在生产环境中运行 Remix 之前进行完全切换。
如果我们已经在使用 React,那么迁移应该会更容易。
从 React 应用迁移
如果我们维护 React 应用,我们可以重用现有代码库的更大部分。然而,如果我们目前使用的是不同的 React 框架,例如 Gatsby 或 Next.js,那么迁移可能仍然需要在生产环境中同时运行遗留应用和 Remix 应用。
从另一个 React 元框架迁移
不同的 React 框架使用不同的路由约定、原语和组件 API。从另一个元框架迁移可能允许我们重用现有的 React 组件,但仍可能需要重构。
从不同的 React 框架迁移的过程可能如下所示:
-
创建一个新的 Remix 应用程序。
-
在子域上注册 Remix 应用程序以共享 cookie。
-
复制、粘贴并适应可重用组件。
-
复制、粘贴并适应页面布局、页脚和导航栏。
-
在 Remix 中开发新页面和流程。
-
将现有页面逐步迁移到 Remix。
我们可能需要重构现有组件以使用 Remix 的原语和实用工具。例如,我们希望重构现有的锚点标签以使用 Remix 的 Link
和 NavLink
组件。最终,最好是将代码复制到 Remix 中并从那里进行重构。这要求我们在遗留应用和 Remix 应用之间维护重复的代码。
如果我们运行没有框架的仅客户端 React 应用程序,那么会更容易。让我们回顾如何将仅客户端的 React 应用迁移到 Remix。
从仅客户端的 React 应用迁移
如果我们维护 Create React App 或 Vite React 应用(仅客户端),我们可能更容易迁移到 Remix,特别是如果应用程序已经使用了 React Router。
在客户端,Remix 运行客户端 React 应用程序,并且大多数 React 代码和客户端请求将像之前一样在 Remix 中工作。因此,我们可以在 Remix 内部运行现有应用。从那里,我们可以逐步重构客户端仅应用的部分到 Remix 路由。
从仅客户端的 React 应用迁移的过程可能如下所示:
-
创建一个新的 Remix 应用程序。
-
将现有应用移入新的 Remix 应用程序中。
-
在
index
路由中渲染现有应用。 -
复制并适应页面布局、页脚和导航栏。
-
在 Remix 中开发新页面和流程。
-
将现有页面逐步迁移到 Remix。
我们可能仍然需要复制和粘贴现有的组件来创建与 Remix 兼容的版本。然而,至少目前,我们可以在同一个代码库中这样做。
如果我们使用 React Router 作为客户端路由解决方案,迁移将变得容易得多。
从 React Router 迁移
Remix 是由 Michael Jackson 和 Ryan Florence 创建的,他们是 React Router 的创造者。多年来,Remix 一直受到 React Router 开发和维护的深刻影响和启发。
React Router 是一个用于 React 客户端路由的库。自从 Remix 开发以来,Remix 团队还致力于发布 React Router 6 版本,使 React Router 的 API 与 Remix 保持一致。自那时起,Remix 和 React Router 都已重构,以建立在相同的基线路由包之上。
当查看 React Router 6 版本的 API 文档时,你可能会注意到许多熟悉的概念,如 loader
和 action
函数,许多熟悉的钩子,如 useLoaderData
、useActionData
、useNavigation
、useSearchParams
、useFetcher
和 useLocation
,以及熟悉的组件,如 Form
和 Link
。
由于 React Router 是一个客户端路由解决方案,因此其 loader
和 action
函数在客户端执行,而不是在服务器上。然而,React Router 使用与 Remix 相同的导航、数据加载和重新验证流程,这允许我们以相同的思维模型、约定和原语构建 React Router 应用程序。这使得从 React Router 6 版本迁移到 Remix 更加容易。
我们可以为仅使用 React 的应用程序推导出以下迁移过程:
-
迁移到 React Router 6 版本。
-
逐步重构代码以使用 React Router 的原语和约定,最重要的是
loader
和action
函数。 -
从 React Router 6 版本迁移到 Remix。
首先,我们需要迁移到 React Router 6 版本。我们可以遵循 React Router 文档中的现有迁移指南。
一旦我们使用 React Router 6 版本,我们就可以随着时间的推移迭代地重构代码。我们将重构现有的 fetch 请求到 React Router 的 loader
和 action
函数,并利用 React Router 的 Link
和 Form
组件来实现导航和突变——就像在 Remix 中一样。这也允许我们利用 React Router 的生命周期钩子,如 useNavigation
和 useFetcher
,来实现挂起状态和乐观 UI。
与 Remix 相比,React Router 不使用基于文件的路由约定。如果我们想利用 Remix 的基于文件的路由约定——或者任何其他路由约定——那么我们可能需要在客户端应用程序中开始定义它。例如,将路由组件移动到新的 routes/
文件夹,并将 loader
和 action
函数与 React Router 路由组件一起放置,以匹配 Remix 的路由文件约定。
在某个时候,我们不得不切换并迁移应用程序到 Remix。我们将应用程序与 Remix 的路由约定和数据流越接近,效果就越好。然而,在迁移之前,没有必要将所有内容重构为 loader
和 action
函数,尽管这样做可能会有所帮助。
我们可以在 Remix 中渲染客户端 React Router 路由,正如前文所述。自然地,这并不像将路由迁移到 Remix 那样有效,但对于大型应用程序来说,这可能是一个有效的选项,以确保及时迁移。
你可以在 Remix 文档中了解更多关于从 React Router 版本 6 到 Remix 的增量迁移信息:remix.run/docs/en/main/guides/migrating-react-router-app
。
既然我们已经讨论了迁移客户端代码的策略,让我们回顾一下后端代码。
与后端应用程序一起工作
Remix 的 loader
和 action
函数在服务器上运行。我们可以使用它们直接从数据库中读取和写入,并使用资源路由实现 webhooks 和服务器端发送事件端点。我们可以使用 Remix 来实现不需要额外后端应用程序的独立全栈应用程序。在本节中,我们将讨论 Remix 如何适应更大的系统架构,以及当存在下游后端应用程序时如何利用 Remix。
在大型应用程序架构中,可能存在更多系统在客户端应用程序和数据库之间。在这种情况下,Remix 将作为我们前端的服务器。
让我们回顾一下 第一章 中的代码示例:
export async function action({ request }) { const userId = await requireUserSession(request);
const form = await request.formData();
const title = form.get("title");
return createExpense({ userId, title });
}
export async function loader({ request }) {
const userId = await requireUserSession(request);
return getExpenses(userId);
}
export default function ExpensesPage() {
const expenses = useLoaderData();
const { state } = useTransition();
const isSubmitting = state === "submitting";
return ( <>
<h1>Expenses</h1>
{expenses.map((project) => (
<Link to={expense.id}>{expense.title}</Link>
))}
<h2>Add expense</h2>
<Form method="post">
<input name="title" />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Adding..." : "Add"}
</button>
</Form> </>
); }
在接收到的请求中,loader
函数获取一个支出列表。路由组件渲染支出列表和支出表单,提交时将数据发送到同一路由的 action
函数。
注意我们在 loader
和 action
函数中如何调用 createExpense
和 getExpense
辅助函数。我们可以实现这些函数以从数据库中读取和写入。然而,我们也可以实现这些函数以从下游后端服务中 fetch
。
同样,我们可以实现 requireUserSession
来向下游认证服务发送请求,而不是在我们的 Remix 应用程序中实现认证代码。最终,Remix 也可以用来将请求转发到后端应用程序并实现 Backend for Frontend (BFF)模式。
Backend for Frontend
BFF 模式指定了一种软件架构,其中每个前端都有一个专用的后端,用于为前端应用程序的特定需求定制内容。然后后端将请求转发或协调到更通用的下游服务。
我们不需要将我们的后端应用程序与前端应用程序同时迁移到 Remix。相反,我们可以将前端请求转发到遗留的后端应用程序。然后我们可以逐步将后端代码迁移到 Remix 的loader
和action
函数中。或者,我们也可以与 Remix 应用程序一起维护后端应用程序。在更大的系统架构中,可能希望仅使用 Remix 作为 Web 服务器,并使用通用的后端服务来实现可跨不同客户端重用的 REST API。
在本节中,你学习了如何将不同的应用程序迁移到 Remix。你现在理解了如何将 Remix 用作 BFF。在下一节中,你将学习如何保持你的 Remix 应用程序更新。
保持 Remix 应用程序更新
Remix,就像每个框架一样,经历着持续的维护和开发。较大的更新以包含重大更改的主要版本的形式引入。升级到较新的主要版本可能需要重构,特别是对于大型应用程序,这可能是一项痛苦的任务。Remix 旨在使升级到主要版本尽可能无痛。在本节中,我们将了解如何在 Remix 中逐步迁移到较新的主要版本。
就像大多数开源项目一样,Remix 使用语义版本控制来表示其补丁和更新。语义版本控制提供了一种在确定性的层次结构中记录三种不同类型更改的方法:
-
2.x.x
:增加第一个数字的更改是包含重大更改的主要版本。 -
x.1.x
:增加中间数字的更改是引入新功能但保持向后兼容的次要版本。 -
x.x.1
:增加最后一个数字的更改是向后兼容的错误修复和依赖项补丁。
一个新的主要版本破坏了向后兼容性,这意味着你必须更新现有代码才能升级到该主要版本。这可能是一个痛苦的过程。幸运的是,Remix 团队提供了未来标志来避免一次性升级过程。
未来标志是可以在remix.config.js
文件中指定的布尔标志:
/** @type {import('@remix-run/dev').AppConfig} */module.exports = {
future: {
v2_errorBoundary: true,
v2_meta: true,
v2_routeConvention: true,
},
};
当 Remix 团队完成新主要版本的某个功能时,它也会在之前的主要版本中发布该功能,但隐藏在未来的标志后面。这意味着我们可以在下一个主要版本发布之前开始使用之前版本的新功能。通过利用未来标志,我们可以逐步(逐个功能)重构我们的代码。
Remix 团队区分两种类型的未来标志:
-
不稳定标志
-
版本标志
不稳定的未来标志(unstable_
)用于 API 仍在积极开发且可能发生变化的特性。这些特性是不稳定的,API 可能在未来的版本中被移除或更改。
一旦一个不稳定的功能变得稳定,该功能可能被引入到小版本更新中,或者转换为一个版本未来标志(vX_
)。基于版本的特性标志允许在当前的 Remix 版本中实现稳定的 API 更改。启用基于版本的特性标志允许开发者为下一个主要版本更新做准备。例如,v2_meta
未来标志用于在 Remix v1 中启用 Remix v2 的更新元函数 API。
未来标志允许 Remix 团队对 Remix 的原语和约定进行迭代,并逐个发布新功能,在当前的主要版本中。这也允许团队尽早收到反馈,并尽早识别潜在的问题和错误。
未来标志并不消除在现有更改中对现有代码进行重构的需要,但它们允许逐步重构,这些重构可以随着时间的推移而扩展。
摘要
在本章中,我们讨论了 Remix 的不同迁移策略。你学习了将非 React、React 和 React Router 应用程序迁移到 Remix 的策略。
对于更大的迁移,你可以在生产环境中并行运行新的 Remix 应用程序和旧的遗留应用程序。你可以在 Remix 中构建新页面,同时逐步将功能从旧应用程序迁移到 Remix。使用子域名为你新的 Remix 应用程序,你可以使用 cookies 共享 UI 状态。
现在,你理解了 React Router 和 Remix 使用相同的基线路由实现。因此,从 React Router 应用程序迁移到 Remix 更容易,因为你可以通过利用共享的原语和约定来逐步准备你的 React Router 应用程序。这允许你在 React Router 和 Remix 应用程序之间重用大量代码,而无需进一步重构。
在阅读本章之后,你现在理解了如何将 Remix 用作 BFF(后端前端)来转发和编排对下游服务的请求。你知道 Remix 可以独立使用,也可以作为更广泛系统架构的一部分。在迁移到 Remix 时,你可以专注于迁移你的前端代码,同时将所有请求从 Remix 的action
和loader
函数转发到现有的后端应用程序。
最后,你学习了 Remix 的未来标志系统。Remix 提供未来标志来解锁当前版本中即将到来的主要版本的功能。这允许基于每个功能的逐步升级,并避免了需要一次性更新所有代码的痛苦迁移。
在过去的 17 章中,你学习了构建全栈应用程序所需的许多概念,以使用 Remix。作为 React 开发者,Remix 提供了许多优秀的原语、约定和杠杆,让你能够释放 Web 平台的全潜能。由于 Remix 拥抱 Web 平台的理念,你不仅练习了如何使用 Remix,还了解了许多 Web 标准和概念,例如 Web Fetch API、渐进增强、HTTP 缓存头和 HTTP cookies。
Remix 确实是一个全栈 Web 框架,通过遵循本书中的练习,你了解了全栈 Web 开发的许多方面,例如请求-响应流程、用户认证、会话管理、数据验证,以及实现渐进式、乐观式和实时 UI。我很期待看到你接下来会构建什么。快乐编码!
进一步阅读
Remix 文档还包括一篇关于如何从 React Router 迁移到 Remix 的指南:remix.run/docs/en/main/guides/migrating-react-router-app
.
Remix 文档还包括 Pedro Cattori 撰写的一篇文章,记录了如何从 webpack 迁移到 Remix 的过程:remix.run/blog/migrate-from-webpack
.
通过查看 Remix 的发布日志,你可以了解 Remix 的最新发布情况:github.com/remix-run/remix/releases
.
Sergio Xalambrí撰写了一篇文章,介绍了如何在同一服务器上同时运行 Next.js 和 Remix 以进行增量迁移:sergiodxa.com/articles/run-next-and-remix-on-the-same-server
.
你可以在 GitHub 上找到 Remix 的路线图:github.com/orgs/remix-run/projects/5
。你还可以在 YouTube 上找到路线图规划会议:www.youtube.com/c/Remix-Run/videos
.
你可以在这里找到有关语义化版本控制的信息:semver.org/
.
在 Matt Brophy 的这篇博客文章中了解更多关于 Remix 未来标志的方法:remix.run/blog/future-flags
.
你可以在 Remix 文档中了解更多关于 Remix 作为 BFF 的信息:remix.run/docs/en/main/guides/bff
.
更多推荐
所有评论(0)