Remix-全栈-Web-开发-二-

63 阅读1小时+

Remix 全栈 Web 开发(二)

原文:zh.annas-archive.org/md5/01f9f3d7f1c9ae14a772b9c9cf48d6f3

译者:飞龙

协议:CC BY-NC-SA 4.0

第六章:提升用户体验

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 的LinkNavLink组件。这些组件在 JavaScript 的帮助下增强了浏览器的默认行为。如果没有 JavaScript,Remix 的LinkNavLink组件仍然会渲染一个浏览器可以与之交互的锚点标签。

如果 JavaScript 可用,LinkNavLink将执行客户端导航并从服务器获取 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,我们被迫将逻辑从客户端移动到我们的服务器端actionsloader函数。考虑到渐进增强,以减少基准用户体验的客户端代码复杂性是一种很好的方法。一旦实现了基准体验,我们就可以添加客户端 JavaScript。

在改进之前先使其变得更糟

一旦在没有 JavaScript 的情况下使其工作,我们就可以考虑使用 JavaScript 进一步增强体验。需要注意的是,启用 JavaScript 将使体验变得更差,而不是更好。默认情况下,浏览器通过在浏览器窗口的标题标签中显示加载旋转器来指示页面加载。完整页面刷新也会重置客户端状态,如表单输入,并使用最新的服务器数据重新验证 UI。通过防止浏览器默认行为,我们摆脱了这些功能。

如果我们跳过在转换或提交期间刷新客户端状态、重置表单和显示加载指示,用户体验将受到影响。因此,我们需要使用 JavaScript 将这些功能重新添加回来,当我们阻止浏览器执行其默认操作时。

我们已经在第四章Remix 中的路由中添加了自定义加载指示。然而,无论何时我们向应用程序添加新的表单,我们都应该调查体验并查看是否需要额外的挂起指示。

在慢速网络上进行测试

在慢速网络上进行测试是检查应用程序用户体验的好方法。特别是在测试挂起状态时特别有帮助。以创建费用表单为例;你可能没有注意到当应用程序在你的本地机器上运行时,缺少加载指示。

浏览器的开发者工具提供了一个切换选项,可以限制你的连接到预设或自定义带宽。将限制设置为慢速 3G允许你在开发快速 Wi-Fi 时无法实现的方式测试你的应用程序。

让我们在慢速 3G 连接上测试 BeeRich:

  1. 通过在项目的根目录中执行npm run dev来运行 BeeRich。

  2. 确保 JavaScript 已启用,以防之前已禁用。

  3. 在你的浏览器中打开应用程序。

  4. 打开开发者工具并导航到网络标签页。

  5. 查找限制功能并选择慢速 3G

  6. 在你的浏览器窗口中打开费用表单(http://localhost:3000/dashboard/expense/)。

  7. 填写表单并点击提交

    注意,提交按钮保持活动状态,可以再次点击。用户并不完全清楚表单目前正在提交。

  8. 在你的编辑器中打开dashboard.expenses._index.tsx路由模块。

  9. 更新路由组件,使其使用useNavigation钩子来推导我们应用程序的当前转换状态:

    import { formAction property. We only want to show the pending UI for this form if this form is being submitted.
    
  10. 使用限制的网络连接测试新的挂起 UI。

注意我们如何通过修改提交按钮来增强用户体验。在慢速连接上提交创建费用表单可能需要几秒钟时间。现在,我们有一个清晰的加载指示。

渐进增强是关于使应用程序尽可能多地对用户可访问。考虑到渐进增强,确保基本用户体验足够简单,可以在较旧的浏览器和设备上使用。

结果表明,以渐进增强为前提构建可以创建一个更简单的心理模型来构建出色的用户界面。首先,我们创建没有 JavaScript 的基本实现。一旦基本实现工作正常,我们就专注于使用 JavaScript 增强体验。我们通过限制网络来测试应用程序。这迫使我们构建一个具有弹性的用户体验,可以上下扩展。Remix 通过提供原语和约定来支持我们,使我们能够进行增量更改以增强体验,直到我们满意。

我们才刚刚开始!Remix 可以扩展到高度动态的体验。接下来,我们将学习如何使用 Remix 预取数据以减少页面转换时间。

预取数据

在本节中,我们将学习如何在 Remix 中预取资产和 loader 数据,以及如何利用预取来加快转换时间。

Remix 在构建时将routes文件夹编译成一个路由层次结构。层次结构信息存储在public文件夹中的一个资产清单中。这个资产清单由 Remix 的前端和后端应用程序共同使用。

由于 Remix 可以访问客户端的资产清单,Remix 预先知道在转换到路由时需要调用哪些loader函数。这允许 Remix 在转换之前预取 loader 数据(和路由资产)。

在 Remix 中启用预取与设置我们想要预取数据的链接上的属性一样简单:

  1. 在您的编辑器中打开/app/routes/dashboard.tsx文件。

  2. 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`.
    
  3. 在您的浏览器窗口中访问收入概览页面(localhost:3000/dashboard/income)。

  4. 打开您的开发者工具的网络标签页。

  5. 清除请求列表并筛选所有请求。

  6. 现在,将鼠标悬停在导航中的费用链接上。

  7. 检查网络标签页。现在,它应该列出四个预取请求,如图 图 6*.1* 所示:

图 6.1 – 检查预取请求

图 6.1 – 检查预取请求

从收入路由跳转到 /dashboard/expenses/$id 的转换与以下路由模块匹配:

  • dashboard.tsx

  • dashboard.expenses.tsx

  • dashboard.expenses.$id.tsx

dashboard.tsx 路由模块已经在页面上激活,无需重新加载。Remix 只为其他两个路由模块加载资产和加载器数据。我们可以在 图 6.1 中看到四个预取请求。有两个是 JSON 内容类型的请求,用于获取所需的加载器数据,还有两个请求用于获取两个新路由模块的代码分割 JavaScript 包。

  1. 通过在 网络 选项卡中点击它们来检查请求。这应该会打开一个请求详情视图。检查嵌套的 预览响应 选项卡。JSON 响应包含两个路由模块的加载器数据。

预取数据是可选的。使用预取,我们可以拉一个杠杆来减少请求时间,但如果用户不访问链接,则可能会引入获取不必要数据的风险。

在渲染时预取是最激进的策略,而在意图上预取则是基于用户在页面上的操作。

Remix 提供了杠杆

预取是我们可以拉的杠杆。使用预取通过增加在网络中下载不必要数据的风险来减少响应时间。Remix 通过提供杠杆允许我们根据我们的用例和需求优化我们的应用程序。

现在我们已经了解了预取,让我们更深入地了解突变。

处理操作数据

loaderaction 函数包含我们 Remix 应用程序的大部分业务逻辑。这是我们获取、过滤和更新数据的地方。这两个函数都必须返回一个 Response 对象。您已经了解了 redirectjson 辅助函数,它们让我们可以创建特定的 Response 对象,并且您已经练习了处理加载器数据。在本节中,我们将学习如何处理操作数据。为此,我们将更新费用详情视图并实现编辑费用表单:

  1. 在您的编辑器中打开 dashboard.expenses.$id.tsx 路由模块。

  2. dashboard.expenses._index.tsx 中获取当前代码。您能修改代码以编辑现有的费用吗?试试看!

    本章的最终代码可在 GitHub 的 /bee-rich/solution 文件夹中找到。随着我们继续前进,我们将帮助您将您的工作与这个最终解决方案对齐。

  3. 确保使用加载器数据中的 expense 对象更新表单的 action 属性:

    <Form method="POST" action={`/dashboard/expenses/${expense.id}`}
    
  4. 如果您还没有,更新 isSubmitting 常量的 formAction 检查:

    const navigation = useNavigation();const isSubmitting = navigation.state !== 'idle' && navigation.formAction === `/dashboard/expenses/${expense.id}`;
    

    我们再次使用 useNavigation 钩子来计算是否应该将挂起的指示添加到表单中。请注意,同样地,我们确保提交的表单操作与该表单的 action 属性匹配。

  5. 接下来,更新路由组件的表单字段:

    <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.
    
  6. 接下来,向 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.
    
  7. 将以下 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.
    
  8. 将缺少的 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.
    
  9. 导入 useActionData 钩子:

    import { useActionData in the route module’s component to access the return data of action:
    
    

    typeof 操作符。请注意,与加载器数据不同,操作数据可以是未定义的。

  10. 使用操作数据在 提交 按钮下方显示成功消息:

    <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.
    
  11. 运行应用程序并测试更新的支出表单!

太棒了!就这样,我们可以利用 action 数据来传达成功的突变。确保您在 income 路由上实现相同的功能。尝试在不看说明的情况下适应收入路由模块。这将帮助您更好地理解本章的要点。如果您遇到困难,请回顾本章的说明。您还可以在 GitHub 上找到本章的解决方案代码。

仅在相同的路由模块中使用 useActionData

注意,表单提交会导航到 action 函数的位置。useLoaderData 只能访问同一路由加载器的加载数据。同样,useActionData 必须在提交表单的 action 函数的路由模块中使用。

Remix 还提供了高级数据突变工具。接下来,我们将添加删除支出的功能,并学习如何在 Remix 中处理并发突变。

处理并发突变

到目前为止,我们已经创建了支出创建和编辑表单。这两个表单都在各自的页面上独立存在。本节将教您如何同时管理多个表单提交。让我们首先为支出概览列表中的每个项添加删除表单。

将表单添加到列表中

本节的目标是为支出概览列表中的每个列表项添加删除表单。点击项应删除相关的支出。让我们开始:

  1. 如果还没有,请遵循 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 组件的说明。

  2. 接下来,打开 dashboard.expenses.$id.tsx 路由模块。

  3. 在路由模块中添加一个 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.
    
  4. 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.
    
  5. 接下来,在您的编辑器中打开 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.
    
  6. 在路由模块组件中使用 useParams 钩子访问支出详情页的 id 路由参数:

    const { id } = useParams();
    
  7. 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.
    
  8. 让我们尝试一下我们的实现。在本地运行应用程序,访问费用概览页面,并通过点击action函数来删除费用对象。该函数处理提交并将用户重定向回当前页面或费用概览页面。在突变之后,Remix 重新获取加载器数据,这触发了重新渲染。费用对象从费用列表中消失。

太好了!费用列表中的每个列表项现在都包含一个用于删除费用的表单。接下来,让我们在删除时指示待处理状态。

支持多个待处理状态

我们已经知道我们可以使用useNavigation钩子来访问全局导航对象。导航对象的状态属性指示我们应用程序的当前转换状态。让我们使用useNavigation钩子来指示删除表单的待处理删除:

  1. /app/components/links.tsx中导入useNavigation

    import { Form, Link as RemixLink, NavLink as RemixNavLink, ListLinkItem function body:
    
    

    const navigation = useNavigation();

  2. 推断表单当前是否正在提交或加载:

    const isSubmitting =  navigation.state !== 'idle' &&  navigation.formAction === deleteProps?.action &&  navigation.formData?.get('intent') === 'delete';
    

    注意,我们在这里采取了额外的安全措施。我们检查当前是否有页面导航正在进行,以及formAction是否与该表单的action函数匹配。最后,我们还要确保表单的intent值与该表单提交按钮的intent值匹配。这确保了只有当此删除按钮被点击时,我们才显示待处理状态。

  3. 最后,使用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.
    
  4. 在开发模式下运行应用程序,并在浏览器窗口中打开localhost:3000/dashboard/expenses

  5. 打开浏览器开发者工具的网络标签页,并将限速设置为慢 3G。这有助于我们更长时间地体验待处理的 UI。同时,确保通过Fetch/XHR请求进行筛选。

  6. 尝试一次性删除多个费用,看看你是否发现了任何问题。

    你可能会注意到当前实现中存在一个缺陷:删除费用似乎会取消所有其他正在进行的删除操作。

你能解释为什么是这样吗?Remix 的导航对象捕获了我们应用程序的全局导航状态。一次只能有一个页面导航。如果用户提交第二个表单,那么 Remix 将取消第一个导航,如图 6**.2所示。导航对象被更新,反映了第二个表单的提交:

图 6.2 – 取消 fetch 请求

图 6.2 – 取消 fetch 请求

注意,删除操作并未被取消;我们只是失去了待处理指示。在客户端,Remix 取消当前提交并相应地更新导航对象。然而,删除费用的 fetch 请求仍然到达服务器,并且操作被执行。Remix 还确保只有在每个提交都已执行之后,才重新验证加载器数据,以避免在最新提交比之前的提交更快完成时出现过时数据。

Remix 跟踪所有正在进行的提交,但只处理最后的导航

请记住,Remix 旨在模拟浏览器的默认行为。只能有一个页面导航。在并发表单提交中,最后一个用户操作决定最终的页面导航。在后台,Remix 会跟踪所有正在进行的提交以管理加载器数据重新验证。

让我们修复丢失的挂起指示。我们希望为每个当前挂起的删除显示挂起 UI。幸运的是,Remix 为我们提供了另一种声明表单的方法。Remix 的 useFetcher 钩子可以声明具有独立提交状态的 Form 组件:

  1. /app/components/links.tsx 中导入 useFetcher,替换 useNavigationForm 的导入:

    import { Link as RemixLink, NavLink as RemixNavLink, useNavigation hook declaration in the ListLinkItem component with a call to useFetcher:
    
    

    const fetcher = useFetcher();

  2. 接下来,更新 isSubmitting 常量的分配:

    const isSubmitting = fetcher.state !== 'idle';
    

    useFetcher 返回的 fetcher 对象具有独立的提交生命周期和导航状态。状态不受应用程序中其他提交或加载活动的影响。

    注意,useFetcher 表单提交仍然会在修改后触发所有活动的 loader 函数重新加载。这会将全局导航状态设置为 loading。然而,useFetcher 表单提交不会将全局导航对象的状态设置为 submitting

  3. 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.
    
  4. 现在,使用费用创建表单创建一些费用。

  5. 打开开发者工具并导航到 网络 选项卡。

  6. 调整到 慢速 3G

  7. 过滤 Fetch/XHR 请求。

  8. 现在,点击删除 (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属性设置为renderviewportintentnone来决定我们希望多积极地预取数据。你进一步了解了动作数据,这些数据可以在突变后用来传达错误或成功状态。

最后,你学习了 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)错误

首先,我们将制造一些意外错误并学习如何处理它们。接下来,我们将回顾在loaderaction函数中返回和抛出Response对象之间的区别。我们将看到如何使用 Remix 的ErrorBoundary处理抛出的响应。最后,我们将向 BeeRich 添加未找到错误处理。

阅读本章后,您将了解如何使用 Remix 的ErrorBoundary组件声明式地管理意外和预期失败。您还将知道抛出响应如何融入 Remix 的异常处理故事,以及抛出和返回响应之间的区别。最后,您将了解如何使用 Remix 处理未找到错误。

技术要求

您可以继续使用上一章中的解决方案。本章不需要额外的设置步骤。如果您遇到困难,可以在此处找到本章的解决方案代码:github.com/PacktPublis…

处理意外错误

在运行时,Remix 应用程序在浏览器和服务器上执行。可能会出错,客户端和服务器上可能会发生意外错误。考虑错误情况以提供弹性用户体验非常重要。在本节中,我们将研究如何在 Remix 的根级别和嵌套路由中处理意外错误。

调用客户端和服务器错误

第二章《创建新的 Remix 应用程序》中,我们提供了一个故障排除指南,并调查了 Remix 如何统一处理客户端和服务器上的错误。让我们通过调用一些“意外”的错误来再次回顾 Remix 的默认错误处理:

  1. 在编辑器中打开您的 BeeRich 应用程序。

  2. app/routes文件夹中打开dashboard.tsx路由模块。

  3. loader函数体中返回语句之前添加以下代码:

    throw Error('Something went wrong!');
    

    通过在loader函数中抛出错误,我们阻止了应用程序处理传入的请求。这会在服务器上创建一个意外的失败。

  4. 在终端中执行npm run dev以运行应用程序。

  5. 在新浏览器窗口中打开localhost:3000/dashboard以访问 BeeRich 仪表板。

    loader函数中抛出错误会渲染 Remix 的错误页面,如图 7.1所示:

图 7.1 – loader函数中的应用错误

图 7.1 – 加载函数中的应用错误

图 7.1 展示了 Remix 处理意外错误时的默认行为。我们可以看到屏幕上显示的“出了点问题!”错误消息以及未能执行的 loader 函数的堆栈跟踪。

  1. 现在,让我们在路由组件中抛出一个错误。将错误从 loader 函数移动到 dashboard.tsx 路由组件内部:

    export default function Component() {  throw Error('Something went wrong!');
    
  2. 刷新浏览器窗口。

    通过重新加载页面,我们触发整个页面的重新加载。Remix 在服务器上处理初始文档请求。因此,仪表板路由模块组件首先在服务器上被调用,我们再次在服务器上抛出错误。

    错误堆栈跟踪会改变,但 Remix 仍然在屏幕上显示“出了点问题!”。

  3. 现在,将错误包裹在一个 useEffect 钩子中,以确保错误在客户端执行:

    import { useEffect } from 'react';export default function Component() {  useEffect(() => {    throw Error('Something went wrong!');  }, []);
    

    React 的 useEffect 钩子仅在客户端执行,不在服务器上执行。这是因为钩子在初始渲染之后执行,而在服务器上我们只渲染一次。

  4. 再次刷新浏览器窗口。你应该在页面上看到另一个堆栈跟踪。这次,堆栈跟踪来自客户端脚本。

    图 7.2 所示,请注意堆栈跟踪中的文件名包含哈希值。这意味着文件已被打包,并且是客户端打包的一部分:

图 7.2 – 客户端应用错误

图 7.2 – 客户端应用错误

这个实验告诉我们 Remix 为浏览器和服务器错误提供了相同的默认体验。接下来,让我们用自定义 UI 替换 Remix 的默认错误页面。

使用根错误边界处理错误

在 React 中,渲染过程中的失败可以通过错误边界来处理。错误边界是实现了错误边界生命周期方法的类组件。Remix 在 React 的错误边界之上构建,并扩展了其功能以处理服务器端错误。我们不是在组件树中嵌套 React 错误边界,而是通过在路由模块 API 中导出 ErrorBoundary 组件来声明它们。

root.tsx 路由模块中的 ErrorBoundary 组件是我们应用程序中最顶层的错误边界。为了替换 Remix 的默认错误页面,我们需要从 root.tsx 导出 ErrorBoundary 组件:

  1. 在您的编辑器中打开 app/root.tsx 路由模块。

  2. 从 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 访问导致失败的错误。我们进一步使用错误对象来显示错误信息。

  3. 制造一个意外的错误,并刷新浏览器窗口以检查更新的错误页面:

图 7.3 – 自定义根 ErrorBoundary 组件

图 7.3 – 自定义根 ErrorBoundary 组件

图 7.3 所示,我们现在为我们的应用程序渲染了一个自定义错误页面。

  1. 检查 root.tsxErrorBoundary 组件的代码。注意我们渲染了我们的样式化 H1ButtonLink 组件。为什么我们的自定义样式没有应用到页面上?

  2. 检查 MetaLinksScripts 组件,将我们的元数据和链接标签以及客户端 JavaScript 脚本附加到 HTML 文档中。这发生在 root.tsx 中的路由模块组件中。然而,在发生错误时,我们不渲染路由模块组件,而是渲染 ErrorBoundary 组件。

    Remix 默认将错误边界的内容包裹在一个 HTML body 标签中。然而,我们也可以提供一个自定义的 HTML 文档。让我们更新代码,以便在 root.tsx 文件中的 ErrorBoundary 组件中渲染 MetaLinksScripts 组件。

  3. root.tsx 中创建一个新的 Document 组件,以便我们可以在 AppErrorBoundary 组件之间重用代码:

    function Document(Document component renders the JSX from the App component. We just replaced Outlet with children.
    
  4. 现在,更新 App 组件以便它使用 Document

    export default function App() {  return (    App component remains unchanged. We just moved the code into the reusable Document component.
    
  5. 接下来,也将错误边界的内容包裹在 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 组件

图 7.4 – 样式化根 ErrorBoundary 组件

太棒了!现在,我们可以在根 ErrorBoundary 组件中利用客户端 JavaScript 和我们的自定义样式。当我们为 ErrorBoundary 组件重用组件时,我们必须记住的一件事是我们不能调用 useLoaderData 钩子。确保不要渲染访问 loader 数据的组件,因为在 ErrorBoundary 组件渲染时,loader 数据未定义。

错误边界无法访问 useLoaderData

如果路由模块的 loaderaction 函数中发生错误,则会渲染错误边界。最终,错误边界中不可用 loader 数据。

BeeRich 中的顶级 ErrorBoundary 组件不渲染任何导航栏或其他布局组件。保持顶级 ErrorBoundary 组件简单可以确保即使在意外失败的情况下也能渲染。

接下来,让我们看看如何通过声明嵌套错误边界来进一步改进错误处理。

嵌套错误处理

错误边界可以嵌套。当抛出错误时,它会通过路由层次结构向上冒泡,直到 Remix 找到最近的错误边界。嵌套错误边界让我们能够包含错误。

让我们在仪表板路由模块中添加一个嵌套错误边界:

  1. 打开 app/routes 文件夹内的 dashboard.tsx 路由模块。

  2. 在页面中添加一个简单的 ErrorBoundary 导出:

    export function ErrorBoundary() {  return <p>Error contained in dashboard.tsx</p>;}
    
  3. 现在,在 dashboard.tsx 路由模块的 loader 函数中抛出一个错误:

    throw Error('Something went wrong!');
    
  4. 运行应用并在新浏览器窗口中打开 localhost:3000/dashboard 访问仪表板。

    注意,我们不是渲染根错误边界,而是在 dashboard.tsx 中嵌套的错误边界。Remix 使用最近的可用错误边界并在父路由组件的 Outlet 中渲染它。

  5. 让我们让错误边界看起来更美观。就像我们在 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`.
    
  6. Layout 函数体中移除 useLoaderData 调用。由于错误边界不能调用 useLoaderData,我们必须将加载数据作为可选属性传递。如 LayoutProps 所见,我们确保 Layout 组件也接受 null 作为 firstExpensefirstInvoice 的值。这可能是在我们的数据库中没有找到任何费用或发票时,或者当错误边界被渲染时的情况。

  7. 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.
    
  8. 接下来,更新 dashboard.tsx 中的 ErrorBoundary 组件:

    import { Link as RemixLink, Outlet, useLoaderData, useLocation, loader function for a moment.
    
  9. 运行应用程序并访问 localhost:3000/dashboard 页面。

    如果没有抛出错误,我们应该看到费用概览页面。如果你不记得这是为什么,请查看 dashboard._index.tsx 路由模块内部。如果仪表板的索引路由是活动的,它将重定向到费用概览页面。

  10. 接下来,在任何活动在仪表板内的 loader 函数或 React 组件中抛出错误:

    throw Error('Something went wrong!');
    

    浏览器窗口现在应该渲染样式化的嵌套错误边界。如 图 7*.5* 所见,将错误边界添加到仪表板路由中使我们能够隔离错误并正确渲染所有父路由:

图 7.5 – 嵌套仪表板 ErrorBoundary 组件

图 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 利用这一点,提供了一个约定来使用异常响应提前停止 actionloader 函数。让我们调用在费用详情 loader 中抛出的未找到响应:

  1. 通过执行 npm run dev 在本地主机上运行 BeeRich。

  2. 在新浏览器窗口中打开 localhost:3000/dashboard/expenses 访问费用概览页面。

  3. 现在,点击概览列表中的费用。这将将我们重定向到费用详情页面。

  4. 将 URL 中的 id 路由参数替换为假的:localhost:3000/dashboard/expenses/fake-id。然后,重新加载浏览器窗口。

这应该会将我们的仪表板错误边界渲染到页面上。

Remix 允许我们使用 ErrorBoundary 组件统一处理意外的错误和抛出的响应。在 loaderaction 函数中抛出的任何 Response 对象都会触发 ErrorBoundary 组件。

抛出的响应允许我们检索额外的信息,例如 ErrorBoundary 组件中错误的状态码。为此,我们需要检查抛出的错误对象是否是 Response 对象或意外的错误。

使用错误边界处理异常

让我们添加第三个错误边界,这次是为嵌套费用详情页面:

  1. 打开 dashboard.expenses.$id.tsx 路由模块。

  2. 导入 Remix 的 useRouteErrorisRouteErrorResponse 辅助函数:

    import {  isRouteErrorResponse,  useActionData,  useLoaderData,  useNavigation,  useParams,  useRouteError,} from '@remix-run/react';
    
  3. 创建一个新的 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.
    
  4. 接下来,测试实现。导航到费用详情页面,并在 URL 中使用假的 id 路由参数。你现在应该能看到页面上的嵌套 ErrorBoundary 组件被渲染。

    注意我们仍然渲染了费用概览列表;这就是嵌套错误处理的力量!

图 7.6 – 嵌套费用详情 ErrorBoundary 组件

图 7.6 – 嵌套费用详情 ErrorBoundary 组件

既然我们已经为费用详情页面实现了嵌套错误边界,那么接下来为收入详情页面实现相同的体验。

一旦你完成收入详情页面的工作,我们将重新审视来自 第六章增强用户体验 的不愉快体验。

创建一个有弹性的体验

你还记得我们在第六章,“增强用户体验”中引入支出删除表单时创建了一个不愉快的体验吗?如果你在一个支出详情页(dashboard/expenses/$id)上,并且一次性快速删除所有支出,你可能会结束在一个未找到的页面上。这是因为我们在dashboard/expenses/$iddeleteExpense函数中的重定向逻辑:

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,并且删除了id2的支出,我们不希望将用户从/dashboard/expenses/1重定向走。然而,在deleteExpense函数中我们也有一个if条件来确保如果用户当前在要删除的支出的详情页上,我们会重定向用户。

当我们快速删除多个支出时,这个逻辑会失败。同时触发多个支出删除操作会创建不同动作请求之间的竞争条件。最终决定用户将被重定向到哪里的将是最后触发的action的响应。然而,到那时,我们可能已经删除了当前在详情视图中查看的支出。

假设我们正在详情页,/dashboard/expenses/1,并且快速删除了id1的支出,然后又删除了id2的支出。在处理删除支出1deleteExpense函数中,我们知道支出1已经被删除,因此我们返回重定向到/dashboard/expenses。然而,在处理删除支出2deleteExpense函数中,我们将返回重定向到当前页/dashboard/expenses/1。Remix 会取最后用户动作的响应并提交重定向到/dashboard/expenses/1。我们在loader函数中抛出一个 404 未找到错误,因为id1的支出找不到(它已经被删除了)。

如*图 7**.6 所示,我们通过引入嵌套的ErrorBoundary组件来增强支出详情的用户体验。太棒了!现在,如果出现 404 错误,用户将停留在支出概览页,错误被包含在嵌套的ErrorBoundary中。我们避免了向用户显示全屏的错误消息,而是优雅地将未找到错误作为仪表板 UI 的一部分显示。

声明式错误处理

Remix 的错误边界允许我们声明式地处理错误和异常。通过添加嵌套的错误边界,我们可以优雅地处理边缘情况,以提供弹性的用户体验。

现在我们已经通过嵌套错误边界确保了弹性的用户体验,让我们增强根错误边界,为常见的 HTTP 状态码添加自定义错误消息。在下一节中,我们将处理页面未找到错误(404)。

处理页面未找到(404)错误

我们还可以在根ErrorBoundary组件中处理抛出的响应。一个只能在根ErrorBoundary组件中处理的特殊情况是 Remix 抛出的页面未找到异常。让我们回顾根错误边界以在根级别处理抛出的响应:

  1. 通过执行npm run dev在本地运行 BeeRich。

  2. 在新浏览器窗口中访问一个不存在的页面,例如localhost:3000/cheesecake

    当访问一个不存在的路由时,Remix 会在根级别抛出一个带有 HTTP 状态码 404 的响应。我们可以使用isRouteErrorResponse来使用根错误边界渲染一个 404 页面。

  3. 在编辑器中打开root.tsx文件。

  4. 从 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>)}
    
  5. 再次访问localhost:3000/cheesecake

    你现在应该能看到图 7.7中显示的 404 未找到页面。7*:

图 7.7 – BeeRich 的 404 页面截图

图 7.7 – BeeRich 的 404 页面截图

太好了!我们已经将根和嵌套错误边界添加到了 BeeRich 中。请注意,我们可以在loaderaction函数中抛出自定义的 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 在 loaderaction 函数中验证用户身份。

在阅读本章之后,您将了解如何在 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 提供的查询字符串匹配的支出:

  1. 在你的编辑器中打开dashboard.expenses.tsx路由模块,检查模块的loader函数。

  2. 首先,将request参数添加到loader函数的参数中:

    import type { URL object and get the q search parameter:
    
    

    `export async function loader({ request }: LoaderFunctionArgs) {  q - 查询的简称。你可以在 MDN Web 文档中找到更多关于 URL 接口的信息:developer.mozilla.org/en-US/docs/…

  3. 更新数据库查询,使其只返回标题包含搜索字符串的支出:

    const expenses = await db.expense.findMany({  orderBy: {    createdAt: 'desc',  },  where: {    title: {      contains: searchString ? searchString : '',    },  },});
    

    如果 URL 不包含查询字符串,我们针对空字符串进行搜索,这将匹配所有支出。在这种情况下,loader函数的行为与之前相同。

  4. 通过在终端中执行npm run dev来以开发模式运行 BeeRich,并导航到支出概览页面(localhost:3000/dashboard/expenses)。

    由于我们没有在 URL 中包含查询字符串,我们仍然返回完整的支出列表。

  5. 接下来,通过添加查询字符串(例如localhost:3000/dashboard/expenses?q=Groceries)更新 URL 栏中的 URL,并刷新页面。现在应该显示一个过滤后的支出列表。

太好了!现在loader函数在存在q搜索参数时处理它,并返回一个过滤后的支出列表。接下来,让我们添加一个搜索输入字段,让用户可以搜索特定的支出。

通过表单提交更新搜索参数

接下来,我们将为用户提供一个搜索输入字段:

  1. 可选地,禁用 JavaScript 以确保基本实现可以在没有客户端 JavaScript 的情况下工作。

    您可以在浏览器开发者工具中禁用 JavaScript 或通过从 root.tsx 中移除 Script 组件来实现。

  2. @remix-run/react 中导入 Remix 的 Form 组件:

    import { useNavigation, Outlet, useLoaderData, useParams, Input component:
    
    

    import { Input } from '~/components/forms';

  3. 接下来,使用 FormInput 组件在 所有支出 屏幕阅读器标题和无序列表支出之间实现一个搜索输入字段:

    <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.
    
  4. 从 Remix 中导入 useLocation 钩子以访问当前 URL 路径:

    import { Form, Outlet, useLoaderData, useLocation, useNavigation, useParams } from '@remix-run/react';
    
  5. 在路由组件的功能体中访问位置:

    const location = useLocation();
    
  6. 使用当前位置的路径动态设置表单的动作:

    <Form method="GET" action={location.pathname}>
    

    提交现在会创建一个对当前页面的 GET 请求,并带有更新后的搜索参数以过滤支出列表。

  7. 通过输入搜索查询并按 Enter 键提交表单来尝试新的搜索输入字段。

  8. 注意,在每次完整页面重新加载后,搜索输入字段都是空的,即使设置了 q 搜索参数。

  9. 从 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`.
    
  10. searchQuery 值用作输入字段的 defaultValue 属性:

    <Input name="q" type="search" label="Search by title" searchQuery by default, even during server-side rendering:
    

图 8.1 – 过滤支出列表的截图

图 8.1 – 过滤支出列表的截图

太棒了!在输入搜索查询后按 Enter 键会提交表单并更新 URL,使其包含搜索查询。然后 loader 函数返回更新后的过滤支出列表。注意,我们没有使用任何 React 状态来实现此功能,并且始终如一,搜索功能在没有 JavaScript 的情况下也能工作。

将 UI 映射到 URL

搜索参数相较于 React 状态的优势在于它们可以通过读取请求 URL 在服务器上访问。搜索参数在完整页面重新加载时保持不变,并且与浏览器的后退和前进按钮一起工作。此外,搜索参数创建的 URL 变体可以被 CDNs 和浏览器缓存。

为收入概览路由实现相同的行为。更新 dashboard.income.tsx 路由模块的 loader 函数并实现搜索表单以查询发票。一旦更新了收入路由,我们就可以通过自定义 JavaScript 来增强体验。

程序化提交表单

目前,用户需要按 Enter 键来触发新的搜索。让我们添加一个防抖搜索,当用户更改输入字段中的值时自动提交表单:

  1. 首先,从 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.
    
  2. 在路由模块的组件体中,通过调用 useSubmit 创建一个新的提交函数:

      const submit = useSubmit();
    
  3. 将以下更改事件处理程序添加到搜索输入:

    Replace code example with just this line:onChange={(e) => submit(e.target.form)}
    

    在更改时,我们通过 submit 函数程序化地提交表单。我们将 submit 传递 HTML 表单元素,从事件的目标对象中访问它。

  4. 尝试当前实现,并在搜索输入字段中输入一些内容。

    你可能会注意到,我们目前为每个更改事件提交一个新的表单。这并不高效。相反,我们应该延迟提交,直到用户完成输入。这种延迟函数调用的方法称为防抖。

  5. Input替换为SearchInput

    import { SearchInput component adds debouncing with a 500-millisecond delay. Refer to the implementation in /app/components/forms.tsx.
    
  6. 现在,更新 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.
    
  7. 由于提交现在是在SearchInput组件内部处理的,因此请从dashboard.expenses.tsx路由模块中移除useSubmit钩子。

你现在知道了在 Remix 中提交数据的三种方式:Form组件、useFetcher钩子和useSubmit钩子。这引发了一个问题:何时最好使用哪种实用工具。

何时使用 Remix 数据获取原语

使用Form组件处理页面上的主要交互。Form组件是实现 Remix 中表单交互的最直接方式。对于所有简单用例,请坚持使用Form组件。

当你想以编程方式提交Form组件(例如,在更改时)时,请使用useSubmit钩子。你可以将useSubmit添加到Form实现中,以逐步增强体验。

记住,一次只能有一个活动导航。使用useFetcher钩子实现应支持并发提交的表单列表或辅助用户交互。辅助交互通常不旨在触发页面导航,并且应该有权访问隔离的导航状态和操作数据。每当你想以编程方式触发useFetcher钩子的Form组件时,可以使用useFetcher.loaduseFetcher.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:

  1. createCookie 用于读取和写入 cookie

  2. createCookieSessionStorage 用于实现使用 cookie 的会话存储

在本章中,我们将使用 Remix 的 createCookieSessionStorage 函数,因为我们的目标是实现用户会话进行身份验证和授权。我们将在 第十五章 高级会话管理 中查看 createCookie 辅助函数,以持久化访客跟踪数据。

使用 Remix 的会话辅助函数

让我们在 BeeRich 中实现注册和登录页面:

  1. 首先,遵循本章节文件夹中 GitHub 上的 README.md 文件来为 BeeRich 准备本节。

  2. 在您按照 README.md 文件中的设置指南完成设置后,打开编辑器中的 modules/session/session.server.ts 文件。

  3. 接下来,从 Remix 中导入 createCookieSessionStorageredirect 辅助函数:

    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.
    
  4. 现在,在已经存在的 registerUserloginUser 函数下方添加以下代码:

    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.
    
  5. 在项目根目录中的 .env 文件中打开并添加一个 SESSION_SECRET 环境变量:

    SESSION_SECRET="[A secret string]"
    

    HTTP cookie 签名涉及使用只有服务器知道的秘密密钥对 cookie 添加加密签名。当客户端在未来的请求中发送此已签名的 cookie 时,服务器使用秘密密钥来验证 cookie 是否被篡改。这增加了额外的安全层。

    我们在启动服务器环境时读取环境变量。确保您重启开发服务器,以防它目前正在运行,以确保新的环境变量被拾取。

  6. 最后,将以下辅助函数添加到 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 函数并解析表单数据。

现在,让我们一步一步地实现:

  1. 打开_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钩子来在表单提交时添加挂起指示器。

  2. 接下来,添加一个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.
    
  3. 从 Remix 导入useActionData

    import { useActionData, useNavigation } from '@remix-run/react';
    
  4. 在路由组件中访问错误消息操作数据:

    const actionData = useActionData<typeof action>();
    
  5. 导入我们的样式内联错误组件:

    import { InlineError } from '~/components/texts';
    
  6. 在提交按钮下方渲染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.
    
  7. 在你的浏览器窗口中打开开发者工具并导航到应用程序标签页:

图 8.2 – 开发者工具的应用程序标签页

图 8.2 – 开发者工具的应用程序标签页

httpOnly标志下。

右键点击 cookie 并选择删除

这允许我们在实现登出流程之前对注册表单进行更多操作。

  1. 再次尝试使用注册流程中使用的电子邮件地址进行注册。你现在应该看到一个内联错误。太棒了!

    随意花更多时间在这一部分,并通过action函数和session.server.ts文件中的代码流来调查代码。使用debuggerconsole.log语句来检查注册过程中发生的情况。

一旦你对添加的代码感到满意,使用开发者工具删除 cookie。这将使我们能够实现和测试登录流程。

添加用户登录流程

将注册流程的路由模块组件复制并粘贴,看看你是否可以更新它使其适用于登录页面。也许你可以尝试对action函数做同样的操作。

一旦你尝试过它,让我们一起来查看实现过程:

  1. 将以下代码添加到更新_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>  );}
    

    登录和注册表单几乎相同;只是输入字段的数目不同。

  2. 接下来,添加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.
    
  3. 使用你在注册流程中使用的电子邮件地址尝试登录流程。你现在应该能够注册并登录到 BeeRich。

到目前为止,我们已经利用会话辅助函数在成功注册或登录后创建用户会话。通过检查开发者工具,我们确保浏览器注册了 cookie。接下来,我们将添加一个登出流程来删除会话 cookie。

登出时删除会话

删除会话 cookie 非常简单。在session.server.ts中,我们可以访问三个会话生命周期方法:getSessioncommitSessiondestroySession。按照以下步骤操作:

  1. 让我们在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.
    
  2. 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 active loaderfunctions 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.

  3. 打开dashboard.tsx路由模块,找到导航栏中的当前注销链接:

    <RemixLink to="/404">Log out</RemixLink>
    

    目前,注销按钮是一个占位符链接到一个不存在的页面。

  4. 从 Remix 导入Form并替换代码以创建一个提交 POST 请求到注销路由的表单:

    <Form method="POST" action="/logout">  <button type="submit">Log out</button></Form>
    

    点击注销链接会提交一个表单到注销动作函数,将用户重定向到登录并移除当前用户会话 cookie。就这样,我们在 BeeRich 中成功实现了注销流程。

在本节中,我们练习了使用 Remix 的会话 cookie 助手创建和删除会话 cookie。接下来,我们将从会话 cookie 中读取以验证用户,并从我们的loader函数中返回特定于用户的数据。

验证对用户数据的访问

我们可以在loaderaction函数中访问 cookie,因为 cookie 被附加到每个发送到 Web 服务器的 HTTP 请求中。这使得 cookie 成为管理会话的绝佳工具。在本节中,我们将从会话 cookie 中读取以验证用户并查询特定于用户的数据。

在服务器上访问 cookie 数据

一旦我们将 cookie 附加到响应中,我们就可以在随后的每个对服务器的请求中访问 cookie 数据。这使我们能够构建个性化的基于会话的用户体验。让我们添加一些助手函数来简化这项任务:

  1. 将以下代码添加到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.
    
  2. 将以下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`.
    

在本节中,我们实现了getUserIdgetUser助手函数。我们使用getUserId来检查用户是否已登录。接下来,我们将使用getUser来获取当前登录用户的用户对象(如果有的话)。

在客户端处理用户数据

在本节中,我们将使用getUser来处理当前登录用户的用户对象:

  1. 首先,在 root.tsx 中导入LoaderFunctionArgsgetUser

    import type { LinksFunction, loader export to root.tsx, querying and returning the current user object:
    
    

    export async function loader({ request }: LoaderFunctionArgs) {  getUser 返回一个不带密码属性的用户对象。这很重要,因为我们把用户对象转发到客户端。我们不得向客户端应用程序泄露用户或应用程序的秘密。我们现在可以使用 useLoaderDataroot.tsx 中访问用户对象。然而,我们可能希望在整个应用程序中都能访问用户对象。让我们看看我们如何使用 Remix 来实现这一点。

  2. app/modules/session 中创建一个 session.ts 文件。

    我们计划创建一个小的 React 钩子来访问 React 应用程序中的根 loader 用户数据。由于我们还想在客户端的 React 应用程序中访问该钩子,因此我们不应将钩子放在 session.server.ts 文件中,因为它只包含在服务器包中。

  3. 将以下 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!
    
  4. _layout.tsx 路由组件中使用 useUser 钩子:

    const user = useUser();
    
  5. 在导航的无序列表中,将当前的 登录注册 列表项替换为以下代码:

    {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 的方法吗?

让我们实现一些身份验证逻辑,如果不存在用户会话,则将用户重定向到登录页面:

  1. 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.
    
  2. 接下来,将以下行添加到所有 loaderaction 函数的顶部,在仪表板路由模块中:

    await requireUserId(request);
    

    requireUserId 调用确保如果用户未进行身份验证,则将用户重定向到登录页面。

    由于 loader 函数并行运行,而 action 函数通过互联网公开 API 端点,我们必须将身份验证检查添加到每个需要身份验证的 loaderaction 函数中。

    我们还必须确保我们只检索与当前 userId 相关的数据。用户不应能够查看其他用户的费用和发票。让我们进一步更新我们的 loaderaction 函数。

  3. 打开 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.
    
  4. 打开 dashboard.expenses.tsx 路由模块并更新 loader 函数,以便检查现有的用户会话:

    import { userId to only filter for user-specific data.
    
  5. 打开 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.
    
  6. 打开 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.
    
  7. 更新 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.
    
  8. 更新 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.

  9. 现在是玩 BeeRich 的好时机。看看你是否可以在不先登录的情况下访问任何仪表板路由。

    尝试通过在几个标签页中操作来破解 BeeRich。在第一个标签页中打开费用创建表单,并在第二个标签页中注销。你还能成功创建新的费用吗?注意 cookies 在不同标签页之间的附加和更新情况。

保护 loader 和 action 函数

Remix 的 loader 函数并行运行以提高执行速度。然而,它们的并发性也决定了我们必须保护每个 loader 函数。loaderaction 函数都可以通过互联网访问,必须像 API 端点一样处理和保护。

我们仍然需要更新收入路由。这将是一个很好的实践,以确保你理解如何验证 loaderaction 函数。花些时间仔细检查收入路由中的每个 loaderaction 函数,以练习你在本章中学到的内容。

在本节中,你学习了如何在 Remix 中从会话 cookie 中访问状态,以及如何使用会话 cookie 在 loaderaction 函数中验证用户。

摘要

在本章中,你学习了 Remix 中的会话和状态管理。首先,你学习了如何使用 URL 搜索参数通过 Remix 的 Form 组件和 useSearchParams 钩子来持久化应用程序状态。URL 经常是我们处理应用程序状态所需的一切。

你还练习了使用 useSubmit 以编程方式提交表单,并更多地了解了 Remix 的不同突变工具。我们得出结论,我们使用 Form 组件和 useSubmit 钩子来处理页面上的主要操作;useFetcher 用于支持具有隔离提交状态的并发提交。

接下来,你了解到 cookies 是 HTTP 协议的一部分,可以用于在页面转换之间持久化状态。Cookies 是会话管理的一个很好的工具。Remix 提供了用于处理 cookies 和会话的辅助函数。Remix 的会话原语允许我们使用不同的策略来管理会话,例如在内存、文件、数据库或 cookies 中存储会话数据。

我们利用 Remix 的原语在 BeeRich 中实现了一个包含登录、注册和注销功能的身份验证流程。你学习了如何验证用户并使用会话 cookie 查询特定用户的内容。

在注册和登录过程中,我们创建和获取用户对象,并将userId写入 Remix 的会话对象。然后,使用loaderaction函数将该对象序列化为字符串,并添加到 HTTP 响应的 cookie 中,以验证用户会话和查询特定用户的数据。

你还学习了如何在你的应用程序中全局访问加载器数据,使用 Remix 的useRouteLoaderData钩子。你练习了创建一个小的自定义钩子,以抽象从根loader访问用户对象。

在阅读本章之后,你将理解action函数是独立的端点,而loader函数是并行运行的。最终,我们必须在每个受限的loaderaction函数中验证用户,以防止未经授权的访问。

在下一章中,你将学习如何在 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/CookiesHeaders接口: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 平台的信息:

内容感知元标签,如titledescription,必须为每个页面动态设置。在 Remix 中,我们可以使用meta导出将元标签注入到我们应用程序的头部。让我们看看它是如何工作的。

导出元函数

Remix 中的每个路由模块都可以导出一个meta函数。Remix 遵循路由层次结构以找到最近的meta导出,并将其注入到 HTML 文档的头部。让我们跳入我们的 BeeRich 应用程序的代码,并调查如何使用meta导出定义元标签:

  1. 打开app/root.tsx文件并查找meta导出:

    export const meta: MetaFunction = () => {  meta function returns a list of metadata objects. Currently, we only return a title metadata object. 
    
  2. 使用npm run dev命令在本地运行 BeeRich。

  3. 通过导航到localhost:3000/在浏览器窗口中打开应用程序。

  4. 使用您浏览器的开发者工具检查 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.
    
  5. 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.
    
  6. 检查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导出:

  1. 打开_layout.login.tsx路由模块并添加以下代码:

    import type { ActionFunctionArgs, LoaderFunctionArgs, npm run dev in your terminal.
    
  2. 通过导航到localhost:3000/login打开登录页面并检查头部元素的内容。

    注意,在root.tsx中定义的标题和描述被嵌套的meta导出覆盖。

在嵌套路由模块中使用meta导出来覆盖父级元标签

meta路由模块导出允许我们在路由层次结构的任何级别定义元标签。Remix 使用最近的meta函数返回值,并通过使用Meta组件将它们添加到文档的头部。嵌套meta导出替换父级meta导出。

Remix 最初在服务器上渲染。渲染的文档包括所有声明的元标签,并确保爬虫可以在不执行客户端 JavaScript 的情况下检查所有元标签。这对 SEO 非常有利。

通常,元标签的内容取决于动态数据。例如,您可能希望使用文章的标题和摘要作为标题和描述元标签。让我们看看我们如何在meta函数中访问加载器数据。

在元函数中使用加载器数据

meta函数在客户端和服务器上都会运行。在初始渲染时,meta函数在服务器端渲染期间被调用。对于所有后续的客户端导航,在从服务器获取加载器数据之后,meta函数在客户端执行。在这两种情况下,Remix 都将路由的加载器数据和所有父级加载器数据的哈希映射传递给meta函数。

在本节中,我们将向 BeeRich 仪表板的标题中添加当前用户的姓名,并探讨我们如何在 meta 函数中利用加载器数据:

  1. 在您的编辑器中打开 dashboard.tsx 路由模块。

  2. 更新 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.
    
  3. 接下来,将以下 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.tsxloader 函数中获取了用户数据:

export async function loader({ request }: LoaderFunctionArgs) {  const user = await getUser(request);
  return { user };
}

dashboard.tsx 中不需要重新获取用户对象。我们可以优化我们的代码,并从根加载器数据中访问用户对象。这样,我们就可以避免在 dashboard.tsx 路由模块的 loader 函数中再次查询数据库:

  1. 首先,将 root.tsx 中的根 loader 函数作为类型导入导入:

    import type { loader as rootLoader } from '~/root';
    

    我们将 loader 函数作为类型导入导入,因为我们只使用它进行类型推断。请注意,我们必须将 loader 导入重命名为 rootLoader 以避免与 dashboard.tsx 路由模块的 loader 函数发生命名冲突。

  2. 接下来,更新 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 参数来找到与 rootid 参数匹配的路由,并检索其(加载器)数据。matches 数组包含当前匹配 URL 并在页面上处于活动状态的路线对象列表。

    Remix 为每个路由模块分配一个唯一的标识符。这些标识符基于路由模块的文件名,但最容易识别路由模块标识符的方法是在开发期间记录 matches 数组。

  3. 现在我们正在使用另一个匹配路由的加载器数据,将 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 文件:

  1. /public 文件夹中创建一个 robots.txt 文件,并添加以下内容:

    User-agent: *Disallow: /dashboard/Allow: /loginAllow: /signupAllow: /$
    

    网络爬虫会从您的网站服务器请求 /robots.txt 文件,以找到关于在您的网站上爬取哪些内容的指令。

    我们指定网络爬虫可以爬取我们应用程序的登录、注册和索引页面。然而,由于仪表板位于登录页面之后,我们阻止爬虫尝试爬取我们的任何仪表板页面。

  2. 现在,使用 npm run dev 运行 BeeRich。

  3. 在您的浏览器中访问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导出:

  1. 访问fonts.google.com/specimen/Ubuntu以检查root.tsx中的links函数:

    export const links: LinksFunction = () =>   { rel: 'stylesheet', href: tailwindCSS },links function.
    
  2. 接下来,更新项目根目录中的tailwind.config.ts文件,将Ubuntu设置为默认的无衬线字体:

    npm run dev in your terminal.
    
  3. 在浏览器窗口中打开 BeeRich。现在,新字体应该已经应用到页面上的所有文本。

  4. 打开开发者工具以检查应用程序的网络活动。

  5. 刷新页面以重置网络标签上显示的活动并查看显示的结果:

![图 9.1 – 网络标签下载 Google 字体的截图

图 9.1 – 网络标签下载 Google 字体的截图

图 9**.1所示,links导出按预期工作。我们请求我们的全局 Tailwind CSS 样式表,并请求fonts.googleapis.com/css2。对fonts.googleapis的请求随后触发了所需字体资产的下载。

就这样,links导出让我们能够在嵌套路由层次结构内声明性地声明外部资源。

在 BeeRich 中,我们为我们的应用程序使用一个全局 Tailwind CSS 样式表。然而,Remix 的links导出在管理特定路由的样式表时也表现良好。让我们看看我们如何可以在 Remix 中使用links导出进行模块化样式表。

Remix 中的样式

Remix 支持任何暴露样式表的样式解决方案。这包括流行的选择,如 PostCSS、Tailwind CSS 和 vanilla-extract。一旦我们有了样式表的路径,我们就可以使用link元素来引用它。

在 BeeRich 中,我们使用 Tailwind CSS。Tailwind 生成一个全局样式表,我们可以在应用的全球链接标签中引用它。Remix 还提供了内置支持来编译 Tailwind CSS 样式表。您可以在remix.run/docs/en/2/styling/tailwind了解更多关于 Remix 与 Tailwind CSS 集成的信息。

此外,Remix 还提供了对模块化 CSS 解决方案的支持。让我们回顾一下它是如何工作的。

路由范围样式表

让我们通过为我们的登录页面创建一个范围 CSS 样式表来实验嵌套links导出:

  1. app/styles中创建一个名为login.css的样式表。

  2. 将以下内容添加到文件中:

    * {  background-color: beige;}
    

    我们使用这个全局 CSS 规则作为示例,展示一些我们只想应用于登录路由的定制样式。其他路由,如注册路由,不应受此 CSS 的影响。

  3. _layout.login.tsx路由模块中导入样式表:

    import loginCSS from '~/styles/login.css';
    
  4. 创建一个新的links导出,并将样式表导入的内容添加到其中:

    import type { ActionFunctionArgs, npm run dev.
    
  5. 在浏览器窗口中访问登录路由,并打开开发者工具的网络标签来检查发生了什么(localhost:3000/login)。

    确保您首先登出,然后再访问登录路由,否则您将被重定向到其他地方。

图 9.2 – 为登录路由加载嵌套样式表

图 9.2 – 为登录路由加载嵌套样式表

注意嵌套样式表与我们的全局链接资源并行加载。Remix 将所有links函数的返回值合并在一起,并使用Links组件将内容注入 HTML 文档的头部。

我们的登录页面现在有了我们想要的令人质疑的外观。但如果我们导航到另一个 URL 会发生什么呢?

  1. 点击右上角的注册按钮以触发客户端从登录路由的过渡。

    注意米色背景颜色消失了。

对于嵌套links导出,一旦我们离开相关的路由,Remix 就会卸载所有资源。这在处理嵌套样式表时特别有用。Remix 使得将样式表范围限定到特定的嵌套路由或路由子集变得容易。

由于 Remix 知道所有的links导出,它还可以在使用Link导出的prefetch属性时预取链接资源,我们将在下一节中对其进行回顾。

预取链接资源

让我们调查 Remix 如何预取链接资源:

  1. 在您的编辑器中打开_layout.tsx路由模块。

  2. 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.
    
  3. 现在,请在您的浏览器窗口中访问注册页面 (localhost:3000/signup)。

  4. 打开 网络 选项卡并清除任何已记录的条目以获得更好的可见性。

  5. 接下来,将鼠标悬停在或聚焦于导航栏中的 登录 锚点标签上,并检查 网络 选项卡:

图 9.3 – 预取链接资源

图 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函数来公开资产。这在我们需要即时动态创建这些资产或管理用户访问时特别有用。要开始,请按照以下步骤操作:

  1. 从公共文件夹中删除现有的robots.txt文件,否则它将覆盖我们的 API 路由。

  2. 在路由文件夹中创建一个新的robots[.txt].tsx文件。

    方括号使我们能够避开路由名称的一部分。我们不是创建一个.txt文件,而是创建一个.tsx文件,但确保路由与robots.txt路径匹配。

  3. 将以下内容添加到新创建的资源路由中:

    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 路由模块导出。现在你知道,当在 LinkNavLink 组件上使用预取属性时,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编码,它将表单数据放入请求体中,使得包括和流式传输二进制文件成为可能。

让我们更新费用创建和编辑表单,允许用户附加文件。首先,让我们对费用创建表单进行更改:

  1. 在编辑器中打开dashboard.expenses._index.tsx路由模块。

    路由组件当前渲染一个带有费用标题、描述和金额输入字段的表单。

  2. 将表单编码类型更新为 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.
    
  3. 现在,运行本地应用 (npm run dev) 并检查更新的用户界面。

    图 10*.1 所示,费用创建表单现在包含一个附件输入字段。

  4. 填写并提交费用创建表单。

    注意,表单提交仍然有效。request.formData 函数可以解析 URL 编码和多部分表单数据。request.formData 的一个缺点是它会将所有表单数据加载到服务器内存中。我们将在本章后面看到我们有哪些替代方案:

图 10.1 – 带有附件输入字段的表单截图

图 10.1 – 带有附件输入字段的表单截图

太好了!就这样,我们向费用创建表单添加了一个附件。像往常一样,将相同的更改应用到发票创建表单。

费用创建表单现在可以包含一个可选的文件附件。在下一节中,我们将在服务器上读取上传的文件并将其持久化到文件系统中。然而,我们还需要将保存的附件与费用关联起来。让我们更新数据库模式以支持向费用和发票添加附件:

  1. 首先,在你的编辑器中打开 prisma/schema.prisma 文件。

  2. 将以下行添加到 ExpenseInvoice 数据库模型:

    attachment field is marked as optional since we added a question mark symbol after the data type.
    
  3. 保存更改,并在项目的根目录下终端中运行 npx prisma format 以格式化 schema.prisma 文件。

  4. 接下来,执行 npm run build 以更新 Prisma 客户端和类型。

    在底层,Prisma 根据的 schema.prisma 文件生成类型。在运行 npm run build 之后,费用和发票类型包括可选的 attachment 属性。

  5. 最后,运行 npm run update:db 以将你的本地 SQLite 数据库模式与更新的 Prisma 模式同步。

一旦创建了一个费用,我们希望让用户查看和删除他们当前的附件。如果没有设置附件,我们还希望用户能够上传一个新的附件。接下来,让我们更新费用编辑表单以添加此功能:

  1. 在你的编辑器中打开 dashboard.expenses.$id.tsx 路由模块。

  2. 更新路由组件的表单编码类型,以便它支持文件上传:

    <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.
    
  3. 在你的编辑器中打开 app/components/forms.tsx 文件并检查 Attachment 组件的实现。

    Attachment 组件期望一个 label 属性和一个 attachmentUrl 属性,并渲染一个链接到附件的锚标签。它还包括一个提交按钮和一个名为 attachmentUrl 的隐藏输入字段。

    让我们在编辑表单中使用 Attachment 组件,让用户查看和删除他们的费用和发票附件。

  4. 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/$`.
    
  5. 在本地运行应用程序并检查费用编辑表单上的新文件输入字段。

我们需要添加一个新的路由模块来处理对文件附件的访问。新的路由模块将是一个嵌套在dashboard/expenses/$id路径中的资源路由。让我们为新的路由模块创建必要的路由结构:

  1. dashboard.expenses.$id.tsx文件重命名为dashboard.expenses.$id._index.tsx

    路由模块仍然匹配之前的相同路径。作为一个索引路由,它现在作为$id路径段的默认子路由。

  2. 更新所有提交到dashboard.expenses.$id._index.tsx的所有表单的action属性。

    我们将dashboard.expenses.$id.tsx中的action函数移动到了dashboard.expenses.$id._index.tsx。这改变了action函数的路径。

    1. 打开dashboard.expenses.tsx父级路由模块并更新ListLinkItem组件的deleteProps属性:
    deleteProps={{  ariaLabel: `Delete expense ${expense.title}`,  action: `/dashboard/expenses/${expense.id}action function.
    
  3. 接下来,创建一个新的通配符路由模块,命名为dashboard.expenses.$id.attachments.$.tsx

    通配符是通配符路由参数,它匹配从其位置开始的 URL 路径的其余部分。无论跟随/dashboard/expenses/$id/attachments/的子路径是什么,通配符参数都会匹配并将子路径存储在params['*']中。

    例如,如果用户访问/dashboard/expenses/$id/attachments/bees/cool-bees.png URL 路径,那么params['*']通配符参数包含bees/cool-bees.png字符串。

  4. 将以下代码添加到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.
    
  5. 在 BeeRich 上签名或登录并创建一个新的费用。

  6. 通过点击仪表板上的费用概览列表中的费用来导航到费用详情页面。

  7. /attachments/bees/cool-bees.png路径追加到浏览器窗口中的 URL。

    你应该看到loader函数,而不仅仅是我们的路由组件的数据。

  8. 检查终端并审查console.log({ id, slug })的输出。更改浏览器窗口中的 URL 以查看输出如何变化。

太好了!我们已经为我们的文件上传功能创建了所需的路由模块结构。一如既往,确保更新收入路由文件以练习你在本节中学到的内容。在本地运行应用程序以调试你的实现。

在本节中,你学习了如何在 HTML 表单元素上设置编码类型并添加文件输入字段。你还进一步练习了使用通配符路由模块和资源路由。接下来,我们将利用 Remix 的服务器端文件上传辅助函数将传入的文件写入文件系统。

服务器端的文件处理

在服务器上处理文件上传时,有几个重要的考虑因素,最重要的是文件大小。在本节中,我们将学习如何在 Remix 的 action 函数中处理文件。我们将从一个简单的实现开始,然后重构代码并考虑更多的问题。

将文件加载到内存中

让我们从实现一些用于处理文件的实用程序开始:

  1. 创建一个新的 app/modules/attachments.server.ts 文件。

  2. 将以下代码添加到 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 库来写入文件系统。

  3. 打开 dashboard.expenses._index.tsx 路由模块。

  4. 更新路由模块的 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.
    
  5. 在本地运行 BeeRich 以测试当前实现。

  6. 填写费用创建表单,附加文件,并在您的编辑器中点击 public 文件夹。就这样,我们可以将文件上传到服务器并将其写入文件系统。

  7. 您现在可以通过导航到 localhost:3000/$file-name 来访问文件。请注意,您还必须在路径中添加文件扩展名。

理论上,我们现在可以在服务器上读取文件名并将其保存到数据库中的费用对象中。由于 public 文件夹中的所有文件都已经可以通过网络访问,我们可以让用户通过链接到 /$filename 来访问他们的文件。

很遗憾,当前简单实现存在一些限制:

  • 文件名冲突

  • 文件大小限制

  • 隐私问题

根据当前实现,我们没有管理文件名以避免冲突。如果两个用户上传了同名文件怎么办?我们也没有处理文件大小限制。大文件可以轻易地消耗服务器上的运行时内存。因此,限制用户可以上传的文件大小或更好地实现文件上传处理程序,使其作为数据流处理传入的文件,而不是一次性将整个文件加载到内存中,这一点非常重要。

此外,通过将文件存储在 public 文件夹中,我们使文件对公众可访问。任何人都可以尝试猜测文件名,直到他们幸运地能够访问另一个用户的文件——这是一个巨大的安全问题,尤其是当我们谈论像发票和费用这样的敏感数据时。

幸运的是,我们可以通过利用 Remix 的原语和约定来解决所有这些问题。让我们首先使用 Remix 的上传处理辅助函数来解决前两个问题。

使用 Remix 的上传处理辅助函数

Remix 提供了一套用于管理文件上传的辅助函数。我们将使用以下函数来改进当前的简单实现:

  • unstable_composeUploadHandlers

  • unstable_createFileUploadHandler

  • unstable_createMemoryUploadHandler

  • unstable_parseMultipartFormData

注意,当前函数包含 unstable_ 前缀。这意味着它们的实现可能在未来的版本中发生变化。

让我们开始吧:

  1. 首先,在项目的根目录下创建一个名为 attachments 的新文件夹。这是我们将在服务器上存储所有附加文件的地方。

  2. 接下来,在您的编辑器中打开 app/modules/attachments.server.ts 文件。

  3. 移除 writeFile 函数。

  4. 相反,使用 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

  5. 接下来,创建一个自定义文件上传处理程序函数:

    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.
    
  6. 使用 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 函数,以便它们利用新的上传处理程序:

  1. 打开 dashboard.expenses._index.tsx 路由模块。

  2. 在路由模块的 action 函数中移除 writeFile 的导入和天真实现:

    const file = formData.get('attachment');if (file && file instanceof File) {  writeFile(file);}
    
  3. 从 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。附件表单数据条目由文件上传处理程序处理,返回上传文件的文件名,如果没有提交文件则返回 nullunstable_createMemoryUploadHandler 为我们处理所有其他表单数据。

  4. 接下来,添加读取附件表单数据并更新数据库查询的代码:

    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 的文件上传辅助函数帮助我们避免文件名冲突,并允许我们流式传输文件数据以避免服务器内存溢出。

接下来,我们将更新支出编辑表单,使其也能处理文件上传:

  1. 在您的编辑器中打开dashboard.expenses.$id._index.tsx文件。

  2. 再次,导入unstable_parseMultipartFormDatauploadHandler

    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

  3. 接下来,更新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.
    
  4. 将以下函数添加到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文件夹中删除相关文件。

  5. 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 函数中被调用的,该值在附件组件中实现。

  6. 更新路由模块的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.
    
  7. 最后,在相同文件中更新deleteExpense函数,以便在删除支出时删除附件:

    npm run dev and open a browser window to test the implementation.
    
  8. 创建一个带有附件的支出。检查您的编辑器中的attachments文件夹。

  9. 通过点击attachments文件夹来删除附件,查看文件是否成功删除。

  10. 接下来,使用编辑表单向同一支出添加新的附件,并再次调查attachments文件夹。

  11. 最后,通过在费用概览列表中点击X按钮来删除费用,并检查文件是否已删除:

图 10.2 – 更新后的费用编辑表单的截图

图 10.2 – 更新后的费用编辑表单的截图

图 10**.2所示,如果未设置附件,费用编辑表单现在应正确地在当前附件和附件输入字段之间切换。删除费用还应从attachments文件夹中删除相关的附件文件。

在继续之前,更新income路由以练习本节中学到的内容。一旦income路由被更新,你就可以进入下一节。

在本节中,你学习了如何使用 Remix 的文件上传辅助函数。你现在理解了在管理文件上传时需要考虑的因素,以及如何使用 Remix 的实用工具来避免内存和文件命名冲突。接下来,我们将实现 splat 路由,以让 BeeRich 用户安全地访问他们的附件。

使用资源路由授权对资产的访问

第九章,“资产和元数据处理”中,你练习了通过资源路由公开资产。现在我们将在此基础上动态创建一个用于请求的费用附件的文件下载。我们将实现负责公开附件的 splat 路由,并确保只有授权用户才能访问他们的文件:

  1. 首先,让我们向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.
    
  2. 接下来,打开dashboard.expenses.$id.attachments.$.tsx splat 路由模块,并用以下代码替换其内容:

    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.
    
  3. 是时候测试实现了。尝试下载当前附件。点击当前附件链接应启动文件下载。

干得好!你在 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 的全栈 Web 开发高级概念

在本最终部分,你将练习全栈 Web 开发的高级概念,如乐观用户界面、缓存策略、HTML 流和实时数据更新。你将再次迭代 BeeRich 以练习所学概念。你还将深入了解会话管理,并理解部署到边缘的含义。最后,你将了解迁移到 Remix 的策略,并学习如何保持 Remix 应用程序的更新。

本部分包含以下章节:

  • 第十一章, 乐观用户界面

  • 第十二章, 缓存策略

  • 第十三章, 延迟加载器数据

  • 第十四章, 使用 Remix 进行实时操作

  • 第十五章*,* 高级会话管理

  • 第十六章, 边缘开发

  • 第十七章, 迁移和升级策略

第十一章:乐观 UI

乐观 UI 通过提供即时反馈,即使在操作需要一点时间的情况下也能让您的应用感觉更加敏捷。这在等待网络响应时尤其有用。乐观更新可以使 UI 感觉更加响应迅速,并改善用户体验。在本章中,您将学习如何使用 Remix 添加乐观 UI 更新。

本章分为两个部分:

  • 考虑乐观 UI

  • 在 Remix 中添加乐观 UI 更新

首先,我们将讨论使用乐观 UI 更新的权衡,并调查客户端/服务器状态同步和回滚的复杂性和风险。接下来,我们将回顾 BeeRich 的当前状态,并调查哪些突变可以通过乐观 UI 更新来增强。然后,我们将使用 Remix 的原语在合适的地方添加乐观 UI 更新。

阅读本章后,您将了解如何评估乐观 UI 的使用。您还将练习使用 Remix 的原语,如 useNavigationuseFetcher,来实现乐观 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 的 loaderaction 函数进行数据读取和写入时,我们避免了在 UI 中引入过时数据,并消除了在实现乐观用户界面更新时降低用户体验的主要担忧。

此外,Remix 的原语,如 useNavigationuseFetcher,允许我们在不添加自定义 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路由模块:

  1. 移除disabled属性和待处理的“保存…”UI 状态:

    <Button type="submit" name="intent" value="update" isPrimary>  Save</Button>
    

    刚开始移除待处理的 UI 可能会感觉有些奇怪。让我们仔细思考一下。现在表单支持后续更新,因为我们不再在待处理提交时禁用提交按钮。由于我们总是显示用户输入的值在更新表单中,输入状态本身已经是乐观的。由于我们仍然在待处理导航时显示全局过渡动画和费用详情脉冲动画,我们仍然传达更新正在进行的信息。此外,我们还在成功更新时显示成功消息。这可能是一个不错的折衷方案。

    但附件怎么办?添加附件会创建一个新的expense.attachment值。我们需要附件文件名值来执行视图和删除附件操作。

    一种解决方案是在乐观地添加附件的同时禁用附件链接和删除按钮,直到我们收到包含新添加的附件值的服务器响应。让我们实现它!

  2. 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.
    
  3. 接下来,更新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钩子的stateformData属性。

就这样,当提交挂起时,我们从列表中删除支出项。一旦action函数完成,Remix 刷新加载器数据并将导航状态设置回idle。如果突变成功,则更新的加载器数据不再包含已删除的支出,并且我们的 UI 更新持续存在。但如果发生错误呢?

  1. 查看位于dashboard.expenses.$id._index.tsx路由模块中的handleDelete函数。目前,如果删除支出失败,我们抛出一个 404Response。这触发了ErrorBoundary。让我们通过在 UI 中为用户提供直接反馈来改进这一点,如果删除操作失败。

  2. handleDelete更新为在删除操作失败时返回一个 JSONResponse

    try {  await deleteExpense(id, userId);} catch (err) {  ListLinkItem component in components/links.tsx.
    
    

    如果操作数据存在且成功为 false,我们知道支出的删除失败了。

  3. 最后,有条件地更新列表元素的className属性,以便在hasFailedtrue时将列表项文本样式设置为红色。

    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.
    
  4. 通过在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属性来检测用户是否删除了附件:

  1. 将以下布尔标志添加到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}  />) : (  )}

  2. 尝试运行实现以验证一切是否按预期工作。一如既往,使用网络选项卡来限制连接以检查待处理状态。

太好了!我们向附件删除表单和费用更新表单添加了乐观 UI 更新。你了解到 Remix 的useFetcheruseNavigation原始功能包含当前正在提交的表单的formData属性。我们可以使用formData属性来乐观地更新 UI,直到loader重新验证将 UI 与服务器状态同步。

摘要

在本章中,你学习了如何在 Remix 中添加乐观 UI 更新。你了解了乐观 UI 的权衡,例如客户端逻辑的复杂性增加以及在回滚情况下用户反馈的必要性。

Remix 的loader重新验证是同步 UI 与服务器状态的一个很好的起点。你现在明白,Remix 的loader重新验证使我们能够避免自定义客户端-服务器状态同步,并让我们避免过时的状态。在依赖加载器数据时,我们能够自动获得回滚。每次突变后,我们都会收到最新的加载器数据,并且我们的 UI 会自动更新。

仍然值得说明为什么突变失败。无论是否有乐观更新,向用户显示错误消息都很重要。对于乐观更新,也可能有突出显示回滚数据的视觉意义。Remix 的ErrorBoundary组件是恢复错误的一个很好的起点。然而,如果我们想要更精细的反馈,我们必须添加自定义错误消息并利用 Remix 的原始功能来突出显示回滚数据。

当实现乐观式用户界面时,我们通常首先从移除挂起的用户界面开始。在列表中添加和移除挂起实体是一种显示即时反馈的简单方法。

你还学习了如何使用 Remix 的原语,如 useNavigationuseFetcher 来实现乐观式用户界面更新。我们可以在客户端使用 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函数可以访问parentsHeaderserrorHeadersactionHeadersloaderHeaders对象,根据通过父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 为所有未来的请求缓存文档。这意味着缓存不仅服务于一个浏览器(用户),还可能提高后续用户请求的响应时间。

让我们调查这种缓存行为:

  1. 在项目的根目录中运行 npm run dev

  2. 接下来,在新的浏览器窗口中打开登录页面 (localhost:3000)。

  3. 打开浏览器的开发者工具并导航到 网络 选项卡。

  4. 如果已勾选,请确保取消勾选 禁用缓存 选项。

  5. 现在,强制刷新页面以模拟初始页面加载:

图 12.1 – 登录页面初始加载

图 12.1 – 登录页面初始加载

注意,指定的缓存头作为响应头的一部分返回。

  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 – 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-cacheprivate 指令为所有 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 不同,后者即使在后退和前进导航期间也会强制浏览器获取新内容。

太好了 – 我们现在已经学会了如何将缓存头应用于文档响应。但关于 loaderaction 数据响应呢?

缓存不可变数据响应

在 Remix 中,我们还可以控制来自 loaderaction 函数的数据响应的 HTTP 头部。因此,我们不仅可以设置文档的缓存控制,还可以设置数据响应的缓存控制。

BeeRich 中的大部分数据都是高度动态的。发票和费用数据可以编辑,并且必须始终是最新的。然而,费用和发票附件的情况不同。每个附件都有一个唯一的文件名(标识符),它是请求 URL 的一部分。最终,两个附件永远不会通过相同的 URL 提供服务。

让我们更新 BeeRich 中的附件逻辑,以利用 HTTP 缓存:

  1. 首先,更新 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.
    
  2. 接下来,更新 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.
    
  3. 通过执行 npm run dev 启动 BeeRich,并在浏览器中导航到费用详情页面。

  4. 接下来,下载附件两次,并在 网络 选项卡中检查第二次网络有效载荷:

图 12.3 – 存储在磁盘上的附件

图 12.3 – 附件已缓存到磁盘

太棒了!如图 12.3.3 所示,我们避免了在第二次下载请求中对 Web 服务器的请求。相反,附件是从浏览器的磁盘缓存中下载的。

缓存很困难,尤其是当你试图缓存特定于用户的数据时。你能想到当前实现中任何潜在的安全问题吗?

想象一下,一个用户从公共电脑登录 BeeRich 以访问费用附件。用户下载其中一个附件以打印它。然后,用户从公共电脑删除附件并从 BeeRich 注销。现在,恶意行为者能否从浏览器缓存中检索附件?有可能。

请求 URL属性从Headers Network选项卡复制并粘贴到你的附件中。现在,从 BeeRich 注销,将被重定向到登录页面。将复制的请求 URL 粘贴到地址栏并按Enter。由于我们允许浏览器将其文档缓存到其私有磁盘缓存中,请求将不会发送到我们会验证用户的资源路由。相反,浏览器从内存或磁盘缓存中检索文档并将其提供给用户,这是一个潜在的安全漏洞。

在本节中,你了解了使用私有和公共缓存控制指令泄露用户数据的潜在安全风险。我们可以使用不同的缓存策略,而不是在浏览器缓存中缓存私有数据。接下来,我们将探讨实体标签。

使用实体标签缓存动态数据响应

对文档的 HTTP 请求可能导致不同的 HTTP 响应。状态码为 200 的响应通常包含包含请求文档的 HTTP 主体 - 例如,HTML 文档、PDF 或图像。

HTTP 请求-响应流允许我们授权用户访问,并可能通过 401(未授权)响应拒绝请求。在上一个章节中,我们在私有和共享缓存中缓存了数据,这缩短了请求-响应流,使其在缓存命中时无法到达我们的服务器。

在本节中,我们将探讨如何利用ETagIf-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 – 基于 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 头。我们还有权访问loaderHeadersparentHeaders,这允许我们合并 HTTP 头并根据加载器数据指定头。

你还学习了如何在 Remix 中缓存文档和数据请求。你学习了如何使用Cache-Control头指定和防止缓存。

然后,你应用了privatepublicmax-ageno-cacheimmutable指令。此外,你还回顾了 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 频道上上传的第一批视频,值得一看:

你还可以在 Sergio 的博客上找到使用 Remix 的 ETags 的出色指南:sergiodxa.com/articles/use-etags-in-remix