Remix-全栈-Web-开发-全-
Remix 全栈 Web 开发(全)
原文:
zh.annas-archive.org/md5/01f9f3d7f1c9ae14a772b9c9cf48d6f3译者:飞龙
前言
欢迎来到《使用 Remix 的全栈网络开发》。我们正处于网络开发的激动人心时期。现代网络应用程序复杂,它们运行在复杂的业务逻辑上。作为网络开发者,我们的任务是提供出色的用户体验,使用最新的网络开发进步,并应对当今业务需求复杂性。
React 是构建现代网络应用程序的事实标准,但它明确界定了其职责的结束。它允许你从可重用组件中组合你的 UI,但 React 并不涵盖你应用程序的其他关键元素。这就是 React 需要全栈网络框架来补充的地方。今天的网络框架提供了路由、数据获取和变更、缓存、会话管理、渐进增强、乐观 UI 以及许多其他对于构建最先进网络体验至关重要的抽象。这正是 Remix 发挥作用的地方。
Remix 是一个面向 React 应用程序的全栈网络框架。它是基于不同的部署目标构建的,并利用网络标准来解锁网络平台的全部潜力。Remix 提供了优秀的原语、约定和杠杆,使我们能够更快地构建现代应用程序和出色的用户体验。最重要的是,Remix 基于一个简单的思维模型,通过利用渐进增强的哲学,以降低复杂性来构建复杂用户界面。构建挂起、乐观和实时用户界面从未如此简单。就我个人而言,我从未在构建网络时感到如此高效,也从未如此享受过这个过程!
本书遵循一个真实世界项目,该项目超越了 Remix 文档、教程和工作坊所能提供的内容。在本书结束时,你将能够应用并阐述许多最佳实践,以便与 Remix 一起工作。你将进一步使用网络标准,并学习如何通过 Remix 解锁网络平台的全部潜力。最后,你将了解在应用程序架构中利用网络服务器环境的优势。让我们深入探讨《使用 Remix 的全栈网络开发》并了解 Remix 如何使我们更快地构建更好的用户体验。
本书面向的对象
本书是为希望使用全栈框架来解锁网络平台全部潜力的 React 开发者而写的。本书也将有助于评估和证明迁移到 Remix 的合理性。
如果你符合以下条件,本书适合你:
-
一位希望利用网络标准来构建快速、流畅和弹性用户体验的网页开发者。
-
一位对全栈网络开发以及将网络服务器环境添加到前端的好处感到好奇的 React 开发者。
-
一位正在评估从单页应用程序迁移到 Remix 的技术负责人,希望了解更多关于当今全栈网络框架的信息。
本书涵盖的内容
第一章, 全栈 Web 框架时代,介绍了 Remix 作为全栈 Web 框架,并强调了 Remix 为 React 开发者提供的优势。本章进一步讨论了 Remix 的哲学,并建立了原语、约定和杠杆的心理模型。最后,本章解释了 Remix 在底层是如何工作的。
第二章, 创建新的 Remix 应用,开始了你的 Remix 开发之旅。本章指导你使用 Remix 的create-remix CLI 脚本来创建新的 Remix 应用程序。本章还提供了 Remix 的文件和文件夹结构的概述以及 Remix 的两个环境——客户端和服务器。最后,本章为 Remix 项目提供了故障排除指南。
第三章, 部署目标、适配器和堆栈,概述了 Remix 的不同部署目标,并讨论了选择适合你应用程序的正确目标时的考虑因素。本章进一步详细说明了如何在适配器之间切换,以及如何使用 Remix 堆栈来使用现有的应用程序模板。最后,本章介绍了本书的演示应用程序 BeeRich。
第四章, Remix 中的路由,回顾了基于文件的 Remix 路由约定。本章指导你创建独立页面、嵌套路由、参数化路由、无路径布局路由以及其他路由概念。本章还讨论了 Remix 中的页面导航。
第五章, 获取和变异数据,深入探讨了 Remix 中的数据获取和变异。本章记录了 Remix 的请求-响应生命周期,并提供了与 Remix 的客户端侧loader和action函数一起工作的详细实践。本章还解释了 Remix 内置的数据验证。
第六章, 提升用户体验,正式介绍了在 Remix 中渐进增强的工作方式。本章随后重点介绍了高级数据获取和变异概念,例如预加载和处理并发变异。
第七章, Remix 中的错误处理,概述了在 Remix 中处理预期和意外错误以开发弹性用户体验的方法。
第八章, 会话管理,指导你在 BeeRich 中实现搜索功能和登录/注册流程。本章提供了使用 Web 标准和 Remix 原语管理 UI 和会话状态的实用深入探讨。
第九章, 资源和元数据处理,专注于 Remix 中的元标签和静态资源。本章包括暴露静态资源和与自定义字体一起工作的实践。最后,本章讨论了如何使用 Remix 管理图像。
第十章,处理文件上传,向您介绍了 Remix 的文件上传辅助工具。本章指导您在 BeeRich 中实现上传用户文件,并强调仅限授权用户访问上传的文件。
第十一章,乐观 UI,讨论了乐观 UI 的权衡。本章进一步指导您在 BeeRich 中使用 Remix 的原语来处理乐观 UI。最后,本章强调了 Remix 通过其原语和内置数据重新验证简化乐观 UI 实现的方式。
第十二章,缓存策略,概述了不同的缓存策略以改善 Remix 应用的性能。首先,本章回顾了不同的 HTTP 缓存策略,然后讨论内存缓存。本章提供了使用 BeeRich 演示应用程序的实用示例。
第十三章,延迟加载器数据,向您介绍了 Remix 的延迟加载器数据概念。本章进一步解释了 HTML 流,并说明了其使用和用例,以使用流来延迟数据请求。
第十四章,使用 Remix 实现实时性,考察了流行的实时技术,并允许您使用 Remix 资源路由和服务器端事件在 BeeRich 中实现实时数据更新。
第十五章,高级会话管理,探讨了额外的会话和 UI 状态管理用例。本章提供了 Remix 的 cookie 辅助原语与它的会话辅助原语的概览。本章进一步指导您在 BeeRich 中实现分页。
第十六章,边缘开发,详细定义了“边缘”这一术语,并考虑了将 Remix 应用程序部署到边缘环境的优势和劣势。
第十七章,迁移和升级策略,总结了本书的学习之旅,并概述了不同的迁移到 Remix 的策略。本章还解释了 Remix 的未来标志以及如何使用它们来简化主要版本升级。
为了充分利用本书
本书将引导您使用 Remix 开发全栈 Web 应用程序。每一章都介绍新的概念,并包含实际示例以获得实践经验。如果您有一些使用 React 或类似的前端框架或库构建 Web 应用的经验,您将能从本书中获得最大收益。由于本书的演示应用程序是用 TypeScript 编写的,因此对 TypeScript 的先前了解也将有助于您跟随代码示例。
要跟随本书中的动手练习,您需要一个已安装 Node.js 和 npm 的计算机。我们建议使用 Node.js 的最新 LTS 版本(目前为 v18)。您可以从这里下载 Node.js 和 npm:nodejs.org/en。VS Code 等编辑器也被推荐使用。
每一章都附有一份或多份README.md文件,位于书的 GitHub 仓库中。如果需要,README.md文件包含每个章节的额外指导和设置说明。
如果您正在使用这本书的数字版,我们建议您亲自输入代码或从书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Full-Stack-Web-Development-with-Remix。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“routes文件夹的根目录映射到/路径。”
代码块按以下方式设置:
import { H1 } from '~/components/headings';export default function LoginPage() {
return (
<main>
<H1>Login!</H1>
</main>
);
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
<Routes> <Route file="root.tsx">
<Route index file="routes/index.tsx" />
<Route path="demo" file="routes/demo.tsx" />
</Route>
</Routes>
任何命令行输入或输出都按以下方式编写:
npx remix routes
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“请注意,有一条路径被标记为索引,而演示路径有一个路径属性,这与它的文件名相匹配。”
提示或重要注意事项
看起来像这样。
联系我们
欢迎读者反馈。
一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。
勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您能向我们报告。请访问www.packtpub.com/support/errata并填写表格。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 mailto:copyright@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《使用 Remix 进行全栈 Web 开发》,我们非常乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
您喜欢在路上阅读,但无法携带您的印刷书籍到处走?您的电子书购买是否与您选择的设备不兼容?
不要担心,现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠远不止这些,您还可以获得独家折扣、时事通讯和丰富的免费内容,每天直接发送到您的邮箱。
按照以下简单步骤获取好处:
- 扫描下面的二维码或访问以下链接

packt.link/free-ebook/9781801075299
-
提交您的购买证明
-
就这些!我们将直接将免费 PDF 和其他好处发送到您的邮箱。
第一部分 – Remix 入门
在本第一部分,您将了解 Remix。您将了解 Remix 的哲学,并理解 Remix 是如何在底层工作的。然后,您将创建您的第一个 Remix 应用程序,并了解 Remix 可以部署到不同的环境和运行时。最后,您将使用本书的示例应用程序 BeeRich 开始您的开发之旅,并了解 Remix 的路由约定。
本部分包含以下章节:
-
第一章,全栈 Web 框架时代
-
第二章,创建新的 Remix 应用程序
-
第三章,部署目标、适配器和堆栈
-
第四章,Remix 中的路由
第一章:全栈网络框架时代
“生活中唯一不变的是变化。”
– 赫拉克利特
我们正处于网络开发的激动人心时期。这个领域的变革速度令人惊叹。从未有过如此多的技术可供选择来开发网络应用。感觉每隔一个月就会有一个新的框架或库发布。现有的框架和库发布带有新功能、破坏性更改和新约定的新主要版本。行业的快速步伐可能让人感到压倒,但看到如此高的创新水平也是令人着迷的。
网络开发的大众群体比前沿技术的步伐要慢得多。大多数企业和开发者都在等待看到哪些技术能够持续存在,然后再采用它们——创造出我们可以认为是行业标准的东西。我会把 React 视为这一标准的一部分。然而,快速行动并采用新技术如果能够大幅推动进步,可以成为一种竞争优势。我相信 Remix 就是这样一种技术。
自从您购买这本书以来,您已经决定尝试 Remix——太棒了!作为一个 React 开发者,Remix 为您提供了许多好处,例如以下这些:
-
为您的前端提供后端环境
-
全栈数据突变故事
-
声明式错误处理方法
-
简化的客户端状态管理
-
服务器端渲染
-
React 的最新进展,例如流式传输
-
一个可以在任何地方运行的应用程序运行时,甚至在边缘
-
通过拥抱网络标准实现渐进式增强
使用 Remix,你可以充分利用网络的全功能。这本书将引导你通过这个过程,从基础知识开始,逐步过渡到更高级的技术。在本章的第一部分,我们将讨论以下主题:
-
介绍 Remix
-
Remix 背后的哲学
-
基本元素、约定和杠杆
-
Remix 幕后
首先,我们将介绍 Remix 作为全栈网络框架。然后,我们将研究 Remix 背后的哲学,并介绍一个心智模型来分类 Remix 提供的不同工具。最后,我们将深入了解 Remix 承担的不同责任。
本章的目标是向您介绍 Remix。最重要的是,我们希望展示 Remix 在 React 开发中的优势。我们希望这能激励您开始使用。因此,本章涉及了几个高级概念。但请放心;本书后面将详细研究所有提到的内容。
介绍 Remix
虽然 Remix 可以被视为另一个 React 框架,但 Remix 背后的团队强调 Remix 不是一个 React 框架,而是一个全栈网络框架——这是一个重要的区别。
在本节中,我们将总结 Remix 作为全栈网络框架的含义。首先,我们将查看网络框架部分,并解释为什么 Remix 真正是网络框架。之后,我们将强调为什么 Remix 是全栈的。
Remix 是一个 Web 框架
Remix 之所以是 Web 框架,主要原因是它对 Web 平台的深度拥抱。Remix 旨在通过使用 Web 标准来实现“快速、流畅且弹性良好的用户体验”。HTML 表单和锚点标签、URL、cookies、元标签、HTTP 头和Web Fetch API都是 Remix 中的第一公民。Remix 的约定、杠杆和原语是精心设计的现有 Web API 和标准的抽象层。这使得 Remix 与其他感觉更脱离 Web 平台的流行框架有所不同。
开放 Web 平台是由万维网联盟(W3C)定义的一系列标准。这包括 JavaScript Web API、HTML 和 CSS、无障碍性指南和 HTTP。Web 标准的推进速度比行业标准慢得多。新的 Web 标准需要很长时间,并且要经过多次迭代才能发布,而且所有浏览器支持它们的时间还要更长。
作为 Web 开发者,您的资源有限。为了最大限度地利用您的时间和精力,关注学习 Web 的核心原则至关重要,这些原则无论您选择什么工具都将适用。学习 Web 的基础知识是可转移的知识,无论您使用什么框架和库都将受益。当使用 Remix 时,您通常会参考 MDN Web 文档而不是 Remix 文档。学习 Remix 意味着学习标准 Web API。
React 在 Remix 中扮演着至关重要的角色。当有道理时,Remix 利用 React 的最新功能。随着 React 18 的推出,React 变得越来越复杂。React 的最新功能更适合框架作者而不是应用程序开发者。Remix 提供了必要的抽象,以利用这些最新进展。
当与 React 配合使用时,Remix 利用客户端路由和数据获取,创建类似于使用 React 构建单页应用程序(SPAs)的体验。然而,Remix 的范畴比 React 更广,解决了 Web 开发中的其他问题,如缓存、用户会话和数据变更。这使得 Remix 成为一个 Web 框架。
Remix 是一个全栈框架
让我们看看为什么 Remix 是全栈的。Remix 采用了 Web 平台的客户端/服务器模型。它协调了您的 Web 应用程序的前端和后端:
-
在服务器上,Remix 充当 HTTP 请求处理器
-
在客户端,Remix 协调一个服务器端渲染的 React 应用程序
Remix 充当前端和后端框架。这两个框架是独立的部分,在不同的环境中执行(浏览器和服务器环境)。在您的应用程序运行时,这两个框架通过网络进行通信。
在开发过程中,您创建一个 Remix 应用程序,其中客户端和服务器代码在一个/app目录中很好地集中。我们甚至可以在同一文件中编写客户端和服务器代码。
以下代码示例展示了 Remix 中页面/路由文件的样子:
// The route's server-side HTTP action handlerexport async function action({ request }) {
// Use the web Fetch API Request object (web standard)
const userId = await requireUserSession(request);
const form = await request.formData();
const title = form.get("title");
return createExpense({ userId, title });
}
// The route's server-side HTTP GET request data loader
export async function loader({ request }) {
const userId = await requireUserSession(request);
return getExpenses(userId);
}
// The route's React component
export default function ExpensesPage() {
// Access the loaded data in React
const expenses = useLoaderData();
// Simplify state management with Remix's hooks
const { state } = useNavigation();
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>
</>
);
}
在代码示例中,我们使用 Remix 的路由模块 API 定义了一个用于服务器端数据加载的loader函数、一个用于数据突变的action函数以及用于渲染 UI 的路由的 React 组件。
Remix 将服务器端请求处理程序和应用程序的路由组件本地化。这使得在客户端和服务器之间共享代码变得容易得多,并提供了对应用程序的前端和后端如何协同工作的全面可见性和控制。
在服务器上,我们使用 Remix 的action和loader函数处理传入的 HTTP 请求并准备响应。在代码示例中,服务器端请求处理程序管理用户会话、加载数据并进行数据突变。我们使用 Web 标准,如 Fetch API、FormData和 HTTP cookies。
在路由组件中,我们使用 Remix 的 React 钩子访问服务器端加载的数据并读取过渡状态。我们进一步使用 Remix 的Form组件声明式地定义数据突变。在客户端,Remix 运行 JavaScript 来增强浏览器的默认体验。这就是我们利用 React 的力量来构建动态 UI 的地方。
Remix 是一个让你充分利用全栈的 Web 框架。Remix 真正地让你能够释放 Web 平台在 React 开发中的全部潜力。
现在我们已经介绍了 Remix,让我们深入了解其哲学。
理解 Remix 背后的哲学
Remix 的使命是让你构建快速、流畅且具有弹性的用户体验。愿景是让你交付人们喜爱的软件。在本节中,我们将更深入地了解 Remix 背后的哲学。你将了解 Remix 的创建以及团队所倡导的价值观。
Remix 是由 Ryan Florence 和 Michael Jackson(在 Twitter 和 GitHub 上的@ryanflorance和@mjackson)创建的。Ryan 和 Michael 是 React 的老兵,也是 React Router 的作者——这是 React 应用程序最受欢迎的路由库,在npm上有超过 10 亿次下载。Remix 的哲学深受 Ryan 和 Michael 在构建和维护 React Router 过程中学到的教训的影响。
最初,Remix 被设计为一个基于许可证的框架。但在 2021 年 10 月,Remix 背后的开发团队宣布他们已经获得了种子资金,并将 Remix 开源。2021 年 11 月,该团队在经过 18 个月的开发后发布了 Remix 的 v1 版本。一年后,在 2022 年 10 月,Shopify 收购了 Remix。现在,Remix 团队完全专注于 Remix 的开发,同时仍在 Shopify 追求相同的使命和愿景。
Remix 不仅开源,而且 Remix 团队还拥抱开放开发。该团队已将路线图以及所有请求评论(RFCs)和提案公开。他们还进行路线图会议的直播,并积极鼓励社区参与和贡献。目标是尽可能地向社区开放开发过程,同时仍然培养引导 Ryan 和 Michael 的哲学思想。
随着时间的推移,Remix 背后的团队提到了许多对他们来说重要的事情。最重要的是,Remix 的目的是推动 Web 的发展。Ryan 和 Michael 都强调,他们希望看到更好的网站。使命是为您提供构建出色用户体验的工具。他们希望人们喜欢使用您的东西。
Remix 团队在 Remix 文档中很好地总结了其哲学思想。Remix 团队强调的一些要点如下:
-
Remix 旨在避免过度抽象。在 Remix 中,API 被视为 Web 平台之上的一个薄抽象层。简洁是王道。Remix 不会重新发明轮子。
-
Remix 既能展望未来,也能回顾过去。它将尖端技术与经过实战检验的 Web 标准相结合,创造新的方法。Remix 利用 HTTP2 流和边缘部署,同时拥抱 HTML 表单、cookies 和 URL。
-
Remix 逐步增强用户体验,同时不偏离浏览器的默认行为。目标是始终忠于浏览器的默认行为,并在可能的情况下随时回退到它。
-
Remix 是关于解锁 Web 平台的全部功能——或者,正如 Remix 团队所表述的,客户端/服务器模型。
-
Remix 背后的团队非常关注网络标签和您的应用程序包大小。目标是加载更少的内容,尽可能快地加载。
框架为您的应用程序代码提供基础和框架。Remix 团队还将 Remix 称为中心栈(而不是全栈)。Remix 的目的是成为核心,连接到您的应用程序的客户端和服务器两端。它旨在成为中心。
对我来说,Remix 是一款功能强大的工具,具有出色的开发者体验,让我能够为 Web 构建内容。我重视 Remix 提供的 API 的简洁性和实用性。自从我开始使用 Remix 以来,我学到了很多关于 Web 的知识,这都要归功于对使用 Web 平台的强调。Remix 结合了新的方法与旧的方法。使用它令人耳目一新,并且已经开始影响其周围的生态系统。我们现在真正生活在全栈 Web 框架的时代。
Remix 团队推广 Remix 思维方式。例如,Ryan 提出了一种三步法来开发网络体验:
-
让所有内容在没有 JavaScript 的情况下也能工作。
-
使用 JavaScript 增强体验。
-
尽可能地将业务逻辑移至服务器。
在每一步中,我们都是在上一步的基础上构建,以增强体验:
-
首先,我们专注于在不使用 JavaScript 的情况下构建特性。使用 Remix,我们利用了 Web 平台。我们使用表单来修改数据,并使用服务器端重定向来传递反馈。在特性在不使用 JavaScript 的情况下工作后,我们可能可以发布它并完成。
-
接下来,我们在客户端使用 JavaScript 来增强用户体验。我们可能会添加乐观 UI、延迟数据加载和实时数据更新。
-
最后,我们将尽可能多的业务逻辑移至服务器。这允许在客户端 JavaScript 未加载的情况下优雅降级。它还减少了你的应用程序的包大小。
通过使用 Remix 并参与 Remix 社区,你将接触到 Remix 的哲学。将 Remix 的哲学应用到你的开发过程中将真正提升你的 React 开发能力。
Remix 的哲学也可以通过它提供的工具来理解。在下一节中,我想向您介绍框架特性的心智模型。
原语、约定和杠杆
在本节中,我们将对 Remix 提供的不同特性进行分类。一个框架为你的应用程序提供了基础和框架。它进一步向你作为开发者暴露工具。我们可以将这些工具分为三个类别:
-
原语
-
约定
-
杠杆
原语、约定和杠杆可以作为一个很好的心智模型来映射 Remix 的不同特性。让我们看看这三个类别如何不同。
原语
原语用于你的应用程序代码与框架层交互。它们是将你的应用程序集成到框架提供的基础和框架中的连接线。常见的原语包括函数、钩子、常量、类型、类和组件。框架暴露这些原语,以便你可以在代码中使用它们。艺术在于使原语易于理解,同时足够可组合,以实现强大的业务逻辑。Remix 正是这样做的。
Remix 为你的客户端和服务器端代码提供了原语。Remix 的原语通常是 Web 平台的薄层抽象,并提供与原生原语相似的 API。例如,Remix 的 Form 组件接受与原生表单元素相同的属性,但还提供了一些额外的属性来增强体验。
此外,Remix 的原语本身也暴露了标准的 Web API。你在 Remix 中编写的绝大多数服务器端代码都可以访问一个遵循 Web Fetch API 规范的 Request 对象。Remix 并没有重新发明轮子。
约定
框架还引入了约定。常见的约定包括文件和文件夹命名约定。在前一节中,我们展示了 Remix 中路由文件的代码示例。Remix 的路由文件(路由模块)允许你导出属于 Remix 路由文件命名约定的特定函数。
约定旨在提升开发者体验。例如,基于文件的路由允许你将应用程序的路由结构定义为文件和文件夹层次结构。Remix 编译你的代码并推断你的路由层次结构,因此你无需将路由层次结构定义为代码。
直观的约定可以减少连接应用程序所需的配置量。它们将负担转移到框架上。约定构成了框架与你的应用程序之间的合同,并且可以显著减少你必须编写的样板代码量。
框架的 API 主要由原语和约定组成。所有框架都包含原语,许多框架也利用约定。然而,Remix 特别强调第三类:杠杆。
杠杆
杠杆可以理解为选项。Ryan 在关于 Remix 的第一场会议演讲中提出了这个隐喻。Ryan 强调,Remix 只是网络平台上的一个薄抽象层。Remix 让你决定要优化哪些网络关键指标。首次字节时间(TTFB)或累积布局偏移(CLS)?加载旋转器还是较慢的页面加载?优化不同的网络关键指标可能是冲突的目标。Remix 提供了杠杆,让你可以引导你的网络应用程序朝着对你正确且重要的方向前进。
杠杆带来了实用性和责任。Remix 提供了原语,但你必须决定如何设计你的应用程序。我相信这种力量使你成为一个更好的网络开发者。但更重要的是,它释放了网络平台的全部潜力。有许多事情需要优化,Remix 提供了杠杆。这使得 Remix 与其他不提供相同类型灵活性和控制的框架区别开来。
现在我们已经为如何分类 Remix 的功能准备了一个心理模型,我们将揭开幕布。Remix 提供了许多出色的功能,并带来了许多生活质量的提升。这是因为它同时承担了多项责任。让我们揭开盖子,了解这意味着什么。
Remix 幕后
那么,Remix 在幕后是如何工作的呢?根据我们迄今为止所学到的,Remix 似乎做了很多事情。在本节中,我们将揭开幕布。这将帮助你理解 Remix 承担的一些责任。
Remix 提供了出色的开发者体验,但有时也感觉像魔法。一点魔法可以走得很远,但如果不理解发生了什么,也可能让人感到不知所措。如果出现问题,魔法也很难调试。这就是为什么我想在我们创建第一个 Remix 项目之前就揭示一些 Remix 的内部工作原理。
我们可以识别出 Remix 承担的三个不同的责任:
-
Remix 打包你的代码
-
Remix 管理你的应用程序的路由
-
Remix 处理传入的 HTTP 请求
根据确定的职责,Remix 可以分为三个主要组件:编译器、路由器和运行时。在接下来的章节中,我们将检查每个组件。首先,让我们更详细地看看 Remix 作为编译器是如何运作的。
Remix 是一个编译器
Remix 将基于文件的路线模块编译成代码。它从文件和文件夹层次结构中推断路线结构。对于每个路线模块,Remix 检查它导出的函数,并将路线文件夹转换成运行时使用的数据结构。这使得 Remix 成为一个编译器。
当你使用 Remix 工作时,你会注意到其构建步骤的速度。这要归功于 Remix 使用的构建工具 esbuild。Remix 不暴露 esbuild。因此,Remix 可以调整其打包代码的方式,并通过使用最新的构建工具在未来保持竞争力。如果发布了更快、更强大的构建工具,Remix 明天就可以切换到 esbuild。
Remix 基于 esbuild 将你的 JavaScript 文件打包成以下内容:
-
服务器包
-
客户端包
-
资产清单
Remix 的命令行界面(CLI)将你的代码编译成客户端和服务器包。服务器包包含 Remix 的 HTTP 处理程序和适配器逻辑。这是在服务器上运行的代码。客户端构建包含操作 Remix 客户端 React 应用程序的客户端脚本。
Remix 还会根据你的路由层次结构编译一个资产清单。客户端和服务器都使用资产清单,其中包含应用程序的依赖关系图信息。清单告诉 Remix 需要加载什么,并允许在页面转换时预取资产和应用程序数据。
说到页面转换...
Remix 是一个路由器
Remix 为你的客户端和服务器代码实现了一个路由器。这简化了服务器到客户端的移交。前端和后端的深度集成使得 Remix 弥合了网络差距。这允许 Remix 做一些很酷的事情。例如,Remix 并行调用你的路由loader函数,以避免请求瀑布。
我们可以简化一下,说 Remix 在底层使用 React Router。更具体地说,Remix 同时使用react-router-dom和@remix-run/router。@remix-run/router是一个前端库/框架无关的包,由 React Router v6 和 Remix 使用。React Router 和 Remix 已经对齐,并具有相似的 API 表面。从许多方面来看,你可以把 Remix 看作是其底层路由解决方案的编译器。
Remix 具有客户端和服务器部分,其路由解决方案也是如此。Remix 暴露的 React 组件确保你的应用程序感觉像一个单页应用(SPA)。Remix 处理表单提交和链接点击。如果加载了 JavaScript,它会阻止浏览器默认行为,但在必要时可以回退到完整页面加载。所有这些都是在运行时由 Remix 的路由器管理的。
说到运行时...
Remix 是一个运行时
Remix 在现有的服务器上运行,例如 Express.js Node 服务器。Remix 提供适配器以创建与不同服务器端 JavaScript 环境的兼容性。这使得 Remix 的 HTTP 处理器对底层服务器保持无感知。
适配器将来自服务器环境的传入请求转换为标准的 Request 对象,并将 HTTP 处理器的响应转换回服务器环境响应的实现。
Remix 通过 JavaScript 服务器接收 HTTP 请求并准备响应。Remix 的路由器知道要加载和渲染哪些路由模块以及要获取哪些资源。在客户端,Remix 激活您的 React 应用程序并协调路由和数据获取。作为一个框架,Remix 为您的应用程序提供基础并执行应用程序的代码。接下来,让我们看看 Remix 不是什么。
Remix 不是什么
在本章的早期部分,我们介绍了 Remix 并探讨了它提供的许多工具和功能。Remix 是一个全栈 Web 框架,但了解 Remix 不是什么也同样重要。最重要的是,Remix 以下列内容之一:
-
一个 JavaScript 服务器
-
一个数据库
-
一个 对象关系映射器(ORM)
-
一个云服务提供商
-
一个样式或主题库
-
一个魔法水晶球
Remix 既不是服务器也不是 JavaScript 引擎。Remix 在 Node.js 等 JavaScript 环境上运行,并使用适配器与 Express.js 等 Web 服务器通信。Remix 也不提供应用程序数据层的解决方案。它帮助您加载和修改数据,但实现这些数据加载器和动作是您的工作。您需要选择适合您用例的数据库解决方案。Remix 也不是一个 ORM。因此,您必须在动作和加载器中查询数据,定义您的数据类型,或使用第三方库以获得支持。
Remix 背后的公司不作为云服务提供商或提供云托管服务。您几乎可以在任何可以执行 JavaScript 的地方托管您的 Remix 应用程序。许多云服务默认支持 Remix,但 Remix 公司本身不提供任何托管服务。
Remix 也不是一个样式或主题库。Remix 对如何使用 CSS 有自己的看法,但其解决方案是通用的。除了在前缀路由级别加载样式表的实用工具之外,Remix 不提供用于样式或主题化的工具。
列出的许多内容都不在 Remix 的范围内,但其中一些可能在将来有所变化。现在,让我们专注于现在。在本章的第一部分,我们了解了很多关于 Remix 提供的众多功能。提到的大多数事情都是在幕后发生的。本书将逐步引导您了解 Remix 的每个方面。每一章都将专注于一个特定主题,例如路由、数据获取和修改、错误处理以及状态管理。通过逐一检查这些主题,我们将探讨 Remix 的意义。我当然很兴奋开始编码!
摘要
在第一章中,我们将 Remix 介绍为一个全栈 Web 框架。Remix 推崇使用 Web 平台,并让您利用标准 Web API。它通过紧密集成前端和后端来弥合网络差距。这使得 Remix 能够做一些酷的事情。
我们还研究了 Remix 背后的哲学。Remix 背后的团队强调避免过度抽象。其使命是让您能够快速、流畅且稳健地构建用户体验。愿景是让您交付人们会喜爱的软件。
我们引入了术语原语、约定和杠杆来对 Remix 的不同功能进行分类。原语是可以导入并用于我们代码中的暴露工具。约定是诸如文件和文件夹命名约定之类的合同,用于避免繁琐的配置。杠杆是 Remix 提供的选项,允许我们针对对我们重要的事情优化我们的应用程序。
您还了解了 Remix 在幕后所做的工作。Remix 承担了三个不同的职责。它是一个编译器、一个路由器和运行时。将这三个职责结合在一个框架中,可以实现一些了不起的事情,例如通过将客户端和服务器代码放置在一起来简化请求瀑布并避免前端-后端分离。
在本章中,我们提到了许多概念,如服务器端渲染、预取和客户端路由。我们将在本书的后续章节中重新审视所有提到的概念。在下一章中,我们将开始我们的 Remix 开发之旅,并创建一个“Hello World” Remix 应用程序。
进一步阅读
如果您还没有查看 Remix 的主页 (remix.run),我鼓励您去查看。Remix 背后的团队在阐述其价值主张和提出解决方案方面做得非常出色。而且,它看起来很棒。
Remix 文档也是如此。我鼓励您在阅读本书的章节时熟悉 Remix 的文档。例如,Remix 背后的团队对其哲学进行了出色的总结。您可以在以下链接了解更多:remix.run/docs/en/v1/pages/philosophy。
您是否对 Remix 的深入解释更感兴趣?您可以在以下链接找到关于 Remix 的深入技术解释:remix.run/docs/en/2/pages/technical-explanation。
您可以在以下链接找到 Remix 与 Shopify 联合的官方公告:remix.run/blog/remixing-shopify。
公共路线图和大多数其他规划文档都位于 GitHub 上。路线图可以在以下链接找到:github.com/orgs/remix-run/projects/5。
如果你想了解 Remix 与其他技术相比的方法,请查看这篇博客文章:remix.run/blog/remix-and-the-edge。如果你想了解更多关于替代解决方案的背景信息,我推荐以下博客文章:frontendmastery.com/posts/the-new-wave-of-javascript-web-frameworks.
第二章:创建新的 Remix 应用程序
开始使用一个新的框架意味着熟悉其原语、约定和杠杆。本书使用一个从零开始构建的演示应用程序。每一章都专注于 Remix 全栈 Web 开发的一个特定主题。在本章中,我们将探索 Remix 的 create-remix CLI 脚本的广度,介绍 Remix 的文件和文件夹结构,并熟悉 Remix 的运行时。
本章涵盖了以下主题:
-
创建一个“Hello World!” Remix 应用程序
-
理解 Remix 的文件和文件夹结构
-
探索客户端和服务器环境
-
Remix 应用程序的故障排除
首先,我们将通过使用 create-remix CLI 脚本设置一个新的 Remix 项目。然后,章节将向您介绍 Remix 的文件夹结构。我们将调查每个文件并了解其功能。接下来,我们将讨论 Remix 的两个环境:客户端和服务器。您将学习如何在 Remix 中管理客户端和服务器代码。最后,我们将介绍一个故障排除指南,帮助我们调试 Remix 应用程序。
到本章结束时,您将了解如何创建 Remix 项目,并熟悉 Remix 的文件和文件夹结构。您还将了解更多关于 Remix 的客户端和服务器环境以及如何故障排除 Remix 应用程序的信息。
技术要求
要完成本章,您需要一个可以运行 Node.js 的计算机。所有常见的操作系统都足够使用。请在您的机器上安装 Node.js 和 npm。推荐使用 VS Code 等编辑器。
您可以从这里下载 Node.js 和 npm:nodejs.org/en/download/。
本章的解决方案可以在以下位置找到:github.com/PacktPublishing/Full-Stack-Web-Development-with-Remix/tree/main/02-creating-a-new-remix-app。请在查看最终代码之前先阅读这一章。
创建一个“Hello World!” Remix 应用程序
本节将指导您使用 create-remix CLI 脚本创建一个新的 Remix 应用程序。该脚本由 Remix 团队维护,用于启动新的 Remix 项目:
-
打开一个新的终端窗口并运行以下命令:
npx create-remix@2我们使用
npx来执行create-remix脚本。npm代表create-remixv2。本书中的示例基于 Remix v2。通过在包名后添加@2后缀,我们确保我们的第一个演示应用程序安装的是 Remix v2 应用程序。对于本书外的项目,我们建议使用以下命令来使用 Remix 的最新稳定版本:
npx create-remix@latest -
如果
npx要求您安装create-remix,请输入y以回答“是”。 -
安装
create-remix脚本后,系统会提示我们提供一个有效的 Remix 项目位置:Where should we create your new project? ./my-remix-app在你的机器上选择一个安装位置或使用 CLI 工具提供的默认安装位置。
按下 Enter 后,脚本会告诉我们它选择了基本模板,也称为 Remix App Server。如果没有指定其他模板,脚本默认使用基本模板。我们将在下一节中了解更多关于模板的内容。
-
脚本提示我们初始化一个新的 Git 仓库。选择 是:
Initialize a new git repository? npm install:使用 npm 安装依赖项?npm install 已完成。接下来,使用终端导航到新创建的项目文件夹:
cd ./my-remix-app恭喜你初始化了你的第一个 Remix 项目!让我们启动本地开发服务器。
-
以下命令启动我们的 Remix 应用程序本地运行:
npm run dev > dev> remix dev 💿 remix dev info building... info built (204ms)Remix App Server started at http://localhost:3000 (http://10.0.0.173:3000)请注意 Remix 构建的速度有多快。在 204 毫秒内构建完成——这就是 esbuild 的力量。确切的毫秒数会根据你的系统而变化,但令人惊讶的是,我们可以在毫秒级别讨论构建时间!
-
在你的浏览器中打开指定的 URL:
http://localhost:3000。它应该渲染一个简单的 HTML 页面。恭喜你在本地运行了你的第一个 Remix 应用程序!然而,如果没有在屏幕上打印“Hello World”,那就不是一个“Hello World”项目。
-
使用你选择的编辑器打开 Remix 项目。在编辑器中,打开位于
app/routes的_index.tsx文件。你会找到一个导出 React 组件的文件。删除现有的 JSX 代码,并用以下代码替换:export default function Index() { return ( <h1>Hello World!</h1> );}注意,Remix 默认使用 TypeScript。如果你之前没有使用过 TypeScript,请不要担心。我们只有少数几个地方需要直接处理 TypeScript。大多数情况下,我们可以享受类型推断和自动完成,而无需自己编写类型。
-
保存文件更改。现在,你的浏览器标签应该会自动重新加载并显示更新后的 HTML:Hello World!
create-remix CLI 脚本使得创建新的 Remix 应用程序变得简单。在本节中,我们使用默认模板初始化了一个简单的 Remix 应用程序,并在屏幕上渲染了 Hello World!。接下来,让我们检查初始化的文件夹结构。我们将检查每个文件并研究其功能。
理解 Remix 的文件和文件夹结构
Remix 承担了编译器、路由器和运行时的责任。它为应用程序提供了基础和框架。因此,它为应用程序提出了一个骨架文件夹结构。一些文件作为入口点,你可以将其连接到应用程序中。其他文件可以用来配置 Remix。让我们回顾一下我们的初始化 Remix 应用程序。
存在哪些文件和文件夹取决于创建过程中的所选配置选项。然而,大多数文件都是所有设置的组成部分。选择基本 Remix App Server 模板会产生以下文件和文件夹结构:
my-remix-app├── .eslintrc.js
├── .gitignore
├── README.md
├── app
│ ├── entry.client.tsx
│ ├── entry.server.tsx
│ ├── root.tsx
│ └── routes
│ └── _index.tsx
├── package.json
├── public
│ └── favicon.ico
├── remix.config.js
├── remix.env.d.ts
└── tsconfig.json
让我们看看每个文件和文件夹。Remix 随带一个 .eslintrc 文件,该文件配置 ESLint 以扩展 Remix 的 ESLint 扩展。你可以根据你的代码检查和格式化偏好调整或删除此文件。
当初始化一个新的 Git 仓库时,Remix 也会创建一个 .gitignore 文件。该文件被设置为忽略 Remix 的构建工件、临时文件,例如 .cache 文件夹,以及其他常见的被忽略的文件和文件夹。你可以根据你应用程序的需求更新此文件。
每个新创建的项目都附带一个 README.md 文件。该文件包含有关如何运行和部署你的应用程序的重要信息。文档根据所选模板而有所不同。确保在启动新的 Remix 应用程序时阅读 README.md 文件。
接下来,让我们继续看 package.json 文件。如果你之前已经与基于 Node.js 的项目合作过,你应该熟悉其内容。Remix 的 package.json 文件包含你预期的所有部分:
-
scripts -
dependencies -
devDependencies
scripts 部分包含一组脚本来在本地运行你的 Remix 应用程序、构建你的应用程序以及在生产中运行你的应用程序。通常,这些脚本的名称为 dev、build 和 start。你的应用程序可能根据所选模板包含额外的脚本。
抽时间调查 dependencies 和 devDependencies 部分。你可能会注意到 Remix 被分割成几个包。一些是依赖项,而其他是 dev 依赖项。一个值得强调的依赖项是正在使用的适配器。
我们在上一章中学到,Remix 可以在任何可以执行 JavaScript 的地方运行。Remix 的服务器端 HTTP 请求处理器使用适配器在不同的 JavaScript 运行时和服务器环境中运行。每个 Remix 应用程序使用一个位于 Remix 和网络服务器之间的适配器。
基本模板使用 @remix-run/serve 包在 Node.js 服务器环境中运行。该包实现了 Remix 应用服务器,一个生产就绪的 Express.js 服务器。与其他模板相比,Remix 应用服务器不暴露 Node.js 服务器设置。非常适合开始使用。
接下来,让我们看看 public 文件夹。public 文件夹包含在互联网上公开的静态文件和资产。目前,该文件夹包含一个 favicon.ico 文件。
在本地运行应用程序 (npm run dev) 之后,文件夹应进一步包含一个 build 文件夹。该文件夹是包含你的捆绑应用程序代码的两个 build 文件夹之一。要构建你的应用程序,定位你的终端并运行 npm run build。这将根据你最新的代码更改生成两个用于生产的捆绑包。
Remix 包含一个客户端和一个服务器应用程序。在编写你的 Remix 应用程序时,你需要为两个不同的环境、运行时和应用编写代码。客户端应用程序代码被打包到 public/build 文件夹中。这些文件通过互联网公开,并且可以从浏览器中获取。
如果你查看 public/build 文件夹,你会注意到每个 JavaScript 模块文件名都以哈希结尾。这个哈希被称为文件的指纹。不能有两个文件名相同但内容不同的文件。这简化了缓存。由于文件的新版本意味着生成了一个新文件,因此我们可以永久缓存每个文件,而无需处理缓存失效问题。如果你的应用程序的新版本中模块的内容发生了变化,那么它将有一个不同的唯一文件名,并且将被重新加载。
在 public/build 文件夹中,你还可以找到清单文件(manifest-*.js)。Remix 编译一个资产清单,用于路由请求和定位资产。由于清单必须由客户端访问,它也是 public/build 包的一部分。
让我们来看看 remix.config.js 文件。这个 JavaScript 文件导出一个 AppConfig 配置对象。这个文件可以用来配置 Remix、启用未来标志和覆盖 Remix 的默认行为。通常,你不需要修改这个文件。然而,由于其内容可能取决于你选择的模板和适配器,因此在切换模板或适配器时可能相关。我们将在本书的后面部分学习如何切换适配器。
接下来,让我们检查 remix.env.d.ts 文件。这个文件包含 TypeScript 编译器的信息。该文件包含三斜杠指令,声明了应用程序所依赖的包。这些声明告诉 TypeScript 你的应用程序依赖于 Remix 的包。
最后,是 tsconfig.json 文件——或者如果你选择了一个没有 TypeScript 的模板,则是 jsconfig.json 文件。这些配置文件包含 TypeScript 编译器的配置选项,并用于打包和编译你的 Remix 应用程序。
现在我们已经访问了所有顶级文件和文件夹,让我们来看看 app 文件夹。这是 Remix 应用程序所在的地方,也是我们编写应用程序代码的地方。Remix 会用一系列启动文件填充这个文件夹。entry.server.tsx 和 entry.client.tsx 文件分别作为客户端和服务器框架的入口点。
entry.client.ts 文件包含作为客户端入口点的代码。其职责是使 React 保持活性,从而在客户端初始化类似 SPA 的体验。Remix 提供了入口文件,以便您根据您的用例进行适配。该文件可以作为一个很好的地方来放置任何在客户端应用程序首次加载时只需执行一次的代码。您也可以删除该文件,在这种情况下,Remix 将回退到默认实现。
entry.server.tsx 文件将其默认导出为 handleRequest 函数。handleRequest 函数在传入的请求上被调用,并生成 HTTP 响应。handleRequest 的一般流程如下:handleRequest 使用请求对象和一些附加参数被调用。该函数在服务器端渲染 React。渲染的标记被返回,并包装在一个新的 Response 对象中,然后返回给适配器代码,适配器代码将响应传递给服务器以向客户端提供服务。
让我们来看看 root.tsx 文件和 routes 文件夹。Remix 使用基于文件的路由解决方案。routes 文件夹中的每个文件都被视为路由树中的嵌套路由模块。文件夹的文件和文件夹层次结构映射到路由层次结构。每个文档/UI 路由都必须导出一个 React 组件。
root.tsx 文件包含根文档路由模块。因此,它也将其默认导出为一个 React 组件:
export default function App() { return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial- scale=1" />
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
注意,Remix 管理整个 HTML 文档,包括 <html /> 标签和 React 中的 <head /> 标签。这使我们能够完全控制 HTML 文档的结构。使用 Remix,您可以有条件地渲染元标签,更改 lang 属性,或有条件地添加和删除客户端 JavaScript (<Scripts />)。
root.tsx 文件中的路由组件渲染了 Remix 的几个内置组件:
-
Meta -
Links -
Outlet -
ScrollRestoration -
Scripts -
LiveReload
Meta 组件将定义在 meta 导出中的元标签添加到 HTML 文档中。同样,Links 组件将定义在 links 导出中的链接添加到 HTML 文档中。您将在 第九章 中了解更多关于 Meta 和 Links 组件的信息,资源和 元数据管理。
与您可能预期的 {children} 不同,Remix 提供了一个 Outlet 组件来指定渲染子路由的位置。您将在 第四章 中了解更多关于 Outlet 组件和子路由嵌套的信息,Remix 中的 路由。
ScrollRestoration 组件管理所有客户端导航的滚动位置。SPA 避免完整的页面重新加载,而是使用 JavaScript 在客户端渲染新的页面。ScrollRestoration 组件用于模拟浏览器的默认行为,并在使用浏览器的后退和前进按钮时恢复滚动位置。
Scripts 组件可能是 Remix 提供的最迷人的组件之一。该组件将捆绑的 Remix 应用程序的所有脚本标签添加到 HTML 文档中。通过移除 Scripts 组件,我们可以从 Remix 应用程序中移除所有客户端 JavaScript。
LiveReload 组件在开发过程中触发页面重新加载,每当开发服务器检测到任何文件更改时。LiveReload 是 Remix 开发服务器设置的一部分,在生产环境中不使用。
注意,Remix 通过公开其内置组件何时以及如何渲染来提供对其内部工作的控制。例如,如果我们对开发过程中的实时重新加载不感兴趣,我们只需移除 LiveReload 组件。如果我们想在没有客户端 JavaScript 的情况下开发静态页面,我们可以移除 Scripts 组件。
routes 文件夹包含您的 Remix 应用程序的所有其他路由。到目前为止,它只包含一个 _index 路由。_index 路由是其父路由的默认子路由,并共享相同的 URL。当前 _index 路由映射到 / 路径名。我们将在 第四章,Remix 中的路由 中了解所有关于路由的内容。
那是相当多的文件!Remix 充当您的编译器、路由器和运行时。因此,Remix 必须了解您的编译器配置和代码的位置。此外,作为您的运行时,它还必须了解所有您的路由模块。由于 Remix 不是一个服务器,它必须将这些入口点暴露给服务器环境。大多数时候,您不需要触摸这些入口点和配置文件。然而,如果您需要,可以轻松地更改这些文件的内容并将应用程序逻辑钩入这些位置。
现在,我们已经了解了 Remix 的文件夹结构,并调查了 Remix 的配置和项目文件,我们将进一步了解您的 Remix 应用程序的两种环境。
探索客户端和服务器环境
在本节中,您将了解每个 Remix 应用程序的两种环境:客户端和服务器。首先,我们将了解代码在运行时是如何执行的。接下来,您将了解在哪里编写您的客户端和服务器代码,以及如何帮助 Remix 的编译器识别哪些属于客户端捆绑包,哪些属于服务器捆绑包。
您的 Remix 应用程序的两个捆绑包
Remix 应用程序服务器不公开其服务器设置,但大多数其他模板都这样做。在本节中,我们将使用 Express.js 模板来回顾 Remix 如何与 Web 服务器交互。
按照以下步骤启动 Express.js Remix 应用程序:
-
在终端中运行以下
create-remix命令:--template flag points to a folder on GitHub.com, using the following pattern: :username/:repository/:path-to-folder. You can learn more about the different --template options by calling npx create-remix@latest --help. -
为此 Remix 应用程序选择一个新的文件夹位置,并按照上一节中练习的提示进行操作。
-
回顾新的 Remix 应用程序。将
package.json文件与上一节中的 Remix App Server 的package.json文件进行比较。dependencies和scripts部分有何不同? -
接下来,打开
server.js文件。该文件包含设置新 Express.js 应用程序的代码。让我们一起来审查这段代码,并讨论最重要的方面。注意,
createRequestHandler是从 Remix 的 Express.js 适配器(@remix-run/express)导入的。接下来,Remix 应用程序的服务器构建是动态导入的:
const BUILD_PATH = path.resolve("build/index.js");const initialBuild = await reimportServer(); const app = express(); Anything put into the `/public` folder is accessible over the internet. All other requests to the web server are then forwarded to Remix’s HTTP handler:app.all("*", process.env.NODE_ENV === "development" ? createDevRequestHandler() : createRequestHandler(initialBuild));The `createRequestHandler` function acts as an `handleRequest`) found in `entry.server.tsx`. The wrapper function handles requests according to the underlying server environment and translates incoming requests into a format understood by Remix. The wrapper function also translates Remix’s `Response` into instructions understood by the server environment.Finally, we start the Express.js application by calling `app.listen`. All incoming requests are mapped from our Express.js app to Remix.
注意,服务器环境被通知了 Remix 应用程序的客户端和服务器包。客户端包通过互联网暴露。服务器包传递给 Remix 适配器,并在传入的请求上调用。
Remix 是一个 HTTP 请求处理器
Remix 不是一个 Web 服务器,而是一个在服务器环境中运行的 HTTP 请求处理器。Remix 使用适配器与底层服务器进行通信。
Remix 的适配器代码在服务器环境中使用。适配器将来自服务器环境的请求传递给我们的 Remix 应用程序,并使用服务器环境的原语来管理我们的 HTTP 处理器的响应。根据所选模板和部署目标,服务器代码会有所不同。
使用 Remix,我们对server.js文件拥有完全的控制权。如果需要,我们可以将钩子插入到服务器代码中并添加额外的逻辑。例如,我们可以在我们的 Express.js 应用程序中添加一个 WebSocket 服务器,并让它与我们的 Remix 应用程序并行运行。Remix 的架构为我们应用程序的运行时提供了完全的控制权。
接下来,让我们在app文件夹内编写一些应用程序代码。
客户端和服务器代码
使用 Remix,你可以充分利用整个 Web 平台。在本节中,我们将学习如何在 Remix 中编写客户端和服务器代码。
在本章的早期部分,我们在第一个 Remix 应用程序的首页上打印了Hello World!。我们使用了以下代码在屏幕上渲染一个 React 组件:
export default function Index() { return (
<h1>Hello World!</h1>
);
}
让我们调查 Remix 的客户端和服务器运行时:
-
在编辑器中打开你的
Hello World!Remix 应用程序,并在app/_index.tsx中的function组件里添加一个console.log语句:export default function Index() {console.log is executed anytime our React component renders. -
在本地启动 Remix 应用程序。在你的终端窗口中,在项目根目录下执行
npm run dev。你能猜到console.log语句会被运行多少次吗? -
在新浏览器标签页中打开应用程序。
-
接下来,回顾你执行
npm run dev命令的终端。你将看到终端中打印了“Another hello to the world!”。Remix App Server started at http://localhost:3000 (http://10.0.0.37:3000)Another hello to the world!GET / 200 - - 66.536 ms终端连接到我们的服务器端环境。在浏览器标签中访问网页会创建一个针对网络服务器的
GET请求。GET / 200– 对/路径的GET请求以状态码200响应。首先,请求被底层服务器环境接收。网络服务器调用 Remix 的适配器回调,适配器将请求转发到 Remix 的
handleRequest函数。然后 Remix 在服务器上渲染 React 应用程序。当我们的IndexReact 组件执行时,console.log语句被调用,将语句打印到终端。最终,console.log在服务器上只执行了一次。但这就是全部吗? -
导航到你的浏览器窗口,并在显示 Remix 应用的标签中打开开发者工具。在开发者工具中,导航到
entry.client.ts文件。在这里,Remix 重新激活你的 React 应用程序。React 在客户端重新渲染并再次渲染Index组件。因此,console.log语句在服务器上执行一次,然后在客户端也执行一次。
你的 Remix 应用程序的 React 代码在服务器和客户端上运行。然而,有些代码应该只运行在服务器或客户端上。例如,entry.client.tsx 模块应该只运行在客户端,而 entry.server.tsx 模块应该始终只在服务器上执行。
同样重要的是,没有任何服务器端代码会进入客户端包。客户端包中的代码在互联网上暴露。想想你的 API 令牌和其他可能存在于你的后端逻辑中的秘密。此外,浏览器和服务器环境不同。Node.js API 在客户端不可用,而浏览器的全局 window 对象在 Node.js 中不可用。服务器端代码在客户端执行时可能会抛出错误,反之亦然。
Remix 是你的编译器,将你的代码打包成服务器和客户端包。但我们如何告诉 Remix 为服务器打包什么,为客户端打包什么?大多数时候,Remix 可以自己找出答案。Remix 使用“摇树”来过滤你的代码,并试图推断哪些代码属于哪个包。然而,你也可以明确地告诉编译器——以及正在你项目上工作的开发者——代码应该只在两个环境中的一个中执行。
Remix 提供了一种约定来标记文件为纯服务器端或客户端模块。在文件名末尾添加 .client. 或 .server. 告诉 Remix 的编译器分别避免将这些文件包含在服务器或客户端包中。例如,你可以将你的数据库设置文件命名为 db.server.ts 以明确排除它从客户端包中。同样,你可以将导入客户端库的文件命名为 libs.client.ts 以明确避免在服务器上导入这些包。
你可能已经注意到,入口文件(entry.server 和 entry.client)都遵循这个约定。你可以随意在两个入口文件中添加 console.log 语句,并识别每个语句的执行位置——终端窗口或浏览器 控制台 选项卡。
注意,一些文件不能声明为服务器或客户端文件。例如,/routes 文件夹中的路由模块不能声明为客户端或服务器文件,因为它们可能包含客户端和服务器代码。
在本节中,你了解了更多关于通过你的 Remix 应用程序进行 GET 请求的代码流程。你了解到 Remix 在服务器上接收 GET 请求并在客户端和服务器上渲染你的 React 应用程序。你的 Remix 应用程序中的一些代码在两个环境中运行,而其他代码则应该只在其中一个环境中运行。
Remix 在客户端和服务器上运行。学习如何在两个环境中进行故障排除至关重要。在下一节中,你将了解更多关于如何解释 Remix 应用程序中的错误消息以及在哪里获得帮助。
故障排除 Remix 应用
在本节中,你将了解更多关于调试 Remix 应用程序的知识。首先,我们将为你提供一个在开发 Remix 时处理问题的通用流程。接下来,我们将记录如何最好地搜索答案并从社区中获得帮助。
一个 Remix 故障排除过程
Remix 不是一个服务器,而是在一个网络服务器和底层服务器运行时环境之上运行。Remix 作为 HTTP 请求处理器,编排你的路由,在运行时执行你的代码,并作为你的编译器。可能会有很多问题发生。这就是为什么在 Remix 中练习调试过程很重要。
在最后一节中,我们了解了 Remix 的两个环境,客户端和服务器。Remix 在服务器上运行,然后在浏览器中执行逻辑。因此,在调试你的 Remix 应用程序时,我们必须调查客户端和服务器环境。让我们分解 Hello World! 应用程序:
-
将以下有问题的代码添加到你的
Hello World!应用程序中的app/routes/_index.tsx文件:export default function Index() {navigator interface to access user agent information. Our goal is to greet the user based on their user agent. -
如果应用程序尚未运行,请运行
npm run dev来启动应用程序。 -
在新浏览器窗口中访问 Remix 应用或刷新现有窗口。不幸的是,你会注意到应用程序抛出一个错误。页面应该显示标题 应用程序错误。哎呀!让我们开始调试。
1. 信任错误消息
如果出现问题,你应该首先检查运行 Remix 的本地终端。你应该能够在终端中看到以下错误消息:
ReferenceError: navigator is not defined
你可能需要滚动一下才能找到堆栈跟踪上面的相关行。此外,错误消息也应该在浏览器标签页中显示。当出现问题时,Remix 会显示一个回退错误页面。页面上的错误消息比终端上的更易读,但考虑终端是故障排除的真相来源。在这种情况下,两个错误都是匹配的。看起来 navigator 对象未定义。
2. 定位到该行
让我们更详细地调查终端中的错误消息。我们可以利用堆栈跟踪来了解哪个文件抛出了错误,并沿着函数调用堆栈向下跟踪堆栈跟踪。你应该能够看到 app/routes/_index.tsx 文件抛出了错误。看起来这是一个应用程序错误,而不是 Remix 或依赖项的错误,因为是我们自己的代码出了问题。
3. 构建时间或运行时
接下来,注意错误仅在我们从网络服务器请求页面后发生。由于 npm run dev 成功而没有抛出任何错误,这是一个运行时问题,而不是构建时问题。服务器启动时也没有抛出错误。这表明错误发生在请求处理器中,而不是在服务器启动代码中。
4. 控制台和网络标签页
让我们在浏览器窗口中打开开发者工具。点击 控制台 标签以查看任何记录的客户端错误。以下错误应该显示出来:
GET http://localhost:3000/ 500 (Internal Server Error)
一个 GET 请求因内部服务器错误而失败。我们进一步调查,通过导航到 网络 标签,如下所示:

图 2.1 – 介绍网络标签页
500。在 响应 标签页(图 2.1 中未显示),我们可以进一步看到 Remix 返回了一个 HTML 文档 – 我们在屏幕上看到的 应用程序错误 文档。
发生了什么?网络服务器尝试处理 GET 请求,但抛出了 ReferenceError。错误被 Remix 捕获。Remix 向浏览器返回了一个错误响应文档。Remix 使用状态码 500 – 内部服务器错误的状态码 – 来告诉我们和浏览器,响应是一个错误响应。
5. 关闭并重新启动
如果我们到目前为止无法识别错误,清理可能影响我们本地开发服务器的任何临时文件是一个好主意。如果开发服务器仍在运行,请停止它。然后运行以下命令来清理所有临时的 build 艺术品:
rm -rf build public/build .cache
这将删除所有临时文件和文件夹。接下来,在终端中执行 npm run dev 来重新启动开发服务器。这将触发新的构建,并导致本地环境清理。错误仍然存在吗?在我们的情况下,它确实存在,但我们能够验证错误不是由于构建损坏造成的。
6. 在 Google 上搜索这个问题
对于这个问题,你会使用什么 Google 搜索查询?也许看看是否能在 Google 上找到这个问题。我可能会使用以下搜索之一:
-
Navigator undefined服务器渲染的 React -
服务器抛出navigator undefined -
Remix 在服务器上抛出 navigator not defined
问题是 window 元素和其他浏览器全局变量在 Node.js 中不存在。由于我们尝试在服务器上执行 React 组件,它抛出 ReferenceError – 这不是 Remix 的问题,但在服务器上渲染 React 代码时的一个常见陷阱。问题解决!
这种错误的快速修复方法是使用 useEffect 调用包裹对浏览器 API 的引用:
import { useEffect, useState } from "react";export default function Index() {
const [userAgent, setUserAgent] = useState('the World');
useEffect(() => {
setUserAgent(navigator.userAgent);
}, []);
console.log(`Another hello to ${userAgent}!`);
return (
<h1>Hello to {userAgent}!</h1>
);
}
useEffect 仅在初始渲染后运行。由于我们在服务器上只渲染一次,我们知道 useEffect 只在客户端执行,永远不会在服务器上执行。我们可以在 useEffect 中安全地调用浏览器 API。
你能想到这种方法的任何缺点吗?重新加载你的应用程序并查看终端。它记录了 Another hello to the world!。在服务器上,我们没有访问 navigator 对象的权限,而是使用 React 状态的默认值。我们只在客户端重新渲染适当的问候消息。
如果 JavaScript 加载失败或被禁用怎么办?如果请求是由不运行 JavaScript 的网络爬虫发起的怎么办?在慢速互联网连接上,布局可能会闪烁,用户可能会先看到服务器值,然后在 JavaScript 加载后更新一次。这不是一个好的用户体验。
Remix 提供了在服务器上处理用户数据的实用工具 – 完全不需要使用 useEffect。大多数时候,我们可以避免客户端-服务器状态不匹配。目前,我们可以满意地认为我们解决了问题,并学会了如何在 Remix 中调查错误。我们可以将描述的故障排除过程总结如下:
-
在终端中查找并阅读错误消息。
-
定位产生错误的文件。
-
理解这是构建时错误还是运行时错误。
-
检查浏览器开发者工具以获取更多上下文。
-
删除所有构建工件并重试。
-
在 Google 上搜索问题。
接下来,我们将专注于 第 6 步。成功的调试需要练习和经验。通常,你只需要使用 Google。在下一节中,我们将记录在处理 Remix 时如何找到答案。
寻找答案
成功的调试需要练习。一旦你遇到一次或两次问题,你将更快地找出根本原因。在此之前,在网上寻找解决方案是良好的实践。在本节中,你将学习如何在处理 Remix 时获得帮助。
按照上一节中概述的故障排除过程进行操作。收集尽可能多的信息非常重要。一旦你开始制定你的问题或疑问,你会发现彻底的调查可能已经提供了答案。至少它将帮助你确定在线搜索的正确关键词。
我们在开发过程中遇到的大多数问题都与 Remix 无关。大多数错误发生是因为在服务器上渲染 React、意外在客户端运行服务器代码,或者因为 node 包的问题。在这些情况下,Stack Overflow、GitHub 和其他地方可能包含我们寻求的答案。
如果不是这样,那么我只能鼓励你加入 Remix Discord 服务器(discord.com/invite/xwx7mMzVkA)。Remix 社区非常支持。
在提问之前,请确保使用 Discord 的搜索功能查看问题是否已经被提出并解答。这样可以节省每个人的时间。通过遵循故障排除过程,你应该能够提供足够多的上下文,以帮助社区调试你的代码。提供代码片段(或者更好的是,一个代码沙盒或公共仓库)也将大大提高社区帮助你解决问题的可能性。Remix 社区非常棒,Discord 是一个寻求帮助的好地方。
如果你遇到 Remix 原语或约定的相关问题,请参考 Remix 文档。以下页面汇总了常见的问题:remix.run/docs/en/2/guides/gotchas。这些问题更难调试。提前了解它们可以节省你昂贵的调试时间。
在本节中,你学习了如何在处理 Remix 时解决问题。你通过一个示例错误并应用提出的调试过程来揭示根本原因。你还进一步学习了如何在线搜索答案以及在哪里提问。
摘要
在本章中,我们创建了我们的第一个 Remix 应用程序。我们使用 Remix 的create-remix CLI 脚本来启动一个带有基本模板的 Remix 应用程序,以及使用 Remix 的 Express.js 模板。
通过遵循本章,你学习了如何使用npm run dev和npm run build在本地构建和运行 Remix 应用程序。更重要的是,你了解了如何找到所有可用的脚本(package.json)以及运行特定模板的附加信息(README.md)。
我们回顾了 Remix 的文件和文件夹结构。阅读本章后,你了解了客户端和服务器入口点:entry.client.tsx和entry.server.tsx。
我们也花了一些时间调查了root.tsx文件。root.tsx文件作为路由树的根。Remix 利用 React 渲染完整的 HTML 文档,包括头部、打包的脚本、链接和元标签。这提供了对屏幕上渲染内容的完全控制。
我们更改了 _index 路由的代码,并在屏幕上渲染了 Hello World!。接下来,我们研究了 Remix 的两个环境:客户端和服务器。您了解到 Remix 在浏览器和 Web 服务器上运行。Remix 将应用程序代码编译成客户端和服务器构建。Web 服务器在运行时将客户端代码作为静态文件暴露,同时调用服务器包来处理传入的请求。
Remix 使用适配器在不同的 Web 服务器和服务器运行时上运行。适配器管理我们的 Remix 应用与底层服务器代码之间的通信。
最后,我们通过解决一个示例错误来练习了 Remix 应用的故障排除。您学习了如何通过查看服务器的终端和浏览器中的 控制台 选项卡来调查错误。您还练习了在线搜索问题,并发现可以向 Remix 社区寻求帮助的地方。
在下一章中,您将了解有关不同模板、部署目标、适配器和 Remix 栈的更多信息。我们将比较不同的服务器运行时和部署环境,以便您为您的 Remix 应用选择正确的选项。您还将被介绍到 Bee-Rich,这是本书的演示应用程序。准备好按照本书的章节从头到尾构建一个 Remix 应用程序吧!
进一步阅读
您不需要使用 create-remix 来引导新的 Remix 项目。您也可以通过安装 Remix 的依赖项并自行设置文件和文件夹结构从头开始。如果您对这个方法感兴趣,请遵循 Remix 文档中的 5 分钟教程:remix.run/docs/en/2/start/quickstart。
我们向您介绍了 Remix 应用服务器。您可以在以下链接中了解更多关于 Remix 基本模板的信息:remix.run/docs/en/2/other-api/serve。
如本章前面所述,请参考 Remix 的自身故障排除指南以获得常见问题的帮助:remix.run/docs/en/2/guides/gotchas。
第三章:部署目标、适配器和栈
在运行时,Remix 在底层 Web 服务器上运行并处理传入的 HTTP 请求。启动一个新的 Remix 项目也意味着选择一个 Web 服务器和 JavaScript 运行时。Remix 团队及其社区为许多流行的部署目标维护了入门模板和适配器。在本章中,我们将回顾不同的部署目标、模板和 Remix Stacks。
我们将涵盖以下主题:
-
选择部署目标
-
在适配器之间切换
-
使用 Remix Stacks
-
与 BeeRich 合作
首先,我们将概述流行的模板、部署目标、JavaScript 运行时和托管环境。接下来,我们将练习切换适配器,并介绍 Remix Stacks。在本章结束时,我们将使用本书的定制模板启动一个新的 Remix 应用程序。
在完成本章学习后,你将了解在选择新的 Remix 应用程序的部署目标和模板时需要考虑的因素。你还将了解更多关于不同的 JavaScript 运行时,并理解长期运行的服务器、无服务器和边缘环境之间的主要区别。此外,你将获得在适配器之间切换的实践经验,并学习如何与 Remix Stacks 一起工作。
技术要求
为了完成本章,你需要一台可以运行 Node.js 的计算机。所有流行的操作系统都足够使用。请在您的机器上安装 Node.js 和 npm。推荐使用 VS Code 等编辑器。
你可以从这里下载 Node.js 和 npm:nodejs.org/en/download/.
本章的代码可以在以下链接找到:github.com/PacktPublishing/Full-Stack-Web-Development-with-Remix/tree/main/03-deployment-targets-adapters-and-stacks.
选择部署目标
Remix 最显著的特点之一是其灵活性。Remix 支持许多不同的 Web 服务器和运行时环境,包括无服务器和边缘环境。选择模板也意味着选择 JavaScript 运行时、托管环境(服务器、无服务器、边缘)以及可能的主机提供商平台。为了决定为项目选择哪个模板,我们需要了解不同部署目标的优缺点,以便做出明智的决定。在本节中,我们概述了流行的模板和适配器,并回顾了它们之间的区别。
在上一章中,我们使用create-remix CLI 脚本来创建一个新的 Remix 应用程序。在这个过程中,我们必须指定一个模板或使用 Remix 的基本模板。Remix 团队和社区为许多不同的部署目标维护了适配器和模板。一个模板会使用所需的 Web 服务器代码启动一个新的 Remix 应用程序。Web 服务器代码不是 Remix 框架的一部分,但它与 Remix 应用程序一起分发,以简化开发过程。
在以下内容中,我们提供了 Remix 常见部署目标的概述。表 3.1 列出了由 Remix 团队和社区或托管提供商公司维护的模板。如果一个模板没有为特定的托管提供商设置,那么托管提供商列将显示通用。
所有引用的模板也列在 Remix 文档中:remix.run/docs/en/dev/other-api/adapter。此外,你还可以在 GitHub 上的 Remix 仓库中找到 Remix 团队维护的所有模板:github.com/remix-run/remix/tree/main/templates。
| 部署目标 | 托管提供商 | JavaScript 运行时 | 托管环境 |
|---|---|---|---|
| Architect (Arc) | AWS | Node.js | Serverless |
| Cloudflare Pages | Cloudflare | Workers runtime | Edge Isolate |
| Cloudflare Workers | Cloudflare | Workers runtime | Edge Isolate |
| Deno | Generic | Deno | Edge Isolate |
| Express.js | Generic | Node.js | Server |
| Fastify | Generic | Node.js | Server |
| Fastly Compute@Edge | Fastly | Wasmtime | Edge Isolate |
| Fly.io | Fly.io | Node.js | Distributed Server |
| Netlify | Netlify | Node.js | Serverless |
| Remix App Server | Generic | Node.js | Server |
| Vercel | Vercel | Node.js | Serverless |
表 3.1 – Remix 的部署目标
如前表所示,许多部署目标都与特定的托管提供商相关联。托管服务特定适配器的优点是部署简单。你会发现,你可以在几分钟内启动并运行这些服务中的大多数。
通用适配器的优势在于额外的灵活性。例如,选择 Express.js 部署目标将创建一个 Express.js 服务器,可以在任何 Node.js 服务器可以运行的地方托管。这包括 AWS、Azure 和 Google Cloud Platform 等云计算平台以及 Railway.app 和 Render.com 等流行的托管平台。
注意,表 3.1 并不完整,因为可用的部署目标、模板和适配器的数量会随时间变化。本章的目标是通过学习如何分类和描述它们,帮助你有效地选择部署目标。首先,让我们看看各种 JavaScript 运行时之间的区别。
选择 JavaScript 运行时
JavaScript 可以在不同的运行时环境中执行。在本节中,我们列出了流行的 JavaScript 运行时,并讨论了在选择运行时时应考虑的因素。
一些流行的 JavaScript 运行时环境如下:
-
浏览器环境
-
Node.js
-
Deno
-
Workers runtime
-
Bun
浏览器是 JavaScript 最原生环境。然而,不同的浏览器使用不同的引擎来执行 JavaScript。因此,它们在技术上构成了不同的运行时环境。幸运的是,它们大多遵循开放网络平台,并支持标准的 JavaScript API 和运行时行为。由于我们正在尝试选择服务器端部署目标,我们目前对服务器端运行时更感兴趣。
JavaScript 最突出的服务器端运行时是 Node.js。Node.js 拥有丰富的包和库生态系统,被广泛使用和支持。然而,也存在其他服务器端 JavaScript 运行时,如 Bun、Deno 和 Cloudflare 的 Workers 运行时(workerd)。
如表 3.1所示,一些部署目标在底层使用 Deno 或 workerd。了解这对你的应用程序开发意味着什么非常重要。如果你对使用 Deno 或 workerd 开始一个项目感兴趣,请确保首先熟悉底层运行时。
比较 JavaScript 运行时,我们必须确保它们支持我们应用程序所需的 API 和功能。例如,workerd 是一个边缘运行时,为 Cloudflare Workers 提供动力。workerd 不支持 Node.js 标准库的执行。因此,只有当这些包不使用 Node.js 标准库时,你才能使用 npm 包。相比之下,大多数 Node.js 包在 Deno 中都能工作,因为 Deno 旨在与 Node.js 兼容。你也将能够在 Deno 中使用大多数 Node.js 标准库。然而,这需要特殊的导入语法。
作为 Remix 开发者,了解一些部署目标基于与 Node.js 不同的 JavaScript 运行时非常重要。每个运行时都带有限制和考虑因素。接下来,我们将研究不同托管环境之间的差异。
选择托管环境
我们托管 Web 应用的方式随着时间的推移而改变。部署可以简单到指向 GitHub 仓库和分支或提供 Docker 镜像。我们很少再自己管理底层服务器基础设施。相反,托管提供商和云平台为我们管理基础设施,并提供我们的应用程序运行的环境。
云平台和托管提供商提供不同的托管环境。这些环境各有优缺点。如表 3.1所示,不同的部署目标适合以下三个托管环境类别之一:
-
服务器
-
无服务器
-
边缘
让我们看看三种不同的托管环境有何不同。以下表格概述了最重要的差异:
| 特性 | 服务器 | 无服务器 | 边缘 |
|---|---|---|---|
| 长运行 | 否 | 是 | 否 |
| 文件系统访问 | 是(视情况而定) | 否 | 否 |
| 独立请求 | 否 | 是 | 是 |
| 默认可扩展 | 否 | 是 | 是 |
| 默认分布式 | 否 | 否 | 是 |
表 3.2 – 不同托管环境的特征
Web 服务器被认为是长期运行的环境。Web 服务器通常只启动一次,只有在升级、重新部署或服务停用时才会停止运行。根据托管提供商的不同,Web 服务器可以访问文件系统。长期运行的服务器不会隔离单个请求。这意味着请求共享全局应用程序状态。
无服务器环境在本质上与长期运行的服务器不同。无服务器是一种云计算模型,其中应用程序被视为一个函数,并由云平台提供商进行编排。无服务器函数专门为传入请求启动。在指定超时后,无服务器函数被终止,并且任何应用程序上下文,如闭包、缓存的程序状态和全局变量,都会丢失。
无服务器环境通常提供按使用付费的定价。我们只为函数处理请求的时间付费。由于基础设施提供商可以在并行中生成多个重复的无服务器函数,因此无服务器本质上也是高度可扩展的。
需要注意的是,无服务器环境与 Web 服务器的要求非常不同。在长期运行的服务器上容易解决的问题在无服务器上可能需要更多的参与。然而,无服务器提供的解决方案旨在可扩展。
例如,Web 服务器在启动时创建数据库连接,并将这些连接共享给所有传入的请求。无服务器函数必须为每个传入请求创建一个新的数据库连接。创建新的数据库连接可能会成为传入请求的瓶颈,并可能导致延迟或超时。此外,数据库服务器一次只能支持有限数量的打开连接。为每个无服务器函数打开新的连接可能会超过最大连接数。
无服务器提供连接池作为解决这些问题的方法之一。连接池允许不同的函数在不同实例之间共享连接。这是无服务器中的一种常见模式。与长期运行的服务器一起工作的事物在无服务器中需要更多的考虑。无服务器旨在进行扩展,但扩展也带来了复杂性作为副产品。
扩展不可避免地会引入复杂性。一旦你需要考虑长期服务器的扩展,你可能会遇到许多无服务器已经为你解决的问题。例如,无服务器环境默认实现负载均衡。
服务器无服务器关注的是可扩展性,而边缘计算关注的是提供地理邻近性。大多数边缘环境都是无服务器的,并且共享相同的优势,如可扩展性和按使用付费定价。使边缘环境特殊的是它们与最终用户的邻近性。边缘函数是区域分布的——例如,在 CDN 的服务器上。这种创建的邻近性可以显著减少响应时间。
大多数服务器和无服务器环境不会自动将你的应用程序分布到不同的区域,至少不是在没有额外的配置开销和额外成本的情况下。边缘计算允许你“开箱即用”地将你的 Web 应用程序分布到全球各地。
边缘环境有其自身的局限性和考虑因素。一般来说,边缘函数在运行时能力方面是最受限制的。一个限制是,在边缘部署需要分布式数据库解决方案。否则,边缘函数与你的数据库服务器之间的通信将导致响应时间延迟。
不同的运行时环境也使用不同的容器技术来部署和运行你的代码。长期运行的服务器可以直接在物理或虚拟机上运行。然而,大多数云提供商使用轻量级的容器技术来运行你的应用程序。需要注意的是,容器领域是多样化的。有几种不同的容器技术和标准,例如 V8 隔离。
V8 是 Chrome 背后的 JavaScript 引擎,也被 Node.js 和 Deno 使用。V8 隔离是重用相同语言运行时的隔离 V8 实例,避免了在接收到的请求上启动新的语言运行时的缓慢冷启动。由于它们比大多数容器技术更轻量级,V8 隔离在计算相对于集中式云数据中心稀疏的边缘环境中被使用。
在选择托管提供商和运行时环境时,研究与其容器技术相关的限制。这些限制可能会影响你的应用程序的大小或超时前的最大运行时间。
考虑到权衡取舍,每个环境都伴随着其自身的考虑因素。接下来,我们将连接这些点,总结如何为特定用例选择正确的部署目标。
做出最终决定
在本节中,我们将总结如何根据我们对 JavaScript 运行时和不同托管环境(服务器、无服务器、边缘)的了解来选择部署目标。
根据我的经验,长期运行的服务器环境提供了大量的灵活性,并且比其替代方案引入的复杂性要少。话虽如此,像 Netlify 和 Vercel 这样的托管提供商为他们的无服务器和边缘服务提供了极佳的开发者体验和补充服务。如果你在寻找一个能够区域性地分配你的长期运行 Web 服务器和数据库的提供商,那么 Fly.io 可能是一个不错的选择。
无服务器提供了许多优势,但也引入了复杂性。边缘计算可以显著提高响应时间,但引入了更多的复杂性。无服务器和边缘环境的额外复杂性是规模和区域分布的副产品。一旦你考虑了长期运行的 Web 服务器的规模和区域分布,你将面临类似的挑战。
个人而言,选择正确的部署目标更多的是避免不必要的障碍,而不是其他任何事情。部署目标必须符合用例并满足系统要求。让我们来看一些例子。
如果你想要利用 Node.js 库或重用 Node.js 代码,你可能想坚持使用基于 Node 的环境。Express.js 的部署目标是显而易见的选择。但是,对于无服务器环境呢?如果你使用的是没有为无服务器环境提供内置解决方案的传统数据库,那么你将不得不为每个新的传入请求创建一个新的数据库连接。这可能会导致响应时间更长,甚至超时。然而,如果你已经使用了一个以无服务器为先的数据库,并且正在寻找可扩展性,那么选择一个无服务器环境可能是一个很好的主意。
如果你只有一个位于俄勒冈州的数据库服务器,那么将你的 Web 应用程序部署到世界各地并不会给你带来太多好处。你的应用程序的每个实例都将不得不从俄勒冈州的数据库服务器请求数据。在这种情况下,你应该选择一个尽可能靠近你的数据库的 Web 服务器或无服务器环境。
如果你想要建立一个博客,你很可能会寻找轻松访问你的博客文章。博客文章通常以文件的形式存储。使用允许你访问文件系统的托管提供商和环境,你可以将你的博客文章与你的代码一起托管。除非你遇到这个问题(任何方法都有缺点),你可能想从一个简单的 Web 服务器架构开始。在这种情况下,你可以选择 Express.js 模板。这将让你通过大多数托管提供商访问底层文件系统。
另一方面,你可能发现一些无服务器提供商提供了有吸引力的免费层和具有竞争力的按使用付费定价。选择正确的运行时取决于你的优先级和需求。选择一个没有文件系统访问权限的托管环境并不意味着你不能与文件一起工作。这仅仅意味着你必须将文件存储在其他地方——例如在文件存储服务中。因此,这更多的是关于做出明智的决定,以避免未来遇到障碍。
最后,决定部署目标也意味着选择一个托管提供商。每个部署提供商都有自己的优缺点。例如,Fly.io 托管长时间运行的 Node.js 服务器,但提供不同地区的分发。Fly.io 可能是一个很好的无服务器边缘部署的替代方案。另一方面,AWS 和 Cloudflare 各自提供丰富的服务生态系统,可以与他们的无服务器和边缘产品一起使用。对于复杂的应用程序,托管在 AWS 或 Cloudflare 可能是正确的选择。
长运行服务器、无服务器和边缘环境之间的界限模糊。不同的提供商进一步为无服务器和服务器环境提供地理分布,以实现类似边缘的用户接近度。一些可能提供两者的最佳结合。
选择部署目标在很大程度上取决于您的应用程序用例。幸运的是,Remix 足够灵活,可以支持各种不同的用例。毕竟,Remix 可以在任何可以执行 JavaScript 的地方运行。
幸运的是,如果需要,可以轻松地在 Remix 项目中交换底层的服务器环境和适配器。在下一节中,我们将练习如何这样做。
在适配器之间切换
我们有时必须从一个模板迁移到另一个模板。为此,我们必须切换 Remix 应用程序的设置。在本章中,我们将介绍切换模板和适配器的过程。
在模板和适配器之间切换的过程可以总结如下:
-
定位您的 Remix 项目,并在编辑器或文件资源管理器中打开它。
-
使用新的模板和适配器创建一个新的 Remix 项目。
-
将新 Remix 项目与您的旧项目并排打开。
-
将新项目中的
app文件夹重命名为temp。将旧项目中的app文件夹移动到新项目中。 -
将
app/entry.client.tsx中的代码替换为temp/entry.client.tsx中的代码,并整合之前添加到文件中的任何自定义代码。 -
将
app/entry.server.tsx中的代码替换为temp/entry.server.tsx中的代码,并整合之前添加到文件中的任何自定义代码。 -
对于您旧项目中根目录下的每个 Remix 特定文件和文件夹:
-
调查您是否进行了任何更改。
-
将任何更改复制粘贴到新项目中。
-
解决任何冲突(如果有)。
-
-
按照新项目中的
README.md文件中的说明来运行、构建和部署应用程序。 -
使用第二章中描述的故障排除过程,即创建新的 Remix 应用程序,来解决任何剩余的问题。
让我们使用我们在第二章中创建的"Hello World!" Remix 应用程序来练习描述的过程。如果您想挑战自己,看看您是否可以通过遵循之前列出的九个步骤自己解决这个问题。否则,让我们一起来完成它:
-
在编辑器或文件资源管理器中打开您的“Hello World!” Remix 应用。您也可以在这里找到代码:
github.com/PacktPublishing/Full-Stack-Web-Development-with-Remix/tree/main/02-creating-a-new-remix-app/hello-world。在第二章,我们使用了基本模板,但这个过程也适用于其他模板。
-
运行
npm install和npm run dev以确保项目在本地运行且无任何问题。 -
现在,使用
create-remixCLI 脚本创建一个新的 Remix 项目。运行以下命令:create-remix to bootstrap the new Remix app. -
完成创建
create-remixCLI 脚本后,我们运行npm install和npm run dev以确保新项目可以在本地运行。 -
在确认两个项目都能无错误运行后,我们在编辑器或文件资源管理器中打开新的 Remix 应用。同时打开两个编辑器/文件资源管理器是最方便的。
-
让我们将新项目中的
app文件夹重命名为temp。接下来,将旧“Hello World!”项目中的app文件夹复制到新项目中。这样我们可以并排检查temp和app文件夹。 -
在新项目中运行
npm run dev。Remix 将尝试使用“Hello World!”的app文件夹构建和运行项目:@remix-run/node package cannot be resolved. Also, notice that the errors originate from app/entry.server.tsx. -
将
app/entry.server.tsx中的代码与temp/entry.server.tsx中的代码进行比较。“Hello World!”应用中使用的 Express.js 模板使用 Remix 的 node 适配器(
@remix-run/node),而 Cloudflare Worker 模板使用@remix-run/cloudflare适配器。Cloudflare Workers 环境在 workerd 上运行,不支持 Node.js 库。要在 Node.js 和 workerd 上运行,Remix 需要设置两个不同的入口点实现并使用不同的适配器。
-
由于我们在
entry.server.tsx中没有自定义代码,我们将app/entry.server.tsx中的代码替换为temp/entry.server.tsx中的代码。 -
当然,没有其他使用
@remix-run/node的地方。在您的编辑器的代码搜索中搜索包名。您应该找到两个匹配项:
app/root.tsx和app/routes/_index.tsx。 -
确保找到并替换导入为
@remix-run/cloudflare。Remix 通过不同的适配器包公开其服务器端类型和原语。当切换适配器时,我们必须找到并替换所有旧适配器的导入为新适配器。
-
接下来,比较并替换
app/entry.client.tsx中的代码,以及temp/entry.client.tsx中的代码。不同的模板可能使用不同的 React 版本或在客户端入口文件中实现额外的逻辑。 -
再次运行
npm run dev。现在它应该能够成功构建和运行新项目! -
让我们调查旧应用的根文件夹。是否有我们创建或修改的文件?我们应该将所有自定义文件和文件夹移动到新项目中。
-
检查
package.json文件。我们没有为我们的“Hello World!”应用程序安装任何额外的包,但通常,我们现在会将任何应用程序依赖项移动到我们的新项目中并安装它们。我们还应该比较两个
package.json文件的脚本。我们必须确保任何自定义或修改过的脚本与我们的新项目的新脚本合并。在我们的案例中,我们不需要做任何事情。我们没有添加任何需要继承的自定义逻辑。
我们做到了!我们从Remix 应用程序服务器移动到了Cloudflare Workers——从长期运行的服务器到边缘环境!
app文件夹中的两个入口文件是由 Remix 启动的,并且针对每个部署目标进行了自定义。因此,我们必须确保更新这些文件中的代码。我们还必须将我们的自定义依赖项与新项目的依赖项合并。这需要我们手动审查package.json文件。最后,我们必须接管对项目中文件和文件夹所做的任何其他更改。
在本节中,我们成功地将一个适配器移动到另一个适配器。在下一节中,我们将学习关于 Remix 栈的内容,以及如何为生产就绪的 Remix 应用程序进行初始化。
使用 Remix 栈
在本节中,你将了解 Remix 栈。首先,我们将查看 Remix 的官方栈以及如何使用它们。接下来,你将学习如何使用社区模板。
与 Remix 官方栈一起工作
Remix 还提供了预配置的生产就绪模板。这些模板被称为 Remix 栈。截至目前,Remix 提供了三个官方栈:
| 栈 | 部署目标 | 包含 | 使用场景 |
|---|---|---|---|
| Blues | Fly.io | PostgreSQL 数据库 | 大规模应用程序和区域分布 |
| Indie | Express.js | SQLite 数据库 | 具有动态数据的小规模项目 |
| Grunge | Architect (AWS Lambda) | DynamoDB 数据库 | 在 AWS 基础设施上的大规模应用程序 |
表 3.3 – 官方 Remix 栈
栈是具有偏见的工程启动器。它们比 Remix 的基本模板更复杂,但同时也提供了更多功能。如表 3.3所示,启动器是根据不同的使用场景构建的,并利用不同的部署目标。
Blues 栈运行在 Fly.io 上。从表 3.1中,你可以推断出 Fly.io 托管长期运行的服务器。Fly.io 提供了长期运行服务器的区域分布,以实现边缘环境般的接近。Blues 栈还包含设置 PostgreSQL 数据库的代码。该栈旨在可扩展并能够开箱即用地实现区域分布。
Indie 栈使用 Express.js 适配器,不针对特定的托管提供商。它包含一个 SQLite 数据库。Indie 栈非常适合启动处理较小动态数据量的项目。从表 3.2中,你可以推断出这个栈——取决于托管提供商——可以访问文件系统,并且能够在不同的请求之间共享应用程序状态。
Grunge 堆栈服务于与 Blues 堆栈类似的使用案例,但使用的是基于 AWS Lambda 的框架 Architect。从 表 3.1 中,您可以得出结论,Architect 在无服务器环境中运行。AWS 基础设施旨在支持大规模应用程序。
让我们尝试运行一个 Remix Stacks。在终端中运行以下命令:
npx create-remix@2 --template remix-run/indie-stack
我们再次使用 create-remix 来启动一个新的 Remix 应用程序。这次,我们引用了 Remix 的三个官方堆栈模板之一:Indie 堆栈。根据您的个人喜好,您也可以选择另一个堆栈。
注意,我们在 @remix-run GitHub 组织中指向了一个名为 indie-stack 的 GitHub 仓库。
调查启动文件夹结构,并参考 README.md 文件以了解所有使用的技术和包的概述。Indie 堆栈有几个值得注意的特点:
-
一个注册和登录设置 (
app/session.server.ts)。 -
一个放置您的数据库逻辑的约定 (
app/models/)。 -
一个用于健康检查的路由 (
app/routes/healthcheck.tsx)。
Remix Stacks 默认实现样式、测试、身份验证和附加功能。Remix 的基本模板提供简单的服务器和适配器设置,而 Remix Stacks 是生产就绪的应用程序,附带了一个有见地的设置。除此之外,Remix 还支持创建自定义模板。在下一节中,我们将学习如何使用和创建自定义模板。
使用自定义模板
我们可以利用社区开发的模板或创建自己的模板,快速使用我们偏好的堆栈开始工作。自定义模板也是为组织开发有见地的模板的绝佳方式。
任何 Remix 项目都可以用作模板。唯一的限制是模板必须是一个有效的 Remix 项目,根目录中有一个 package.json 文件。访问模板的最简单方法是通过 GitHub,但 Remix 也可以通过 URL、您机器上的本地路径、GitHub 仓库的子文件夹或本地或远程的 tarball 访问模板。
让我们尝试本书 GitHub 仓库中的自定义模板。在终端中使用带有模板标志的 create-remix 命令:
npx create-remix@2 --template PacktPublishing/Full-Stack-Web-Development-with-Remix/03-deployment-targets-adapters-and-stacks/bee-rich
确保选择 remix.init 脚本。
运行 init 脚本后,你应该在终端看到一个简短的消息:
Running template's remix.init script...Hey there! Great job practicing using a custom stack! You're doing great!
这条消息源自项目根目录中的 remix.init/index.js 脚本。该脚本允许模板作者实现自定义设置步骤。您也可以通过在项目根目录中调用 npx remix init 再次运行该脚本。
顺便说一句,我刚刚偷偷地帮你设置了 BeeRich 应用程序,它将作为本书其余部分的演示应用程序。接下来,我们将用 BeeRich 开始我们的开发之旅。
使用 BeeRich
欢迎来到 BeeRich!BeeRich 是一个类似于仪表盘的应用程序,它模仿了个人和企业用例。BeeRich 是一个个人财务管理应用程序,帮助你管理好你的蜜蜂——请原谅我——账目。嗯,至少那是目标。目前还没有太多内容。在每一章中,我们将向这个应用程序添加更多代码。在本节中,我们将本地运行 BeeRich 并审查文件夹结构。
在上一节中,我们使用 create-remix 脚本启动了 BeeRich。BeeRich 仅仅是在 Remix 的 Express.js 模板上构建的一个简单骨架应用程序,我们在 第二章,创建新的 Remix 应用程序 中尝试了它。你还可以在本书的 GitHub 仓库中找到 BeeRich Remix 模板:github.com/PacktPublishing/Full-Stack-Web-Development-with-Remix。
让我们本地运行 BeeRich。打开终端并导航到项目的根目录。然后在终端中执行以下命令:
npm run dev
应用程序现在应该已经在端口 3000 上运行:http://localhost:3000/。欢迎来到 BeeRich!
npm run dev 命令启动 Remix 的开发服务器以构建和监视开发环境。你可以在 package.json 文件中找到所有可用脚本的列表。此外,请查看 BeeRich 项目的根目录下的 README.md 文件以获取有关 BeeRich 的更多信息。
我们已经包含了 Tailwind CSS,这样我们就可以快速添加一些样式,而无需离开我们的 JavaScript 模块。你还可以在 app/components 中找到一些样式化的可重用组件。访问 /demo 路由来检查一些可重用组件(localhost:3000/demo)。
随意偏离本书中的课程并探索替代方案。如果你遇到困难,你总是可以重置你的应用程序并使用 GitHub 上当前章节文件夹中的应用程序作为基准。
在下一章中,我们将向 BeeRich 添加页面和路由。
摘要
在本章中,你学习了在选择 Remix 应用程序的部署目标时需要考虑什么。你了解了不同的托管提供商、环境和运行时。你现在明白 Remix 可以在长期运行的服务器、无服务器环境和边缘运行时上运行。每个环境都有其优势和劣势,在选择模板和部署目标时你必须考虑这些因素。
Remix 的不同部署目标运行在不同的 JavaScript 运行时上,例如 Node.js、workerd 和 Deno。不同的 JavaScript 运行时支持不同的网络标准,并且可能支持或不支持 Node.js 标准库。在挑选模板时,你必须考虑你想要使用的 JavaScript 运行时。
本章向您介绍了一个九步过程,用于从一种适配器迁移到另一种适配器。更换适配器使您能够尝试不同的托管提供商和环境,并在您的需求随时间变化时保持敏捷。
Remix 为不同的部署目标提供了基本模板,用于设置 Remix 的适配器。然而,Remix 还提供了生产就绪的堆栈。在本章中,您练习了使用create-remix脚本来使用 Remix 的 Indie Stack 启动一个新的 Remix 应用程序。
接下来,我们使用自定义模板启动了演示应用程序 BeeRich,我们将在接下来的章节中使用它来练习我们的 Remix 技能。在下一章中,我们将学习 Remix 中的路由并添加 BeeRich 的页面。
进一步阅读
您可以在 Remix 文档中找到可用适配器的列表:remix.run/docs/en/2/other-api/adapter。
Remix 文档还提供了更多关于可用模板的解释,请在此处查看:remix.run/docs/en/2/discussion/runtimes。
您可以在此处找到 Remix 堆栈的官方公告帖子:remix.run/blog/remix-stacks。
听 Wes Bos 和 Scott Tolinski 在 Syntax.fm 上讨论无服务器限制:syntax.fm/show/542/serverless-limitations。
在此处了解更多关于无服务器计算和容器之间区别的信息:www.cloudflare.com/learning/serverless/serverless-vs-containers/。
您可以在此处找到有关 V8 隔离器的更多信息:developers.cloudflare.com/workers/learning/how-workers-works/#isolates。
第四章:Remix 中的路由
“路由可能是理解 Remix 中最重要的概念。”
– Remix 文档
迈克尔·杰克逊和瑞安·弗洛里斯花费了多年时间构建 React Router。路由在 Remix 中扮演着核心角色并不令人惊讶。迈克尔和瑞安从 React Router 带到 Remix 的一个核心思想是嵌套路由。嵌套路由是一个强大的功能,它能够组合路由组件。
在本章中,您将了解 Remix 中的路由。我们将涵盖以下主题:
-
使用 Remix 的路由模块 API 进行工作
-
从嵌套路由组合页面
-
使用路由参数进行动态路由
-
共享布局
-
在 Remix 中处理导航
在本章中,我们将深入了解嵌套路由并介绍 Remix 的路由约定。我们将从创建独立页面并回顾 Remix 的路由模块导出开始。接下来,我们将回顾嵌套、索引、动态和(无路径)布局路由。最后,我们将学习如何在路由之间进行转换,并了解全局导航对象。
到本章结束时,您将了解 Remix 路由解决方案背后的核心原则。您将练习创建新的路由模块,并了解 Remix 支持哪些导出。您将理解嵌套路由和布局路由的优势。您还将练习如何处理路由参数。最后,您将了解如何使用 Remix 的全局导航对象。
在本章中,我们将向 BeeRich 应用程序添加登录和注册页面。我们将进一步创建一个包含嵌套支出和收入子路由的仪表板路由。然后,我们将为索引、登录和注册页面创建共享布局。最后,我们将使用全局导航对象来动画化页面转换。让我们直接进入正题,并在 Remix 中创建我们的第一个路由。
技术要求
在第三章,部署目标、适配器和堆栈中,我们为本书设置了演示应用程序。如果您还没有这样做,请确保遵循第三章的说明,因为我们将在本章继续使用 BeeRich。
您可以在 GitHub 上找到本章的解决方案代码和附加信息:github.com/PacktPublishing/Full-Stack-Web-Development-with-Remix/tree/main/04-routing-in-remix。
使用 Remix 的路由模块 API 进行工作
Remix 承担了编译器、运行时和路由器的责任。在 Remix 中,您将创建路由(路由模块)作为层次结构的一部分。在其他方面,Remix 的路由器确定哪些路由模块与请求匹配并渲染。
本节将指导你如何在 Remix 中创建路由模块。你将学习如何创建独立的页面,并了解路由如何与root.tsx文件相关联。你将进一步了解索引路由是如何融入画面(或者说,我应该说是屏幕)的。最后,本节将介绍路由模块可以公开的不同导出。
路由文件命名规范
在我们开始之前,请注意,Remix 从 Remix v2 开始切换到新的路由文件命名约定。本书遵循该约定。
如果你刚接触 Remix,那么这一章将帮助你开始使用 Remix 的最新约定。如果你之前有使用 Remix v1 文件系统路由约定的经验,你可以参考以下指南了解有哪些变化:remix.run/docs/en/1.19.3/file-conventions/route-files-v2。
使用基于文件的路由进行工作
我们首先检查 BeeRich 当前的路线结构。我们将继续使用上一章的 BeeRich 代码。或者,你可以在以下位置找到本章的起始代码:github.com/PacktPublishing/Full-Stack-Web-Development-with-Remix/tree/main/03-deployment-targets-adapters-and-stacks/bee-rich。
让我们回顾当前的路线层次结构:
-
在你的 Remix 项目的根目录下终端中运行以下命令:
app/routes folder. We can see that there are currently two files in the folder:* `demo.tsx`* `_index.tsx`You will find that this matches the hierarchy displayed by the `remix route` command. The hierarchy can be interpreted as a tree. Everything starts at the root (`root.tsx`). Nested, we have two child routes, each pointing to a file in the `routes` folder. Since the two route modules themselves have no children, they are leaves.Note that one route is flagged as `index`, while the demo route has a `path` property, which matches its filename. Each route segment in the routes hierarchy can have one index file. The index file is the default child route for a parent route and its URL path segment. The root of the `routes` folder maps to the `/`path. The `_index.tsx` file on the root level of the `routes` folder acts as the default route module for that path segment (`/`). -
让我们在本地运行我们的应用程序,通过在终端中执行
npm run dev来启动。 -
通过在浏览器中导航到
localhost:3000/来打开应用程序。这将把我们引导到应用程序的/路径。欢迎(再次)来到 BeeRich! -
在你的编辑器中,打开
app/routes/_index.tsx文件。该文件包含以下代码:import { H1 } from '~/components/headings';_index.tsx route module maps to the /path of the application. It is the default child route of the routes foler and renders when we visit the homepage of BeeRich. We will later see that this pattern holds true for every nested index file inside the routes hierarchy. -
现在,让我们访问
/demo页面。在浏览器地址栏的 URL 中添加demo。演示页面展示了我们可以用来构建 BeeRich 应用程序的可用可重用组件。 -
打开
app/routes/demo.tsx文件。注意,我们再次将 React 组件作为模块的默认导出。
这里有一些魔法在起作用。我们不需要通过代码明确指定 Remix 的路由层次结构。没有配置文件将组件映射到路径名。这是routes文件夹层次结构的强大之处。它创建了一个资产清单,其中包括应用程序的路由层次结构。
当访问一个页面时,Remix 知道要渲染哪个路由组件。将路由组件作为默认导出是一个约定,这样 Remix 就能找到你的代码。
让我们深入了解 Remix 的基于文件的路由约定,并创建一些独立的页面。
创建路由模块
在第八章 会话管理中,我们将向 BeeRich 添加注册和登录认证流程。在这一章中,我们将设置一般的路由结构来完成此操作:
-
在
routes文件夹中创建一个新文件,并将其命名为login.tsx。 -
接下来,使用浏览器地址栏导航到新创建的页面(
localhost:3000/login)。我们将在屏幕上看到以下错误弹出:
Error: Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: object. GET request for an HTML document), Remix looked for a default export of a React component. However, we did not specify a default export, or even a React component for that matter.
重要提示
UI 路由必须导出一个 React 组件作为其默认导出。这是 Remix 基于文件的路由约定的一部分。
-
要从屏幕上移除错误,切换到您的编辑器并在新创建的
login.tsx文件中添加一个 React 组件。现在以下代码就足够了:import { H1 } from '~/components/headings';export default function Component() { return <H1>Login!</H1>;}注意我们使用
H1,来自app/components文件夹的可重用组件,以避免重写大量的自定义样式。此外,请注意导入路径以波浪线符号(
~)开头。这是一个整洁的 TypeScript 特性。我们在tsconfig.json路径配置中将app文件夹映射到~。这允许我们使用波浪线符号作为快捷方式来定位app文件夹中的任何文件或文件夹。 -
刷新浏览器窗口。现在
/login页面应该可以正确渲染。太棒了!您刚刚使用 Remix 创建了一个独立的页面! -
接下来,在
routes文件夹中创建一个signup.tsx文件。然后,从登录页面复制并粘贴代码,并调整代码以更好地适应注册页面。 -
使用浏览器地址栏导航到新创建的页面(
localhost:3000/signup)。 -
让我们再次运行
remix routes命令来查看路由层次结构是如何增长的。在您的终端中执行以下命令:npx remix routes这产生了以下路由层次结构:
<Routes> <Route file="root.tsx"> <Route index file="routes/_index.tsx" /> <Route path="signup" file="routes/signup.tsx" /> <Route path="login" file="routes/login.tsx" /> <Route path="demo" file="routes/demo.tsx" /> </Route></Routes>
如预期的那样,路由层次结构增加了两个叶子路由。每个叶子路由代表应用程序中的一个页面。我们的应用程序现在处理以下四个路径名:
-
/ -
/``demo -
/``login -
/``signup
到目前为止,我们routes文件夹中的所有路由模块都是文档/UI 路由,它们作为路由层次结构的一部分渲染 React 组件到页面。在下一节中,我们将提供可用的路由模块导出的简要概述,并讨论 UI 和资源路由之间的区别。
可用的路由模块导出
在 Remix 中,我们区分资源路由和 UI 路由。每个 UI 路由必须导出一个 React 组件作为默认导出。资源路由不导出一个 React 组件作为默认导出。相反,资源路由必须导出一个action和/或loader函数。除了其他功能外,资源路由可以公开 API 端点,处理 Webhooks 或动态创建资产。我们将在本章后面创建我们的第一个资源路由。
Remix 检查路由模块导出的函数以确定它是一个 UI 路由还是一个资源路由。虽然资源路由仅支持导出action或loader函数,但 UI 路由可以导出以下命名的导出:
-
action -
ErrorBoundary -
handle -
headers -
links -
loader -
meta -
shouldRevalidate
我们将在接下来的章节中了解更多关于这些导出角色的信息。现在,让我们专注于 Remix 如何使用路由模块导出。Remix 在构建时编译路由层次结构。
在你的文件资源管理器中,导航到 public/build/ 并打开 manifest-*.js 文件。清单文件是在构建应用程序后创建的(npm run build 或 npm run dev),其中包含一个路由部分。路由部分包含应用程序中每个路由模块的条目。demo.tsx 路由的条目如下:
'routes/demo': { id: 'routes/demo',
parentId: 'root',
path: 'demo',
module: '/build/routes/demo-EKLEFBX2.js',
imports: ['/build/_shared/chunk-AATHADRZ.js'],
hasAction: false,
hasLoader: false,
hasErrorBoundary: false,
},
如代码所示,Remix 通过布尔标志跟踪导出的函数。布尔标志设置为 false,因为我们的演示路由没有导出 action、loader 或 ErrorBoundary。
Remix 的路由模块导出是文件路由约定的一部分。Remix 编译路由文件夹的内容并生成一个清单文件。这就是 Remix 让我们避免在代码中进行路由层次结构配置,而是促进基于约定的路由层次结构的原因。
在本节中,我们创建了两个新的路由模块,并了解了 Remix 中路由模块支持的命名导出。我们还使用了 remix routes 命令来可视化我们的路由层次结构。在下一节中,我们将深入探讨嵌套路由。
从嵌套路由组合页面
BeeRich 是一个个人记账应用程序。用户应该能够查看他们的支出和收入来源。在本节中,我们将创建嵌套路由的层次结构来组合 BeeRich 的仪表板页面。
到目前为止,我们已经看到 Outlet 在 root.tsx 文件中被使用。Outlet 组件在父路由的标记中声明了子路由的位置。root.tsx 中的 Outlet 组件在 HTML 的 body 元素内渲染。因此,所有子路由都被包裹在 body 元素内。这就是嵌套路由的力量。使用嵌套路由,你可以从多个路由模块中组合页面。
让我们使用嵌套路由和 Outlet 组件来构建我们的仪表板。两个路由,/dashboard/expenses 和 /dashboard/income,将作为我们的概览页面:
-
首先,在
routes文件夹内添加两个文件:-
dashboard.expenses.tsx -
dashboard.income.tsx
注意,我们使用点分隔符(
.)来分隔 URL 中的路径段(/)。 -
-
在两个文件中添加一个简单的路由组件。参考
login.tsx。在两个文件中,将组件作为默认导出。 -
然后,运行应用程序(
npm run dev),并在浏览器窗口中查看更改。目标是分别列出所有支出和收入来源(发票)。目前,我们将模拟数据并专注于设置路由层次结构。
-
将以下代码添加到
dashboard.expenses.tsx文件中:import { H1 } from '~/components/headings';export default function Component() { return ( <div className="w-full"> <H1>Your expenses</H1> <div className="mt-10 w-full flex flex-col-reverse lg:flex-row"> <section className="lg:p-8 w-full lg:max-w-2xl"> <h2 className="sr-only">All expenses</h2> <ul className="flex flex-col"> <li> <p className="text-xl font-semibold">Food</p> <p>$100</p> </li> <li> <p className="text-xl font-semibold">Transport</p> <p>$100</p> </li> <li> <p className="text-xl font-semibold">Entertainment </p> <p>$100</p> </li> </ul> </section> </div> </div> );}提供的代码将硬编码的支出列表渲染到页面中。
你也可以在 GitHub 上找到本章的最终代码:
github.com/PacktPublishing/Full-Stack-Web-Development-with-Remix/tree/main/04-routing-in-remix/bee-rich/solution。 -
同样,我们希望在
/dashboard/income页面上渲染所有收入来源的列表。从dashboard.expenses.tsx文件中复制代码,并将其修改为在dashboard.income.tsx中渲染硬编码的发票而不是支出。如图 4.1所示,两个页面现在都渲染了一个项目列表。在未来,用户还应该能够编辑支出或发票。为此,我们现在将创建一个嵌套的详细视图。

图 4.1 – 支出概览页面的截图
-
到目前为止,我们通过在浏览器地址栏中更改 URL 来在页面之间导航。现在,是时候添加锚标签了。用户应该能够点击支出概览中的列表项以查看支出详情视图。让我们用锚标签包裹硬编码的列表项内容。第一个列表项可能看起来如下:
<li> <a href="/dashboard/expenses/1"> <p className="text-xl font-semibold">Food</p> <p>$100</p> </a></li>确保每个项目都用锚标签包裹。
每个列表项现在都链接到一个包含支出模拟标识符的唯一 URL。在下一节中,我们将了解更多关于带有参数的动态路由。现在,让我们使用虚构的模拟标识符来专注于路由结构。
-
在你的浏览器窗口中,点击列表项中的一个。点击一个项应该会导航你到一个404 未找到页面。这是预期的,因为我们还没有创建相关的路由模块。
-
让我们为具有标识符
1的支出添加一个硬编码的详细页面。创建一个dashboard.expenses.1.tsx文件。 -
将以下代码添加到
dashboard.expenses.1.tsx中:import { H2 } from '~/components/headings';export default function Component() { return ( <div className="w-full h-full p-8"> <H2>Food</H2> <p>$100</p> </div> );}新创建的详情路由专门用于标识符为
1的支出。通过在支出概览中点击列表项,我们应该被重定向到列表项的详情视图。 -
让我们测试我们的实现。使用地址栏导航回
/dashboard/expenses/页面。然后,点击具有标识符1的列表项。Snap!出问题了!我们可以看到 URL 如预期那样发生了变化。点击列表项会将 URL 的路径名更新为
/dashboard/expenses/1。然而,页面内容并没有更新。我们仍然看到支出概览页面的内容。这是怎么回事?我们在
/expenses路径内创建了一个嵌套的dashboard.expenses.1.tsx文件。但为什么文件的内容没有出现在页面上?Remix 似乎只渲染了dashboard.expenses.tsx文件。问题在于
dashboard.expenses.tsx和dashboard.expenses.1.tsx都匹配相同的路径(/dashboard/expenses/)。通过这样做,我们将dashboard.expenses.tsx文件提升为父布局路由模块(如root.tsx)。如果我们想显示dashboard.expenses.tsx文件的内容或dashboard.expenses.1.tsx文件的内容,那么我们需要将dashboard.expenses.tsx从一个父路由改为兄弟路由。您之前已经了解到索引路由被用作 URL 路径段的默认子路由。让我们通过添加索引路由来尝试修复问题。
-
将
dashboard.expenses.tsx文件重命名为dashboard.expenses._index.tsx。通过这样做,我们在
expenses路径中声明了两个路由模块为兄弟关系。兄弟路由模块始终匹配不同的路径名。 -
在您的浏览器窗口中,导航回
/dashboard/expenses/页面。现在,点击列表项以导航到/dashboard/expenses/1。现在 UI 在两个页面之间按预期更新。通过将两个路由模块声明为兄弟关系,我们显示概览页面或详细页面内容。
Remix 的路由层次区分了特定 URL 路径的父路由和兄弟路由。dashboard.expenses.tsx是/到expenses路径。
父路由使用Outlet组件来声明其子组件应与其自己的布局一起渲染的位置。root.tsx是顶级父路由。
让我们试试看:
-
撤销您的更改。将
dashboard.expenses._index.tsx文件重命名为dashboard.expenses.tsx。 -
现在,从 Remix 导入
Outlet组件,并将其添加到所有****支出部分下方:className attributes and the last two list items from this example for easier readability. You can find the complete code on GitHub. -
刷新您的浏览器窗口。现在,当访问
/dashboard/expenses/1时,您应该看到支出概览和详细视图并排显示。

图 4.2 – 包含嵌套详细视图的支出概览页面截图
太棒了!我们创建了第一个布局路由,从不同的路由模块中组合页面。通过使用嵌套路由,我们将 URL 的不同部分映射到不同的路由模块。这创建了一个层次嵌套的结构。父路由使用Outlet组件来声明子路由应在页面上渲染的位置。
注意,/dashboard路径没有伴随dashboard.tsx文件。这应该告诉我们布局路由模块是可选的。我们可以使用布局路由在所有子路由之间共享一个共同的布局或创建由不同路由模块组成的复杂页面。然而,如果它们不是必需的,则可以省略。
对于 BeeRich,我们希望使用父布局路由而不是索引路由来渲染概览页面。在这种页面架构中,用户可以在一个页面上看到所有与支出相关的信息。
为了练习使用父布局路由,请确保更新收入页面并将 Outlet 组件添加到 dashboard.income.tsx 文件中。
在本节中,你学习了如何将页面组合成不同的路由模块。Remix 将 URL 的段映射到路由层次结构中的不同路由模块。使用父布局路由,我们可以在父路由内部嵌套和渲染子路由。然后,每个页面由不同嵌套路由模块的代码组成。接下来,我们将重构硬编码的参数路由并利用参数化路径段来创建动态支出详情页面。
使用路由参数进行动态路由
URL 通常包含标识符等参数,用于指定相关资源。这允许应用程序检索请求的正确数据。在本节中,你将学习如何在 Remix 中处理 URL 参数。
到目前为止,我们已经为硬编码的支出详情页面创建了一个路由模块 (/dashboard.expenses.1.tsx)。URL 中的数字 1 指的是具有支出标识符 1 的支出。然而,目标是创建一个能够处理变量标识符的动态路由模块。幸运的是,Remix 提供了一种定义参数化路由段的方法。
参数化路由段
在 Remix 中,URL 的动态段被称为参数化段。我们使用 $ 符号来声明路由参数。这会将 URL 段转换为我们可以访问并用于获取数据的参数。
让我们看看如何在 BeeRich 中使用参数化段来处理支出详情路由:
-
将
dashboard.expenses.1.tsx重命名为dashboard.expenses.$id.tsx。$符号是 Remix 的路由约定的一部分,用于声明动态路由段的参数。 -
接下来,更新文件内的代码并添加以下模拟数据:
const data = [ { id: 1, title: 'Food', amount: 100, }, { id: 2, title: 'Transport', amount: 100, }, { id: 3, title: 'Entertainment', amount: 100, },];模拟数据目前是我们的数据源。我们将在 第五章 中添加一个真实数据库,获取和 修改数据。
-
之后,添加一个具有以下代码的
loader函数:import type { LoaderFunctionArgs } from '@remix-run/node';import { json } from '@remix-run/node';export function loader({ loader function runs server-side before its route component is rendered. It is the perfect place to fetch data (on the server) dynamically based on route parameters.Remix exposes the `LoaderFunctionArgs` type to type the `loader` function’s arguments. As visible in the code, Remix provides a `params` argument that can be used to access the route parameters of the current URL.Using a parameterized route, we can now utilize the `params` argument to access the dynamic values of our URL. We access the `id` parameter from the parameter argument and use it to find the right expense from our mock data.We then return the expense object (using the `json` helper function provided by Remix). We will learn more about server-side data fetching and Remix’s server-side conventions and primitives in *Chapter 5*, *Fetching and* *Mutating Data*. -
在文件的路线组件中,我们可以使用
useLoaderData钩子访问loader响应数据。按照以下方式更新现有的组件代码:import { useLoaderData } from '@remix-run/react';export default function Component {useLoaderData hook provides access to the route module’s loader data. In our case, we returned an expense object that we can now access in our React application.We pass `typeof loader` as the generic type variable to `userLoaderData` to infer the type of the loader data, based on the `loader` function’s return value. These type hints are great for autocompletion.With that, we have created a dynamic route that renders its content based on the URL! -
确保测试实现。使用支出列表的锚点标签在不同的支出详情路由之间导航。
如代码所示,如果没有任何支出标识符与路由参数匹配,
loader函数会抛出一个Response。如果你看到错误,请确保标识符存在于模拟支出数组中。是的,你可以使用 Remix 抛出Response对象——我们将在下一节中探讨这一点。
一旦你测试了你的实现,为收入页面创建一个类似的经验。这将帮助你练习到目前为止所学的内容。
如果你还没有这样做,请将dashboard.income.tsx文件中的每个列表元素包裹在一个锚点元素中。然后,创建dashboard.income.$id.tsx文件并实现loader函数。如果你遇到困难,请回顾支出详情路由的实现并将其应用于dashboard.income.$id.tsx。你也可以参考来自第二章的故障排除过程,创建新的 Remix 应用。最后,你可以在本章节的最终解决方案中找到:github.com/PacktPublishing/Full-Stack-Web-Development-with-Remix/tree/main/04-routing-in-remix/bee-rich。
在本节中,你学习了如何处理动态路由段以及如何声明路由参数。你还简要了解了 Remix 的loader函数和useLoaderData钩子。在下一节中,我们将利用共享布局来改进我们应用程序的外观和感觉。
共享布局
嵌套路由使我们能够从嵌套路由层次结构中组合页面。在本节中,我们将利用父路由在嵌套子路由之间重用布局。我们还将学习如何在路由之间共享代码,而无需在 URL 中创建新段,使用无路径布局路由。
使用父布局路由
让我们改进 BeeRich 仪表板的外观和感觉。理想情况下,用户应该能够快速在收入和支出概览页面之间切换。是时候添加导航栏了。
我们已经为dashboard/expenses和dashboard/income页面使用了父布局路由。通过在父路由(dashboard.expenses.tsx和dashboard.income.tsx,分别)中渲染支出和收入的列表,我们在同一页面上嵌套了子路由的内容。
现在,我们将再次利用嵌套路由来为所有仪表板页面添加共享导航栏。按照以下步骤操作:
-
在
routes文件夹内创建一个dashboard.tsx文件。由于
dashboard.tsx路由模块与dashboard路径段匹配,它充当该段嵌套路由模块的父路由。 -
将以下代码添加到
dashboard.tsx中:import { Outlet } from '@remix-run/react';import { Container } from '~/components/containers';export default function Component() { return ( <> <header> <Container className="p-4 mb-10"> <nav> <ul className="w-full flex flex-row gap-5 font-bold text-lg lg:text-2xl"> <li> <a href="/">BeeRich</a> </li> <li className="ml-auto"> <a href="/404">Log out</a> </li> </ul> <ul className="mt-10 w-full flex flex-row gap-5"> <li className="ml-auto"> <a href="/dashboard/income">Income</a> </li> <li className="mr-auto"> <a href="/dashboard/expenses">Expenses</a> </li> </ul> </nav> </Container> </header> <main className="p-4 w-full flex justify-center items- center"> <Outlet /> </main> </> );}代码在 HTML 的
header元素内添加了一个导航栏,并在main元素内渲染 Remix 的Outlet组件。 -
运行应用(
npm run dev),然后在浏览器窗口中访问/dashboard/expenses以查看更改。如图 4**.3所示,通过添加父路由模块,我们将
dashboard的每个子路由都包裹在dashboard.tsx中渲染的标记内。布局路由提供了一种将所有子路由包裹在公共布局中的绝佳方式。

图 4.3 – 共享仪表板布局的支出页面截图
让我们也将导航栏添加到我们的登录、注册和主页(_index.tsx)页面。我们再次可以使用父布局路由来共享公共布局。然而,这将向路径添加一个新的路由段。结果证明,添加父路由并不是我们想要的。父路由会改变 URL,但主页应该位于/路径。同样,我们希望登录页面位于/login,注册页面位于/signup。相反,让我们看看如何使布局路由路径无路径。
使用无路径布局路由
幸运的是,Remix 提供了一种声明方式:使用$符号声明参数化路由模块,使用下划线前缀(_)声明无路径布局路由。无路径路由的行为就像父布局路由一样,但不会向 URL 路径添加段。
让我们添加一个无路径布局路由,以便在登录、注册和主页之间共享布局:
-
在
routes文件夹中创建一个新的_layout.tsx文件。下划线告诉 Remix 将路由模块视为无路径。路由模块的名称将不会添加到 URL 中。您已经从索引路由中了解到下划线前缀,它具有类似的作用。
注意,选定的名称
layout不是约定的一部分——您可以按自己的喜好命名路由模块。 -
接下来,将以下代码添加到
routes/_layout.tsx文件中:import { Outlet } from '@remix-run/react';export default function Component() { return ( <> <header className="mb-4 lg:mb-10"> <nav className="p-4"> <ul className="w-full flex flex-row gap-5 text-lg lg:text-2xl font-bold"> <li> <a href="/">Home</a> </li> <li className="ml-auto"> <a href="/login">Log in</a> </li> <li> <a href="/signup">Sign up</a> </li> </ul> </nav> </header> <main className="p-4 w-full flex justify-center items- center"> <Outlet /> </main> </> );}这段代码添加了一个导航栏并在一个样式化的
main元素中渲染Outlet。 -
将
_index.tsx、login.tsx和signup.tsx路由模块分别重命名为_layout._index.tsx、_layout.login.tsx和_layout.signup.tsx。 -
从
_index.tsxJSX 代码中删除mainHTML 标签,因为它现在已经被__layout.tsx模块渲染。这就是组合和嵌套路由的力量。我们可以减少代码重复并重用布局。 -
如往常一样,在继续之前确保测试您的更改。运行应用程序,并通过尝试新的导航栏来访问
/、/login和/signup页面。
在本节中,您学习了如何利用布局路由为嵌套子路由添加公共结构和样式。您还介绍了无路径布局路由,以便在不向 URL 路径添加路由段的情况下共享布局。在下一节中,我们将学习更多关于 Remix 中页面导航的内容。
处理 Remix 中的导航
到目前为止,我们已使用锚点标签在页面之间进行导航。您可能已经注意到,每次导航(使用锚点标签)都会触发整个页面的重新加载。这是浏览器在页面之间导航时的默认行为。然而,Remix 也提供了客户端导航的原语。
在本节中,我们将向您介绍 Remix 的链接组件和 Remix 的全局导航对象。我们将练习使用导航对象来指示页面加载,并了解 Remix 中关于服务器端重定向的更多信息。
使用 Remix 的链接组件进行导航
默认情况下,页面导航会触发对资源 Web 服务器的文档请求。Web 服务器将请求转发到 Remix(我们的 HTTP 请求处理器)。然后 Remix 在服务器上渲染一个新的文档以满足请求,并响应以渲染的 HTML 文档(或任何其他 HTTP 响应)。
当使用 Remix 的链接组件时,Remix 阻止浏览器默认行为进行全页请求。相反,Remix 使用 Web 的 Fetch API 执行 fetch 请求来获取请求 URL 所需的数据。我们避免全页刷新,并执行客户端导航。让我们用 Remix 的链接组件(Link和NavLink)替换 BeeRich 中的锚标签。你可以在/app/components/links.tsx中找到这两个链接组件的样式示例实现。有关这两个组件的更多信息,请访问 React Router 文档:reactrouter.com/en/6.15.0/components/nav-link:
-
首先,将
routes/_layout.tsx中的锚标签替换为。按照以下代码更新:import { Outlet } from '@remix-run/react';import { NavLink } from '~/components/links';export default function Component() { return ( <> <header className="mb-4 lg:mb-10"> <nav className="p-4"> <ul className="w-full flex flex-row gap-5 text-lg lg:text-2xl font-bold"> <li> <NavLink to="/">Home</NavLink> </li> <li className="ml-auto"> <NavLink to="/login">Log in</NavLink> </li> <li> <NavLink to="/signup">Sign up</NavLink> </li> </ul> </nav> </header> <main className="p-4 w-full flex justify-center items- center"> <Outlet /> </main> </> );}我们不是直接使用 Remix 的 NavLink 组件,而是使用一个样式包装组件。
-
接下来,运行应用程序(
npm run dev)并在浏览器窗口中打开它。导航到应用程序的主页。现在导航栏应该显示三个样式不错的链接。 -
在主页上,点击登录或注册链接。你应该看到浏览器窗口标签页中的 favicon 不再表示全页刷新。相反,Remix 现在使用 JavaScript 的 Fetch API 获取所需数据和资产,执行客户端导航。这些更改使得我们的 Remix 应用程序感觉更像是一个单页应用(SPA)。
-
现在,让我们重构
dashboard.tsx父路由的代码。从components文件夹中导入Container和NavLink组件,从 Remix 导入Link,并应用以下更改:import { Link components RemixLink to highlight that we are not using our own Link wrapper component. We also utilize another wrapper component – Container – to reuse some additional styling. -
接下来,重构
dashboard.expenses.tsx路由模块:import { Outlet } from '@remix-run/react';import { H1 } from '~/components/headings';ListLinkItem component from the components folder to add custom styling to the expenses list. Under the hood, the component renders Remix’s NavLink component. -
此外,将
dashboard.income.tsx中的锚标签替换为ListLinkItem组件。以dashboard.expenses.tsx文件为参考。 -
在本地运行应用程序,并在浏览器窗口中访问。测试支出和收入概览页面上的链接。注意 Remix 不再执行全页刷新来在页面之间导航。
选择 Remix 的链接组件可以避免页面刷新。在使用全页刷新时,我们必须在每次导航时重新获取所有数据。当使用 Remix 的链接组件时,Remix 只获取新添加的路由模块所需的数据。然而,我们也可以看到当使用锚标签时,Remix 仍然可以正常工作。Remix 尽可能使用 JavaScript 来增强体验,但在必要时可以回退到浏览器的默认行为。
有时候,当我们试图用 JavaScript 增强体验时,我们可能会无意中降低体验。默认情况下,浏览器在页面加载期间用加载指示器替换标签页的 favicon。如果没有完整的页面重新加载,我们就失去了页面转换发生的任何指示。这对于网络连接较慢的用户来说尤其令人沮丧。在下一节中,我们将利用 Remix 的全局转换对象重新添加页面加载指示器。
指示页面转换
您可能会问自己为什么我们需要页面加载指示器。在localhost上导航如此之快,以至于您几乎注意不到它们。然而,在慢速 3G连接上,它们可能会感觉非常漫长。让我们证明页面转换是必要的:
-
通过在项目的
root文件夹中执行npm run dev来运行 BeeRich。 -
通过访问
localhost:3000在浏览器中打开应用程序。 -
打开浏览器开发者工具,并导航到网络标签。
-
搜索节流功能,并从下拉菜单中选择慢速 3G。
-
还要检查禁用缓存复选框以模拟用户的首次访问。
所有主流浏览器都提供类似的设置。如果您找不到描述的节流和缓存功能,请在 Google 上搜索。
-
确保重新加载浏览器窗口以重置浏览器缓存。
-
最后,在您的应用的不同页面之间导航。

图 4.4 – Chrome DevTools 网络标签的截图
体验如何?使用慢速互联网连接显示了加载指示器的重要性。通过移除浏览器的默认行为(浏览器窗口标签上的加载旋转器),我们剥夺了用户清晰的加载指示。我们降低了用户体验。幸运的是,Remix 提供了一个全局导航对象,我们可以使用 JavaScript 重新添加加载指示器。
首先,让我们向费用和收入详情视图添加一个加载指示器:
-
打开
/routes/dashboard.expenses.tsx文件,并从 Remix 导入useNavigation钩子:import { Outlet, useNavigation } from '@remix-run/react'; -
在路由组件内部调用钩子以访问全局导航对象:
const navigation = navigation.state property can have one of the following three values:* `idle`* `loading`* `submitting` -
让我们亲自看看!打开浏览器窗口,导航到
费用页面。 -
在浏览器开发者工具中打开控制台标签。
-
点击费用列表中的不同费用项。
如果没有页面导航,
navigation.state设置为idle。在GET请求中,navigation.state设置为loading。在表单提交时,状态设置为submitting然后loading(因为每次提交也涉及页面导航)。使用 Remix,我们可以通过检查
navigation.state值轻松显示挂起的 UI。 -
让我们在
dashboard.expenses.tsx中添加一个简单的 CSS 动画。首先,导入clsx来管理 Tailwind CSS 类:import clsx from 'clsx'; -
然后,将现有的
Outlet组件包裹在一个样式化的部分中:loading state and conditionally render Tailwind’s built-in pulse animation. -
确保你的浏览器窗口仍然限制网络带宽。点击支出概览列表中的支出项,并注意当页面导航正在进行时,详情视图会闪烁。
-
将相同的动画添加到
dashboard.income.tsx以练习使用 Remix 的导航对象。
让我们在应用程序中添加一个全局进度条:
-
在你的编辑器中打开
root.tsx文件。 -
从
components文件夹导入PageTransitionProgressBar:import { PageTransitionProgressBar } from './components/progress'; -
然后,在根组件内部渲染
PageTransitionProgressBar组件。这确保了组件将在我们应用程序的所有路由和页面上渲染。我们将组件放置在Outlet组件上方:<body className="bg-background dark:bg-darkBackground text-lg text-text dark:text-darkText"> PageTransitionProgressBar component uses the navigation object and tracks a CSS animation across the idle, loading, and submitting states. -
在你的编辑器中打开
components/progress文件,并检查PageTransitionProgressBar组件的实现。
在本节中,你学习了如何利用导航对象和 useNavigation 钩子向用户指示加载状态。现在,我们将通过介绍服务器端重定向来结束本章。
从服务器重定向用户
有时,触发导航的最佳位置是在服务器上。在 Remix 中,loader 函数是检查请求的资源是否存在以及用户是否有权访问的好地方。在本节中,你将了解 Remix 中关于服务器端重定向的更多信息。
检查 dashboard.expenses.$id.tsx 路由文件:
export function loader({ params }: LoaderFunctionArgs) { const { id } = params;
const expense = data.find((expense) => expense.id === Number(id));
if (!expense) throw new Response('Not found', { status: 404 });
return json(expense);
}
在 loader 函数中,我们访问路由参数以在模拟数据中找到匹配的支出。如果我们找不到与 id 参数匹配的支出,我们抛出一个 Response。抛出响应是提前停止执行并返回错误响应的好方法。我们将在 第七章 中学习如何处理抛出的 Response 对象,Remix 中的错误处理。
现在,让我们专注于 Remix 的 request/response 流。了解 Remix 既是前端又是后端,遵循 Web 的客户端/服务器模型,这一点很重要。我们的前端应用程序在浏览器中运行并向 Web 服务器(后端)发出请求。后端应用程序处理传入的 HTTP 请求并以 HTTP 响应回答。
Remix 请求/响应模型的好处在于,它遵循 Web 的 Fetch API 标准,而不是自定义。在 loader 函数中,我们返回 Response 对象,遵循 Fetch API 的 Response 规范:developer.mozilla.org/en-US/docs/Web/API/Response。
此外,Remix 提供了创建 Response 对象的原语。我们不仅自己创建 Response 对象,还可以使用以下三个辅助函数:
-
defer -
json -
redirect
我们已经在 loader 函数中使用了 json 辅助函数。该辅助函数返回一个带有 Content-Type HTTP 头为 application/json 的 Response 对象。redirect 辅助函数创建带有 302 状态码的 Response。defer 是一个用于流式传输响应的高级辅助函数。我们将在 第十三章**,延迟 加载数据 *中练习使用 defer。
让我们使用 redirect 来修复我们应用程序中的一个错误。你注意到如果你直接导航到 localhost:300/dashboard 会发生什么吗?我们渲染了一个空白的仪表盘。
你能想到为什么会有这种行为吗?当前 /dashboard 路径段没有默认子路径(一个嵌套的索引路由模块)。最终,当访问 /dashboard 路径时,它与子路由中的任何一个都不匹配。当访问 localhost:300/dashboard 时,dashboard.tsx 中的 Outlet 返回 null,我们的仪表盘保持为空。
有时候,没有默认子路由并不是一个问题。在 /dashboard/expenses 和 /dashboard/income 概览页面上,当 URL 中没有添加标识符时,Outlet 也返回 null。在没有渲染详细视图的情况下显示概览列表似乎没问题。
对于 /dashboard,我们应该考虑比渲染一个空仪表盘更好的解决方案。例如,我们可以创建一个 dashboard._index.tsx 文件,并导出一个渲染简单欢迎信息的路由组件。或者我们可以将用户重定向到另一个页面!
在 routes 文件夹中创建一个 dashboard._index.tsx 文件,并添加以下代码:
import { redirect } from '@remix-run/node';export function loader() {
return redirect('/dashboard/expenses');
}
通过这简单的四行代码,我们创建了我们的第一个资源路由。资源路由不导出一个路由组件(否则它将是一个 UI 路由)。相反,资源路由导出一个 loader 函数和/或 action 函数。
在这里,我们使用 loader 函数立即将用户重定向到支出概览页面。如果用户在地址栏中输入 localhost:3000/dashboard,那么 loader 函数将返回一个带有 30x 状态码的 Response - 一个重定向。
通过导航到 localhost:3000/dashboard 来尝试一下。在你的终端中,服务器日志应该如下所示:
GET /dashboard 302 - - 2.918 msGET /dashboard/expenses 200 - - 8.031 ms
在导航到 /dashboard 时,我们重定向(302 状态码)到 /dashboard/expenses 路径,然后它被服务(200 状态码)。
注意,redirect 只是一个用于创建重定向 Response 对象的辅助函数。它不过是一个简单的包装,方便使用:
return new Response(null, { status: 302,
headers: {
Location: '/dashboard/expenses',
},
});
Remix 提供了对整个 Web 平台的完全访问权限。你可以创建并返回具有不同状态码、实例属性、缓存头等 Response 对象。你所需要做的就是遵循 MDN Web 文档(developer.mozilla.org/en-US/docs/Web/API/Response)。在本书的后续部分,我们将实现实时功能,添加缓存头,并优雅地处理认证错误,所有这些都将通过使用 Web 平台的 Response API 完成。
在本节中,你学习了服务器端重定向。你现在理解了 Remix 在服务器上执行 loader 函数,并且 loader 函数必须返回一个 Response 对象(或 Remix 可以解析为 Response 对象的东西)。你还练习了将用户从资源路由重定向到 UI 路由。现在,让我们回顾一下本章所学的内容。
摘要
Remix 提供了一个基于约定的文件路由器。可以说,Remix 路由器最强大的功能之一是嵌套路由。在 Remix 中,你创建路由(路由模块)作为层次结构的一部分。Remix 的路由器将 URL 的路径名映射到一组匹配的路由模块。路由模块构成了你的 Remix 应用程序的页面。
在本章中,你在 Remix 中创建了你的第一个路由。我们首先创建了两个独立的页面。你学习了索引路由作为其父路由默认子路由的特殊作用。你还了解了 Remix 路由模块中可用的导出。
接下来,我们为我们的仪表板创建了一个嵌套路由层次结构。我们使用了父布局路由和 Outlet 组件在不同的子路由之间重用样式和内容。
我们还使用 loader 函数和路由参数来创建我们的收入和支出详情视图的路由。你学习了如何使用 $ 语法声明参数化路由模块。你还使用了 Remix 的 userLoaderData 钩子来访问 loader 函数返回的 JSON 数据。
此后,我们使用了无路径布局路由,在不同子路由之间共享布局,而不需要在 URL 中添加路径段。你创建了一个无路径路由,用于包裹 BeeRich 的登录、注册和主页。
你还学习了 Remix 中的导航和过渡。我们用 Remix 的 Link 和 NavLink 组件替换了 HTML 锚标签。现在,你理解了避免完整页面重新加载的好处,并知道 Remix 在每个页面请求时都会获取所需的脚本和数据。
你还进一步学习了 useNavigation 钩子和 Remix 的导航对象。你通过使用导航对象的 state 属性来练习向页面添加加载指示器。最后,你学习了更多关于 Remix 中的 Request/Response 模型以及如何从服务器重定向用户。
在下一章中,你将了解在 Remix 中的数据加载和变更。我们将使用 Remix 的 action 和 loader 函数来实现管理 BeeRich 中我们的支出和收入数据的 UI。
进一步阅读
在本章中,你学习了 Remix 路由解决方案的关键概念。你可以在 Remix 的路由文档中找到更多概念,例如可选的路由模块:remix.run/docs/en/2/file-conventions/routes。
在 Remix 文档中了解 Link 和 NavLink 组件:
你可以在这里了解更多关于 Fetch API 的 Request/Response 模型:developer.mozilla.org/en-US/docs/Web/API/Fetch_API。
第二部分 – 使用 Remix 和 Web 平台
在这部分,你将获得使用 Remix 的原语、约定和杠杆的实际经验,并从头到尾构建一个全栈 Web 应用程序。你将深入理解 Remix 的请求-响应流程、渐进增强,以及如何考虑错误和会话管理来构建健壮的用户体验。最后,你将练习在 Remix 中处理 Web 资产和文件上传。
本部分包含以下章节:
-
第五章, 获取和变更数据
-
第六章, 增强用户体验
-
第七章, Remix 中的错误处理
-
第八章, 会话管理
-
第九章, 处理资产和元数据
-
第十章, 处理文件上传
第五章:获取和修改数据
在当今的 Web 开发领域中,处理动态数据至关重要。大多数现代应用程序都与来自各种来源的数据进行交互。应用程序管理加载状态、错误和数据更新的方式在用户体验中起着重要作用。幸运的是,Remix 为获取和更新数据提供了全面的解决方案。
本章涵盖了以下主题:
-
获取数据
-
修改数据
在本章中,我们将实现 BeeRich 中的数据读取和写入。首先,我们将练习数据加载。然后,我们将学习 Remix 中的数据修改,并实现支出创建表单。
到本章结束时,你将了解如何在 Remix 中获取和修改数据。你还将理解 Remix 如何执行loader和action函数,以及如何在修改后重新验证 loader 数据。最后,你将练习以渐进增强为出发点构建应用程序,我们将在第六章,渐进增强用户体验中继续构建。
技术要求
你可以在此处找到本章的设置说明:github.com/PacktPublishing/Full-Stack-Web-Development-with-Remix/blob/main/05-fetching-and-mutating-data/bee-rich/README.md。
注意,本章start文件夹中的代码与我们在第四章,Remix 中的路由中的最终解决方案不同。在继续之前,请先阅读 GitHub 上本章文件夹的README.md文件中的说明。
获取数据
在深入本章之前,请确保你已经遵循了技术要求部分的步骤。一旦你完成了设置指南,让我们简要回顾一下关键步骤,以防止出现任何问题:
-
运行
npm i以安装所有依赖项。 -
如果你项目根目录中缺少
.env文件,请创建一个新的.env文件,并将其以下行添加到其中:DATABASE_URL="file:./dev.db"Prisma –我们选择的数据工具包 – 使用
DATABASE_URL环境变量连接到我们的数据库。 -
接下来,运行
npm run build以生成我们数据模式的数据客户端。Prisma 从prisma/schema.prisma文件读取我们的 Prisma 模式,并为我们生成类型和函数。 -
运行
npm run update:db以创建或更新 SQLite 数据库。我们使用 SQLite 和 Prisma 来持久化我们的开发数据。 -
最后,运行
npm run seed以使用模拟数据填充我们的本地数据库。你可以在prisma/seed.tsx中找到模拟脚本。
我们已经设置了数据库,现在我们可以使用 Prisma 来查询数据库。接下来,让我们添加从数据库获取数据的代码到 BeeRich 中。
在路由级别获取数据
让我们使用 Remix 的loader函数和路由级别数据获取来查询数据库以获取支出数据:
-
在你的编辑器中打开
app/routes/dashboard.expenses.tsx文件。 -
向路由模块添加
loader函数:export async function loader() { return {};}loader函数是 Remix 的 HTTPRequest–Response接口。Remix 的loader函数仅在服务器上执行,并且必须返回一个Response对象 (developer.mozilla.org/en-US/docs/Web/API/Response).目前,我们返回一个空的 JavaScript 对象。Remix 会为我们序列化对象(
JSON.stringify)并创建一个带有Content-Typeapplication/json的Response对象。 -
接下来,导入我们新的数据库客户端:
import { db } from '~/modules/db.server';
重要提示
你可以在本地的 README.md 文件中找到更多关于我们 Prisma 客户端设置的信息:github.com/PacktPublishing/Full-Stack-Web-Development-with-Remix/blob/main/05-fetching-and-mutating-data/bee-rich/README.md.
-
使用数据库客户端查询所有费用,并从
loader函数返回结果数组:import { db } from '~/modules/db.server';export async function loader() {json helper function from Remix and pass the expenses array to it:json 辅助函数返回一个带有 Content-Type application/json 的 Response 对象。使用辅助函数允许我们将 init 对象作为第二个参数提供,以便向响应添加 HTTP 头部、状态文本和状态码。这一步是可选的,因为我们还没有在
Response对象中返回任何 HTTP 头部或 cookies。我们将在本书的后面更详细地介绍这一点。 -
更新数据库查询以按日期排序数据:
const expenses = await db.expense.findMany({loader function. Performing as much logic as we can on the server minimizes the need for client-side state management. This is a best practice we should keep in mind.
尽可能地将逻辑移动到服务器
我们应该尝试将尽可能多的代码移动到服务器端的 action 和 loader 函数。
将代码移动到服务器确保发送到客户端的代码更少。它还增加了在客户端 JavaScript 完全加载之前可以工作的逻辑量。最后,我们通过将代码移动到快速且强大的服务器和数据库运行时,确保我们降低客户端应用程序的复杂性。
-
此后,在
loader函数中添加console.log以便我们可以在终端中跟踪其执行。 -
运行应用程序(
npm run dev)并在浏览器窗口中打开 BeeRich。 -
最后,在不同页面之间导航并检查服务器终端中的
console.log消息。注意当loader函数执行时。由于loader函数仅在服务器上运行,你将在终端而不是浏览器控制台中找到日志语句。
太棒了!每当将 dashboard/expenses 路径添加到 URL 中时,Remix 会调用 dashboard.expenses.tsx 路由模块的 loader 函数。
接下来,我们需要找出如何在 React 中访问数据。幸运的是,在 Remix 中这非常简单。
-
再次,在你的编辑器中打开
dashboard.expenses.tsx路由模块。 -
从
@remix-run/react中导入useLoaderData钩子。 -
接下来,在路由模块组件中调用钩子:
const expenses = useLoaderData();我们使用
useLoaderData钩子首先访问 loader 数据loader函数,然后在服务器上渲染 React 应用程序。在我们的 React 组件中,我们访问获取到的数据,而无需管理加载状态。这使我们能够消除许多 React 应用程序都遭受的大量样板代码。 -
到目前为止,我们路由组件中的
expenses变量被类型化为any。这并不理想。幸运的是,我们正在使用 TypeScript 来编写前端和后端代码。我们还在同一个app文件夹中集中存放客户端和服务器代码。这使我们能够做一些强大的事情,例如,在调用useLoaderData时推断 loader 数据的类型。将
loader函数的类型传递给useLoaderData的泛型槽位:const expenses = useLoaderData<expenses (that is, by hovering over the variable name in your editor). The variable is now typed as an expense object array that is wrapped by JsonifyObject<{…}>.Remix co-locates your client and server code in the same file. This allows us to infer the types of fetched data across the network (from server to client). However, since we work with `Response` objects in `loader` functions, the returned data is serialized as JSON. This changes the shape of the data. For instance, JSON cannot manage `Date` objects but serializes them to `string`.`JsonifyObject` is a helper type from Remix that ensures that the expense object is correctly typed after being serialized to JSON. We will return to that later; for now, we are happy that we can easily access the `loader` data and have it fully typed. -
让我们使用支出数组来替换硬编码的支出列表。遍历支出数据以渲染支出列表:
<ul className="flex flex-col"> {createdAt property is of the string type. We turn it into a Date object by calling new Date(expense.createdAt). In the loader function, the expenses array carries createdAt properties of the Date type. On the client, we need to deserialize the data as it was parsed to JSON. -
最后,运行应用程序并访问支出概览页面。你应该能够从种子数据中查看支出列表。
太棒了!我们利用服务器端的
loader函数来获取数据,然后在 React 中访问这些数据以渲染我们的页面。Remix 的loader函数允许我们将服务器端数据加载和数据渲染放在同一个文件中。
注意,在 Remix 中,数据获取发生在路由级别的loader函数中。通过放弃从组件中获取数据的部分灵活性,路由级别的数据获取提供了许多优势。
基于组件的数据加载容易受到阻塞请求的影响,这可能导致获取瀑布。一个经常获取数据的组件会推迟渲染其子组件,直到数据加载完成。这阻止了子组件启动它们自己的获取请求,实际上创建了一个获取请求的瀑布。
想象一个渲染页面布局的组件。首先,它获取用户对象,并在用户数据被获取之前显示一个大的加载指示器。在用户数据被获取后,页面被渲染。嵌套组件现在会获取它们自己的数据。用户获取请求阻塞了这些请求。这种行为可能在应用程序嵌套子树中重复多次。
Remix 促进路由级别的数据获取
在 Remix 中,我们的目标是路由模块中获取数据,而不是组件。通过避免在组件级别进行细粒度数据获取,我们旨在优化数据加载并防止获取瀑布。我们应该将此作为最佳实践记住。
注意,路由级别的数据获取并不意味着你只能在该路由级别访问数据。你可以在应用程序的任何自定义钩子或组件中使用useLoaderData、useRouteLoaderData和useMatches钩子来访问 loader 数据。
useRouteLoaderData和useMatches钩子用于访问任何当前活动路由中的数据——与返回钩子所在的路由模块数据的useLoaderData相比。有关更多信息,请参阅 Remix 文档:remix.run/docs/en/2/hooks/use-route-loader-data。
如往常一样,确保为收入路由实现相同的功能。这确保你在继续之前重新审视本节中介绍的概念。
接下来,让我们看看如何根据动态路由参数获取数据。
在参数化路由中获取动态数据
现在我们已经更新了支出概览页面,你可能已经注意到这破坏了我们硬编码的支出详情路由。让我们修复它。
你可能还记得从第四章,Remix 中的路由,我们设计了支出详情页面作为一个嵌套路由,它在支出概览页面中渲染。它也是一个使用动态路由参数的参数化路由。
让我们更新代码,以便根据路由参数从数据库查询请求的支出:
-
在你的编辑器中打开
/app/routes/dashboard.expenses.$id.tsx文件。我们已经在其中使用loader函数来渲染模拟数据。 -
从文件中删除模拟数据数组。
-
接下来,更新
loader函数以查询数据库并找到我们从 URL 访问的具有id参数的唯一支出对象:404 Response if we cannot find an expense that matches the id parameter. This is a great way to stop further executions and show the user that something went wrong. -
通过在终端中执行
npm run dev来运行 BeeRich。 -
打开一个浏览器窗口并导航到
localhost:3000/dashboard/expenses/。 -
点击列表中的任一支出。
注意 URL 会改变以包含支出的
id参数。Remix 执行客户端导航(当 JS 可用时)以更新 URL。
Remix 在我们导航到相关路由段时执行loader函数。在每次页面导航时,Remix 都会获取与请求的新页面匹配的每个新路由模块的loader数据。如果我们导航到/login,那么所有新匹配的路由的loader函数都会执行。这可能包括以下路由模块(从根到叶):
-
root.tsx -
routes/_layout.tsx -
routes/_layout.login.tsx
如果我们进一步从/login导航到/signup,那么只有_layout.signup.tsx路由模块的loader函数会执行,因为它是我们之前尚未激活的唯一路由段。
让我们通过回顾浏览器窗口开发者工具中的网络选项卡来可视化正在发生的事情:
-
在运行你的应用的浏览器窗口中打开开发者工具。
-
打开开发者工具的网络选项卡。
-
通过
Fetch/XHR网络请求进行筛选。这是可选的,但有助于你找到所有发送到loader函数的 fetch 请求。 -
现在,将 URL 栏中的 URL 更改为
localhost:3000/dashboard/expenses/并重新加载浏览器窗口。 -
你应该看不到在
dashboard.expenses.tsx路由模块的loader函数执行时出现的 fetch 请求,数据被用于在服务器上渲染 HTML。 -
接下来,点击费用列表中的任何一项费用。
Remix 现在在客户端运行。在 hydration 之后,客户端 Remix 应用接管了我们应用程序的路由。这允许我们避免全页请求,这需要更多的网络带宽并增加响应时间。
由于我们正在使用 Remix 的
Link组件,Remix 可以拦截页面转换。如果加载了 JavaScript,Remix 会阻止浏览器默认行为(全页刷新),而是通过向我们的loader函数发送 fetch 请求来模拟这种行为以获取所需数据。 -
在
/dashboard/expenses/$id路由中检查请求会调用dashboard.expenses.$id.tsx路由模块的loader函数,如 图 5.1 所示:

图 5.1 – 路由转换后获取的 loader 数据截图
如 图 5.1 所示,Remix 执行对 loader 函数的 fetch 请求,然后返回 JSON 格式的费用对象。
-
点击另一项费用。注意,Remix 对每个导航到费用详情路由的行为进行重复。每次我们点击新的费用时,URL 都会改变,并且详情页的
loader函数会再次调用下一个$id路由参数。注意,Remix 从不重新获取概述页的费用数组。这是因为我们仍然位于
dashboard.expenses.tsx路由段。Remix 只为新的匹配路由段加载数据。
太好了!我们现在能够使用参数化路由获取动态数据。请注意,Remix 只为新匹配的路由段获取数据。这避免了不必要的请求。我们还了解到,Remix 在服务器端渲染和客户端导航期间从 loader 函数获取数据。Remix 在网络的两侧按路由级别处理数据加载。
Remix 是一个前端和后端框架
Remix 在服务器端渲染 React 之前,在服务器上对初始请求调用 loader 函数。在客户端,Remix 使用 AJAX 请求(fetch 请求)在客户端导航时获取 loader 数据。
通过更新 dashboard.income.$id.tsx 路由模块以匹配 dashboard.expenses.$id.tsx,回顾本节中介绍的概念。在继续之前,确保测试你的实现。通过使用 网络 选项卡检查在 /income 和 /expenses 路由之间切换时执行的 loader 函数。
接下来,让我们看看 loader 函数是如何并行调用的。为此,我们需要对我们的应用程序逻辑进行一些调整。
并行加载数据
如 图 5**.2 所示,费用概览页面有两个部分 – 所有费用的列表 (dashboard.expenses.tsx) 和当前选中费用的详细视图 (dashboard.expenses.$id.tsx)。当导航到 /dashboard/expenses 时,详细视图是空的,因为嵌套的 $id 路由模块不会出现在屏幕上:

图 5.2 – 费用路由的截图,包含嵌套的 $id 路由模块
让我们更新 /dashboard/expenses。我们将更新此链接,使其指向最近创建的费用:
-
在您的编辑器中打开
dashboard.tsx路由模块。 -
将以下
loader函数添加到路由模块中:import { json } from '@remix-run/node';import { db } from '~/modules/db.server';export async function loader() { const firstExpense = await db.expense.findFirst({ orderBy: { createdAt: 'desc' }, }); return json({ firstExpense });}loader函数查询数据库以获取最近创建的费用。然后它返回查询到的firstExpense。注意,如果数据库中没有费用条目,
firstExpense也可以是null。 -
接下来,使用
useLoaderData钩子访问firstExpense对象:const { firstExpense } = useLoaderData<typeof loader>();我们使用
loader函数的类型来键入useLoaderData钩子。firstExpense现在正确地键入为费用对象的序列化版本或null。 -
使用
firstExpense更新NavLink的to属性。由于firstExpense可能是null,我们必须确保我们条件性地进行此更改:<li className="mr-auto"> <NavLink to=NavLink component now navigates the user to the most recently created expense. -
通过执行
npm run dev来运行 BeeRich,并在浏览器窗口中打开费用概览页面:localhost:3000/dashboard/expenses。注意,
NavLink组件丢失了其活动样式。这是因为链接现在指向一个嵌套路由,它不会在 Remix 的NavLink组件上触发isActive条件。幸运的是,我们的自定义NavLink组件提供了一个styleAsActive属性,我们可以在需要时使用它来应用活动样式。 -
从
@remix-run/react中导入useLocation钩子。 -
接下来,在路由模块组件中调用钩子:
const location = useLocation();Remix 的
useLocation钩子让我们能够访问一个全局位置对象,其中包含有关当前 URL 的信息。 -
将
styleAsActive属性添加到NavLink中。当用户位于/``expenses路由时,将属性设置为true:<NavLink to={firstExpense ? `/dashboard/expenses/${firstExpense.id}` : '/dashboard/expenses'} /dashboard/expenses route.With these changes in place, let’s learn about parallel data fetching. -
在浏览器窗口中访问
/dashboard/income页面并清除 网络 选项卡。 -
现在,通过点击 费用 导航菜单链接,导航到最近创建的费用详情页面。
-
如 图 5**.3 所示,你现在应该在
loader函数中看到两个 fetch 请求:-
dashboard.expenses.tsx -
dashboard.expenses.$id.tsx
dashboard.tsx中的loader函数在没有导航之前页面上的路由模块已经激活的情况下不会执行: -

图 5.3 – 检查 fetch 请求瀑布图
如 *图 5**.3 所示,我们可以在 网络 选项卡中检查获取请求的水瀑布。绿色条表示请求的服务器执行时间。请注意,两个获取请求都是并行执行的。这平缓了请求水瀑布并提高了响应时间。
你可能已经注意到我们在 dashboard.expenses.$id.tsx 的 loader 函数中再次查询数据库。你可能已经问过自己为什么我们不重用来自 dashboard.expenses.tsx 的 loader 数据,因为我们已经从数据库中获取了所有费用。这是 loader 函数并行执行的一个权衡。由于所有 loader 都是在并行执行,因此几个 loader 不能相互依赖。
让我们总结一下我们的观察结果:
-
在初始请求时,Remix 在服务器上渲染我们的应用程序。
-
所有活跃的
loader函数都并行执行,并在服务器端渲染期间传递给 React 应用程序。 -
使用加载器,我们在每个路由级别上获取应用程序数据。
-
Remix 使用 JavaScript 模拟浏览器的默认行为。所有后续的页面导航如果已加载 JavaScript,则执行客户端操作。
-
Remix 使用获取请求在导航时加载所有必需的
loader数据。 -
所有新匹配的路由段
loader函数都并行执行。 -
已经活跃的
loader函数不再执行。
通过在 /income 路由上实现相同的功能来练习本节中学到的内容。在 dashboard.tsx 路由模块的 loader 函数中查询最近创建的发票。然后,更新 NavLink 组件。最后,使用 useLocation 钩子添加 styleAsActive 属性。
接下来,让我们看看我们是否可以稍微优化一下代码:
-
在你的编辑器中打开
app/routes/dashboard.tsx文件。 -
在将发票查询添加到
loader函数后,该函数可能看起来像这样:export async function loader() { const firstExpense = await db.expense.findFirst({ orderBy: { createdAt: 'desc' }, });loader function, we have the opportunity to execute the queries in parallel to reduce the response time. -
重构代码,使其并行执行两个查询:
export async function loader() { const Promise.all to execute them in parallel.
在本节中,我们介绍了 Remix 中的数据获取。你学习了如何使用和类型化 useLoaderData 钩子以及如何根据动态路由参数查询数据。你现在知道 Remix 在路由模块级别上促进数据获取。这允许我们并行执行 loader 函数并避免请求瀑布。你进一步理解了尽可能将逻辑移动到服务器以减少客户端包的大小和复杂性的重要性。最后,你练习了优化 loader 函数以并行执行独立请求。接下来,我们将学习关于数据变更的内容。
数据变更
创建和更新数据与获取数据一样重要。在本节中,我们将添加一个费用创建表单并学习如何在 Remix 中变更数据。
无 JavaScript 的数据变更
记得我们之前讨论过的瑞安·弗洛伦斯(Ryan Florence)构建 Web UI 的三个步骤吗?第一章?第一步是让用户体验在没有 JavaScript 的情况下也能工作。之后,我们添加 JavaScript 来增强用户体验,但确保基本实现仍然有效。这个过程被称为渐进增强。
在本节中,我们使用 Remix 的action函数来处理服务器上的传入表单提交。在action函数中,我们将验证用户数据并将新的支出对象写入数据库。让我们看看我们如何使用原生的表单元素提交用户数据,而无需客户端 JavaScript:
-
首先,为支出创建表单创建一个新的路由模块:
app/routes/dashboard.expenses._index.tsx。我们将支出创建表单添加到
/dashboard/expenses/路径的索引路由中。这导致以下路由层次结构:-
/dashboard/expenses/:显示支出列表和支出创建表单 -
/dashboard/expenses/$id:显示支出列表和具有id标识符的支出详情
-
-
向
dashboard.expenses._index.tsx添加一个包含 HTML 表单元素的路由组件:export default function Component() { return ( <form method and action attributes. method defines the submission method (POST or GET), and action sets the path name for the submission.We set the method to `POST` as we are mutating data.
使用 POST 进行修改,使用 GET 进行加载
HTML 表单使用 HTTP 动词POST和GET进行提交。最好使用POST请求进行修改,使用GET请求从服务器读取数据。
我们希望将数据提交到dashboard.expenses._index.tsx路由模块(表单元素的路由模块)。请注意,这是默认行为,因此我们也可以省略属性声明。然而,出于教育目的,我们将action设置为/dashboard/expenses?index作为动作的路径名。
注意添加到动作路径名的?index搜索参数。索引路由模块及其父路由模块都匹配相同的 URL。在定义action函数时,该动作可以存在于dashboard.expenses.tsx或dashboard.expenses._index.tsx中。?index搜索参数告诉 Remix 提交到索引路由模块,而不是父模块。
-
接下来,让我们添加支出数据的输入字段:
export default function Component() { return ( <form method="post" action="/dashboard/expenses/?index"> <label className="w-full lg:max-w-md"> Title: <input type="text" route module component now renders a simple HTML form with fields for the expense data. It doesn’t look pretty without CSS. We will solve this in the next step. For now, let’s focus on the content of the form.We add `name` attributes to the input and textarea elements. On form submission, the form data includes key-value pairs for every named input field. The `name` attributes are used as the keys of the form data. Finally, a button of the `submit` type is used to submit the form data on click. -
接下来,向路由模块添加一个
action函数:import type { loader functions to handle HTTP GET requests. Remix’s action function is called for all other HTTP requests to the route, including POST requests.Both functions receive a couple of parameters. You already know about the `params` parameter, which lets us access dynamic route segments. Now, we’re using the `request` parameter, which gives us access to the `Request` object ([`developer.mozilla.org/en-US/docs/Web/API/Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request)).We type `loader` function parameters with the `LoaderFunctionArgs` type and `action` function parameters with the `ActionFunctionArgs` type. -
使用
request参数将请求体解析为表单数据:export async function action({ request }: ActionFunctionArgs) { Request object.By calling the `formData` function, we parse the request body into a `FormData` object that provides us with access to the key-value pairs of the form input data. You can find more information about form data in the MDN Web Docs: [`developer.mozilla.org/en-US/docs/Web/API/FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData). -
从 Remix 导入我们的数据库客户端和
redirect函数:import { redirect } from '@remix-run/node';import { db } from '~/modules/db.server'; -
在使用之前验证表单数据以创建新的支出:
export async function action({ request }: ActionFunctionArgs) { const formData = await request.formData(); const title = formData.get('title'); const description = formData.get('description'); const amount = formData.get('amount');zod library to help validate user input. For now, we keep it simple and manually validate the data using if stamtents.Note that, once again, we co-locate the server-side HTTP handler and the associated client-side UI together in one file. -
检查
action函数的返回语句。我们将重定向到创建的支出的详情页面以传达成功:redirect(`/dashboard/expenses/${expense.id}`);与
loader函数一样,action函数必须返回一个Response对象或纯 JSON(Remix 为我们将其包装在Response对象中)。我们使用 Remix 的
redirect辅助函数来创建重定向响应对象。 -
在您的编辑器中打开
/dashboard/expenses.$id.tsx路由模块,并添加一个快速链接到支出创建页面:FloatingActionLink component wraps Remix’s Link component. The quick link from the expense details page to the creation form allows for a convenient workflow:* Expense creation redirects to the created expense.* The expense details page offers a quick link back to expense creation. -
执行
npm run dev以启动开发服务器。 -
导航到费用概览页面(
localhost:3000/dashboard/expenses/)并测试实现。创建你的第一个费用!
你注意到了什么?当提交原生的 HTML 表单元素时,浏览器执行了整个页面的重新加载。这是浏览器在表单提交时的默认行为。此外,你可能还会注意到,创建的费用在提交完成后出现在费用列表中。
整个页面的重新加载会触发整个页面的刷新。Remix 在服务器上渲染 HTML 并触发所有活动的加载器函数。费用概览在提交时重新加载。
表单元素提供了一个FormData API,并使用命名输入字段来声明应该发送到服务器的字段。表单提交会触发对action函数的POST请求。我们使用Request对象来解析提交的表单数据。这发生在服务器上。到目前为止,我们的实现没有使用任何客户端 JavaScript。
在 React 单页应用(SPA)中,我们通常在onSubmit处理程序中调用event.preventDefault来防止浏览器的默认行为。这里,我们发起一个客户端的 fetch 请求。这可能看起来像这样:
function CreateExpenseForm() { const [title, setTitle] = React.useState('');
const [description, setDescription] = React.useState('');
const [amount, setAmount] = React.useState(0);
const [isSubmitting, setIsSubmitting] = React.useState(false);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSubmitting(true);
await fetch('/dashboard/expenses/?index', {
method: 'POST',
body: JSON.stringify({ title, description, amount }),
});
setIsSubmitting(false);
};
return (
<form onSubmit={handleSubmit}>
<Input
label="Title:"
placeholder="Dinner for Two"
required
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
<Textarea label="Description:" value={description} onChange={(event) => setDescription(event.target.value)} />
<Input
label="Amount (in USD):"
type="number"
required
value={amount}
onChange={(e) => setAmount(e.target.valueAsNumber)}
/>
<Button type="submit" isPrimary disabled={isSubmitting}>
Create
</Button>
</form>
);
}
通过这样做,我们避免了整个页面的重新加载,并能够控制哪些 React 状态应该通过修改来更新。然而,由于不支持原生的表单提交,我们失去了回退到浏览器默认行为的能力。更糟糕的是,自行实现这一功能迫使我们开发针对挂起状态、错误处理和修改后的状态重新验证的自定义解决方案。
在不使用 JavaScript 的情况下使其工作使我们能够支持在 JavaScript 加载之前或加载失败时的用户交互。这是使用 Remix 原语和约定时所获得的一个强大功能。然而,Remix 也具有向上扩展的能力。让我们添加 JavaScript 来增强体验。
使用 JavaScript 修改数据
Remix 提供了一个Form组件,它渐进地增强了体验。我们只需要通过 Remix 的Form组件替换原生的 HTML 表单元素:
-
从 Remix 导入
Form组件:import { Form } from '@remix-run/react'; -
使用它来替换原生的 HTML 表单元素:
export default function Component() { return (Form component, Remix prevents the browser’s default behavior and executes a client-side fetch request to submit the form, avoiding the full-page reload. This works out of the box without us adding an onSubmit handler. -
我们的自定义页面加载指示器被触发,表明表单提交也会影响 Remix 的全局导航对象。
-
提交后,应用仍然执行重定向并将用户过渡到费用详情页面。
-
提交后,新的费用作为
/dashboard/expenses/路由的加载器数据的一部分出现在费用概览列表中。Remix 模拟了浏览器在页面上刷新所有内容的默认行为。
记得 Remix 的NavLink和Link组件如何增强锚标签导航吗?通过用 Remix 的Form组件替换原生的表单元素,我们能够获得客户端数据获取、渐进增强和客户端状态重新验证的功能。
我们现在利用客户端的 JavaScript,但这个 JavaScript 是通过 Remix 的Form组件提供的,不需要任何自定义样板代码。Remix 的Form组件将全局导航对象的州设置为submitting和loading,以管理挂起的 UI。使用 Remix 的Form组件进一步确保在突变后重新获取所有活动的loader函数。
Remix 在每次操作后都会重新验证 loader 数据
Remix 在执行action函数后,通过从所有活动的loader函数重新获取来刷新所有 loader 数据,就像在 HTML 表单提交时进行完整页面重新加载一样。
数据重新验证是一个强大的功能,它让我们能够避免客户端上的过时数据,或者不必开发自定义逻辑来同步客户端和服务器状态。
太棒了!在本节中,我们实现了一个费用创建表单及其相关的action函数。你学习了 Remix 如何通过提供Form组件来移除样板代码,该组件在幕后完成繁重的工作。你练习了使用命名输入字段和 Remix 的Form组件声明获取请求。
在继续之前,让我们通过引入一些准备好的 UI 组件来美化我们的表单:
-
从
components文件夹中导入Textarea、Input和Form组件:import { Form, Input, Textarea } from '~/components/forms'; -
移除 Remix 的
Form组件的导入,因为我们将使用我们自己的自定义包装组件。 -
用我们的样式化替代品替换原生的标签、输入和文本区域元素:
export default function CreateExpensePage() { return ( <Form method="post" action="/dashboard/expenses/?index">Button component from the components folder:import { Button } from '~/components/buttons'; -
用我们的包装组件替换原生的按钮元素,并添加
isPrimary属性:<Button type="submit" isPrimary> Create</Button> -
在本地运行 BeeRich。现在表单看起来应该好多了。
确保你实现了发票创建表单,并复制我们在费用路由中做的事情。首先,在不使用 JavaScript 的情况下实现功能。然后,使用 Remix 的Form组件增强体验。我鼓励你保留Fetch/XHR网络请求,并检查 Remix 在每次表单提交后如何重新验证所有 loader 数据。如果你在处理收入路由时遇到困难,你可以在 GitHub 上找到本章的最终解决方案:github.com/PacktPublishing/Full-Stack-Web-Development-with-Remix/tree/main/05-fetching-and-mutating-data/bee-rich/solution。
摘要
读取和写入数据是现代网络应用开发的重要方面。Remix 提供了原语、约定和杠杆,以支持这两者。
本章介绍了 Remix 的服务器端loader和action函数。你了解到loader和action函数是路由级别的 HTTP 请求处理器,用于获取和修改数据。Loaders 处理 HTTP GET请求,而action函数接收所有其他传入的 HTTP 请求。
最初,Remix 在服务器上渲染我们的应用程序。所有后续的页面转换都在客户端发生。在初始请求中,加载器数据在服务器端渲染期间使用。在所有后续导航中,Remix 通过 fetch 请求获取加载器数据,并且仅在客户端重新渲染路由层次结构中变化的部分。
接下来,您了解到路由级别的数据获取使我们能够——在其他方面——简化可能发生在组件级别获取中的请求瀑布。Remix 还并行执行 loader 函数以减少响应时间。
通过完成本章,您现在应该了解 Remix 如何使用 HTML 表单元素声明式地处理突变。Remix 提供了开箱即用的渐进式增强。默认情况下,Remix 执行客户端 fetch 请求以执行数据突变。然而,如果 JavaScript 不可用(尚未加载、加载失败或被禁用),那么 Remix 可以回退到浏览器的默认行为。
在本章中,我们添加了一个 action 函数来验证请求数据并创建一个新的支出对象。Remix 在每次突变后自动重新获取所有加载器数据。使用 Remix,我们能够开箱即用地获得数据重新验证。
Remix 的数据加载和突变可以在有和无 JavaScript 的情况下工作。这使我们能够逐步增强用户体验,并使我们的应用程序对更多用户可访问。在下一章中,我们将学习更多关于逐步增强用户体验的内容。我们将正式化本章所学内容,并学习更多用于增强 Remix 应用程序体验的工具。
进一步阅读
Remix 文档在这里概述了 Remix 中的全栈数据流:remix.run/docs/en/2/discussion/data-flow.
您可以在以下位置找到有关 Remix 数据加载的文档:remix.run/docs/en/2/guides/data-loading.
您可以在以下位置找到有关 Remix 突变的文档:remix.run/docs/en/2/guides/data-writes.
Remix 团队创建了一个名为 Remix Singles 的精彩视频系列,深入探讨了如何在 Remix 中处理数据。该系列从关于数据加载的视频开始,您可以在以下位置找到:www.youtube.com/watch?v=NXqEP_PsPNc.
MDN Web Docs 是一个学习 HTTP 协议的绝佳地方:developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP.
第六章:提升用户体验
Remix 使我们能够以渐进增强为前提构建应用程序。在 Remix 中,通过进行增量更改来提升用户体验。这允许我们遵循一个简单的步骤过程来构建我们的应用程序。
在第五章“获取和变更数据”中,我们向 BeeRich 应用程序添加了数据加载和变更。在构建创建费用表单时,我们首先实现了无需 JavaScript 的 UI,然后使用 JavaScript 增强了浏览器的默认行为。通过这样做,我们逐步提升了体验。
在本章中,我们将涵盖以下主题:
-
理解渐进增强
-
预取数据
-
与动作数据一起工作
-
处理并发变更
首先,我们将正式说明渐进增强在 Remix 中的工作方式。之后,我们将关注高级数据加载和变更主题,包括如何预取加载器数据和资源。接下来,我们将学习如何访问动作数据以显示变更反馈。最后,我们将学习如何在 Remix 中支持并发变更。
阅读本章后,您将了解以渐进增强为前提工作的好处。您还将学习如何预取数据、处理动作数据以及同时处理多个表单提交。
技术要求
在开始本章之前,请遵循 GitHub 上本章文件夹中的README.md文件中的说明。您可以在以下位置找到本章的代码:github.com/PacktPublishing/Full-Stack-Web-Development-with-Remix/tree/main/06-enhancing-the-user-experience。
理解渐进增强
在本节中,我们将深入了解渐进增强背后的动机,并介绍以渐进增强为前提的最佳实践。
渐进增强是一种设计理念,旨在为旧设备和浏览器上的用户提供一个基本用户体验。以渐进增强为前提构建就像设计移动端优先;你从一个适用于小屏幕的最小 UI 开始,然后逐步发展。
一个较少使用的术语是优雅降级。优雅降级描述了一个类似的概念:在支持旧版浏览器和设备的同时,努力提供最佳的用户体验。渐进增强是从下往上工作,而优雅降级则是从上往下工作。
Remix 中的渐进增强
Remix 通过利用网络标准使我们能够构建高度动态的用户体验。默认情况下,Remix 无需 JavaScript 即可工作。当考虑到渐进增强时,我们可以向上提升体验,但仍然在 JavaScript 仍在加载、加载失败或被禁用时保持其可访问性和可用性。
在第四章 Remix 中的路由中,我们学习了 Remix 的Link和NavLink组件。这些组件在 JavaScript 的帮助下增强了浏览器的默认行为。如果没有 JavaScript,Remix 的Link和NavLink组件仍然会渲染一个浏览器可以与之交互的锚点标签。
如果 JavaScript 可用,Link和NavLink将执行客户端导航并从服务器获取 fetch 加载器和资源数据,而无需重新请求完整的 HTML 文档。客户端导航旨在通过避免重复下载整个 HTML 文档来减少响应时间。
Remix 还支持数据变更的渐进增强。当使用 Remix 的Form组件时,我们再次让 Remix 增强体验。如果 JavaScript 可用,Remix 将阻止浏览器默认行为并启动对指定操作的 fetch 请求。然而,如果没有 JavaScript,我们仍然渲染一个浏览器可以与之交互的表单元素。Remix 能够回退到原生表单提交。
Remix 提供了创建高度动态但渐进增强体验的工具。当考虑到渐进增强时,第一步是使其在没有 JavaScript 的情况下工作。
使其在没有 JavaScript 的情况下工作
在没有 JavaScript 的情况下使其工作使我们能够保持简单并利用浏览器的默认行为。
有几个原因可能导致客户端上没有 JavaScript 可用:
-
当用户与页面交互时,JavaScript 仍在加载,或 React 仍在激活。这通常发生在较慢的网络连接上。
-
由于网络错误,JavaScript 加载失败。
-
由于包中的错误,JavaScript 未能被解释、执行或激活。
-
用户已在浏览器中禁用了 JavaScript。
-
用户的环境不支持 JavaScript。
当我们的应用程序在没有 JavaScript 的情况下工作时,我们可能能够触及更慢的网络或远离我们的服务器位置的用户。我们还可以更好地服务依赖于浏览器默认行为的辅助技术。
在没有 JavaScript 的情况下启动也确保了我们的应用程序逻辑尽可能在服务器上运行,从而减少了我们的客户端包大小并使我们的客户端应用程序保持简单。
有几种方法可以在 Remix 应用程序中禁用 JavaScript 以模拟此类环境。一方面,我们可以在浏览器开发者工具中禁用 JavaScript。或者,我们可以在编辑器中打开app/root.tsx文件并移除Scripts组件:
<body> <Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
移除Scripts组件将从服务器端渲染的 HTML 文档中移除所有脚本标签。通过禁用 JavaScript 或未加载任何 JavaScript,我们被迫将逻辑从客户端移动到我们的服务器端actions和loader函数。考虑到渐进增强,以减少基准用户体验的客户端代码复杂性是一种很好的方法。一旦实现了基准体验,我们就可以添加客户端 JavaScript。
在改进之前先使其变得更糟
一旦在没有 JavaScript 的情况下使其工作,我们就可以考虑使用 JavaScript 进一步增强体验。需要注意的是,启用 JavaScript 将使体验变得更差,而不是更好。默认情况下,浏览器通过在浏览器窗口的标题标签中显示加载旋转器来指示页面加载。完整页面刷新也会重置客户端状态,如表单输入,并使用最新的服务器数据重新验证 UI。通过防止浏览器默认行为,我们摆脱了这些功能。
如果我们跳过在转换或提交期间刷新客户端状态、重置表单和显示加载指示,用户体验将受到影响。因此,我们需要使用 JavaScript 将这些功能重新添加回来,当我们阻止浏览器执行其默认操作时。
我们已经在第四章,Remix 中的路由中添加了自定义加载指示。然而,无论何时我们向应用程序添加新的表单,我们都应该调查体验并查看是否需要额外的挂起指示。
在慢速网络上进行测试
在慢速网络上进行测试是检查应用程序用户体验的好方法。特别是在测试挂起状态时特别有帮助。以创建费用表单为例;你可能没有注意到当应用程序在你的本地机器上运行时,缺少加载指示。
浏览器的开发者工具提供了一个切换选项,可以限制你的连接到预设或自定义带宽。将限制设置为慢速 3G允许你在开发快速 Wi-Fi 时无法实现的方式测试你的应用程序。
让我们在慢速 3G 连接上测试 BeeRich:
-
通过在项目的根目录中执行
npm run dev来运行 BeeRich。 -
确保 JavaScript 已启用,以防之前已禁用。
-
在你的浏览器中打开应用程序。
-
打开开发者工具并导航到网络标签页。
-
查找限制功能并选择慢速 3G。
-
在你的浏览器窗口中打开费用表单(
http://localhost:3000/dashboard/expense/)。 -
填写表单并点击提交。
注意,提交按钮保持活动状态,可以再次点击。用户并不完全清楚表单目前正在提交。
-
在你的编辑器中打开
dashboard.expenses._index.tsx路由模块。 -
更新路由组件,使其使用
useNavigation钩子来推导我们应用程序的当前转换状态:import { formAction property. We only want to show the pending UI for this form if this form is being submitted. -
使用限制的网络连接测试新的挂起 UI。
注意我们如何通过修改提交按钮来增强用户体验。在慢速连接上提交创建费用表单可能需要几秒钟时间。现在,我们有一个清晰的加载指示。
渐进增强是关于使应用程序尽可能多地对用户可访问。考虑到渐进增强,确保基本用户体验足够简单,可以在较旧的浏览器和设备上使用。
结果表明,以渐进增强为前提构建可以创建一个更简单的心理模型来构建出色的用户界面。首先,我们创建没有 JavaScript 的基本实现。一旦基本实现工作正常,我们就专注于使用 JavaScript 增强体验。我们通过限制网络来测试应用程序。这迫使我们构建一个具有弹性的用户体验,可以上下扩展。Remix 通过提供原语和约定来支持我们,使我们能够进行增量更改以增强体验,直到我们满意。
我们才刚刚开始!Remix 可以扩展到高度动态的体验。接下来,我们将学习如何使用 Remix 预取数据以减少页面转换时间。
预取数据
在本节中,我们将学习如何在 Remix 中预取资产和 loader 数据,以及如何利用预取来加快转换时间。
Remix 在构建时将routes文件夹编译成一个路由层次结构。层次结构信息存储在public文件夹中的一个资产清单中。这个资产清单由 Remix 的前端和后端应用程序共同使用。
由于 Remix 可以访问客户端的资产清单,Remix 预先知道在转换到路由时需要调用哪些loader函数。这允许 Remix 在转换之前预取 loader 数据(和路由资产)。
在 Remix 中启用预取与设置我们想要预取数据的链接上的属性一样简单:
-
在您的编辑器中打开
/app/routes/dashboard.tsx文件。 -
将
prefetch属性添加到收入和费用导航链接中:<ul className="mt-10 w-full flex flex-row gap-5"> <li className="ml-auto"> <NavLink to={firstInvoice ? `/dashboard/income/${firstInvoice.id}` : '/dashboard/income'} prefetch property of Remix’s Link and NavLink components can be set to one of the following four values:* `none`* `render`* `intent`* `viewport`By default, `prefetch` is set to `none`, which means data and assets won’t be prefetched for this link. If `prefetch` is set to `render`, then the loader data and assets for the link are fetched once this link is rendered on the page. If `prefetch` is set to `viewport`, then Remix starts prefetching once the link is within the user’s viewport on the screen. If `prefetch` is set to `intent`, then Remix starts prefetching once the user focuses or hovers over the link; that is, the user shows an intent to use the link. For now, we will set `prefetch` to `intent`. -
在您的浏览器窗口中访问收入概览页面(
localhost:3000/dashboard/income)。 -
打开您的开发者工具的网络标签页。
-
清除请求列表并筛选所有请求。
-
现在,将鼠标悬停在导航中的费用链接上。
-
检查网络标签页。现在,它应该列出四个预取请求,如图 图 6**.1 所示:

图 6.1 – 检查预取请求
从收入路由跳转到 /dashboard/expenses/$id 的转换与以下路由模块匹配:
-
dashboard.tsx -
dashboard.expenses.tsx -
dashboard.expenses.$id.tsx
dashboard.tsx 路由模块已经在页面上激活,无需重新加载。Remix 只为其他两个路由模块加载资产和加载器数据。我们可以在 图 6.1 中看到四个预取请求。有两个是 JSON 内容类型的请求,用于获取所需的加载器数据,还有两个请求用于获取两个新路由模块的代码分割 JavaScript 包。
- 通过在 网络 选项卡中点击它们来检查请求。这应该会打开一个请求详情视图。检查嵌套的 预览 和 响应 选项卡。JSON 响应包含两个路由模块的加载器数据。
预取数据是可选的。使用预取,我们可以拉一个杠杆来减少请求时间,但如果用户不访问链接,则可能会引入获取不必要数据的风险。
在渲染时预取是最激进的策略,而在意图上预取则是基于用户在页面上的操作。
Remix 提供了杠杆
预取是我们可以拉的杠杆。使用预取通过增加在网络中下载不必要数据的风险来减少响应时间。Remix 通过提供杠杆允许我们根据我们的用例和需求优化我们的应用程序。
现在我们已经了解了预取,让我们更深入地了解突变。
处理操作数据
loader 和 action 函数包含我们 Remix 应用程序的大部分业务逻辑。这是我们获取、过滤和更新数据的地方。这两个函数都必须返回一个 Response 对象。您已经了解了 redirect 和 json 辅助函数,它们让我们可以创建特定的 Response 对象,并且您已经练习了处理加载器数据。在本节中,我们将学习如何处理操作数据。为此,我们将更新费用详情视图并实现编辑费用表单:
-
在您的编辑器中打开
dashboard.expenses.$id.tsx路由模块。 -
从
dashboard.expenses._index.tsx中获取当前代码。您能修改代码以编辑现有的费用吗?试试看!本章的最终代码可在 GitHub 的
/bee-rich/solution文件夹中找到。随着我们继续前进,我们将帮助您将您的工作与这个最终解决方案对齐。 -
确保使用加载器数据中的
expense对象更新表单的action属性:<Form method="POST" action={`/dashboard/expenses/${expense.id}`} -
如果您还没有,更新
isSubmitting常量的formAction检查:const navigation = useNavigation();const isSubmitting = navigation.state !== 'idle' && navigation.formAction === `/dashboard/expenses/${expense.id}`;我们再次使用
useNavigation钩子来计算是否应该将挂起的指示添加到表单中。请注意,同样地,我们确保提交的表单操作与该表单的action属性匹配。 -
接下来,更新路由组件的表单字段:
<Input label="Title:" type="text" name="title" defaultValue property to set the form’s initial values. Compare this to setting the value property, which also requires us to register onChange event handlers and work with React states. Since we use Remix’s Form component, we don’t need to keep track of the input field value changes, which greatly simplifies our client-side code.Note that we added `name` and `value` properties to the `action` function. We use the `intent` value on the server to know which action to execute. -
接下来,向
Form组件添加 React 的key属性,以确保 React 在我们切换到不同的费用详情页面时每次都重建表单的内容:<Form method="POST" action={`/dashboard/expenses/${expense.id}`} defaultValue value of the input fields when loading a new expense details page.To better understand this, remove the `key` property and navigate between different expenses. You will see that the form does not update with the new expense data if we don’t tell React that each form is unique based on the expense identifier. -
将以下
action函数添加到路由模块中:import type { $id route parameter to decide which expense to update. Furthermore, we throw an error if the route parameter is not defined.We utilize the value on the `action` function.As you can see, we will add a deletion action in the next section. For now, let’s focus on the update functionality. -
将缺少的
updateExpense函数添加到路由模块文件中:async function updateExpense(formData: FormData, id: string): Promise<Response> { const title = formData.get('title'); const description = formData.get('description'); const amount = formData.get('amount'); if (typeof title !== 'string' || typeof description !== 'string' || typeof amount !== 'string') { throw Error('something went wrong'); } const amountNumber = Number.parseFloat(amount); if (Number.isNaN(amountNumber)) { throw Error('something went wrong'); } await db.expense.update({ where: { id }, data: { title, description, amount: amountNumber }, }); action function. Like in loader functions, we can return JSON data in action functions. This is useful when communicating error or success states to the user after the mutation.In this case, we return a success state after successfully updating the expense. -
导入
useActionData钩子:import { useActionData in the route module’s component to access the return data of action:typeof 操作符。请注意,与加载器数据不同,操作数据可以是未定义的。
-
使用操作数据在 提交 按钮下方显示成功消息:
<Button type="submit" name="intent" value="update" disabled={isSubmitting} isPrimary> {isSubmitting ? 'Save...' : 'Save'}</Button>action data is present and the success property is true, we will show the user a Changes saved! message. -
运行应用程序并测试更新的支出表单!
太棒了!就这样,我们可以利用 action 数据来传达成功的突变。确保您在 income 路由上实现相同的功能。尝试在不看说明的情况下适应收入路由模块。这将帮助您更好地理解本章的要点。如果您遇到困难,请回顾本章的说明。您还可以在 GitHub 上找到本章的解决方案代码。
仅在相同的路由模块中使用 useActionData
注意,表单提交会导航到 action 函数的位置。useLoaderData 只能访问同一路由加载器的加载数据。同样,useActionData 必须在提交表单的 action 函数的路由模块中使用。
Remix 还提供了高级数据突变工具。接下来,我们将添加删除支出的功能,并学习如何在 Remix 中处理并发突变。
处理并发突变
到目前为止,我们已经创建了支出创建和编辑表单。这两个表单都在各自的页面上独立存在。本节将教您如何同时管理多个表单提交。让我们首先为支出概览列表中的每个项添加删除表单。
将表单添加到列表中
本节的目标是为支出概览列表中的每个列表项添加删除表单。点击项应删除相关的支出。让我们开始:
-
如果还没有,请遵循 GitHub 上本章
README.md文件中的说明:github.com/PacktPublishing/Full-Stack-Web-Development-with-Remix/blob/main/06-enhancing-the-user-experience/bee-rich/README.md。README.md文件包括如何更新本章ListLinkItem组件的说明。 -
接下来,打开
dashboard.expenses.$id.tsx路由模块。 -
在路由模块中添加一个
deleteExpense函数:async function deleteExpense(request: Request, id: string): Promise<Response> { const referer = action URL (request.url) contains the id parameter of the expense that should be deleted. However, that may not be the expense that’s currently displayed in the details route module. We use the referer header to derive the route from which the form was submitted. The goal is to keep the user on the current route unless the current route is the details page of the expense that is being deleted. This ensures that deletion does not navigate the user away from the current page unless the current expense is deleted. -
在
action函数中调用新创建的deleteExpense函数:if (intent === 'delete') { ListLinkItem component in /app/components/links.tsx.The updated `ListLinkItem` component renders a delete (`deleteProps` property is provided. The `name` and `value` properties to specify the type of `action` function to perform. -
接下来,在您的编辑器中打开
dashboard.expenses.tsx文件,并将deleteProps属性传递给ListLinkItem组件:<ListLinkItem key={expense.id} to={`/dashboard/expenses/${expense.id}`}useParams from Remix inside the dashboard.expenses.tsx route module:导入
{ Outlet, useLoaderData, useNavigation, useParams }从@remix-run/react;The `useParams` hook can be used to access route parameters on the client. We use this hook to calculate the `isActive` property of the `ListLinkItem` component. -
在路由模块组件中使用
useParams钩子访问支出详情页的id路由参数:const { id } = useParams(); -
将
isActive属性添加到ListLinkItem组件:<ListLinkItem key={expense.id} to={`/dashboard/expenses/${expense.id}`} ListLinkItem component used the NavLink component’s isActive parameter from the className property to update the styling. The new implementation requires custom logic as the ListLinkItem component now renders more than just a NavLink. We use the useParam hook to access the current id parameter and then derive whether the href attribute of ListLinkItem points to the currently displayed expense. -
让我们尝试一下我们的实现。在本地运行应用程序,访问费用概览页面,并通过点击
action函数来删除费用对象。该函数处理提交并将用户重定向回当前页面或费用概览页面。在突变之后,Remix 重新获取加载器数据,这触发了重新渲染。费用对象从费用列表中消失。
太好了!费用列表中的每个列表项现在都包含一个用于删除费用的表单。接下来,让我们在删除时指示待处理状态。
支持多个待处理状态
我们已经知道我们可以使用useNavigation钩子来访问全局导航对象。导航对象的状态属性指示我们应用程序的当前转换状态。让我们使用useNavigation钩子来指示删除表单的待处理删除:
-
在
/app/components/links.tsx中导入useNavigation:import { Form, Link as RemixLink, NavLink as RemixNavLink, ListLinkItem function body:const navigation = useNavigation();
-
推断表单当前是否正在提交或加载:
const isSubmitting = navigation.state !== 'idle' && navigation.formAction === deleteProps?.action && navigation.formData?.get('intent') === 'delete';注意,我们在这里采取了额外的安全措施。我们检查当前是否有页面导航正在进行,以及
formAction是否与该表单的action函数匹配。最后,我们还要确保表单的intent值与该表单提交按钮的intent值匹配。这确保了只有当此删除按钮被点击时,我们才显示待处理状态。 -
最后,使用
isSubmitting禁用提交按钮,并条件性地指示待处理提交状态:<button type="submit" aria-label={deleteProps.ariaLabel} name="intent" value="delete" isSubmitting is true. Based on our previous experience with the navigation object, this should suffice. Let’s test it out and see it in action. -
在开发模式下运行应用程序,并在浏览器窗口中打开
localhost:3000/dashboard/expenses。 -
打开浏览器开发者工具的网络标签页,并将限速设置为慢 3G。这有助于我们更长时间地体验待处理的 UI。同时,确保通过Fetch/XHR请求进行筛选。
-
尝试一次性删除多个费用,看看你是否发现了任何问题。
你可能会注意到当前实现中存在一个缺陷:删除费用似乎会取消所有其他正在进行的删除操作。
你能解释为什么是这样吗?Remix 的导航对象捕获了我们应用程序的全局导航状态。一次只能有一个页面导航。如果用户提交第二个表单,那么 Remix 将取消第一个导航,如图 6**.2所示。导航对象被更新,反映了第二个表单的提交:

图 6.2 – 取消 fetch 请求
注意,删除操作并未被取消;我们只是失去了待处理指示。在客户端,Remix 取消当前提交并相应地更新导航对象。然而,删除费用的 fetch 请求仍然到达服务器,并且操作被执行。Remix 还确保只有在每个提交都已执行之后,才重新验证加载器数据,以避免在最新提交比之前的提交更快完成时出现过时数据。
Remix 跟踪所有正在进行的提交,但只处理最后的导航
请记住,Remix 旨在模拟浏览器的默认行为。只能有一个页面导航。在并发表单提交中,最后一个用户操作决定最终的页面导航。在后台,Remix 会跟踪所有正在进行的提交以管理加载器数据重新验证。
让我们修复丢失的挂起指示。我们希望为每个当前挂起的删除显示挂起 UI。幸运的是,Remix 为我们提供了另一种声明表单的方法。Remix 的 useFetcher 钩子可以声明具有独立提交状态的 Form 组件:
-
在
/app/components/links.tsx中导入useFetcher,替换useNavigation和Form的导入:import { Link as RemixLink, NavLink as RemixNavLink, useNavigation hook declaration in the ListLinkItem component with a call to useFetcher:const fetcher = useFetcher();
-
接下来,更新
isSubmitting常量的分配:const isSubmitting = fetcher.state !== 'idle';从
useFetcher返回的 fetcher 对象具有独立的提交生命周期和导航状态。状态不受应用程序中其他提交或加载活动的影响。注意,
useFetcher表单提交仍然会在修改后触发所有活动的loader函数重新加载。这会将全局导航状态设置为loading。然而,useFetcher表单提交不会将全局导航对象的状态设置为submitting。 -
将
Form替换为fetcher.Form:useFetcher object provides several different ways to fetch and mutate data. It offers a load function to fetch data from a loader outside the app’s navigation lifecycle. It also offers a submit function to call an action function programmatically. Finally, useFetcher also provides a Form component.There are plenty of use cases for `useFetcher`. Here, we use the hook to create isolated forms for every item in a list.Since `useFetcher` is a hook, we must follow React’s rules for hooks. When working with a list and `useFetcher`, we must declare a new `useFetcher` object for each list element. This ensures that each item has its own navigation state. Usually, this is done by creating a list item component where each list item manages its `useFetcher` object. Conveniently, we are already doing this with the `ListLinkItem` component. -
现在,使用费用创建表单创建一些费用。
-
打开开发者工具并导航到 网络 选项卡。
-
调整到 慢速 3G。
-
过滤 Fetch/XHR 请求。
-
现在,点击删除 (
useFetcher。
使用 useFetcher 无需页面导航即可加载数据和修改数据
通过 Form 组件进行数据修改会将用户导航到 action 函数的位置。这是浏览器表单提交的默认行为。
useFetcher 钩子允许我们在不触发页面导航的情况下加载数据和修改数据;如果 JavaScript 已加载,useFetcher 具有独立的导航状态,不会触发页面导航。
有一个需要注意的事项是,useFetcher 仍然尊重来自 action 函数的重定向响应。此外,如果 JavaScript 不可用,useFetcher.Form 将回退到原生表单元素的默认行为。
useFetcher 有许多用例。你可以在 Remix 文档中了解更多关于 useFetcher 的信息:remix.run/docs/en/2/hooks/use-fetcher。
接下来,练习你所学的知识,并使用更新的 ListLinkItem 组件处理 income 路由。这将帮助你学习本节新引入的概念。
太棒了!我们在本章中覆盖了很多内容。
请注意,我们目前正面临用户体验问题。如果你在一个费用详情页面(dashboard/expenses/$id)上,并且一次性快速删除所有费用,你可能会结束在一个找不到页面上。我们将在下一章中一起解决这个问题,第七章,Remix 中的错误处理。
摘要
在本章中,我们学习了渐进式增强。渐进式增强是一种设计理念,旨在为较旧设备和浏览器上的用户提供基本用户体验。
你了解到 Remix 的原语在有和没有 JavaScript 的情况下都能工作。这使我们能够渐进式地增强体验,并使我们的应用程序对更多用户可访问。通过考虑渐进式增强来构建,我们确保尽可能多的设备和浏览器都能获得简单但健壮的体验。一旦我们确保了基本体验,我们就可以使用 JavaScript 来增强体验。
接下来,你了解到 Remix 可以向上和向下扩展。我们可以从简单开始,甚至禁用 JavaScript,但通过进行增量更改,我们可以创建具有并发突变、数据重新验证和预取的高度动态体验。
Remix 提供了优化我们所需重要体验的杠杆。我们可以通过将prefetch属性设置为render、viewport、intent或none来决定我们希望多积极地预取数据。你进一步了解了动作数据,这些数据可以在突变后用来传达错误或成功状态。
最后,你学习了 Remix 如何管理并发表单提交。你知道只能有一个活动的页面导航。Remix 取消所有挂起的导航,并相应地更新全局导航对象。
如果我们想要管理并发挂起的指示和隔离的动作数据,那么我们可以使用 Remix 的useFetcher钩子。这可以用来程序化地提交表单,同时也提供了一个useFetcher.Form组件,如果 JavaScript 可用,则不会触发页面导航。
useFetcher钩子特别有用,允许同时提交多个表单,同时并行传达每个表单的挂起状态。这通常发生在渲染表单列表时,正如我们在 BeeRich 中的费用概览列表中看到的那样。
在下一章中,我们将专注于处理错误,并了解我们如何使用 Remix 在出现问题时提供良好的用户体验。
进一步阅读
Remix 团队创建了一个名为 Remix Singles 的精彩视频系列,深入探讨了如何在 Remix 中处理数据。我建议你观看整个系列。特别是对于本章,该系列有一个关于使用useFetcher进行并发突变的视频,你可以在这里找到:www.youtube.com/watch?v=vTzNpiOk668&list=PLXoynULbYuEDG2wBFSZ66b85EIspy3fy6。
Remix 文档包括一个关于渐进式增强的页面:remix.run/docs/en/2/discussion/progressive-enhancement。
你还可以在 MDN Web 文档中了解更多关于渐进增强的内容:developer.mozilla.org/en-US/docs/Glossary/Progressive_Enhancement。
你可以在 Remix 文档中找到更多关于 useFetcher 钩子的信息:remix.run/docs/en/2/hooks/use-fetcher。
第七章:Remix 的错误处理
错误处理是构建弹性用户体验的重要组成部分。我们可以区分两种错误:
-
意外错误,例如网络超时
-
故意抛出的预期失败(异常)
Remix 提供了处理意外和预期错误的原语和约定。本章涵盖了以下主题:
-
处理意外错误
-
处理抛出的响应
-
处理页面未找到(404)错误
首先,我们将制造一些意外错误并学习如何处理它们。接下来,我们将回顾在loader和action函数中返回和抛出Response对象之间的区别。我们将看到如何使用 Remix 的ErrorBoundary处理抛出的响应。最后,我们将向 BeeRich 添加未找到错误处理。
阅读本章后,您将了解如何使用 Remix 的ErrorBoundary组件声明式地管理意外和预期失败。您还将知道抛出响应如何融入 Remix 的异常处理故事,以及抛出和返回响应之间的区别。最后,您将了解如何使用 Remix 处理未找到错误。
技术要求
您可以继续使用上一章中的解决方案。本章不需要额外的设置步骤。如果您遇到困难,可以在此处找到本章的解决方案代码:https://github.com/PacktPublishing/Full-Stack-Web-Development-with-Remix/tree/main/07-error-handling-in-remix。
处理意外错误
在运行时,Remix 应用程序在浏览器和服务器上执行。可能会出错,客户端和服务器上可能会发生意外错误。考虑错误情况以提供弹性用户体验非常重要。在本节中,我们将研究如何在 Remix 的根级别和嵌套路由中处理意外错误。
调用客户端和服务器错误
在第二章《创建新的 Remix 应用程序》中,我们提供了一个故障排除指南,并调查了 Remix 如何统一处理客户端和服务器上的错误。让我们通过调用一些“意外”的错误来再次回顾 Remix 的默认错误处理:
-
在编辑器中打开您的 BeeRich 应用程序。
-
在
app/routes文件夹中打开dashboard.tsx路由模块。 -
在
loader函数体中返回语句之前添加以下代码:throw Error('Something went wrong!');通过在
loader函数中抛出错误,我们阻止了应用程序处理传入的请求。这会在服务器上创建一个意外的失败。 -
在终端中执行
npm run dev以运行应用程序。 -
在新浏览器窗口中打开
localhost:3000/dashboard以访问 BeeRich 仪表板。在
loader函数中抛出错误会渲染 Remix 的错误页面,如图 7.1所示:

图 7.1 – 加载函数中的应用错误
图 7.1 展示了 Remix 处理意外错误时的默认行为。我们可以看到屏幕上显示的“出了点问题!”错误消息以及未能执行的 loader 函数的堆栈跟踪。
-
现在,让我们在路由组件中抛出一个错误。将错误从
loader函数移动到dashboard.tsx路由组件内部:export default function Component() { throw Error('Something went wrong!'); -
刷新浏览器窗口。
通过重新加载页面,我们触发整个页面的重新加载。Remix 在服务器上处理初始文档请求。因此,仪表板路由模块组件首先在服务器上被调用,我们再次在服务器上抛出错误。
错误堆栈跟踪会改变,但 Remix 仍然在屏幕上显示“出了点问题!”。
-
现在,将错误包裹在一个
useEffect钩子中,以确保错误在客户端执行:import { useEffect } from 'react';export default function Component() { useEffect(() => { throw Error('Something went wrong!'); }, []);React 的
useEffect钩子仅在客户端执行,不在服务器上执行。这是因为钩子在初始渲染之后执行,而在服务器上我们只渲染一次。 -
再次刷新浏览器窗口。你应该在页面上看到另一个堆栈跟踪。这次,堆栈跟踪来自客户端脚本。
如图 7.2 所示,请注意堆栈跟踪中的文件名包含哈希值。这意味着文件已被打包,并且是客户端打包的一部分:

图 7.2 – 客户端应用错误
这个实验告诉我们 Remix 为浏览器和服务器错误提供了相同的默认体验。接下来,让我们用自定义 UI 替换 Remix 的默认错误页面。
使用根错误边界处理错误
在 React 中,渲染过程中的失败可以通过错误边界来处理。错误边界是实现了错误边界生命周期方法的类组件。Remix 在 React 的错误边界之上构建,并扩展了其功能以处理服务器端错误。我们不是在组件树中嵌套 React 错误边界,而是通过在路由模块 API 中导出 ErrorBoundary 组件来声明它们。
root.tsx 路由模块中的 ErrorBoundary 组件是我们应用程序中最顶层的错误边界。为了替换 Remix 的默认错误页面,我们需要从 root.tsx 导出 ErrorBoundary 组件:
-
在您的编辑器中打开
app/root.tsx路由模块。 -
从 Remix 导入
useRouteError钩子:import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration, ErrorBoundary and add the following code to it:import { H1 } from './components/headings';import { ButtonLink } from './components/links';export function ErrorBoundary() { ErrorBoundary 组件是 Remix 路由 API 的一部分,并在发生错误时替换路由模块组件。我们可以通过调用 useRouteError 访问导致失败的错误。我们进一步使用错误对象来显示错误信息。 -
制造一个意外的错误,并刷新浏览器窗口以检查更新的错误页面:

图 7.3 – 自定义根 ErrorBoundary 组件
如 图 7.3 所示,我们现在为我们的应用程序渲染了一个自定义错误页面。
-
检查
root.tsx中ErrorBoundary组件的代码。注意我们渲染了我们的样式化H1和ButtonLink组件。为什么我们的自定义样式没有应用到页面上? -
检查
Meta、Links和Scripts组件,将我们的元数据和链接标签以及客户端 JavaScript 脚本附加到 HTML 文档中。这发生在root.tsx中的路由模块组件中。然而,在发生错误时,我们不渲染路由模块组件,而是渲染ErrorBoundary组件。Remix 默认将错误边界的内容包裹在一个 HTML body 标签中。然而,我们也可以提供一个自定义的 HTML 文档。让我们更新代码,以便在
root.tsx文件中的ErrorBoundary组件中渲染Meta、Links和Scripts组件。 -
在
root.tsx中创建一个新的Document组件,以便我们可以在App和ErrorBoundary组件之间重用代码:function Document(Document component renders the JSX from the App component. We just replaced Outlet with children. -
现在,更新
App组件以便它使用Document:export default function App() { return ( App component remains unchanged. We just moved the code into the reusable Document component. -
接下来,也将错误边界的内容包裹在
Document组件内部:export function ErrorBoundary() { const error = useRouteError(); let errorMessage = error instanceof Error ? error.message : null; return ( Document component, the error boundary now includes our application’s scripts, stylesheets, and custom html and head elements:

图 7.4 – 样式化根 ErrorBoundary 组件
太棒了!现在,我们可以在根 ErrorBoundary 组件中利用客户端 JavaScript 和我们的自定义样式。当我们为 ErrorBoundary 组件重用组件时,我们必须记住的一件事是我们不能调用 useLoaderData 钩子。确保不要渲染访问 loader 数据的组件,因为在 ErrorBoundary 组件渲染时,loader 数据未定义。
错误边界无法访问 useLoaderData
如果路由模块的 loader 或 action 函数中发生错误,则会渲染错误边界。最终,错误边界中不可用 loader 数据。
BeeRich 中的顶级 ErrorBoundary 组件不渲染任何导航栏或其他布局组件。保持顶级 ErrorBoundary 组件简单可以确保即使在意外失败的情况下也能渲染。
接下来,让我们看看如何通过声明嵌套错误边界来进一步改进错误处理。
嵌套错误处理
错误边界可以嵌套。当抛出错误时,它会通过路由层次结构向上冒泡,直到 Remix 找到最近的错误边界。嵌套错误边界让我们能够包含错误。
让我们在仪表板路由模块中添加一个嵌套错误边界:
-
打开
app/routes文件夹内的dashboard.tsx路由模块。 -
在页面中添加一个简单的
ErrorBoundary导出:export function ErrorBoundary() { return <p>Error contained in dashboard.tsx</p>;} -
现在,在
dashboard.tsx路由模块的loader函数中抛出一个错误:throw Error('Something went wrong!'); -
运行应用并在新浏览器窗口中打开
localhost:3000/dashboard访问仪表板。注意,我们不是渲染根错误边界,而是在
dashboard.tsx中嵌套的错误边界。Remix 使用最近的可用错误边界并在父路由组件的Outlet中渲染它。 -
让我们让错误边界看起来更美观。就像我们在
root.tsx中做的那样,我们希望在ErrorBoundary组件和路由组件之间共享标记。重构路由组件,使其成为一个可重用的Layout组件。首先,从函数定义中移除export default并添加一个LayoutProps类型来指定组件期望的属性:import type { Layout component expects three props: React children, firstExpense, and firstInvoice. To type the props correctly, we wrap the Expense and Invoice types from Prisma with Remix’s SerializeFrom type.Prisma (our database ORM) generates the `Expense` and `Invoice` types based on our Prisma database schema. Remix’s `SerializeFrom` type transforms the `Expense` and `Invoice` types into their serialized versions. This is necessary as the data travels over the network, serialized as JSON. For instance, the `createdAt` field is of the `Date` type on the server but serialized as `string` once accessed via `useLoaderData`. -
从
Layout函数体中移除useLoaderData调用。由于错误边界不能调用useLoaderData,我们必须将加载数据作为可选属性传递。如LayoutProps所见,我们确保Layout组件也接受null作为firstExpense和firstInvoice的值。这可能是在我们的数据库中没有找到任何费用或发票时,或者当错误边界被渲染时的情况。 -
将
Outlet的渲染替换为children:<main className="p-4 w-full flex justify-center items-center">Layout:`export default function Component() { const { firstExpense, firstInvoice } = useLoaderData
(); return ( );}The route component renders the same content as before. We just moved some code to the new `Layout` component. -
接下来,更新
dashboard.tsx中的ErrorBoundary组件:import { Link as RemixLink, Outlet, useLoaderData, useLocation, loader function for a moment. -
运行应用程序并访问
localhost:3000/dashboard页面。如果没有抛出错误,我们应该看到费用概览页面。如果你不记得这是为什么,请查看
dashboard._index.tsx路由模块内部。如果仪表板的索引路由是活动的,它将重定向到费用概览页面。 -
接下来,在任何活动在仪表板内的
loader函数或 React 组件中抛出错误:throw Error('Something went wrong!');浏览器窗口现在应该渲染样式化的嵌套错误边界。如 图 7**.5 所见,将错误边界添加到仪表板路由中使我们能够隔离错误并正确渲染所有父路由:

图 7.5 – 嵌套仪表板 ErrorBoundary 组件
嵌套错误边界允许我们在路由层次结构的子集中包含错误。这确保了父路由按预期渲染。错误边界离发生的错误越近,错误就越被限制。
在本节中,你学习了如何使用 Remix 的 ErrorBoundary 组件来处理意外的错误。接下来,让我们看看如何使用 ErrorBoundary 组件来处理预期的异常。
处理抛出的响应
我们已经在 BeeRich 中利用了抛出 Response 对象的优势。例如,在 第四章,Remix 中的路由,我们在 dashboard.expenses.$id.tsx 路由模块中添加了以下 loader 函数:
export function loader({ params }: LoaderFunctionArgs) { const { id } = params;
const expense = data.find((expense) => expense.id === Number(id));
if (!expense) throw new Response('Not found', { status: 404 });
return json(expense);
}
在 loader 函数中,如果我们找不到 id 路由参数对应的费用,我们将抛出一个 Response 对象。这将在 loader 函数执行期间创建一个预期的失败。让我们调查 Remix 在发生预期异常时的默认行为。
抛出响应
在 JavaScript 中,throw 语句用于抛出用户定义的异常。然后,catch 块可以捕获抛出的异常。我们可以抛出任何值,包括 Response 对象。Remix 利用这一点,提供了一个约定来使用异常响应提前停止 action 和 loader 函数。让我们调用在费用详情 loader 中抛出的未找到响应:
-
通过执行
npm run dev在本地主机上运行 BeeRich。 -
在新浏览器窗口中打开
localhost:3000/dashboard/expenses访问费用概览页面。 -
现在,点击概览列表中的费用。这将将我们重定向到费用详情页面。
-
将 URL 中的
id路由参数替换为假的:localhost:3000/dashboard/expenses/fake-id。然后,重新加载浏览器窗口。
这应该会将我们的仪表板错误边界渲染到页面上。
Remix 允许我们使用 ErrorBoundary 组件统一处理意外的错误和抛出的响应。在 loader 或 action 函数中抛出的任何 Response 对象都会触发 ErrorBoundary 组件。
抛出的响应允许我们检索额外的信息,例如 ErrorBoundary 组件中错误的状态码。为此,我们需要检查抛出的错误对象是否是 Response 对象或意外的错误。
使用错误边界处理异常
让我们添加第三个错误边界,这次是为嵌套费用详情页面:
-
打开
dashboard.expenses.$id.tsx路由模块。 -
导入 Remix 的
useRouteError和isRouteErrorResponse辅助函数:import { isRouteErrorResponse, useActionData, useLoaderData, useNavigation, useParams, useRouteError,} from '@remix-run/react'; -
创建一个新的
ErrorBoundary导出,并添加以下代码:export function ErrorBoundary() { const error = useRouteError(); const { id } = useParams(); let heading = 'Something went wrong'; let message = `Apologies, something went wrong on our end, please try again.`; if (useParams hook to access the expense id route parameter. Then, we use Remix’s isRouteErrorResponse helper to check whether the error object is a Response object. If yes, then we can read the status code and other fields of the Response object to provide a more specific error message. -
接下来,测试实现。导航到费用详情页面,并在 URL 中使用假的
id路由参数。你现在应该能看到页面上的嵌套ErrorBoundary组件被渲染。注意我们仍然渲染了费用概览列表;这就是嵌套错误处理的力量!

图 7.6 – 嵌套费用详情 ErrorBoundary 组件
既然我们已经为费用详情页面实现了嵌套错误边界,那么接下来为收入详情页面实现相同的体验。
一旦你完成收入详情页面的工作,我们将重新审视来自 第六章,增强用户体验 的不愉快体验。
创建一个有弹性的体验
你还记得我们在第六章,“增强用户体验”中引入支出删除表单时创建了一个不愉快的体验吗?如果你在一个支出详情页(dashboard/expenses/$id)上,并且一次性快速删除所有支出,你可能会结束在一个未找到的页面上。这是因为我们在dashboard/expenses/$id的deleteExpense函数中的重定向逻辑:
async function deleteExpense(request: Request, id: string): Promise<Response> { const referer = request.headers.get('referer');
const redirectPath = referer || '/dashboard/expenses';
try {
await db.expense.delete({ where: { id } });
} catch (err) {
throw new Response('Not found', { status: 404 });
}
if (redirectPath.includes(id)) {
return redirect('/dashboard/expenses');
}
return redirect(redirectPath);
}
我们如果可用,会使用referer头将用户在删除后重定向回当前路由。这是为了提升用户体验。如果用户当前在详情页,/dashboard/expenses/1,并且删除了id为2的支出,我们不希望将用户从/dashboard/expenses/1重定向走。然而,在deleteExpense函数中我们也有一个if条件来确保如果用户当前在要删除的支出的详情页上,我们会重定向用户。
当我们快速删除多个支出时,这个逻辑会失败。同时触发多个支出删除操作会创建不同动作请求之间的竞争条件。最终决定用户将被重定向到哪里的将是最后触发的action的响应。然而,到那时,我们可能已经删除了当前在详情视图中查看的支出。
假设我们正在详情页,/dashboard/expenses/1,并且快速删除了id为1的支出,然后又删除了id为2的支出。在处理删除支出1的deleteExpense函数中,我们知道支出1已经被删除,因此我们返回重定向到/dashboard/expenses。然而,在处理删除支出2的deleteExpense函数中,我们将返回重定向到当前页/dashboard/expenses/1。Remix 会取最后用户动作的响应并提交重定向到/dashboard/expenses/1。我们在loader函数中抛出一个 404 未找到错误,因为id为1的支出找不到(它已经被删除了)。
如*图 7**.6 所示,我们通过引入嵌套的ErrorBoundary组件来增强支出详情的用户体验。太棒了!现在,如果出现 404 错误,用户将停留在支出概览页,错误被包含在嵌套的ErrorBoundary中。我们避免了向用户显示全屏的错误消息,而是优雅地将未找到错误作为仪表板 UI 的一部分显示。
声明式错误处理
Remix 的错误边界允许我们声明式地处理错误和异常。通过添加嵌套的错误边界,我们可以优雅地处理边缘情况,以提供弹性的用户体验。
现在我们已经通过嵌套错误边界确保了弹性的用户体验,让我们增强根错误边界,为常见的 HTTP 状态码添加自定义错误消息。在下一节中,我们将处理页面未找到错误(404)。
处理页面未找到(404)错误
我们还可以在根ErrorBoundary组件中处理抛出的响应。一个只能在根ErrorBoundary组件中处理的特殊情况是 Remix 抛出的页面未找到异常。让我们回顾根错误边界以在根级别处理抛出的响应:
-
通过执行
npm run dev在本地运行 BeeRich。 -
在新浏览器窗口中访问一个不存在的页面,例如
localhost:3000/cheesecake。当访问一个不存在的路由时,Remix 会在根级别抛出一个带有 HTTP 状态码 404 的响应。我们可以使用
isRouteErrorResponse来使用根错误边界渲染一个 404 页面。 -
在编辑器中打开
root.tsx文件。 -
从 Remix 导入
isRouteErrorResponse:import { ErrorBoundary export in root.tsx:const error = useRouteError();let heading = 'Unexpected Error';let message = 'We are very sorry. An unexpected error occurred. Please try again or contact us if the problem persists.';if (H1 and p texts with the heading and message values:
<H1>{heading}</H1><p>{message}</p>{errorMessage && ( <div className="border-4 border-red-500 p-10"> <p>Error message: {errorMessage}</p> </div>)} -
再次访问
localhost:3000/cheesecake。你现在应该能看到图 7.7中显示的 404 未找到页面。7*:

图 7.7 – BeeRich 的 404 页面截图
太好了!我们已经将根和嵌套错误边界添加到了 BeeRich 中。请注意,我们可以在loader和action函数中抛出自定义的 404 响应。嵌套错误边界可以处理这些抛出的异常。如果请求的 URL 不匹配任何路由,Remix 会抛出一个根级别的 404 响应。由于异常是在根级别抛出的,我们使用根错误边界来处理 Remix 中的全局 404 未找到页面。
摘要
在本章中,你学习了 Remix 让我们可以使用 Remix 的ErrorBoundary组件声明式地处理预期的和意外的失败。
根ErrorBoundary导出处理了抛出的响应和错误,如果还没有其他嵌套错误边界处理它们的话。错误和抛出的响应会通过路由层次结构向上冒泡。
然后,你学习了错误边界无法访问加载器数据。在边界中渲染任何访问useLoaderData钩子的组件是很重要的。
使用错误边界使应用程序对错误更加健壮。紧密的错误边界可以在意外错误仅影响嵌套路由模块时保持我们应用程序的部分功能。
在下一章中,我们将抛出更多的响应——更确切地说,是 401 响应——因为我们实现了一个身份验证流程,并学习了如何使用 Remix 进行状态管理。
进一步阅读
你可以在 MDN 上找到所有 HTTP 状态码的列表:developer.mozilla.org/en-US/docs/Web/HTTP/Status。
如果你想了解更多关于异常和错误处理的信息,我推荐你查看 Shawn Wang(@swyx)的博客文章 错误不是异常:www.swyx.io/errors-not-exceptions。
你可以在 Remix 文档中找到更多关于 ErrorBoundary 路由模块导出的信息:remix.run/docs/en/2/route/error-boundary。
Remix 文档还包含一个关于处理未找到错误的指南,你可以在这里找到:remix.run/docs/en/2/guides/not-found。
第八章:会话管理
会话管理描述了在不同用户交互和请求-响应往返中保留数据的过程。会话管理对于在网络上提供个性化体验至关重要。在本章中,我们将使用 Remix 的原语来管理应用程序状态和用户会话数据。本章涵盖了以下主题:
-
使用搜索参数
-
使用 cookies 创建用户会话
-
验证用户数据的访问权限
首先,我们将使用 Remix 的原语将应用程序状态与 URL 搜索参数关联起来。然后,我们将利用 HTTP cookies 来持久化用户会话数据。最后,我们将使用会话 cookie 在 loader 和 action 函数中验证用户身份。
在阅读本章之后,您将了解如何在 Remix 中使用搜索参数来控制应用程序状态。您还将知道如何使用 Remix 的 useSubmit 钩子程序来程序化地提交表单。您将进一步练习使用 Remix 的会话 cookie 辅助工具,并学习如何在 Remix 中实现登录、注册和注销功能。最后,您将了解如何在服务器上验证用户身份,以及如何在您的应用程序中全局访问加载器数据。
技术要求
您可以在此处找到本章的代码:github.com/PacktPublishing/Full-Stack-Web-Development-with-Remix/blob/main/08-session-management/。
在开始本章之前,请遵循 GitHub 上本章 bee-rich 文件夹中的 README.md 文件中的说明。此 README 文件指导您将 User 模型添加到 BeeRich 应用程序的数据库模式中。它还帮助您初始化一个包含一些有用辅助函数的 session.server.ts 文件。请注意,遵循 README 指南将暂时中断创建和编辑费用和收入表单操作。我们将在本章中更新代码。在此期间,请使用种子数据来填充数据库以进行测试。
使用搜索参数
URL 存储有关用户当前位置的信息。我们已利用动态路由参数来处理费用和发票标识符。同样,我们可以使用 URL 搜索参数来存储额外的应用程序状态。
URL 是持久化仅涉及一个或几个页面的状态的完美位置。在本节中,我们将使用 URL 搜索参数在 BeeRich 的费用概览页上创建一个搜索过滤器。
你知道吗?Google 使用搜索参数来实现搜索查询?打开google.com,并使用搜索输入字段开始一个新的 Google 搜索。按下Enter后,Google 会带你到搜索结果页面。如果你检查 URL,你会看到 Google 使用一个名为q(可能是查询的简称)的搜索参数来存储你的搜索查询:www.google.com/search?q=Using+search+params+in+Remix.run。
搜索参数是添加到路径名之后、问号(?)之后的 URL 中的键值对,并通过&符号附加。搜索参数允许我们在 URL 路径之外存储额外的可选应用程序状态。
让我们在 BeeRich 中构建一个类似于 Google 搜索的体验,通过搜索过滤器过滤支出列表。
在loader函数中读取搜索参数
支出列表在dashboard.expenses.tsx路由模块中获取并渲染。现在,我们希望允许用户通过搜索输入字段来过滤列表。
我们可以将工作分为两个步骤:
-
更新数据库查询,使其通过搜索查询进行过滤。
-
提供用户界面,以便他们可以输入搜索查询。
首先,让我们更新loader函数。目标是更新loader函数,使其只获取通过请求 URL 提供的查询字符串匹配的支出:
-
在你的编辑器中打开
dashboard.expenses.tsx路由模块,检查模块的loader函数。 -
首先,将
request参数添加到loader函数的参数中:import type { URL object and get the q search parameter:`export async function loader({ request }: LoaderFunctionArgs) { q - 查询的简称。你可以在 MDN Web 文档中找到更多关于 URL 接口的信息:https://developer.mozilla.org/en-US/docs/Web/API/URL。
-
更新数据库查询,使其只返回标题包含搜索字符串的支出:
const expenses = await db.expense.findMany({ orderBy: { createdAt: 'desc', }, where: { title: { contains: searchString ? searchString : '', }, },});如果 URL 不包含查询字符串,我们针对空字符串进行搜索,这将匹配所有支出。在这种情况下,
loader函数的行为与之前相同。 -
通过在终端中执行
npm run dev来以开发模式运行 BeeRich,并导航到支出概览页面(localhost:3000/dashboard/expenses)。由于我们没有在 URL 中包含查询字符串,我们仍然返回完整的支出列表。
-
接下来,通过添加查询字符串(例如
localhost:3000/dashboard/expenses?q=Groceries)更新 URL 栏中的 URL,并刷新页面。现在应该显示一个过滤后的支出列表。
太好了!现在loader函数在存在q搜索参数时处理它,并返回一个过滤后的支出列表。接下来,让我们添加一个搜索输入字段,让用户可以搜索特定的支出。
通过表单提交更新搜索参数
接下来,我们将为用户提供一个搜索输入字段:
-
可选地,禁用 JavaScript 以确保基本实现可以在没有客户端 JavaScript 的情况下工作。
您可以在浏览器开发者工具中禁用 JavaScript 或通过从
root.tsx中移除Script组件来实现。 -
从
@remix-run/react中导入 Remix 的Form组件:import { useNavigation, Outlet, useLoaderData, useParams, Input component:import { Input } from '~/components/forms';
-
接下来,使用
Form和Input组件在 所有支出 屏幕阅读器标题和无序列表支出之间实现一个搜索输入字段:<h2 className="sr-only">All expenses</h2><Form q search parameter. For this, we set the form method to GET to perform an HTTP GET request.Conveniently, by default, a form submission appends the form data as search parameters to the request URL. We add the `name` attribute to the input field as only named input fields are part of the submission.We still have one problem to solve: since the search form is rendered on a layout parent route, it is visible on several pages. By default, the form submits and navigates to `/dashboard/expenses`. However, we would like the user to remain on their current page.Since we are not targeting a specific `action` function, we can point the form action to the current URL path. This ensures that a form submission does not redirect users away from their current page. -
从 Remix 中导入
useLocation钩子以访问当前 URL 路径:import { Form, Outlet, useLoaderData, useLocation, useNavigation, useParams } from '@remix-run/react'; -
在路由组件的功能体中访问位置:
const location = useLocation(); -
使用当前位置的路径动态设置表单的动作:
<Form method="GET" action={location.pathname}>提交现在会创建一个对当前页面的 GET 请求,并带有更新后的搜索参数以过滤支出列表。
-
通过输入搜索查询并按 Enter 键提交表单来尝试新的搜索输入字段。
-
注意,在每次完整页面重新加载后,搜索输入字段都是空的,即使设置了
q搜索参数。 -
从 Remix 中导入
useSearchParams钩子:import { Form, Outlet, useLoaderData, useLocation, useNavigation, useParams, q search parameter in the route component:const [searchParams] = useSearchParams();const searchQuery = searchParams.get('q') || '';
Note that the `searchParams` object implements the web's `URLSearchParams` interface that we also use in the `loader` function when accessing the URL's `searchParams`. -
将
searchQuery值用作输入字段的defaultValue属性:<Input name="q" type="search" label="Search by title" searchQuery by default, even during server-side rendering:

图 8.1 – 过滤支出列表的截图
太棒了!在输入搜索查询后按 Enter 键会提交表单并更新 URL,使其包含搜索查询。然后 loader 函数返回更新后的过滤支出列表。注意,我们没有使用任何 React 状态来实现此功能,并且始终如一,搜索功能在没有 JavaScript 的情况下也能工作。
将 UI 映射到 URL
搜索参数相较于 React 状态的优势在于它们可以通过读取请求 URL 在服务器上访问。搜索参数在完整页面重新加载时保持不变,并且与浏览器的后退和前进按钮一起工作。此外,搜索参数创建的 URL 变体可以被 CDNs 和浏览器缓存。
为收入概览路由实现相同的行为。更新 dashboard.income.tsx 路由模块的 loader 函数并实现搜索表单以查询发票。一旦更新了收入路由,我们就可以通过自定义 JavaScript 来增强体验。
程序化提交表单
目前,用户需要按 Enter 键来触发新的搜索。让我们添加一个防抖搜索,当用户更改输入字段中的值时自动提交表单:
-
首先,从 Remix 中导入
useSubmit:import { Form, Outlet, useLoaderData, useLocation, useNavigation, useParams, useSearchParams, useSubmit hook lets us submit forms programmatically. You might remember that useFetcher also offers a submit function. Both useSubmit and useFetcher().submit allow us to submit forms programmatically.Fetcher submissions behave like `fetch` requests and do not trigger global transitions in Remix. They don’t affect the global `useNavigation` state or initiate page navigations. The `useSubmit` hook mimics Remix’s `Form` behavior.In our case, we use Remix’s `Form` component for the search and want to retrigger the `/dashboard/expenses` route module’s `loader` function so that the loader data updates. For such cases, we want to use the `useSubmit` hook. -
在路由模块的组件体中,通过调用
useSubmit创建一个新的提交函数:const submit = useSubmit(); -
将以下更改事件处理程序添加到搜索输入:
Replace code example with just this line:onChange={(e) => submit(e.target.form)}在更改时,我们通过
submit函数程序化地提交表单。我们将submit传递 HTML 表单元素,从事件的目标对象中访问它。 -
尝试当前实现,并在搜索输入字段中输入一些内容。
你可能会注意到,我们目前为每个更改事件提交一个新的表单。这并不高效。相反,我们应该延迟提交,直到用户完成输入。这种延迟函数调用的方法称为防抖。
-
将
Input替换为SearchInput:import { SearchInput component adds debouncing with a 500-millisecond delay. Refer to the implementation in /app/components/forms.tsx. -
现在,更新 JSX 以渲染
SearchInput组件而不是Input组件:<SearchInput component uses Remix's useSubmit hook to programmatically submit the form that it is embedded in after a timeout once the user finishes typing. -
由于提交现在是在
SearchInput组件内部处理的,因此请从dashboard.expenses.tsx路由模块中移除useSubmit钩子。
你现在知道了在 Remix 中提交数据的三种方式:Form组件、useFetcher钩子和useSubmit钩子。这引发了一个问题:何时最好使用哪种实用工具。
何时使用 Remix 数据获取原语
使用Form组件处理页面上的主要交互。Form组件是实现 Remix 中表单交互的最直接方式。对于所有简单用例,请坚持使用Form组件。
当你想以编程方式提交Form组件(例如,在更改时)时,请使用useSubmit钩子。你可以将useSubmit添加到Form实现中,以逐步增强体验。
记住,一次只能有一个活动导航。使用useFetcher钩子实现应支持并发提交的表单列表或辅助用户交互。辅助交互通常不旨在触发页面导航,并且应该有权访问隔离的导航状态和操作数据。每当你想以编程方式触发useFetcher钩子的Form组件时,可以使用useFetcher.load和useFetcher.submit。
在本节中,你学习了如何使用 URL 处理应用程序状态。你还了解到,可以通过将表单方法设置为"GET"来执行 GET 请求。最后,你练习了如何使用useSubmit以编程方式提交表单。
确保更新收入路由以练习本节学到的内容。一旦完成,我们就可以开始调查如何使用 cookie 处理用户会话。
使用 cookie 创建用户会话
会话在多个请求中维护用户与 Web 应用程序交互的状态。会话跟踪诸如用户认证凭据、购物车内容、颜色方案偏好以及其他特定于用户的数据。在本节中,我们将使用 Remix 的会话 cookie 助手在 BeeRich 中创建登录和注册流程。
管理会话的一种方式是通过 cookie。cookie 包含小数据块,并附加到文档和 fetch 请求中,这使得它们成为处理用户会话、个性化跟踪的绝佳方式。此外,cookie 可以被加密,以安全地携带用户凭据,而无需客户端访问。
Cookie 是 HTTP 协议的一部分,它使得在无状态的 HTTP 协议中持久化信息成为可能。与用户可见的 URL 搜索参数不同,cookie 数据可以被加密,并且只能在服务器上访问。搜索参数非常适合存储与应用程序状态相关但不特定于用户的会话。Cookie 是用于用户身份验证和存储少量私有会话数据的理想选择。
一个网络服务器可以通过在 HTTP 响应中设置 Set-Cookie 标头来将 cookie 添加到当前会话中。一旦设置了 cookie,浏览器就会根据在 cookie 设置期间指定的生命周期,使用 Cookie 标头将 cookie 附加到所有后续请求中。
Remix 提供了两种不同的抽象来处理 cookie:
-
createCookie用于读取和写入 cookie -
createCookieSessionStorage用于实现使用 cookie 的会话存储
在本章中,我们将使用 Remix 的 createCookieSessionStorage 函数,因为我们的目标是实现用户会话进行身份验证和授权。我们将在 第十五章 高级会话管理 中查看 createCookie 辅助函数,以持久化访客跟踪数据。
使用 Remix 的会话辅助函数
让我们在 BeeRich 中实现注册和登录页面:
-
首先,遵循本章节文件夹中 GitHub 上的
README.md文件来为 BeeRich 准备本节。 -
在您按照
README.md文件中的设置指南完成设置后,打开编辑器中的modules/session/session.server.ts文件。 -
接下来,从 Remix 中导入
createCookieSessionStorage和redirect辅助函数:import { createCookieSessionStorage helper function builds on top of Remix’s cookie helpers to store session data in a cookie.Refer to the Remix documentation for alternative session helpers. For example, `createMemorySessionStorage` manages session data in the server’s memory; `createSessionStorage` is more generic and allows us to retrieve session data from a custom storage implementation while storing only the session identifier in a cookie. -
现在,在已经存在的
registerUser和loginUser函数下方添加以下代码:const sessionSecret = process.env.createCookieSessionStorage helper function to create a session storage object. The object contains three functions to help us manage the lifecycle of our user sessions.The `createCookieSessionStorage` function expects a cookie configuration object to set the session cookie’s lifetime (`maxAge`), its access rules (`secure`, `sameSite`, `path`, and `httpOnly`), and signing secrets (`secrets`). You can refer to the Remix documentation for more information about the configuration options.We set the cookie to expire after 30 days, meaning it will be automatically deleted after that period. By setting `httpOnly` to `true`, we ensure that the cookie cannot be read by the client, enhancing security. We also use secrets to sign the cookie, adding an extra layer of verification. -
在项目根目录中的
.env文件中打开并添加一个SESSION_SECRET环境变量:SESSION_SECRET="[A secret string]"HTTP cookie 签名涉及使用只有服务器知道的秘密密钥对 cookie 添加加密签名。当客户端在未来的请求中发送此已签名的 cookie 时,服务器使用秘密密钥来验证 cookie 是否被篡改。这增加了额外的安全层。
我们在启动服务器环境时读取环境变量。确保您重启开发服务器,以防它目前正在运行,以确保新的环境变量被拾取。
-
最后,将以下辅助函数添加到
session.server.ts文件中:export async function createUserSession(user: User, headers = new Headers()) { const session = await createUserSession to initiate the session cookie after successful registration or login.`createUserSession` expects a user object and an optional `headers` parameter. The function then calls `getSession` to create a new session object for the current user. We add `userId` to the session object and use `commitSession` to parse the object into a cookie value. We set the cookie to the `Headers` object.Note that cookies can only store a small amount of data (a few KB). Hence, we only store `userId`. When storing more data, it might make sense to store the session data in a database and only store a session identifier in the session cookie (for example, using the `createSessionStorage` helper).
现在我们能够创建新的用户会话了,让我们实现一个注册流程来注册新用户。
添加用户注册流程
在本节中,我们将应用到目前为止所学的内容来实现注册表单和相关 action 函数。在阅读解决方案之前,我鼓励您自己尝试一下。首先,更新注册路由模块组件。然后,添加一个 action 函数并解析表单数据。
现在,让我们一步一步地实现:
-
打开
_layout.signup.tsx路由模块并更新路由模块组件:import { useNavigation } from '@remix-run/react';import { Button } from '~/components/buttons';import { Card } from '~/components/containers';import { Form, Input } from '~/components/forms';import { H1 } from '~/components/headings';export default function Component() { const navigation = useNavigation(); const isSubmitting = navigation.state !== 'idle' && navigation.formAction === '/signup'; return ( <Card> <Form method="POST" action="/signup"> <H1>Sign Up</H1> <Input label="Name:" name="name" required /> <Input label="Email:" name="email" type="email" required /> <Input label="Password:" name="password" type="password" required /> <Button disabled={isSubmitting} type="submit" isPrimary> {isSubmitting ? 'Signing you up...' : 'Sign up!'} </Button> </Form> </Card> );}注意,我们使用了一些可重用的组件来添加我们的自定义样式。另外,注意我们设置了表单方法为 POST。注册流程会修改数据,不能是 GET 请求。最后,我们再次利用
useNavigation钩子来在表单提交时添加挂起指示器。 -
接下来,添加一个
action函数来处理注册表单提交:import type { ActionFunctionArgs } from '@remix-run/node';import { json, redirect } from '@remix-run/node';import { createUserSession, registerUser } from '~/modules/session/session.server';export async function action({ request }: ActionFunctionArgs) { string. Once the data has been validated, we call the `registerUser` function to create a new user object or throw an error if the user already exists in the database. If the creation is successful, we call `createUserSession` to add the session cookie to the response headers. Otherwise, we return an error response.On success, we redirect the user to the dashboard. On error, we return the error message as action data. Next, let's display the error message to the users. -
从 Remix 导入
useActionData:import { useActionData, useNavigation } from '@remix-run/react'; -
在路由组件中访问错误消息操作数据:
const actionData = useActionData<typeof action>(); -
导入我们的样式内联错误组件:
import { InlineError } from '~/components/texts'; -
在提交按钮下方渲染
InlineError组件以显示任何错误消息:<InlineError aria-live="assertive">{npm run dev and visiting the /signup page.After form submission, you should be redirected to the dashboard. Great! But what if we want to log out? For now, we can clear the cookie using the developer tools. -
在你的浏览器窗口中打开开发者工具并导航到应用程序标签页:

图 8.2 – 开发者工具的应用程序标签页
在httpOnly标志下。
右键点击 cookie 并选择删除。
这允许我们在实现登出流程之前对注册表单进行更多操作。
-
再次尝试使用注册流程中使用的电子邮件地址进行注册。你现在应该看到一个内联错误。太棒了!
随意花更多时间在这一部分,并通过
action函数和session.server.ts文件中的代码流来调查代码。使用debugger或console.log语句来检查注册过程中发生的情况。
一旦你对添加的代码感到满意,使用开发者工具删除 cookie。这将使我们能够实现和测试登录流程。
添加用户登录流程
将注册流程的路由模块组件复制并粘贴,看看你是否可以更新它使其适用于登录页面。也许你可以尝试对action函数做同样的操作。
一旦你尝试过它,让我们一起来查看实现过程:
-
将以下代码添加到更新
_layout.login.tsx路由组件:import { useActionData, useNavigation } from '@remix-run/react';import { Button } from '~/components/buttons';import { Card } from '~/components/containers';import { Form, Input } from '~/components/forms';import { H1 } from '~/components/headings';import { InlineError } from '~/components/texts';export default function Component() { const navigation = useNavigation(); const isSubmitting = navigation.state !== 'idle' && navigation.formAction === '/login'; const actionData = useActionData<typeof action>(); return ( <Card> <Form method="POST" action="/login"> <H1>Log In</H1> <Input label="Email:" name="email" type="email" required /> <Input label="Password:" name="password" type="password" required /> <Button disabled={isSubmitting} type="submit" isPrimary> {isSubmitting ? 'Logging you in...' : 'Log in!'} </Button> <InlineError aria-live="assertive">{actionData?.error && actionData.error}</InlineError> </Form> </Card> );}登录和注册表单几乎相同;只是输入字段的数目不同。
-
接下来,添加
action函数来处理登录表单提交:import type { ActionFunctionArgs } from '@remix-run/node';import { json, redirect } from '@remix-run/node';import { createUserSession, loginUser } from '~/modules/session/session.server';export async function action({ request }: ActionFunctionArgs) { loginUser helper function. If the user can be found in the database and the password matches, we create the session cookie and add it to the response. Otherwise, we use the error message to return a JSON response. -
使用你在注册流程中使用的电子邮件地址尝试登录流程。你现在应该能够注册并登录到 BeeRich。
到目前为止,我们已经利用会话辅助函数在成功注册或登录后创建用户会话。通过检查开发者工具,我们确保浏览器注册了 cookie。接下来,我们将添加一个登出流程来删除会话 cookie。
登出时删除会话
删除会话 cookie 非常简单。在session.server.ts中,我们可以访问三个会话生命周期方法:getSession、commitSession和destroySession。按照以下步骤操作:
-
让我们在
session.server.ts中添加一个辅助函数来从传入的请求中获取当前用户会话:function getUserSession(request: Request) { return getUserSession function is a helper that we will utilize to access the current session object from the cookie header of a request.Remix’s `getSession` function parses the cookie header and returns a session object we can use to access the stored data. Once we have the session object, we can read from it or destroy it using the `destroySession` life cycle method. -
在
session.server.ts中添加一个登出函数:export async function logout(logout function parses the current session object from the incoming request and then redirects the user to the login page. The returned response uses the _layout.logout.tsx route module and add the following code:import type { ActionFunctionArgs } from '@remix-run/node';import { redirect } from '@remix-run/node';import { logout } from '~/modules/session/session.server';export function action({ request }: ActionFunctionArgs) { action function that executes the logout function. This removes the session cookie and redirects the user to login. The logout route module also has a loader function to redirect all traffic to login. This is convenient if a user accidentally navigates to the logout page.Remember that Remix refetches all loader data from all activeloaderfunctions after anactionfunction executes. Sincelogoutmutates the server state (the user session), we use anactionfunction and not aloaderfunction to implementlogout. After logging out, we want to remove all user-specific data from the page by revalidating all loader data.Note that the logout route module does not export a route component. Thus, it is not a document but a resource route. -
打开
dashboard.tsx路由模块,找到导航栏中的当前注销链接:<RemixLink to="/404">Log out</RemixLink>目前,注销按钮是一个占位符链接到一个不存在的页面。
-
从 Remix 导入
Form并替换代码以创建一个提交 POST 请求到注销路由的表单:<Form method="POST" action="/logout"> <button type="submit">Log out</button></Form>点击注销链接会提交一个表单到注销动作函数,将用户重定向到登录并移除当前用户会话 cookie。就这样,我们在 BeeRich 中成功实现了注销流程。
在本节中,我们练习了使用 Remix 的会话 cookie 助手创建和删除会话 cookie。接下来,我们将从会话 cookie 中读取以验证用户,并从我们的loader函数中返回特定于用户的数据。
验证对用户数据的访问
我们可以在loader和action函数中访问 cookie,因为 cookie 被附加到每个发送到 Web 服务器的 HTTP 请求中。这使得 cookie 成为管理会话的绝佳工具。在本节中,我们将从会话 cookie 中读取以验证用户并查询特定于用户的数据。
在服务器上访问 cookie 数据
一旦我们将 cookie 附加到响应中,我们就可以在随后的每个对服务器的请求中访问 cookie 数据。这使我们能够构建个性化的基于会话的用户体验。让我们添加一些助手函数来简化这项任务:
-
将以下代码添加到
session.server.ts文件中:export async function getUserId(request: Request) { createUserSession to write userId to the session cookie. The `getUserId` function expects a `Request` object and returns `userId` from the session cookie if it’s present, or null otherwise. We use `getUserSession` to get the current session object from the cookie header of the `Request` object.We also add a `getUser` function that uses the `getUserId` function under the hood and returns the user object from the database. To avoid exposing the user’s password hash, we ensure not to query the password field from the database.Let’s see how we can use `getUserId` to check whether a user is logged in. -
将以下
loader函数添加到登录和注册路由模块中:import type { LoaderFunctionArgs } from '@remix-run/node';import { redirect } from '@remix-run/node';import { userId exists, then we can be sure that the user has already been authenticated. In this case, we redirect to the dashboard. Otherwise, we show the login or signup page.Note that we return an empty object for our base case in the `loader` function. This is because a `loader` function cannot return `undefined`.
在本节中,我们实现了getUserId和getUser助手函数。我们使用getUserId来检查用户是否已登录。接下来,我们将使用getUser来获取当前登录用户的用户对象(如果有的话)。
在客户端处理用户数据
在本节中,我们将使用getUser来处理当前登录用户的用户对象:
-
首先,在 root.tsx 中导入
LoaderFunctionArgs和getUser。import type { LinksFunction, loader export to root.tsx, querying and returning the current user object:export async function loader({ request }: LoaderFunctionArgs) { getUser返回一个不带密码属性的用户对象。这很重要,因为我们把用户对象转发到客户端。我们不得向客户端应用程序泄露用户或应用程序的秘密。我们现在可以使用useLoaderData在root.tsx中访问用户对象。然而,我们可能希望在整个应用程序中都能访问用户对象。让我们看看我们如何使用 Remix 来实现这一点。 -
在
app/modules/session中创建一个session.ts文件。我们计划创建一个小的 React 钩子来访问 React 应用程序中的根
loader用户数据。由于我们还想在客户端的 React 应用程序中访问该钩子,因此我们不应将钩子放在session.server.ts文件中,因为它只包含在服务器包中。 -
将以下
useUser钩子添加到session.ts:import type { User } from '@prisma/client';import { useRouteLoaderData hook to access the root loader data user object. We also import the type of the root loader function for type inference. We further deserialize the user object to match the User type from @prisma/client without the password property.Note that Remix assigns every route module a unique identifier. The ID of the root route module is "root". We must pass `useRouteLoaderData` the ID of the route module of which we want to access the loader data. Remix's route module IDs match the route file name relative to the app folder. You can find more information in the Remix documentation: [`remix.run/docs/en/2/hooks/use-route-loader-data`](https://remix.run/docs/en/2/hooks/use-route-loader-data).We can now call `useUser` throughout our Remix application to access the current user object. You can use the same pattern for any global application state.Let’s try out the hook in action! -
在
_layout.tsx路由组件中使用useUser钩子:const user = useUser(); -
在导航的无序列表中,将当前的 登录 和 注册 列表项替换为以下代码:
{user ? ( <li className="ml-auto"> <NavLink to="/dashboard" prefetch="intent"> Dashboard </NavLink> </li>) : ( <> <li className="ml-auto"> <NavLink to="/login" prefetch="intent"> Log in </NavLink> </li> <li> <NavLink to="/signup" prefetch="intent"> Sign up </NavLink> </li> </>)}如果没有用户登录或用户已登录,我们现在将条件性地渲染 登录 和 注册 链接,如果用户已登录,则显示 仪表板 链接。
注意,useUser 返回的用户对象也可以是 null。如果存在会话,我们尝试查询用户对象,否则返回 null。然而,有时我们必须确保用户已登录。我们将在下一节中查看如何强制执行身份验证。
在服务器上强制执行身份验证
BeeRich 的仪表板路由仅适用于已登录用户。你能想到一种强制检查是否存在会话 cookie 的方法吗?
让我们实现一些身份验证逻辑,如果不存在用户会话,则将用户重定向到登录页面:
-
在
session.server.tsx中创建另一个辅助函数:export async function requireUserId(request: Request) { const session = await getUserSession(request); const userId = session.get('userId');requireUserId looks similar to getUserId, but this time, we throw a redirect Response if no user session was found.Note that throwing a redirect `Response` does not trigger the `ErrorBoundary` component. Redirects are a special case where we leave the current route module and navigate to another one instead. The final `Response` of a redirect is the document response of the redirected route module. -
接下来,将以下行添加到所有
loader和action函数的顶部,在仪表板路由模块中:await requireUserId(request);requireUserId调用确保如果用户未进行身份验证,则将用户重定向到登录页面。由于
loader函数并行运行,而action函数通过互联网公开 API 端点,我们必须将身份验证检查添加到每个需要身份验证的loader和action函数中。我们还必须确保我们只检索与当前
userId相关的数据。用户不应能够查看其他用户的费用和发票。让我们进一步更新我们的loader和action函数。 -
打开
dashboard.tsx路由模块并更新loader函数,以便要求用户会话并使用userId查询特定于用户的费用和收入对象:import type { userId, we ensure that a session cookie is present. If no session cookie is present, requireUserId will throw redirect to the login route.We also filter our database queries for user-specific content. We now query for the last expense and invoice objects created by the logged-in user. -
打开
dashboard.expenses.tsx路由模块并更新loader函数,以便检查现有的用户会话:import { userId to only filter for user-specific data. -
打开
dashboard.expenses._index.tsx路由模块并更新action函数:import { requireUserId } from '~/modules/session/session.server';export async function action({ request }: ActionFunctionArgs) { userId parameter that was retrieved from the session cookie. -
打开
dashboard.expenses.$id.tsx路由模块并更新loader函数:export async function loader({ userId cookie value. This ensures that a user can’t visit different expense detail pages and view the content of other users. -
更新
dashboard.expenses.$id.tsx中的deleteExpense处理函数:async function deleteExpense(request: Request, id: string, id and userId. This ensures that a user can only ever delete an expense that was also created by that user. -
更新
dashboard.expenses.$id.tsx中的updateExpense处理函数:async function updateExpense(formData: FormData, id: string, action function in dashboard.expenses.$id.tsx:导出异步函数 action({ params, request }: ActionFunctionArgs) { requireUserId to enforce an existing user session. Then, we pass userId to the deleteExpense and updateExpense handler functions.That was quite a bit of code to go through, but by making some minor changes here and there, we have fully authenticated our application’s HTTP endpoints and ensured that only authenticated users can visit our dashboard pages.
-
现在是玩 BeeRich 的好时机。看看你是否可以在不先登录的情况下访问任何仪表板路由。
尝试通过在几个标签页中操作来破解 BeeRich。在第一个标签页中打开费用创建表单,并在第二个标签页中注销。你还能成功创建新的费用吗?注意 cookies 在不同标签页之间的附加和更新情况。
保护 loader 和 action 函数
Remix 的 loader 函数并行运行以提高执行速度。然而,它们的并发性也决定了我们必须保护每个 loader 函数。loader 和 action 函数都可以通过互联网访问,必须像 API 端点一样处理和保护。
我们仍然需要更新收入路由。这将是一个很好的实践,以确保你理解如何验证 loader 和 action 函数。花些时间仔细检查收入路由中的每个 loader 和 action 函数,以练习你在本章中学到的内容。
在本节中,你学习了如何在 Remix 中从会话 cookie 中访问状态,以及如何使用会话 cookie 在 loader 和 action 函数中验证用户。
摘要
在本章中,你学习了 Remix 中的会话和状态管理。首先,你学习了如何使用 URL 搜索参数通过 Remix 的 Form 组件和 useSearchParams 钩子来持久化应用程序状态。URL 经常是我们处理应用程序状态所需的一切。
你还练习了使用 useSubmit 以编程方式提交表单,并更多地了解了 Remix 的不同突变工具。我们得出结论,我们使用 Form 组件和 useSubmit 钩子来处理页面上的主要操作;useFetcher 用于支持具有隔离提交状态的并发提交。
接下来,你了解到 cookies 是 HTTP 协议的一部分,可以用于在页面转换之间持久化状态。Cookies 是会话管理的一个很好的工具。Remix 提供了用于处理 cookies 和会话的辅助函数。Remix 的会话原语允许我们使用不同的策略来管理会话,例如在内存、文件、数据库或 cookies 中存储会话数据。
我们利用 Remix 的原语在 BeeRich 中实现了一个包含登录、注册和注销功能的身份验证流程。你学习了如何验证用户并使用会话 cookie 查询特定用户的内容。
在注册和登录过程中,我们创建和获取用户对象,并将userId写入 Remix 的会话对象。然后,使用loader和action函数将该对象序列化为字符串,并添加到 HTTP 响应的 cookie 中,以验证用户会话和查询特定用户的数据。
你还学习了如何在你的应用程序中全局访问加载器数据,使用 Remix 的useRouteLoaderData钩子。你练习了创建一个小的自定义钩子,以抽象从根loader访问用户对象。
在阅读本章之后,你将理解action函数是独立的端点,而loader函数是并行运行的。最终,我们必须在每个受限的loader和action函数中验证用户,以防止未经授权的访问。
在下一章中,你将学习如何在 Remix 中处理静态资源和文件。
进一步阅读
有关 URL 搜索参数和URLSearchParams接口的更多信息,请参阅 MDN Web Docs:developer.mozilla.org/en-US/docs/Web/API/URLSearchParams。
如果你想了解更多关于 HTTP cookie 的信息,请参考 MDN Web Docs:developer.mozilla.org/en-US/docs/Web/HTTP/Cookies或Headers接口:developer.mozilla.org/en-US/docs/Web/API/Headers。
通过阅读 MDN Web Docs 来刷新你对 HTML 表单的知识:developer.mozilla.org/en-US/docs/Web/HTML/Element/form。
Remix 为处理会话提供了几个原语。你可以在 Remix 文档中找到更多信息:remix.run/docs/en/2/utils/sessions。
Remix 还提供了用于处理 cookie 的底层原语:remix.run/docs/en/2/utils/cookies。
第九章:资产和元数据管理
到目前为止,我们已经练习了在 Remix 中处理路由、数据加载和变更、错误处理以及状态和会话管理。然而,构建 Web 应用还涉及到管理静态资产,以确保用户体验的流畅和高效。
在本章中,我们将学习如何在 Remix 中管理静态资产和元标签。本章分为三个部分:
-
在 Remix 中使用元标签
-
处理字体、图像、样式表和其他资产
-
使用加载函数暴露资产
首先,我们将使用 Remix 的meta导出功能,根据加载数据创建动态元标签。接下来,我们将研究如何在 Remix 中暴露静态资产。我们将创建一个robots.txt文件,添加自定义字体,并尝试嵌套样式表。之后,我们将讨论在 Remix 中管理图像。最后,我们将看到如何在loader函数中动态创建资产。
阅读本章后,您将了解如何在 Remix 中处理元标签。您还将知道如何暴露和访问静态资产以及如何链接外部资源。最后,您将了解如何通过loader函数暴露动态资产。
技术要求
您可以在此处找到本章的代码:github.com/PacktPublishing/Full-Stack-Web-Development-with-Remix/tree/main/09-assets-and-meta-data-handling。您可以继续使用上一章的最终解决方案。本章不需要额外的设置步骤。
在 Remix 中使用元标签
元标签用于描述 HTML 文档的内容。它们对于搜索引擎优化(SEO)非常重要,并且被网络爬虫用来理解您网站的内容。元标签还用于配置浏览器行为、链接预览以及网站在书签列表和搜索结果中的外观。
例如,标题、描述和图像元标签用于链接预览和搜索页面,如 Google 的搜索结果。标题元标签还与 favicon 一起使用,以在书签列表中显示网站。
在本节中,您将学习如何向您的 Remix 应用程序添加元标签。
声明全局元标签
一个应用程序通常会在每个页面上包含一些全局元标签。由于 Remix 允许我们在 React 中管理完整的 HTML 文档,包括头部,我们可以在应用程序的根目录中内联全局元标签:
<head> <meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
查看位于root.tsx中的Document组件。注意,我们导出了两个全局元标签来设置应用程序的charSet属性和viewport元标签。和往常一样,您可以在 MDN Web Docs 中找到有关浏览器 API 和 Web 平台的信息:
-
viewport元标签:developer.mozilla.org/en-US/docs/Web/HTML/Viewport_meta_tag -
charSet属性:developer.mozilla.org/en-US/docs/Web/HTML/Element/meta#attributes
内容感知元标签,如title和description,必须为每个页面动态设置。在 Remix 中,我们可以使用meta导出将元标签注入到我们应用程序的头部。让我们看看它是如何工作的。
导出元函数
Remix 中的每个路由模块都可以导出一个meta函数。Remix 遵循路由层次结构以找到最近的meta导出,并将其注入到 HTML 文档的头部。让我们跳入我们的 BeeRich 应用程序的代码,并调查如何使用meta导出定义元标签:
-
打开
app/root.tsx文件并查找meta导出:export const meta: MetaFunction = () => { meta function returns a list of metadata objects. Currently, we only return a title metadata object. -
使用
npmrun dev命令在本地运行 BeeRich。 -
通过导航到
localhost:3000/在浏览器窗口中打开应用程序。 -
使用您浏览器的开发者工具检查 HTML。头部元素的内容应如下所示:
<head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> title element made it into the head of the HTML document. -
在
root.tsx中的meta函数返回值中添加一个描述元数据对象:export const meta: MetaFunction = () => { return [ { title: 'BeeRich' }, { meta export into the head of our app? The answer can be found in the root.tsx file. -
检查
root.tsx中的Document组件的 JSX:<head> <meta charSet="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> Meta component. Remix uses the Meta component to add the meta exports to our application. The Meta component receives the content of the closest meta export and injects its content into our React application.By default, the `Meta` component is rendered in the head of our Remix application. If we were to remove the `Meta` component, the `meta` exports would not end up in our document anymore.
接下来,让我们调查 Remix 如何管理嵌套的meta导出。
嵌套meta导出
在本节中,我们将向嵌套路由模块添加一些meta导出:
-
打开
_layout.login.tsx路由模块并添加以下代码:import type { ActionFunctionArgs, LoaderFunctionArgs, npm run dev in your terminal. -
通过导航到
localhost:3000/login打开登录页面并检查头部元素的内容。注意,在
root.tsx中定义的标题和描述被嵌套的meta导出覆盖。
在嵌套路由模块中使用meta导出来覆盖父级元标签
meta路由模块导出允许我们在路由层次结构的任何级别定义元标签。Remix 使用最近的meta函数返回值,并通过使用Meta组件将它们添加到文档的头部。嵌套meta导出替换父级meta导出。
Remix 最初在服务器上渲染。渲染的文档包括所有声明的元标签,并确保爬虫可以在不执行客户端 JavaScript 的情况下检查所有元标签。这对 SEO 非常有利。
通常,元标签的内容取决于动态数据。例如,您可能希望使用文章的标题和摘要作为标题和描述元标签。让我们看看我们如何在meta函数中访问加载器数据。
在元函数中使用加载器数据
meta函数在客户端和服务器上都会运行。在初始渲染时,meta函数在服务器端渲染期间被调用。对于所有后续的客户端导航,在从服务器获取加载器数据之后,meta函数在客户端执行。在这两种情况下,Remix 都将路由的加载器数据和所有父级加载器数据的哈希映射传递给meta函数。
在本节中,我们将向 BeeRich 仪表板的标题中添加当前用户的姓名,并探讨我们如何在 meta 函数中利用加载器数据:
-
在您的编辑器中打开
dashboard.tsx路由模块。 -
更新
dashboard.tsx中的loader函数以返回当前用户的姓名。import { requireUserId helper function was used to get userId from the session cookie and authenticate the user.Now, we replace the usage of `requireUserId` with `getUser` and `logout`. We use the `getUser` helper functions to query for the user object. We then check whether the user object exists; otherwise, we call `logout` to clear the session. Finally, we return the user name as part of the loader data. -
接下来,将以下
meta函数导出添加到路由模块中:import type { LoaderFunctionArgs, data property. Then, we read the username property to dynamically create a title tag.We also set the `robots` meta tag to `noindex` as the dashboard is hidden behind a login page. Web crawlers should not attempt to index our dashboard pages. All nested routes will inherit the `noindex` value if they don’t export a `meta` function themselves.
太好了!就这样,我们可以使用动态数据来创建元标签。然而,一个问题仍然存在:为什么我们在访问 username 之前使用 ? 操作符来检查(加载器)data 属性是否已定义?
注意,如果发生错误,meta 函数也会执行。这确保了即使在错误边界被渲染时,我们的元标签也会被添加。如果我们渲染错误边界,那么加载器数据将不可用。最终,在 meta 中,我们必须始终在访问其属性之前检查 data 属性是否已定义。
元函数在客户端、服务器和错误上运行
即使路由的 loader 函数抛出错误,meta 函数也会运行。因此,在访问它之前检查预期的加载器数据是否存在非常重要。此外,我们必须确保我们的 meta 函数可以在客户端和服务器上安全执行,因为它们在两个环境中都会运行。任何服务器端逻辑都必须在 loader 函数(在服务器上)中执行,并且任何所需的数据都应该通过加载器数据转发到 meta 函数。
网络爬虫使用元标签来理解您页面的内容。Remix 对嵌套路由模块中元标签管理的声明式方法确保了元标签和相关加载器数据的同位放置。然而,有时我们可以避免重新获取数据,如果该数据已经在另一个活动的 loader 中被获取。让我们看看那是什么样子。
在元函数中使用匹配数据
在 Remix 中,meta 函数会在所有加载器运行之后执行。这意味着我们可以访问所有当前活动的加载器数据。在本节中,我们将了解 Remix 的 matches 数组以及如何在 meta 中访问其他路由的加载器数据。
记住,我们已经在 root.tsx 的 loader 函数中获取了用户数据:
export async function loader({ request }: LoaderFunctionArgs) { const user = await getUser(request);
return { user };
}
在 dashboard.tsx 中不需要重新获取用户对象。我们可以优化我们的代码,并从根加载器数据中访问用户对象。这样,我们就可以避免在 dashboard.tsx 路由模块的 loader 函数中再次查询数据库:
-
首先,将
root.tsx中的根loader函数作为类型导入导入:import type { loader as rootLoader } from '~/root';我们将
loader函数作为类型导入导入,因为我们只使用它进行类型推断。请注意,我们必须将loader导入重命名为rootLoader以避免与dashboard.tsx路由模块的loader函数发生命名冲突。 -
接下来,更新
dashboard.tsx中的元函数,如下所示:export const meta: MetaFunction<typeof loader, root as the id parameter, of which the loader data is of the type returned by the rootLoader function.接下来,我们使用
matches参数来找到与root的id参数匹配的路由,并检索其(加载器)数据。matches数组包含当前匹配 URL 并在页面上处于活动状态的路线对象列表。Remix 为每个路由模块分配一个唯一的标识符。这些标识符基于路由模块的文件名,但最容易识别路由模块标识符的方法是在开发期间记录
matches数组。 -
现在我们正在使用另一个匹配路由的加载器数据,将
dashboard.tsx路由模块的loader函数中的更改恢复。将getUser函数调用替换为requireUserId。这样可以避免数据库查询并优化我们的代码。最后,从loader函数返回对象中移除我们添加的username参数。
在本节中,您学习了如何输入和使用 Remix 的 matches 数组,以及如何在 meta 函数中访问其他路由的加载器数据。接下来,让我们学习如何在 Remix 中处理静态资源。
处理字体、图像、样式表和其他资源
字体、图像和样式表是需要在构建网站时高效管理的静态资源示例。为了确保快速的用户体验,有必要优化、最小化和缓存这些资源。适当管理静态资源可以显著提高页面加载时间并提升整体用户体验。在本节中,您将学习如何在 Remix 中访问和管理静态资源。让我们首先回顾如何在 Remix 中访问一个简单的静态文件。
处理静态资源
我们可以在我们的网络服务器上托管静态资源,以便我们的客户端应用程序可以访问它们。正如我们之前所学的,Remix 不是一个网络服务器,而是一个 HTTP 请求处理器。因此,Remix 不提供内置的提供静态资产的方式。为公共资产设置访问权限是底层网络服务器的责任。
幸运的是,Remix 的入门模板遵循了提供 public 文件夹以通过网络公开静态资源的常见模式,并附带执行此操作所需的样板代码。例如,BeeRich – 通过使用 Express.js 适配器的 create-remix CLI 工具进行引导 – 包含一个引导的 server.js 文件,该文件设置 Express.js 以提供静态资源:
app.use(express.static('public', { maxAge: '1h' }));
到目前为止,BeeRich 应用程序的 public 文件夹包含一个 favicon.ico 文件,它作为我们网站的 favicon。让我们也添加一个 robots.txt 文件:
-
在
/public文件夹中创建一个robots.txt文件,并添加以下内容:User-agent: *Disallow: /dashboard/Allow: /loginAllow: /signupAllow: /$网络爬虫会从您的网站服务器请求
/robots.txt文件,以找到关于在您的网站上爬取哪些内容的指令。我们指定网络爬虫可以爬取我们应用程序的登录、注册和索引页面。然而,由于仪表板位于登录页面之后,我们阻止爬虫尝试爬取我们的任何仪表板页面。
-
现在,使用
npm run dev运行 BeeRich。 -
在您的浏览器中访问
localhost:3000/robots.txt。现在,您应该看到robots.txt文本文件的内容在您的浏览器中显示。
我们使用底层 Web 服务器来提供 Remix 应用程序的静态内容。同样,我们可以通过将字体、图片、样式表、第三方脚本和其他静态资产作为文件放置在public文件夹中来公开它们。
注意,一些资源,如图片,在您从浏览器访问它们之前应该进行优化。通常,图片和其他大型资源最好托管并优化在 CDN 和静态文件存储等专用服务中。
第三方资产,如第三方样式表和字体,也可以通过使用 HTML 链接标签来引用,这样我们就不必在public文件夹中自行管理它们。让我们学习如何在 Remix 中公开链接标签。
在 Remix 中管理链接
HTML link元素用于引用第三方资源,如样式表和字体。在 Remix 中,可以通过links路由模块导出声明链接。
Remix 提供了一个Links组件,可以将所有links返回值注入到 HTML 文档的头部。您可能已经在检查root.tsx中的Document组件时注意到了Links组件:
<head> <Meta />
<Links />
</head>
到目前为止,我们在root.tsx路由模块中只有一个是 BeeRich 的links导出,用于链接到我们的全局 Tailwind CSS 样式表:
import tailwindCSS from './styles/tailwind.css';export const links: LinksFunction = () => [{ rel: 'stylesheet', href: tailwindCSS }];
让我们添加一个来自 Google Fonts 的字体来练习使用links导出:
-
访问
fonts.google.com/specimen/Ubuntu以检查root.tsx中的links函数:export const links: LinksFunction = () => { rel: 'stylesheet', href: tailwindCSS },links function. -
接下来,更新项目根目录中的
tailwind.config.ts文件,将Ubuntu设置为默认的无衬线字体:npm run dev in your terminal. -
在浏览器窗口中打开 BeeRich。现在,新字体应该已经应用到页面上的所有文本。
-
打开开发者工具以检查应用程序的网络活动。
-
刷新页面以重置网络标签上显示的活动并查看显示的结果:

图 9.2 – 为登录路由加载嵌套样式表
注意嵌套样式表与我们的全局链接资源并行加载。Remix 将所有links函数的返回值合并在一起,并使用Links组件将内容注入 HTML 文档的头部。
我们的登录页面现在有了我们想要的令人质疑的外观。但如果我们导航到另一个 URL 会发生什么呢?
-
点击右上角的注册按钮以触发客户端从登录路由的过渡。
注意米色背景颜色消失了。
对于嵌套links导出,一旦我们离开相关的路由,Remix 就会卸载所有资源。这在处理嵌套样式表时特别有用。Remix 使得将样式表范围限定到特定的嵌套路由或路由子集变得容易。
由于 Remix 知道所有的links导出,它还可以在使用Link导出的prefetch属性时预取链接资源,我们将在下一节中对其进行回顾。
预取链接资源
让我们调查 Remix 如何预取链接资源:
-
在您的编辑器中打开
_layout.tsx路由模块。 -
将
prefetch属性添加到路由组件中渲染的所有NavLink组件,并将其值设置为"intent":<NavLink to="/" _layout.tsx route module component for readability.As discussed in *Chapter 6*, *Enhancing the User Experience*, we can add the `prefetch` property to Remix’s `Link` and `NavLink` components to prefetch the content of the new matching route modules. Setting `prefetch` to `intent` prefetches the content of all newly matching route modules on hover or focus. -
现在,请在您的浏览器窗口中访问注册页面 (
localhost:3000/signup)。 -
打开 网络 选项卡并清除任何已记录的条目以获得更好的可见性。
-
接下来,将鼠标悬停在或聚焦于导航栏中的 登录 锚点标签上,并检查 网络 选项卡:

图 9.3 – 预取链接资源
注意,Remix 会与加载器数据一起预取链接资源以及登录路由的 JavaScript 模块。根据请求瀑布图,我们可以看出 Remix 首先获取加载器数据和 JavaScript 模块,然后再预取链接资源。
Remix 必须首先获取新路由的加载器数据和 JavaScript 包,以便知道要获取哪些链接。一旦获取了这些信息,Remix 就会并行预取所有链接资源。
Remix 允许我们在嵌套路由模块中声明元标签和链接。当使用 links 导出时,Remix 通过 prefetch 属性使我们能够与加载器数据和路由的 JavaScript 模块一起预取链接资源。
在转换过程中,一旦卸载了相关路由,Remix 也会卸载所有外部资源。这确保了作用域样式表和其他资源不会影响其他路由。
接下来,让我们回顾一下在 Remix 中处理图像的一些技巧。
在 Remix 中处理图像
遵循最佳实践以向用户提供性能最优异的图像并非易事。我们需要使用 webp 等网络友好格式,但同时也需要为不支持它们的浏览器提供回退方案。除此之外,我们还需要为各种设备屏幕提供不同大小的图像。这就是为什么使用专门的服务来处理图像通常比在您自己的 Web 服务器上托管它们更好。
与其他一些框架不同,Remix 没有内置专门用于处理图像的工具或功能。您可以像其他任何静态文件一样将图像放在公共文件夹中。但由于图像需要优化,通常最好使用专门的服务来管理和交付它们。
虽然图像优化不是本书的重点,但它仍然是一个重要的考虑因素。为了帮助您在 Remix 中开始图像优化,我们建议查看开源的 unpic-img 项目。unpic-img 提供了一个可以与几个流行的 CDN 配置的最小 React 组件。要开始,请访问 GitHub 上的项目 github.com/ascorbic/unpic-img。
在本节中,您学习了如何在 Remix 中公开静态资产以及如何处理链接元素。您练习了使用 links 函数声明外部资源。我们还尝试了嵌套样式表,并讨论了图像优化的重要性。
Remix 还提供了一种通过资源路由中的loader函数提供资产的方法。在下一节中,我们将使用我们的robots.txt文件作为如何使用资源路由和loader函数来公开静态资产的示例。
使用加载器函数公开资产
在本章的早期部分,我们创建了一个robots.txt文件,并通过将其放入public文件夹来公开它。然而,我们也可以在资源路由中使用loader函数来公开资产。这在我们需要即时动态创建这些资产或管理用户访问时特别有用。要开始,请按照以下步骤操作:
-
从公共文件夹中删除现有的
robots.txt文件,否则它将覆盖我们的 API 路由。 -
在路由文件夹中创建一个新的
robots[.txt].tsx文件。方括号使我们能够避开路由名称的一部分。我们不是创建一个
.txt文件,而是创建一个.tsx文件,但确保路由与robots.txt路径匹配。 -
将以下内容添加到新创建的资源路由中:
const textContent = `User-agent: *Disallow: /dashboard/Allow: /loginAllow: /signupAllow: /$`;export function loader() { return new Response(textContent, { headers: { 'Content-Type': 'text/plain' } });}
Remix 允许我们在loader函数中返回任何类型的 HTTP 响应。任何没有路由组件的路由都成为可以接收 HTTP GET 请求的资源路由。robots[.txt].tsx中的loader函数响应对/robots.txt的传入 GET 请求,并返回具有指定文本内容的文本文件。
资源路由和Response API 是强大的工具,允许我们即时生成 PDF、图像、文本内容或 JSON 数据。括号注释(例如,[.txt])用于避开路由名称的一部分。
使用loader函数生成资产允许我们在传入的请求上运行动态计算。例如,我们可以验证用户会话或动态生成资产。我们的robots.txt文件目前是静态的,不需要额外的计算。在这种情况下,将文件存储在public文件夹中就足够了。
摘要
在本章中,您学习了如何在 Remix 中处理静态资产和元标签。
首先,我们向您介绍了meta路由模块导出。您了解到 Remix 通过使用Meta组件将meta函数的返回值注入 HTML 元素的头中。Remix 将始终使用路由层次结构中最接近的meta函数导出,并忽略所有其他更高层次的路由中的meta函数导出。
您还了解到 Remix 在客户端和服务器上都会运行meta函数。Remix 传递meta函数,一个可以用来访问路由加载器数据的data属性。在跟随本章之后,您应该理解如果loader函数抛出错误,meta函数的data属性可能未定义。因此,在meta函数中仅条件性地访问加载器数据是很重要的。
您还练习了输入matches参数,并学习了如何在meta函数中访问其他匹配的路由数据。
接下来,你学习了如何处理静态资源。现在你明白静态资源是由底层网络服务器提供的,而不是由 Remix 直接提供。Remix 的启动模板设置了一个 public 文件夹和必要的服务器代码。
你还了解到了 links 路由模块导出。现在你知道,当在 Link 和 NavLink 组件上使用预取属性时,Remix 会预取链接资源。你还练习了通过声明嵌套的 links 导出创建路由作用域的 CSS 样式表。
在阅读这一章后,你了解了图像优化的重要性以及你可以从哪里开始。你明白在提供之前必须对图像进行优化。你还知道可以使用 CDN 和其他服务来为我们处理图像优化。
最后,你了解到你可以使用资源路由来提供静态资源。我们使用了方括号符号来转义路由名称的部分(例如,[.txt])。有了这个,我们创建了一个匹配 /robots.txt 路径的资源路由,并实现了一个返回文本文件响应的 loader 函数。
在下一章中,我们将向 BeeRich 添加文件上传功能,并使用资源路由来管理用户对用户文件的访问。这将结束本书的第二部分,并使我们能够启动第三部分的高级主题。
进一步阅读
你可以在 MDN Web Docs 中找到更多关于元标签的信息:developer.mozilla.org/en-US/docs/Web/HTML/Element/meta。
你可以在 Remix 文档中了解更多关于 Remix 的路由模块元导出的信息:remix.run/docs/en/2.0.0/route/meta。
你可以在 Google 文档中了解更多关于 robots.txt 文件的信息:developers.google.com/search/docs/crawling-indexing/robots/intro。
如果你想要了解更多关于链接标签的信息,请查看 MDN Web Docs:developer.mozilla.org/en-US/docs/Web/HTML/Element/link。
你可以在 Remix 文档中找到更多关于在 Remix 中处理样式的信息:remix.run/docs/en/2/styling/css。
你可以在 Remix 文档中了解更多关于使用资源路由的信息:remix.run/docs/en/2/guides/resource-routes。
第十章:处理文件上传
在网络上上传文件是我们经常做的事情。网络提供了内置的文件上传支持。然而,将文件上传和处理作为表单提交的一部分仍然需要考虑一些额外的因素,我们将在本章中介绍。本章分为四个部分:
-
在 Remix 中使用多部分表单数据
-
在服务器上处理文件
-
使用资源路由授权访问资产
-
将文件转发到第三方服务
在本章中,我们将对 BeeRich 进行迭代以支持文件上传。首先,我们将更新创建和编辑表单以允许添加和删除附件。接下来,我们将重构action函数以在服务器上处理附加文件。进一步地,我们将研究如何授权访问上传的文件。最后,我们将了解文件大小考虑因素并讨论不同的文件存储解决方案。
阅读本章后,您将了解如何在 Remix 中处理多部分表单数据。您将知道如何使用 Remix 的文件上传助手以及如何使用资源路由授权访问上传的文件。您还将获得处理文件时需要考虑的理论知识以及如何将文件转发到第三方服务的理解。
技术要求
您可以在此处找到本章的代码:github.com/PacktPublishing/Full-Stack-Web-Development-with-Remix/blob/main/10-working-with-file-uploads/.
在开始本章之前,请按照 GitHub 上本章文件夹中README.md文件中的说明清理来自第九章、“资产和”元数据处理的实验。
在 Remix 中使用多部分表单数据
默认情况下,表单数据使用application/x-www-form-urlencoded编码类型。URL 编码的表单数据将表单数据作为键值对附加到请求 URL 作为查询参数。要向 HTML 表单附加文件,我们需要更改表单的编码类型。在传输如文件这样的二进制数据时,将表单数据附加到 URL 不是正确的方法。在本节中,您将学习如何使用多部分编码来支持文件上传。
HTML 表单元素有三种不同的编码类型:
-
application/x-www-form-urlencoded -
multipart/form-data -
text/plain
text/plain不是我们想要的。纯文本编码不用于客户端-服务器通信,因为它以人类可读的格式提交数据。相反,我们想要使用multipart/form-data编码,它将表单数据放入请求体中,使得包括和流式传输二进制文件成为可能。
让我们更新费用创建和编辑表单,允许用户附加文件。首先,让我们对费用创建表单进行更改:
-
在编辑器中打开
dashboard.expenses._index.tsx路由模块。路由组件当前渲染一个带有费用标题、描述和金额输入字段的表单。
-
将表单编码类型更新为
multipart/form-data并添加一个文件输入字段:<Form method="POST" action="/dashboard/expenses/?index" encType property sets the encoding type for the HTML form element. The default value, application/x-www-form-urlencoded, is what we’ve used thus far in BeeRich. For file uploads, we must update encyType to multipart/form-data.Note that the input element’s `multiple` property can be used to attach several files to one input field. By default, the input element’s `multiple` property is set to `false`. This means the input field lets the user attach only one file. -
现在,运行本地应用 (
npm run dev) 并检查更新的用户界面。如 *图 10**.1 所示,费用创建表单现在包含一个附件输入字段。
-
填写并提交费用创建表单。
注意,表单提交仍然有效。
request.formData函数可以解析 URL 编码和多部分表单数据。request.formData的一个缺点是它会将所有表单数据加载到服务器内存中。我们将在本章后面看到我们有哪些替代方案:

图 10.1 – 带有附件输入字段的表单截图
太好了!就这样,我们向费用创建表单添加了一个附件。像往常一样,将相同的更改应用到发票创建表单。
费用创建表单现在可以包含一个可选的文件附件。在下一节中,我们将在服务器上读取上传的文件并将其持久化到文件系统中。然而,我们还需要将保存的附件与费用关联起来。让我们更新数据库模式以支持向费用和发票添加附件:
-
首先,在你的编辑器中打开
prisma/schema.prisma文件。 -
将以下行添加到
Expense和Invoice数据库模型:attachment field is marked as optional since we added a question mark symbol after the data type. -
保存更改,并在项目的根目录下终端中运行
npx prisma format以格式化schema.prisma文件。 -
接下来,执行
npm run build以更新 Prisma 客户端和类型。在底层,Prisma 根据的
schema.prisma文件生成类型。在运行npm run build之后,费用和发票类型包括可选的attachment属性。 -
最后,运行
npm run update:db以将你的本地 SQLite 数据库模式与更新的 Prisma 模式同步。
一旦创建了一个费用,我们希望让用户查看和删除他们当前的附件。如果没有设置附件,我们还希望用户能够上传一个新的附件。接下来,让我们更新费用编辑表单以添加此功能:
-
在你的编辑器中打开
dashboard.expenses.$id.tsx路由模块。 -
更新路由组件的表单编码类型,以便它支持文件上传:
<Form method="POST" action={`/dashboard/expenses/${expense.id}`} key={expense.id} loader function’s return value.We need to access the `expense.attachment` property on the client. Note that we already return the full expense object. This automatically includes the new `expense.attachment` property. We can go ahead and read the property in the route’s component without changing the `loader` function. -
在你的编辑器中打开
app/components/forms.tsx文件并检查Attachment组件的实现。Attachment组件期望一个label属性和一个attachmentUrl属性,并渲染一个链接到附件的锚标签。它还包括一个提交按钮和一个名为attachmentUrl的隐藏输入字段。让我们在编辑表单中使用
Attachment组件,让用户查看和删除他们的费用和发票附件。 -
在
dashboard.expenses.$id.tsx中导入可重用的Attachment组件:import { dashboard.expenses.$id.tsx, right before the submit button:{expense.attachment ? ( <Attachment label="Current Attachment" attachmentUrl={
/dashboard/expenses/${expense.id}/attachments/${expense.attachment}} />) : ( )}If the expense object has an attachment, then we render the `Attachment` component, which displays a link to the attachment and a submit button to remove the attachment. Otherwise, we display the same input field as in the expense creation form so that users can add a new attachment.Notice that the `attachmentUrl` property value points to a new path: `dashboard/expenses/$id/attachments/$`. -
在本地运行应用程序并检查费用编辑表单上的新文件输入字段。
我们需要添加一个新的路由模块来处理对文件附件的访问。新的路由模块将是一个嵌套在dashboard/expenses/$id路径中的资源路由。让我们为新的路由模块创建必要的路由结构:
-
将
dashboard.expenses.$id.tsx文件重命名为dashboard.expenses.$id._index.tsx。路由模块仍然匹配之前的相同路径。作为一个索引路由,它现在作为
$id路径段的默认子路由。 -
更新所有提交到
dashboard.expenses.$id._index.tsx的所有表单的action属性。我们将
dashboard.expenses.$id.tsx中的action函数移动到了dashboard.expenses.$id._index.tsx。这改变了action函数的路径。- 打开
dashboard.expenses.tsx父级路由模块并更新ListLinkItem组件的deleteProps属性:
deleteProps={{ ariaLabel: `Delete expense ${expense.title}`, action: `/dashboard/expenses/${expense.id}action function. - 打开
-
接下来,创建一个新的通配符路由模块,命名为
dashboard.expenses.$id.attachments.$.tsx。通配符是通配符路由参数,它匹配从其位置开始的 URL 路径的其余部分。无论跟随
/dashboard/expenses/$id/attachments/的子路径是什么,通配符参数都会匹配并将子路径存储在params['*']中。例如,如果用户访问
/dashboard/expenses/$id/attachments/bees/cool-bees.pngURL 路径,那么params['*']通配符参数包含bees/cool-bees.png字符串。 -
将以下代码添加到
dashboard.expenses.$id.attachments.$.tsx通配符路由模块中:import type { LoaderFunctionArgs } from '@remix-run/node';export async function loader({ request, params }: LoaderFunctionArgs) { const { id } = params; npm run dev) and open the application in a new browser window. -
在 BeeRich 上签名或登录并创建一个新的费用。
-
通过点击仪表板上的费用概览列表中的费用来导航到费用详情页面。
-
将
/attachments/bees/cool-bees.png路径追加到浏览器窗口中的 URL。你应该看到
loader函数,而不仅仅是我们的路由组件的数据。 -
检查终端并审查
console.log({ id, slug })的输出。更改浏览器窗口中的 URL 以查看输出如何变化。
太好了!我们已经为我们的文件上传功能创建了所需的路由模块结构。一如既往,确保更新收入路由文件以练习你在本节中学到的内容。在本地运行应用程序以调试你的实现。
在本节中,你学习了如何在 HTML 表单元素上设置编码类型并添加文件输入字段。你还进一步练习了使用通配符路由模块和资源路由。接下来,我们将利用 Remix 的服务器端文件上传辅助函数将传入的文件写入文件系统。
服务器端的文件处理
在服务器上处理文件上传时,有几个重要的考虑因素,最重要的是文件大小。在本节中,我们将学习如何在 Remix 的 action 函数中处理文件。我们将从一个简单的实现开始,然后重构代码并考虑更多的问题。
将文件加载到内存中
让我们从实现一些用于处理文件的实用程序开始:
-
创建一个新的
app/modules/attachments.server.ts文件。 -
将以下代码添加到
attachments.server.ts:import fs from 'fs';import path from 'path';export async function writeFile(file: File) { const localPath = path.join(process.cwd(), 'public', file.name); const arrayBufferView = new Uint8Array(await file.arrayBuffer()); fs.writeFileSync(localPath, arrayBufferView);}添加的
writeFile函数接受一个文件并将其写入public文件夹。请注意,这并不是我们的最终解决方案,而是一个中间步骤。我们访问文件系统的方式取决于底层服务器运行时。BeeRich 在 Node.js 运行时上运行。因此,在 BeeRich 中,我们使用 Node.js 库来写入文件系统。
-
打开
dashboard.expenses._index.tsx路由模块。 -
更新路由模块的
action函数,如下所示:attachment form data entry, check whether it is a file, and then pass it to the writeFile function. Currently, writeFile writes the uploaded file to the public folder for easy public access. -
在本地运行 BeeRich 以测试当前实现。
-
填写费用创建表单,附加文件,并在您的编辑器中点击
public文件夹。就这样,我们可以将文件上传到服务器并将其写入文件系统。 -
您现在可以通过导航到
localhost:3000/$file-name来访问文件。请注意,您还必须在路径中添加文件扩展名。
理论上,我们现在可以在服务器上读取文件名并将其保存到数据库中的费用对象中。由于 public 文件夹中的所有文件都已经可以通过网络访问,我们可以让用户通过链接到 /$filename 来访问他们的文件。
很遗憾,当前简单实现存在一些限制:
-
文件名冲突
-
文件大小限制
-
隐私问题
根据当前实现,我们没有管理文件名以避免冲突。如果两个用户上传了同名文件怎么办?我们也没有处理文件大小限制。大文件可以轻易地消耗服务器上的运行时内存。因此,限制用户可以上传的文件大小或更好地实现文件上传处理程序,使其作为数据流处理传入的文件,而不是一次性将整个文件加载到内存中,这一点非常重要。
此外,通过将文件存储在 public 文件夹中,我们使文件对公众可访问。任何人都可以尝试猜测文件名,直到他们幸运地能够访问另一个用户的文件——这是一个巨大的安全问题,尤其是当我们谈论像发票和费用这样的敏感数据时。
幸运的是,我们可以通过利用 Remix 的原语和约定来解决所有这些问题。让我们首先使用 Remix 的上传处理辅助函数来解决前两个问题。
使用 Remix 的上传处理辅助函数
Remix 提供了一套用于管理文件上传的辅助函数。我们将使用以下函数来改进当前的简单实现:
-
unstable_composeUploadHandlers -
unstable_createFileUploadHandler -
unstable_createMemoryUploadHandler -
unstable_parseMultipartFormData
注意,当前函数包含 unstable_ 前缀。这意味着它们的实现可能在未来的版本中发生变化。
让我们开始吧:
-
首先,在项目的根目录下创建一个名为
attachments的新文件夹。这是我们将在服务器上存储所有附加文件的地方。 -
接下来,在您的编辑器中打开
app/modules/attachments.server.ts文件。 -
移除
writeFile函数。 -
相反,使用
unstable_createFileUploadHandler函数创建一个新的文件上传处理程序:import type { UploadHandler } from '@remix-run/node';import { unstable_composeUploadHandlers, unstable_createFileUploadHandler, unstable_createMemoryUploadHandler,} from '@remix-run/node';const standardFileUploadHandler = unstable_createFileUploadHandler({ directory: './attachments', avoidFileConflicts: true,});unstable_createFileUploadHandler函数接受一个配置对象来指定上传文件的存储位置。它还允许我们设置avoidFileConflicts标志以创建唯一的文件名。standardFileUploadHandler函数负责将上传的文件写入文件系统。有关可用的配置选项的更多信息,请参阅 Remix 文档:remix.run/docs/en/2.0.0/utils/unstable-create-file-upload-handler。 -
接下来,创建一个自定义文件上传处理程序函数:
const attachmentsUploadHandler: UploadHandler = async (args) => { if (args.name !== 'attachment' || !args.filename) return null; standardFileUploadHandler to add a bit of helper logic. First, we ensure that we only process file attachments with the attachment input name. Then, we make sure to return the filename or null if no file was attached.Notice that `attachmentsUploadHandler` implements Remix’s `UploadHandler` type. This allows us to compose it together with Remix’s file helper functions. -
使用 Remix 的
unstable_composeUploadHandlers函数组合我们的attachmentsUploadHandler辅助函数和 Remix 的unstable_createMemoryUploadHandler:export const uploadHandler = unstable_composeUploadHandlers( attachmentsUploadHandler, unstable_createMemoryUploadHandler(),);有了这个,我们创建了一个高级的
uploadHandler辅助函数,由两个上传处理程序组成。uploadHandler为每个表单数据条目调用两个处理程序。首先,我们尝试使用attachmentsUploadHandler处理表单数据条目。如果attachmentsUploadHandler返回null,那么我们也尝试使用unstable_createMemoryUploadHandler处理表单数据条目。如其名所示,Remix 的
unstable_createMemoryUploadHandler将处理所有其他表单数据字段并将它们上传到服务器内存,这样我们就可以像往常一样使用FormData接口来访问它。
干得好!让我们更新我们的 action 函数,以便它们利用新的上传处理程序:
-
打开
dashboard.expenses._index.tsx路由模块。 -
在路由模块的
action函数中移除writeFile的导入和天真实现:const file = formData.get('attachment');if (file && file instanceof File) { writeFile(file);} -
从 Remix 导入
unstable_parseMultipartFormData:import { redirect, uploadHandler from app/modules/attachments.server.tsx:import { parseMultipartFormData } to replace request.formData():const formData = await unstable_parseMultipartFormData(request, uploadHandler);在这里,我们让
unstable_parseMultipartFormData使用我们的自定义uploadHandler处理多部分表单数据。unstable_parseMultipartFormData为每个表单数据条目调用我们的高阶上传处理程序。组合上传处理程序遍历我们的上传处理程序,直到其中一个返回既不是null也不是undefined。附件表单数据条目由文件上传处理程序处理,返回上传文件的文件名,如果没有提交文件则返回null。unstable_createMemoryUploadHandler为我们处理所有其他表单数据。 -
接下来,添加读取附件表单数据并更新数据库查询的代码:
export async function action({ request }: ActionFunctionArgs) { const userId = await requireUserId(request); attachments folder.Note that the file upload will fail if the file size exceeds 30 MB. This is Remix’s default maximum file size. The file size can be increased by updating the configuration options that are passed to `unstable_createFileUploadHandler`.
恭喜!您成功地将文件上传添加到支出创建表单中。确保在进入下一节之前将相同的更改应用到收入路由上。重用attachments.server.ts中的辅助函数来更新发票创建action函数。
防止服务器内存溢出
在处理文件上传时,我们必须注意内存限制。大文件大小很容易使我们的服务器不堪重负。这就是为什么处理传入的文件时,将其分块处理而不是完全加载到内存中很重要的原因。Remix 的文件上传辅助函数帮助我们避免文件名冲突,并允许我们流式传输文件数据以避免服务器内存溢出。
接下来,我们将更新支出编辑表单,使其也能处理文件上传:
-
在您的编辑器中打开
dashboard.expenses.$id._index.tsx文件。 -
再次,导入
unstable_parseMultipartFormData和uploadHandler:import { json, redirect, action function with the following code:export async function action({ params, request }: ActionFunctionArgs) { const userId = await requireUserId(request); const { id } = params; if (!id) throw Error('id 路由参数必须被定义');action 函数区分删除和更新表单提交。删除表单提交来自 ListLinkItem 组件,而更新提交来自 dashboard.expenses.$id._index.tsx 路由模块中的支出编辑表单。在本章早期,我们更新了支出编辑表单的编码为多部分编码,但没有对支出删除表单做同样的处理。因此,
action函数必须能够支持多部分和 URL 编码的表单数据。为此,我们使用content-type头区分使用了哪种表单编码,并且只为multipart/form-data使用parseMultipartFormData。 -
接下来,更新
updateExpense函数,使其读取attachment表单数据条目并将值添加到数据库更新查询中:updateExpense function is called when the edit expense form is submitted. Here, we want to ensure that newly uploaded attachments are added to the expense update query.Note that we already persisted the file to the filesystem when calling `unstable_parseMultipartFormData(request, uploadHandler)`. The `updateExpense` function ensures that the expense entry in the database is updated accordingly.We must also make sure we clean up the filesystem whenever an attachment is removed or the associated expense is deleted. -
将以下函数添加到
attachments.server.ts文件中:import fs from 'fs';import path from 'path';export function deleteAttachment(fileName: string) { const localPath = path.join(process.cwd(), 'attachments', fileName); try { fs.unlinkSync(localPath); } catch (error) { console.error(error); }}deleteAttachment接收一个fileName并从attachments文件夹中删除相关文件。 -
在
dashboard.expenses.$id._index.tsx中导入deleteAttachment:import { removeAttachment function, which we will call in the route module’s action function:async function removeAttachment(formData: FormData, id: string, userId: string): Promise
{ const attachmentUrl = removeAttachment 是当提交按钮带有 remove-attachment 值时在路由模块的 action 函数中被调用的,该值在附件组件中实现。 -
更新路由模块的
action函数,使其处理remove-attachment表单的动作意图:const intent = formData.get('intent');if (intent === 'delete') { return deleteExpense(request, id, userId);}if (intent === 'update') { return updateExpense(formData, id, userId);}remove-attachment value originates from, investigate the Attachment component in app/components/forms.tsx. The Attachment component contains a hidden input field for attachmentUrl and a submit button with a value of remove-attachment. The component is nested in the dashboard.expenses.$id._index.tsx route module’s form and submits to the same action function. -
最后,在相同文件中更新
deleteExpense函数,以便在删除支出时删除附件:npm run dev and open a browser window to test the implementation. -
创建一个带有附件的支出。检查您的编辑器中的
attachments文件夹。 -
通过点击
attachments文件夹来删除附件,查看文件是否成功删除。 -
接下来,使用编辑表单向同一支出添加新的附件,并再次调查
attachments文件夹。 -
最后,通过在费用概览列表中点击X按钮来删除费用,并检查文件是否已删除:

图 10.2 – 更新后的费用编辑表单的截图
如图 10**.2所示,如果未设置附件,费用编辑表单现在应正确地在当前附件和附件输入字段之间切换。删除费用还应从attachments文件夹中删除相关的附件文件。
在继续之前,更新income路由以练习本节中学到的内容。一旦income路由被更新,你就可以进入下一节。
在本节中,你学习了如何使用 Remix 的文件上传辅助函数。你现在理解了在管理文件上传时需要考虑的因素,以及如何使用 Remix 的实用工具来避免内存和文件命名冲突。接下来,我们将实现 splat 路由,以让 BeeRich 用户安全地访问他们的附件。
使用资源路由授权对资产的访问
在第九章,“资产和元数据处理”中,你练习了通过资源路由公开资产。现在我们将在此基础上动态创建一个用于请求的费用附件的文件下载。我们将实现负责公开附件的 splat 路由,并确保只有授权用户才能访问他们的文件:
-
首先,让我们向
attachments.server.ts文件添加一个额外的辅助函数:export function buildFileResponse(fileName: string): Response { const localPath = path.join(process.cwd(), 'attachments', fileName); try { const file = fs.readFileSync(localPath); return buildFileResponse function takes a fileName string and attempts to stream the associated file into a Response object. The content-disposition header ensures that the response is treated as a file download.Again, we avoid loading the full file into memory. Instead, we make sure to read the file into a buffer and manage it in chunks to avoid exceeding the server’s memory capabilities. -
接下来,打开
dashboard.expenses.$id.attachments.$.tsxsplat 路由模块,并用以下代码替换其内容:import type { LoaderFunctionArgs } from '@remix-run/node';import { redirect } from '@remix-run/router';import { id parameter of the expense of the requested attachment.Notice that we query by a combination of expense `id` and user `id`. This ensures that a user can only access their own expenses.We then do some sanity checks before returning the response created by our new `buildFileResponse` helper function. -
是时候测试实现了。尝试下载当前附件。点击当前附件链接应启动文件下载。
干得好!你在 BeeRich 中实现了涵盖多个不同表单、路由和实用工具的全栈文件上传功能。
在继续之前,请确保你实现了收入 splat 路由。在收入路由上重复这项工作将帮助你练习所有新的概念。
限制对用户文件的访问
重要的是要记住,public文件夹中的文件可以通过互联网公开访问。我们必须确保通过授权代码保护私有用户数据。在 Remix 中,我们可以使用资源路由在授予用户文件访问权限之前动态检查访问权限。
在本节中,你学习了如何创建动态响应并通过资源路由公开资产。你现在理解了如何在资源路由中授权用户以限制访问。接下来,我们将讨论将文件转发到第三方服务。
将文件转发到第三方服务
到目前为止,我们已经在服务器的文件系统中托管用户文件。这对于 BeeRich 的教育范围来说是足够的。然而,当处理用户文件时,我们也应考虑在专用的文件存储服务上托管它们。本节简要概述了在处理用户文件时还需要考虑的其他事项。
直接在 Web 服务器上托管用户文件可能对于大多数用例来说可能不够。在本地托管文件可能难以扩展,需要你在系统中保护敏感用户文件和备份。此外,读取和写入磁盘可能会为 Web 服务器创建大量开销,这些开销可以通过将读取和写入委托给第三方服务来避免。
大多数流行的第三方存储服务提供 API 以流式传输文件。这使我们能够将文件上传作为数据流接收,以便我们可以将流转发到第三方服务。上传完成后,存储 API 通常提供一个指向已上传文件的 URL,我们可以在数据库中使用它来链接到新文件。
Remix 的上传处理程序原语允许你为不同的第三方服务创建自定义处理程序。我们不必写入本地文件系统,可以创建一个上传处理程序,将数据流式传输到云提供商。
流行的文件托管提供商包括 AWS S3、Cloudflare、Cloudinary、Firebase 和 Vercel。你可以在 进一步 阅读 部分找到一个使用 Cloudinary 的示例实现。
摘要
在本章中,你学习了如何将文件添加到 HTML 表单中,以及如何在 Remix 中处理文件上传。
HTML 表单支持不同的编码类型。多部分表单编码将表单数据添加到响应体中。当附加二进制数据,如文件时,这是必需的。在服务器上,我们然后可以流式传输响应体,并分块处理上传的文件。
通过阅读本章,你现在理解 Remix 提供了一套文件上传实用工具来处理文件上传。Remix 实用工具帮助我们避免文件命名冲突,并允许我们配置文件大小限制和文件流。我们可以进一步组合几个文件上传处理程序,并通过实现 UploadHandler 类型来实现自定义包装器。
接下来,你学习了如何通过验证用户会话并确保授权的数据库查询来查询实体 id 和用户 id 的唯一组合,从而限制对资源路由的访问。我们不得将用户文件放在 public 文件夹中。相反,我们必须利用资源路由和自定义授权逻辑。
最后,我们讨论了第三方文件托管服务的使用。你现在理解使用第三方服务可能更具可扩展性,并允许我们将存储文件的许多复杂性卸载到第三方服务。
恭喜!就这样,你已经完成了这本书的第二部分。在下一章中,我们将开启本书的高级主题,并了解更多关于乐观用户界面的内容。继续前进,用 Remix 解锁 Web 平台的全部潜力。
进一步阅读
你可以通过 MDN Web 文档了解 HTML 表单元素的enctype属性:developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/enctype。
你可以通过 MDN Web 文档了解 HTTP POST 请求的更多信息:developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST。
查看 Remix 示例仓库中的此示例实现,用于上传文件到 Cloudinary:github.com/remix-run/examples/tree/main/file-and-cloudinary-upload。
你可以通过阅读 Remix 文档了解更多关于 Remix 文件上传辅助工具的信息:
-
remix.run/docs/en/2.0.0/utils/unstable-create-file-upload-handler -
remix.run/docs/en/2.0.0/utils/unstable-create-memory-upload-handler
第三部分 - 使用 Remix 的全栈 Web 开发高级概念
在本最终部分,你将练习全栈 Web 开发的高级概念,如乐观用户界面、缓存策略、HTML 流和实时数据更新。你将再次迭代 BeeRich 以练习所学概念。你还将深入了解会话管理,并理解部署到边缘的含义。最后,你将了解迁移到 Remix 的策略,并学习如何保持 Remix 应用程序的更新。
本部分包含以下章节:
-
第十一章, 乐观用户界面
-
第十二章, 缓存策略
-
第十三章, 延迟加载器数据
-
第十四章, 使用 Remix 进行实时操作
-
第十五章**, 高级会话管理
-
第十六章, 边缘开发
-
第十七章, 迁移和升级策略
第十一章:乐观 UI
乐观 UI 通过提供即时反馈,即使在操作需要一点时间的情况下也能让您的应用感觉更加敏捷。这在等待网络响应时尤其有用。乐观更新可以使 UI 感觉更加响应迅速,并改善用户体验。在本章中,您将学习如何使用 Remix 添加乐观 UI 更新。
本章分为两个部分:
-
考虑乐观 UI
-
在 Remix 中添加乐观 UI 更新
首先,我们将讨论使用乐观 UI 更新的权衡,并调查客户端/服务器状态同步和回滚的复杂性和风险。接下来,我们将回顾 BeeRich 的当前状态,并调查哪些突变可以通过乐观 UI 更新来增强。然后,我们将使用 Remix 的原语在合适的地方添加乐观 UI 更新。
阅读本章后,您将了解如何评估乐观 UI 的使用。您还将练习使用 Remix 的原语,如 useNavigation 和 useFetcher,来实现乐观 UI。最后,您将理解 Remix 如何通过提供一个有弹性的基线来简化乐观 UI 的实现。
技术要求
您可以在此处找到本章的代码:github.com/PacktPublishing/Full-Stack-Web-Development-with-Remix/tree/main/11-optimistic-ui。
BeeRich 已经成长了很多。现在是重构代码的好时机。在开始本章之前,我们希望更新当前代码。我们还将使用 zod 增强我们的表单验证和解析。按照 README.md 文件中的逐步指南准备 BeeRich 以迎接即将到来的高级主题。
考虑乐观 UI
网络应用程序的真相来源通常存储在远程数据库中。我们只能在更新数据库并从服务器收到确认后才能确定突变是否成功。因此,UI 对突变的响应将被延迟,直到我们从服务器得到回复。
乐观 UI 是一种用于在等待执行解决时向用户提供即时反馈的模式。在乐观地更新 UI 时,我们在收到来自服务器的最终响应之前就应用 UI 更新。大多数时候,我们的突变都成功了,那么为什么还要等待服务器响应呢?在本节中,我们将讨论乐观 UI 更新的某些权衡。
传达回滚
乐观地更新 UI 可以加快当乐观状态与服务器响应一致时的感知响应时间。当乐观更新与服务器响应不一致时,则需要回滚或纠正乐观更新。这就是乐观 UI 模式开始变得复杂的地方。
当乐观突变出现问题时,我们必须向用户传达错误并突出显示回滚。否则,我们可能会失去用户对我们应用程序的信任和信心。例如,在尝试删除一个项目后,我们可能需要回滚到乐观删除该项目,并告诉用户为什么项目再次出现——“我刚刚删除了那个项目;为什么 它又回来了?”
在考虑乐观 UI 时,调查突变的错误率是一个好主意。如果错误率很高,那么回滚的数量可能会比增加的响应时间更有害于用户体验。像往常一样,这取决于用例、应用程序类型和用户。
我们可以总结说,随着回滚操作需要正确沟通,使用乐观 UI 的错误处理变得更加难以实现。此外,乐观 UI 还需要重新同步客户端和服务器状态,导致客户端代码更加复杂。
同步客户端和服务器状态
乐观 UI 的最大风险之一是在 UI 中引入过时状态。在应用乐观更新时,与服务器响应一致地同步 UI 状态可能变得相当具有挑战性。结果逻辑可能很复杂,并可能导致应用程序 UI 的一部分与服务器状态不同步的 bug。
当添加乐观更新时,我们可能允许用户连续提交几个更新。我们每次都乐观地更新 UI。然后,我们必须处理 UI 与服务器响应的同步。当几个更新同时发生时,这可能会导致竞争条件和其他需要彻底同步逻辑和错误处理的难题。
乐观 UI 更新是可选的。如果正确实现,它们可能会通过加快感知响应时间来改善用户体验。然而,也存在风险,即如果未彻底实现,乐观 UI 更新可能会不成比例地增加我们应用程序状态管理的复杂性,并降低用户体验。
如果回滚操作没有正确沟通,乐观 UI 更新可能会导致状态过时、复杂的客户端-服务器状态同步逻辑,以及更差的用户体验。总之,我们必须谨慎评估某个突变是否因添加乐观 UI 更新而受益,或者它是否会不成比例地增加复杂性。
幸运的是,Remix 为实施乐观 UI 更新提供了一个很好的基础,并允许我们通过对我们现有的挂起 UI 进行增量更改来实现乐观 UI 更新。让我们再次提醒自己 Remix 的loader重新验证功能。
在 Remix 中同步客户端和服务器状态
Remix 通过提供数据重新验证流程,开箱即用地管理了乐观 UI 的复杂性。在深入代码之前,让我们快速回顾一下 Remix 内置的loader重新验证功能。
无论何时我们在 Remix 中提交表单并执行 action 函数,Remix 都会自动从所有活动的 loader 函数重新获取数据。这确保了在每次数据突变后,我们总是更新页面上的所有数据。
当使用 Remix 的 loader 和 action 函数进行数据读取和写入时,我们避免了在 UI 中引入过时数据,并消除了在实现乐观用户界面更新时降低用户体验的主要担忧。
此外,Remix 的原语,如 useNavigation 和 useFetcher,允许我们在不添加自定义 React 状态的情况下读取挂起的提交数据,这有助于将添加乐观用户界面时的复杂性保持在较低水平。让我们通过向 BeeRich 添加乐观用户界面来看看这一点。首先,让我们回顾 BeeRich 应用程序中的当前突变,并调查添加乐观用户界面是否会改善用户体验。
在 Remix 中添加乐观用户界面更新
在本节中,我们将回顾我们的 BeeRich 应用程序,并讨论哪些用户操作通过添加乐观的用户界面更新将获得最大的收益。然后,我们将继续进行必要的代码更改。
创建费用
通过在项目的根目录中执行 npm run dev 来在本地运行 BeeRich,并导航到费用概览页面(localhost:3000/dashboard/expenses)。现在,创建一个新的费用。
注意,在提交费用创建表单后,我们将被重定向到费用详情页面。现在,URL 包含新的费用标识符。在重定向后,我们可以访问新创建的费用加载器数据,包括费用标识符。所有对费用的进一步更新都需要费用标识符。
在费用创建表单中添加乐观的用户界面更新可能会变得相当复杂。实现这一目标的一种方法是在将用户重定向到实际详情页面之前,乐观地更新创建表单的外观和感觉,使其看起来像费用更新表单。然而,在我们从费用创建提交中接收到费用 id 参数之前,我们无法执行任何后续的费用更新提交。我们可以禁用所有提交按钮,直到我们收到服务器响应,或者我们可以排队后续提交,并使用用户尝试提交的最新更改以编程方式提交更新。这可能会变得相当复杂。
当考虑附件逻辑时,事情变得更加复杂。如果我们仍在等待 id 参数,而用户想要删除附加文件或尝试上传新的附件怎么办?我们可以通过禁用附件操作直到我们从服务器获取费用 id 参数来防止对附件的所有后续更改。
和往常一样,这归结为权衡。通过添加乐观更新,我们能够增加多少响应时间并提升用户体验?这是否值得增加的复杂性?由于我们的应用程序相当快,我们决定不在费用创建表单中添加乐观更新。相反,让我们继续并调查费用更新表单。
更新费用
导航到费用概览页面(localhost:3000/dashboard/expenses)并选择一个费用。这将带我们到费用详情页面,该页面渲染费用更新表单。现在,对现有的费用进行一些修改并点击id参数。相反,我们显示一个成功消息:更改已保存。
技术上,UI 已经显示了乐观更新,因为我们总是向用户显示最新的输入值。让我们也更新dashboard.expenses.$id._index.tsx路由模块:
-
移除
disabled属性和待处理的“保存…”UI 状态:<Button type="submit" name="intent" value="update" isPrimary> Save</Button>刚开始移除待处理的 UI 可能会感觉有些奇怪。让我们仔细思考一下。现在表单支持后续更新,因为我们不再在待处理提交时禁用提交按钮。由于我们总是显示用户输入的值在更新表单中,输入状态本身已经是乐观的。由于我们仍然在待处理导航时显示全局过渡动画和费用详情脉冲动画,我们仍然传达更新正在进行的信息。此外,我们还在成功更新时显示成功消息。这可能是一个不错的折衷方案。
但附件怎么办?添加附件会创建一个新的
expense.attachment值。我们需要附件文件名值来执行视图和删除附件操作。一种解决方案是在乐观地添加附件的同时禁用附件链接和删除按钮,直到我们收到包含新添加的附件值的服务器响应。让我们实现它!
-
在
dashboard.expenses.$id._index.tsx路由模块组件中,使用 Remix 的全局导航对象来确定附件是否正在上传:const navigation = useNavigation();const attachment = formData property. This property contains the data of the currently submitted form or undefined if no submission is in progress. By checking for the name property of the attachment input value, we can verify whether a file has been appended to the expense update form. -
接下来,更新
Attachment组件的条件渲染语句,以便在附件正在上传时进行渲染。此外,将disabled属性传递给Attachment组件:{Attachment component when an upload is still in progress. This is an optimistic update to the UI since the upload is still in progress.By disabling the `Attachment` component’s actions if an upload is still in progress, we prevent users from viewing or removing a pending attachment.
Remix 的useNavigation钩子和其formData属性允许我们有条件地更新 UI,而无需创建额外的自定义 React 状态。这很好,因为我们完全避免了同步逻辑的需要。Remix 的ErrorBoundary组件进一步确保在发生错误时有一个健壮的基线。
太好了!就这样,我们在附加文件和更新费用时添加了乐观 UI 更新。现在,用户可以在不等待服务器响应的情况下进行多次更新。如果出现问题,Remix 将显示我们的ErrorBoundary,让用户知道错误信息。
就像往常一样,为收入路由实现相同的功能。这确保你在继续前进之前重新回顾所学的内容。
接下来,让我们调查机会删除表单,以便添加乐观的 UI 更新。
删除支出
在支出概览页面上,我们为每个支出渲染一个ListLinkItem组件。app/components/links.tsx中的ListLinkItem组件使用useFetcher.Form提交删除突变。在列表中乐观地删除或添加元素是提供即时反馈的好方法。让我们看看我们如何将乐观 UI 添加到我们的支出删除表单中。
在删除时实现乐观更新的方法之一是在进入挂起状态后立即隐藏列表项。按照以下方式更新app/components/links.tsx中的ListLinkItem组件:
const fetcher = useFetcher();const isSubmitting = fetcher.state !== 'idle';
if (isSubmitting) {
return null;
}
你已经知道useFetcher钩子管理其转换生命周期。当尝试使用useFetcher.Form实现挂起和乐观 UI 时,我们不使用useNavigation;相反,我们使用useFetcher钩子的state和formData属性。
就这样,当提交挂起时,我们从列表中删除支出项。一旦action函数完成,Remix 刷新加载器数据并将导航状态设置回idle。如果突变成功,则更新的加载器数据不再包含已删除的支出,并且我们的 UI 更新持续存在。但如果发生错误呢?
-
查看位于
dashboard.expenses.$id._index.tsx路由模块中的handleDelete函数。目前,如果删除支出失败,我们抛出一个 404Response。这触发了ErrorBoundary。让我们通过在 UI 中为用户提供直接反馈来改进这一点,如果删除操作失败。 -
将
handleDelete更新为在删除操作失败时返回一个 JSONResponse:try { await deleteExpense(id, userId);} catch (err) { ListLinkItem component in components/links.tsx.如果操作数据存在且成功为 false,我们知道支出的删除失败了。
-
最后,有条件地更新列表元素的
className属性,以便在hasFailed为true时将列表项文本样式设置为红色。className={clsx( 'w-full flex flex-row items-center border', isActive ? 'bg-secondary dark:bg-darkSecondary border-secondary dark:border-darkSecondary' : 'hover:bg-backgroundPrimary dark:hover:bg-darkBackgroundPrimary border-background dark:border-darkBackground hover:border-secondary dark:hover:border-darkSecondary',ErrorBoundary and instead display error feedback right where the error happened in the UI. -
通过在
dashboard.expenses.$id._index.tsx中的handleDelete的 try-case 内部抛出错误来测试更改:try { ErrorBoundary is a great fallback in case something goes wrong. However, sometimes, it is a good idea to enhance the user experience further by providing inline feedback. This allows the user to retry the failed action immediately, and Remix’s loader data revalidation takes care of the rest.
Remix 为乐观 UI 提供了一个很好的基础
Remix 在突变后自动更新加载器数据,以保持客户端和服务器同步。这极大地简化了创建乐观 UI 的过程。
干得好!一如既往,确保也要更新本节中的收入路由,以反映最新的更改。这确保了你在进入下一节之前已经练习了所学的内容。
在这部分中,我们为删除支出添加了乐观 UI 更新。我们还使用了 fetcher 的data属性来表示操作失败并需要回滚。接下来,让我们调查我们是否可以乐观地删除支出和收入附件。
移除附件
我们已经乐观地显示了待上传文件中的附件组件。当点击附件删除按钮时,我们也应考虑乐观地删除附件。
这次,我们不想在待删除的附件上显示附件组件,而是显示文件输入。然而,我们还想防止竞争条件,并应确保在服务器确认删除之前禁用输入。
由于附件删除表单提交使用的是Form组件(而不是useFetcher.Form),我们知道提交是通过全局导航对象处理的。因此,我们可以通过检查全局导航对象上的formData属性来检测用户是否删除了附件:
-
将以下布尔标志添加到
dashboard.expenses.$id._index.tsx路由模块组件中:const isUploadingAttachment = attachment instanceof File && attachment.name !== '';Attachment component if an upload is pending or an attachment exists on the expense but not if an attachment is currently being removed. Additionally, disable the input field if a submission is pending:{(isUploadingAttachment || expense.attachment) && !isRemovingAttachment ? ( <Attachment label="当前附件" attachmentUrl={
/dashboard/expenses/${expense.id}/attachments/${expense.attachment}} disabled={isUploadingAttachment} />) : ( )} -
尝试运行实现以验证一切是否按预期工作。一如既往,使用网络选项卡来限制连接以检查待处理状态。
太好了!我们向附件删除表单和费用更新表单添加了乐观 UI 更新。你了解到 Remix 的useFetcher和useNavigation原始功能包含当前正在提交的表单的formData属性。我们可以使用formData属性来乐观地更新 UI,直到loader重新验证将 UI 与服务器状态同步。
摘要
在本章中,你学习了如何在 Remix 中添加乐观 UI 更新。你了解了乐观 UI 的权衡,例如客户端逻辑的复杂性增加以及在回滚情况下用户反馈的必要性。
Remix 的loader重新验证是同步 UI 与服务器状态的一个很好的起点。你现在明白,Remix 的loader重新验证使我们能够避免自定义客户端-服务器状态同步,并让我们避免过时的状态。在依赖加载器数据时,我们能够自动获得回滚。每次突变后,我们都会收到最新的加载器数据,并且我们的 UI 会自动更新。
仍然值得说明为什么突变失败。无论是否有乐观更新,向用户显示错误消息都很重要。对于乐观更新,也可能有突出显示回滚数据的视觉意义。Remix 的ErrorBoundary组件是恢复错误的一个很好的起点。然而,如果我们想要更精细的反馈,我们必须添加自定义错误消息并利用 Remix 的原始功能来突出显示回滚数据。
当实现乐观式用户界面时,我们通常首先从移除挂起的用户界面开始。在列表中添加和移除挂起实体是一种显示即时反馈的简单方法。
你还学习了如何使用 Remix 的原语,如 useNavigation 和 useFetcher 来实现乐观式用户界面更新。我们可以在客户端使用 formData 属性在服务器返回最终响应之前显示用户数据。
在下一章中,我们将学习不同的缓存策略,以进一步提高 Remix 应用程序的反应时间和性能。
进一步阅读
你可以在 Remix 文档中找到更多关于如何实现乐观式用户界面的信息:remix.run/docs/en/2/discussion/pending-ui。
在 Remix 的 YouTube 频道上还有一个关于乐观式用户界面的精彩 Remix 单视频:www.youtube.com/watch?v=EdB_nj01C80。
第十二章:缓存策略
“在计算机科学中,只有两件难事:缓存失效和命名事物。” – Phil Karlton
缓存可以通过消除或缩短网络往返次数以及重用先前存储的数据和内容,显著提高网站的性能。然而,缓存也难以正确设置。通常,Remix 在 Web 平台之上提供了一个薄薄的抽象层,简化了 HTTP 缓存策略的使用。
在本章中,我们将了解不同的缓存策略以及如何利用 Remix 来使用它们。本章分为两个部分:
-
与 HTTP 缓存一起工作
-
探索内存缓存
首先,我们将了解 HTTP 缓存。我们将研究不同的 HTTP 缓存头部,并了解如何在浏览器和 CDN 中利用 HTTP 缓存。接下来,我们将关注内存缓存。我们将参考第三章,部署目标、适配器和堆栈,了解何时何地可以在内存中缓存数据。我们还将讨论使用 Redis 等服务来缓存数据。
阅读本章后,您将了解如何利用 Remix 进行缓存以改善用户体验。您还将练习使用 HTTP 头部,并了解何时使用不同的缓存策略,例如 CDN、浏览器、实体标签(ETags)和内存缓存。
技术要求
您可以在此处找到本章的代码:github.com/PacktPublishing/Full-Stack-Web-Development-with-Remix/tree/main/12-caching-strategies。您可以继续使用上一章的最终解决方案。本章不需要额外的设置步骤。
与 HTTP 缓存一起工作
Web 平台使用 HTTP 头部来控制缓存行为。Web 客户端可以读取响应头部中指定的缓存指令,以重用先前获取的数据。这允许 Web 客户端避免不必要的网络请求并提高响应时间。在本节中,您将了解流行的 HTTP 缓存头部和策略,以及如何在 Remix 中使用它们。首先,我们将看到如何为文档响应定义 HTTP 头部。
在 Remix 中添加 HTTP 头部
Remix 的 route 模块 API 包括一个headers导出,我们可以用它来向路由的文档响应添加 HTTP 头部。像links函数一样,headers函数仅在服务器上执行。
headers函数在所有loader函数和所有父headers函数之后被调用。headers函数可以访问parentsHeaders、errorHeaders、actionHeaders和loaderHeaders对象,根据通过父header函数、loader 数据响应、action 数据响应和错误响应添加的头部来更新文档头部。Remix 利用可用的最深导出的headers函数,并允许您按需混合和合并头部。
基于加载器数据的缓存控制
Remix 的 headers 函数接收 loaderHeaders 参数,这允许我们根据加载器数据为每个路由指定缓存指令,以实现细粒度的缓存控制。
现在我们已经从理论上了解了如何使用 Remix 应用 HTTP 头部,让我们运行我们的 BeeRich 路由来调查如何利用缓存。
在共享缓存中缓存公共页面
没有特定于用户信息的公共页面可以存储在共享缓存中,如 CDN。在你的(Remix)网络服务器前添加 CDN 可以在全球范围内以及更接近用户的地方分发缓存内容。它减少了缓存内容的请求响应时间以及网络服务器需要处理的请求数量。
如果你不确定 CDN 是什么,MDN Web Docs 提供了一个很好的介绍:developer.mozilla.org/en-US/docs/Glossary/CDN。
在本节中,我们将使用 Remix 的 headers 路由模块 API 为 BeeRich 的公共页面添加 HTTP 缓存头部。
BeeRich 由公共和私有路由组成。公共页面嵌套在 _layout 段中,包括 BeeRich 主页 (_layout._index.tsx) 以及登录和注册页面。我们可以确定这些页面是静态的,并且不依赖于特定于用户的数据。如果用户偶尔看到过时的页面版本,我们是可以接受的。我们可以指定 HTTP 头部,以便在请求新版本之前,我们继续为页面提供缓存版本,时间为一个小时。
让我们看看这会是什么样子。将以下 headers 函数导出添加到 _layout.tsx 路径无布局路由模块中:
import type { HeadersFunction } from '@remix-run/node';export const headers: HeadersFunction = () => {
return {
'Cache-Control': 'public, max-age=3600',
};
};
通过这些更改,我们将缓存头应用于所有不自身导出 headers 函数的子路由。指定的缓存头包括 public 值和 max-age 指令。
max-age 指令定义了可用响应在必须重新生成之前可以重用的秒数。这意味着嵌套路由,如 /、/login 和 /signup,现在被缓存了 3,600 秒(1 小时)。
public 值表示响应数据可以存储在公共缓存中。我们可以区分公共(共享)和私有缓存。私有缓存存在于网络客户端(例如,浏览器)中,而共享缓存存在于代理服务和 CDN 上。通过指定文档可以公开缓存,我们也允许代理和 CDN 为所有未来的请求缓存文档。这意味着缓存不仅服务于一个浏览器(用户),还可能提高后续用户请求的响应时间。
让我们调查这种缓存行为:
-
在项目的根目录中运行
npm run dev。 -
接下来,在新的浏览器窗口中打开登录页面 (
localhost:3000)。 -
打开浏览器的开发者工具并导航到 网络 选项卡。
-
如果已勾选,请确保取消勾选 禁用缓存 选项。
-
现在,强制刷新页面以模拟初始页面加载:

图 12.1 – 登录页面初始加载
注意,指定的缓存头作为响应头的一部分返回。
- 再次刷新页面;你可能看到文档是从磁盘缓存中恢复的。一些浏览器为了更好的开发者体验,禁用了 localhost 上的文档请求缓存头。所以,如果你在 localhost 上似乎无法使其工作,请不要担心。
谨慎不要公开缓存包含用户特定信息的文档。虽然 CDN 通常会自动删除Set-Cookie头,但当你希望服务器响应中包含用户会话 cookie 时,你很可能想完全避免缓存。如果你正在使用 CDN,请确保只为访客缓存,而不是已登录用户,以避免为已登录用户缓存条件渲染的 UI。例如,_layout.tsx中的导航栏在用户登录时会显示“注销”按钮。缓存这可能会导致 React 在客户端进行水合和重新渲染页面后,将“注销”按钮替换为“登录”和“注册”时布局发生变化。
让我们调查 Remix 如何为我们页面的公共资源使用 HTTP 缓存头。
理解 Remix 的内置缓存
Remix 默认优化了许多服务资源。在本节中,我们将回顾 Remix 如何利用静态资源的 HTTP 缓存头来优化我们应用程序的性能。
按照上一节的步骤,在浏览器窗口中打开 BeeRich 的登录页面。在网络标签中单击任何下载的 JavaScript 包,并检查响应头:

图 12.2 – Remix 的内置缓存行为
如图 12.2所示,manifest-*.js文件是从浏览器的内存或磁盘缓存中检索的。Remix 为每个 JavaScript 包添加了缓存控制头(Cache-Control: public, max-age=31536000, immutable)。每个 JavaScript 包被定义为公开缓存,最多可达一年——这是可能的max-age值的最大值。immutable指令进一步表明资源内容永远不会改变,这有助于我们避免潜在的重新验证请求。
接下来,检查root.ts文件中links导出的tailwind.css样式表。比较链接样式表和 JavaScript 包的缓存控制头。它们匹配!
最后,检查静态资源的名称。注意,所有 JavaScript 包和链接资源都包含一个哈希后缀。哈希是根据资源内容计算的。每次我们更新任何资源时,都会创建一个新的版本。哈希确保不会有两个名称相同但内容不同的资源。这允许 Remix 允许客户端无限期地缓存每个资源。
Remix 内置的 HTTP 缓存
静态资产的基于哈希的文件名确保新版本自动产生新资产。这允许 Remix 向 links 路由模块 API 返回的所有链接资产添加积极的缓存指令。Remix 将相同的指令添加到所有其 JavaScript 打包中。
Remix 使用的积极缓存指令允许浏览器和 CDN 缓存你的 Remix 应用程序的所有静态资产。这可以显著提高性能。
接下来,我们将讨论如何缓存个性化页面和内容。
在私有缓存中缓存个性化页面
控制 HTTP 缓存不仅关乎缓存响应,还关乎控制何时不缓存。Remix 通过提供对每个文档和数据请求的 Response 对象的访问,提供了对应该缓存什么的完全控制。
BeeRich 的 dashboard 路由是包含用户特定数据的个性化页面。用户特定数据不得存储在共享缓存中,以避免泄露私人用户信息。dashboard 路由上的内容高度动态,我们应该只短暂缓存它,以避免过时的 UI 状态。
让我们利用 dashboard.tsx 路由上的 no-cache 和 private 指令为所有 dashboard 路由应用默认设置:
import type { HeadersFunction, LoaderFunctionArgs, MetaFunction, SerializeFrom } from '@remix-run/node';export const headers: HeadersFunction = () => {
return {
'Cache-Control': 'no-cache, private',
};
};
添加的缓存控制头指定,dashboard 路由上的 HTML 文档只能缓存在私有缓存中(例如,浏览器),并且任何请求都应该发送到服务器进行重新验证。
注意,no-cache 指令仍然允许在浏览器使用后退和前进按钮时重用内容。这与 no-store 不同,后者即使在后退和前进导航期间也会强制浏览器获取新内容。
太好了 – 我们现在已经学会了如何将缓存头应用于文档响应。但关于 loader 和 action 数据响应呢?
缓存不可变数据响应
在 Remix 中,我们还可以控制来自 loader 和 action 函数的数据响应的 HTTP 头部。因此,我们不仅可以设置文档的缓存控制,还可以设置数据响应的缓存控制。
BeeRich 中的大部分数据都是高度动态的。发票和费用数据可以编辑,并且必须始终是最新的。然而,费用和发票附件的情况不同。每个附件都有一个唯一的文件名(标识符),它是请求 URL 的一部分。最终,两个附件永远不会通过相同的 URL 提供服务。
让我们更新 BeeRich 中的附件逻辑,以利用 HTTP 缓存:
-
首先,更新
app/modules/attachments.server.ts中的buildFileResponse函数,以便它支持传递自定义头部:export function buildFileResponse(fileName: string, headers object to be passed in so that HTTP headers can be added to the file response object. -
接下来,更新
dashboard.expenses.$id.attachments.$.tsx资源路由模块中的loader函数:export async function loader({ request, params }: LoaderFunctionArgs) { const userId = await requireUserId(request); const { id } = params; const slug = params['*']; if (!id || !slug) throw Error('id and slug route parameters must be defined'); const expense = await db.expense.findUnique({ where: { id_userId: { id, userId } } }); if (!expense || !expense.attachment) throw new Response('Not found', { status: 404 }); if (slug !== expense.attachment) return redirect(`/dashboard/expenses/${id}/attachments/${expense.attachment}`); buildFileResponse function, which returns the file download response.Since we know that the attachment never changes – a new attachment would create a new filename – we apply the `immutable` directive and cache the asset for a year. Because the attachments contain sensitive user information, we set the cache to `private` to avoid shared caching. -
通过执行
npm run dev启动 BeeRich,并在浏览器中导航到费用详情页面。 -
接下来,下载附件两次,并在 网络 选项卡中检查第二次网络有效载荷:

图 12.3 – 附件已缓存到磁盘
太棒了!如图 12.3.3 所示,我们避免了在第二次下载请求中对 Web 服务器的请求。相反,附件是从浏览器的磁盘缓存中下载的。
缓存很困难,尤其是当你试图缓存特定于用户的数据时。你能想到当前实现中任何潜在的安全问题吗?
想象一下,一个用户从公共电脑登录 BeeRich 以访问费用附件。用户下载其中一个附件以打印它。然后,用户从公共电脑删除附件并从 BeeRich 注销。现在,恶意行为者能否从浏览器缓存中检索附件?有可能。
将请求 URL属性从Headers Network选项卡复制并粘贴到你的附件中。现在,从 BeeRich 注销,将被重定向到登录页面。将复制的请求 URL 粘贴到地址栏并按Enter。由于我们允许浏览器将其文档缓存到其私有磁盘缓存中,请求将不会发送到我们会验证用户的资源路由。相反,浏览器从内存或磁盘缓存中检索文档并将其提供给用户,这是一个潜在的安全漏洞。
在本节中,你了解了使用私有和公共缓存控制指令泄露用户数据的潜在安全风险。我们可以使用不同的缓存策略,而不是在浏览器缓存中缓存私有数据。接下来,我们将探讨实体标签。
使用实体标签缓存动态数据响应
对文档的 HTTP 请求可能导致不同的 HTTP 响应。状态码为 200 的响应通常包含包含请求文档的 HTTP 主体 - 例如,HTML 文档、PDF 或图像。
HTTP 请求-响应流允许我们授权用户访问,并可能通过 401(未授权)响应拒绝请求。在上一个章节中,我们在私有和共享缓存中缓存了数据,这缩短了请求-响应流,使其在缓存命中时无法到达我们的服务器。
在本节中,我们将探讨如何利用ETag和If-None-Match头,这样我们就可以避免重新发送完整的响应,但仍然在服务器上执行授权功能。
ETag头可能携带一个用于响应的唯一标识符(实体标签),客户端可以使用它将带有If-None-Match头的后续请求附加到相同的 URL。然后,服务器可以计算新的响应并将新标签与请求的If-None-Match头进行比较。
让我们更新dashboard.expenses.$id.attachments.$.tsx资源路由模块中的loader函数,看看它在实际操作中的样子:
export async function loader({ request, params }: LoaderFunctionArgs) { const userId = await requireUserId(request);
const { id } = params;
const slug = params['*'];
if (!id || !slug) throw Error('id and slug route parameters must be defined');
const expense = await db.expense.findUnique({ where: { id_userId: { id, userId } } });
if (!expense || !expense.attachment) throw new Response('Not found', { status: 404 });
if (slug !== expense.attachment) return redirect(`/dashboard/expenses/${id}/attachments/${expense.attachment}`);
const headers = new Headers();
headers.set('ETag', expense.attachment);
if (request.headers.get('If-None-Match') === expense.attachment) {
return new Response(null, { status: 304, headers });
}
return buildFileResponse(expense.attachment, headers);
}
我们使用附件标识符作为实体标签并将其附加到响应头中。如果客户端两次请求相同的附件,我们可以通过If-None-Match请求头访问之前发送的ETag头。
在 loader 函数中授权用户后,我们可以检查请求是否包含 If-None-Match 标头。在这种情况下,我们可以通过使用 304 状态码通知客户端响应没有变化。然后客户端可以使用缓存的响应体而不是重新下载附件。
通过重复前几节中的步骤来调查新的实现,下载相同的附件两次。请注意,你的浏览器的访客和隐身模式会在每个会话中重置缓存,这使得它们成为测试初始页面加载时间的优秀工具:

图 12.4 – 基于 ETag 的附件缓存
如图 12**.4所示,任何后续的附件下载现在都会触发一个收到 304 响应的请求。当检查 ETag(响应)和 If-None-Match(请求)标头时。
最后,复制请求 URL属性并登出。现在,通过导航到请求 URL 来尝试访问附件。注意,BeeRich 会重定向到登录页面。这是因为基于 ETag 的缓存触发了对服务器的请求。服务器随后检查会话 cookie 并根据情况重定向。
ETags 带有一套不同的权衡。当使用 ETags 来重新验证内容时,我们无法避免往返于 Web 服务器,但我们仍然可以避免下载相同的响应体两次。这作为一个良好的折衷方案,因为我们可以在服务器上执行授权和身份验证功能,同时通过重用现有的响应体来提高性能。
太好了!我们在 BeeRich 中实现了三种 HTTP 缓存策略:公共页面的公共缓存、动态仪表板页面的无缓存,以及私有静态资产的 ETags。你还学习了 Remix 如何默认使用 HTTP 缓存静态资产。
确保你更新了 dashboard.income.$id.attachments.$.tsx 资源路由,以便利用基于 ETag 的缓存进行发票附件。
HTTP 缓存有很多优点。在本章中,你了解了一些常见的策略,但还有很多其他的策略,例如过时但可验证的缓存策略。有关 HTTP 缓存策略的更多信息,请参阅进一步阅读部分。
接下来,让我们讨论如何在 Remix 服务器上利用缓存。
探索内存缓存
缓存的有效性随着缓存与用户距离的接近而提高。浏览器内缓存可以完全避免网络请求。基于 CDN 的缓存可以显著缩短网络请求。然而,我们可能也会放弃更多对缓存的控制,如果它离我们的 Remix 服务器越远。
在本节中,我们将讨论内存缓存策略,并了解内存缓存选项的优缺点。
HTTP 缓存可能并不总是正确的策略。例如,我们已经讨论了在缓存用户指定信息时的隐私问题。在某些情况下,在 Web 服务器上实现自定义缓存层可能是有意义的。
最简单的方法是将计算结果或获取的响应存储在服务器本身的内存中。然而,正如我们在第三章,“部署目标、适配器和堆栈”中学习的那样,这并不总是可能的。运行时环境,如边缘和无服务器,可能在每个请求后关闭,并且可能无法在请求之间共享内存。
在 BeeRich 中,我们使用了一个长期运行的 Express.js 服务器。长期运行的环境能够在请求之间共享内存。因此,我们可以使用服务器的内存来缓存数据。在内存中缓存数据可以让我们避免数据库查询和下游的获取请求。在内存中缓存数据是提高性能的绝佳方式。然而,我们也必须考虑内存限制和溢出问题。
或者,我们可以利用像 Redis 这样的低延迟内存数据库服务来存储计算或获取结果。当在无服务器或边缘运行时使用 Redis 作为缓存也是一个很好的解决方案,在这些环境中,请求之间可能无法共享内存。
但对于 BeeRich 呢?BeeRich 使用 SQLite 数据库,它为简单的查询提供了非常快的响应(几毫秒)。使用 Redis 可能不会提高性能,因为它会引入对 Redis 服务器的网络请求。
不幸的是,在现实世界中,数据库和 API 请求可能要慢得多。在这些情况下,将结果缓存到 Redis 或服务器内存中以重用获取的结果并避免后续缓慢的请求可能是有意义的。
一个很好的例子是我们的用户对象。我们在root.tsx loader函数中为每个进入的请求获取用户对象。我们可以确定我们读取用户对象的频率远高于更新它。如果响应变得缓慢,这可能是一个很好的迹象,表明将用户对象存储在内存缓存中。
内存缓存需要我们实现自定义的缓存失效逻辑,但这也可能在 HTTP 缓存不是最佳工具时提高性能。总之,如果我们的响应变得缓慢,并且我们确定缓慢的数据库或 API 查询是根本原因,那么添加像 Redis 这样的服务可能是一个很好的考虑。
摘要
在本章中,你学习了不同的缓存策略以及如何使用 Remix 实现它们。
Remix 的headers路由模块 API 导出允许我们为 HTML 文档在每个路由级别指定 HTTP 头。我们还有权访问loaderHeaders和parentHeaders,这允许我们合并 HTTP 头并根据加载器数据指定头。
你还学习了如何在 Remix 中缓存文档和数据请求。你学习了如何使用Cache-Control头指定和防止缓存。
然后,你应用了private、public、max-age、no-cache和immutable指令。此外,你还回顾了 Remix 如何默认实现 HTTP 缓存以用于静态资源。
接下来,你学习了缓存用户特定数据时的隐私问题以及如何使用 ETags 在向服务器发送请求以检查用户授权时避免下载完整的响应。
最后,我们讨论了内存缓存以及使用 Redis 等服务来避免对缓慢的第三方服务或数据库的请求。
在下一章中,我们将学习关于延迟加载器数据的内容。与缓存一样,延迟加载器数据是提高 Web 应用程序用户体验和性能的强大杠杆。
进一步阅读
你可以在 MDN Web Docs 中了解更多关于 CDN 的信息:developer.mozilla.org/en-US/docs/Glossary/CDN。
你也可以在 MDN Web Docs 中找到 HTTP 缓存概念的概述:developer.mozilla.org/en-US/docs/Web/HTTP/Caching。
MDN Web Docs 还提供了关于每个 HTTP 缓存头部的详细信息:
请参考 Remix 文档以获取有关 Remix 的headers路由模块 API 的更多信息:remix.run/docs/en/2/route/headers。
Ryan Florence 在 Remix YouTube 频道上录制了两段关于缓存的精彩视频。有趣的事实——它们是 Remix YouTube 频道上上传的第一批视频,值得一看:
-
Remix Run – HTTP 缓存简介:
www.youtube.com/watch?v=3XkU_DXcgl0 -
CDN 缓存、静态站点生成和服务器端渲染:
www.youtube.com/watch?v=bfLFHp7Sbkg
你还可以在 Sergio 的博客上找到使用 Remix 的 ETags 的出色指南:sergiodxa.com/articles/use-etags-in-remix。
第十三章:延迟加载器数据
在服务器上执行数据加载可以加快初始页面加载时间并提高核心 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 的
EventEmitterAPI 并为我们的服务器环境声明一个全局可访问的事件发射器。EventEmitter对象可用于监听和发出事件。我们将在action函数中使用emit函数将数据更改通知给服务器发送事件连接处理代码。注意,
EventEmitterAPI 与 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.


浙公网安备 33010602011771号