React-现代全栈项目-全-

React 现代全栈项目(全)

原文:zh.annas-archive.org/md5/698b69ebe010bfc0cb8e00bb1fbda841

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

你好——我是丹尼尔,一名企业家、技术顾问和全栈开发者,专注于 React 生态系统中的技术。

在我作为企业和技术顾问以及公共部门开发者的时间里,我注意到越来越多的公司希望缩小前端和后端开发者之间的差距。他们的业务需求通常导致需要所谓的“前端后端”,即从不同的后端系统中获取数据,并以便于在前端显示的方式准备数据。

作为一名企业家,我也在小型团队中启动新项目方面有经验,在这种情况下,确保你的团队中的每个开发者都能做任何事情,而不仅仅是前端或后端,这是至关重要的。在这种情况下,通常有道理使用同一种语言来开发后端和前端,这通常是 JavaScript(或 TypeScript),因为有一个庞大的生态系统和大量的开发者可用。

在这两种情况下,成为一名全栈开发者变得越来越重要。我已经长期辅导开发者学习全栈开发,并注意到大多数开发者在学习全栈开发时都会遇到一些常见问题和误解。在这本书中,我想总结我关于全栈开发的全部学习和教学经验,为你提供关于如何进一步学习 JavaScript 全栈开发不断增长的生态系统的指南。

现在,许多公司使用由 MongoDB、Express、React 和 Node.js 组成的堆栈,称为 MERN 堆栈。在这本书中,我将教你如何使用这些技术构建现代全栈 React 应用程序。我将从零开始教授这些技术,尽可能少地使用库,这样你就可以学习基本概念。这将使你能够适应未来的新技术,即使这本书中使用的具体工具变得过时。此外,我还会教授应用程序的部署和 DevOps,因为我发现这个领域往往被忽视,而且了解它的开发者不多。在书的最后一部分,我将介绍 Next.js 作为全栈框架,并展望该领域的最新发展,如 React 服务器组件和服务器操作。

我希望你喜欢阅读这本书。如果你有任何问题或反馈,请随时联系我!

本书面向对象

这本书是为已经具备 React 经验并希望学习如何创建、集成和部署各种后端系统以成为全栈开发者的开发者而写的。你应该已经对 JavaScript 和 React 有很好的理解,但不需要有任何关于后端系统开发、创建、集成和部署的先验知识。如果你面临以下挑战之一,这本书将非常适合你:

  • 你知道如何使用 React 制作前端,但不知道如何正确地将其与后端集成

  • 你想从头开始创建一个全栈项目,但不知道如何

  • 你想了解更多关于应用程序部署和 DevOps 的知识

  • 你想了解更多关于现代 React 开发的知识,例如 React 服务器组件、服务器操作和 Next.js

本书将为你提供真实世界的项目,包括成为全栈开发者所需的所有步骤,包括但不限于后端开发、前端开发、测试(单元测试和端到端测试)以及部署。

本书涵盖的内容

第一章为全栈开发做准备,简要概述了本书的内容,并教你如何设置一个将作为你全栈项目开发基础的项目。

第二章了解 Node.js 和 MongoDB,提供了如何使用 Node.js 编写和运行脚本的说明。然后,它解释了如何使用 Docker 设置数据库服务。它还介绍了文档数据库 MongoDB 以及如何通过 Node.js 访问 MongoDB 数据库。

第三章使用 Express 实现后端,通过创建后端服务来实践你在第二章中学到的知识。Express 用于提供 REST API,Mongoose ODM 用于与 MongoDB 接口,Jest 用于编写后端代码的单元测试。

第四章使用 React 和 TanStack Query 集成前端,提供了如何创建与之前创建的后端服务接口的前端说明。它使用 Vite 设置 React 项目,在其中我们创建了一个基本用户界面。然后,它教你如何使用数据获取库 TanStack Query 来处理后端状态并将后端 API 与前端集成。

第五章使用 Docker 和 CI/CD 部署应用程序,通过教你关于 Docker 以及如何使用它打包应用程序来深入探讨 DevOps。然后,它提供了将应用程序部署到云提供商以及如何配置 CI/CD 来自动化部署的说明。

第六章使用 JWT 添加身份验证,教你关于 JSON Web Tokens 的知识,这是一种向 Web 应用程序添加身份验证的方法。它还提供了使用 React Router 设置多个路由的说明。

第七章使用服务器端渲染提高加载时间,涵盖了基准测试应用程序并教你关于 Web Vitals 的知识。然后,它提供了从零开始实现服务器端渲染 React 组件的方法以及如何在服务器上预取数据的说明。

第八章确保客户通过搜索引擎优化找到你,专注于如何优化应用程序以便搜索引擎(如 Google 或 Bing)找到。此外,它还提供了有关如何创建元标签以方便与各种社交媒体网站集成的信息。

第九章使用 Playwright 实现端到端测试,介绍了 Playwright 作为编写端到端测试的工具,它自动在应用程序中执行操作,以确定在做出更改后代码是否仍然按预期运行。它还涵盖了如何使用 GitHub Actions 在 CI 中运行 Playwright。

第十章使用 MongoDB 和 Victory 聚合和可视化统计数据,提供了如何在应用程序中收集事件的说明。然后,它教你如何使用 MongoDB 聚合数据以生成汇总统计信息,例如查看次数或会话持续时间。最后,它涵盖了使用 Victory 库创建图表以可视化这些聚合统计信息。

第十一章使用 GraphQL API 构建后端,介绍了 GraphQL 作为 REST API 的替代品,你将了解何时使用它以及如何在后端实现它。

第十二章使用 Apollo Client 在前端与 GraphQL 接口,教你如何在前端使用 Apollo Client 与之前实现的 GraphQL 后端进行接口。

第十三章使用 Express 和 Socket.IO 构建事件驱动后端,介绍了一种事件驱动架构,这对于处理实时数据的应用程序很有用,例如协作应用程序(Google Docs 或在线白板)或金融应用程序(Kraken 加密货币交易所)。它教你关于 WebSockets 以及如何使用 Socket.IO 实现事件驱动后端。

第十四章创建用于消费和发送事件的前端,实现了之前创建的事件驱动后端的前端,并使用 Socket.IO 与其接口。

第十五章使用 MongoDB 为 Socket.IO 添加持久性,教你如何将数据库正确集成到事件驱动应用程序中,以持久化(并稍后回放)事件。

第十六章开始使用 Next.js,介绍了 Next.js 作为为 React 准备的、适用于企业的全栈 Web 应用程序框架。它突出了使用框架与使用简单的打包器(如 Vite)之间的区别。它还教你关于 Next.js App Router,这是一种定义路由和页面的新范式。

第十七章介绍 React 服务器组件,介绍了 React 中的一个新概念——服务器组件,允许您直接将 React 应用程序与数据库集成,而无需 REST 或 GraphQL API。此外,它还介绍了服务器操作,允许您通过前端调用服务器上的函数。

第十八章高级 Next.js 概念和优化,深入探讨了 Next.js 框架,提供了关于 Next.js 中缓存如何工作以及如何用于优化应用程序的信息。它还教授您如何在 Next.js 中定义 API 路由以及如何添加用于搜索引擎优化的元数据。最后,它教授您如何在 Next.js 中最佳地加载图像和字体。

第十九章部署 Next.js 应用程序,教授您两种部署 Next.js 应用程序的方法。最简单的方法是使用 Vercel 平台,我们可以快速将应用程序部署上线。然而,它也教授您如何使用 Docker 创建自定义部署设置。

第二十章深入全栈开发,简要介绍了本书尚未涉及的各种高级主题。它从其他全栈框架的概述开始,然后总结了维护大型项目、优化包大小、UI 库概述以及高级状态管理解决方案等概念。

要充分利用本书

本书涵盖的软件/硬件 操作系统要求
Node.js v20.10.0 Windows, macOS, 或 Linux
Git v2.43.0
Visual Studio Code v1.84.2
Docker v24.0.6
Docker Desktop v4.25.2
MongoDB Shell v2.1.0

如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub(github.com/PacktPublishing/Modern-Full-Stack-React-Projects)下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的书籍和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

代码实战

本书的相关视频可在packt.link/VINfo查看。

使用的约定

在本书中使用了多种文本约定。

文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“首先,您需要创建一个robots.txt文件,以允许搜索引擎爬取您网站的哪些部分,以及它们允许爬取哪些部分。”

代码块如下设置:

export const getPostById = async (postId) => {
  const res = await fetch(`${import.meta.env.VITE_BACKEND_URL}/posts/${postId}`)
  return await res.json()
}

当我们希望将您的注意力引到代码块的一个特定部分时,相关的行或项目将以粗体显示:

      {fullPost ? (
        <h3>{title}</h3>
      ) : (
        <Link to={`/posts/${_id}`}>
          <h3>{title}</h3>
        </Link>
      )}

任何命令行输入或输出都应如下所示:

$ npm install node-emoji

粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“连接到数据库,然后展开Playgrounds部分(如果尚未展开)并点击创建新****Playground按钮。”

小贴士或重要注意事项

看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈: 如果您对这本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。

勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/support/errata 并填写表格。

盗版: 如果您在互联网上以任何形式发现我们作品的非法副本,如果您能向我们提供位置地址或网站名称,我们将不胜感激。请通过电子邮件发送至 copyright@packt.com 并附上材料的链接。

如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com

分享您的想法

一旦您阅读了《Modern Full-Stack React Projects》,我们非常乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走?

您的电子书购买是否与您选择的设备不兼容?

不必担心,现在,随着每本 Packt 书籍的购买,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠不会就此停止,您还可以获得独家折扣、时事通讯和每日免费内容的每日访问权限。

按照以下简单步骤获取好处:

  1. 扫描下面的二维码或访问以下链接

二维码图片

packt.link/free-ebook/978-1-83763-795-9

  1. 提交您的购买证明

  2. 就这样!我们将直接将您的免费 PDF 和其他好处发送到您的电子邮件

第一部分:全栈开发入门

在本部分,你将学习如何设置项目以及用于全栈开发的工具。你还将了解并开始使用Node.jsDockerMongoDB。完成本部分后,你将拥有一个基本的项目设置,可用于本书中进一步开发的项目。

本部分包括以下章节:

  • 第一章为全栈开发做准备

  • 第二章了解 Node.js 和 MongoDB

第一章:准备进行全栈开发

在本章中,我首先将简要概述本书的内容,并解释为什么本书中教授的技能在现代开发环境中很重要。然后,我们将开始行动,设置一个将作为我们全栈项目开发基础的项目。在本章结束时,你将拥有一个集成开发环境IDE)和项目,它们已设置好并准备好进行全栈开发,并且将了解哪些工具可以用于设置此类项目。

在本章中,我们将涵盖以下主要内容:

  • 成为全栈开发者的动机

  • 第三版的新内容是什么?

  • 充分利用本书

  • 设置开发环境

技术要求

本章将指导你设置本书中开发全栈网络应用程序所需的所有必要技术。在我们开始之前,如果你还没有安装以下内容,请安装它们:

  • Node.js v20.10.0

  • Git v2.43.0

  • Visual Studio Code v1.84.2

这些版本是书中使用的版本。虽然安装较新版本通常不会有问题,但请注意,某些步骤在较新版本上可能有所不同。如果你在使用本书中提供的代码和步骤时遇到问题,请尝试使用提到的版本。

你可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch1

本章的 CiA 视频可以在以下网址找到:youtu.be/dyf3nECvKAE

重要

如果你克隆了本书的完整仓库,当运行 npm install 时,Husky 可能找不到 .git 目录。在这种情况下,只需在相应章节文件夹的根目录下运行 git init

成为全栈开发者的动机

随着公司寻求增加前端和后端之间的合作并缩小差距,理解全栈开发变得越来越重要。前端正越来越深入地与后端集成,使用如服务器端渲染等技术。在本书中,我们将学习全栈项目的开发、集成、测试和部署。

Full-Stack React Projects 本次发布的新内容是什么?

与 Full-Stack React Projects 的先前版本相比,这个新版本更注重前端与后端的集成,而不是前两个版本,因此故意不太多关注创建用户界面UI)或在前端使用 UI 库,如Material UIMUI)。本版提供了集成和部署全栈 Web 应用程序的基本知识。应用程序的部署在先前版本中完全缺失,测试也仅简要介绍。本版更注重全栈开发的这些基本部分,这样,在阅读本书后,您将能够开发、集成、测试和部署全栈 Web 应用程序。

充分利用本书

为了使本书简短而直接,我们将使用特定的技术和工具。然而,这些概念也适用于其他技术。我们将尝试简要介绍替代方案,以便如果某些方案不适合您的项目,您可以挑选和选择不同的工具。我建议首先尝试本书中介绍的技术,以便能够遵循说明,但请不要犹豫,以后可以自己尝试替代方案。

强烈建议您亲自编写代码。不要简单地运行提供的代码示例。为了正确学习和理解,亲自编写代码非常重要。然而,如果您遇到任何问题,您始终可以参考代码示例。

话虽如此,让我们在下一节中开始设置我们的开发环境。

设置开发环境

在这本书中,我们将使用Visual Studio CodeVS Code)作为我们的代码编辑器。请随意使用您偏好的任何编辑器,但请记住,您选择的编辑器中使用的扩展和配置的设置可能略有不同。

现在我们将安装 VS Code 和有用的扩展,然后继续设置我们开发环境所需的所有工具。

安装 VS Code 和扩展

在我们开始开发并设置其他工具之前,我们需要按照以下步骤设置我们的代码编辑器:

  1. 从官方网站(撰写本文时,网址为code.visualstudio.com/)下载适用于您操作系统的 VS Code。本书中将使用版本1.84.2

  2. 下载并安装应用程序后,打开它,您应该会看到以下窗口:

图 1.1 – VS Code 的新安装(在 macOS 上)

图 1.1 – VS Code 的新安装(在 macOS 上)

  1. 为了让事情变得简单,我们将安装一些扩展,因此点击截图左侧顶部第五个图标上的 扩展 图标。应该会打开一个侧边栏,您将在顶部看到 在市场搜索扩展。在此处输入扩展名称并点击 安装 以安装它。让我们先安装 Docker 扩展:

图 1.2 – 在 VS Code 中安装 Docker 扩展

图 1.2 – 在 VS Code 中安装 Docker 扩展

  1. 安装以下扩展:

    • Docker(由 Microsoft 提供)

    • ESLint(由 Microsoft 提供)

    • Prettier – 代码格式化工具(由 Prettier 提供)

    • MongoDB for VS Code(由 MongoDB 提供)

    VS Code 已经内置了对 JavaScript 和 Node.js 的支持。

  2. 为本书中制作的项目创建一个文件夹(例如,您可以将其命名为 Full-Stack-React-Projects)。在这个文件夹内部,创建一个名为 ch1 的新文件夹。

  3. 前往 文件 选项卡(从顶部开始的第一个图标)并点击 打开文件夹 按钮以打开空的 ch1 文件夹。

  4. 如果您收到一个对话框询问 您信任此文件夹中文件的作者吗?,请选择 信任父文件夹‘Full-Stack-React-Projects’中的所有文件作者,然后点击 是的,我信任 作者 按钮。

图 1.3 – 允许 VS Code 在我们的项目文件夹中执行文件

图 1.3 – 允许 VS Code 在我们的项目文件夹中执行文件

小贴士

在您自己的项目中,您可以安全地忽略此警告,因为您可以确信这些项目中不包含恶意代码。当从不受信任的来源打开文件夹时,您可以按 不,我不信任作者,并且仍然浏览代码。然而,这样做时,VS Code 的一些功能将被禁用。

我们现在已经成功设置了 VS Code,并准备好开始设置我们的项目!如果您已从 GitHub 代码示例中克隆了文件夹,也会弹出一个通知,告诉您找到了 Git 仓库。您可以简单地关闭它,因为我们只想打开 ch1 文件夹。

现在 VS Code 已经准备好了,让我们继续通过使用 Vite 设置一个新的项目。

使用 Vite 设置项目

对于这本书,我们将使用 Vite 来设置我们的项目,因为根据 The State of JS 2022 调查(2022.stateofjs.com/),它是最受欢迎和最受欢迎的。Vite 还使得设置现代前端项目变得容易,同时如果需要,还可以稍后扩展配置。按照以下步骤使用 Vite 设置您的项目:

  1. 在 VS Code 菜单栏中,转到 终端 | 新建终端 以打开一个新的终端。

  2. 在终端内部,运行以下命令:

    $ npm create vite@5.0.0 .
    

    确保命令末尾有一个句号,以便在当前文件夹中创建项目,而不是创建一个新的文件夹。

注意

为了确保即使新版本发布,本书中的说明仍然有效,我们将所有包固定到特定版本。请按照给定的版本进行操作。完成本书后,当您自己开始新项目时,应始终尝试使用最新版本,但请注意,可能需要进行一些更改才能使其正常工作。请查阅相应包的文档,并遵循从本书版本到最新版本的迁移路径。

  1. 当被问及是否安装 create-vite 时,只需键入 y 并按 Return/Enter 键继续。

  2. 当被问及框架时,使用箭头键选择 React 并按 Return 键。如果您被要求输入项目名称,按 Ctrl + C 取消,然后再次运行命令,确保在末尾有一个句点以选择当前文件夹。

  3. 当被问及变体时,选择 JavaScript

  4. 现在,我们的项目已经搭建完成,我们可以运行 npm install 来安装依赖。

  5. 之后,运行 npm run dev 来启动开发服务器,如下截图所示:

图 1.4 – 使用 Vite 搭建项目后和启动开发服务器前的终端

图 1.4 – 使用 Vite 搭建项目后和启动开发服务器前的终端

注意

为了简化设置过程,我们直接使用了 npm。如果您更喜欢 yarnpnpm,可以分别运行 yarn create vitepnpm create vite

  1. 在终端中,您将看到一个 URL,告诉您应用正在运行的位置。您可以选择按住 Ctrl (Cmd 在 macOS 上) 并点击链接在浏览器中打开,或者手动在浏览器中输入 URL。

  2. 要测试您的应用是否交互式,点击带有文本 count is 0 的按钮,每次按下它都应该增加计数。

图 1.5 – 使用 Vite 运行的第一个 React 应用

图 1.5 – 使用 Vite 运行的第一个 React 应用

Vite 的替代方案

Vite 的替代方案包括打包器,如 webpack、Rollup 和 Parcel。这些打包器配置高度灵活,但通常在开发服务器方面并不提供很好的体验。它们必须首先将所有我们的代码打包在一起,然后再将其提供给浏览器。相反,Vite 本地支持 ECMAScript 模块ESM)标准。此外,Vite 起步时配置需求非常少。Vite 的一个缺点是,它可能难以配置某些更复杂的场景。一个有希望的即将到来的打包器是 Turbopack;然而,在撰写本文时,它仍然非常新。对于全栈开发,我们将稍后了解 Next.js,这是一个提供开箱即用开发服务器的 React 框架。

现在我们已经搭建好了样板项目,让我们花些时间设置一些工具,这些工具将强制执行最佳实践并保持一致的代码风格。

设置 ESLint 和 Prettier 以强制执行最佳实践和代码风格

现在我们已经设置了 React 应用,我们将设置ESLint来强制执行 JavaScript 和 React 的编码最佳实践。我们还将设置Prettier来强制执行代码风格并自动格式化我们的代码。

安装必要的依赖项

首先,我们将安装所有必要的依赖项:

  1. 在终端中,单击终端窗格右上角的分割终端图标以创建一个新的终端窗格。这将保持我们的应用运行,同时我们可以运行其他命令。

  2. 单击这个新打开的窗格以将其聚焦。然后,输入以下命令来安装 ESLint、Prettier 和相关插件:

    $ npm install --save-dev prettier@3.1.0 \
      eslint@8.54.0 \
      eslint-plugin-react@7.33.2 \
      eslint-config-prettier@9.0.0 \
      eslint-plugin-jsx-a11y@6.8.0
    

    安装的包如下:

    • prettier:根据定义的代码风格自动格式化我们的代码

    • eslint:分析我们的代码并强制执行最佳实践

    • eslint-config-react:启用与 React 项目相关的 ESLint 规则

    • eslint-config-prettier:禁用与代码风格相关的 ESLint 规则,以便 Prettier 可以处理它们

    • eslint-plugin-jsx-a11y:允许 ESLint 检查我们的 JSX 代码中的可访问性(a11y)问题

注意

npm中的--save-dev标志将那些依赖项保存为dev依赖项,这意味着它们只会在开发时安装。它们不会被安装并包含在部署的应用中。这对于保持我们容器的大小尽可能小非常重要。

在安装了依赖项之后,我们需要配置 Prettier 和 ESLint。我们将从配置 Prettier 开始。

配置 Prettier

Prettier 将为我们格式化代码,并替换 VS Code 中 JavaScript 的默认代码格式化器。它将允许我们花更多的时间编写代码,在保存文件时自动为我们正确地格式化。按照以下步骤配置 Prettier:

  1. 在 VS Code 左侧侧边栏的文件列表下方右键单击(如果未打开,请单击文件图标)并按新建文件...来创建一个新文件。命名为.prettierrc.json(不要忘记文件名开头的点!)。

  2. 新创建的文件应自动打开,因此我们可以开始将以下配置写入其中。我们首先创建一个新的对象,并将trailingComma选项设置为all,以确保跨越多行的对象和数组始终在末尾有逗号,即使是最后一个元素。这减少了通过 Git 提交更改时需要修改的行数:

    {
      "trailingComma": "all",
    
  3. 然后,我们将tabWidth选项设置为2个空格:

      "tabWidth": 2,
    
  4. printWidth设置为每行80个字符,以避免代码中出现长行:

      "printWidth": 80,
    
  5. semi选项设置为false以避免在不必要的地方使用分号:

      "semi": false,
    
  6. 最后,我们强制使用单引号而不是双引号:

      "jsxSingleQuote": true,
      "singleQuote": true
    }
    

注意

这些 Prettier 设置只是编码风格约定的一个示例。当然,你可以根据自己的喜好进行调整。还有更多选项,所有这些都可以在 Prettier 文档中找到(prettier.io/docs/en/options.html)。

配置 Prettier 扩展

现在我们已经有了 Prettier 的配置文件,我们需要确保 VS Code 扩展正确配置,以便为我们格式化代码:

  1. 在 Windows/Linux 上,通过文件 | 首选项... | 设置打开 VS Code 设置,或在 macOS 上通过代码 | 设置... | 设置

  2. 在新打开的设置编辑器中,点击工作区选项卡。这确保我们将所有设置保存在项目文件夹中的.vscode/settings.json文件中。当其他开发者打开我们的项目时,他们也会自动使用这些设置。

  3. 在搜索栏中搜索保存时格式化编辑器,并勾选复选框以启用保存时格式化代码。

  4. 在搜索栏中搜索编辑器默认格式化程序,并从列表中选择Prettier - 代码格式化程序

  5. 为了验证 Prettier 是否正常工作,打开.prettierrc.json文件,在行首添加一些额外的空格,并保存文件。你应该会注意到 Prettier 已经重新格式化了代码以符合定义的代码风格。它将缩进空格的数量减少到两个。

现在 Prettier 已经正确设置,我们不再需要手动格式化代码了。请随意输入代码,并在保存文件时自动获得格式化!

创建 Prettier 忽略文件

为了提高性能并避免在应该自动格式化的文件上运行 Prettier,我们可以通过创建 Prettier 忽略文件来忽略某些文件和文件夹。按照以下步骤操作:

  1. 在我们项目的根目录下创建一个名为.prettierignore的新文件,类似于我们创建.prettierrc.json文件的方式。

  2. 向其中添加以下内容以忽略转译的源代码:

    dist/
    

    node_modules/文件夹会自动被 Prettier 忽略。

现在我们已经成功设置了 Prettier,我们将配置 ESLint 以强制执行编码最佳实践。

配置 ESLint

虽然 Prettier 专注于我们代码的样式和格式,但 ESLint 专注于实际代码,避免常见的错误或不必要的代码。现在让我们来配置它:

  1. 删除自动创建的.eslintrc.cjs文件。

  2. 创建一个新的.eslintrc.json文件,并开始将其配置写入其中。首先,我们将root设置为true,以确保 ESLint 不会查看父文件夹以获取更多配置:

    {
      "root": true,
    
  3. 定义一个env对象,在其中我们将浏览器环境设置为true,以便 ESLint 能够理解浏览器特定的全局变量,如documentwindow

      "env": {
        "browser": true
      },
    
  4. 定义一个parserOptions对象,其中我们指定我们正在使用最新的 ECMAScript 版本和 ESM:

      "parserOptions": {
        "ecmaVersion": "latest",
        "sourceType": "module"
      },
    
  5. 定义一个 extends 数组以扩展到推荐配置。具体来说,我们扩展到 ESLint 的推荐规则和我们安装的插件的推荐规则:

      "extends": [
        "eslint:recommended",
        "plugin:react/recommended",
        "plugin:react/jsx-runtime",
        "plugin:jsx-a11y/recommended",
    
  6. 作为数组的最后一个元素,我们使用 prettier 来禁用 ESLint 中所有与代码风格相关的规则,并让 Prettier 来处理:

        "prettier"
      ],
    
  7. 现在,我们为插件定义设置。首先,我们告诉 react 插件自动检测我们安装的 React 版本:

      "settings": {
        "react": {
          "version": "detect"
        }
      },
    
  8. 最后,在 settings 部分之外,我们定义一个 overrides 数组,在其中指定 ESLint 应该只检查 .js.****jsx 文件:

      "overrides": [
        {
          "files": ["*.js", "*.jsx"]
        }
      ]
    }
    
  9. 创建一个新的 .eslintignore 文件,内容如下:

    dist/
    vite.config.js
    

    The node_modules/ folder is automatically ignored by ESLint.

  10. 保存文件并在终端中运行 npx eslint src 来运行代码检查器。你会看到由于我们配置的规则与 Vite 默认项目中提供的源不匹配,已经存在一些错误:

图 1.6 – 当第一次运行 ESLint 时,我们会得到一些关于规则违反的错误

图 1.6 – 当第一次运行 ESLint 时,我们会得到一些关于规则违反的错误

  1. 幸运的是,所有这些问题都可以通过 ESLint 自动修复。运行 npx eslint src --fix 来自动修复问题。现在,当你再次运行 npx eslint src 时,你将不会得到任何输出。这意味着没有代码检查器错误!

小贴士

npx 命令允许我们在类似于在 package.json 脚本中运行它们的环境中执行由 npm 包提供的命令。它还可以运行远程包而无需永久安装。如果包尚未安装,它将询问你是否应该这样做。

添加一个新的脚本来运行我们的代码检查器

在上一节中,我们通过手动运行 npx eslint src 来调用代码检查器。我们现在要将一个 lint 脚本添加到 package.json 中:

  1. 在终端中运行以下命令来在 package.json 文件中定义一个 lint 脚本:

    $ npm pkg set scripts.lint="eslint src"
    
  2. 现在,在终端中运行 npm run lint。这应该像 npx eslint src 一样成功执行 eslint src

图 1.7 – 代码检查器成功运行,没有错误

图 1.7 – 代码检查器成功运行,没有错误

在设置好 ESLint 和 Prettier 之后,我们仍需要确保在提交代码之前它们已经运行。让我们设置 Husky 来确保我们现在提交的代码是正确的。

设置 Husky 以确保我们提交的代码正确

在设置好 Prettier 和 ESLint 之后,我们现在将自动通过 Prettier 在保存时格式化代码,并在 VS Code 中看到 ESLint 的错误,当我们犯错或忽略最佳实践时。然而,我们可能会错过一些这些错误,并意外提交无效的代码。为了避免这种情况,我们可以设置 Huskylint-staged,它们在我们将代码提交到 Git 之前运行,并确保在提交之前 Prettier 和 ESLint 已成功在源代码上执行。

重要

如果你克隆了本书的完整仓库,Husky 在运行npm install时可能找不到.git目录。在这种情况下,只需在相应章节文件夹的根目录下运行git init

让我们按照以下步骤设置 Husky 和 lint-staged:

  1. 运行以下命令将 Husky 和 lint-staged 作为dev依赖项安装:

    $ npm install --save-dev husky@8.0.3 \
      lint-staged@15.1.0
    
  2. 打开package.json文件,并在devDependencies之后添加以下lint-staged配置,然后保存文件。这将运行 Prettier 和 ESLint 对所有提交的.js.jsx文件,并尝试自动修复代码风格和 lint 错误,如果可能的话:

      "lint-staged": {
        "**/*.{js,jsx}": [
          "npx prettier --write",
          "npx eslint --fix"
        ]
      }
    
  3. ch1文件夹中初始化一个 Git 仓库,并仅使用package.json文件进行初始提交,因为 lint-staged 在初始提交中不会执行:

    $ git init
    $ git add package.json
    $ git commit -m "chore: initial commit"
    
  4. husky install脚本添加到package.json中的prepare脚本中,以便在项目克隆和执行npm install时自动安装 Husky:

    $ npm pkg set scripts.prepare="husky install"
    
  5. 由于我们现在不需要再次运行npm install,我们需要这次手动运行prepare脚本:

    $ npm run prepare
    
  6. 为 lint-staged 添加一个pre-commit钩子,以便每次我们执行git commit时,ESLint 和 Prettier 都会运行:

    $ npx husky add .husky/pre-commit "npx lint-staged"
    
  7. 现在,将所有文件添加到 Git 中,并尝试进行提交:

    $ git add .
    $ git commit -m "chore: basic project setup"
    

如果一切顺利,你应该在运行git commit后看到husky运行lint-staged,它反过来运行prettiereslint。如果你遇到配置错误,请确保所有文件都已正确保存,然后再次运行git commit

图 1.8 – Husky 和 lint-staged 在我们提交之前成功执行代码风格和最佳实践

图 1.8 – Husky 和 lint-staged 在我们提交之前成功执行代码风格和最佳实践

设置 commitlint 以强制我们的提交信息遵循标准

除了检查我们的代码,我们还可以检查我们的提交信息。你可能已经注意到,我们已经在提交信息前加了类型(例如chore类型)。类型使得跟踪提交中发生了什么变得更加容易。为了强制使用类型,我们可以设置commitlint。按照以下步骤进行设置:

  1. 安装 commitlint 和 commitlint 的常规配置:

    $ npm install --save-dev @commitlint/cli@18.4.3 \
      @commitlint/config-conventional@18.4.3
    
  2. 在我们项目的根目录下创建一个新的.commitlintrc.json文件,并添加以下内容:

    {
      "extends": ["@commitlint/config-conventional"]
    }
    
  3. 向 Husky 添加一个commit-msg钩子:

    $ npx husky add .husky/commit-msg \
      'npx commitlint --edit ${1}'
    
  4. 现在,如果我们尝试添加更改的文件并提交而没有类型或错误的类型,我们将从 commitlint 收到错误,并且无法进行此类提交。如果我们添加正确的类型,它将成功:

    $ git add .
    $ git commit -m "no type"
    $ git commit -m "wrong: type"
    $ git commit -m "chore: configure commitlint"
    

下图显示了 Husky 的作用。如果我们写了一个错误的提交信息,它将拒绝它,并阻止我们提交代码。只有当我们输入一个格式正确的提交信息时,提交才会通过:

图 1.9 – commitlint 成功运行并阻止提交没有类型和错误类型的提交

图 1.9 – commitlint 成功运行并防止没有类型和类型错误的提交

在 commitlint 约定配置([www.conventionalcommits.org/](https://www.conventionalcommits.org/))中的提交信息结构是,必须首先列出类型,然后可选的范围,接着是描述,例如type(scope): description。可能类型如下:

  • fix: 用于错误修复

  • feat: 用于新功能

  • refactor: 用于重构代码而不添加功能或修复错误

  • build: 用于构建系统或依赖项的更改

  • ci: 用于 CI/CD 配置的更改

  • docs: 仅用于文档的更改

  • perf: 用于性能优化

  • style: 用于修复代码格式

  • test: 用于添加或调整测试

范围是可选的,最好在 monorepo 中使用,以指定对其中某个应用程序或库进行了更改。

摘要

现在我们已经成功设置了我们的项目并开始实施标准,我们可以在不担心代码风格一致、提交信息一致或犯小错误的情况下继续我们的项目工作。ESLint、Prettier、Husky 和 commitlint 已经为我们解决了这些问题。

在下一章,第二章了解 Node.js 和 MongoDB,我们将学习如何编写和运行小的 Node.js 脚本以及数据库系统 MongoDB 是如何工作的。

第二章:了解 Node.js 和 MongoDB

在上一章中,我们设置了我们的 IDE 和一个基本的前端开发项目。在本章中,我们将首先学习如何使用 Node.js 编写和运行脚本。然后,我们将介绍 Docker 作为设置数据库服务的一种方式。一旦我们设置了 Docker 和数据库的容器,我们将访问它以了解更多关于 MongoDB 的信息,这是我们接下来将要使用的文档数据库。最后,我们将通过通过 Node.js 脚本访问 MongoDB 来连接本章所学的一切。

到本章结束时,您将了解使用 JavaScript 进行后端开发中最重要工具和概念。本章为我们提供了一个良好的基础,以便在接下来的章节中为我们的第一个全栈应用创建后端服务。

在本章中,我们将涵盖以下主要主题:

  • 使用 Node.js 编写和运行脚本

  • 介绍 Docker,一个容器平台

  • 介绍 MongoDB,一个文档数据库

  • 通过 Node.js 访问 MongoDB 数据库

技术要求

在我们开始之前,请安装以下内容(除了来自 第一章为全栈开发做准备)的所有技术要求,如果您还没有安装它们:

  • Docker v24.0.6

  • Docker Desktop v4.25.2

  • MongoDB Shell v2.1.0

列出的版本是本书中使用的版本。虽然安装较新版本不应有问题,但请注意,某些步骤在较新版本上可能有所不同。如果您在本书中提供的代码和步骤遇到问题,请尝试使用提到的版本。

您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch2.

本章的 CiA 视频可以在:youtu.be/q_LHsdJEaPo 找到。

重要

如果您克隆了本书的完整仓库,当运行 npm install 时,Husky 可能找不到 .git 目录。在这种情况下,只需在相应章节文件夹的根目录下运行 git init

使用 Node.js 编写和运行脚本

为了成为全栈开发者,熟悉后端技术非常重要。因为我们已经熟悉了从编写前端应用中使用的 JavaScript,我们可以使用 Node.js 来用 JavaScript 开发后端服务。在本节中,我们将创建第一个简单的 Node.js 脚本,以便熟悉后端脚本和前端代码之间的区别。

浏览器中的 JavaScript 和 Node.js 之间的相似之处和不同之处

Node.js 是基于 V8 构建的,这是基于 Chromium 的浏览器(Google Chrome、Brave、Opera、Vivaldi 和 Microsoft Edge)使用的 JavaScript 引擎。因此,JavaScript 代码在浏览器和 Node.js 中将以相同的方式运行。然而,有一些差异,特别是在环境方面。环境建立在引擎之上,并允许我们在浏览器中渲染某些内容(使用 documentwindow 对象)。在 Node.js 中,有一些模块提供与操作系统接口,用于创建文件和处理网络请求等任务。这些模块允许我们使用 Node.js 创建后端服务。

让我们来看看 Node.js 架构与浏览器中 JavaScript 的对比:

图 2.1 – Node.js 架构与浏览器中 JavaScript 的对比

图 2.1 – Node.js 架构与浏览器中 JavaScript 的对比

从可视化中我们可以看出,Node.js 和浏览器 JavaScript 都运行在 JavaScript 引擎上,在 Node.js 中总是 V8,对于基于 Chromium 的浏览器可以是 V8,Firefox 中的 SpiderMonkey,或 Safari 中的 JavaScriptCore。

现在我们知道我们可以在 Node.js 中运行 JavaScript 代码,让我们试试看!

创建我们的第一个 Node.js 脚本

在我们开始编写后端服务之前,我们需要熟悉 Node.js 环境。因此,让我们先写一个简单的“hello world”示例:

  1. 将上一章的 ch1 文件夹复制到新的 ch2 文件夹中,如下所示:

    $ cp -R ch1 ch2
    

注意

在 macOS 上,运行命令时需要使用大写的 -R 标志,而不是 -r-r 标志对符号链接的处理方式不同,会导致 node_modules/ 文件夹损坏。-r 标志仅出于历史原因存在,不应在 macOS 上使用。始终优先使用 -R 标志。

  1. 在 VS Code 中打开新的 ch2 文件夹。

  2. ch2 文件夹中创建一个新的 backend 文件夹。这将包含我们的后端代码。

  3. backend 文件夹中创建一个 helloworld.js 文件,并输入以下代码:

    console.log('hello node.js world!')
    
  4. ch2 文件夹中打开一个终端,并运行以下命令来执行 Node.js 脚本:

    $ node backend/helloworld.js
    

你会看到控制台输出显示 hello node.js world!。在编写 Node.js 代码时,我们可以利用前端 JavaScript 世界中熟悉的函数,并在后端运行相同的 JavaScript 代码!

注意

虽然大多数前端 JavaScript 代码在 Node.js 中运行良好,但并非所有前端代码都能在 Node.js 环境中自动运行。有一些对象,如 documentwindow,是特定于浏览器环境的。这一点需要记住,尤其是在我们后面介绍服务器端渲染时。

现在我们已经对 Node.js 的工作原理有了基本的了解,让我们开始用 Node.js 处理文件。

在 Node.js 中处理文件

与浏览器环境不同,Node.js 通过 node:fs(文件系统)模块提供了处理我们计算机上文件的功能。例如,我们可以利用这个功能来读取和写入各种文件,甚至可以将文件用作简单的数据库。

按照以下步骤创建你的第一个处理文件的 Node.js 脚本:

  1. 创建一个新的 backend/files.js 文件。

  2. node:fs 内部 Node.js 模块导入 writeFileSyncreadFileSync 函数。这个模块不需要通过 npm 安装,因为它是由 Node.js 运行时提供的。

    import { writeFileSync, readFileSync } from 'node:fs'
    
  3. 创建一个包含用户信息的简单数组,包括姓名和电子邮件地址:

    const users = [{ name: 'Adam Ondra', email: 'adam.ondra@climb.ing' }]
    
  4. 在我们能够将这个数组保存到文件之前,我们首先需要使用 JSON.stringify 将它转换成一个字符串:

    const usersJson = JSON.stringify(users)
    
  5. 现在我们可以通过使用 writeFileSync 函数将我们的 JSON 字符串保存到文件中。这个函数接受两个参数——首先是要写入的文件名,然后是要写入文件中的字符串:

    writeFileSync('backend/users.json', usersJson)
    
  6. 在写入文件后,我们可以尝试再次使用 readFileSync 读取它,并使用 JSON.parse 解析 JSON 字符串:

    const readUsersJson = readFileSync('backend/users.json')
    const readUsers = JSON.parse(readUsersJson)
    
  7. 最后,我们打印出解析后的数组:

    console.log(readUsers)
    
  8. 现在我们可以运行我们的脚本了。你会看到数组被打印出来,并且在我们的 backend/ 文件夹中创建了一个 users.json 文件:

    $ node backend/files.js
    

你可能已经注意到,我们一直在使用 writeFileSync,而不是 writeFile。在 Node.js 中,默认的行为是异步运行所有操作,这意味着如果我们使用 writeFile,在调用 readFile 时文件可能还没有被创建,因为异步代码不是按顺序执行的。

当编写像我们这样的简单脚本时,这种行为可能会让人烦恼,但当处理例如网络请求时,它非常有用,我们不想在处理另一个请求时阻塞其他用户访问我们的服务。

在了解了如何使用 Node.js 处理文件之后,让我们再来了解一下在浏览器和 Node.js 中异步代码是如何执行的。

浏览器和 Node.js 中的 JavaScript 并发

JavaScript 的一个基本且特殊的特性是,大多数 API 函数默认都是异步的。这意味着代码不会简单地按照定义的顺序执行。具体来说,JavaScript 是事件驱动的。在浏览器中,这意味着 JavaScript 代码会因为用户交互而运行。例如,当按钮被点击时,我们定义一个 onClick 处理器来执行一些代码。

在服务器端,输入/输出操作,如读写文件和网络请求,是异步处理的。这意味着我们可以同时处理多个网络请求,而无需自己处理线程或多进程。具体来说,在 Node.js 中,libuv负责为 I/O 操作分配线程,同时给我们,作为程序员,提供一个单独的运行时线程来编写我们的代码。然而,这并不意味着每个连接到我们后端都会创建一个新的线程。当有利时,线程会动态创建。作为开发者,我们不需要处理多线程,可以专注于使用异步代码和回调进行开发。

如果代码是同步的,它将通过在浏览器中的onClick监听器上直接放置代码来执行 – 当用户点击相关元素时,回调函数也将放入任务队列,这意味着它将在栈上没有其他内容时执行。同样,在 Node.js 中,我们可以添加网络事件的监听器,并在收到请求时执行回调。

与多线程服务器相比,Node.js 服务器在一个包含事件循环的单线程中接受所有请求。多线程服务器的缺点是线程可以完全阻塞 I/O 并减慢服务器。然而,Node.js 默认情况下以细粒度方式动态地将操作委托给线程。这导致 I/O 操作阻塞更少。Node.js 的缺点是我们对多线程的控制较少,因此需要尽可能避免使用同步函数。否则,我们将阻塞主 Node.js 线程并减慢我们的服务器。为了简单起见,我们在这章中仍然使用同步函数。从现在开始,在下一章中,我们将避免使用这些函数,并完全依赖异步函数(在可能的情况下)以获得最佳性能。

以下图表展示了多线程服务器与 Node.js 服务器的区别:

图 2.2 – 多线程服务器与 Node.js 服务器的区别

图 2.2 – 多线程服务器与 Node.js 服务器的区别

我们可以通过使用setTimeout函数来观察这种异步行为,这是一个你可能从前端代码中熟悉的函数。它等待指定数量的毫秒,然后执行回调函数中指定的代码。例如,如果我们运行以下代码(无论是使用 Node.js 脚本还是在浏览器中,两者的结果都是相同的):

console.log('first')
setTimeout(() => {
  console.log('second')
}, 1000)
console.log('third')

我们可以看到它们按照以下顺序打印出来:

first
third
second

这是有意义的,因为我们通过延迟“第二个”console.log一秒钟。然而,如果我们执行以下代码,也会得到相同的结果:

console.log('first')
setTimeout(() => {
  console.log('second')
}, 0)
console.log('third')

现在我们等待零毫秒后执行代码,你可能会认为“第二个”会在“第一个”之后打印出来。然而,情况并非如此。相反,我们得到了与之前相同的输出:

first
third
second

原因是当我们使用 setTimeout 时,JavaScript 引擎会调用 Web API(在浏览器上)或原生 API(在 Node.js 上)。这个 API 在引擎的本地代码中运行,内部跟踪超时,并将回调放入任务队列,因为计时器立即完成。当这个过程中发生时,JavaScript 引擎继续通过将其推入栈中并执行它来处理其他代码。当栈为空(没有更多的代码要执行)时,事件循环前进。它看到任务队列中有东西,因此执行那个代码,导致最后打印出“second”。

小贴士

您可以使用 Loupe 工具来可视化调用栈、Web API、事件循环和回调/任务队列的内部工作原理:latentflip.com/loupe/

现在我们已经了解了在浏览器和 Node.js 中如何处理异步代码,让我们使用 Node.js 创建我们的第一个 Web 服务器!

创建我们的第一个 Web 服务器

现在我们已经了解了 Node.js 的工作原理的基础,我们可以使用 node:http 库来创建一个简单的 Web 服务器。对于我们的第一个简单服务器,我们将只对任何请求返回一个 200 OK 响应和一些纯文本。让我们开始以下步骤:

  1. 创建一个新的 backend/simpleweb.js 文件,打开它,并从 node:http 模块导入 createServer 函数:

    import { createServer } from 'node:http'
    
  2. createServer 函数是异步的,因此我们需要向它传递一个回调函数。当服务器收到请求时,这个函数将被执行。它有两个参数,一个请求对象(req)和一个响应对象(res)。使用 createServer 函数定义一个新的服务器:

    const server = createServer((req, res) => {
    
  3. 现在,我们将忽略请求对象,只返回一个静态响应。首先,我们将状态码设置为 200

      res.statusCode = 200
    
  4. 然后,我们将 Content-Type 标头设置为 text/plain,这样浏览器就知道它正在处理什么类型的响应数据:

      res.setHeader('Content-Type', 'text/plain')
    
  5. 最后,我们通过在响应中返回一个 Hello HTTP world! 字符串来结束请求:

      res.end('Hello HTTP world!')
    })
    
  6. 在定义服务器后,我们需要确保在特定的主机和端口上监听。这些将定义服务器将在哪里可用。现在,我们使用 localhost 的端口 3000 来确保我们的服务器可以通过 http://localhost:3000/ 访问:

    const host = 'localhost'
    const port = 3000
    
  7. server.listen 函数也是异步的,需要我们传递一个回调函数,该函数将在服务器启动并运行后立即执行。我们现在可以在这里简单地记录一些信息:

    server.listen(port, host, () => {
      console.log(`Server listening on http://${host}:${port}`)
    })
    
  8. 按以下方式运行 Node.js 脚本:

    $ node backend/simpleweb.js
    
  9. 您会注意到我们得到了 Server listening on http://localhost:3000 的日志消息,因此我们知道服务器已成功启动。这次,终端没有返回控制权;脚本继续运行。现在我们可以在浏览器中打开 http://localhost:3000

图 2.3 – 来自我们第一个 Web 服务器的纯文本响应!

图 2.3 – 来自我们第一个 Web 服务器的纯文本响应!

现在我们已经设置了一个简单的 Web 服务器,我们可以将其扩展以服务 JSON 文件而不是简单地返回纯文本。

扩展 Web 服务器以服务我们的 JSON 文件

我们现在可以尝试将我们对node:fs模块的了解与 HTTP 服务器结合起来,创建一个服务之前创建的users.json文件的服务器。让我们从以下步骤开始:

  1. backend/simpleweb.js文件复制到新的backend/webfiles.js文件。

  2. 在文件的开头添加对readFileSync的导入:

    import { readFileSync } from 'node:fs'
    
  3. Content-Type头更改为application/json

      res.setHeader('Content-Type', 'application/json')
    
  4. res.end()中的字符串替换为文件中的 JSON 字符串。在这种情况下,我们不需要解析 JSON,因为res.end()无论如何都期望一个字符串:

      res.end(readFileSync('backend/users.json'))
    
  5. 如果它仍在运行,通过Ctrl + C停止先前的服务器脚本。我们必须这样做,因为我们不能在同一个端口上监听两次。

  6. 运行服务器并刷新页面,以查看文件中的 JSON 被打印出来。尝试更改users.json文件,看看在下一个请求(刷新网站时)它又是如何被读取的:

    $ node backend/webfiles.js
    

虽然作为练习很有用,但文件并不是用于生产的适当数据库。因此,我们稍后将介绍 MongoDB 作为数据库。我们将在 Docker 中运行 MongoDB 服务器,所以让我们先简要地看看 Docker。

介绍 Docker,一个容器平台

Docker 是一个允许我们在称为容器的松散隔离环境中打包、管理和运行应用程序的平台。容器轻量级,彼此隔离,并包含运行应用程序所需的所有依赖项。因此,我们可以使用容器轻松地设置各种服务和应用程序,而无需处理依赖项或它们之间的冲突。

注意

还有其他工具,例如 Podman(它甚至有一个与 Docker CLI 命令兼容的层),以及 Rancher Desktop,它也支持 Docker CLI 命令。

我们可以在本地使用 Docker 设置和运行隔离环境中的服务。这样做可以避免污染我们的主机环境,并确保有一个一致的状态可以在此基础上构建。这种一致性在大型开发团队中尤为重要,因为它确保每个人都在使用相同的状态。

此外,Docker 使得将容器部署到各种云服务并运行在持续集成/持续交付CI/CD)工作流程中变得容易。

在本节中,我们将首先了解 Docker 平台的整体情况。然后,我们将学习如何创建容器以及如何从 VS Code 访问 Docker。最后,我们将了解 Docker 的工作原理以及如何用它来管理服务。

Docker 平台

Docker 平台本质上由三个部分组成:

  • Docker 客户端:可以通过将命令发送到Docker 守护进程来运行命令,该守护进程要么在本地机器上运行,要么在远程环境中运行。

  • Docker 主机:包含 Docker 守护进程、镜像和容器。

  • Docker 仓库:托管和存储 Docker 镜像、扩展和插件。默认情况下,将使用公共仓库 Docker Hub 来搜索镜像。

图 2.4 – Docker 平台概览

图 2.4 – Docker 平台概览

包含 MongoDB 服务器的 mongo 镜像是基于 ubuntu 镜像的。

Docker 容器是镜像的实例。它们运行一个配置了服务的操作系统(例如在 Ubuntu 上的 MongoDB 服务器)。此外,它们可以被配置,例如,将容器内的某些端口转发到主机,或者将存储卷挂载到容器中,该卷在主机机器上存储数据。默认情况下,容器与主机机器隔离,因此如果我们想从主机访问端口或存储,我们需要告诉 Docker 允许这样做。

安装 Docker

设置 Docker 平台进行本地开发最简单的方法是使用 Docker Desktop。它可以从官方 Docker 网站下载(www.docker.com/products/docker-desktop/)。按照说明进行安装并启动 Docker 引擎。安装后,你应该在终端中有一个可用的 docker 命令。运行以下命令以验证其是否正常工作:

$ docker -v

此命令应输出 Docker 版本,如下例所示:

Docker version 24.0.6, build ed223bc

安装并启动 Docker 后,我们可以继续创建容器。

创建容器

Docker 客户端可以通过 docker run 命令从镜像实例化一个容器。现在让我们创建一个 ubuntu 容器并在其中运行一个 shell (/bin/bash):

$ docker run -i -t ubuntu:24.04 /bin/bash

注意

镜像名称后面的 :24.04 字符串被称为 标签,它可以用来将镜像固定到特定版本。在这本书中,我们使用标签来拉取特定版本的镜像,以便即使发布新版本,步骤也可以重复。默认情况下,如果没有指定标签,Docker 将尝试使用 latest 标签。

将打开一个新的 shell。我们可以通过执行以下命令来验证这个 shell 是否在容器中运行,以查看正在运行的操作系统的版本:

$ uname -a

如果你得到一个以 -linuxkit 结尾的版本号,那么你已经在容器中成功运行了一个命令,因为 LinuxKit 是一个用于创建小型 Linux 虚拟机的工具包!

你现在可以输入以下命令来退出 shell 和容器:

$ exit

下图显示了运行这些命令的结果:

图 2.5 – 运行我们的第一个 Docker 容器

图 2.5 – 运行我们的第一个 Docker 容器

docker run 命令执行以下操作:

  • 如果你之前从未运行过基于 ubuntu 镜像的容器,Docker 将首先从 Docker 仓库拉取该镜像(这相当于执行 docker pull ubuntu 命令)。

  • 下载镜像后,Docker 将创建一个新的容器(相当于执行 docker container create 命令)。

  • 然后,Docker 为容器配置了一个读写文件系统并创建了一个默认的网络接口。

  • 最后,Docker 启动容器并执行指定的命令。在我们的例子中,我们指定了 /bin/bash 命令。因为我们传递了 -i(保持 STDIN 打开)和 -t(分配伪终端)选项,Docker 将容器的 shell 连接到我们当前运行的终端,使我们能够像直接访问主机机器上的终端一样使用容器。

如我们所见,Docker 对于创建我们的应用程序和服务运行的自包含环境非常有用。在本书的后续章节中,我们将学习如何将我们的应用程序打包到 Docker 容器中。现在,我们只是使用 Docker 来运行服务,而无需在主机系统上安装它们。

通过 VS Code 访问 Docker

我们也可以通过我们在 第一章 中安装的 VS Code 扩展来访问 Docker,为全栈开发做准备。要做到这一点,请点击 VS Code 左侧边栏中的 Docker 图标。Docker 侧边栏将打开,显示容器、镜像、注册表、网络、卷、上下文和相关资源的列表:

图 2.6 – VS Code 中的 Docker 侧边栏

图 2.6 – VS Code 中的 Docker 侧边栏

在这里,您可以查看哪些容器已停止,哪些正在运行。您可以在容器上右键单击以启动、停止、重启或删除它。您还可以查看其日志以调试容器内部发生的情况。此外,您还可以将 shell 连接到容器以获取对其操作系统的访问权限。

现在我们已经了解了 Docker 的基础知识,我们可以为我们的 MongoDB 数据库服务器创建一个容器。

介绍 MongoDB,一个文档数据库

截至撰写本文时,MongoDB 是最受欢迎的 NoSQL 数据库。与 结构化查询语言SQL)数据库(如 MySQL 或 PostgreSQL)不同,NoSQL 表示数据库特别不使用 SQL 来查询数据库。相反,NoSQL 数据库有各种其他查询数据库的方式,并且通常数据存储和查询的结构大不相同。

存在以下主要类型的 NoSQL 数据库:

  • 键值存储(例如,Valkey/Redis)

  • 列式数据库(例如,Amazon Redshift)

  • 基于图的数据库(例如,Neo4j)

  • 文档数据库(例如,MongoDB)

图 2.7 – NoSQL 数据库概述

图 2.7 – NoSQL 数据库概述

MongoDB 是一种基于文档的数据库,这意味着数据库中的每个条目都存储为文档。在 MongoDB 中,这些文档基本上是 JSON 对象(内部,它们以 BSON 格式存储,这是一种二进制 JSON 格式,可以节省空间并提高性能,以及其他优点)。相反,SQL 数据库将数据存储为表中的行。因此,MongoDB 提供了更多的灵活性。字段可以自由添加或省略在文档中。这种结构的缺点是我们没有文档的一致模式。然而,这可以通过使用库,如 Mongoose 来解决,我们将在 第三章使用 Express、Mongoose ODM 和 Jest 实现后端 中了解。

图 2.8 – MongoDB 与 SQL 数据库的比较

图 2.8 – MongoDB 与 SQL 数据库的比较

MongoDB 也基于 JavaScript 引擎。从版本 3.2 开始,它使用 SpiderMonkey(Firefox 使用的 JavaScript 引擎)而不是 V8。尽管如此,这意味着我们仍然可以在 MongoDB 中执行 JavaScript 代码。例如,我们可以在 MongoDB Shell 中使用 JavaScript 来帮助进行管理任务。然而,我们必须对此保持谨慎,因为 MongoDB 环境与浏览器或 Node.js 环境大不相同。

在本节中,我们将首先学习如何使用 Docker 设置 MongoDB 服务器。然后,我们将更深入地了解 MongoDB 以及如何使用 MongoDB Shell 直接访问它来进行数据库和数据的管理。我们还将学习如何使用 VS Code 访问 MongoDB。在本节的最后,您将了解 MongoDB 中的 CRUD 操作是如何工作的。

注意

CRUD 是创建、读取、更新和删除的缩写,这是后端服务通常提供的常见操作。

设置 MongoDB 服务器

在我们开始使用 MongoDB 之前,我们需要设置一个服务器。由于我们已安装 Docker,我们可以通过在 Docker 容器中运行 MongoDB 来简化操作。这样做还可以通过创建单独的容器来为我们的应用程序拥有独立的、干净的 MongoDB 实例。让我们从以下步骤开始:

  1. 确保 Docker Desktop 正在运行且 Docker 已启动。您可以通过运行以下命令来验证这一点,该命令列出了所有正在运行的容器:

    $ docker ps
    CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES
    

    如果您已经运行了一些容器,将随后列出已启动的容器列表。

  2. 运行以下 Docker 命令以创建一个新的容器并包含 MongoDB 服务器:

    docker run command creates and runs a new container. The arguments are as follows:*   **-d**: Runs the container in the background (daemon mode).*   **--name**: Specifies a name for the container. In our case, we named it **dbserver**.*   **-p**: Maps a port from the container to the host. In our case, we map the default MongoDB server port **27017** in the container to the same port on our host. This allows us to access the MongoDB server running within our container from outside of it. If you already have a MongoDB server running on that port, feel free to change the first number to some other port, but make sure to also adjust the port number from **27017** to your specified port in the following guides.*   **--restart unless-stopped**: Makes sure to automatically start (and restart) the container unless we manually stop it. This ensures that every time we start Docker, our MongoDB server will already be running.*   **mongo**: This is the image name. The **mongo** image contains a MongoDB server.
    
  3. 按照 MongoDB 网站上的说明([www.mongodb.com/docs/mongodb-shell/install/](https://www.mongodb.com/docs/mongodb-shell/install/))在您的宿主系统上安装 MongoDB Shell(不要在容器内安装)。

  4. 在您的宿主系统上,运行以下命令以使用 MongoDB Shell(mongosh)连接到 MongoDB 服务器。在主机名和端口号之后,我们指定一个数据库名称。我们将把我们的数据库命名为ch2

    ch2> prompt. Here, we can enter commands to be executed on our database. Interestingly, MongoDB, like Node.js, also exposes a JavaScript engine, but with yet another different environment. So, we can run JavaScript code, such as the following:
    
    

    ch2> console.log("test")

    
    

下图显示了在 MongoDB Shell 中执行的 JavaScript 代码:

图 2.9 – 连接到运行在 Docker 容器中的我们的 MongoDB 数据库服务器

图 2.9 – 连接到运行在 Docker 容器中的我们的 MongoDB 数据库服务器

现在我们已经连接到我们的 MongoDB 数据库服务器,我们可以开始练习直接在数据库上运行命令。

直接在数据库上运行命令

在我们开始创建一个与 MongoDB 交互的后端服务之前,让我们花些时间通过 MongoDB Shell 熟悉 MongoDB 本身。MongoDB Shell 对于数据库的调试和维护任务非常重要,因此深入了解它是明智的。

创建集合并插入和列出文档

集合在 MongoDB 中相当于关系数据库中的表。它们存储文档,类似于 JSON 对象。为了更容易理解,可以将集合视为一个非常大的包含 JSON 对象的 JSON 数组。与简单的数组不同,集合支持创建索引,这可以加快查找文档中某些字段的查找速度。在 MongoDB 中,当我们尝试向其中插入文档或为其创建索引时,集合会自动创建。

让我们使用 MongoDB Shell 将一个文档插入到我们的users集合中:

  1. 要将新的用户文档插入到users集合中,请在 MongoDB Shell 中运行以下命令:

    db, then the collection name follows, and finally comes the operation, all separated by periods.
    

注意

虽然insertOne()允许我们向集合中插入单个文档,但还有一个insertMany()方法,我们可以传递一个文档数组以添加到集合中。

  1. 现在,我们可以通过运行以下命令列出users集合中的所有文档:

    > db.users.find()
    

    这样做将返回一个包含我们之前插入的文档的数组:

    [
      {
        _id: ObjectId("6405f062b0d06adeaeefc3bc"),
        username: 'dan',
        fullName: 'Daniel Bugl',
        age: 26
      }
    ]
    

如我们所见,MongoDB 自动为我们文档创建了一个唯一的 ID(ObjectId)。该 ID 由 12 个字节的十六进制格式组成(因此每个字节显示为两个字符)。字节定义如下:

  • 前面的 4 个字节是一个时间戳,表示自 Unix 纪元以来的 ID 创建时间

  • 接下来的 5 个字节是机器和当前运行的数据库进程的唯一随机值

  • 最后 3 个字节是一个随机初始化的递增计数器

注意

MongoDB 中ObjectId标识符的生成方式确保了 ID 的唯一性,即使在同一时间从不同的实例生成两个 ID 时也能避免 ID 冲突,而不需要实例之间的通信形式,这会减慢数据库扩展时文档的创建速度。

查询和排序文档

现在我们已经插入了一些文档,我们可以通过访问对象的不同字段来查询它们。我们还可以对 MongoDB 返回的文档列表进行排序。按照以下步骤操作:

  1. 在我们开始查询之前,让我们将另外两个文档插入到我们的users集合中:

    > db.users.insertMany([
      { username: 'jane', fullName: 'Jane Doe', age: 32 },
      { username: 'john', fullName: 'John Doe', age: 30 }
    ])
    
  2. 现在我们可以通过使用findOne并传递包含username字段的对象来开始查询特定的用户名。当使用findOne时,MongoDB 将返回第一个匹配的对象:

    > db.users.findOne({ username: 'jane' })
    
  3. 我们还可以查询全名,或者集合中任何其他字段的文档。当使用find时,MongoDB 将返回所有匹配项的数组:

    > db.users.find({ fullName: 'Daniel Bugl' })
    
  4. 需要注意的一个重要事项是,在查询ObjectId时,我们需要用ObjectId()构造函数将 ID 字符串括起来,如下所示:

    ObjectId() constructor to a valid ObjectId returned from the previous commands.
    
  5. MongoDB 还提供了一些查询运算符,以\(**为前缀。例如,我们可以使用**\)gt运算符在我们的集合中找到所有 30 岁以上的所有人,如下所示:

    John Doe does not get returned, because his age is exactly 30\. If we want to match ages greater than or equal to 30, we need to use the $gte operator.
    
  6. 如果我们想对结果进行排序,我们可以在.find()之后使用.sort()方法。例如,我们可以返回users集合中所有按年龄升序排序的项(1表示升序,-1表示降序):

    > db.users.find().sort({ age: 1 })
    

更新文档

要在 MongoDB 中更新文档,我们将查询和插入操作的参数组合成一个单一的操作。我们可以使用与find()相同的标准来过滤文档。要更新文档中的单个字段,我们使用$set运算符:

  1. 我们可以更新用户名为dan的用户的age字段,如下所示:

    > db.users.updateOne({ username: 'dan' }, { $set: { age: 27 } })
    

注意

就像findOne一样,updateOne只更新第一个匹配的文档。如果我们想更新所有匹配的文档,我们可以使用updateMany

MongoDB 将返回一个对象,其中包含有关匹配的文档数量(matchedCount)、修改的文档数量(modifiedCount)以及插入的文档数量(upsertedCount)的信息。

  1. updateOne方法接受第三个参数,这是一个options对象。这里一个有用的选项是upsert选项,如果设置为true,将在文档不存在时插入文档,如果已存在则更新它。让我们首先尝试使用upsert: false更新一个不存在的用户:

    > db.users.updateOne({ username: 'new' }, { $set: { fullName: 'New User' } })
    
  2. 现在我们将upsert设置为true,这将插入用户:

    > db.users.updateOne({ username: 'new' }, { $set: { fullName: 'New User' } }, { upsert: true })
    

注意

如果你想从一个文档中删除一个字段,请使用$unset运算符。如果你想用新的文档替换整个文档,可以使用replaceOne方法并将新文档作为第二个参数传递给它。

删除文档

要从数据库中删除文档,MongoDB 提供了deleteOnedeleteMany方法,这些方法与updateOneupdateMany方法具有类似的 API。第一个参数再次用于匹配文档。

假设用户名为new的用户想要删除他们的账户。为了做到这一点,我们需要从users集合中删除他们。我们可以这样做:

> db.users.deleteOne({ username: 'new' })

就这么简单!正如你所见,如果你已经知道如何处理 JSON 对象和 JavaScript,那么在 MongoDB 中执行 CRUD 操作非常简单,这使得它成为 Node.js 后端的完美数据库。

现在我们已经学会了如何使用 MongoDB Shell 访问 MongoDB,让我们学习如何在 VS Code 中访问它。

通过 VS Code 访问数据库

到目前为止,我们一直在使用终端来访问数据库。如果你还记得,在第一章准备全栈开发中,我们为 VS Code 安装了一个 MongoDB 扩展。现在我们可以使用这个扩展以更直观的方式访问我们的数据库:

  1. 点击左侧侧边栏上的 MongoDB 图标(它应该是一个叶子图标),然后点击 Add Connection 按钮:

图 2.10 – VS Code 中的 MongoDB 侧边栏

图 2.10 – VS Code 中的 MongoDB 侧边栏

  1. 将会打开一个新的 MongoDB 选项卡。在这个选项卡中,点击 Connect with Connection String 框中的 Connect

图 2.11 – 在 VS Code 中添加新的 MongoDB 连接

图 2.11 – 在 VS Code 中添加新的 MongoDB 连接

  1. 应该在顶部打开一个弹出窗口。在这个弹出窗口中,输入以下连接字符串以连接到你的本地数据库:

    mongodb://localhost:27017/
    
  2. Return/Enter 确认。新的连接将列在 MongoDB 侧边栏中。你可以浏览树形结构来查看数据库、集合和文档。例如,点击第一个文档来查看它:

图 2.12 – 在 VS Code 的 MongoDB 扩展中查看文档

图 2.12 – 在 VS Code 的 MongoDB 扩展中查看文档

小贴士

你也可以通过在 VS Code 中编辑一个字段并保存文件来直接编辑一个文档。更新的文档将自动保存到数据库中。

MongoDB 扩展对于调试我们的数据库非常有用,因为它让我们可以直观地发现问题,并快速对文档进行编辑。此外,我们可以在 Documents 上右键单击并选择 Search for documents… 以打开一个新窗口,在那里我们可以运行 MongoDB 查询,就像我们在终端中做的那样。可以通过点击右上角的 Play 按钮在数据库上执行查询。你可能需要通过点击 Yes 确认一个对话框,然后结果将显示在一个新的面板中,如下面的截图所示:

图 2.13 – 在 VS Code 中查询 MongoDB

图 2.13 – 在 VS Code 中查询 MongoDB

现在我们已经学会了使用和调试 MongoDB 数据库的基础知识,我们可以开始将数据库集成到 Node.js 后端服务中,而不仅仅是简单地从文件中存储和读取信息。

通过 Node.js 访问 MongoDB 数据库

现在我们将创建一个新的网络服务器,它将不再从 JSON 文件中返回用户,而是从我们之前创建的 users 集合中返回用户列表:

  1. ch2文件夹中,打开终端。安装mongodb包,该包包含 Node.js 的官方 MongoDB 驱动程序:

    $ npm install mongodb@6.3.0
    
  2. 创建一个新的backend/mongodbweb.js文件并打开它。导入以下内容:

    import { createServer } from 'node:http'
    import { MongoClient } from 'mongodb'
    
  3. 定义连接 URL 和数据库名称,然后创建一个新的 MongoDB 客户端:

    const url = 'mongodb://localhost:27017/'
    const dbName = 'ch2'
    const client = new MongoClient(url)
    
  4. 连接到数据库,并在成功连接后或连接出错时记录一条消息:

    try {
      await client.connect()
      console.log('Successfully connected to database!')
    } catch (err) {
      console.error('Error connecting to database:', err)
    }
    
  5. 接下来,创建一个 HTTP 服务器,就像我们之前做的那样:

    const server = createServer(async (req, res) => {
    
  6. 然后,从客户端选择数据库,并选择数据库中的users集合:

      const db = client.db(dbName)
      const users = db.collection('users')
    
  7. 现在,在users集合上执行find()方法。在 MongoDB Node.js 驱动程序中,我们还需要调用toArray()方法将迭代器解析为数组:

      const usersList = await users.find().toArray()
    
  8. 最后,设置状态码和响应头,并返回用户列表:

      res.statusCode = 200
      res.setHeader('Content-Type', 'application/json')
      res.end(JSON.stringify(usersList))
    })
    
  9. 现在我们已经定义了我们的服务器,将之前的代码复制到监听localhost端口的3000端口的代码中:

    const host = 'localhost'
    const port = 3000
    server.listen(port, host, () => {
      console.log(`Server listening on http://${host}:${port}`)
    })
    
  10. 通过执行脚本启动服务器:

    $ node backend/mongodbweb.js
    
  11. 在浏览器中打开http://localhost:3000,你应该会看到从我们的数据库返回的用户列表:

图 2.14 – 我们第一个从 MongoDB 数据库检索数据的 Node.js 服务!

图 2.14 – 我们第一个从 MongoDB 数据库检索数据的 Node.js 服务!

正如我们所见,我们可以在 Node.js 中使用与 MongoDB Shell 中类似的方法。然而,node:http模块和mongodb包的 API 非常底层,需要编写大量代码来创建 HTTP API 并与数据库通信。

在下一章中,我们将学习关于库的内容,这些库将这些过程抽象化,以便更容易地创建 HTTP API 和处理数据库。这些库是 Express 和 Mongoose。Express 是一个允许我们轻松定义 API 路由和处理请求的 Web 框架。Mongoose 允许我们在数据库中为文档创建模式,以便更容易地创建、读取、更新和删除对象。

摘要

在本章中,我们学习了如何使用 Node.js 开发可以在服务器上运行的脚本。我们还学习了如何使用 Docker 创建容器,以及 MongoDB 的工作原理及其接口。在本章末尾,我们甚至成功地使用 Node.js 和 MongoDB 创建了我们第一个简单的后端服务!

在下一章中,第三章使用 Express、Mongoose ODM 和 Jest 实现后端,我们将学习如何将本章学到的内容结合起来,将我们的简单后端服务扩展为适用于博客应用的成品后端。

第二部分:使用 REST API 构建和部署我们的第一个全栈应用程序

在本部分,我们将构建和部署我们的第一个全栈应用程序,并使用REST API。我们将首先使用ExpressMongoose ODM实现后端服务。然后,我们将使用Jest为其创建单元测试。之后,我们将使用React创建前端,并使用TanStack Query将其与后端服务集成。最后,我们将使用Docker部署应用程序,并学习如何设置 CI/CD 管道。

本部分包括以下章节:

  • 第三章使用 Express、Mongoose ODM 和 Jest 实现后端

  • 第四章使用 React 和 TanStack Query 集成前端

  • 第五章使用 Docker 和 CI/CD 部署应用程序

第三章:使用 Express、Mongoose ODM 和 Jest 实现后端

在学习了 Node.js 和 MongoDB 的基础知识之后,我们将通过使用 Express 提供 REST API、Mongoose 对象数据建模ODM)与 MongoDB 接口以及 Jest 测试我们的代码来将它们付诸实践。我们首先将学习如何使用架构模式来构建后端项目。然后,我们将使用 Mongoose 创建数据库模式。接下来,我们将创建服务函数以与数据库模式接口,并使用 Jest 为它们编写测试。然后,我们将学习 REST 是什么以及何时有用。最后,我们将提供一个 REST API 并使用 Express 来提供服务。在本章结束时,我们将拥有一个可供下一章开发的前端使用的工作后端服务。

在本章中,我们将介绍以下主要内容:

  • 设计后端服务

  • 使用 Mongoose 创建数据库模式

  • 开发和测试服务函数

  • 使用 Express 提供 REST API

技术要求

在我们开始之前,请从第一章为全栈开发做准备,以及第二章了解 Node.jsMongoDB中安装所有要求。

那些章节中列出的版本是本书中使用的版本。虽然安装较新版本可能不会出现问题,但请注意,某些步骤在较新版本上可能有所不同。如果您在使用本书中提供的代码和步骤时遇到问题,请尝试使用第一章第二章中提到的版本。

您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch3

如果您克隆了本书的完整仓库,当运行npm install时,Husky 可能找不到.git目录。在这种情况下,只需在相应章节文件夹的根目录中运行git init即可。

本章的 CiA 视频可以在以下网址找到:youtu.be/fFHVVVn03rc

设计后端服务

为了设计我们的后端服务,我们将使用一种名为模型-视图-控制器MVC)的现有架构模式的变体。MVC 模式包括以下部分:

  • 模型:处理数据和基本数据逻辑

  • 控制器:控制数据的处理和显示方式

  • 视图:显示当前状态

在传统的全栈应用中,后端会完全渲染和显示前端,通常一个交互需要整个页面的刷新。MVC 架构主要是为这类应用设计的。然而,在现代应用中,前端通常是交互式的,并且仅通过服务器端渲染在后台进行渲染。因此,在现代应用中,我们通常区分实际的底层服务(服务)和用于前端的后台(处理静态站点生成和服务器端渲染):

图 3.1 – 一个现代全栈架构,包含单个后端服务和具有服务器端渲染(SSR)和静态站点生成(SSG)的前端

图 3.1 – 一个现代全栈架构,包含单个后端服务和具有服务器端渲染(SSR)和静态站点生成(SSG)的前端

对于现代应用,后端服务只处理处理和提供请求及数据,不再渲染用户界面。相反,我们有一个专门处理前端和用户界面服务器端渲染的独立应用。为了适应这种变化,我们将 MVC 架构模式调整为适用于后端服务的以下数据-服务-路由模式:

  • 路由层:定义消费者可以访问的路由,并通过处理请求参数和主体来处理用户输入,然后调用服务函数

  • 服务层:提供服务函数,例如 创建-读取-更新-删除CRUD)函数,这些函数通过数据层访问数据库

  • 数据层:仅处理访问数据库和进行基本验证以确保数据库的一致性

这种关注点分离对于只暴露路由而不处理用户界面渲染的服务来说效果最佳。在这个模式中的每一层只处理请求处理中的一个步骤。

在了解了后端服务的设计之后,让我们开始创建一个反映我们所学的文件夹结构。

创建后端服务的文件夹结构

现在,我们将根据这个模式创建后端服务的文件夹结构。按照以下步骤操作:

  1. 首先,将 ch2 文件夹复制到新的 ch3 文件夹中,以创建一个新的用于后端服务的文件夹,如下所示:

    $ cp -R ch2 ch3
    
  2. 在 VS Code 中打开新的 ch3 文件夹。

  3. 编辑 .eslintrc.json 文件,将 browser 环境替换为 nodees6 环境,如下所示:

      "env": {
        "node": true,
        "es6": true
      },
    
  4. 此外,从 .eslintrc.json 文件中 移除 reactjsx-a11y 插件。现在我们也可以通过移除高亮行来移除与 React 相关的 设置覆盖

      "extends": [
        "eslint:recommended",
        "plugin:react/recommended",
        "plugin:react/jsx-runtime",
        "plugin:jsx-a11y/recommended",
        "prettier"
      ],
      "settings": {
        "react": {
          "version": "detect"
        }
      },
      "overrides": [
        {
          "files": ["*.js", "*.jsx"]
        }
      ]
    
  5. 删除 index.htmlvite.config.js 文件。

  6. 我们现在也可以从 .eslintignore 文件中 移除 vite.config.js 文件:

    dist/
    vite.config.js
    
  7. 删除 publicbackendsrc 文件夹。

  8. 在 VS Code 中打开 ch3 文件夹,打开一个终端并运行以下命令以移除 vitereact

    $ npm uninstall --save react react-dom
    $ npm uninstall --save-dev vite @types/react \
      @types/react-dom @vitejs/plugin-react \
      eslint-plugin-jsx-a11y eslint-plugin-react
    
  9. 编辑 package.json 文件,并从中 删除 devbuildpreview 脚本:

      "scripts": {
        "dev": "vite",
        "build": "vite build",
        "lint": "eslint src",
        "preview": "vite preview",
        "prepare": "husky install"
      },
    
  10. 现在,创建一个新的 src/ 文件夹,并在其中创建 src/db/(用于数据层)、src/services/(用于服务层)和 src/routes/(用于路由层)文件夹。

我们的第一个应用程序将是一个博客应用程序。对于这样的应用程序,我们需要 API 能够执行以下操作:

  • 获取帖子列表

  • 获取单个帖子

  • 创建一个新的帖子

  • 更新现有的帖子

  • 删除现有的帖子

为了提供这些功能,我们首先需要创建一个数据库模式来定义我们的数据库中博客文章对象的外观。然后,我们需要服务函数来处理博客文章的 CRUD 功能。最后,我们将定义我们的 REST API 以查询、创建、更新和删除博客文章。

使用 Mongoose 创建数据库模式

在我们开始定义数据库模式之前,我们首先需要设置 Mongoose 本身。Mongoose 是一个库,通过减少与 MongoDB 交互所需的样板代码来简化 MongoDB 对象建模。它还包括常见的业务逻辑,例如自动设置 createdAtupdatedAt 时间戳以及验证和类型转换,以保持数据库状态的一致性。

按照以下步骤设置 mongoose 库:

  1. 首先,安装 mongoose 库:

    $ npm install mongoose@8.0.2
    
  2. src/db/init.js 文件中创建一个新的 src/db/init.js 文件,并在其中导入 mongoose

    import mongoose from 'mongoose'
    
  3. 定义并导出一个初始化数据库连接的函数:

    export function initDatabase() {
    
  4. 首先,我们定义 DATABASE_URL 以指向通过 Docker 运行的本地 MongoDB 实例,并指定 blog 作为数据库名称:

      const DATABASE_URL = 'mongodb://localhost:27017/blog'
    

    连接字符串与我们之前在通过 Node.js 直接访问数据库时使用的类似。

  5. 然后,向 Mongoose 连接的 open 事件添加一个监听器,以便我们在连接到数据库后显示一条日志消息:

      mongoose.connection.on('open', () => {
        console.info('successfully connected to database:', DATABASE_URL)
      })
    
  6. 现在,使用 mongoose.connect() 函数连接到我们的 MongoDB 数据库并返回 connection 对象:

      const connection = mongoose.connect(DATABASE_URL)
      return connection
    }
    
  7. src/example.js 文件中创建一个新的 src/example.js 文件,并在其中导入并运行 initDatabase 函数:

    import { initDatabase } from './db/init.js'
    initDatabase()
    
  8. 使用 Node.js 运行 src/example.js 文件以查看 Mongoose 成功连接到我们的数据库:

    $ node src/example.js
    

    和往常一样,您可以通过在终端中按 Ctrl + C 来停止服务器。

我们可以看到日志消息被打印到终端,因此我们知道 Mongoose 成功连接到了我们的数据库!如果有错误,例如,因为 Docker(或容器)没有运行,它将挂起一段时间,然后抛出一个关于连接被拒绝的错误 (ECONNREFUSED)。在这种情况下,请确保 Docker MongoDB 容器正在正常运行并且可以被连接到。

定义博客文章的模型

在初始化数据库后,我们应该做的第一件事是定义博客文章的数据结构。在我们的系统中,博客文章应该有一个标题、一个作者、内容以及与文章关联的一些标签。按照以下步骤定义博客文章的数据结构:

  1. 创建一个新的 src/db/models/ 文件夹。

  2. 在那个文件夹内,创建一个新的 src/db/models/post.js 文件,导入 mongooseSchema 类:

    import mongoose, { Schema } from 'mongoose'
    
  3. 为文章定义一个新的模式:

    const postSchema = new Schema({
    
  4. 现在指定博客文章的所有属性及其对应类型。我们有一个必需的 title、一个 authorcontents,它们都是字符串:

      title: { type: String, required: true },
      author: String,
      contents: String,
    
  5. 最后,我们有 tags,它是一个字符串数组:

      tags: [String],
    })
    
  6. 现在我们已经定义了模式,我们可以通过使用 mongoose.model() 函数从它创建一个 Mongoose 模型:

    export const Post = mongoose.model('post', postSchema)
    

注意

mongoose.model() 的第一个参数指定了集合的名称。在我们的例子中,集合将被命名为 posts,因为我们指定了 post 作为名称。在 Mongoose 模型中,我们需要指定文档的单数形式名称。

现在我们已经定义了博客文章的数据结构和模型,我们可以开始使用它来创建和查询文章。

使用博客文章模型

创建我们的模型后,让我们尝试使用它!目前,我们只是在 src/example.js 文件中访问它,因为我们还没有定义任何服务函数或路由:

  1. src/example.js 文件中导入 Post 模型:

    import { initDatabase } from './db/init.js'
    import { Post } from './db/models/post.js'
    
  2. 我们之前定义的 initDatabase() 函数是一个 async 函数,所以我们需要 await 它;否则,我们将在连接到数据库之前尝试访问数据库:

    await initDatabase()
    
  3. 通过调用 new Post() 并定义一些示例数据来创建一个新的博客文章:

    const post = new Post({
      title: 'Hello Mongoose!',
      author: 'Daniel Bugl',
      contents: 'This post is stored in a MongoDB database using Mongoose.',
      tags: ['mongoose', 'mongodb'],
    })
    
  4. 在博客文章上调用 .save() 以将其保存到数据库:

    await post.save()
    
  5. 现在,我们可以使用 .find() 函数列出所有文章,并记录结果:

    const posts = await Post.find()
    console.log(posts)
    
  6. 运行示例脚本以查看我们的文章被插入和列出:

    $ node src/example.js
    

    运行前面的脚本后,您将得到以下结果:

图 3.2 – 我们通过 Mongoose 插入的第一个文档!

图 3.2 – 我们通过 Mongoose 插入的第一个文档!

如您所见,使用 Mongoose 与直接使用 MongoDB 非常相似。然而,它为我们提供了模型的一些包装器,以便于使用,使得处理文档更加容易。

在博客文章中定义创建和最后更新日期

您可能已经注意到我们没有为博客文章添加任何日期。因此,我们不知道博客文章是在何时创建的,或者最后一次更新是在何时。Mongoose 使得实现此类功能变得简单,现在让我们试试看:

  1. 编辑 src/db/models/post.js 文件,并将第二个参数添加到 new Schema() 构造函数中。第二个参数指定了模式的选项。在这里,我们设置了 timestamps: true 设置:

    const postSchema = new Schema(
      {
        title: String,
        author: String,
        contents: String,
        tags: [String],
      },
      { timestamps: true },
    )
    
  2. 现在我们需要做的就是通过运行示例脚本创建一个新的博客文章,我们会看到最后插入的文章现在有 createdAtupdatedAt 时间戳:

    $ node src/example.js
    
  3. 为了查看 updatedAt 时间戳是否工作,让我们尝试通过使用 findByIdAndUpdate 方法更新创建的博客文章。将 await post.save() 的结果保存在 createdPost 常量中,然后在 src/example.js 文件的末尾添加以下代码,在 Post.find() 调用之前:

    const createdPost = await post.save()
    await Post.findByIdAndUpdate(createdPost._id, {
      $set: { title: 'Hello again, Mongoose!' },
    })
    
  4. 再次运行服务器以查看博客文章的更新:

    $ node src/example.js
    

    你将得到三篇文章,其中最后一篇现在看起来如下:

图 3.3 – 我们自动更新时间戳的更新文档

图 3.3 – 我们自动更新时间戳的更新文档

如我们所见,使用 Mongoose 使得处理 MongoDB 文档变得更加方便!现在我们已经定义了数据库模型,让我们开始开发(并为它们编写测试)服务函数!

开发和测试服务函数

到目前为止,我们一直通过将代码放入 src/example.js 文件中来测试代码。现在,我们将编写一些服务函数,并通过使用 Jest 学习如何为它们编写实际的测试。

设置测试环境

首先,我们将按照以下步骤设置我们的测试环境:

  1. jestmongodb-memory-server 作为开发依赖项安装:

    $ npm install --save-dev jest@29.7.0 \
    mongodb-memory-server library allows us to spin up a fresh instance of a MongoDB database, storing data only in memory, so that we can run our tests on a fresh database instance.
    
  2. 创建一个 src/test/ 文件夹,用于放置我们的测试设置。

  3. 在此文件夹中,创建一个 src/test/globalSetup.js 文件,其中我们将从之前安装的库中导入 MongoMemoryServer

    import { MongoMemoryServer } from 'mongodb-memory-server'
    
  4. 现在定义一个 globalSetup 函数,为 MongoDB 创建一个内存服务器:

    export default async function globalSetup() {
      const instance = await MongoMemoryServer.create({
    
  5. 在创建 MongoMemoryServer 时,将二进制版本设置为 6.0.4,这是我们为我们的 Docker 容器安装的相同版本:

        binary: {
          version: '6.0.4',
        },
      })
    
  6. 我们将 MongoDB 实例存储为全局变量,以便在之后的 globalTeardown 函数中访问它:

      global.__MONGOINSTANCE = instance
    
  7. 我们还将把连接到我们的测试实例的 URL 存储在 DATABASE_URL 环境变量中:

      process.env.DATABASE_URL = instance.getUri()
    }
    
  8. 编辑 src/db/init.js 并调整 DATABASE_URL 以从环境变量中获取,以便我们的测试将使用正确的数据库:

    export function initDatabase() {
      const DATABASE_URL = process.env.DATABASE_URL
    
  9. 此外,创建一个 src/test/globalTeardown.js 文件,在测试完成后停止 MongoDB 实例,并在其中添加以下代码:

    export default async function globalTeardown() {
      await global.__MONGOINSTANCE.stop()
    }
    
  10. 现在,创建一个 src/test/setupFileAfterEnv.js 文件。在这里,我们将定义一个 beforeAll 函数,在所有测试运行之前初始化我们的数据库连接,以及一个 afterAll 函数,在所有测试运行完成后从数据库断开连接:

    import mongoose from 'mongoose'
    import { beforeAll, afterAll } from '@jest/globals'
    import { initDatabase } from '../db/init.js'
    beforeAll(async () => {
      await initDatabase()
    })
    afterAll(async () => {
      await mongoose.disconnect()
    })
    
  11. 然后,在项目的根目录中创建一个新的 jest.config.json 文件,我们将在这里定义测试的配置。在 jest.config.json 文件中,我们首先将测试环境设置为 node

    {
      "testEnvironment": "node",
    
  12. 接下来,告诉 Jest 使用我们之前创建的 globalSetupglobalTeardownsetupFileAfterEnv 文件:

      "globalSetup": "<rootDir>/src/test/globalSetup.js",
      "globalTeardown": "<rootDir>/src/test/globalTeardown.js",
      "setupFilesAfterEnv": ["<rootDir>/src/test/setupFileAfterEnv.js"]
    }
    

注意

在这种情况下, 是一个特殊的字符串,Jest 会自动将其解析为根目录。你在这里不需要手动填写根目录。

  1. 最后,编辑package.json文件并添加一个test脚本,该脚本将运行 Jest:

      "scripts": {
        "test": "NODE_OPTIONS=--experimental-vm-modules jest",
        "lint": "eslint src",
        "prepare": "husky install"
      },
    

注意

在编写本书时,JavaScript 生态系统仍在向ECMAScript 模块ESM)标准过渡。在这本书中,我们已经使用了这个新标准。然而,Jest 默认不支持它,因此我们需要在运行 Jest 时传递--experimental-vm-modules选项。

  1. 如果我们现在尝试运行此脚本,我们会看到没有找到测试,但我们仍然可以看到 Jest 已设置并正常工作:

    $ npm test
    

图 3.4 – Jest 已成功设置,但我们还没有定义任何测试

图 3.4 – Jest 已成功设置,但我们还没有定义任何测试

现在我们已经设置了测试环境,我们可以开始编写我们的服务函数和单元测试。在编写服务函数后立即编写单元测试总是一个好主意,这意味着我们可以在仍然记得它们预期行为的情况下立即调试它们。

编写我们的第一个服务函数:createPost

对于我们的第一个服务函数,我们将创建一个用于创建新帖子的函数。然后我们可以通过验证创建函数是否创建了一个具有指定属性的新帖子来编写针对它的测试。按照以下步骤进行:

  1. 创建一个新的src/services/posts.js文件。

  2. src/services/posts.js文件中,首先导入Post模型:

    import { Post } from '../db/models/post.js'
    
  3. 定义一个新的createPost函数,它接受一个包含标题作者内容标签的对象作为参数,并创建并返回一个新的帖子:

    export async function createPost({ title, author, contents, tags }) {
      const post = new Post({ title, author, contents, tags })
      return await post.save()
    }
    

    我们在这里特别列出了我们希望用户能够提供的所有属性,而不是简单地传递整个对象给new Post()构造函数。虽然这种方式需要我们编写更多的代码,但它允许我们控制用户应该能够设置哪些属性。例如,如果我们后来向数据库模型添加权限,我们可能会不小心允许用户在这里设置那些权限,如果我们忘记排除那些属性。出于这些安全原因,始终有一个允许属性的列表而不是简单地传递整个对象是一个好的做法。

在编写我们的第一个服务函数之后,让我们继续编写针对它的测试用例。

定义 createPost 服务函数的测试用例

要测试createPost函数是否按预期工作,我们将使用 Jest 定义针对它的单元测试用例:

  1. 创建一个新的src/tests/文件夹,它将包含所有测试定义。

注意

或者,测试文件也可以与它们所测试的相关文件位于同一位置。然而,在这本书中,我们使用tests目录来更容易地区分测试和其他文件。

  1. 为与帖子相关的测试创建一个新的src/tests/posts.test.js文件。在这个文件中,首先从@jest/globals导入mongoosedescribeexpecttest函数:

    import mongoose from 'mongoose'
    import { describe, expect, test } from '@jest/globals'
    
  2. 还从我们的服务中导入createPost函数和Post模型:

    import { createPost } from '../services/posts.js'
    import { Post } from '../db/models/post.js'
    
  3. 然后,使用describe()函数定义一个新的测试。这个函数描述了一组测试。我们可以将我们的组命名为创建帖子

    describe('creating posts', () => {
    
  4. 在这个组内,我们将使用test()函数定义一个测试。我们可以传递一个async函数来使用 async/await 语法。我们称第一个测试为创建包含所有参数的帖子****应成功

      test('with all parameters should succeed', async () => {
    
  5. 在这个测试中,我们将使用createPost函数使用一些参数创建一个新的帖子:

        const post = {
          title: 'Hello Mongoose!',
          author: 'Daniel Bugl',
          contents: 'This post is stored in a MongoDB database using Mongoose.',
          tags: ['mongoose', 'mongodb'],
        }
        const createdPost = await createPost(post)
    
  6. 然后,使用 Jest 的expect()函数和toBeInstanceOf匹配器来验证它返回的是一个ObjectId,以验证它返回了一个帖子:

        expect(createdPost._id).toBeInstanceOf(mongoose.Types.ObjectId)
    
  7. 现在直接使用 Mongoose 来查找具有给定 ID 的帖子:

        const foundPost = await Post.findById(createdPost._id)
    
  8. 我们expect()****foundPost等于一个包含至少我们定义的原帖子对象的属性的对象。此外,我们期望创建的帖子有createdAtupdatedAt时间戳:

        expect(foundPost).toEqual(expect.objectContaining(post))
        expect(foundPost.createdAt).toBeInstanceOf(Date)
        expect(foundPost.updatedAt).toBeInstanceOf(Date)
      })
    
  9. 此外,定义第二个测试,称为创建没有标题的帖子应失败。因为我们定义了标题为必需的,所以不可能创建没有标题的帖子:

      test('without title should fail', async () => {
        const post = {
          author: 'Daniel Bugl',
          contents: 'Post with no title',
          tags: ['empty'],
        }
    
  10. 使用try/catch结构来捕获错误,并expect()错误是一个 Mongoose ValidationError,这告诉我们标题是必需的:

        try {
          await createPost(post)
         } catch (err) {
          expect(err).toBeInstanceOf(mongoose.Error.ValidationError)
          expect(err.message).toContain('`title` is required')
        }
      })
    
  11. 最后,创建一个名为创建具有最小参数的帖子应成功的测试,并且只输入标题

      test('with minimal parameters should succeed', async () => {
        const post = {
          title: 'Only a title',
        }
        const createdPost = await createPost(post)
        expect(createdPost._id).toBeInstanceOf(mongoose.Types.ObjectId)
      })
    })
    
  12. 现在我们已经定义了我们的测试,运行我们之前定义的脚本:

    $ npm test
    

如我们所见,使用单元测试,我们可以在不定义和手动访问路由或编写一些手动测试设置的情况下对服务函数进行隔离测试。这些测试还有额外的优势,即当我们稍后更改代码时,我们可以通过重新运行测试来确保之前定义的行为没有改变。

定义一个函数来列出帖子

在定义了创建帖子的函数之后,我们现在将定义一个内部的listPosts函数,它允许我们查询帖子并定义排序顺序。然后,我们将使用这个函数来定义listAllPostslistPostsByAuthorlistPostsByTag函数:

  1. 编辑queryoptions参数(包含sortBysortOrder属性)。通过sortBy,我们可以定义我们想要排序的字段,而sortOrder参数允许我们指定帖子应该按升序还是降序排序。默认情况下,我们列出所有帖子(空对象作为查询),并首先显示最新的帖子(按createdAt降序排序):

    async function listPosts(
      query = {},
      { sortBy = 'createdAt', sortOrder = 'descending' } = {},
    ) {
    
  2. 我们可以使用 Mongoose 模型的.find()方法来列出所有帖子,并通过传递参数来排序:

      return await Post.find(query).sort({ [sortBy]: sortOrder })
    }
    
  3. 现在我们可以定义一个函数来列出所有帖子,它只需简单地将一个空对象作为查询传递:

    export async function listAllPosts(options) {
      return await listPosts({}, options)
    }
    
  4. 类似地,我们可以创建一个函数来通过将author传递到查询对象中来列出特定作者的帖子:

    export async function listPostsByAuthor(author, options) {
      return await listPosts({ author }, options)
    }
    
  5. 最后,定义一个函数来按标签列出帖子:

    export async function listPostsByTag(tags, options) {
      return await listPosts({ tags }, options)
    }
    

    在 MongoDB 中,我们可以简单地通过将字符串作为单个值匹配来匹配数组中的字符串,所以我们只需要添加一个 tags: 'nodejs' 的查询。MongoDB 将返回所有在 tags 数组中包含 'nodejs' 字符串的文档。

注意

{ [变量]: … } 操作符将存储在 变量 中的字符串解析为创建的对象的关键名称。因此,如果我们的变量包含 'createdAt',则生成的对象将是 { createdAt: … }

在定义列表帖子函数之后,我们也为它编写测试用例。

定义列表帖子的测试用例

定义列表帖子的测试用例与创建帖子类似。然而,我们现在需要创建一个初始状态,其中数据库中已经有一些帖子,以便能够测试列表函数。我们可以通过使用 beforeEach() 函数来实现,该函数在每个测试用例执行之前执行一些代码。我们可以为整个测试文件使用 beforeEach() 函数,或者只为 describe() 组内的每个测试运行它。在我们的情况下,我们将为整个文件定义它,因为样本帖子在稍后测试删除帖子函数时将很有用:

  1. 编辑 src/tests/posts.js 文件,调整 import 语句以从 @jest/globals 导入 beforeEach 函数,并从我们的服务中导入列出帖子的各种函数:

    import { describe, expect, test, beforeEach } from '@jest/globals'
    import { createPost,
             listAllPosts,
             listPostsByAuthor,
             listPostsByTag,
    } from '../services/posts.js'
    
  2. 在文件末尾,定义一个样本帖子数组:

    const samplePosts = [
      { title: 'Learning Redux', author: 'Daniel Bugl', tags: ['redux'] },
      { title: 'Learn React Hooks', author: 'Daniel Bugl', tags: ['react'] },
      {
        title: 'Full-Stack React Projects',
        author: 'Daniel Bugl',
        tags: ['react', 'nodejs'],
      },
      { title: 'Guide to TypeScript' },
    ]
    
  3. 现在,定义一个空数组,它将被创建的帖子填充。然后,定义一个 beforeEach 函数,它首先从数据库中清除所有帖子,并清除创建的样本帖子数组,然后再次在数据库中创建之前定义在数组中的每个帖子。这确保了在每次测试用例运行之前数据库处于一致状态,并且在测试列表帖子函数时有一个数组进行比较:

    let createdSamplePosts = []
    beforeEach(async () => {
      await Post.deleteMany({})
      createdSamplePosts = []
      for (const post of samplePosts) {
        const createdPost = new Post(post)
        createdSamplePosts.push(await createdPost.save())
      }
    })
    

    为了确保我们的单元测试是模块化和相互独立的,我们直接使用 Mongoose 函数将帖子插入数据库(而不是使用 createPost 函数)。

  4. 现在我们有一些样本帖子准备好了,让我们编写我们的第一个测试用例,它应该简单地列出所有帖子。我们将定义一个新的测试组用于 列出帖子,并定义一个测试用例来验证所有样本帖子是否通过 listAllPosts() 函数列出:

    describe('listing posts', () => {
      test('should return all posts', async () => {
        const posts = await listAllPosts()
        expect(posts.length).toEqual(createdSamplePosts.length)
      })
    
  5. 接下来,创建一个测试用例来验证默认排序顺序是否显示最新的帖子。我们手动按 createdAt(降序)对 createdSamplePosts 数组进行排序,然后比较排序后的日期与 listAllPosts() 函数返回的日期:

      test('should return posts sorted by creation date descending by default', async () => {
        const posts = await listAllPosts()
        const sortedSamplePosts = createdSamplePosts.sort(
          (a, b) => b.createdAt - a.createdAt,
        )
        expect(posts.map((post) => post.createdAt)).toEqual(
          sortedSamplePosts.map((post) => post.createdAt),
        )
      })
    

注意

.map() 函数将一个函数应用于数组的每个元素,并返回结果。在我们的情况下,我们从数组的所有元素中选择 createdAt 属性。我们不能直接比较数组,因为 Mongoose 返回的文档包含大量隐藏的元数据,Jest 将尝试比较这些元数据。

  1. 此外,定义一个测试用例,其中将 sortBy 值更改为 updatedAt,并将 sortOrder 值更改为 ascending(首先显示最早更新的帖子):

      test('should take into account provided sorting options', async () => {
        const posts = await listAllPosts({
          sortBy: 'updatedAt',
          sortOrder: 'ascending',
        })
        const sortedSamplePosts = createdSamplePosts.sort(
          (a, b) => a.updatedAt - b.updatedAt,
        )
        expect(posts.map((post) => post.updatedAt)).toEqual(
          sortedSamplePosts.map((post) => post.updatedAt),
        )
      })
    
  2. 然后,添加一个测试用例来确保按作者列出帖子是否正常工作:

      test('should be able to filter posts by author', async () => {
        const posts = await listPostsByAuthor('Daniel Bugl')
        expect(posts.length).toBe(3)
      })
    

注意

我们通过在每个测试用例运行之前创建一组特定的样本帖子来控制测试环境。我们可以利用这个受控环境来简化我们的测试。因为我们已经知道只有三位作者的帖子,所以我们可以简单地检查函数是否返回了恰好三个帖子。这样做使我们的测试保持简单,并且由于我们完全控制了环境,它们仍然是安全的。

  1. 最后,添加一个测试用例来验证按标签列出帖子是否正常工作:

      test('should be able to filter posts by tag', async () => {
        const posts = await listPostsByTag('nodejs')
        expect(posts.length).toBe(1)
      })
    })
    
  2. 再次运行测试,并观察它们全部通过:

    $ npm test
    

图 3.5 – 所有测试都成功通过!

图 3.5 – 所有测试都成功通过!

如我们所见,对于某些测试,我们需要准备一个初始状态。在我们的例子中,我们只需创建一些帖子,但这个初始状态可能变得更加复杂。例如,在一个更高级的博客平台上,可能首先需要创建一个用户账户,然后在平台上创建一个博客,然后为该博客创建博客帖子。在这种情况下,我们可以创建测试实用函数,如 createTestUsercreateTestBlogcreateTestPost 并在测试中导入它们。然后,我们可以在多个测试文件中的 beforeEach() 中使用这些函数,而不是每次都手动执行。根据你的应用程序结构,可能需要不同的测试实用函数,所以请随意定义它们,以适合你的需求。

在定义了列表帖子功能的测试用例之后,让我们继续定义获取单个帖子、更新帖子以及删除帖子功能。

定义获取单个帖子、更新和删除帖子功能

获取单个帖子、更新和删除帖子功能可以非常类似于列表帖子功能来定义。让我们现在快速完成它:

  1. 编辑 src/services/posts.js 文件并定义一个 getPostById 函数,如下所示:

    export async function getPostById(postId) {
      return await Post.findById(postId)
    }
    

    定义一个仅调用 Post.findById 的服务函数可能看起来有点微不足道,但无论如何定义它都是一种良好的实践。稍后,我们可能想要添加一些额外的限制,例如访问控制。拥有服务函数允许我们只在一个地方更改它,我们不必担心忘记在某处添加它。另一个好处是,如果我们,例如,稍后想要更改数据库提供者,开发者只需担心让服务函数再次工作,并且可以使用测试用例进行验证。

  2. 在同一文件中,定义updatePost函数。它将接受现有帖子的 ID 以及要更新的参数对象。我们将使用 Mongoose 的findOneAndUpdate函数以及$set运算符来更改指定的参数。作为第三个参数,我们提供一个包含new: true的选项对象,以便函数返回修改后的对象而不是原始对象:

    export async function updatePost(postId, { title, author, contents, tags }) {
      return await Post.findOneAndUpdate(
        { _id: postId },
        { $set: { title, author, contents, tags } },
        { new: true },
      )
    }
    
  3. 在同一文件中,还定义一个deletePost函数,该函数简单地接受现有帖子的 ID 并将其从数据库中删除:

    export async function deletePost(postId) {
      return await Post.deleteOne({ _id: postId })
    }
    

小贴士

根据您的应用程序,您可能希望设置一个deletedOn时间戳而不是立即删除。然后,设置一个函数来获取所有已删除超过 30 天的文档并将它们删除。当然,这意味着我们需要在listPosts函数中始终过滤掉已删除的帖子,并且我们需要为此行为编写测试用例!

  1. 编辑src/tests/posts.js文件并导入getPostById函数:

      getPostById,
    } from '../services/posts.js'
    
  2. 添加通过 ID 获取帖子以及因为数据库中不存在该 ID 而无法获取帖子的测试:

    describe('getting a post', () => {
      test('should return the full post', async () => {
        const post = await getPostById(createdSamplePosts[0]._id)
        expect(post.toObject()).toEqual(createdSamplePosts[0].toObject())
      })
      test('should fail if the id does not exist', async () => {
        const post = await getPostById('000000000000000000000000')
        expect(post).toEqual(null)
      })
    })
    

    在第一个测试中,我们使用.toObject()将具有所有内部属性和元数据的 Mongoose 对象转换为纯旧 JavaScript 对象POJO),以便我们可以通过比较所有属性来将其与样本帖子对象进行比较。

  3. 接下来,导入updatePost函数:

      updatePost,
    } from '../services/posts.js'
    
  4. 然后,添加测试以成功更新帖子。我们添加一个测试来验证指定的属性是否已更改,并添加另一个测试来验证它不会干扰其他属性:

    describe('updating posts', () => {
      test('should update the specified property', async () => {
        await updatePost(createdSamplePosts[0]._id, {
          author: 'Test Author',
        })
        const updatedPost = await Post.findById(createdSamplePosts[0]._id)
        expect(updatedPost.author).toEqual('Test Author')
      })
      test('should not update other properties', async () => {
        await updatePost(createdSamplePosts[0]._id, {
          author: 'Test Author',
        })
        const updatedPost = await Post.findById(createdSamplePosts[0]._id)
        expect(updatedPost.title).toEqual('Learning Redux')
      })
    
  5. 此外,添加一个测试以确保updatedAt时间戳已更新。为此,首先使用.getTime()Date对象转换为数字,然后我们可以使用expect(…).toBeGreaterThan(…)匹配器进行比较:

      test('should update the updatedAt timestamp', async () => {
        await updatePost(createdSamplePosts[0]._id, {
          author: 'Test Author',
        })
        const updatedPost = await Post.findById(createdSamplePosts[0]._id)
        expect(updatedPost.updatedAt.getTime()).toBeGreaterThan(
            createdSamplePosts[0].updatedAt.getTime(),
          )
      })
    
  6. 还添加一个失败的测试来查看当找不到匹配 ID 的帖子时,updatePost函数是否返回null

      test('should fail if the id does not exist', async () => {
        const post = await updatePost('000000000000000000000000', {
          author: 'Test Author',
        })
        expect(post).toEqual(null)
      })
    })
    
  7. 最后,导入deletePost函数:

      deletePost,
    } from '../services/posts.js'
    
  8. 然后,通过检查帖子是否被删除并验证返回的deletedCount来添加成功和失败的删除测试:

    describe('deleting posts', () => {
      test('should remove the post from the database', async () => {
        const result = await deletePost(createdSamplePosts[0]._id)
        expect(result.deletedCount).toEqual(1)
        const deletedPost = await Post.findById(createdSamplePosts[0]._id)
        expect(deletedPost).toEqual(null)
      })
      test('should fail if the id does not exist', async () => {
        const result = await deletePost('000000000000000000000000')
        expect(result.deletedCount).toEqual(0)
      })
    })
    
  9. 最后,再次运行所有测试;它们都应该通过:

    $ npm test
    

为服务函数编写测试可能很繁琐,但长远来看可以节省我们大量时间。添加额外的功能,如访问控制,可能会改变服务函数的基本行为。通过单元测试,我们可以确保在添加新功能时不会破坏现有行为。

使用 Jest VS Code 扩展

到目前为止,我们通过在终端中使用 Jest 来运行我们的测试。还有一个 VS Code 的 Jest 扩展,我们可以使用它来使运行测试更加直观。该扩展对于具有多个文件中许多测试的大型项目尤其有帮助。此外,扩展可以自动监视并重新运行测试,如果我们更改定义。我们可以按照以下方式安装扩展:

  1. 在 VS Code 的侧边栏中转到 扩展 选项卡。

  2. 在搜索框中输入 Orta.vscode-jest 来查找 Jest 扩展。

  3. 通过点击 安装 按钮来安装扩展。

  4. 现在转到侧边栏上新添加的测试图标(它应该是一个化学试剂瓶图标):

图 3.6 – 由 Jest 扩展提供的 VS Code 中的测试选项卡

图 3.6 – 由 Jest 扩展提供的 VS Code 中的测试选项卡

Jest 扩展为我们提供了所有已定义测试的概览。我们可以悬停在它们上方并点击 播放 图标来重新运行特定的测试。默认情况下,Jest 扩展启用了 auto-run-watch(如图 3.6 所示)。如果 auto-run-watch 被启用,当测试定义文件被保存时,扩展会自动重新运行测试。这非常方便!

现在我们已经定义并测试了我们的服务函数,我们可以在定义路由时开始使用它们,这是我们接下来将要做的!

使用 Express 提供 REST API

在设置好我们的数据和服务层之后,我们就有了一个良好的框架来编写后端代码。然而,我们仍然需要一个让用户访问我们后端的接口。这个接口将是一个 表征状态转移REST) API。REST API 提供了一种通过 HTTP 请求访问我们服务器的方式,这在我们开发前端时可以加以利用。

图 3.7 – 使用 HTTP 请求的客户端和服务器之间的交互

图 3.7 – 使用 HTTP 请求的客户端和服务器之间的交互

如我们所见,客户端可以向我们的后端服务器发送请求,服务器将对此做出响应。在基于 REST 的架构中,有五种常用的方法:

  • GET:这个方法用于读取资源。通常,它不应该影响数据库状态,并且给定相同的输入,它应该返回相同的输出(除非数据库状态通过其他请求被更改)。这种行为被称为 幂等性。对于成功的 GET 请求,服务器通常会返回资源(资源)并带有 200 OK 状态码。

  • POST:这个方法用于根据请求体中提供的信息创建新的资源。对于成功的 POST 请求,服务器通常会返回一个包含 201 Created 状态码的新创建的对象,或者返回一个空的响应(带有 201 Created 状态码),并在 Location 头部包含一个指向新创建资源的 URL。

  • PUT:这个方法用于使用请求体中提供的新数据完全替换具有给定 ID 的现有资源。在某些情况下,它也可以用于使用客户端指定的 ID 创建新的资源。对于成功的 PUT 请求,服务器要么返回带有 200 OK 状态码的更新资源,要么返回 204 No Content(如果没有返回更新资源),或者返回 201 Created(如果创建了新的资源)。

  • PATCH: 这用于修改具有给定 ID 的现有资源,仅更新请求体中指定的字段,而不是替换整个资源。对于成功的 PATCH 请求,服务器要么返回更新后的资源并返回 200 OK,要么如果未返回更新后的资源,则返回 204 No Content。

  • DELETE: 这用于删除具有给定 ID 的资源。对于成功的 DELETE 请求,服务器要么返回已删除的资源并返回 200 OK,要么如果未返回已删除的资源,则返回 204 No Content。

HTTP REST API 路由通常在类似文件夹的结构中定义。始终以/api/v1/v1是 API 定义的版本,从1开始)作为所有路由的前缀是一个好主意。如果我们想稍后更改 API 定义,我们就可以轻松地同时运行/api/v1//api/v2/一段时间,直到一切迁移完成。

定义我们的 API 路由

现在我们已经了解了 HTTP REST API 的工作原理,让我们首先定义后端的路由,涵盖我们在服务函数中已经实现的所有功能:

  • GET /api/v1/posts: 获取所有帖子的列表

  • GET /api/v1/posts?sortBy=updatedAt&sortOrder=ascending: 获取按updatedAt(升序)排序的所有帖子列表

注意

?符号之后的内容称为查询字符串,其格式为key1=value1&key2=value2&…。查询字符串可以用于向路由提供额外的可选参数。

  • GET /api/v1/posts?author=daniel: 获取作者为“daniel”的帖子列表

  • GET /api/v1/posts?tag=react: 获取带有标签react的帖子列表

  • GET /api/v1/posts/:id: 通过 ID 获取单个帖子

  • POST /api/v1/posts: 创建新的帖子

  • PATCH /api/v1/posts/:id: 通过 ID 更新现有帖子

  • DELETE /api/v1/posts/:id: 通过 ID 删除现有帖子

如我们所见,通过将我们已开发的服务函数和我们关于 REST API 的知识结合起来,我们可以轻松地定义后端的路由。现在我们已经定义了路由,让我们设置 Express 和我们的后端服务器,以便能够公开这些路由。

注意

这只是 REST API 设计的一个示例。它旨在作为一个示例,帮助你开始全栈开发。稍后,在您自己的时间里,您可以自由地查看其他资源,例如standards.rest,以加深您对 REST API 设计的了解。

设置 Express

Express 是 Node.js 的 Web 应用程序框架。它提供了实用函数,可以轻松定义 REST API 的路由并服务 HTTP 服务器。Express 也非常可扩展,在 JavaScript 生态系统中有许多针对它的插件。

注意

虽然 Express 在写作时是最知名的框架,但也有更新的框架,例如 Koa (koajs.com) 或 Fastify (fastify.dev)。Koa 是由 Express 背后的团队设计的,但旨在更小、更易于表达和更健壮。Fastify 专注于效率和低开销。请自行检查这些,看看它们是否更适合您的需求。

在我们可以设置路由之前,让我们花些时间按照以下步骤设置我们的 Express 应用程序和后端服务器:

  1. 首先,安装 express 依赖项:

    $ npm install express@4.18.2
    
  2. 创建一个新的 src/app.js 文件。此文件将包含设置我们的 Express 应用程序所需的所有内容。在此文件中,首先导入 express

    import express from 'express'
    
  3. 然后创建一个新的 Express 应用程序,如下所示:

    const app = express()
    
  4. 现在,我们可以在 Express 应用程序上定义路由。例如,要定义一个 GET 路由,我们可以编写以下代码:

    app.get('/', (req, res) => {
      res.send('Hello from Express!')
    })
    
  5. 我们导出应用以便在其他文件中使用它:

    export { app }
    
  6. 接下来,我们需要创建一个服务器并指定一个端口,这与我们之前创建 HTTP 服务器时所做的类似。为此,我们创建一个新的 src/index.js 文件。在此文件中,我们导入 Express 应用程序:

    import { app } from './app.js'
    
  7. 然后,我们定义一个端口,让 Express 应用程序监听它,并记录一条消息告诉我们服务器正在哪里运行:

    const PORT = 3000
    app.listen(PORT)
    console.info(`express server running on http://localhost:${PORT}`)
    
  8. 编辑 package.json 并添加一个 start 脚本来运行我们的服务器:

      "scripts": {
        "start": "node src/index.js",
    
  9. 通过执行以下命令运行后端服务器:

    $ npm start
    
  10. 现在,在您的浏览器中导航到 http://localhost:3000/,您将看到 Hello from Express! 正在被打印出来,就像之前使用普通的 http 服务器一样:

图 3.8 – 从浏览器访问我们的第一个 Express 应用程序!

图 3.8 – 从浏览器访问我们的第一个 Express 应用程序!

那就是设置简单 Express 应用程序的全部内容!我们现在可以通过使用 app.get() 为 GET 路由,app.post() 为 POST 路由等来继续定义路由。然而,在我们开始开发路由之前,让我们花些时间来改善我们的开发环境。首先,我们应该使 PORTDATABASE_URL 可配置,这样我们就可以在不更改代码的情况下更改它们。为此,我们将使用环境变量。

使用 dotenv 设置环境变量

使用 dotenv 是加载环境变量的好方法,它将环境变量从 .env 文件加载到我们的 process.env 中。这使得定义用于本地开发的环境变量变得容易,同时仍然可以在测试环境等地方设置不同的值。按照以下步骤设置 dotenv

  1. 安装 dotenv 依赖项:

    $ npm install dotenv@16.3.1
    
  2. 编辑 src/index.js,在那里导入 dotenv 并调用 dotenv.config() 来初始化环境变量。我们应该在我们应用程序中的任何其他代码之前这样做:

    import dotenv from 'dotenv'
    dotenv.config()
    
  3. 现在,我们可以开始用环境变量替换我们的静态变量。编辑 src/index.js 并将静态端口 3000 替换为 process.env.PORT

    const PORT = process.env.PORT
    
  4. 我们已经在设置 Jest 时将initDatabase函数迁移到使用process.env.DATABASE_URL。现在,我们可以编辑src/index.js并从中导入initDatabase

    import { initDatabase } from './db/init.js'
    
  5. 调整现有代码,首先调用initDatabase,只有当数据库初始化后,才开始 Express 应用。现在我们还可以通过添加 try/catch 块来处理连接数据库时的错误:

    try {
    await initDatabase()
    const PORT = process.env.PORT
      app.listen(PORT)
      console.info(`express server running on http://localhost:${PORT}`)
    } catch (err) {
      console.error('error connecting to database:', err)
    }
    
  6. 最后,在项目的根目录下创建一个.env文件,并在其中定义两个环境变量:

    PORT=3000
    DATABASE_URL=mongodb://localhost:27017/blog
    
  7. 我们应该将.env文件排除在 Git 仓库之外,因为它仅用于本地开发。编辑.gitignore并在新行中添加.env

    .env
    

    目前,我们的环境变量中没有合理的信息,但这样做仍然是一个好习惯。稍后,我们可能有一些凭证存储在环境变量中,我们不希望不小心将其推送到 Git 仓库。

  8. 为了使某人更容易开始我们的项目,我们可以创建.env文件的副本,并将其复制到.env.template,确保它不包含任何敏感凭证!敏感凭证可以存储在,例如,一个共享密码管理器中。

  9. 如果它之前仍在运行,请停止服务器(在终端中按Ctrl + C),然后按照以下步骤重新启动:

    $ npm start
    

你将得到以下结果:

图 3.9 – 使用环境变量初始化数据库连接和 Express 服务器

图 3.9 – 使用环境变量初始化数据库连接和 Express 服务器

如我们所见,dotenv使得在开发过程中维护环境变量变得容易,同时仍然允许我们在持续集成、测试或生产环境中更改它们。

你可能已经注意到,在做出一些更改后,我们需要手动重启服务器。这与我们从 Vite 中获得的即时热重载形成了鲜明对比,在那里我们做出的任何更改都会立即应用于浏览器中的前端。现在,让我们花些时间通过使服务器在更改时自动重启来改善开发体验。

使用 nodemon 简化开发

要使我们的服务器在更改时自动重启,我们可以使用nodemon工具。nodemon工具允许我们运行服务器,类似于 node CLI 命令。然而,它提供了在源文件更改时自动重启服务器的可能性。

  1. nodemon工具安装为开发依赖项:

    $ npm install –save-dev nodemon@3.0.2
    
  2. 在项目的根目录下创建一个新的nodemon.json文件,并将以下内容添加到其中:

    {
      "watch": ["./src", ".env", "package-lock.json"]
    }
    

    这确保了src/文件夹中的所有代码都会被监视以检测更改,如果其中任何文件被更改,它将刷新。此外,我们还指定了.env文件,以防环境变量被更改,以及package-lock.json文件,以防添加或升级了包。

  3. 现在编辑package.json,并定义一个新的"dev"脚本来运行nodemon

      "scripts": {
        "dev": "nodemon src/index.js",
    
  4. 停止服务器(如果它目前正在运行),然后通过运行以下命令重新启动它:

    $ npm run dev
    
  5. 如我们所见,我们的服务器现在正在通过nodemon运行!我们可以通过更改.env文件中的端口号来尝试它:

    PORT=3001
    DATABASE_URL=mongodb://localhost:27017/blog
    
  6. 也要编辑.env.template,将端口号更改为3001

    PORT=3001
    
  7. 保持服务器运行。

图 3.10 – 在我们更改端口号后,Nodemon 自动重新启动服务器

图 3.10 – 在我们更改端口号后,Nodemon 自动重新启动服务器

在做出更改后,nodemon会自动为我们使用新端口号重新启动服务器。现在我们有了类似热重载的功能,但适用于后端开发——太棒了!现在我们已经改善了后端开发者的体验,让我们开始使用 Express 编写我们的 API 路由。保持服务器运行(通过nodemon),以便在编码时看到它重新启动和更新:

使用 Express 创建我们的 API 路由

我们现在可以开始使用 Express 创建之前定义的 API 路由。我们首先定义 GET 路由:

  1. 创建一个新的src/routes/posts.js文件,并在其中导入服务函数:

    import {
      listAllPosts,
      listPostsByAuthor,
      listPostsByTag,
      getPostById,
    } from '../services/posts.js'
    
  2. 现在创建并导出一个名为postsRoutes的新函数,该函数接受 Express 应用作为参数:

    export function postsRoutes(app) {
    
  3. 在这个函数中,定义路由。从GET /****api/v1/posts路由开始:

      app.get('/api/v1/posts', async (req, res) => {
    
  4. 在此路由中,我们需要利用查询参数(Express 中的req.query)将它们映射到我们函数的参数上。我们希望能够为sortBysortOrderauthortag添加查询参数:

        const { sortBy, sortOrder, author, tag } = req.query
        const options = { sortBy, sortOrder }
    
  5. 在我们调用服务函数之前,这些函数可能会在将无效数据传递给数据库函数时抛出错误,我们应该添加一个 try-catch 块来正确处理潜在的错误:

        try {
    
  6. 我们现在需要检查是否提供了authortag。如果两者都提供了,我们通过调用res.json()返回一个400 Bad Request状态码和包含错误信息的 JSON 对象:

          if (author && tag) {
            return res
              .status(400)
              .json({ error: 'query by either author or tag, not both' })
    
  7. 否则,我们调用相应的服务函数,并通过调用res.json()在 Express 中返回 JSON 响应。如果发生错误,我们捕获它,记录它,并返回一个 500 状态码:

          } else if (author) {
            return res.json(await listPostsByAuthor(author, options))
          } else if (tag) {
            return res.json(await listPostsByTag(tag, options))
          } else {
            return res.json(await listAllPosts(options))
          }
        } catch (err) {
          console.error('error listing posts', err)
          return res.status(500).end()
        }
      })
    
  8. 接下来,我们定义一个 API 路由来获取单个帖子。我们使用:id参数占位符来能够在函数中将其作为动态参数访问:

      app.get('/api/v1/posts/:id', async (req, res) => {
    
  9. 现在,我们可以通过访问req.params.id来获取路由中的:id部分,并将其传递给我们的服务函数:

        const { id } = req.params
        try {
          const post = await getPostById(id)
    
  10. 如果函数的结果是null,我们返回一个 404 响应,因为帖子未找到。否则,我们返回作为 JSON 响应的帖子:

          if (post === null) return res.status(404).end()
          return res.json(post)
        } catch (err) {
          console.error('error getting post', err)
          return res.status(500).end()
        }
      })
    }
    

    默认情况下,Express 将返回带有状态 200 OK 的 JSON 响应。

  11. 在定义我们的 GET 路由之后,我们仍然需要在我们的应用中挂载它们。编辑src/app.js,并在其中导入postsRoutes函数:

    import { postsRoutes } from './routes/posts.js'
    
  12. 然后,在初始化我们的 Express 应用后,调用postsRoutes(app)函数:

    const app = express()
    postsRoutes(app)
    
  13. 前往 http://localhost:3001/api/v1/posts 查看该路由的实际效果!

图 3.11 – 我们第一个真正的 API 路由正在运行!

图 3.11 – 我们第一个真正的 API 路由正在运行!

小贴士

您可以在浏览器中安装一个 JSON 格式化器 扩展来格式化 JSON 响应,就像在 图 3.11 中那样。

在定义了 GET 路由之后,我们需要定义 POST 路由。然而,这些路由接受一个体,其格式为 JSON 对象。因此,我们需要一种方法在 Express 中解析这个 JSON 体。

定义带有 JSON 请求体的路由

在 Express 中,要定义带有 JSON 请求体的路由,我们需要使用 body-parser 模块。此模块会检测客户端是否发送了 JSON 请求(通过查看 Content-Type 头部),然后自动为我们解析它,以便我们可以访问 req.body 中的对象。

  1. 安装 body-parser 依赖项:

    $ npm install body-parser@1.20.2
    
  2. 编辑 src/app.js 并在其中的 body-parser 模块中导入:

    import bodyParser from 'body-parser'
    
  3. 现在,在我们的应用初始化后添加以下代码,将 body-parser 插件作为中间件加载到我们的 Express 应用中:

    const app = express()
    app.use(bodyParser.json())
    

注意

Express 的中间件允许我们在每个请求之前和之后执行某些操作。在这种情况下,body-parser 为我们读取 JSON 体,将其解析为 JSON,并给我们一个 JavaScript 对象,我们可以轻松地从我们的路由定义中访问它。需要注意的是,只有定义在中间件之后的路由才能访问它,因此定义中间件和路由的顺序很重要!

  1. 在加载 body-parser 之后,我们编辑 src/routes/posts.js 并导入制作其余路由所需的服务函数:

      createPost,
      updatePost,
      deletePost,
    } from '../services/posts.js'
    
  2. 现在,我们通过使用 app.postreq.bodypostsRoutes 函数内部定义 POST /api/v1/posts 路由:

      app.post('/api/v1/posts', async (req, res) => {
        try {
          const post = await createPost(req.body)
          return res.json(post)
        } catch (err) {
          console.error('error creating post', err)
          return res.status(500).end()
        }
      })
    
  3. 同样,我们可以定义更新路由,其中我们需要使用 id 参数和请求体:

      app.patch('/api/v1/posts/:id', async (req, res) => {
        try {
          const post = await updatePost(req.params.id, req.body)
          return res.json(post)
        } catch (err) {
          console.error('error updating post', err)
          return res.status(500).end()
        }
      })
    
  4. 最后,我们定义一个删除路由,它不需要 body-parser;我们只需要在这里获取 id 参数。如果帖子未找到,则返回 404,如果帖子成功删除,则返回 204 No Content:

      app.delete('/api/v1/posts/:id', async (req, res) => {
        try {
          const { deletedCount } = await deletePost(req.params.id)
          if (deletedCount === 0) return res.sendStatus(404)
          return res.status(204).end()
        } catch (err) {
          console.error('error deleting post', err)
          return res.status(500).end()
        }
      })
    

如我们所见,Express 使定义和处理路由、请求和响应变得容易得多。它已经为我们检测并设置了头部,因此可以正确地读取和发送 JSON 响应。它还允许我们轻松地更改 HTTP 状态码。

现在我们已经完成了带有 JSON 请求体的路由定义,让我们允许从其他 URL 访问我们的路由,使用 跨源资源共享CORS)。

使用 CORS 允许从其他 URL 访问

浏览器有一个安全特性,只允许我们访问与我们当前页面相同的 URL 的 API。为了允许从除后端 URL 本身之外的其他 URL(例如,当我们将在下一章中在另一个端口上运行前端时)访问我们的后端,我们需要允许 CORS 请求。现在让我们通过使用 Express 的 cors 库来设置它:

  1. 安装 cors 依赖项:

    $ npm install cors@2.8.5
    
  2. 编辑 src/app.js 并在其中导入 cors

    import cors from 'cors'
    
  3. 现在我们需要在应用初始化后添加以下代码,将cors插件作为中间件加载到我们的 Express 应用中:

    const app = express()
    app.use(cors())
    app.use(bodyParser.json())
    

现在 CORS 请求被允许后,我们可以在浏览器中开始尝试这些路由!

尝试路由

在定义我们的路由后,我们可以通过在浏览器中使用fetch()函数来尝试它们:

  1. 在你的浏览器中,访问http://localhost:3001/,通过右键点击页面并点击Inspect来打开控制台,然后转到Console标签页。

  2. 在控制台中输入以下代码来发送一个 GET 请求以获取所有帖子:

    fetch('http://localhost:3001/api/v1/posts')
      .then(res => res.json())
      .then(console.log)
    
  3. 现在我们可以修改这段代码,通过指定Content-Type头来告诉服务器我们将发送 JSON,然后使用JSON.stringify发送一个体(因为体必须是一个字符串)来发送一个 POST 请求:

    fetch('http://localhost:3001/api/v1/posts', {
        headers: { 'Content-Type': 'application/json' },
        method: 'POST',
        body: JSON.stringify({ title: 'Test Post' })
    })
      .then(res => res.json())
      .then(console.log)
    
  4. 同样,我们也可以发送一个PATCH请求,如下所示:

    fetch('http://localhost:3001/api/v1/posts/642a8b15950196ee8b3437b2', {
        headers: { 'Content-Type': 'application/json' },
        method: 'PATCH',
        body: JSON.stringify({ title: 'Test Post Changed' })
    })
      .then(res => res.json())
      .then(console.log)
    

    确保将 URL 中的 MongoDB IDs 替换为之前通过POST请求返回的一个!

  5. 最后,我们可以发送一个DELETE请求:

    fetch('http://localhost:3001/api/v1/posts/642a8b15950196ee8b3437b2', {
        method: 'DELETE',
    })
      .then(res => res.status)
      .then(console.log)
    
  6. 当进行 GET 请求时,我们可以看到我们的帖子现在已经被再次删除:

    fetch('http://localhost:3001/api/v1/posts/642a8b15950196ee8b3437b2')
      .then(res => res.status)
      .then(console.log)
    

    这个请求现在应该返回一个404

小贴士

除了浏览器控制台,您还可以使用curl或 Postman 等应用程序来发送请求。如果您已经熟悉这些工具,请随意使用不同的工具来尝试请求。

我们现在已经成功定义了处理简单博客文章 API 所需的所有路由!

摘要

我们的后端服务的第一版现在已完成,允许我们通过 REST API(使用 Express)创建、读取、更新和删除博客文章,这些文章随后被存储在 MongoDB 数据库中(使用 Mongoose)。此外,我们还创建了带有单元测试的服务函数,这些测试是用 Jest 测试套件定义的。总的来说,我们在本章中为我们的后端建立了一个坚实的基础。

在下一章,第四章使用 React 和 TanStack Query 集成前端,我们将使用 TanStack Query 库(一个处理异步状态和从我们的服务器获取的数据的库)将我们的后端集成到 React 前端中。这意味着在下一章之后,我们将开发出我们的第一个全栈应用程序!

第四章:使用 React 和 TanStack Query 集成前端

在设计、实现和测试我们的后端服务之后,现在是时候创建一个前端来与后端接口了。首先,我们将基于 Vite 模板和前几章创建的后端服务设置一个全栈 React 项目。然后,我们将为我们的博客应用程序创建一个基本用户界面。最后,我们将使用 TanStack Query,一个数据获取库来处理后端状态,将后端 API 集成到前端。到本章结束时,我们将成功开发我们的第一个全栈应用程序!

在本章中,我们将涵盖以下主要主题:

  • React 原则

  • 设置全栈 React 项目

  • 为我们的应用程序创建用户界面

  • 使用 TanStack Query 集成后端服务

技术要求

在我们开始之前,请安装 第一章 中提到的 全栈开发准备第二章 中提到的 了解 Node.jsMongoDB 的所有要求。

那些章节中列出的版本是本书中使用的版本。虽然安装较新版本不应有问题,但请注意,某些步骤在较新版本上可能有所不同。如果您在这本书提供的代码和步骤中遇到问题,请尝试使用 第一章2 中提到的版本。

您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch4

如果您克隆了本书的完整仓库,在运行 npm install 时,Husky 可能找不到 .git 目录。在这种情况下,只需在相应章节文件夹的根目录下运行 git init

本章的 CiA 视频可在:youtu.be/WXqJu2Ut7Hs 找到

React 原则

在我们开始学习如何设置全栈 React 项目之前,让我们回顾 React 的三个基本原则。这些原则使我们能够轻松编写可扩展的 Web 应用程序:

  • 声明式:不是告诉 React 如何做事,而是告诉它我们想要它做什么。因此,我们可以轻松地设计我们的应用程序,当数据发生变化时,React 将高效地更新和渲染正确的组件。例如,以下代码,它将数组中的字符串重复,是命令式的,与声明式相反:

    const input = ['a', 'b', 'c']
    let result = []
    for (let i = 0; i < input.length; i++) {
      result.push(input[i] + input[i])
    }
    console.log(result) // prints: [ 'aa', 'bb', 'cc' ]
    

    如我们所见,在命令式代码中,我们需要逐步告诉 JavaScript 应该做什么。然而,在声明式代码中,我们只需简单地告诉计算机我们想要什么,如下所示:

    const input = ['a', 'b', 'c']
    const result = input.map(str => str + str)
    console.log(result) // prints: ['aa', 'bb', 'cc']
    

    在这个声明式代码中,我们告诉计算机我们想要将 input 数组中的每个 str 元素映射到 str + str。如您所见,声明式代码要简洁得多。

  • 基于组件的:React 封装了管理自身状态和视图的组件,然后允许我们组合它们以创建复杂用户界面。

  • 一次学习,到处编写:React 不对你的技术栈做出假设,并试图确保你可以尽可能不重写现有代码来开发应用程序。

React 的三个基本原理使其易于编写代码、封装组件以及在多个平台上共享代码。React 试图尽可能多地利用现有的 JavaScript 特性,而不是重新发明轮子。因此,我们将学习适用于许多更多情况的软件设计模式,而不仅仅是设计用户界面。

现在我们已经学习了 React 的基本原理,让我们开始设置一个全栈 React 项目!

设置一个全栈 React 项目

在我们开始开发前端应用程序之前,我们首先需要将基于 Vite 创建的先前创建的前端模板与在第三章中创建的后端服务合并,即使用 Express、Mongoose ODM 和 Jest 实现后端。让我们按照以下步骤合并它们:

  1. ch1文件夹复制到新的ch4文件夹中,如下所示:

    $ cp -R ch1 ch4
    
  2. ch3文件夹复制到新的ch4/backend文件夹中,如下所示:

    $ cp -R ch3 ch4/backend
    
  3. 删除 复制的ch4/backend文件夹中的.git文件夹,如下所示:

    $ rm -rf ch4/backend/.git
    
  4. 在 VS Code 中打开新的ch4文件夹。

  5. 删除 backend/package.json文件中的 Husky prepare脚本(代码片段中已突出显示),因为我们已经在根目录中设置了 Husky。

      "scripts": {
        "dev": "nodemon src/index.js",
        "start": "node src/index.js",
        "test": "NODE_OPTIONS=--experimental-vm-modules jest",
        "lint": "eslint src",
        "prepare": "husky install"
      },
    
  6. 同时删除以下lint-staged配置从backend/package.json文件中:

      "lint-staged": {
        "**/*.{js,jsx}": [
          "npx prettier --write",
          "npx eslint --fix"
        ]
      }
    
  7. 然后,删除 backend/.huskybackend/.vscodebackend/.git文件夹。

  8. 为了确保所有依赖项都已正确安装,请在ch4文件夹的根目录下运行以下命令:

    $ npm install
    
  9. 同时也前往backend/目录并在那里安装所有依赖项:

    $ cd backend/
    $ npm install
    
  10. 我们现在也可以从后端项目中删除huskylint-staged@commitlint包,因为我们已经在主项目文件夹中设置了。

    $ npm uninstall husky lint-staged \
      @commitlint/cli @commitlint/config-conventional
    

小贴士

定期检查哪些包你仍然需要,哪些可以丢弃,以保持项目整洁总是一个好主意。在这种情况下,我们从另一个项目中复制了代码,但不需要 Husky / lint-staged / commitlint 设置,因为我们已经在项目的根目录中设置了。

  1. 现在回到ch4文件夹的根目录,并运行以下命令以启动前端服务器:

    $ cd ../
    $ npm run dev
    
  2. 通过访问 Vite 显示的 URL 在浏览器中打开前端:http://localhost:5173/

  3. 打开src/App.jsx,按照以下方式更改标题,并保存文件:

          <h1>Vite + React + Node.js</h1>
    
  4. 你会看到变化立即在浏览器中反映出来!

通过结合我们之前章节的项目成功设置我们的全栈项目后,现在让我们开始设计和创建博客应用的用户界面。

创建我们应用的用户界面

当设计前端的结构时,我们也应该考虑文件夹结构,以便我们的应用在未来可以轻松扩展。类似于我们为后端所做的那样,我们也会把所有源代码放入一个 src/ 文件夹中。然后我们可以根据不同的功能将文件分组到不同的文件夹中。另一种流行的前端项目结构方式是按路由分组代码。当然,混合它们也是可能的,例如,在 Next.js 项目中,我们可以按功能分组我们的组件,然后为使用组件的路由创建另一个文件夹和文件结构。对于全栈项目,首先通过创建用于 API 集成和 UI 组件的单独文件夹来分离代码也是有意义的。

现在,让我们定义我们项目的文件夹结构:

  1. 创建一个新的 src/api/ 文件夹。

  2. 创建一个新的 src/components/ 文件夹。

小贴士

首先从一个简单的结构开始是个好主意,只有在实际需要时才进行更深的嵌套。在开始一个项目时,不要花太多时间思考文件结构,因为通常你事先不知道文件应该如何分组,而且它可能以后还会改变。

在定义我们项目的高级文件夹结构之后,现在让我们花些时间考虑组件结构。

组件结构

根据我们在后端定义的内容,我们的博客应用将具有以下功能:

  • 查看单个帖子

  • 创建新帖子

  • 列出帖子

  • 过滤帖子

  • 对帖子进行排序

React 中组件的理念是让每个组件处理单个任务或 UI 元素。我们应该尽量使组件尽可能细粒度,以便能够重用代码。如果我们发现自己正在从一个组件复制粘贴代码到另一个组件,那么创建一个新的组件并在多个其他组件中重用它可能是个好主意。

通常,在开发前端时,我们从一个 UI 原型开始。对于我们的博客应用,原型可能看起来如下:

图 4.1 – 我们博客应用的初始原型

图 4.1 – 我们博客应用的初始原型

注意

在这本书中,我们不会涵盖 UI 或 CSS 框架。因此,组件的设计和开发没有添加样式。相反,本书专注于后端与前端集成的全栈方面。您可以根据自己的需要使用 UI 框架(如 MUI),或 CSS 框架(如 Tailwind)来为博客应用添加样式。

当将 UI 拆分为组件时,我们使用 单一职责原则,该原则指出每个模块应该对功能的一个封装部分负责。

在我们的原型中,我们可以围绕每个组件和子组件绘制方框,并给它们命名。请记住,每个组件应该只有一个职责。我们首先从构成应用的基本组件开始:

图 4.2 – 在我们的原型中定义基本组件

图 4.2 – 在我们的原型中定义基本组件

我们定义了一个CreatePost组件,用于创建新帖子,一个PostFilter组件用于过滤帖子列表,一个PostSorting组件用于排序帖子,以及一个Post组件用于显示单个帖子。

现在我们已经定义了基本组件,我们将查看哪些组件在逻辑上属于一组,从而形成一个组:我们可以在PostList中将Post组件分组,然后创建一个App组件来将所有内容分组并定义应用的结构。

现在我们已经完成了 React 组件的结构化,我们可以继续实现静态 React 组件。

实现静态 React 组件

在与后端集成之前,我们将应用的基本功能建模为静态 React 组件。首先处理应用静态视图结构是有意义的,因为我们可以在添加组件集成之前,如果需要的话,可以随意调整应用 UI 的结构,这将使得移动它们变得更加困难和繁琐。首先处理 UI 也更容易,这有助于我们快速开始项目和功能。然后,我们可以继续实现集成和处理状态。

让我们现在开始实现静态组件。

Post 组件

在创建原型和后端设计时,我们已经考虑了帖子应该包含哪些元素。帖子应该有一个titlecontentsauthor

让我们现在实现Post组件:

  1. 首先,创建一个新的src/components/Post.jsx文件。

  2. 在那个文件中,导入PropTypes

    import PropTypes from 'prop-types'
    
  3. 定义一个函数组件,接受titlecontentsauthor属性:

    export function Post({ title, contents, author }) {
    
  4. 接下来,以类似于原型的方式渲染所有属性:

      return (
        <article>
          <h3>{title}</h3>
          <div>{contents}</div>
          {author && (
            <em>
              <br />
              Written by <strong>{author}</strong>
            </em>
          )}
        </article>
      )
    }
    

Tip

请注意,你应该始终优先使用 CSS 进行间距设置,而不是使用
HTML 标签。然而,在这本书中,我们专注于 UI 结构和与后端的集成,所以我们尽可能使用 HTML。

  1. 现在,定义propTypes,确保只有title是必需的:

    Post.propTypes = {
      title: PropTypes.string.isRequired,
      contents: PropTypes.string,
      author: PropTypes.string,
    }
    

Info

PropTypes用于验证传递给 React 组件的属性,并确保我们在使用 JavaScript 时传递了正确的属性。当使用类型安全的语言,如 TypeScript 时,我们可以通过直接为传递给组件的属性类型化来做到这一点。

  1. 让我们通过替换****src/App.jsx文件的内容来测试我们的组件:

    import { Post } from './components/Post.jsx'
    export function App() {
      return (
        <Post
          title='Full-Stack React Projects'
          contents="Let's become full-stack developers!"
          author='Daniel Bugl'
        />
      )
    }
    
  2. 编辑src/main.jsx并更新App组件的导入,因为我们现在不再使用export default

    import { App } from './App.jsx'
    

Info

我个人倾向于不使用默认导出,因为它们使得从其他文件重新组合和重新导出组件和函数变得更加困难。此外,它们允许我们更改组件的名称,这可能会造成混淆。例如,如果我们更改组件的名称,导入时的名称不会自动更改。

  1. 同时,从 src/main.jsx删除以下行:

    import './index.css'
    
  2. 最后,我们可以删除 index.cssApp.css 文件,因为它们不再需要了。

现在我们已经实现了静态的 Post 组件,我们可以继续到 CreatePost 组件。

CreatePost 组件

我们现在将实现一个表单,允许创建新的帖子。在这里,我们提供了 authortitle 字段,以及一个 <textarea> 元素用于输入博客帖子的内容。

现在让我们实现 CreatePost 组件:

  1. 创建一个新的 src/components/CreatePost.jsx 文件。

  2. 定义以下组件,它包含一个表单,用于输入博客帖子的标题、作者和内容:

    export function CreatePost() {
      return (
        <form onSubmit={(e) => e.preventDefault()}>
          <div>
            <label htmlFor='create-title'>Title: </label>
            <input type='text' name='create-title' id='create-title' />
          </div>
          <br />
          <div>
            <label htmlFor='create-author'>Author: </label>
            <input type='text' name='create-author' id='create-author' />
          </div>
          <br />
          <textarea />
          <br />
          <br />
          <input type='submit' value='Create' />
        </form>
      )
    }
    

    在前面的代码块中,我们定义了一个 onSubmit 处理程序,并在事件对象上调用 e.preventDefault() 以避免在表单提交时刷新页面。

  3. 让我们通过替换 src/App.jsx 文件的内容来测试这个组件:

    import { CreatePost } from './components/CreatePost.jsx'
    export function App() {
      return <CreatePost />
    }
    

如你所见,CreatePost 组件渲染良好。我们现在可以继续到 PostFilterPostSorting 组件。

小贴士

如果你想要一次性测试多个组件并保留测试结果以供以后使用,或者为你的组件库创建一个样式指南,你应该了解一下 Storybook (storybook.js.org),这是一个用于独立构建、测试和记录 UI 组件的有用工具。

PostFilter 和 PostSorting 组件

CreatePost 组件类似,我们将创建两个组件,它们提供输入字段以过滤和排序帖子。让我们从 PostFilter 开始:

  1. 创建一个新的 src/components/PostFilter.jsx 文件。

  2. 在这个文件中,我们导入 PropTypes

    import PropTypes from 'prop-types'
    
  3. 现在,我们定义 PostFilter 组件并使用 field 属性:

    export function PostFilter({ field }) {
      return (
        <div>
          <label htmlFor={`filter-${field}`}>{field}: </label>
          <input
            type='text'
            name={`filter-${field}`}
            id={`filter-${field}`}
          />
        </div>
      )
    }
    PostFilter.propTypes = {
      field: PropTypes.string.isRequired,
    }
    

    接下来,我们将定义 PostSorting 组件。

  4. 创建一个新的 src/components/PostSorting.jsx 文件。

  5. 在这个文件中,我们创建了一个 select 输入以选择要排序的字段。我们还创建了一个另一个 select 输入以选择排序顺序:

    import PropTypes from 'prop-types'
    export function PostSorting({ fields = [] }) {
      return (
        <div>
          <label htmlFor='sortBy'>Sort By: </label>
          <select name='sortBy' id='sortBy'>
            {fields.map((field) => (
              <option key={field} value={field}>
                {field}
              </option>
            ))}
          </select>
          {' / '}
          <label htmlFor='sortOrder'>Sort Order: </label>
          <select name='sortOrder' id='sortOrder'>
            <option value={'ascending'}>ascending</option>
            <option value={'descending'}>descending</option>
          </select>
        </div>
      )
    }
    PostSorting.propTypes = {
      fields: PropTypes.arrayOf(PropTypes.string).isRequired,
    }
    

现在我们已经成功定义了用于过滤和排序帖子的 UI 组件。在下一步中,我们将创建一个 PostList 组件,将过滤和排序与帖子列表结合起来。

PostList 组件

在实现了其他与帖子相关的组件之后,我们现在可以开始实现我们博客应用最重要的部分,即博客帖子的源。目前,源将简单地显示一个博客帖子列表。

让我们现在开始实现 PostList 组件:

  1. 创建一个新的 src/components/PostList.jsx 文件。

  2. 首先,我们导入 FragmentPropTypesPost 组件:

    import { Fragment } from 'react'
    import PropTypes from 'prop-types'
    import { Post } from './Post.jsx'
    
  3. 然后,我们定义了PostList函数组件,它接受一个作为属性的posts数组。如果posts未定义,我们默认将其设置为空数组:

    export function PostList({ posts = [] }) {
    
  4. 接下来,我们使用.map函数和扩展语法来渲染所有帖子:

      return (
        <div>
          {posts.map((post) => (
            <Post {...post} key={post._id} />
          ))}
        </div>
      )
    }
    

    我们为每个帖子返回<Post>组件,并将post对象中的所有键作为属性传递给组件。我们通过使用扩展语法来完成此操作,这具有与手动将对象的所有键作为属性列出相同的效果,如下所示:

    <Post
      title={post.title}
      author={post.author}
      contents={post.contents}
    />
    

注意

如果我们在渲染元素列表,我们必须给每个元素一个唯一的key属性。React 使用这个key属性在数据发生变化时高效地计算两个列表之间的差异。

我们使用了map函数,它将一个函数应用于数组的所有元素。这与使用for循环并存储所有结果类似,但更简洁、声明性更强、更容易阅读!作为使用map函数的替代方案,我们可以这样做:

let renderedPosts = []
let index = 0
for (let post of posts) {
  renderedPosts.push(<Post {...post} key={post._id} />)
  index++
}
return (
  <div>
    {renderedPosts}
  </div>
)

然而,使用这种风格在 React 中被推荐。

  1. 我们还需要定义属性类型。在这里,我们可以利用Post组件的属性类型,通过将其包裹在PropTypes.shape()函数中来使用,该函数定义了一个对象属性类型:

    PostList.propTypes = {
      posts: PropTypes.arrayOf(PropTypes.shape(Post.propTypes)).isRequired,
    }
    
  2. 在原型中,我们每篇博客文章后面都有一个水平线。我们可以通过使用Fragment来实现这一点,而不需要额外的

    容器元素,如下所示:

          {posts.map((post) => (
            <Fragment key={post._id}>
              <Post {...post} />
              <hr />
            </Fragment>
          ))}
    

注意

key属性始终必须添加到在map函数中渲染的最高父元素。在这种情况下,我们必须将key属性从Post组件移动到Fragment

  1. 再次,我们通过编辑src/App.jsx文件来测试我们的组件:

    import { PostList } from './components/PostList.jsx'
    const posts = [
      {
        title: 'Full-Stack React Projects',
        contents: "Let's become full-stack developers!",
        author: 'Daniel Bugl',
      },
      { title: 'Hello React!' },
    ]
    export function App() {
      return <PostList posts={posts} />
    }
    

    现在,我们可以看到我们的应用列出了我们在posts数组中定义的所有帖子。

如您所见,通过PostList组件列出多个帖子工作得很好。现在我们可以继续组装应用。

组装应用

实现所有组件后,我们现在必须在App组件中将所有内容组合在一起。然后,我们将成功复制原型!

让我们开始修改App组件并将我们的博客应用组装起来:

  1. 打开src/App.jsx并为CreatePostPostFilterPostSorting组件添加导入:

    import { PostList } from './components/PostList.jsx'
    import { CreatePost } from './components/CreatePost.jsx'
    import { PostFilter } from './components/PostFilter.jsx'
    import { PostSorting } from './components/PostSorting.jsx'
    
  2. 调整App组件以包含所有组件:

    export function App() {
      return (
        <div style={{ padding: 8 }}>
          <CreatePost />
          <br />
          <hr />
          Filter by:
          <PostFilter field='author' />
          <br />
          <PostSorting fields={['createdAt', 'updatedAt']} />
          <hr />
          <PostList posts={posts} />
        </div>
      )
    }
    
  3. 保存文件后,浏览器应自动刷新,现在我们可以看到完整的 UI:

图 4.3 – 根据原型实现的我们的静态博客应用完整实现

图 4.3 – 根据原型实现的我们的静态博客应用完整实现

如我们所见,我们之前定义的所有静态组件都在一个App组件中一起渲染。现在,我们的应用看起来就像原型一样。接下来,我们可以继续将我们的组件与后端服务集成。

使用 TanStack Query 集成后端服务

在完成所有 UI 组件的创建后,我们现在可以继续将它们与上一章中创建的后端集成。对于集成,我们将使用 TanStack Query(之前称为 React Query),这是一个数据获取库,它还可以帮助我们进行缓存、同步和从后端更新数据。

TanStack Query 专注于管理获取的数据的状态(服务器状态)。虽然其他状态管理库也可以处理服务器状态,但它们专注于管理客户端状态。服务器状态与客户端状态有一些明显的区别,例如以下内容:

  • 在客户端无法直接控制的位置远程持久化

  • 需要异步 API 来获取和更新状态

  • 必须处理共享所有权,这意味着其他人可以在你不知情的情况下更改状态

  • 当服务器或其他人在某个时候更改状态时,状态变得过时(“过时”)

这些与服务器状态相关的挑战导致了一系列问题,例如必须缓存、去重多个请求、在后台更新“过时”状态等。

TanStack Query 提供了现成的解决方案来解决这些问题,因此处理服务器状态变得简单。你总是可以将其与其他关注客户端状态的州管理库结合使用。对于客户端状态本质上只是反映服务器状态的使用案例,TanStack Query 本身就足以作为一个状态管理解决方案!

注意

React Query 被重命名为 TanStack Query 的原因是这个库现在也支持其他框架,例如 Solid、Vue 和 Svelte!

现在你已经知道了为什么以及如何使用 TanStack Query 来帮助我们集成前端和后端,让我们开始使用它吧!

为 React 设置 TanStack Query

要设置 TanStack Query,我们首先必须安装依赖项并设置一个查询客户端。查询客户端通过上下文提供给 React,并将存储有关活动请求、缓存结果、何时定期重新获取数据以及 TanStack Query 运作所需的一切信息。

让我们现在开始设置它:

  1. 打开一个新的终端(不要退出 Vite!)并在我们项目的根目录下运行以下命令来安装 @tanstack/react-query 依赖项:

    App component to a new Blog component, as we are going to use the App component for setting up libraries and contexts instead.
    
  2. src/App.jsx 文件重命名为 src/Blog.jsx

    不要更新导入。如果 VS Code 要求你更新导入,请点击 No

  3. 现在,在 src/Blog.jsx 中,将函数名从 App 改为 Blog

    export function Blog() {
    
  4. 创建一个新的 src/App.jsx 文件。在这个文件中,从 TanStack React Query 导入 QueryClientQueryClientProvider

    import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
    
  5. 还要导入 Blog 组件:

    import { Blog } from './Blog.jsx'
    
  6. 现在,创建一个新的查询客户端:

    const queryClient = new QueryClient()
    
  7. 定义 App 组件并渲染包裹在 QueryClientProvider 中的 Blog 组件:

    export function App() {
      return (
        <QueryClientProvider client={queryClient}>
          <Blog />
        </QueryClientProvider>
      )
    }
    

那就是设置 TanStack Query 所需要做的全部!现在我们可以在我们的 Blog 组件(及其子组件)中使用它了。

获取博客文章

我们首先应该做的是从我们的后端获取博客文章列表。现在让我们来实现它:

  1. 首先,在打开的第二个终端窗口中(不是 Vite 运行的地方),运行后端服务器(不要退出 Vite!),如下所示:

    $ cd backend/
    $ npm start
    

    如果出现错误,请确保 Docker 和 MongoDB 正在正常运行!

小贴士

如果你想同时开发后端和前端,你可以使用npm run dev启动后端,以确保在更改代码时它能够热重载。

  1. 在项目的根目录中创建一个.env文件,并将以下内容输入到其中:

    VITE_BACKEND_URL="http://localhost:3001/api/v1"
    

    Vite 默认支持dotenv。所有应该在前端中可访问的环境变量都需要以VITE_为前缀。在这里,我们设置一个环境变量以指向我们的后端服务器。

  2. 创建一个新的src/api/posts.js文件。在这个文件中,我们将定义一个函数来获取文章,该函数接受作为参数的/posts端点的查询参数。这些查询参数用于按作者和标签筛选,并使用sortBysortOrder定义排序:

    export const getPosts = async (queryParams) => {
    
  3. 记住,我们可以使用fetch函数向服务器发送请求。我们需要将环境变量传递给它,并添加/posts端点。在路径之后,我们添加查询参数,这些参数以?符号为前缀:

      const res = await fetch(
        `${import.meta.env.VITE_BACKEND_URL}/posts?` +
    
  4. 现在我们需要使用URLSearchParams类将一个对象转换为查询参数。这个类会自动为我们转义输入并将其转换为有效的查询参数:

          new URLSearchParams(queryParams),
    
  5. 就像我们在浏览器中之前做的那样,我们需要将响应解析为 JSON:

      )
      return await res.json()
    }
    
  6. 编辑src/Blog.jsx删除示例posts数组:

    const posts = [
      {
        title: 'Full-Stack React Projects',
        contents: "Let's become full-stack developers!",
        author: 'Daniel Bugl',
      },
      { title: 'Hello React!' },
    ]
    
  7. 此外,从@tanstack/react-query导入useQuery函数,并从src/Blog.jsx文件中的api文件夹导入getPosts函数:

    import { useQuery } from '@tanstack/react-query'
    import { PostList } from './components/PostList.jsx'
    import { CreatePost } from './components/CreatePost.jsx'
    import { PostFilter } from './components/PostFilter.jsx'
    import { PostSorting } from './components/PostSorting.jsx'
    import { getPosts } from './api/posts.js'
    
  8. Blog组件内部,定义一个useQuery钩子:

    export function Blog() {
      const postsQuery = useQuery({
        queryKey: ['posts'],
        queryFn: () => getPosts(),
    queryKey is very important in TanStack Query, as it is used to uniquely identify a request, among other things, for caching purposes. Always make sure to use unique query keys. Otherwise, you might see requests not triggering properly.For the `queryFn` option, we just call the `getPosts` function, without query params for now.
    
  9. useQuery钩子之后,我们从查询中获取文章,如果文章尚未加载,则回退到空数组:

    const posts = postsQuery.data ?? []
    
  10. 检查你的浏览器,你会看到文章现在是从我们的后端加载的!

现在我们已经成功获取了博客文章,让我们让筛选和排序工作起来!

实现筛选和排序

要实现筛选和排序,我们需要处理一些本地状态,并将其作为查询参数传递给postsQuery。现在让我们来做这件事:

  1. 我们首先编辑src/Blog.jsx文件并从 React 导入useState钩子:

    import { useState } from 'react'
    
  2. Blog组件中,在useQuery钩子之前添加author筛选器和排序选项的状态钩子:

      const [author, setAuthor] = useState('')
      const [sortBy, setSortBy] = useState('createdAt')
      const [sortOrder, setSortOrder] = useState('descending')
    
  3. 然后,我们将queryKey调整为包含查询参数(这样每当查询参数发生变化时,TanStack Query 都会重新获取,除非请求已经被缓存)。我们还将queryFn调整为调用带有相关查询参数的getPosts

      const postsQuery = useQuery({
        queryKey: ['posts', { author, sortBy, sortOrder }],
        queryFn: () => getPosts({ author, sortBy, sortOrder }),
      })
    
  4. 现在将值和相关的onChange处理程序传递给筛选和排序组件:

          <PostFilter
            field='author'
            value={author}
            onChange={(value) => setAuthor(value)}
          />
          <br />
          <PostSorting
            fields={['createdAt', 'updatedAt']}
            value={sortBy}
            onChange={(value) => setSortBy(value)}
            orderValue={sortOrder}
            onOrderChange={(orderValue) => setSortOrder(orderValue)}
          />
    

注意

为了简单起见,我们现在只使用状态钩子。状态管理解决方案或上下文可以使处理过滤和排序变得容易得多,尤其是在大型应用程序中。对于我们的小型博客应用程序,使用状态钩子是可以的,因为我们主要关注后端和前端之间的集成。

  1. 现在,编辑src/components/PostFilter.jsx并添加valueonChange属性:

    export function PostFilter({ field, value, onChange }) {
      return (
        <div>
          <label htmlFor={`filter-${field}`}>{field}: </label>
          <input
            type='text'
            name={`filter-${field}`}
            id={`filter-${field}`}
            value={value}
            onChange={(e) => onChange(e.target.value)}
          />
        </div>
      )
    }
    PostFilter.propTypes = {
      field: PropTypes.string.isRequired,
      value: PropTypes.string.isRequired,
      onChange: PropTypes.func.isRequired,
    }
    
  2. 我们也为src/components/PostSorting.jsx做同样的处理:

    export function PostSorting({
      fields = [],
      value,
      onChange,
      orderValue,
      onOrderChange,
    }) {
      return (
        <div>
          <label htmlFor='sortBy'>Sort By: </label>
          <select
            name='sortBy'
            id='sortBy'
            value={value}
            onChange={(e) => onChange(e.target.value)}
          >
            {fields.map((field) => (
              <option key={field} value={field}>
                {field}
              </option>
            ))}
          </select>
          {' / '}
          <label htmlFor='sortOrder'>Sort Order: </label>
          <select
            name='sortOrder'
            id='sortOrder'
            value={orderValue}
            onChange={(e) => onOrderChange(e.target.value)}
          >
            <option value={'ascending'}>ascending</option>
            <option value={'descending'}>descending</option>
          </select>
        </div>
      )
    }
    PostSorting.propTypes = {
      fields: PropTypes.arrayOf(PropTypes.string).isRequired,
      value: PropTypes.string.isRequired,
      onChange: PropTypes.func.isRequired,
      orderValue: PropTypes.string.isRequired,
      onOrderChange: PropTypes.func.isRequired,
    }
    
  3. 在您的浏览器中,输入Daniel Bugl作为作者。您应该看到 TanStack Query 在您输入时从后端重新获取帖子,一旦找到匹配项,后端将返回该作者的所有帖子!

  4. 在测试完毕后,请确保再次清除过滤器,这样后来创建的帖子就不会再根据作者进行过滤。

小贴士

如果您不想向后端发送那么多请求,请确保使用防抖状态钩子,如useDebounce,然后将仅防抖的值传递给查询参数。如果您对了解useDebounce钩子和其他有用的钩子感兴趣,我建议您查看我写的名为Learn React Hooks的书籍。

应用程序现在应该如下所示,帖子根据在字段中输入的作者进行过滤,并按所选字段排序,按所选顺序排序:

图 4.4 – 我们的第一个全栈应用程序 – 前端从后端获取帖子!

图 4.4 – 我们的第一个全栈应用程序 – 前端从后端获取帖子!

现在排序和过滤都正常工作后,让我们了解突变,它允许我们向服务器发送更改后端状态(例如,在数据库中插入或更新条目)的请求。

创建新帖子

我们现在将实现创建帖子的功能。为此,我们需要使用 TanStack Query 的useMutation钩子。虽然查询意味着是幂等的(这意味着多次调用它们不应影响结果),但突变用于创建/更新/删除数据或在服务器上执行操作。现在让我们开始使用突变来创建新的帖子:

  1. 编辑src/api/posts.js并定义一个新的createPost函数,该函数接受一个post对象作为参数:

    export const createPost = async (post) => {
    
  2. 我们还像对getPosts一样向/posts端点发送请求:

      const res = await fetch(`${import.meta.env.VITE_BACKEND_URL}/posts`, {
    
  3. 然而,现在我们也将method设置为POST请求,传递一个头部信息告诉后端我们将发送一个 JSON 体,然后将我们的post对象作为 JSON 字符串发送:

        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(post),
    
  4. 就像getPosts一样,我们还需要将响应解析为 JSON:

      })
      return await res.json()
    }
    

    在定义了createPost API 函数之后,让我们在CreatePost组件中使用它,通过在那里创建一个新的突变钩子:

  5. 编辑src/components/CreatePost.jsx并从@tanstack/react-query导入useMutation钩子,从 React 导入useState钩子以及我们的createPost API 函数:

    import { useMutation } from '@tanstack/react-query'
    import { useState } from 'react'
    import { createPost } from '../api/posts.js'
    
  6. CreatePost组件内部,为titleauthorcontents定义状态钩子:

      const [title, setTitle] = useState('')
      const [author, setAuthor] = useState('')
      const [contents, setContents] = useState('')
    
  7. 现在,定义一个突变钩子。在这里,我们将调用我们的createPost函数:

      const createPostMutation = useMutation({
        mutationFn: () => createPost({ title, author, contents }),
      })
    
  8. 接下来,我们将定义一个handleSubmit函数,该函数将阻止默认的提交操作(这将刷新页面),而是调用.mutate()来执行突变:

      const handleSubmit = (e) => {
        e.preventDefault()
        createPostMutation.mutate()
      }
    
  9. 我们将onSubmit处理程序添加到我们的表单中:

        <form onSubmit={handleSubmit}>
    
  10. 我们还像之前为排序和过滤器所做的那样,为我们的字段添加了valueonChange属性:

          <div>
            <label htmlFor='create-title'>Title: </label>
            <input
              type='text'
              name='create-title'
              id='create-title'
              value={title}
              onChange={(e) => setTitle(e.target.value)}
            />
          </div>
          <br />
          <div>
            <label htmlFor='create-author'>Author: </label>
            <input
              type='text'
              name='create-author'
              id='create-author'
              value={author}
              onChange={(e) => setAuthor(e.target.value)}
            />
          </div>
          <br />
          <textarea
            value={contents}
            onChange={(e) => setContents(e.target.value)}
          />
    
  11. 对于提交按钮,我们确保在等待突变完成时显示Creating…而不是Create,并且如果没有设置标题(这是必需的),或者突变当前正在挂起时,我们也会禁用按钮:

          <br />
          <br />
          <input
            type='submit'
            value={createPostMutation.isPending ? 'Creating...' : 'Create'}
            disabled={!title || createPostMutation.isPending}
          />
    
  12. 最后,我们在提交按钮下方添加了一条消息,如果突变成功,将会显示:

          {createPostMutation.isSuccess ? (
            <>
              <br />
              Post created successfully!
            </>
          ) : null}
        </form>
    

注意

除了isPendingisSuccess,突变还会返回isIdle(当突变处于空闲或新鲜/重置状态时)和isError状态。相同的也可以从查询中访问,例如,在帖子正在获取时显示加载动画。

  1. 现在,我们可以尝试添加一个新的帖子,看起来工作得很好,但是帖子列表并没有自动更新,只有刷新后才会更新!

问题在于查询键没有改变,所以 TanStack Query 没有刷新帖子列表。然而,我们还想在创建新帖子时刷新列表。现在让我们修复这个问题。

无效化查询

为了确保创建新帖子后帖子列表被刷新,我们需要无效化查询。我们可以利用查询客户端来完成这个操作。现在让我们来做这件事:

  1. 编辑src/components/CreatePost.jsx并导入useQueryClient钩子:

    import { useMutation, useQueryClient } from '@tanstack/react-query'
    
  2. 使用查询客户端无效化以'posts'查询键开头的所有查询。这将与getPosts请求的任何查询参数一起工作,因为它匹配数组中所有以'posts'开头的查询:

      const queryClient = useQueryClient()
      const createPostMutation = useMutation({
        mutationFn: () => createPost({ title, author, contents }),
        onSuccess: () => queryClient.invalidateQueries(['posts']),
      })
    

尝试创建一个新的帖子,你会看到现在它确实可以工作,即使有活跃的过滤器和排序!正如我们所看到的,TanStack Query 在轻松处理服务器状态方面非常出色。

摘要

在本章中,我们学习了如何创建一个 React 前端,并使用 TanStack Query 将其与我们的后端集成。我们已经涵盖了后端的主要功能:按排序列出帖子、创建帖子以及按作者过滤。处理标签以及删除和编辑帖子与已解释的功能类似,留作你的练习。

在下一章,第五章使用 Docker 和 CI/CD 部署应用程序,我们将使用 Docker 部署我们的应用程序,并设置 CI/CD 管道来自动化应用程序的部署。

第五章:使用 Docker 和 CI/CD 部署应用程序

现在我们已经成功开发了一个包含后端服务和前端的全栈应用程序,我们将把我们的应用程序打包成 Docker 镜像,并学习如何使用持续集成CI)和持续交付CD)原则来部署它们。我们已经学习了如何在第二章“了解 Node.js 和 MongoDB”中启动 Docker 容器。在本章中,我们将学习如何创建自己的 Docker 镜像以实例化容器。然后,我们将手动将我们的应用程序部署到云服务提供商。最后,我们将配置 CI/CD 以自动化应用程序的部署。在本章结束时,我们将成功部署我们的第一个全栈MongoDB Express React Node.jsMERN)应用程序,并为其设置未来的自动化部署!

在本章中,我们将涵盖以下主要主题:

  • 创建 Docker 镜像

  • 将我们的全栈应用程序部署到云端

  • 配置 CI 以自动化测试

  • 配置 CD 以自动化部署

技术要求

在我们开始之前,请从第一章“为全栈开发做准备”和第二章“了解 Node.js 和 MongoDB”中安装所有要求。

那些章节中列出的版本是书中使用的版本。虽然安装较新版本不应有问题,但请注意,某些步骤在较新版本上可能工作方式不同。如果您在使用本书中提供的代码和步骤时遇到问题,请尝试使用第一章第二章中提到的版本。

您可以在 GitHub 上找到本章节的代码:github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch5

本章节的 CiA 视频可以在以下网址找到:youtu.be/aQplfCQGWew

创建 Docker 镜像

第二章“了解 Node.js 和 MongoDB”中,我们了解到在 Docker 平台上,我们使用 Docker 镜像来创建容器,然后可以运行服务。我们已经学习了如何使用现有的 mongo 镜像来创建数据库服务的容器。在本节中,我们将学习如何创建自己的镜像以实例化容器。为此,我们首先需要创建一个Dockerfile,它包含构建 Docker 镜像所需的所有指令。首先,我们将为我们的后端服务创建一个 Docker 镜像并从中运行一个容器。然后,我们将对前端执行相同的操作。最后,我们将创建一个Docker Compose 文件,以启动我们的数据库和后端服务以及前端服务。

创建后端 Dockerfile

Dockerfile 告诉 Docker 如何逐步构建镜像。文件中的每一行都是一个指令,告诉 Docker 要做什么。Dockerfile 的格式如下:

# comment
INSTRUCTION arguments

每个 Dockerfile 都必须以一个FROM指令开始,该指令指定了新创建的镜像应该基于哪个镜像。你可以从现有的镜像扩展你的镜像,例如ubuntunode

让我们开始创建我们后端服务的 Dockerfile:

  1. ch4文件夹复制到一个新的ch5文件夹,如下所示:

    $ cp -R ch4 ch5
    
  2. ch5文件夹内创建一个新的backend/Dockerfile文件。

  3. 在这个文件中,我们首先定义了我们镜像的基础镜像,它将是node镜像的版本 20:

    FROM node:20
    

    该镜像由 Docker Hub 提供,类似于我们之前创建容器时使用的ubuntumongo镜像。

备注

请注意,只使用官方镜像和由可信作者创建的镜像。例如,node镜像由 Node.js 团队官方维护。

  1. 然后,我们设置工作目录,这是我们服务中所有文件在镜像内部放置的位置:

    WORKDIR /app
    

    WORKDIR指令类似于在终端中使用cd。它更改工作目录,这样我们就不需要将所有后续命令的前缀设置为完整路径。如果文件夹不存在,Docker 会为我们创建它。

  2. 接下来,我们将package.jsonpackage-lock.json文件从我们的项目复制到工作目录:

    COPY package.json package-lock.json ./
    

    COPY指令将文件从你的本地文件系统复制到 Docker 镜像中(相对于本地工作目录)。可以指定多个文件,指令的最后一个参数是目标位置(在这种情况下,是镜像的当前工作目录)。

    package-lock.json文件是必需的,以确保 Docker 镜像包含与我们的本地构建相同的npm包版本。

  3. 现在,我们运行npm install来在镜像中安装所有依赖项:

    RUN npm install
    

    RUN指令在镜像的工作目录中执行一个命令。

  4. 然后,我们将应用程序的其余部分从本地文件系统复制到 Docker 镜像中:

    COPY . .
    

备注

你想知道为什么我们最初只复制了package.jsonpackage-lock.json吗?Docker 镜像是一层层构建的。每个指令形成一个镜像层。如果有什么变化,只有变化之后的层需要重新构建。所以,在我们的例子中,如果代码有任何变化,只有这个最后的COPY指令在重新构建 Docker 镜像时会被重新执行。只有当依赖项发生变化时,其他的COPY指令和npm install才会被重新执行。使用这种指令顺序可以极大地减少重新构建镜像所需的时间。

  1. 最后,我们运行我们的应用程序:

    CMD ["npm", "start"]
    

    CMD指令在构建镜像时不会执行。相反,它将信息存储在镜像的元数据中,告诉 Docker 当从镜像实例化容器时要运行哪个命令。在我们的例子中,当使用我们的镜像时,容器将运行npm start

备注

你可能已经注意到,我们向 CMD 指令传递了一个 JSON 数组,而不是简单地写入 CMD npm start。这种包含 JSON 数组的版本被称为 exec 形式,如果第一个参数是一个可执行文件,它将直接运行命令而不调用 shell。没有 JSON 数组的版本被称为 shell 形式,它将使用 shell 执行命令,并在前面加上 /bin/sh -c。在没有 shell 的情况下运行命令的优点是允许应用程序正确接收信号,例如当应用程序被终止时发出的 SIGTERMSIGKILL 信号。或者,可以使用 ENTRYPOINT 指令来指定运行特定命令时应使用的可执行文件(默认为 /bin/sh -c)。在某些情况下,你可能甚至想直接使用 CMD ["node", "src/index.js"] 来运行脚本,这样脚本就可以正确接收 所有 信号。然而,这需要我们在后端服务器中实现 SIGINT 信号,以便通过 Ctrl + C 来关闭容器,因此为了保持简单,我们只是使用 npm start

在创建我们的 Dockerfile 之后,我们还应该创建一个 .dockerignore 文件,以确保不必要的文件不会被复制到我们的镜像中。

创建 .dockerignore 文件

COPY 命令,其中我们复制所有文件,也会复制 node_modules 文件夹和其他文件,例如我们不希望进入镜像的 .env 文件。为了防止某些文件被复制到我们的 Docker 镜像中,我们需要创建一个 .dockerignore 文件。现在让我们来做这件事:

  1. 创建一个新的 backend/.dockerignore 文件。

  2. 打开它并输入以下内容以忽略 node_modules 文件夹和所有 .****env 文件:

    node_modules
    .env*
    

现在我们已经定义了 .dockerignore 文件,COPY 指令将忽略这些文件夹和文件。现在让我们构建 Docker 镜像。

构建 Docker 镜像

在成功创建后端 Dockerfile 和一个 .dockerignore 文件以防止某些文件和文件夹被添加到我们的 Docker 镜像之后,我们现在可以开始构建我们的 Docker 镜像:

  1. 打开一个终端。

  2. 运行以下命令来构建 Docker 镜像:

    blog-backend as the name of our image and backend/ as the working directory.
    

运行命令后,Docker 将首先读取 Dockerfile 和 .dockerignore 文件。然后,它将下载 node 镜像并逐条执行我们的指令。最后,它将所有层和元数据导出到我们的 Docker 镜像中。

以下截图显示了创建 Docker 镜像的输出:

图 5.1 – 创建 Docker 镜像时的输出

图 5.1 – 创建 Docker 镜像时的输出

现在我们已经成功创建了我们的镜像,接下来让我们基于它创建并运行一个容器!

从我们的镜像创建和运行容器

我们已经在第二章,“了解 Node.js 和 MongoDB”中创建了基于ubuntumongo镜像的 Docker 容器。现在,我们将从我们自己的镜像创建并运行一个容器。让我们现在开始做这件事:

  1. 运行以下命令来列出所有可用的镜像:

    blog-backend image that we just created, and the mongo and ubuntu images that we previously used.
    
  2. 确保我们的数据库的dbserver容器已经运行。

  3. 然后,按照以下步骤启动一个新的容器:

    docker run command:*   **-it** runs the container in interactive mode (**-t** to allocate a pseudo Terminal and **-i** to keep the input stream open).*   **-e PORT=3001** sets the **PORT** environment variable inside the container to **3001**.*   **-e DATABASE_URL=mongodb://host.docker.internal:27017/blog** sets the **DATABASE_URL** environment variable. Here, we replaced **localhost** with **host.docker.internal**, as the MongoDB service runs in a different container on the Docker host (our machine).*   **-p 3001:3001** forwards port **3001** from inside the container to port **3001** on the host (our machine).*   **blog-backend** is the name of our image.
    
  4. blog-backend容器现在正在运行,这看起来与在终端上直接运行后端非常相似。转到http://localhost:3001/api/v1/posts以验证它是否像以前一样正常运行并返回所有帖子。

  5. 目前请保持容器运行。

我们已经成功地将后端打包为 Docker 镜像,并从中启动了一个容器!现在,让我们为前端做同样的事情。

创建前端 Dockerfile

在为后端服务创建 Docker 镜像后,我们现在将重复相同的步骤来创建前端镜像。我们将首先创建 Dockerfile,然后创建.dockerignore文件,构建镜像,然后运行容器。现在,我们将从创建前端 Dockerfile 开始。

在我们前端的 Dockerfile 中,我们将使用两个镜像:

  • 一个用于使用Vite(构建完成后将被丢弃,只保留构建输出)的构建镜像

  • 一个最终的镜像,该镜像将用于我们的静态网站,通过 nginx 服务

让我们现在创建 Dockerfile:

  1. 在我们项目的根目录下创建一个新的 Dockerfile。

  2. 在这个新创建的文件中,首先,再次使用node镜像,但这次我们将其标记为AS build。这样做使得 Docker 中的多阶段构建成为可能,这意味着我们可以稍后使用另一个基础镜像来创建我们的最终镜像:

    FROM node:20 AS build
    
  3. 在构建时,我们还设置了VITE_BACKEND_URL环境变量。在 Docker 中,我们可以使用ARG指令来定义仅在构建镜像时相关的环境变量:

    ARG VITE_BACKEND_URL=http://localhost:3001/api/v1
    

注意

虽然ARG指令定义了一个可以在构建时通过--build-arg标志更改的环境变量,但ENV指令将环境变量设置为固定值,当从生成的镜像运行容器时,该值将保持不变。因此,如果我们想在构建时自定义环境变量,我们应该使用ARG指令。然而,如果我们想在运行时自定义环境变量,ENV则更为合适。

  1. 我们将工作目录设置为/build,然后重复为后端定义的相同指令来安装所有必要的依赖项并复制必要的文件:

    WORKDIR /build
    COPY package.json .
    COPY package-lock.json .
    RUN npm install
    COPY . .
    
  2. 此外,我们执行npm run build来创建我们的 Vite 应用的静态构建:

    RUN npm run build
    
  3. 现在,我们的构建阶段已完成。我们再次使用FROM指令来创建最终阶段。这次,我们基于nginx镜像,该镜像运行 nginx 网络服务器:

    FROM nginx AS final
    
  4. 我们将此阶段的当前工作目录设置为 /var/www/html,这是 nginx 从中提供静态文件的文件夹:

    WORKDIR /usr/share/nginx/html
    
  5. 最后,我们将从 /build/dist 文件夹(这是 Vite 放置构建的静态文件的地方)从 build 阶段复制所有内容到 final 阶段:

    COPY --from=build /build/dist .
    

    在这种情况下不需要 CMD 指令,因为 nginx 镜像已经包含了一个用于正确运行 web 服务器的指令。

我们成功地为前端创建了一个多阶段 Dockerfile!现在,让我们继续创建 .dockerignore 文件。

为前端创建 .dockerignore 文件

我们还需要为前端创建一个 .dockerignore 文件。在这里,除了排除 node_modules/ 文件夹和 .env 文件外,我们还要排除包含我们的后端服务的 backend/ 文件夹以及 .vscode.git.husky 文件夹。现在让我们创建 .dockerignore 文件:

  1. 在我们项目的根目录下创建一个新的 .dockerignore 文件。

  2. 在这个新创建的文件中,输入以下内容:

    node_modules
    .env*
    backend
    .vscode
    .git
    .husky
    .commitlintrc.json
    

现在我们已经忽略了构建 Docker 镜像不需要的文件,让我们构建它!

构建 Docker 前端镜像

就像之前一样,我们执行 docker build 命令来构建镜像,给它命名为 blog-frontend,并指定根目录作为路径:

node image to build our frontend in the build stage. Then, it will switch to the final stage, use the nginx image, and copy over the built static files from the build stage.
Now, let’s create and run the frontend container.
Creating and running the frontend container
Similarly to what we did for the backend container, we can also create and run a container from the `blog-frontend` image by executing the following command:

nginx 镜像在端口 80 上运行 web 服务器,因此,如果我们想在主机上使用端口 3000,我们需要通过传递 -p 3000:80 将端口 80 转发到 3000。

运行此命令并在浏览器中导航到 http://localhost:3000 后,你应该能看到前端被正确提供并显示来自后端的博客文章。

现在我们已经创建了后端和前端的镜像和容器,我们将学习一种更轻松地管理多个镜像的方法。

使用 Docker Compose 管理多个镜像

Docker Compose 是一个工具,允许我们使用 Docker 定义和运行多容器应用程序。我们不需要手动构建和运行后端、前端和数据库容器,我们可以使用 Compose 一起构建和运行它们。要开始使用 Compose,我们需要在我们的项目根目录中创建一个 compose.yaml 文件,如下所示:

  1. 在我们项目的根目录下创建一个新的 compose.yaml 文件。

  2. 打开新创建的文件,首先定义 Docker Compose 文件规范的版本:

    version: '3.9'
    
    1. 现在,定义一个 services 对象,我们将在这里定义我们想要使用的所有服务:
    services:
    
    1. 首先,我们有 blog-database,它使用 mongo 镜像并转发端口 27017
      blog-database:
        image: mongo
        ports:
          - '27017:27017'
    

注意

在 YAML 文件中,行的缩进非常重要,用于区分属性嵌套的位置,因此请务必在每行之前正确放置空格。

  1. 接下来,我们有 blog-backend,它使用 backend/ 文件夹中定义的 Dockerfile,定义了 PORTDATABASE_URL 环境变量,转发端口到主机,并依赖于 blog-database

      blog-backend:
        build: backend/
        environment:
          - PORT=3001
          - DATABASE_URL=mongodb://host.docker.internal:27017/blog
        ports:
          - '3001:3001'
        depends_on:
          - blog-database
    
    1. 最后,我们有 blog-frontend,它使用根目录中定义的 Dockerfile,定义了 VITE_BACKEND_URL 构建参数,将端口转发到主机,并依赖于 blog-backend
      blog-frontend:
        build:
          context: .
          args:
            VITE_BACKEND_URL: http://localhost:3001/api/v1
        ports:
          - '3000:80'
        depends_on:
          - blog-backend
    
    1. 在定义服务后,保存文件。
  2. 然后,通过在终端中使用 Ctrl + C 键组合来停止运行的后端和前端容器。

  3. 还要停止已经运行的 dbserver 容器,如下所示:

    $ docker stop dbserver
    
    1. 最后,在终端中运行以下命令以使用 Docker Compose 启动所有服务:
    $ docker compose up
    

Docker Compose 将现在为数据库、后端和前端创建容器,并启动所有容器。您将开始看到来自不同服务的日志输出。如果您访问 http://localhost:3000,您可以看到前端正在运行。创建一个新的帖子来验证后端和数据库的连接是否正常工作。

下面的截图显示了 docker compose up 命令创建和启动所有容器的输出:

图 5.2 – 使用 Docker Compose 创建和运行多个容器

图 5.2 – 使用 Docker Compose 创建和运行多个容器

截图输出之后,是来自各种服务的日志消息,包括 MongoDB 数据库服务和我们的后端和前端服务。

就像往常一样,您可以按 Ctrl + C 来停止所有 Docker Compose 容器。

现在我们已经设置了 Docker Compose,一次性启动所有服务并统一管理它们变得非常容易。如果您查看您的 Docker 容器,您可能会注意到还有很多过时的容器仍然留在之前构建 blog-backendblog-frontend 容器时。现在让我们学习如何清理这些容器。

清理未使用的容器

在使用 Docker 进行了一段时间的实验后,将会有很多不再使用的镜像和容器。Docker 通常不会删除对象,除非您明确要求它这样做,这会导致它占用大量磁盘空间。如果您想删除对象,您可以选择逐个删除,或者使用 Docker 提供的 prune 命令之一:

  • docker container prune:这将删除所有已停止的容器

  • docker image prune:这将删除所有悬空镜像(未标记且未被任何容器引用的镜像)

  • docker image prune -a:这将删除所有未被任何容器使用的镜像

  • docker volume prune:这将删除所有未被任何容器使用的卷

  • docker network prune:这将清理所有未被任何容器使用的网络

  • docker system prune:这将删除除卷之外的所有内容

  • docker system prune --volumes:这将删除所有内容

因此,如果您想删除所有未使用的容器,您应该首先确保所有您还想使用的容器都在运行。然后,在终端中执行 docker container prune

现在我们已经学会了如何在本地使用 Docker 将我们的服务打包成镜像并在容器中运行,接下来让我们继续将我们的全栈应用程序部署到云端。

将我们的全栈应用程序部署到云端

在本地创建 Docker 镜像和容器之后,现在是时候学习如何将它们部署到云端,以便每个人都能访问我们的服务。在这本书中,我们将以 Google Cloud 为例,但一般的流程也适用于其他提供商,例如 Amazon Web ServicesAWS)和 Microsoft Azure。对于 MongoDB 数据库,我们将使用 MongoDB Atlas,但请随意使用任何可以为您托管 MongoDB 数据库的提供商。

创建 MongoDB Atlas 数据库

为了托管我们的数据库,我们将使用 MongoDB 团队提供的官方云解决方案,名为 MongoDB Atlas。现在让我们开始注册和设置数据库:

  1. 前往 www.mongodb.com/atlas 并按 免费试用 创建新账户,或使用现有账户登录。

注意

以下说明可能因 MongoDB Atlas UI 的更新而略有不同。如果选项与列表中列出的不完全一致,请尝试按照网站上的说明进行操作,以创建数据库和用户来访问它。这适用于我们将在本章中设置的所有云服务。

  1. 从侧边栏选择 数据库,然后按 创建 以创建一个新的数据库部署。如果您创建了新账户,您应该会自动被要求创建新的数据库部署。

  2. 在 Google Cloud 上选择 共享 / M0 沙盒(免费实例)和您首选的区域。

  3. 给您的集群起一个您喜欢的名字。

  4. 创建 以创建您的 M0 沙盒集群。数据库变得可访问需要一些时间(通常大约一分钟)。然而,您可以在等待集群设置的同时继续设置用户。

  5. 在侧边栏的 数据库 部分点击你新创建的集群旁边的 连接 按钮。

  6. 在弹出的窗口中,选择 允许从任何地方访问,然后按 添加 IP 地址

  7. 为您的数据库用户设置用户名和密码,然后按 创建 数据库用户

  8. 选择连接方法 并选择 驱动程序

  9. 将显示一个连接字符串;将其复制并保存以备后用,用您之前设置的密码替换 字符串。连接字符串应具有以下格式:

    mongodb+srv://<username>:<password>@<cluster-name>.<cluster-id>.mongodb.net/?retryWrites=true&w=majority
    
    1. 通过在终端中打开并使用 mongo 壳连接到它来验证连接字符串是否有效:
    $ mongosh "<connection-string>"
    

以下截图显示了 MongoDB Atlas 中的 数据库部署 选项卡的外观:

图 5.3 – 在 MongoDB Atlas 上部署的新 M0 沙盒数据库集群

图 5.3 – 在 MongoDB Atlas 上部署的新 M0 沙盒数据库集群

现在我们已经在云中成功创建了我们的 MongoDB 数据库,我们可以继续设置 Google Cloud 以部署我们的后端和前端。

在 Google Cloud 上创建一个账户

让我们从现在开始创建 Google Cloud 账户。在创建账户时,你需要输入账单信息,但你将获得 300 美元的免费信用额度,可以免费试用 Google Cloud:

  1. 在你的浏览器中访问cloud.google.com

  2. 如果你还没有账户,请点击免费开始,如果你已经有了账户,请点击登录

  3. 使用你的 Google 账户登录并按照指示操作,直到你能够访问 Google Cloud 控制台。

你现在应该会看到一个类似于以下图所示的屏幕:

图 5.4 – 注册后的 Google Cloud 控制台

图 5.4 – 注册后的 Google Cloud 控制台

现在你已经设置好账户并准备就绪,让我们开始部署我们的服务。

将我们的 Docker 镜像部署到 Docker 仓库

在我们可以在云服务提供商上部署服务之前,我们首先需要将我们的 Docker 镜像部署到一个Docker 仓库,以便云服务提供商可以从那里访问它并从中创建一个容器。按照以下步骤将我们的 Docker 镜像部署到 Docker Hub,官方 Docker 仓库:

  1. 访问hub.docker.com并登录或注册账户。

  2. 点击创建仓库按钮以创建一个新的仓库。该仓库将包含我们的镜像。

  3. 将仓库名称输入为blog-frontend,留空描述,并将可见性设置为公开。然后点击创建按钮。

  4. 重复步骤 2步骤 3,但这次,将blog-backend作为仓库名称输入。

  5. 打开一个新的终端并输入以下命令以登录到你的 Docker Hub 账户:

    $ docker login
    

    输入你的 Docker Hub 用户名和密码,然后按Return键或Enter键。

    1. 重新构建你的 Linux 镜像(以便稍后能够部署到 Google Cloud),使用你的仓库名称标记你的镜像(将[USERNAME]替换为你的 Docker Hub 用户名),并将其推送到仓库:
    $ docker build --platform linux/amd64 -t blog-frontend .
    $ docker tag blog-frontend [USERNAME]/blog-frontend
    $ docker push [USERNAME]/blog-frontend
    
    1. 在终端中导航到backend/,并为blog-backend镜像重复步骤 6
    $ cd backend/
    $ docker build --platform linux/amd64 -t blog-backend .
    $ docker tag blog-backend [USERNAME]/blog-backend
    $ docker push [USERNAME]/blog-backend
    

现在两个仓库都已设置好,镜像也已推送到它们,它们应该会在 Docker Hub 上显示以下信息:包含:镜像 | 最后推送:几秒钟前

图 5.5 – Docker Hub 展示我们的仓库概览

图 5.5 – Docker Hub 展示我们的仓库概览

现在我们已经将 Docker 镜像发布到公共 Docker 仓库(Docker Hub),我们可以继续设置 Google Cloud 以部署我们的服务。

注意

本书在 Docker Hub 上创建的仓库是公开的。您也可以选择在 Docker Hub 上免费创建最多一个私有仓库。否则,您可能需要拥有 Docker Hub 订阅,使用不同的注册表,或者托管自己的注册表。例如,可以使用Google Artifact RegistryCloud Run上部署私有 Docker 镜像。

将后端 Docker 镜像部署到 Cloud Run

在 Docker Hub 注册表成功发布我们的 Docker 镜像后,是时候使用 Google Cloud Run 来部署它们了。Cloud Run 是一个托管计算平台。它允许我们在 Google Cloud 基础设施上直接运行容器,使应用程序部署变得简单快捷。Cloud Run 的替代方案将是基于 Kubernetes 的基础设施,例如 AWS ECS Fargate 或 DigitalOcean。

按照以下步骤将后端部署到 Google Cloud Run:

  1. 前往console.cloud.google.com/

  2. 在顶部搜索栏中输入Cloud Run,并选择Cloud Run – 适用于容器化****应用程序产品。

  3. 点击创建服务按钮以创建新服务。

注意

在您能够创建服务之前,您可能需要首先创建一个项目。在这种情况下,只需按照网站上的说明创建一个您选择的名称的新项目。之后,点击创建服务按钮以创建新服务。

  1. 容器镜像URL 框中输入[USERNAME]****/blog-backend

  2. 服务名称框中输入blog-backend,选择您选择的区域,保留CPU 仅在请求处理期间分配选中,并选择所有 – 允许从互联网直接访问您的服务身份验证 – 允许****未经验证的调用

  3. 展开容器、网络、安全部分,滚动到环境变量,然后点击添加变量

  4. 将新环境变量命名为DATABASE_URL,作为值,输入您之前保存的 MongoDB Atlas 的连接字符串。

注意

为了简单起见,我们在这里使用常规环境变量。为了使包含凭证的变量更安全,应将其作为秘密添加,这需要启用Secrets API,将秘密添加到秘密管理器中,然后引用秘密并将其选择为要公开的环境变量。

  1. 将其余选项保留为默认选项,然后点击创建

  2. 您将被重定向到新创建的服务,其中容器正在部署。等待部署完成,这可能需要几分钟。

  3. 当服务完成部署后,您应该看到一个勾选标记和一个 URL。点击 URL 以打开后端,您将看到我们的Hello World from Express!消息,这意味着我们的后端已成功在云端部署!

在 Google Cloud Run 中部署的服务如下所示:

图 5.6 – 在 Google Cloud Run 上成功部署的服务

图 5.6 – 在 Google Cloud Run 上成功部署的服务

将前端 Docker 镜像部署到 Cloud Run

对于前端,我们首先需要重新构建容器以更改 VITE_BACKEND_URL 环境变量,该变量被静态构建到我们的项目中。让我们先做这个:

  1. 打开终端并运行以下命令以使用环境变量重新构建前端:

    [URL] with the URL to the backend service deployed on Google Cloud Run.
    
    1. 使用您的 Docker Hub 用户名标记它,并将新版本的镜像部署到 Docker Hub:
    $ docker tag blog-frontend [USERNAME]/blog-frontend
    $ docker push [USERNAME]/blog-frontend
    

现在,我们可以重复我们用于部署后端的类似步骤来部署我们的前端:

  1. 创建一个新的 Cloud Run 服务,在 容器镜像 URL 框中输入 [USERNAME]****/blog-frontend,在 服务名称 框中输入 blog-frontend

  2. 选择您喜欢的区域并启用 允许 未认证调用

  3. 展开 容器、网络、安全 并将容器端口从 8080 更改为 80

  4. 点击 创建 以创建服务,并等待其部署。

  5. 在您的浏览器中打开此 URL,您应该能看到已部署的前端。通过向部署的后端发送请求,现在也可以添加和列出博客文章,然后这些文章会存储在我们的 MongoDB Atlas 集群中。

我们已成功手动部署了我们的第一个全栈 React 和 Node.js 应用程序,其中包含云中的 MongoDB 数据库!在下一节中,我们将专注于使用 CI/CD 自动化测试和部署。

配置 CI 以自动化测试

持续集成 (CI) 涵盖了将代码更改自动集成以更快地发现错误并保持代码库易于维护的过程。通常,这是通过在代码合并到主分支之前,当开发者提交拉取/合并请求时自动运行脚本来实现的。这种做法使我们能够在代码合并之前通过例如运行代码检查器和测试来早期发现代码中的问题。因此,CI 使我们对代码更有信心,并允许我们更快、更频繁地做出和部署更改。

下图展示了可能的 CI/CD 管道的简单概述:

图 5.7 – CI/CD 管道简单概述

图 5.7 – CI/CD 管道简单概述

注意

在这本书中,我们将使用 GitHub Actions 进行 CI/CD。虽然语法和配置文件可能在其他系统上看起来和工作方式不同,例如 GitLab CI/CD 或 CircleCI,但基本原理是相似的。

在 GitHub Actions 中,workflows可以在仓库中发生事件时触发,例如向分支推送、打开新的拉取请求或创建新的问题。工作流程可以包含一个或多个jobs,这些作业可以并行或顺序执行。每个作业在其自己的runner内运行,该 runner 从 CI 定义中获取指令并在指定的容器内执行它们。在作业内,可以执行actions,这些 actions 可以是 GitHub 上提供的现有 actions,或者我们可以编写自己的 actions。

为前端添加持续集成

让我们开始创建一个工作流程,当创建拉取请求或向main分支推送时,它将构建前端:

  1. 在我们项目的根目录下创建一个新的.github/文件夹。在其内部,创建一个workflows/文件夹。

  2. .github/workflows/文件夹内,创建一个名为frontend-ci.yaml的新文件。

  3. 打开.github/workflows/frontend-ci.yaml文件,并首先给工作流程起一个名字:

    name: Blog Frontend CI
    
    1. 然后,使用on关键字监听事件。我们将在新拉取请求或向main分支推送时执行作业:
    on:
      push:
        branches:
          - main
      pull_request:
        branches:
          - main
    
    1. 现在,我们定义一个将运行代码检查器和构建前端的作业:
    jobs:
      lint-and-build:
    
    1. 我们在ubuntu-latest容器上运行作业:
        runs-on: ubuntu-latest
    
    1. 我们可以使用矩阵策略来使用不同的变量多次运行我们的测试。在我们的情况下,我们希望在多个 Node.js 版本上运行它:
        strategy:
          matrix:
            node-version: [16.x, 18.x, 20.x]
    
    1. 现在,我们在作业内定义步骤。确保stepsstrategy处于相同的缩进级别:
        steps:
    
    1. 首先,我们使用actions/checkout操作,它将检出我们的仓库:
          - uses: actions/checkout@v3
    
    1. 然后,我们使用actions/setup-node操作,它在我们的容器内设置 Node.js。在这里,我们使用之前定义的node-version变量:
          - name: Use Node.js ${{ matrix.node-version }}
            uses: actions/setup-node@v3
            with:
              node-version: ${{ matrix.node-version }}
              cache: 'npm'
    

    cache选项指定用于缓存依赖项的包管理器。

    1. 最后,我们安装依赖项,运行代码检查器并构建我们的前端:
          - name: Install dependencies
            run: npm install
          - name: Run linter on frontend
            run: npm run lint
          - name: Build frontend
            run: npm run build
    

为后端添加持续集成

现在我们已经为前端添加了持续集成,让我们也通过在创建拉取请求或向main分支推送时构建和测试后端来添加后端持续集成:

  1. .github/workflows/文件夹内,创建一个名为backend-ci.yaml的新文件。

  2. 打开.github/workflows/backend-ci.yaml文件,首先给它起一个名字,并监听与前端 CI 相同的事件:

    name: Blog Backend CI
    on:
      push:
        branches:
          - main
      pull_request:
        branches:
          - main
    
    1. 现在,我们定义一个将构建和测试后端的作业。我们将默认工作目录设置为backend/文件夹,以便在该文件夹内运行所有操作:
    jobs:
      lint-and-test:
        runs-on: ubuntu-latest
        strategy:
          matrix:
            node-version: [16.x, 18.x, 20.x]
        defaults:
          run:
            working-directory: ./backend
    
    1. 然后,我们使用与前端相同的操作来检出仓库并设置 Node.js:
        steps:
          - uses: actions/checkout@v3
          - name: Use Node.js ${{ matrix.node-version }}
            uses: actions/setup-node@v3
            with:
              node-version: ${{ matrix.node-version }}
              cache: 'npm'
          - name: Install dependencies
            run: npm install
    
    1. 最后,我们在后端运行代码检查器并运行测试:
          - name: Run linter on backend
            run: npm run lint
          - name: Run backend tests
            run: npm test
    
    1. 保存工作流程文件,并通过在 GitHub 上创建一个新的仓库并将现有仓库推送到 GitHub 来提交和推送它们。
  3. 前往 GitHub 上的仓库并选择 操作 选项卡。您应该在这里看到您的工作流程正在运行。

以下截图显示了我们的 CI 工作流程在 GitHub 上成功运行:

图 5.8 – 后端和前端 CI 工作流程在 GitHub Actions 中成功运行

图 5.8 – 后端和前端 CI 工作流程在 GitHub Actions 中成功运行

如果我们对 main 分支创建一个新的拉取请求,我们还可以看到我们的 CI 工作流程在新代码上运行正常。例如,如果我们为前端添加了标记帖子的方式,并且不小心在后台要求标记(而没有考虑我们之前只要求标题的规则),我们将看到相应的测试失败:

图 5.9 – 后端 CI 工作流程在拉取请求中失败

图 5.9 – 后端 CI 工作流程在拉取请求中失败

我们还可以看到 GitHub Actions 在其中一个版本失败后自动取消其他 Node.js 版本运行的作业,以避免浪费时间。

现在我们已经成功设置了 CI 工作流程,让我们继续设置 CD 以自动化我们的全栈应用程序的部署。

配置 CD 以自动化部署

在拉取/合并请求合并后,持续交付CD)开始发挥作用。CD 通过自动部署服务和应用程序为我们自动化发布过程。通常,这涉及一个多阶段过程,其中代码首先自动部署到预发布环境,然后可以手动部署到其他环境,直到生产环境。如果生产环境的部署也是一个自动化过程,则称为 持续部署 而不是持续交付。

首先,我们需要获取凭据以验证 Docker Hub 和 Google Cloud。然后,我们可以设置部署我们博客的工作流程。

获取 Docker Hub 凭据

让我们从获取访问 Docker Hub 的凭据开始:

  1. 前往 hub.docker.com/

  2. 点击您的个人资料并转到您的账户设置。

  3. 点击 安全 选项卡并按下 新建访问 令牌 按钮。

  4. 在描述中写 GitHub Actions 并按下 生成 按钮。给予 读取、写入、删除 权限。

  5. 复制访问令牌并将其存储在安全的地方。

  6. 前往您的 GitHub 仓库,然后转到 设置 | 密钥和变量 | 操作

  7. 按下 新建仓库密钥 按钮以添加新的密钥。作为名称,写 DOCKERHUB_USERNAME,并将 Docker Hub 上的用户名用作密钥值。

  8. 添加另一个名为 DOCKERHUB_TOKEN 的密钥,并将之前创建的访问令牌粘贴为密钥值。

获取 Google Cloud 凭据

现在,我们将创建一个服务账户以访问 Google Cloud Run:

  1. 前往 console.cloud.google.com/

  2. 在顶部的搜索框中输入服务帐户,然后转到IAM 和 admin – 服务 帐户页面。

  3. 点击创建服务帐户按钮。

  4. 服务帐户名称框中输入GitHub Actions。ID 应该自动生成为github-actions。点击创建 并继续

  5. 授予服务对Cloud Run 管理员角色的访问权限并点击继续

  6. 点击完成以完成创建服务帐户。

  7. 在概览列表中,复制你新创建的服务帐户的电子邮件并保存以备后用。

  8. 点击默认计算服务帐户的电子邮件地址。转到权限选项卡并点击授予访问权限

  9. 将你新创建的服务帐户的电子邮件粘贴到新主体字段中,并分配Cloud Run 服务代理角色。点击保存以确认。

  10. 在概览列表中,点击三个点图标以打开你的github-actions服务帐户上的操作,并选择管理密钥

  11. 在新页面上,点击添加密钥 | 创建新密钥,然后在弹出窗口中点击创建。应该会下载一个 JSON 文件。

  12. 前往你的 GitHub 仓库,然后转到设置 | 秘密和变量 | 操作。点击新建仓库秘密按钮以添加新的秘密。

  13. 在你的 GitHub 仓库中添加一个新的秘密,命名为GOOGLECLOUD_SERVICE_ACCOUNT,并将之前复制的你新创建的服务帐户的电子邮件作为秘密值粘贴。

  14. 在你的 GitHub 仓库中添加一个新的秘密,命名为GOOGLECLOUD_CREDENTIALS,并将下载的 JSON 文件内容粘贴为秘密。

  15. 在你的 GitHub 仓库中添加一个新的秘密,命名为GOOGLECLOUD_REGION,并将秘密值设置为创建 Cloud Run 服务时选择的区域。

注意

为了更好的安全性,Google 建议使用工作负载身份联合而不是导出服务帐户密钥 JSON 凭据。然而,设置工作负载身份联合要复杂一些。有关如何设置的更多信息,请参阅此处:github.com/google-github-actions/auth#setup

定义部署工作流程

现在凭据作为秘密值可用,我们可以开始定义部署工作流程:

  1. .github/workflows/文件夹内,创建一个名为cd.yaml的新文件。

  2. 打开.github/workflows/cd.yaml文件,首先给它起一个名字:

    name: Deploy Blog Application
    
    1. 对于 CD,我们只在推送main分支时执行工作流程:
    on:
      push:
        branches:
          - main
    
    1. 我们开始定义一个部署作业,其中我们将环境设置为生产,并将 URL 指向已部署的前端 URL:
    jobs:
      deploy:
        runs-on: ubuntu-latest
        environment:
          name: production
          url: ${{ steps.deploy-frontend.outputs.url }}
    

    我们将在稍后定义一个具有deploy-frontend ID 的步骤,该步骤在steps.deploy-frontend.outputs.url中存储一个变量。

    1. 对于步骤,就像我们之前做的那样,我们首先需要检出我们的仓库:
        steps:
          - uses: actions/checkout@v3
    
    1. 然后,我们使用之前在 secrets 中设置的凭据登录 Docker Hub:
          - name: Login to Docker Hub
            uses: docker/login-action@v2
            with:
              username: ${{ secrets.DOCKERHUB_USERNAME }}
              password: ${{ secrets.DOCKERHUB_TOKEN }}
    
    1. 接下来,我们使用之前设置的凭据登录到 Google Cloud:
          - uses: google-github-actions/auth@v1
            with:
              service_account: ${{ secrets.GOOGLECLOUD_SERVICE_ACCOUNT }}
              credentials_json: ${{ secrets.GOOGLECLOUD_CREDENTIALS }}
    
    1. 现在,我们使用docker/build-push-action构建并推送后端 Docker 镜像到 Docker 仓库:
          - name: Build and push backend image
            uses: docker/build-push-action@v4
            with:
              context: ./backend
              file: ./backend/Dockerfile
              push: true
              tags: ${{ secrets.DOCKERHUB_USERNAME }}/blog-backend:latest
    
    1. 在推送后端 Docker 镜像之后,我们现在可以使用google-github-actions/deploy-cloudrun操作在 Cloud Run 上部署它:
          - id: deploy-backend
            name: Deploy backend
            uses: google-github-actions/deploy-cloudrun@v1
            with:
              service: blog-backend
              image: ${{ secrets.DOCKERHUB_USERNAME }}/blog-backend:latest
              region: ${{ secrets.GOOGLECLOUD_REGION }}
    

    我们给这个步骤分配了deploy-backend ID,因为我们需要使用它来引用后端 URL,以便在下一步构建前端镜像。

    1. 在构建和部署后端之后,我们以类似的方式构建前端,确保将VITE_BACKEND_URL作为build-args传递:
          - name: Build and push frontend image
            uses: docker/build-push-action@v4
            with:
              context: .
              file: ./Dockerfile
              push: true
              tags: ${{ secrets.DOCKERHUB_USERNAME }}/blog-frontend:latest
              build-args: VITE_BACKEND_URL=${{ steps.deploy-backend.outputs.url }}/api/v1
    
    1. 最后,我们可以部署前端,给这个步骤分配deploy-frontend ID,以便正确设置我们的环境 URL:
          - id: deploy-frontend
            name: Deploy frontend
            uses: google-github-actions/deploy-cloudrun@v1
            with:
              service: blog-frontend
              image: ${{ secrets.DOCKERHUB_USERNAME }}/blog-frontend:latest
              region: ${{ secrets.GOOGLECLOUD_REGION }}
    
    1. 保存文件并提交您的更改到main分支。您将在 GitHub Actions 上看到部署博客应用程序被触发。

以下截图显示了我们的博客应用程序通过 GitHub Actions 成功部署的结果:

图 5.10 – 使用 GitHub Actions 成功部署我们的全栈应用程序

图 5.10 – 使用 GitHub Actions 成功部署我们的全栈应用程序

您可以点击 URL 打开已部署的前端,您会看到它以与手动部署版本相同的方式工作。

恭喜!您已成功自动化了您的第一个全栈应用程序的集成和部署!

注意

在这本书中,我们只创建了一个阶段的部署,自动直接部署到生产环境。在实际应用中,您可能希望定义多个阶段。例如,CD 可以自动部署到测试环境。然后,将生产部署配置为需要手动确认。

摘要

在本章中,我们首先学习了如何创建 Docker 镜像以及如何从它们实例化本地容器。然后,我们通过使用 Docker Compose 自动化了这个过程。接下来,我们在 Docker Hub 注册表中发布了我们的镜像,以便能够在 Google Cloud Run 上部署它们。然后,我们手动在 Cloud Run 上部署了我们的全栈应用程序。最后,我们学习了如何使用 GitHub Actions 设置 CI/CD 工作流程来自动运行 lint、测试和部署博客应用程序。

到目前为止,我们应用程序中的所有内容都是公开可访问的。由于没有用户管理,任何人都可以像任何作者一样创建帖子。在下一章,第六章使用 JWT 添加身份验证中,我们将学习如何在我们的全栈博客应用程序中实现用户账户和身份验证。我们将学习JSON Web TokensJWTs)是什么,并实现多个登录和注册的路由。


第三部分:练习全栈 Web 应用程序的开发

在本部分,我们将更深入地探讨全栈 Web 开发。我们将首先通过使用JSON Web Tokens为我们的应用程序添加身份验证。然后,我们将学习如何使用服务器端渲染来提高加载时间。接下来,我们将学习如何为搜索引擎优化应用程序。我们还将使用Playwright实现端到端测试,以确保我们的应用程序保持稳健。然后,我们将学习如何收集事件,使用MongoDB聚合数据,并使用Victory创建统计图表来可视化聚合数据。在本部分的最后,我们将学习如何使用GraphQL API构建后端,以及如何使用Apollo Client在前端与 GraphQL 交互。

本部分包括以下章节:

  • 第六章, 使用 JWT 添加身份验证

  • 第七章, 使用服务器端渲染提高加载时间

  • 第八章, 确保客户通过搜索引擎找到您——搜索引擎优化

  • 第九章, 使用 Playwright 实现端到端测试

  • 第十章, 使用 MongoDB 和 Victory 聚合和可视化统计数据

  • 第十一章, 使用 GraphQL API 构建后端

  • 第十二章, 使用 Apollo Client 在前端与 GraphQL 交互

第六章:使用 JWT 添加认证

在开发和部署我们的第一个全栈应用程序后,我们现在有一种方式让任何人都可以在我们的博客上创建帖子。然而,由于作者是一个输入字段,任何人都可以输入任何作者,冒充他人!这不好。在本章中,我们将添加使用JSON Web Token(JWT)的认证以及通过添加额外的路由使用 React Router 来注册和登录我们的应用程序的功能。

在本章中,我们将涵盖以下主要主题:

  • JWT 是什么?

  • 在后端使用 JWT 实现登录、注册和认证路由

  • 使用 React Router 和 JWT 在前端集成登录和注册

  • 高级令牌处理

技术要求

在我们开始之前,请安装第一章“为全栈开发做准备”和第二章“了解 Node.js 和 MongoDB”中提到的所有要求。

那些章节中列出的版本是本书中使用的版本。虽然安装较新版本不应有问题,但请注意,某些步骤可能会有所不同。如果您在这本书提供的代码和步骤中遇到问题,请尝试使用第一章第二章中提到的版本。

您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch6

本章的 CiA 视频可以在youtu.be/LloHmkgRLWk找到。

JWT 是什么?

JWT,发音为“jot”,是一个开放行业标准(RFC 7519),用于在多个当事人之间安全地传递声明。声明可以是关于某个当事人或对象的信息,例如用户的电子邮件地址、用户 ID 和角色。在我们的案例中,我们将在后端和前端之间传递 JWT。

JWT 被许多产品和服务使用,并得到第三方认证提供商的支持,如 Auth0、Okta 和 Firebase Auth。解析 JWT 很容易,我们只需要对它们进行 base64 解码并解析 JSON 字符串。在验证签名后,我们可以确信 JWT 是真实的,并信任其中的声明。

JWT 由以下组件组成:

  • 头部:包含算法和令牌类型

  • 有效载荷:包含令牌的数据/声明

  • 签名:用于验证令牌是否由合法来源创建

这三个组件通过点(.)连接成一个字符串,形成一个 JWT,如下所示:

header.payload.signature

让我们分别查看每个组件。

JWT 头

JWT 头部通常由一个令牌类型(在我们的情况下,JWT),由typ属性指定,以及用于创建签名的算法(在我们的情况下,我们将使用基于 SHA256 哈希的消息认证码 HMAC SHA256),由alg属性指定。头部被定义为 JSON 对象,如下所示:

{
  "alg": "HS256",
  "typ": "JWT"
}

此 JSON 对象随后被 base64 编码,形成 JWT 的第一部分。

JWT 有效载荷

JWT 的主要部分是有效载荷,它包含所有声明。声明是关于实体的信息(例如用户)和附加数据。JWT 标准区分三种类型的声明:

  • 已注册声明:这些是预定义的声明,建议设置它们。它们包括以下信息:

    • 发行者(iss),即创建令牌的实体。

    • 过期时间(exp),它告诉我们令牌何时过期。

    • 主题(sub),它告诉我们由令牌标识的实体信息(例如,在登录过程中生成令牌的用户)。

    • 受众(aud),它告诉我们令牌的预期接收者。

    • 发布时间(iat),它告诉我们令牌何时创建。

    • 不早于时间(nbf),它指定一个时间点,在此时间点之前令牌尚未有效。

    • JWT ID(jti),它为 JWT 提供唯一标识符。它用于防止 JWT 被重放。

注意

JWT 标准中定义的 JSON 对象属性都是三字母名称,以使 JWT 尽可能紧凑。

  • 公共声明:这些是常用且在许多服务中共享的额外声明。这些声明的列表可以在互联网数字分配机构IANA)网站上找到:www.iana.org/assignments/jwt/jwt.xhtml。如果我们想存储额外的信息,我们应该首先咨询这个列表,看看是否可以使用标准化的声明名称。

  • 私有声明:这些是自定义定义的声明,既不是已注册的也不是公共的。如果我们需要一个尚未定义的特殊声明,我们可以创建一个只有我们的服务才能理解的私有声明。

所有声明都是可选的,但至少包括一个声明来识别主题是有意义的,例如sub已注册声明。

将我们所学的知识整合起来,我们可以创建以下示例有效载荷:

{
  "sub": "1234567890",
  "name": "Daniel Bugl",
  "admin": true
}

在我们的示例中,sub声明是一个已注册的声明,name声明是一个公共声明,而admin声明是一个私有声明。

有效载荷也被 base64 编码,形成 JWT 的第二部分。因此,这些信息对任何有权访问令牌的人都是公开可读的。不要将机密信息放入 JWT 的有效载荷或头部!然而,信息不能被更改而不使现有签名无效,使所有声明防篡改。只有具有访问私钥的后端服务才能生成新的签名以创建有效的 JWT。

JWT 签名

JWT 的最后部分是其签名。签名证明了我们之前定义的所有信息都没有被篡改。签名是通过将 base64 编码的头部和负载合并,用句号连接这些字符串,并使用指定的算法用密钥签名来创建的:

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

现在我们已经了解了 JWT 的不同组件,让我们将这些内容全部放在一起来创建一个有效的 JWT。

创建 JWT

按照以下步骤创建一个 JWT:

  1. 前往jwt.io/网站,并滚动到调试器部分。

  2. 输入我们之前定义的头部和负载。

  3. 全栈作为密钥。

  4. 编码的 JWT 应该在你更改值时实时更新。

如您所见,我们已经成功创建了第一个 JWT:

图 6.1 – 使用 jwt.io 调试器创建的第一个 JWT

图 6.1 – 使用 jwt.io 调试器创建的第一个 JWT

生成的 JWT 由三个组件组成,每个组件都是 base64 编码的,并用句号分隔。在调试器中,它们用三种不同的颜色突出显示。尝试通过从“编码”部分的 base64 字符串中删除一些字符来更改它;你会看到 JWT 现在因“无效签名”问题而无效。现在我们已经创建了第一个 JWT,让我们学习如何使用它。

使用 JWT

在登录过程中,我们将在后端为登录用户生成一个 JWT。这个 JWT 将被返回给用户的浏览器。当用户想要访问受保护的路由时,我们可以通过使用带有Bearer模式的Authorization头部将 JWT 发送到后端服务器,如下所示:

Authorization: Bearer <token>

后端可以检查这个头部,验证令牌的签名,并授予用户访问某些路由的权限。通过在头部而不是 cookie 中发送令牌,我们不必处理处理 cookie 时可能遇到的 CORS 问题。

注意

注意不要在头部发送太多数据,因为一些服务器不接受超过 8 KB 的头部。这意味着,例如,复杂的角色信息不应存储在 JWT 声明中,因为它可能会占用太多空间。相反,这类信息可以存储在与 JWT 中的用户 ID 关联的数据库中。

使用 JWT 的一个有趣的优势是,认证服务器和我们的应用的实际后端不必是同一个。我们可以有一个独立的认证服务,获取一个 JWT,然后在后端验证 JWT 的签名,以确保它们是由认证服务生成的。这允许我们使用外部服务进行认证,例如 Auth0、Okta 或 Firebase Auth。

以下图表显示了 JWT 的授权流程:

图 6.2 – JWT 的授权流程

图 6.2 – JWT 的授权流程

如我们所见,应用程序请求授权服务器进行授权,这也可以是第三方提供者、独立服务或后端服务的一部分。然后,当授权被授予(如果登录详情正确),授权服务器返回一个 JWT。然后可以使用这个 JWT 来访问 API 上的受保护路线。在授予访问权限之前,JWT 签名被验证以确保它没有被篡改。

存储 JWT

我们应该非常小心地考虑我们存储 JWT 的位置。本地存储并不是存储如 JWT 之类的认证信息的好方法。跨站脚本可以用来窃取本地存储中的所有数据。对于短期令牌,我们可以将它们存储在 JavaScript 运行时变量(如 React 上下文)中。对于长期存储,我们可以使用具有额外安全保证的httpOnlycookie。

现在我们已经了解了 JWT 的工作原理,让我们将理论付诸实践,在后端使用 JWT 实现登录、注册和认证路线。

使用 JWT 在后端实现登录、注册和认证路线

现在我们已经了解了 JWT,我们将在后端实现它们。首先,我们需要在数据库中创建一个用户模型,然后我们可以创建注册和登录应用的路线。最后,我们将实现需要 JWT 才能访问的认证路线。

创建用户模型

我们将开始后端实现,创建一个用户模型,如下所示:

  1. ch5文件夹复制到一个新的ch6文件夹,如下所示:

    $ cp -R ch5 ch6
    
  2. 在 VS Code 中打开ch6文件夹。

  3. 创建一个新的backend/src/db/models/user.js文件,并在其中定义一个新的userSchema

    import mongoose, { Schema } from 'mongoose'
    const userSchema = new Schema({
    
  4. 用户应该有一个必需的唯一用户名和一个必需的密码

      username: { type: String, required: true, unique: true },
      password: { type: String, required: true },
    })
    
  5. 创建并导出模型:

    export const User = mongoose.model('user', userSchema)
    
  6. 在这一点上,让我们也调整一下帖子模型,以便我们可以存储一个用户 ID 的引用而不是作者的用户名。编辑backend/src/db/models/post.js,如下所示:

        author: ObjectId, with a reference to the user model, and made author required (as you will need to be logged in to create a post after we add an authenticated route later in this chapter).Making `author` required means that the unit tests will need to be adjusted, but doing so is left as an exercise for you.
    

现在我们已经成功创建了用户模型,让我们继续创建注册服务,以便我们有一种创建新用户的方法。

创建注册服务

当用户注册时,我们需要在将其存储在数据库之前对用户提供的密码进行散列。我们绝不应该以明文形式存储密码,因为这意味着如果我们的数据库泄露,攻击者将能够访问所有用户的密码。散列是一个单向函数,以确定的方式将字符串转换为不同的字符串。这意味着,例如,如果我们执行hash("password1"),每次执行都会得到一个特定的字符串。然而,如果我们执行hash("password2"),我们会得到一个完全不同的字符串。通过选择一个好的散列函数,我们可以确保逆向散列的计算成本如此之高,以至于在合理的时间内无法完成。当用户注册时,我们可以存储他们密码的散列。当用户输入密码登录时,我们可以再次散列他们输入的密码,并将其与数据库中的散列进行比较。

让我们开始实现带有散列密码的注册服务:

  1. 安装bcryptnpm 包。我们将使用它来在存储之前散列密码:

    $ cd backend
    $ npm install bcrypt@5.1.1
    
  2. 创建一个新的backend/src/services/users.js文件并导入bcryptUser模型:

    import bcrypt from 'bcrypt'
    import { User } from '../db/models/user.js'
    
  3. 定义一个createUser函数,它接受usernamepassword值:

    export async function createUser({ username, password }) {
    
  4. 在这个函数内部,我们使用bcrypt.hash函数通过 10 轮盐值(重复 10 次散列以使其更难逆向)从明文密码创建散列:

      const hashedPassword = await bcrypt.hash(password, 10)
    
  5. 现在,我们可以创建一个新的用户并将其存储在我们的数据库中:

      const user = new User({ username, password: hashedPassword })
      return await user.save()
    }
    

为了简洁,我们不会涵盖为用户服务创建测试。有关如何为您的服务函数创建测试的信息,请参阅第三章使用 Express、Mongoose ODM 和 Jest 实现后端服务。您可以编写与我们为帖子服务函数所做的类似的测试。

在创建注册服务之后,我们可以创建注册路由。

创建注册路由

现在,让我们通过添加一个 API 路由来暴露注册服务功能:

  1. 创建一个新的backend/src/routes/users.js文件并导入createUser服务:

    import { createUser } from '../services/users.js'
    
  2. 定义一个新的userRoutes函数并暴露一个POST /api/v1/user/signup路由。此路由从请求体中创建一个新的用户并返回用户名:

    export function userRoutes(app) {
      app.post('/api/v1/user/signup', async (req, res) => {
        try {
          const user = await createUser(req.body)
          return res.status(201).json({ username: user.username })
        } catch (err) {
          return res.status(400).json({
            error: 'failed to create the user, does the username already exist?'
          })
        }
      })
    }
    

    在这种情况下,我们定义了一个单独的user路由而不是users,因为我们一次只处理一个用户。为了保持简单,错误处理非常基础。区分可能发生的不同错误并显示不同的错误消息将是一个好主意。

  3. 编辑backend/src/app.js并导入userRoutes函数:

    import { postRoutes } from './routes/posts.js'
    import { userRoutes } from './routes/users.js'
    
  4. 在同一文件中,在postRoutes函数之后调用userRoutes函数以挂载它们:

    postRoutes(app)
    userRoutes(app)
    
  5. 确保 Docker 中的dbserver容器正在运行。

  6. backend/文件夹内的终端中运行以下命令以启动后端:

    $ cd backend 
    $ npm run dev
    
  7. 现在,向新的POST /api/v1/user/signup路由发送请求。您将看到,如果提供了正确的usernamepassword值,创建用户是有效的。在后台运行时,在空白标签页或http://localhost:3001/中输入以下代码:

    const res = await fetch('http://localhost:3001/api/v1/user/signup', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username: 'dan', password: 'hunter2' })
    })
    console.log(await res.json())
    
  8. 如果我们尝试使用相同的用户名(通过再次执行相同的 fetch)创建另一个用户,它将失败,因为username字段在 Mongoose 中被定义为唯一的。

现在我们已经成功创建了第一个用户,让我们继续创建登录服务,以便我们的用户能够登录。

创建登录服务

到目前为止,我们只在数据库中创建了一个用户。因为我们还没有对用户进行授权,所以我们还没有处理 JWTs。现在让我们开始处理:

  1. 打开一个新的终端并安装jsonwebtoken库,它包含处理 JWT 创建和验证的函数:

    $ cd backend
    $ npm install jsonwebtoken@9.0.2
    
  2. 编辑backend/src/services/users.js文件并从jsonwebtoken库中导入jwt

    import jwt from 'jsonwebtoken'
    
  3. 定义一个新的loginUser函数,它接受用户名和密码:

    export async function loginUser({ username, password }) {
    
  4. 现在,从我们的数据库中获取具有给定username的用户:

      const user = await User.findOne({ username })
      if (!user) {
        throw new Error('invalid username!')
      }
    
  5. 然后,使用bcrypt.compare比较输入的密码与数据库中的散列密码:

      const isPasswordCorrect = await bcrypt.compare(password, user.password)
      if (!isPasswordCorrect) {
        throw new Error('invalid password!')
      }
    
  6. 如果用户正确输入用户名和密码,我们使用jwt.sign()创建一个新的 JWT 并用一个密钥签名。对于密钥,我们使用一个环境变量:

      const token = jwt.sign({ sub: user._id }, process.env.JWT_SECRET, {
        expiresIn: '24h',
      })
    

    在最后一个参数中,我们还指定我们的令牌应该有效期为 24 小时。

注意

我们使用用户 ID 而不是用户名来识别用户。这样做是为了使系统具有未来性,因为用户 ID 是一个永远不会改变的值。将来,我们可能想添加一个更改用户名的方法。如果我们总是使用用户名来识别用户,处理这样的更改将很困难。

  1. 最后,我们返回令牌:

      return token
    }
    
  2. 现在,通过编辑.****env文件来定义JWT_SECRET环境变量:

    JWT_SECRET=replace-with-random-secret
    

    确保您为生产环境生成一个安全的 JWT 密钥,您永远不会在开发环境中或用于调试时暴露或使用它!如果您想再次将您的应用程序部署到 Google Cloud Run,您还需要将此密钥作为环境变量添加到那里。

  3. 我们还会在.env.template中添加一个示例:

    JWT_SECRET=replace-with-random-secret
    

在成功创建用于创建和签名 JWTs 的登录服务之后,我们可以创建登录路由。

创建登录路由

我们仍然需要将登录服务作为 API 路由公开,以便用户能够登录。现在让我们来做这件事:

  1. 编辑backend/src/routes/users.js文件并导入loginUser函数:

    import { createUser, loginUser } from '../services/users.js'
    
  2. userRoutes函数内部添加一个新的POST /api/v1/user/login路由,其中我们调用loginUser函数并返回令牌:

      app.post('/api/v1/user/login', async (req, res) => {
        try {
          const token = await loginUser(req.body)
          return res.status(200).send({ token })
        } catch (err) {
          return res.status(400).send({
            error: 'login failed, did you enter the correct username/password?'
          })
        }
      })
    
  3. 如果后端不再运行,请重新启动它。然后,通过在您的浏览器控制台中输入以下代码来请求/api/v1/user/login以测试它:

    const res = await fetch('http://localhost:3001/api/v1/user/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username: 'dan', password: 'hunter2' })
    })
    console.log(await res.json())
    
  4. 我们已经成功创建了一个有效的 JWT!为了验证 JWT 是否有效,我们可以将其粘贴到jwt.io/的调试器中。确保您还更改了页面上的Verify Signature部分中的密钥,如下面的截图所示:

图 6.3 – 验证从登录服务创建的 JWT

图 6.3 – 验证从登录服务创建的 JWT

注意

当从浏览器中的 JSON 响应中复制令牌时,确保您复制的是完整的字符串值,而不是截断的值(字符串中间有)。否则,JWT 可能在调试器中无法正确解码。

在成功登录用户并为他们创建令牌之后,我们现在可以保护某些路由并确保只有登录用户可以访问它们。

定义认证路由

现在我们已经成功创建了一个有效的 JWT,我们可以开始保护路由。为此,我们将使用express-jwt库,如下所示:

  1. 安装express-jwtnpm 包:

    $ cd backend
    $ npm install express-jwt@8.4.1
    
  2. backend/src/middleware文件夹中创建一个新的文件夹。在其内部,创建一个新的backend/src/middleware/jwt.js文件,并在其中导入expressjwt

    import { expressjwt } from 'express-jwt'
    
  3. 使用expressjwt函数和您的密钥和算法设置创建并导出requireAuth中间件:

    export const requireAuth = expressjwt({
      secret: () => process.env.JWT_SECRET,
      algorithms: ['HS256'],
    })
    

    我们需要使用一个函数来处理密钥,因为dotenv在导入时还没有初始化,所以环境变量将只能在之后才可用。指定算法是必需的,以防止潜在的降级攻击。

  4. 编辑backend/src/routes/posts.js并导入requireAuth中间件:

    import { requireAuth } from '../middleware/jwt.js'
    
  5. 将中间件添加到创建路由中。在 Express 中,可以通过将中间件作为函数的第二个参数传递来将其添加到特定路由,如下所示:

      app.post('/api/v1/posts', requireAuth, async (req, res) => {
    
  6. 对于编辑路由重复相同的操作:

      app.patch('/api/v1/posts/:id', requireAuth, async (req, res) => {
    
  7. 现在,对删除路由也这样做:

      app.delete('/api/v1/posts/:id', requireAuth, async (req, res) => {
    
  8. 尝试在不登录的情况下访问路由。您将看到它们以401 Unauthorized状态失败。将以下代码执行到您的浏览器控制台:

    const res = await fetch('http://localhost:3001/api/v1/posts', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({ title: 'Test Post' })
    })
    console.log(await res.json())
    

    您可以在以下屏幕截图中看到执行代码的结果:

图 6.4 – 尝试在没有 JWT 的情况下访问受保护的路由,然后使用 JWT

图 6.4 – 尝试在没有 JWT 的情况下访问受保护的路由,然后使用 JWT

注意

除了使用express-jwt库之外,我们还可以手动从Authorization头中提取令牌,并使用jsonwebtoken库中的jwt.verify函数来验证它。

路由现在已被保护,但我们还没有考虑哪个用户访问了它们。现在让我们通过从令牌中访问当前登录用户来实现这一点。

访问当前登录用户

在添加了认证路由之后,我们成功保护了一些路由,使得只有登录用户才能访问。然而,仍然可以编辑其他用户的帖子或以不同的用户名创建帖子。让我们来改变这一点:

  1. 编辑backend/src/services/posts.js文件,并在createPost函数中添加userId参数,从对象中删除author

    export async function createPost(userId, { title, author, contents, tags }) {
    
  2. 我们将不再通过请求体设置作者,而是将作者设置为登录用户的 ID:

      const post = new Post({ title, author: userId, contents, tags })
    
  3. 我们以类似的方式调整updatePostdeletePost函数(添加userId参数,删除author参数,并从$set对象中删除作者变量),确保当前登录用户是帖子的作者:

    export async function updatePost(userId, postId, { title, author, contents, tags }) {
      return await Post.findOneAndUpdate(
        { _id: postId, author: userId },
        { $set: { title, author, contents, tags } },
        { new: true },
      )
    }
    export async function deletePost(userId, postId) {
      return await Post.deleteOne({ _id: postId, author: userId })
    }
    

    在我们的案例中,我们简单地通过给定的 ID 和当前用户作为作者来获取帖子。我们仍然可以扩展此代码,首先获取具有给定 ID 的帖子,检查它是否存在(如果不存在,则返回404 Not Found错误),如果存在,则验证作者是否是当前登录用户(如果不是,则返回403 Forbidden错误)。

注意

这是一个破坏性的 API 更改,需要更改测试。为了简洁,我们不会一步一步地调整测试,所以这留作您的练习。

  1. 编辑backend/src/routes/posts.js文件,并使用req.auth.sub变量将用户 ID 传递给createPost函数:

        const post = await createPost(req.auth.sub, req.body)
    
  2. updatePost函数也进行同样的操作:

        const post = await updatePost(req.auth.sub, req.params.id, req.body)
    
  3. 此外,还需要对deletePost函数进行同样的操作:

        const { deletedCount } = await deletePost(req.auth.sub, req.params.id)
    
  4. 尝试创建一个新的帖子;您将看到它是通过 JWT 中标识的用户创建的。您可以通过在浏览器控制台中执行以下代码来完成此操作(不要忘记将替换为您之前生成的 JWT):

    const res = await fetch('http://localhost:3001/api/v1/posts', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Authorization': 'Bearer <TOKEN>'
        },
        body: JSON.stringify({ title: 'Test Post' })
    })
    console.log(await res.json())
    

    编辑和删除您的帖子也是可能的,但不再适用于其他用户的帖子!

信息

express-jwt中间件将 JWT 中解码的所有声明存储在req.auth对象中。因此,我们可以在这里访问创建 JWT 时做出的任何声明。当然,中间件首先验证 JWT 签名与定义的密钥是否匹配,以确保它收到了一个真实的 JWT。

现在我们已经设置了登录、注册和认证路由,让我们继续在前端集成登录和注册。

使用 React Router 和 JWT 在前端集成登录和注册

现在我们已经在后端成功实现了授权,让我们开始扩展前端,添加注册和登录页面,并将它们连接到后端。首先,我们将学习如何在 React 应用程序中使用 React Router 实现多个页面。然后,我们将实现注册 UI 并将其连接到后端。之后,我们将实现登录 UI,在前端存储令牌,并在成功登录时设置自动重定向。最后,我们将更新创建帖子的代码,在授权头中传递令牌,并正确访问我们的认证路由。

让我们从设置 React Router 开始,进行前端集成。

使用 React Router 实现多个路由

React Router 是一个库,它允许我们通过在多个不同路由上定义多个页面来管理我们的应用路由,就像我们在 Express 中为 API 路由所做的那样,但这是针对前端!让我们设置 React Router:

  1. 在前端项目中安装 react-router-dom 库(在 ch6 文件夹的根目录,而不是在 backend 文件夹内):

    $ npm install react-router-dom@6.21.0
    
  2. 编辑 src/App.jsx 并导入 createBrowserRouter 函数和 RouterProvider 组件:

    import { createBrowserRouter, RouterProvider } from 'react-router-dom'
    
  3. 创建一个新的 router 并定义路由。首先,我们将定义一个索引路由来渲染我们的 Blog 组件:

    const router = createBrowserRouter([
      {
        path: '/',
        element: <Blog />,
      },
    ])
    
  4. 然后,在 App 组件中,将 组件替换为 ,如下所示:

    export function App() {
      return (
        <QueryClientProvider client={queryClient}>
          <RouterProvider router={router} />
        </QueryClientProvider>
      )
    }
    
  5. ch6 文件夹的根目录下运行以下命令以启动前端:

    $ npm run dev
    
  6. 博客应该以与之前相同的方式渲染,但现在,我们可以开始定义新的路由!您可以通过访问我们未定义的页面来验证 React Router 是否工作 – 例如,http://localhost:5173/test。React Router 将显示默认的 404 页面,如下面的截图所示:

图 6.5 – React Router 提供的默认 404 页面

图 6.5 – React Router 提供的默认 404 页面

现在我们已经成功设置了 React Router,我们可以继续创建注册页面。

创建注册页面

我们将首先更新我们的文件夹结构,以便它支持多个页面。然后,我们将实现一个 Signup 组件并定义一个指向它的 /signup 路由。按照以下步骤操作:

  1. 创建一个新的 src/pages/ 文件夹。

  2. src/Blog.jsx 文件移动到 src/pages/ 文件夹中。当 VS Code 询问您更新所有导入时,选择 。或者,按照以下方式在 src/App.jsx 中更新导入:

    import { Blog } from './pages/Blog.jsx'
    
  3. 创建一个新的 src/api/users.js 文件并定义一个针对 注册 路由的 API 函数,如下所示:

    export const signup = async ({ username, password }) => {
      const res = await fetch(`${import.meta.env.VITE_BACKEND_URL}/user/signup`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username, password }),
      })
      if (!res.ok) throw new Error('failed to sign up')
      return await res.json()
    }
    

    我们在这里检查 res.ok,当响应状态码是错误码时,例如 400,它将是 false

  4. 创建一个新的 src/pages/Signup.jsx 文件,从 react-router-dom 中导入 useStateuseMutationuseNavigate 钩子,以及 signup 函数,并在其中定义一个 Signup 组件:

    import { useState } from 'react'
    import { useMutation } from '@tanstack/react-query'
    import { useNavigate } from 'react-router-dom'
    import { signup } from '../api/users.js'
    export function Signup() {
    
  5. 在这个组件中,我们首先为 用户名密码 字段创建状态钩子:

      const [username, setUsername] = useState('')
      const [password, setPassword] = useState('')
    
  6. 然后,我们使用 useNavigate 钩子获取一个函数来导航到不同的路由:

      const navigate = useNavigate()
    
  7. 我们还定义了一个 useMutation 钩子来发送 注册 请求。在成功后,我们将导航到 /login 路由,我们将在稍后定义它:

      const signupMutation = useMutation({
        mutationFn: () => signup({ username, password }),
        onSuccess: () => navigate('/login'),
        onError: () => alert('failed to sign up!'),
      })
    

    如果发生错误,我们也可以使用 signupMutation.isError 状态和后端响应来显示一个格式更漂亮的错误消息。

  8. 然后,我们定义一个处理表单提交的函数,就像我们为 CreatePost 组件所做的那样:

      const handleSubmit = (e) => {
        e.preventDefault()
        signupMutation.mutate()
      }
    
  9. 现在,我们创建一个简单的表单来输入用户名、密码和一个提交请求的按钮,类似于 CreatePost 组件:

      return (
        <form onSubmit={handleSubmit}>
          <div>
            <label htmlFor='create-username'>Username: </label>
            <input
              type='text'
              name='create-username'
              id='create-username'
              value={username}
              onChange={(e) => setUsername(e.target.value)}
            />
          </div>
          <br />
          <div>
            <label htmlFor='create-password'>Password: </label>
            <input
              type='password'
              name='create-password'
              id='create-password'
              value={password}
              onChange={(e) => setPassword(e.target.value)}
            />
          </div>
          <br />
          <input
            type='submit'
            value={signupMutation.isPending ? 'Signing up...' : 'Sign Up'}
            disabled={!username || !password || signupMutation.isPending}
          />
        </form>
      )
    }
    
  10. 编辑 src/App.jsx 并导入 Signup 页面组件:

    import { Signup } from './pages/Signup.jsx'
    
  11. 添加一个新的/signup路由,指向Signup页面组件:

    const router = createBrowserRouter([
      {
        path: '/',
        element: <Blog />,
      },
      {
        path: '/signup',
        element: <Signup />,
      },
    ])
    

在定义注册页面之后,我们仍然需要一种链接到它的方法。现在让我们添加这个链接。

现在我们博客应用中有多个页面,我们需要在它们之间建立链接。为此,我们可以使用 React Router 提供的Link组件。我们也可以使用普通的<a href="">链接,但那样会导致整个页面刷新。Link组件使用客户端路由,因此避免了页面的完全刷新。相反,它立即在客户端渲染新的组件。

按照以下步骤从首页创建到注册页面的链接:

  1. 创建一个新的src/components/Header.jsx文件,并从react-router-dom导入Link组件:

    import { Link } from 'react-router-dom'
    
  2. 定义一个组件并返回Link组件以定义到注册路由的链接,如下所示:

    export function Header() {
      return (
        <div>
          <Link to='/signup'>Sign Up</Link>
        </div>
      )
    }
    
  3. 编辑src/pages/Blog.jsx并导入Header组件:

    import { Header } from '../components/Header.jsx'
    
  4. 然后,在Blog组件中渲染Header组件:

      return (
        <div style={{ padding: 8 }}>
          <Header />
          <br />
          <hr />
          <br />
          <CreatePost />
    
  5. 编辑src/pages/Signup.jsx并导入Link组件:

    import { useNavigate, Link } from 'react-router-dom'
    
  6. 添加Link组件以链接回首页:

      return (
        <form onSubmit={handleSubmit}>
          <Link to='/'>Back to main page</Link>
          <hr />
          <br />
    

现在我们已经成功链接了注册页面,接下来让我们继续创建登录页面。

创建登录页面并存储 JWT

现在我们已经成功定义了注册页面,我们可以创建登录页面。然而,首先我们需要想出一个存储 JWT 的方法。我们不应该将其存储在本地存储中,因为潜在的攻击者可以从那里窃取令牌(例如,通过脚本注入)。在一个单页应用SPA)中,由于没有页面刷新,将令牌存储在运行时使用 React 上下文是一种安全且简单的方法。现在让我们这样做:

  1. 创建一个新的src/contexts/文件夹。在其内部,创建一个src/contexts/AuthContext.jsx文件,并从react导入createContextuseStateuseContext函数:

    import { createContext, useState, useContext } from 'react'
    import PropTypes from 'prop-types'
    
  2. 然后,定义以下上下文:

    export const AuthContext = createContext({
      token: null,
      setToken: () => {},
    })
    
  3. 接下来,定义一个AuthContextProvider组件,该组件使用状态钩子提供上下文:

    export const AuthContextProvider = ({ children }) => {
      const [token, setToken] = useState(null)
      return (
        <AuthContext.Provider value={{ token, setToken }}>
          {children}
        </AuthContext.Provider>
      )
    }
    AuthContextProvider.propTypes = {
      children: PropTypes.element.isRequired,
    }
    
  4. 此外,定义一个钩子来使用上下文,类似于useState的 API:

    export function useAuth() {
      const { token, setToken } = useContext(AuthContext)
      return [token, setToken]
    }
    
  5. 编辑src/App.jsx并导入AuthContextProvider

    import { AuthContextProvider } from './contexts/AuthContext.jsx'
    
  6. RouterProvider包裹在AuthContextProvider中,使其对所有页面可用:

          <AuthContextProvider>
            <RouterProvider router={router} />
          </AuthContextProvider>
    
  7. 编辑src/api/users.js并定义一个新的登录函数:

    export const login = async ({ username, password }) => {
      const res = await fetch(`${import.meta.env.VITE_BACKEND_URL}/user/login`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username, password }),
      })
      if (!res.ok) throw new Error('failed to login')
      return await res.json()
    }
    
  8. src/pages/Signup.jsx文件复制到新的src/pages/Login.jsx文件中,调整导入和组件名称。此外,添加对useAuth钩子的新导入:

    import { login } from '../api/users.js'
    import { useAuth } from '../contexts/AuthContext.jsx'
    export function Login() {
    
  9. 接下来,编辑src/pages/Login.jsx,添加useAuth钩子,调整signupMutation以调用登录、设置令牌并在成功登录后导航到首页:

      const [, setToken] = useAuth()
      const loginMutation = useMutation({
        mutationFn: () => login({ username, password }),
        onSuccess: (data) => {
          setToken(data.token)
          navigate('/')
        },
        onError: () => alert('failed to login!'),
      })
      const handleSubmit = (e) => {
        e.preventDefault()
        loginMutation.mutate()
      }
    
  10. 调整提交按钮,如下所示:

          <input
            type='submit'
            value={loginMutation.isPending ? 'Logging in...' : 'Log In'}
            disabled={!username || !password || loginMutation.isPending}
          />
    
  11. 编辑src/App.jsx并导入Login页面:

    import { Login } from './pages/Login.jsx'
    
  12. 最后,定义/login路由,如下所示:

      {
        path: '/login',
        element: <Login />,
      },
    

这样,我们的注册和登录页面就正常工作了,但我们仍然需要在首页上链接到登录页面并显示当前登录的用户。我们现在就来做这件事。

使用存储的 JWT 和实现简单的注销

在本节中,我们将通过检查上下文中是否存储了有效的 JWT 来验证用户是否已经登录。然后,我们将使用 auth 上下文钩子通过简单地从其中移除令牌来再次注销我们的用户。这不是一个完整的注销,因为 JWT 在技术上仍然是有效的。为了进行完整的注销,我们必须在后端使令牌无效(例如,通过在认证服务数据库中将该令牌列入黑名单)。这个过程被称为令牌撤销

让我们开始使用存储的 JWT 并实现简单的注销:

  1. 在我们项目的根目录(前端)安装jwt-decode库:

    $ npm install jwt-decode@4.0.0
    
  2. 编辑src/components/Header.jsx并导入jwtDecode函数和useAuth钩子:

    import { jwtDecode } from 'jwt-decode'
    import { useAuth } from '../contexts/AuthContext.jsx'
    
  3. Header组件的useAuth钩子中获取令牌:

    export function Header() {
      const [token, setToken] = useAuth()
    
  4. 添加对令牌是否正确设置的检查。如果是,解析令牌并渲染从其中获取的用户 ID:

      if (token) {
        const { sub } = jwtDecode(token)
        return (
          <div>
            Logged in as <b>{sub}</b>
    

备注

在这种情况下,我们只在一个地方解码令牌。如果这个功能在多个地方使用,将解码抽象成一个单独的钩子是有意义的。

  1. 此外,我们还将在这里显示一个注销按钮,它只是重置令牌:

            <br />
            <button onClick={() => setToken(null)}>Logout</button>
          </div>
        )
      }
    
  2. 在此过程中,我们还将添加一个链接到登录页面到页眉,如果用户尚未登录:

      return (
        <div>
          <Link to='/login'>Log In</Link> | <Link to='/signup'>Sign Up</Link>
        </div>
      )
    

恭喜!我们已经成功实现了简单的 JWT 用户认证流程。然而,你可能已经注意到,我们博客中的所有用户都显示为他们的用户 ID,而不是他们的用户名。让我们来改变这一点。

获取用户名

为了显示用户名而不是用户 ID,我们将创建一个User组件,该组件将从我们后端的一个端点获取用户信息,我们现在将创建它。目前,我们只显示用户名,但将来这个功能可以用来获取其他信息,例如用户的头像或全名。

实现后端端点

让我们开始实现用于获取用户信息的后端端点:

  1. 编辑backend/src/services/users.js并添加一个新函数,通过id获取用户信息。如果找不到匹配的用户,我们将返回用户 ID 作为后备:

    export async function getUserInfoById(userId) {
      try {
        const user = await User.findById(userId)
        if (!user) return { username: userId }
        return { username: user.username }
      } catch (err) {
        return { username: userId }
      }
    }
    

    我们特别确保我们只在这里返回用户名,以避免泄露密码或其他敏感用户信息!

  2. 编辑backend/src/routes/users.js并在此处导入新定义的函数:

    import { createUser, loginUser, getUserInfoById } from '../services/users.js'
    
  3. 然后,在userRoutes函数内部定义一个新的路由,该路由将获取具有特定 ID 的用户。对于此路由,我们使用复数users,因为我们在这里处理多个用户:

      app.get('/api/v1/users/:id', async (req, res) => {
        const userInfo = await getUserInfoById(req.params.id)
        return res.status(200).send(userInfo)
      })
    
  4. 由于我们已经在后端工作,让我们也更改现有的author过滤器,使其与用户名一起工作。编辑backend/src/services/posts.js并导入User模型:

    import { User } from '../db/models/user.js'
    
  5. 通过查找具有给定用户名的用户,然后按用户 ID 列出所有帖子(如果找到了)来重构listPostsByAuthor函数:

    export async function listPostsByAuthor(authorUsername, options) {
      const user = await User.findOne({ username: authorUsername })
      if (!user) return []
      return await listPosts({ author: user._id }, options)
    }
    

现在我们有一个返回给定用户 ID 的用户信息的端点,让我们在前端使用它!

实现一个用于获取和渲染用户名的 User 组件

在前端,我们将创建一个组件来获取和渲染用户名。React Query 在这里帮了我们很大忙,因为我们不需要担心多次获取相同的用户 ID – 它会为我们缓存结果并立即返回,而不是再次发起请求。

按照以下步骤实现User组件:

  1. 首先,我们需要定义 API 函数。编辑src/api/users.js并添加一个通过id获取用户信息的函数:

    export const getUserInfo = async (id) => {
      const res = await fetch(`${import.meta.env.VITE_BACKEND_URL}/users/${id}`, {
        method: 'GET',
        headers: { 'Content-Type': 'application/json' },
      })
      return await res.json()
    }
    
  2. 创建一个新的src/components/User.jsx文件并导入useQueryPropTypes和 API 函数:

    import { useQuery } from '@tanstack/react-query'
    import PropTypes from 'prop-types'
    import { getUserInfo } from '../api/users.js'
    
  3. 现在,定义组件并通过查询钩子获取用户信息:

    export function User({ id }) {
      const userInfoQuery = useQuery({
        queryKey: ['users', id],
        queryFn: () => getUserInfo(id),
      })
      const userInfo = userInfoQuery.data ?? {}
    
  4. 如果有用户名,我们渲染它,否则回退到 ID:

      return <strong>{userInfo?.username ?? id}</strong>
    }
    
  5. 最后,我们为组件定义 prop 类型:

    User.propTypes = {
      id: PropTypes.string.isRequired,
    }
    
  6. 现在,我们可以使用新创建的组件并将其导入到src/components/Header.jsx中:

    import { User } from './User.jsx'
    
  7. 然后,我们可以编辑现有代码以渲染User组件而不是直接渲染用户 ID:

            Logged in as <User id={sub} />
    
  8. 接下来,我们对src/components/Post.jsx执行相同的操作并导入User组件:

    import { User } from './User.jsx'
    
  9. 然后,我们调整代码以渲染User组件:

              Written by <User id={author} />
    

现在,我们的用户名将再次正确渲染,如下面的截图所示:

图 6.6 – 正确获取并显示用户名

图 6.6 – 正确获取并显示用户名

现在用户名已经正确显示,我们需要做一件事:在创建帖子时发送 JWT 头。

在创建帖子时发送 JWT 头

在创建帖子时,我们不再需要发送作者信息。相反,我们需要发送带有Authentication头的 JWT。

让我们重构代码以便我们可以这样做:

  1. 编辑src/api/posts.jsx并调整createPost函数,使其接受 JWT 作为第一个参数,然后将其作为Authentication头传递:

    export const createPost = async (token, post) => {
      const res = await fetch(`${import.meta.env.VITE_BACKEND_URL}/posts`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
         Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify(post),
      })
      return await res.json()
    }
    
  2. 编辑src/components/CreatePost.jsx并导入useAuth钩子:

    import { useAuth } from '../contexts/AuthContext.jsx'
    
  3. 从组件内部的useAuth钩子中获取 JWT:

    export function CreatePost() {
      const [token] = useAuth()
    
  4. 删除 author状态:

      const [author, setAuthor] = useState('')
    
  5. 还要从createPost函数中删除 author状态,并改为将token状态作为第一个参数传递:

         mutationFn: () => createPost(token, { title, author, contents }),
    
  6. 在渲染组件之前,通过检查是否存在令牌来检查用户是否已登录。如果用户未登录,我们告诉他们先登录:

      if (!token) return <div>Please log in to create new posts.</div>
      return (
        <form onSubmit={handleSubmit}>
    
  7. 删除以下代码以删除author字段:

          <br />
          <div>
            <label htmlFor='create-author'>Author: </label>
            <input
              type='text'
              name='create-author'
              id='create-author'
              value={author}
              onChange={(e) => setAuthor(e.target.value)}
            />
          </div>
    

现在,创建帖子又成功工作了!它将当前登录用户的用户 ID 存储在数据库中作为作者,并在显示帖子时将其解析为用户名。

接下来,我们将学习高级令牌处理。

高级令牌处理

你可能已经注意到,我们的简单认证解决方案仍然缺少一些完整解决方案应该具备的功能,例如以下内容:

  • 使用非对称密钥对令牌进行加密,这样我们就可以在不暴露我们的秘密(私钥)给所有服务的情况下验证其真实性(使用公钥)。到目前为止,我们一直在使用对称密钥,这意味着我们需要相同的秘密来生成和验证 JWT。

  • 将令牌存储在安全的httpOnly cookie 中,以便在页面刷新或关闭后再次访问。

  • 在后端注销后使令牌无效。

实现这些功能需要大量手动工作,因此最佳实践是使用认证解决方案,如 Auth0 或 Firebase Auth。这些解决方案的工作方式与我们的简单 JWT 实现类似,但它们为我们提供了一个外部的认证服务来创建和处理令牌。本章旨在介绍这些提供商幕后是如何工作的,以便您可以轻松理解并集成您项目中的任何提供商。

到目前为止,所有用户都被视为平等,每个人都可以创建帖子,但只能更新和删除自己的帖子。对于一个公开的博客,为管理员提供一个删除他人帖子以管理平台内容的方法会很好。添加角色的一个好方法是存储和从数据库中检索它们。虽然在 JWT 中添加角色在技术上可行,但它有一些缺点,例如在角色更改时需要使现有令牌无效并创建新的令牌。

摘要

在本章中,我们深入学习了 JWT 的工作原理。首先,我们学习了认证理论和 JWT,以及如何手动创建它们。然后,我们在后端实现了登录、注册和认证路由。接下来,我们通过创建新页面并在它们之间使用 React Router 进行路由来将这些路由集成到前端。最后,我们通过学习高级令牌处理以及关于认证和角色管理的更多学习要点来结束本章。

在下一章,第七章使用服务器端渲染提高加载时间,我们将学习如何实现服务器端渲染以改善我们博客的初始加载时间。在第一次加载时,我们已经做了很多请求(获取所有博客帖子,然后是每位作者的昵称)。我们可以通过在后台这样做来将它们捆绑在一起。

第七章:使用服务器端渲染提高加载时间

在使用 JWT 实现身份验证后,让我们专注于优化我们的博客应用程序的性能。我们将从基准测试我们应用程序当前的加载时间开始,并了解需要考虑的各种指标。然后,我们将学习如何在服务器上渲染 React 组件和获取数据。在本章的结尾,我们将简要介绍高级服务器端渲染的概念。

在本章中,我们将涵盖以下主要主题:

  • 基准测试我们的应用程序加载时间

  • 在服务器上渲染 React 组件

  • 服务器端数据获取

  • 高级服务器端渲染

技术要求

在我们开始之前,请安装第一章(B19385_01.xhtml#_idTextAnchor016,“为全栈开发做准备”)和第二章(B19385_02.xhtml#_idTextAnchor028,“了解 Node.js 和 MongoDB”)中提到的所有要求。

那些章节中列出的版本是书中使用的版本。虽然安装较新版本不应有问题,但请注意,某些步骤在较新版本上可能有所不同。如果您在使用本书提供的代码和步骤时遇到问题,请尝试使用第一章第二章中提到的版本。

您可以在此 GitHub 上找到本章的代码:github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch7

本章的 CiA 视频可以在以下网址找到:youtu.be/0OlmicibYWQ

基准测试我们的应用程序加载时间

在我们开始提高加载时间之前,我们首先必须了解用于衡量我们应用程序性能的指标。衡量 Web 应用程序性能的主要指标被称为核心 Web 指标,具体如下:

  • 首次内容渲染时间(FCP):通过报告直到页面上第一个图像或文本块被渲染的时间来衡量应用程序的加载性能。一个好的目标是将此指标低于 1.8 秒。

  • 最大内容渲染时间(LCP):通过报告直到最大的图像或文本块在视口中可见的时间来衡量应用程序的加载性能。一个好的目标是将此指标低于 2.5 秒。

  • 总阻塞时间(TBT):通过报告 FCP 和用户能够与页面交互之间的时间来衡量应用程序的交互性。一个好的目标是将此指标低于 200 毫秒。

  • 累积布局偏移(CLS):此指标通过报告在加载过程中页面上意外的移动来衡量应用的视觉稳定性,例如,一个链接最初在页面顶部加载,但在其他元素加载时被推得更低。虽然这个指标并没有直接衡量应用的性能,但它仍然是一个重要的指标,因为它可能导致用户在尝试点击某个东西时感到烦恼,但布局发生了偏移。

所有这些指标都可以通过使用开源的Lighthouse工具来衡量,该工具也可在 Google Chrome 开发者工具的Lighthouse面板下找到。现在让我们开始基准测试我们的应用:

  1. ch6文件夹复制到一个新的ch7文件夹中,如下所示:

    $ cp -R ch6 ch7
    
  2. 在 VS Code 中打开ch7文件夹,打开终端,并使用以下命令运行前端:

    $ npm run dev
    
  3. 确保 Docker 中的dbserver容器正在运行。

  4. 打开一个新的终端,并使用以下命令运行后端:

    $ cd backend
    $ npm run dev
    
  5. 在 Google Chrome 中转到http://localhost:5173并打开检查器(右键单击然后按检查)。

注意

最好在隐身标签页中这样做,这样扩展程序就不会干扰测量。

  1. 打开Lighthouse标签页(它可能被>>菜单隐藏)。它应该看起来如下所示:

图 7.1 – Google Chrome 开发者工具中的 Lighthouse 标签页

图 7.1 – Google Chrome 开发者工具中的 Lighthouse 标签页

  1. 灯塔标签页中,保留所有选项的默认设置,然后点击分析页面加载按钮。

    Lighthouse 将开始分析网站,并给出包含首次内容渲染最大内容渲染总阻塞时间累积布局偏移等指标的报告。正如我们所见,我们的应用在 TBT 和 CLS 方面表现相当不错,但在 FCP 和 LCP 方面表现特别糟糕。请参考以下截图:

图 7.2 – 在开发模式下分析我们的应用时的 Lighthouse 结果(当鼠标悬停在性能分数上时)

图 7.2 – 在开发模式下分析我们的应用时的 Lighthouse 结果(当鼠标悬停在性能分数上时)

画图花费如此长时间有两个原因。首先,我们正在以开发模式运行服务器,这通常会使一切变慢。此外,我们正在客户端渲染一切,这意味着浏览器必须首先下载并执行我们的 JavaScript 代码,然后才能开始渲染界面。现在让我们静态构建前端并再次基准测试:

  1. 使用以下命令全局安装serve工具,这是一个运行简单 Web 服务器的工具:

    $ npm install -g serve
    
  2. 使用以下命令构建前端(在项目根目录下执行):

    $ npm run build
    
  3. 通过运行以下命令静态地提供我们的应用:

    $ serve dist/
    
  4. 在 Google Chrome 中打开http://localhost:3000并再次运行 Lighthouse(你可能需要清除旧报告或点击左上角的列表并选择(新报告)以再次分析)。

    你应该在静态提供的前端上看到新的基准测试结果,这更接近于在生产中提供的方式。你可以在下面的屏幕截图中看到结果示例:

图 7.3 – Lighthouse 报告结果在我们的静态构建应用上

图 7.3 – Lighthouse 报告结果在我们的静态构建应用上

现在,结果相当不错!然而,它还可以进一步改进。此外,核心 Web Vitals并没有考虑到获取作者用户名的级联请求。虽然在我们应用中,第一次和最大的内容渲染很快,但作者名称在那个点还没有加载。除了 Lighthouse 报告外,我们还可以查看网络标签页来进一步调试我们应用的性能,如下所示:

  1. 在 DevTools 中,转到网络标签页。

  2. 在标签页打开时刷新页面。你会看到一个瀑布图和请求的测量时间,如下面的屏幕截图所示:

图 7.4 – 网络标签页上的瀑布图

图 7.4 – 网络标签页上的瀑布图

但时间极低(所有都在 10 毫秒以下)。这是因为我们的后端在本地上运行,所以没有网络延迟。这不是一个现实场景。在生产中,我们发出的每个请求都会有延迟,因此我们首先必须等待拉取博客文章,然后分别获取每个作者的名称。我们可以使用 DevTools 来模拟更慢的网络连接;现在就让我们来做这件事:

  1. 网络标签页的顶部,点击 限速下拉菜单。

  2. 选择慢速 3G预设。以下截图供参考:

图 7.5 – 在 Google Chrome DevTools 中模拟慢速网络

图 7.5 – 在 Google Chrome DevTools 中模拟慢速网络

注意

Lighthouse 内置了一种限速形式,类似于我们在这里使用的网络限速,但并不相同。虽然 DevTools 中的网络限速是添加到所有请求的固定延迟,但 Lighthouse 的限速尝试通过根据初始未限速加载中观察到的数据调整限速来模拟更真实的场景。

  1. 刷新页面。你现在会看到应用正在缓慢加载主布局,然后是所有帖子的列表,最后解析作者 ID 到用户名。

这是我们页面在慢速网络上的加载方式。现在,加载我们应用的总体时间几乎接近九秒!你可以查看瀑布图来了解为什么会这样:

图 7.6 – 在开启 Slow 3G 限速的情况下检查瀑布图

图 7.6 – 在开启 Slow 3G 限速的情况下检查瀑布图

在我们的应用中,问题在于请求是级联的。首先,HTML 文档加载,然后加载我们应用的 JavaScript 文件。这个 JavaScript 文件随后被执行并开始渲染布局和获取帖子列表。帖子加载后,会并行发出多个请求以解析作者名称。由于每个请求在我们的模拟慢速网络中需要超过两秒钟,我们最终的总加载时间超过八秒钟。

现在我们已经学会了如何基准测试 Web 应用,并发现了我们应用中的性能瓶颈(级联请求),让我们学习如何提高性能!

在服务器上渲染 React 组件

在上一节中,我们将级联请求识别为在慢速连接上表现不佳的问题。以下是一些可能的解决方案:

  • 捆绑请求:在服务器上获取所有内容,然后通过单个请求一次性将所有内容提供给客户端。这将解决获取作者名称时的级联请求问题,但不会解决 HTML 页面加载和 JavaScript 执行以开始获取数据之间的初始等待时间。每个请求的延迟为两秒钟,这意味着在 HTML 获取后仍然增加了四秒钟(两秒钟用于加载 JavaScript 和两秒钟用于发出请求)。

  • 服务器端渲染:在服务器上渲染包含所有数据的初始用户界面,并代替仅包含指向 JavaScript 文件的 URL 的初始 HTML 提供它。这意味着不需要额外的请求来获取数据或 JavaScript,我们可以立即显示博客文章。这种方法的优势之一是允许缓存结果,因此,只有当添加博客文章时,我们才需要在服务器上重新生成页面。这种方法的一个缺点是它会给服务器带来更大的压力,尤其是在页面复杂且难以渲染时。

在数据变化不频繁或所有用户访问相同数据的情况下,服务器端渲染是有益的。在数据频繁变化或针对每个用户进行个性化处理的情况下,通过创建新的路由或使用能够聚合请求的系统(例如 GraphQL),将请求捆绑在一起可能更有意义。我们将在本书稍后的第十一章“使用 GraphQL API 构建后端”中了解更多关于 GraphQL 的内容。然而,在本章中,我们将专注于服务器端渲染方法。

让我们来看看服务器端渲染与客户端渲染之间的区别:

  • 客户端渲染中,浏览器下载一个最小的 HTML 页面,通常只包含有关下载包含所有将渲染应用代码的 JavaScript 包的信息。

  • 服务器端渲染 中,React 组件在服务器上渲染,并以 HTML 的形式提供给浏览器。这确保了应用可以立即渲染。JavaScript 包可以在稍后加载。

图 7.7 – 客户端渲染和服务器端渲染之间的区别

图 7.7 – 客户端渲染和服务器端渲染之间的区别

还可以将这两个结合到 同构渲染 中。这涉及到在服务器端渲染初始页面,然后继续在客户端渲染更改。同构渲染结合了两个世界的最佳之处。

除了性能改进之外,服务器端渲染对 搜索引擎优化SEO)也有好处,因为搜索引擎爬虫不需要运行 JavaScript 就能看到页面。我们将在下一章中了解更多关于 SEO 的内容,第八章确保客户通过搜索引擎优化找到你

现在我们已经了解了服务器端渲染,让我们开始在前端实现它,如下所示:

  • 设置服务器

  • 定义服务器端入口点

  • 定义客户端入口点

  • 更新 index.htmlpackage.json

  • 让 React Router 与服务器端渲染一起工作

首先,让我们设置服务器。

设置服务器

在我们可以开始进行服务器端渲染之前,我们需要设置一些样板代码来同时运行 Express 服务器和 Vite,这样我们就不丢失 Vite 的好处,例如热重载。让我们按照以下步骤设置服务器:

  1. 在我们项目的根目录(前端)中安装 expressdotenv 依赖项;我们将使用它们创建一个小型网络服务器来服务我们的服务器端渲染页面:

    $ npm install express@4.18.2 dotenv@16.3.1
    
  2. 编辑 .eslintrc.json 并添加 node 环境,因为我们现在要向我们的前端添加服务器端代码:

      "env": {
        "browser": true,
        "node": true
      },
    
  3. ch7 文件夹中创建一个新的 server.js 文件,并导入 fspathurlexpressdotenv 依赖项:

    import fs from 'fs'
    import path from 'path'
    import { fileURLToPath } from 'url'
    import express from 'express'
    import dotenv from 'dotenv'
    dotenv.config()
    
  4. 将当前路径保存在一个变量中,稍后用于引用我们项目中的其他文件,使用与 ESM 兼容的 import.meta.url 变量,它包含一个 file:// URL 到我们的项目:

    const __dirname = path.dirname(fileURLToPath(import.meta.url))
    

    我们在这里将此 URL 转换为常规路径。

  5. 定义一个新的 createDevServer 函数,我们将创建一个带有热重载和服务器端渲染的 Vite 开发服务器:

    async function createDevServer() {
    
  6. 在这个函数内部,我们首先定义 Express 应用程序:

      const app = express()
    
  7. 然后,导入并创建一个 Vite 开发服务器。我们在这里使用动态 import 语法,这样我们就不需要在定义生产服务器时导入 Vite:

      const vite = await (
        await import('vite')
      ).createServer({
        server: { middlewareMode: true },
        appType: 'custom',
      })
      app.use(vite.middlewares)
    

    中间件模式将 Vite 作为中间件运行在现有的 Express 服务器中。将 appType 设置为 custom 将禁用 Vite 的自有服务逻辑,这样我们就可以控制要服务的 HTML。

  8. 现在,定义一个匹配所有路径的路由,并开始加载 index.html 文件:

      app.use('*', async (req, res, next) => {
        try {
          const templateHtml = fs.readFileSync(
            path.resolve(__dirname, 'index.html'),
            'utf-8',
          )
    

    确保在index.html中以 UTF-8 模式加载,以支持各种语言和表情符号。

  9. 接下来,注入 Vite 热模块替换客户端以允许热重载:

          const template = await vite.transformIndexHtml(
            req.originalUrl,
            templateHtml
          )
    
  10. 加载我们将在下一步定义的服务器端渲染应用程序的入口点文件:

          const { render } = await vite.ssrLoadModule('/src/entry-server.jsx')
    

    Vite 中的ssrLoadModule函数自动转换 ESM 源代码,使其在 Node.js 中可用。这意味着我们可以不运行手动构建就热重载入口点文件。

  11. 使用 React 渲染应用程序。我们将在服务器端入口点中稍后定义render函数。现在,我们只需调用该函数:

          const appHtml = await render()
    
  12. 通过匹配占位符字符串将我们的应用程序渲染的 HTML 插入到 HTML 模板中,该占位符字符串我们将在index.html文件中稍后定义:

          const html = template.replace(`<!--ssr-outlet-->`, appHtml)
    
  13. 返回包含最终 HTML 内容的200 OK响应:

          res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
    
  14. 为了完成服务器创建,捕获所有错误并让 Vite 修复堆栈跟踪,将堆栈跟踪中的源文件映射回实际源代码。然后,返回创建的 Express 应用程序:

        } catch (e) {
          vite.ssrFixStacktrace(e)
          next(e)
        }
      })
      return app
    }
    
  15. 最后,执行createDevServer函数并使应用程序监听定义的端口:

    const app = await createDevServer()
    app.listen(process.env.PORT, () =>
      console.log(
        `ssr dev server running on http://localhost:${process.env.PORT}`,
      ),
    )
    
  16. 不要忘记在.env文件中定义PORT环境变量。编辑.env文件并添加PORT环境变量,如下所示:

    VITE_BACKEND_URL="http://localhost:3001/api/v1"
    PORT=5173
    

现在我们已经成功创建了与 Vite 集成的 Express 服务器,我们继续实现服务器端入口点。

定义服务器端入口点

服务器端入口点将使用ReactDOMServer在服务器上渲染我们的 React 组件。我们需要区分这个入口点和客户端入口点,因为并非 React 能做的所有事情都在服务器端受支持。具体来说,一些钩子,如 effect 钩子,在服务器端不会运行。此外,我们将在服务器端以不同的方式处理路由,但关于这一点稍后讨论。

现在,让我们开始定义服务器端入口点:

  1. 首先,创建一个新的src/entry-server.jsx文件,并导入ReactDOMServerApp组件:

    import ReactDOMServer from 'react-dom/server
    import { App } from './App.jsx'
    
  2. 定义并导出render函数,该函数使用ReactDOMServer.renderToString函数返回App组件:

    export async function render() {
      return ReactDOMServer.renderToString(
        <App />,
      )
    }
    

在定义服务器端入口点后,我们将继续定义客户端入口点。

定义客户端入口点

客户端入口点使用常规ReactDOM来渲染我们的 React 组件。然而,我们需要让 React 知道要利用已经服务器端渲染的 DOM。而不是渲染,我们激活现有的 DOM。就像给植物加水一样,激活通过向服务器端渲染的静态 DOM 添加所有 React 功能,使 DOM“活跃”起来。

按照以下步骤定义客户端入口点:

  1. 将现有的src/main.jsx文件重命名为src/entry-client.jsx

  2. createRoot函数替换为hydrateRoot函数,如下所示:

    ReactDOM.hydrateRoot(
      document.getElementById('root'),
      <React.StrictMode>
        <App />
      </React.StrictMode>,
    )
    

    hydrateRoot 函数接受组件作为第二个参数,并且不需要我们调用 .render()

现在我们已经定义了两个入口点,让我们更新 index.htmlpackage.json

更新 index.html 和 package.json

我们仍然需要在 index.html 文件中添加占位符字符串,并调整 package.json 以执行我们的自定义服务器而不是直接执行 vite 命令。现在让我们来做这件事:

  1. 编辑 index.html 并添加一个占位符,其中将注入服务器渲染的 HTML:

        <div id="root"><!--ssr-outlet--></div>
    
  2. 调整模块导入以指向客户端入口点:

        <script type="module" src="/src/entry-client.jsx"></script>
    
  3. 现在,编辑 package.json 并将开发脚本替换为以下内容:

        "dev": "node server",
    
  4. 此外,将 build 命令替换为构建服务器和客户端的命令:

        "build": "npm run build:client && npm run build:server",
        "build:client": "vite build --outDir dist/client",
        "build:server": "vite build --outDir dist/server --ssr src/entry-server.jsx",
    

我们现在的设置已经准备好进行服务器端渲染。然而,当你启动服务器时,你会立即注意到 React Router 并没有与我们的当前设置一起工作。现在让我们修复这个问题。

使 React Router 与服务器端渲染一起工作

要使 React Router 与服务器端渲染一起工作,我们需要在服务器端使用 StaticRouter,在客户端使用 BrowserRouter。我们可以为两边复用相同的路由定义。让我们开始重构代码,使 React Router 在服务器端工作:

  1. 编辑 src/App.jsx 并从其中 移除 与路由器相关的导入(高亮行):

    import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
    import { createBrowserRouter, RouterProvider } from 'react-router-dom'
    import { AuthContextProvider } from './contexts/AuthContext.jsx'
    import { Blog } from './pages/Blog.jsx'
    import { Signup } from './pages/Signup.jsx'
    import { Login } from './pages/Login.jsx'
    
  2. 导入 PropTypes,因为我们稍后会用到它:

    import PropTypes from 'prop-types'
    
  3. 接下来,从其中 移除 以下路由定义;我们很快会将它们放入一个新文件中:

    const router = createBrowserRouter([
      {
        path: '/',
        element: <Blog />,
      },
      {
        path: '/signup',
        element: <Signup />,
      },
      {
        path: '/login',
        element: <Login />,
      },
    ])
    
  4. 调整函数以接受 children 并将 RouterProvider 替换为 {children}

    export function App({ children }) {
      return (
        <QueryClientProvider client={queryClient}>
          <AuthContextProvider>
            {children}
    </AuthContextProvider>
        </QueryClientProvider>
      )
    }
    
  5. 现在,我们还需要为 App 组件添加 propTypes 定义:

    App.propTypes = {
      children: PropTypes.element.isRequired,
    }
    
  6. 创建一个新的 src/routes.jsx 文件并将之前移除的导入放在那里:

    import { Blog } from './pages/Blog.jsx'
    import { Signup } from './pages/Signup.jsx'
    import { Login } from './pages/Login.jsx'
    
  7. 然后,添加路由定义并导出它们:

    export const routes = [
      {
        path: '/',
        element: <Blog />,
      },
      {
        path: '/signup',
        element: <Signup />,
      },
      {
        path: '/login',
        element: <Login />,
      },
    ]
    

现在我们已经以可以复用客户端和服务器端入口点路由的方式重构了我们的应用结构,让我们在客户端入口点重新定义路由器。

定义客户端路由器

按照以下步骤在客户端入口点重新定义路由器:

  1. 编辑 src/entry-client.jsx 并导入 RouterProvidercreateBrowserRouter 函数和 routes

    import React from 'react'
    import ReactDOM from 'react-dom/client'
    import { createBrowserRouter, RouterProvider } from 'react-router-dom'
    import { App } from './App.jsx'
    import { routes } from './routes.jsx'
    
  2. 然后,基于 routes 定义创建一个新的浏览器路由器:

    const router = createBrowserRouter(routes)
    
  3. 调整 render 函数以使用 RouterProvider 渲染 App

    ReactDOM.hydrateRoot(
      document.getElementById('root'),
      <React.StrictMode>
        <App>
          <RouterProvider router={router} />
        </App>
      </React.StrictMode>,
    )
    

接下来,让我们定义 服务器端路由器

将 Express 请求映射到 Fetch 请求

在服务器端,我们将得到一个 Express 请求,我们首先需要将其转换为 Fetch 请求,以便 React Router 能够理解它。现在让我们来做这件事:

  1. src/request.js 文件中创建一个新的文件并定义一个 createFetchRequest 函数,该函数接受一个 Express 请求作为参数:

    export function createFetchRequest(req) {
    
  2. 首先,定义请求的 origin 并构建 URL:

      const origin = `${req.protocol}://${req.get('host')}`
      const url = new URL(req.originalUrl || req.url, origin)
    

    我们需要首先使用 req.originalUrl(如果可用),以考虑到 Vite 中间件可能会更改 URL。

  3. 然后,我们定义一个新的 AbortController 来处理请求关闭的情况:

      const controller = new AbortController()
      req.on('close', () => controller.abort())
    
  4. 接下来,我们将 Express 请求 头部映射到 Fetch 头部:

      const headers = new Headers()
      for (const [key, values] of Object.entries(req.headers)) {
        if (!values) continue
        if (Array.isArray(values)) {
          for (const value of values) {
            headers.append(key, value)
          }
        } else {
          headers.set(key, values)
        }
      }
    
  5. 现在,我们可以为 Fetch 请求构建 init 对象,它由 methodheadersAbortController 组成:

      const init = {
        method: req.method,
        headers,
        signal: controller.signal,
      }
    
  6. 如果我们的请求不是 GETHEAD 请求,我们也会得到 body,所以,让我们也将它添加到 Fetch 请求中:

      if (req.method !== 'GET' && req.method !== 'HEAD') {
        init.body = req.body
      }
    
  7. 最后,让我们从我们的提取信息中创建 Fetch 请求对象:

      return new Request(url.href, init)
    }
    

现在我们有一个将 Express 请求转换为 Fetch 请求的实用函数,我们可以利用它来定义服务器端路由器。

定义服务器端路由器

服务器端路由器的工作方式与客户端路由器非常相似,只是我们是从 Express 而不是从页面获取请求信息,并使用 StaticRouter,因为服务器端的路由不能改变。按照以下步骤定义服务器端路由器:

  1. 编辑 src/entry-server.jsx 并导入 StaticRouterProvidercreateStaticHandler 以及 createStaticRouter 函数。还要导入 routes 定义和刚刚定义的 createFetchRequest 函数:

    import ReactDOMServer from 'react-dom/server'
    import {
      createStaticHandler,
      createStaticRouter,
      StaticRouterProvider,
    } from 'react-router-dom/server'
    import { App } from './App.jsx'
    import { routes } from './routes.jsx'
    import { createFetchRequest } from './request.js'
    
  2. 定义路由的静态处理器:

    const handler = createStaticHandler(routes)
    
  3. 调整 render 函数以接受 Express 请求对象,然后使用我们之前定义的函数从它创建一个 Fetch 请求:

    export async function render(req) {
      const fetchRequest = createFetchRequest(req)
    
  4. 现在我们可以使用这个转换后的请求传递给我们的静态处理器,它为路由创建上下文,允许 React Router 看到我们正在尝试访问哪个路由以及使用哪些参数:

      const context = await handler.query(fetchRequest)
    
  5. 从处理器定义的路由和上下文中,我们可以创建一个静态路由器:

      const router = createStaticRouter(handler.dataRoutes, context)
    
  6. 最后,我们可以调整渲染以渲染静态路由器和我们的重构 App 结构:

      return ReactDOMServer.renderToString(
        <App>
          <StaticRouterProvider router={router} context={context} />
        </App>,
      )
    }
    
  7. 还有另一件事要做。我们需要将 Express 请求传递到服务器端入口点的 render() 函数。编辑 server.js 文件中的以下行:

          const appHtml = await render(req)
    
  8. 如果前端和后端已经运行,请确保退出它们。

  9. 按照以下步骤启动前端:

    $ npm run dev
    
  10. 此外,在单独的终端中启动后端:

    $ cd backend
    $ npm run dev
    

前端现在将输出 ssr dev server running on http://localhost:5173 并成功在服务器端渲染所有页面!你可以通过打开 DevTools,点击右上角的齿轮图标,在 设置 | 首选项 面板中向下滚动到 调试器 部分,并勾选 禁用 JavaScript 来验证它是服务器端渲染的,如下所示:

图 7.8 – 在 DevTools 中禁用 JavaScript

图 7.8 – 在 DevTools 中禁用 JavaScript

现在,刷新页面,你会看到应用的一部分仍然被渲染。目前只有应用的上半部分是通过服务器端完全渲染的。文章列表尚未在服务器端渲染。这是因为useQuery钩子内部使用了一个 effect 钩子,在组件挂载后获取数据。因此,它们不适用于服务器端渲染。然而,我们仍然可以使数据获取与服务器端渲染一起工作。我们将在下一节中学习这一点。

服务器端数据获取

如我们所见,数据获取在服务器端不是即时的。对于 React Query 的服务器端数据获取有两种方法:

  • 初始数据方法:在useQuery钩子中使用initialData选项来传递预取数据。这种方法足够用于获取文章列表,但对于获取深层嵌套数据,例如每个作者的昵称,可能会很棘手。

  • 数据同步方法:这允许我们预取任何请求并按查询键存储结果,甚至在服务器端预取任何请求,即使它深深嵌套在应用中,也不需要通过属性或上下文传递预取数据。

我们首先将使用initialData选项来获取博客文章列表,然后扩展我们的解决方案以采用数据同步方法,以便我们可以了解这两种方法的工作原理以及它们的优缺点。

使用初始数据

React Router 允许我们通过initialData选项定义Blog组件和useQuery钩子。现在让我们来做这件事:

  1. 编辑src/routes.jsx并从react-router-dom导入useLoaderData钩子和getPosts函数:

    import { useLoaderData } from 'react-router-dom'
    import { Blog } from './pages/Blog.jsx'
    import { Signup } from './pages/Signup.jsx'
    import { Login } from './pages/Login.jsx'
    import { getPosts } from './api/posts.js'
    
  2. 调整路由以定义一个loader函数,其中我们简单地调用getPosts函数。然后我们可以在Component()方法中使用useLoaderData钩子从 loader 获取数据,并将其传递给Blog组件,如下所示:

    export const routes = [
      {
        path: '/',
        loader: getPosts,
        Component() {
          const posts = useLoaderData()
          return <Blog initialData={posts} />
        },
      },
    
  3. 编辑src/pages/Blog.jsx并在其中导入PropTypes,这样我们就可以在稍后为组件定义一个新的属性:

    import PropTypes from 'prop-types'
    
  4. 然后,将initialData属性添加到Blog组件中:

    export function Blog({ initialData }) {
    
  5. initialData属性传递给useQuery钩子,如下所示:

      const postsQuery = useQuery({
        queryKey: ['posts', { author, sortBy, sortOrder }],
        queryFn: () => getPosts({ author, sortBy, sortOrder }),
        initialData,
      })
    
  6. 最后,为Blog组件定义propTypes

    Blog.propTypes = {
      initialData: PropTypes.shape(PostList.propTypes.posts),
    }
    

刷新前端页面(禁用 JavaScript)后,现在将显示文章列表,但不会解析作者昵称。正如我们所见,初始数据方法相当简单。然而,如果我们想获取所有作者的昵称,我们可能需要将它们存储在某个地方,然后通过属性或上下文将它们传递到用户组件中,这两种方法都相当繁琐,而且如果我们需要稍后进行更多请求,扩展性不会很好。幸运的是,还有一种更高级的方法,我们现在将要学习。

使用数据同步

使用水合方法,我们创建一个查询客户端来预取我们想要进行的任何请求,然后将其脱水,通过加载器将其传递给组件,并在那里再次水合。使用这种方法,我们可以简单地执行任何查询并使用查询键存储它。如果一个组件使用相同的查询键,它将能够在服务器端渲染结果。现在让我们实现水合方法:

  1. 编辑src/routes.jsx并从 React Query 导入QueryClientdehydrate函数和Hydrate组件:

    import { QueryClient, dehydrate, HydrationBoundary } from '@tanstack/react-query'
    
  2. 此外,还需要导入getUserInfo函数,因为我们现在也将获取用户名:

    import { getUserInfo } from './api/users.js'
    
  3. 调整加载器;我们现在将在那里创建一个查询客户端:

      {
        path: '/',
        loader: async () => {
          const queryClient = new QueryClient()
    
  4. 然后,我们通过传递与组件相同的默认参数来模拟Blog组件的getPosts请求:

          const author = ''
          const sortBy = 'createdAt'
          const sortOrder = 'descending'
          const posts = await getPosts({ author, sortBy, sortOrder })
    

注意

这种默认参数的重复有点问题。然而,根据我们当前的服务器端渲染解决方案,数据获取和组件渲染过于分离,无法在它们之间正确共享代码。一个更复杂的服务器端渲染解决方案,如 Next.js 或 Remix,可以更好地处理这种模式。

  1. 现在,我们可以调用queryClient.prefetchQuery,使用与组件中useQuery钩子将使用的相同查询键来预取查询的结果:

          await queryClient.prefetchQuery({
            queryKey: ['posts', { author, sortBy, sortOrder }],
            queryFn: () => posts,
          })
    
  2. 接下来,我们使用获取到的帖子数组来从它们中获取唯一的作者 ID 列表:

          const uniqueAuthors = posts
            .map((post) => post.author)
            .filter((value, index, array) => array.indexOf(value) === index)
    
  3. 我们现在遍历所有作者 ID 并预取它们的信息:

          for (const userId of uniqueAuthors) {
            await queryClient.prefetchQuery({
              queryKey: ['users', userId],
              queryFn: () => getUserInfo(userId),
            })
          }
    
  4. 现在我们已经预取了所有必要的数据,我们需要在queryClient上调用dehydrate以返回可序列化的格式:

          return dehydrate(queryClient)
        },
    
  5. Component()方法中,我们获取这个脱水状态并使用Hydrate组件再次水合。这个过程使得数据对服务器端渲染的查询客户端可访问:

        Component() {
          const dehydratedState = useLoaderData()
          return (
            <HydrationBoundary state={dehydratedState}>
              <Blog />
            </HydrationBoundary>
          )
        },
      },
    
  6. 最后,我们可以将src/pages/Blog.jsx组件恢复到之前的状态。我们首先移除PropTypes导入:

    import PropTypes from 'prop-types'
    
  7. 然后,我们移除initialData属性:

    export function Blog({ initialData }) {
    
  8. 我们还在useQuery钩子中移除了它:

      const postsQuery = useQuery({
        queryKey: ['posts', { author, sortBy, sortOrder }],
        queryFn: () => getPosts({ author, sortBy, sortOrder }),
        initialData,
      })
    
  9. 最后,我们移除propTypes定义:

    Blog.propTypes = {
      initialData: PropTypes.shape(PostList.propTypes),
    }
    
  10. 通过Ctrl + C退出前端,然后按照以下方式重新启动它:

    $ npm run dev
    
  11. 刷新页面,你现在会看到完整的博客文章列表,包括所有作者名称,现在已经在服务器端正确渲染,即使禁用了 JavaScript!

让我们再进行一次基准测试,看看性能是如何提高的:

  1. 打开 Chrome 开发者工具。

  2. 通过转到齿轮图标,设置 | 首选项,取消选中禁用 JavaScript来再次启用 JavaScript。

  3. 前往Lighthouse标签页。点击分析页面加载以生成新的报告。

图 7.9 – 服务器端渲染应用与开发服务器的 Lighthouse 性能分数

图 7.9 – 服务器端渲染应用与开发服务器的 Lighthouse 性能分数

首屏加载时间(FCP)和最大内容渲染时间(LCP)几乎比之前在生产模式下客户端渲染报告的时间减少了一半。查看 网络 选项卡中的瀑布图,我们现在可以看到只有一个请求用于获取初始页面。

让我们通过学习高级服务器端渲染来结束本章内容。

高级服务器端渲染

在前面的章节中,我们已经成功创建了一个可以进行服务器端渲染并具有热重载功能的服务器,这对于开发非常有用,但会在生产中降低性能。现在,让我们为生产服务器创建另一个服务器函数,该函数将构建文件,使用压缩,并且不加载 Vite 中间件进行热重载。按照以下步骤创建生产服务器:

  1. 在我们项目的根目录下,使用以下命令安装 compression 依赖项:

    $ npm install compression@1.7.4
    
  2. 编辑 server.js 并在 createDevServer 函数之上定义一个新的用于生产服务器的函数:

    async function createProdServer() {
    
  3. 在这个函数中,我们定义了一个新的 Express 应用,并使用 compression 包和 serve-static 包来服务我们的客户端:

      const app = express()
      app.use((await import('compression')).default())
      app.use(
        (await import('serve-static')).default(
          path.resolve(__dirname, 'dist/client'),
          {
            index: false,
          },
        ),
      )
    
  4. 然后,我们定义了一个捕获所有路径的路由,这次是从 dist/ 文件夹中加载的模板:

      app.use('*', async (req, res, next) => {
        try {
          let template = fs.readFileSync(
            path.resolve(__dirname, 'dist/client/index.html'),
            'utf-8',
          )
    
  5. 现在,我们直接导入并渲染服务器端入口点:

          const render = (await import('./dist/server/entry-server.js')).render
    
  6. 如前所述,我们渲染 React 应用,将 index.html 中的占位符替换为渲染的应用,并返回生成的 HTML:

          const appHtml = await render(req)
          const html = template.replace(`<!--ssr-outlet-->`, appHtml)
          res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
    
  7. 对于错误处理,我们现在简单地将其传递给下一个中间件并返回应用:

        } catch (e) {
          next(e)
        }
      })
      return app
    }
    
  8. server.js 文件底部,我们创建开发服务器的地方,现在我们检查 NODE_ENV 环境变量,并使用它来决定是启动生产服务器还是开发服务器:

    if (process.env.NODE_ENV === 'production') {
      const app = await createProdServer()
      app.listen(process.env.PORT, () =>
        console.log(
          `ssr production server running on http://localhost:${process.env.PORT}`,
        ),
      )
    } else {
      const app = await createDevServer()
      app.listen(process.env.PORT, () =>
        console.log(
          `ssr dev server running on http://localhost:${process.env.PORT}`,
        ),
      )
    }
    
  9. 按照以下步骤安装 cross-env 包:

    $ npm install cross-env@7.0.3
    
  10. 编辑 package.json 并添加一个 start 脚本,该脚本以生产模式启动服务器:

        "start": "cross-env NODE_ENV=production node server",
    
  11. 关闭前端开发服务器,构建并启动生产服务器:

    $ npm run build
    $ npm start
    

如我们所见,我们的服务器仍然可以很好地提供服务应用,但现在我们不再处于开发模式,因此没有热重载可用。这标志着我们服务器端渲染实现的完成!正如你所想象的那样,本章中服务器端渲染的实现相对基础,我们还需要处理多个问题:

  • 重定向和适当的 HTTP 状态码

  • 静态站点生成(缓存生成的 HTML 页面,这样我们就不必每次都进行服务器端渲染)

  • 更好的数据获取功能

  • 在服务器和客户端之间更好地进行代码拆分

  • 在服务器和客户端之间更好地处理环境变量

为了解决这些问题,最好在 Web 框架中使用一个完整的后端渲染实现,例如 Next.js 或 Remix。这些框架已经提供了进行后端渲染、数据获取和路由的现成方法,并且不需要我们手动使所有这些协同工作。我们将在第十六章Next.js 入门中了解更多关于 Next.js 的内容。

摘要

在本章中,我们首先学习了如何使用 Lighthouse 和 Chrome DevTools 对 Web 应用进行基准测试。我们还了解了此类基准测试的有用指标,称为核心 Web 指标。然后,我们学习了在服务器上渲染 React 组件以及客户端渲染和服务器端渲染之间的区别。接下来,我们使用 Vite 和 React Router 为我们应用实现了服务器端渲染。然后,我们使用 React Query 实现了服务器端数据获取。然后,我们再次对应用进行了基准测试,并看到了超过 40%的性能提升。最后,我们学习了如何让我们的服务器端渲染服务器为生产做好准备,以及一个更复杂的后端渲染框架需要处理的概念。

在下一章第八章确保客户通过搜索引擎优化找到您,我们将学习如何使我们的 Web 应用更容易被搜索引擎爬虫访问,从而提高我们在 Lighthouse 报告中看到的 SEO 评分。我们将添加元标签以获取更多关于我们的 Web 应用的信息,并为各种社交媒体网站添加集成。

第八章:通过搜索引擎优化确保客户能找到你

在上一章中优化我们博客的性能时,您可能已经注意到 Lighthouse 报告还包括一个搜索引擎优化SEO)得分,我们的应用程序在这个得分上相对较低。这个得分告诉我们我们的应用程序在正确索引和被搜索引擎如 Google 或 Bing 找到方面优化得有多好。当然,在成功开发了一个工作的博客应用程序之后,我们希望我们的博客能被用户找到。在本章中,我们将学习 SEO 的基础知识以及如何优化我们的 React 应用程序的 SEO 得分。然后,我们将学习如何创建元标签,以便更容易地在各种社交媒体网站上集成。

在本章中,我们将涵盖以下主要主题:

  • 优化应用程序以适应搜索引擎

  • 改进社交媒体嵌入

技术要求

在我们开始之前,请从第一章**,准备全栈开发第二章**,了解 Node.jsMongoDB中安装所有要求。

那些章节中列出的版本是书中使用的版本。虽然安装较新版本不应成问题,但请注意,某些步骤在较新版本上可能工作方式不同。如果您在这本书提供的代码和步骤上遇到问题,请尝试使用第一章第二章中提到的版本。

您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch8

本章的 CiA 视频可以在以下网址找到:youtu.be/1xN3l0MMTbY

如果您克隆了本书的完整仓库,Husky 在运行npm install时可能找不到.git目录。在这种情况下,只需在相应章节文件夹的根目录下运行git init

优化应用程序以适应搜索引擎

在我们开始优化我们的应用程序以适应搜索引擎之前,让我们简要了解搜索引擎是如何工作的。搜索引擎通过在索引中存储有关网站的信息来工作。索引包含网站的地址、内容和元信息。在索引中添加或更新页面称为索引,由爬虫完成。爬虫是一种自动软件,它抓取网站并将它们索引。它被称为爬虫,因为它会跟随网站上的进一步链接以找到更多网站。更高级的爬虫,如Googlebot,还可以检测是否需要 JavaScript 来渲染网站的页面内容,甚至可以渲染它。

以下图形展示了搜索引擎爬虫的工作原理:

图 8.1 – 搜索引擎爬虫工作原理的可视化

图 8.1 – 搜索引擎爬虫工作原理的可视化

如我们所见,一个搜索爬虫有一个包含它需要爬取和索引的 URL 的队列。然后,它逐个访问这些 URL,获取 HTML,如果它是一个高级爬虫,它会检测是否需要执行 JavaScript 来渲染内容。在这种情况下,URL 会被添加到渲染队列中,渲染后的 HTML 稍后会被传递回爬虫。然后,爬虫提取所有指向其他页面的链接并将它们添加到队列中。最后,解析后的内容被添加到索引中。

要查看一个网站是否已经被搜索引擎索引,大多数搜索引擎都提供了一个 site: 操作符,它可以用来检查一个 URL 是否已经被它索引。例如,site:wikipedia.org 会显示维基百科上已经索引的各种 URL。如果你的网站还没有被索引,你可以将其提交给像Google Search Console这样的工具。Google Search Console 还有一个关于索引状态和索引问题的详细概述。然而,为了使网站被发现,并不需要提交我们的网站,因为大多数搜索引擎会自动爬取网络,最终会发现我们的网站。

如果你的网站仍然没有被索引,这可能是因为它配置不当。首先,你需要创建一个robots.txt文件来指定搜索引擎是否允许爬取你的网站的部分,以及允许爬取哪些部分。

注意

robots.txt不应用于从 Google 搜索结果中隐藏网页。相反,它用于减少对不重要或类似页面的爬虫流量。如果你想要完全从 Google 搜索结果中隐藏网页,要么对它们进行密码保护,要么使用noindex元标签。

接下来,你需要确保你的网站内容对爬虫可见。服务器端渲染可以通过允许爬虫在不运行 JavaScript 的情况下查看你的网站内容来帮助这里。此外,使用特殊 HTML 标签添加元信息可以帮助爬虫获取有关你的网站的更多信息。对于小型网站,页面需要正确链接或添加手动网站地图。对于大型网站,例如拥有许多文章的博客,应该始终定义网站地图。最后,良好的性能、快速的加载时间和良好的用户体验可以使你的网站在搜索引擎中排名更高。

我们已经添加了服务器端渲染来通过立即提供服务内容而不依赖于 JavaScript 来渲染它来加速爬取。现在,让我们进一步优化我们的应用程序以适应搜索引擎。我们首先创建一个robots.txt文件。

创建 robots.txt 文件

首先,让我们确保爬虫明确允许访问我们的应用程序并索引其上的所有页面。为此,我们需要创建一个robots.txt文件,爬虫会读取该文件以找出它们允许访问的页面(如果有)。按照以下步骤创建一个允许所有爬虫访问所有页面的robots.txt文件:

  1. ch7文件夹复制到一个新的ch8文件夹,如下所示:

    $ cp -R ch7 ch8
    
  2. 打开 VS Code 中的ch8文件夹。

  3. 在项目的根目录中创建一个新的public/robots.txt文件。

  4. 打开新创建的文件,并输入以下内容以允许所有爬虫索引所有页面:

    User-agent: *
    Allow: /
    

    robots.txt通过定义块来工作,每个块由匹配用户代理来定义。用户代理可以匹配各种爬虫,例如 Google 的Googlebot,或者您可以使用*来匹配所有爬虫。在用户代理之后,可以做出一个或多个Allow和/或Disallow语句,这些语句决定爬虫是否允许访问某些路径。在我们的情况下,我们允许访问所有路径。此外,可以指定一个Sitemap,但我们将稍后在创建一个 sitemap 子部分中了解更多。

  5. 打开一个新的终端窗口,通过运行以下命令启动前端:

    $ npm run dev
    
  6. 打开另一个终端窗口,通过运行以下命令启动后端:

    $ cd backend
    $ npm run dev
    
  7. 在您的浏览器中转到http://localhost:5173/robots.txt以查看正确提供的服务robots.txt文件。

现在我们已经成功允许爬虫访问我们的应用,我们应该改进我们的 URL 结构。让我们通过为每篇帖子创建单独的页面来实现这一点。

为帖子创建单独的页面

目前,在我们的博客应用中无法仅查看单个帖子,我们只能查看所有帖子的列表。这对 SEO 来说不好,因为它意味着搜索引擎总是会链接到索引页面,而这个页面可能已经包含与用户搜索内容不同的文章。让我们对我们的应用进行一点重构,只显示主页上的帖子标题和作者,然后为每篇博客帖子链接到单独的页面:

  1. 编辑src/components/Post.jsx以允许在列表中显示帖子的小版本的同时显示单个完整帖子,并提供到完整版本的链接。首先,我们从react-router-dom导入Link组件:

    import { Link } from 'react-router-dom'
    
  2. 然后,我们在Post组件中添加一个_id属性和一个fullPost属性。fullPost属性默认设置为false(当在帖子列表中显示时)并在使用它时在单篇帖子页面中设置为true

    export function Post({
      title,
      contents,
      author,
      _id,
      fullPost = false,
    }) {
    
  3. 我们对组件进行一些调整,以便在尚未在单篇帖子页面时显示到单篇帖子页面的链接:

          {fullPost ? (
            <h3>{title}</h3>
          ) : (
            <Link to={`/posts/${_id}`}>
              <h3>{title}</h3>
            </Link>
          )}
    
  4. 此外,我们只在单篇帖子页面上显示博客帖子的内容,并相应地调整作者信息的间距:

          {fullPost && <div>{contents}</div>}
          {author && (
            <em>
              {fullPost && <br />}
              Written by <User id={author} />
            </em>
          )}
    
  5. 调整属性类型以添加新定义的属性:

    Post.propTypes = {
      title: PropTypes.string.isRequired,
      contents: PropTypes.string,
      author: PropTypes.string,
      _id: PropTypes.string.isRequired,
      fullPost: PropTypes.bool,
    }
    
  6. 编辑src/api/posts.js并添加一个新函数,通过id获取单个帖子:

    export const getPostById = async (postId) => {
      const res = await fetch(`${import.meta.env.VITE_BACKEND_URL}/posts/${postId}`)
      return await res.json()
    }
    
  7. 创建一个新的src/pages/ViewPost.jsx文件,并首先导入我们将需要的所有组件和函数:

    import { Link } from 'react-router-dom'
    import PropTypes from 'prop-types'
    import { useQuery } from '@tanstack/react-query'
    import { Header } from '../components/Header.jsx'
    import { Post } from '../components/Post.jsx'
    import { getPostById } from '../api/posts.js'
    
  8. 然后,定义一个组件,该组件接受postId作为属性:

    export function ViewPost({ postId }) {
    
  9. 在组件中,我们使用查询钩子通过id获取单个帖子:

      const postQuery = useQuery({
        queryKey: ['post', postId],
        queryFn: () => getPostById(postId),
      })
      const post = postQuery.data
    
  10. 接下来,渲染页眉和返回主页的链接:

      return (
        <div style={{ padding: 8 }}>
          <Header />
          <br />
          <hr />
          <Link to='/'>Back to main page</Link>
          <br />
          <hr />
    
  11. 然后,如果我们成功获取了具有给定 ID 的帖子,就使用设置fullPost属性的帖子进行渲染。否则,我们显示未找到的信息:

          {post ? <Post {...post} fullPost /> : `Post with id ${postId} not found.`}
        </div>
      )
    }
    
  12. 最后,定义ViewPost组件的 prop 类型:

    ViewPost.propTypes = {
      postId: PropTypes.string.isRequired,
    }
    
  13. 编辑src/routes.jsx并导入ViewPost组件和getPostById函数(用于服务器端渲染):

    import { ViewPost } from './pages/ViewPost.jsx'
    import { getPosts, getPostById } from './api/posts.js'
    
  14. 定义一个新的/posts/:postId路由以查看单个文章。在加载器中,我们获取单个博客文章和作者(如果有)。然后返回脱水的状态和文章 ID:

      {
        path: '/posts/:postId',
        loader: async ({ params }) => {
          const postId = params.postId
          const queryClient = new QueryClient()
          const post = await getPostById(postId)
          await queryClient.prefetchQuery({
            queryKey: ['post', postId],
            queryFn: () => post,
          })
          if (post?.author) {
            await queryClient.prefetchQuery({
              queryKey: ['users', post.author],
              queryFn: () =>
                getUserInfo(post.author),
            })
          }
          return { dehydratedState: dehydrate(queryClient), postId }
        },
    
  15. 为路由定义一个组件方法,其中我们获取dehydratedStatepostId,并将它们传递给ViewPost组件,如下所示:

        Component() {
          const { dehydratedState, postId } = useLoaderData()
          return (
            <HydrationBoundary state={dehydratedState}>
              <ViewPost postId={postId} />
            </HydrationBoundary>
          )
        },
      },
    
  16. 在您的浏览器中访问http://localhost:5173/,您会看到列表中的所有博客文章标题现在都有一个链接。点击链接查看完整的博客文章,如下面的截图所示:

图 8.2 – 在单独的页面上查看单个博客文章

图 8.2 – 在单独的页面上查看单个博客文章

现在我们的博客应用已经组织得更好了,因为我们不再在主页上看到所有博客文章的完整内容。我们现在只看到标题和作者,然后可以决定这篇文章是否对我们感兴趣。此外,搜索引擎可以为每个博客文章提供单独的条目,这使得在我们的应用中查找文章更容易。不过,在 URL 结构方面仍有改进的空间,因为它目前只包含文章 ID。让我们在下一步中引入更有意义的 URL。

创建有意义的 URL(slugs)

网站通常在 URL 中放置关键词,以便用户只需查看 URL 就能更容易地看到他们将要打开的内容。URL 中的关键词也是搜索引擎的排名因素,尽管不是那么强烈。最强的因素始终是优质内容。尽管如此,良好的 URL 结构可以提高用户体验。例如,如果链接是/posts/64a42dfd6a7b7ab47009f5e3/making-sure-customers-find-you-with-search-engine-optimization而不是仅仅/posts/64a42dfd6a7b7ab47009f5e3,那么仅从 URL 本身就可以清楚地知道他们将在页面上找到什么内容。这样的关键词在 URL 中被称为 URL slug,这个名字来源于新闻业中的“slugs”,指的是使用文章的简短描述作为内部名称。让我们开始在我们的文章页面上引入 slugs:

  1. 编辑src/routes.jsx并调整路径以允许可选地包含一个 slug:

        path: '/posts/:postId/:slug?',
    

注意

我们没有对 slug 是否正确进行检查。实际上,这并不是真的必要,许多页面都不这样做。只要我们有正确的 ID,我们就可以渲染博客文章。我们只需要确保指向页面的所有链接都包含正确的 slug。然而,我们还可以添加一个带有rel="canonical"属性的元素到页面中,指定带有正确 slug 的规范页面。这将告诉爬虫在使用不正确的 slug 时不要索引重复页面。

  1. 在我们项目的根目录中安装slug npm 包,它包含一个用于正确 slugify 标题的函数:

    title string ourselves.
    
  2. 编辑 src/components/Post.jsx 并导入 slug 函数:

    import slug from 'slug'
    
  3. 然后,通过添加 slug 调整博客帖子的链接,如下所示:

            <Link to={`/posts/${_id}/${slug(title)}`}>
    
  4. 现在,当我们从列表中打开一个链接时,URL 将如下所示:

    http://localhost:5173/posts/64a42dfd6a7b7ab47009f5e3/making-sure-customers-find-you-with-search-engine-optimization
    

现在我们为我们的博客帖子有了可读的 URL!然而,你可能已经注意到,在我们的应用的所有页面上标题仍然是 Vite + React。现在让我们通过引入动态标题并在页面标题中包含博客帖子标题来改变这一点。

添加动态标题

页面的标题对于 SEO 的重要性甚至超过了 URL 中的关键词,因为这是在大多数情况下将在搜索结果中显示的标题。因此,我们应该明智地选择标题,如果我们有动态内容(如我们的博客),我们也应该动态调整标题以适应内容。我们可以使用 React Helmet 库来简化 HTML 文档 <head> 部分的更改。这个库允许我们渲染一个特殊的 Helmet 组件。此组件的子元素将替换 <head> 部分中现有的标签。按照以下步骤使用 React Helmet 动态设置标题:

  1. 首先,让我们改变我们应用的一般标题,因为它仍然是 Vite + React。编辑我们项目的根目录下的 index.html 并更改标题。我们将把我们的博客应用命名为 Full-Stack React Blog

        <title>Full-Stack React Blog</title>
    
  2. 在我们项目的根目录中,安装 react-helmet-async 依赖项以便能够动态更改标题:

    $ npm install react-helmet-async@1.3.0
    

注意

React Helmet Async 是原始 React Helmet 的分支,它增加了对较新 React 版本的支持。

  1. 编辑 src/pages/ViewPost.jsx 并从 react-helmet-async 中导入 Helmet 组件:

    import { Helmet } from 'react-helmet-async'
    
  2. 渲染 Helmet 组件并在其中定义 </strong> 标签,如下所示:</p> <pre><code class="language-js">  return (     <div style={{ padding: 8 }}>       {post && (         <Helmet>           <title>{post.title} | Full-Stack React Blog</title>         </Helmet>       )} </code></pre> </li> <li> <p>编辑 <strong>src/pages/Blog.jsx</strong> 并导入 <strong>Helmet</strong>:</p> <pre><code class="language-js">import { Helmet } from 'react-helmet-async' </code></pre> </li> <li> <p>然后,在 <strong>Blog</strong> 组件中将标题重置为 <strong>Full-Stack React Blog</strong>:</p> <pre><code class="language-js">  return (     <div style={{ padding: 8 }}>       <Helmet>         <title>Full-Stack React Blog</title>       </Helmet> </code></pre> </li> <li> <p>编辑 <strong>src/App.jsx</strong> 并导入 <strong>HelmetProvider</strong>:</p> <pre><code class="language-js">import { HelmetProvider } from 'react-helmet-async' </code></pre> </li> <li> <p>然后,调整 <strong>App</strong> 组件以渲染 <strong>HelmetProvider</strong>:</p> <pre><code class="language-js">export function App({ children }) {   return (     <HelmetProvider>       <QueryClientProvider client={queryClient}>         <AuthContextProvider>           {children}         </AuthContextProvider>       </QueryClientProvider>     </HelmetProvider>   ) } </code></pre> </li> <li> <p>在应用中点击单个帖子,你会看到标题现在更新为包括帖子标题!</p> </li> </ol> <p>现在我们已经成功设置了动态标题,让我们关注 <code><head></code> 部分中的其他重要信息,即 <strong>meta 标签</strong>。</p> <h2 id="添加其他元标签">添加其他元标签</h2> <p>元标签,正如其名所示,包含有关页面元信息。除了标题外,我们还可以设置诸如简短描述或浏览器应该如何渲染网站等信息。在本节中,我们将介绍最重要的与 SEO 相关的元标签,从描述元标签开始。</p> <h3 id="描述元标签">描述元标签</h3> <p>描述元标签包含页面内容的简短描述。类似于标题标签,我们也可以动态设置此标签,如下所示:</p> <ol> <li> <p>编辑 <strong>src/pages/Blog.jsx</strong> 并添加以下通用的 <strong><meta></strong> 标签:</p> <pre><code class="language-js">      <Helmet>         <title>Full-Stack React Blog</title>         <meta           name='description'           content='A blog full of articles about full-stack React development.'         />       </Helmet> </code></pre> <p>现在,让我们为每篇博客文章添加一个动态的 meta 描述标签。meta 描述应该有 50 到 160 个字符,由于我们没有博客文章的简短摘要,我们只需使用完整内容并在 160 个字符后截断。当然,如果作者在创建帖子时添加简短摘要会更好,但为了简单起见,我们在这里只截断描述。</p> </li> <li> <p>编辑<strong>src/pages/ViewPost.jsx</strong>文件,并定义一个简单的函数来截断字符串:</p> <pre><code class="language-js">function truncate(str, max = 160) {   if (!str) return str   if (str.length > max) {     return str.slice(0, max - 3) + '...'   } else {     return str   } } </code></pre> <p>我们将字符串限制在 160 个字符以内,如果超过 160 个字符,我们将截断到 157 个字符,并在末尾添加三个点。</p> </li> <li> <p>将截断的内容作为 meta 描述标签添加到<strong>Helmet</strong>组件中,如下所示:</p> <pre><code class="language-js">      {post && (         <Helmet>           <title>{post.title} | Full-Stack React Blog</title>         <meta name='description' content={truncate(post.contents)} /> </code></pre> </li> </ol> <p>在添加了 description 元标签之后,让我们学习其他可能用到的元标签。</p> <h3 id="robots-元标签">Robots 元标签</h3> <p><code>robots</code>元标签告诉爬虫它们是否以及如何爬取网页。它可以与<code>robots.txt</code>一起使用,但我们应该只在想要动态限制特定页面爬取方式时使用它。它看起来如下所示:</p> <pre><code class="language-js"><meta name="robots" content="index, follow"> </code></pre> <p><code>index</code>关键字告诉爬虫索引页面,<code>follow</code>关键字告诉爬虫爬取页面上的进一步链接。可以通过使用<code>noindex</code>和<code>nofollow</code>来关闭<code>index</code>和<code>follow</code>关键字。</p> <h3 id="viewport-元标签">Viewport 元标签</h3> <p>另一个需要添加的重要元标签是 viewport 标签,它告诉浏览器(和爬虫)您的网站是移动友好的。以下是一个示例,说明元标签如何影响移动设备上页面的渲染方式:</p> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_08_3.jpg" alt="图 8.3 – 添加 viewport 元标签前后博客文章的渲染效果" loading="lazy"></p> <p>图 8.3 – 添加 viewport 元标签前后博客文章的渲染效果</p> <p>Vite 已经在它提供的<code>index.html</code>模板中为我们自动添加了这个元标签。您可以通过查看<code>index.html</code>文件来看到它:</p> <pre><code class="language-js"><meta name="viewport" content="width=device-width, initial-scale=1.0" /> </code></pre> <p>在了解 viewport 标签之后,我们继续学习 charset 元标签。</p> <h3 id="charset-元标签">Charset 元标签</h3> <p>charset 元标签告诉浏览器和爬虫关于网页的字符编码。通常,您希望将其设置为 UTF-8,以确保所有 Unicode 字符都能正确渲染。同样,Vite 已经为我们自动添加了这个元标签:</p> <pre><code class="language-js"><meta charset="UTF-8" /> </code></pre> <p>现在我们已经了解了相关的元标签,让我们继续创建一个网站地图,这有助于爬虫更容易地找到我们应用上的所有页面。</p> <h3 id="其他相关元信息">其他相关元信息</h3> <p>对于网站,可能还有其他相关的元信息,例如在<code><html></code>标签中设置语言,如下所示:</p> <pre><code class="language-js"><html lang="en"> </code></pre> <p>设置 favicon 也可以提高搜索片段,这是用户在决定是否点击链接时看到的。</p> <h2 id="创建网站地图">创建网站地图</h2> <p>站点地图包含一个应用程序中所有 URL 的列表,这样爬虫可以轻松地检测新内容并更有效地爬取应用程序。它还确保所有内容都被找到,这对于拥有大量页面/帖子的基于内容的应用程序尤为重要。通常,站点地图以 XML 格式提供。它们对于 SEO 不是强制性的,但会使爬虫更容易、更快地抓取应用程序上的内容。由于我们的博客应用程序有动态内容,我们还应该创建一个动态的站点地图。按照以下步骤为我们的博客应用程序创建一个动态站点地图:</p> <ol> <li> <p>首先,我们需要为我们的(已部署的)前端提供一个基础 URL,以便将所有路径添加到我们的站点地图中。目前,我们只是将其设置为本地主机 URL,但在生产环境中,这个环境变量应该更改为应用程序的正确基础 URL。编辑项目根目录下的<strong>.env</strong>文件,并添加一个<strong>FRONTEND_URL</strong>环境变量:</p> <pre><code class="language-js">FRONTEND_URL="http://localhost:5173" </code></pre> </li> <li> <p>在我们项目的根目录下创建一个新的<strong>generateSitemap.js</strong>文件,首先导入<strong>slug</strong>函数和<strong>dotenv</strong>:</p> <pre><code class="language-js">import slug from 'slug' import dotenv from 'dotenv' dotenv.config() </code></pre> </li> <li> <p>然后,将之前创建的环境变量保存在一个<strong>baseUrl</strong>变量中:</p> <pre><code class="language-js">const baseUrl = process.env.FRONTEND_URL </code></pre> </li> <li> <p>现在,定义一个<strong>async</strong>函数来生成站点地图。在这个函数中,我们首先获取一个博客帖子列表,因为我们希望每个博客帖子都成为站点地图的一部分:</p> <pre><code class="language-js">export async function generateSitemap() {   const postsRequest = await fetch(`${process.env.VITE_BACKEND_URL}/posts`)   const posts = await postsRequest.json() </code></pre> </li> <li> <p>接下来,我们返回一个包含站点地图 XML 的字符串。我们首先定义 XML 头和一个<strong><urlset></strong>标签:</p> <pre><code class="language-js">  return `<?xml version="1.0" encoding="UTF-8"?> <urlset > </code></pre> </li> <li> <p>在<strong><urlset></strong>标签内部,我们可以使用带有<strong><loc></strong>标签的<strong><url></strong>标签来链接到各个页面。我们首先列出所有静态页面:</p> <pre><code class="language-js">    <url>         <loc>${baseUrl}</loc>     </url>     <url>         <loc>${baseUrl}/signup</loc>     </url>     <url>         <loc>${baseUrl}/login</loc>     </url> </code></pre> </li> <li> <p>然后,我们遍历从后端获取的所有帖子,并为每个帖子生成一个<strong><url></strong>标签,从帖子 ID 和缩略名构建 URL:</p> <pre><code class="language-js">    ${posts       .map(         (post) => `     <url>         <loc>${baseUrl}/posts/${post._id}/${slug(post.title)}</loc> </code></pre> </li> <li> <p>我们还可以可选地指定一个<strong><lastmod></strong>标签,告诉爬虫内容最后修改的时间:</p> <pre><code class="language-js">        <lastmod>${post.updatedAt ?? post.createdAt}</lastmod> </code></pre> </li> <li> <p>最后,我们将所有生成的<strong><url></strong>标签合并成一个单独的字符串,并关闭<strong><urlset></strong>标签:</p> <pre><code class="language-js">    </url>`,       )       .join('')} </urlset>` } </code></pre> <p>现在我们有一个动态生成站点地图的函数,我们仍然需要在我们的服务器中包含一个到它的路由。</p> </li> <li> <p>编辑<strong>server.js</strong>文件,并在其中导入<strong>generateSitemap</strong>函数:</p> <pre><code class="language-js">import { generateSitemap } from './generateSitemap.js' </code></pre> </li> <li> <p>然后,转到<strong>createProdServer</strong>函数内部的第一个<strong>app.use('*')</strong>声明,检查 URL 是否为<strong>/sitemap.xml</strong>。如果是,则生成站点地图并将其作为 XML 返回:</p> <pre><code class="language-js">  app.use('*', async (req, res, next) => {     if (req.originalUrl === '/sitemap.xml') {       const sitemap = await generateSitemap()       return res         .status(200)         .set({ 'Content-Type': 'application/xml' })         .end(sitemap)     } </code></pre> </li> </ol> <p>注意</p> <p>在更复杂的设置中,我们可以在我们的 Express 服务器、我们自己的 Web 服务器或一个单独的缓存服务上缓存生成的站点地图。</p> <ol> <li> <p>对于<strong>createDevServer</strong>函数内部的第二个<strong>app.use('*')</strong>声明,我们进行与上一步相同的更改。</p> </li> <li> <p>重新启动服务器,并访问<strong><a href="http://localhost:5173/sitemap.xml" target="_blank">http://localhost:5173/sitemap.xml</a></strong>以查看动态生成的站点地图,其中包含所有创建的帖子及其最后修改的时间戳。</p> </li> <li> <p>我们现在可以在<strong>robots.txt</strong>文件中链接到网站地图。例如,我们将设置 URL 为 localhost。在生产应用程序中,您将调整此 URL 以指向部署应用程序的 URL。编辑<strong>public/robots.txt</strong>并添加以下行:</p> <pre><code class="language-js">Sitemap: http://localhost:5173/sitemap.xml </code></pre> </li> </ol> <p>现在我们已经成功实施了提高我们应用程序搜索引擎优化的措施,让我们来看看 Lighthouse 报告中的我们的 SEO 评分:</p> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_08_4.jpg" alt="图 8.4 – 我们灯塔 SEO 评分现在为 100!" loading="lazy"></p> <p>图 8.4 – 我们灯塔 SEO 评分现在为 100!</p> <p>如我们所见,我们的 SEO 评分现在为 100(之前为 91)。这可能看起来只是轻微的改进,但 Lighthouse 报告只考虑基本检查,例如有标题、描述、viewport 标签和 <code>robots.txt</code> 文件。我们已经做了更多工作来优化访客和搜索引擎的用户体验,例如改进 URL 结构和添加动态标题和描述。</p> <p>我们可以通过通过<strong>内容分发网络</strong>(<strong>CDN</strong>)提供静态资源和使用响应式图片(为较慢的连接提供不同大小的图片以优化性能并避免加载全尺寸图片)来进一步优化我们的应用程序。然而,这超出了本书的范围。</p> <p>为了总结本章,我们将探讨如何提高社交媒体网站上的嵌入。</p> <h1 id="提高社交媒体嵌入">提高社交媒体嵌入</h1> <p>我们已经添加了搜索引擎的重要元标签。然而,社交媒体网站,如 Facebook 和 X(前身为 Twitter),读取额外的元标签以改善您在他们的网站和应用程序上的应用程序嵌入。大多数社交网络使用一个称为 Open Graph Meta Tags 的标准,最初由 Facebook 创建。这些标签可以包含有关页面类型、特殊标题、描述和嵌入社交媒体网站时使用的图像的附加信息。</p> <h2 id="open-graph-元标签">Open Graph 元标签</h2> <p><strong>Open Graph</strong>(<strong>OG</strong>)元标签有四个通用的属性,每个页面都可以有:</p> <ul> <li> <p><strong>og:type</strong>: 描述页面的类型;特定类型可能有额外的属性</p> </li> <li> <p><strong>og:title</strong>: 描述页面在嵌入时应显示的标题</p> </li> <li> <p><strong>og:image</strong>: 一个用于嵌入的图像的 URL</p> </li> <li> <p><strong>og:url</strong>: 一个用于嵌入的链接的 URL</p> </li> </ul> <p><code>og:type</code> 元标签描述了页面上的内容类型。它告诉社交媒体网站如何格式化嵌入。以下是一些可能的值:</p> <ul> <li> <p><strong>website</strong>: 默认值,一个基本的嵌入</p> </li> <li> <p><strong>article</strong>: 这是针对新闻和博客文章的,有额外的参数,如<strong>发布时间</strong>、<strong>修改时间</strong>、<strong>作者</strong>、<strong>部分</strong>和<strong>标签</strong></p> </li> <li> <p><strong>profile</strong>: 对于用户资料,有额外的参数,如<strong>名字</strong>、<strong>姓氏</strong>、<strong>用户名</strong>和<strong>性别</strong></p> </li> <li> <p><strong>book</strong>: 对于书籍,有额外的参数,如<strong>作者</strong>、<strong>isbn</strong>、<strong>发布日期</strong>和<strong>标签</strong></p> </li> <li> <p><strong>音乐</strong>类型:这包括<strong>music.song</strong>、<strong>music.album</strong>、<strong>music.playlist</strong>和<strong>music.radio_station</strong>,每个都有不同的附加参数</p> </li> <li> <p><strong>视频</strong>类型:这包括<strong>video.movie</strong>、<strong>video.episode</strong>、<strong>video.tv_show</strong>和<strong>video.other</strong>,每个都有不同的附加参数</p> </li> </ul> <p>OG 元标签的完整描述和所有可能的值可以在它们的官方网站上找到:<a href="https://ogp.me/" target="_blank"><code>ogp.me/</code></a>。</p> <p>信息</p> <p>大多数社交媒体网站都支持 OG 元标签用于嵌入。然而,一些网站,包括 X(前身为 Twitter),有自己的元标签,如果提供,则优先于 OG 元标签。尽管如此,X 仍然可以读取 OG 元标签,所以只提供那些就足够了。</p> <p>现在,我们将关注<code>article</code>类型,因为我们正在开发一个博客应用程序,所以我们可以使用这个类型为博客文章提供更好的嵌入:</p> <h2 id="使用-og-文章元标签">使用 OG 文章元标签</h2> <p>正如我们所学的,<code>article</code>类型允许我们在页面上包含关于文章发布时间、修改时间和作者的信息。现在让我们为我们的单篇文章页面做这件事:</p> <ol> <li> <p>编辑<strong>src/pages/ViewPost.jsx</strong>并导入<strong>getUserInfo</strong> API 函数,因为我们需要解析对应元标签的作者名称:</p> <pre><code class="language-js">import { getUserInfo } from '../api/users.js' </code></pre> </li> <li> <p>在<strong>ViewPost</strong>组件中,在获取文章后,获取作者名称。我们确保只有当<strong>post?.author</strong>属性存在时才进行此调用,通过使用<strong>useQuery</strong>钩子的<strong>enabled</strong>选项:</p> <pre><code class="language-js">  const userInfoQuery = useQuery({     queryKey: ['users', post?.author],     queryFn: () => getUserInfo(post?.author),     enabled: Boolean(post?.author),   })   const userInfo = userInfoQuery.data ?? {} </code></pre> </li> <li> <p>在<strong>Helmet</strong>组件内部,我们将<strong>og:type</strong>标签定义为<strong>article</strong>,并定义标题、发布时间和修改时间:</p> <pre><code class="language-js">      {post && (         <Helmet>           <title>{post.title} | Full-Stack React Blog</title>           <meta name='description' content={truncate(post.contents)} />           <meta property='og:type' content='article' />           <meta property='og:title' content={post.title} />           <meta property='og:article:published_time' content={post.createdAt} />           <meta property='og:article:modified_time' content={post.updatedAt} /> </code></pre> </li> <li> <p>然后,我们将<strong>og:article:author</strong>设置为解析后的用户名:</p> <pre><code class="language-js">          <meta property='og:article:author' content={userInfo.username} /> </code></pre> </li> <li> <p>最后,我们遍历标签(如果没有标签,我们默认为空数组)并为每个标签定义一个元标签:</p> <pre><code class="language-js">          {(post.tags ?? []).map((tag) => (             <meta key={tag} property='og:article:tag' content={tag} />           ))}         </Helmet>       )} </code></pre> <p>OG 元标签中的数组通过多次重新定义相同的属性来工作。</p> </li> </ol> <p>现在我们已经成功添加了元标签,我们的博客应用程序已经针对搜索引擎和社交媒体网站进行了优化!</p> <h1 id="摘要-6">摘要</h1> <p>在本章中,我们首先简要了解了搜索引擎的工作原理。然后,我们创建了一个<code>robots.txt</code>文件,并为每个博客文章创建了单独的页面,以更好地优化我们的博客以适应搜索引擎。接下来,我们创建了有意义的 URL(别名)并设置了动态标题和元标签。然后,我们在所有优化完成后创建了一个网站地图并评估了博客的 SEO 得分。最后,我们学习了社交媒体嵌入的工作原理以及哪些元标签可以用来改进文章的嵌入,例如博客文章。</p> <p>在下一章第九章《使用 Playwright 实现端到端测试》中,我们将学习如何通过设置 Playwright 来编写用户界面的端到端测试。然后,我们将为我们的博客应用程序编写一些前端测试。</p> <h1 id="第九章使用-playwright-实现端到端测试">第九章:使用 Playwright 实现端到端测试</h1> <p>在前面的章节中,我们已经使用 Jest 为我们的后端编写了单元测试。现在,我们将学习如何使用 Playwright 在我们的用户界面编写和运行端到端测试。首先,我们在项目中设置 Playwright 和 VS Code,以便运行前端测试。然后,我们将为我们的应用程序编写一些前端测试。接下来,我们将了解如何使用 fixtures 重复使用测试设置。最后,我们将学习如何查看测试报告,并使用 GitHub Actions 在 CI 中运行 Playwright。</p> <p>在本章中,我们将介绍以下主要主题:</p> <ul> <li> <p>设置 Playwright 以进行端到端测试</p> </li> <li> <p>编写和运行端到端测试</p> </li> <li> <p>使用 fixtures 重复使用测试设置</p> </li> <li> <p>查看测试报告和在 CI 中运行</p> </li> </ul> <h1 id="技术要求-8">技术要求</h1> <p>在我们开始之前,请从<em>第一章**,准备全栈开发</em>和<em>第二章**,了解 Node.js</em>和 MongoDB*中安装所有要求。</p> <p>那些章节中列出的版本是本书中使用的版本。虽然安装较新版本不应有问题,但请注意,某些步骤在较新版本上可能有所不同。如果您在使用本书中提供的代码和步骤时遇到问题,请尝试使用<em>第一章</em>和<em>第二章</em>中<em>技术要求</em>部分中提到的版本。</p> <p>您可以在 GitHub 上找到本章的代码:<a href="https://github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch9" target="_blank"><code>github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch9</code></a>.</p> <p>如果您克隆了本书的完整仓库,当运行 <code>npm install</code> 时,Husky 可能找不到 <code>.git</code> 目录。在这种情况下,只需在相应章节文件夹的根目录下运行 <code>git init</code>。</p> <p>本章的 CiA 链接可在:<a href="https://youtu.be/WjwEwUR8g2c" target="_blank"><code>youtu.be/WjwEwUR8g2c</code></a></p> <h1 id="设置-playwright-以进行端到端测试">设置 Playwright 以进行端到端测试</h1> <p>Playwright 是一个测试运行器,用于在各种网页渲染引擎上方便地进行端到端测试,例如 Chromium(Chrome、Edge、Opera 等)、WebKit(Safari)和 Firefox。它可以在 Windows、Linux 和 macOS 上本地或 CI 中运行测试。运行 Playwright 有两种方式:</p> <ul> <li> <p><strong>Headed</strong>:打开一个浏览器窗口,可以看到 Playwright 正在做什么</p> </li> <li> <p><strong>Headless</strong>:在后台运行渲染引擎,仅在终端或生成的测试报告中显示测试结果</p> </li> </ul> <p>在本章中,我们将探讨运行 Playwright 的两种方式。现在,让我们在我们的项目中安装 Playwright。</p> <h2 id="安装-playwright">安装 Playwright</h2> <p>要安装 Playwright,我们可以使用 <code>npm init playwright</code>,这将运行一个命令来安装 Playwright,为我们创建一个用于端到端测试的文件夹,添加一个 GitHub Actions 工作流程以在 CI 中运行测试,并安装 Playwright 浏览器以便在多种引擎中运行测试。按照以下步骤安装 Playwright:</p> <ol> <li> <p>将现有的 <strong>ch8</strong> 文件夹复制到新的 <strong>ch9</strong> 文件夹,如下所示:</p> <pre><code class="language-js">$ cp -R ch8 ch9 </code></pre> </li> <li> <p>在 VS Code 中打开 <strong>ch9</strong> 文件夹并打开一个新的终端。</p> </li> <li> <p>运行以下命令:</p> <pre><code class="language-js">$ npm init playwright@1.17.131 </code></pre> </li> </ol> <p>注意</p> <p>通常,在这里安装最新版本是一个好主意,方法是运行 <strong>npm init playwright@latest</strong>。然而,为了确保即使发布带有破坏性更改的新版本,本书中的说明也是可重复的,我们在这里锁定版本。</p> <ol> <li> <p>当询问是否要继续安装 <strong>create-playwright</strong> 包时,按 <em>Return/Enter</em> 确认。然后选择 <strong>JavaScript</strong>。至于目录名,保持 <strong>tests</strong> 的默认名称并按 <em>Return/Enter</em> 确认。输入 <strong>y</strong> 以添加 GitHub Actions 工作流程。再次输入 <strong>y</strong> 以安装 Playwright 浏览器。现在将花费一些时间下载和安装不同的浏览器引擎。</p> </li> <li> <p>我们需要调整一些文件以使 Playwright 能够与 ES 模块一起工作。编辑 <strong>playwright.config.js</strong> 并将文件开头的 <strong>require()</strong> 导入行更改为以下内容:</p> <pre><code class="language-js">import { defineConfig, devices } from '@playwright/test' </code></pre> </li> <li> <p>同时,将 <strong>module.exports</strong> 的导出更改为以下内容:</p> <pre><code class="language-js">export default defineConfig({ </code></pre> </li> <li> <p><em>删除</em> <strong>tests-examples/</strong> 文件夹和 <strong>tests/example.spec.js</strong> 文件。</p> </li> </ol> <p>安装 Playwright 后,我们需要为端到端测试准备我们的后端,所以现在就来做这件事。</p> <h2 id="准备后端进行端到端测试">准备后端进行端到端测试</h2> <p>为了准备后端进行端到端测试,我们需要启动一个带有内存中 MongoDB 服务器的后端实例,类似于我们为 Jest 测试所做的那样。现在就来做这件事:</p> <ol> <li> <p>创建一个新的 <strong>backend/src/e2e.js</strong> 文件。在文件内部,导入 <strong>dotenv</strong>、<strong>globalSetup</strong> 以及 <strong>app</strong> 和 <strong>initDatabase</strong> 函数:</p> <pre><code class="language-js">import dotenv from 'dotenv' dotenv.config() import globalSetup from './test/globalSetup.js' import { app } from './app.js' import { initDatabase } from './db/init.js' </code></pre> </li> <li> <p>然后,定义一个新的 <strong>async</strong> 函数来运行测试服务器:</p> <pre><code class="language-js">async function runTestingServer() { </code></pre> </li> <li> <p>在这个函数内部,我们首先运行 <strong>globalSetup</strong> 函数,该函数运行一个内存中的 MongoDB 服务器。然后,初始化数据库并运行 Express 应用程序:</p> <pre><code class="language-js">  await globalSetup()   await initDatabase()   const PORT = process.env.PORT   app.listen(PORT)   console.info(`TESTING express server running on http://localhost:${PORT}`) } </code></pre> </li> <li> <p>最后,我们运行定义好的函数:</p> <pre><code class="language-js">runTestingServer() </code></pre> </li> <li> <p>编辑 <strong>backend/package.json</strong> 并添加一个新的脚本来运行 <strong>e2e.js</strong> 文件:</p> <pre><code class="language-js">    "e2e": „node src/e2e.js", </code></pre> </li> <li> <p>在项目的根目录中,安装 <strong>concurrently</strong>,这是一个用于并行运行两个命令的工具:</p> <pre><code class="language-js">$ npm install --save-dev concurrently@8.2.2 </code></pre> <p>我们将使用这个工具并行运行后端和前端。</p> </li> <li> <p>在项目的根目录中编辑 <strong>package.json</strong> 并定义一个 <strong>e2e</strong> 脚本,该脚本将并行运行 <strong>e2e:client</strong> 和 <strong>e2e:server</strong> 脚本:</p> <pre><code class="language-js">    "e2e": "concurrently \"npm run e2e:client\" \"npm run e2e:server\"", </code></pre> </li> <li> <p>现在,定义 <strong>e2e:client</strong> 脚本,其中我们只是运行预构建的前端:</p> <pre><code class="language-js">    "e2e:client": "npm run build && npm run start", </code></pre> <p>由于性能原因,我们不运行开发服务器。否则,我们会减慢我们的端到端测试速度。我们在这里可以省略构建脚本,但这样我们必须在运行测试之前记住构建我们的前端,并且我们必须在 CI 中这样做。或者,当本地运行测试时,尤其是当我们只运行某些测试而不是所有测试时,我们可以运行开发服务器而不是构建。</p> </li> <li> <p>然后,我们定义 <strong>e2e:server</strong> 脚本,该脚本在 <strong>backend</strong> 文件夹中运行 <strong>e2e</strong> 脚本:</p> <pre><code class="language-js">    "e2e:server": "cd backend/ && npm run e2e", </code></pre> </li> <li> <p>编辑<strong>playwright.config.js</strong>并设置<strong>baseURL</strong>,通过更改以下行:</p> <pre><code class="language-js">  use: {     /* Base URL to use in actions like `await page.goto('/')`. */     baseURL: 'http://localhost:5173', </code></pre> </li> <li> <p>最后,编辑<strong>playwright.config.js</strong>并将文件底部的<strong>webServer</strong>配置<em>替换</em>为以下内容:</p> <pre><code class="language-js">  webServer: {     command: 'npm run e2e',     url: 'http://localhost:5173',   }, </code></pre> </li> </ol> <p>现在我们已经成功设置了 Playwright 并准备好了后端进行端到端测试,让我们开始编写和运行端到端测试!</p> <h1 id="编写和运行端到端测试">编写和运行端到端测试</h1> <p>现在我们将使用 Playwright 编写和运行我们的第一个端到端测试。让我们从一个简单的测试开始,这个测试只是验证我们是否已经正确优化了标题以供搜索引擎使用。按照以下步骤编写和运行你的第一个端到端测试:</p> <ol> <li> <p>创建一个新的<strong>tests/seo.spec.js</strong>文件。在这个文件中,我们将检查我们的页面标题是否设置正确。</p> </li> <li> <p>在这个新创建的文件中,首先从<strong>@playwright/test</strong>导入<strong>test</strong>和<strong>expect</strong>函数:</p> <pre><code class="language-js">import { test, expect } from '@playwright/test' </code></pre> </li> <li> <p>然后,我们定义一个测试,检查博客的标题是否设置正确:</p> <pre><code class="language-js">test('has title', async ({ page }) => { </code></pre> <p>如你所见,<code>test</code>函数与我们在 Jest 中定义测试的方式相似。Playwright 还允许我们访问测试中的特殊上下文,称为<code>page</code>。<code>page</code>是 Playwright 中最基本的固定装置,允许我们访问浏览器功能和与页面交互。</p> </li> <li> <p>在测试中,我们首先使用<strong>page.goto</strong>函数导航到我们的前端 URL:</p> <pre><code class="language-js">  await page.goto('/') </code></pre> </li> <li> <p>然后,我们使用<strong>expect</strong>函数来检查页面是否显示了正确的标题:</p> <pre><code class="language-js">  await expect(page).toHaveTitle('Full-Stack React Blog') }) </code></pre> <p>如我们所见,Playwright 的语法与 Jest 非常相似。我们还有一个<code>expect</code>函数来进行断言,例如检查页面是否有特定的标题。</p> </li> <li> <p>在运行测试之前,请确保<strong>dbserver</strong>Docker 容器正在运行。</p> </li> <li> <p>我们现在可以通过打开一个新的终端并执行以下命令来运行这个测试:</p> <pre><code class="language-js">ch9 folder), and not inside the backend folder, when running this command! </code></pre> </li> </ol> <p>你会看到 Playwright 在我们的测试中运行了三次(在 Chromium、Firefox 和 Webkit 上),并且所有测试都成功通过。以下截图显示了在命令行中运行 Playwright 的结果:</p> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_09_1.jpg" alt="图 9.1 – 在 Playwright 中运行我们的第一个测试!" loading="lazy"></p> <p>图 9.1 – 在 Playwright 中运行我们的第一个测试!</p> <p>现在我们已经成功执行了测试,让我们继续使用 VS Code 扩展运行测试。</p> <h2 id="使用-vs-code-扩展">使用 VS Code 扩展</h2> <p>我们可以通过 VS Code 扩展运行特定的测试(或所有测试),而不是手动通过命令行运行所有测试,这与我们对 Jest 所做的方式类似。此外,该扩展还允许我们获得测试成功(或失败)的视觉概述,允许我们在浏览器中运行时检查测试,甚至可以记录我们在浏览器中的交互并从中生成测试!</p> <p>让我们先设置 VS Code 扩展并从中运行我们的测试:</p> <ol> <li> <p>在 VS Code 中打开<strong>扩展</strong>选项卡并搜索<strong>Playwright</strong>。</p> </li> <li> <p>点击<strong>安装</strong>按钮通过 Microsoft 安装<strong>Playwright Test for VS Code</strong>。</p> </li> <li> <p>在 VS Code 中点击<strong>测试</strong>选项卡(flask 图标),我们之前也用它来使用 Jest 扩展。在这里,您现在将看到列表中的<strong>Jest</strong>和<strong>Playwright</strong>。</p> </li> <li> <p>展开到<strong>Playwright</strong> | <strong>tests</strong>路径,点击<strong>seo.spec.js</strong>来加载文件,然后点击<strong>seo.spec.js</strong>旁边的<strong>运行</strong>图标来运行测试。</p> </li> </ol> <p>如以下截图所示,测试已成功执行,所有测试都通过:</p> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_09_2.jpg" alt="图 9.2 – 我们从 VS Code 扩展中成功运行的剧作家测试!" loading="lazy"></p> <p>图 9.2 – 我们从 VS Code 扩展中成功运行的剧作家测试!</p> <p>现在我们已经在无头模式下成功运行了 VS Code 扩展中的测试,让我们继续在带头模式下运行它们,这样我们可以在运行测试时显示浏览器中 Playwright 正在做什么。</p> <h3 id="在运行测试时显示浏览器">在运行测试时显示浏览器</h3> <p>Playwright VS Code 扩展还有一个有用的<strong>显示浏览器</strong>选项,在运行测试时打开浏览器。这允许我们在测试运行时调试测试或前端。现在让我们试试:</p> <ol> <li> <p>在<strong>测试</strong>侧边栏的底部,勾选侧边栏底部的<strong>显示浏览器</strong>复选框,然后再次运行测试。</p> <p>将打开一个浏览器窗口并运行测试。然而,我们的测试非常快且简单,所以它会在很短的时间内运行,没有太多可看。</p> </li> <li> <p>为了更好地检查测试,我们可以使用跟踪查看器。在<strong>测试</strong>侧边栏的底部勾选<strong>显示跟踪查看器</strong>,然后再次运行测试。您将看到以下窗口打开:</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_09_3.jpg" alt="图 9.3 – The Playwright trace viewer" loading="lazy"></p> <p>图 9.3 – Playwright 跟踪查看器</p> <p>如我们所见,Playwright 跟踪查看器显示测试运行了<code>page.goto</code>然后<code>expect.toHaveTitle</code>。它还显示了测试的每个步骤中的应用程序状态。在我们的例子中,我们只有一个步骤。这个功能在开发更大、更复杂的测试时特别有用。</p> <p>注意</p> <p>还可以在 UI 模式下运行 Playwright,这将在单独的窗口中打开 Playwright 应用程序,允许我们单独运行测试并观察它们的执行,类似于在 VS Code 扩展中使用<strong>显示跟踪查看器</strong>功能。您可以通过执行以下命令在 UI 模式下运行 Playwright:<strong>npx playwright test --ui</strong></p> <p>现在我们已经了解了如何使用扩展来运行测试,我们可以继续到扩展的一个非常有用的功能:记录操作以创建一个新的测试。现在让我们来做这件事。</p> <h3 id="记录测试">记录测试</h3> <p>Playwright 扩展还可以记录新的测试。现在让我们使用 VS Code 扩展的测试录制功能为注册页面创建一个新的测试:</p> <ol> <li> <p>与运行 Playwright 测试不同,测试录制器不会自动启动我们的前端和后端,因此我们首先需要手动启动它们。打开一个新的终端并执行以下命令:</p> <pre><code class="language-js">$ npm run e2e </code></pre> </li> <li> <p>在<strong>测试</strong>侧边栏的底部部分,点击<strong>记录新</strong>。应该会打开一个浏览器窗口。</p> </li> <li> <p>在浏览器窗口中,将<strong><a href="http://localhost:5173/" target="_blank">http://localhost:5173/</a></strong>粘贴到 URL 栏中,导航到前端。</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_09_4.jpg" alt="图 9.4 – 当悬停在“注册”链接上时的 Playwright 测试记录器" loading="lazy"></p> <p>图 9.4 – 当悬停在“注册”链接上时的 Playwright 测试记录器</p> <ol> <li> <p>然后,点击<strong>注册</strong>链接。注册页面应该会打开。</p> </li> <li> <p>在这个新页面上,输入用户名和密码;例如,<strong>test</strong>和<strong>test</strong>。然后按<strong>注册</strong>按钮。</p> </li> <li> <p>您将被重定向到登录页面。现在,使用之前相同的用户名和密码登录。</p> </li> <li> <p>您将被重定向到主页,并以<strong>test</strong>身份登录。现在您可以关闭浏览器窗口。您会看到在 VS Code 中现在有一个新的<strong>test-1.spec.ts</strong>文件,其中包含我们在浏览器中刚刚执行的所有操作!</p> </li> <li> <p>保存文件并退出正在运行后端和前端的<strong>e2e</strong>脚本。现在您将在<strong>测试</strong>侧边栏中看到<strong>test-1.spec.ts</strong>文件。如果您尝试运行测试,您会注意到它在登录部分卡住了,因为我们的测试目前还没有等待重定向到登录页面。</p> </li> </ol> <p>虽然记录测试是一个有用的功能,可以加快编写端到端测试的速度,但它并不能总是为我们编写功能测试。我们现在需要清理我们的记录测试并向其中添加断言。</p> <p>仅供参考,以下是 Playwright 测试记录器生成的完整代码:</p> <pre><code class="language-js">import { test, expect } from '@playwright/test' test('test', async ({ page }) => {   await page.goto('http://localhost:5173/')   await page.getByRole('link', { name: 'Sign Up' }).click()   await page.getByLabel('Username:').click()   await page.getByLabel('Username:').fill('test')   await page.getByLabel('Password:').click()   await page.getByLabel('Password:').fill('test')   await page.getByRole('button', { name: 'Sign Up' }).click()   await page.getByLabel('Username:').click()   await page.getByLabel('Username:').fill('test')   await page.getByLabel('Password:').click()   await page.getByLabel('Password:').fill('test')   await page.getByRole('button', { name: 'Log In' }).click() }) </code></pre> <p>现在我们已经记录了一个测试,让我们清理它以使其正常运行。</p> <h3 id="清理并最终确定记录的测试">清理并最终确定记录的测试</h3> <p>如果您查看测试,您会看到它包含了我们在浏览器中执行的所有操作,但它并没有验证我们是否成功登录。它也没有等待页面加载完成,并且一些匹配器没有匹配正确的文本。让我们现在修复这些问题:</p> <ol> <li> <p>将<strong>tests/test-1.spec.ts</strong>重命名为<strong>tests/auth.spec.js</strong>。</p> </li> <li> <p>编辑<strong>tests/auth.spec.js</strong>并将测试重命名为<strong>allows sign up and</strong> <strong>log in</strong>:</p> <pre><code class="language-js">test('allows sign up and log in', async ({ page }) => { </code></pre> </li> <li> <p>我们需要定义一个唯一的用户名,以便能够在不重启后端以清除 MongoDB 内存服务器的情况下多次运行我们的测试:</p> <pre><code class="language-js">  const testUser = 'test' + Date.now() </code></pre> </li> </ol> <p>注意</p> <p>重要的是不要使用相同的用户名注册两次,因为内存中的 MongoDB 数据库被所有测试重用。确保测试可以独立运行,不要依赖于其他测试文件中的数据,因为测试文件可能会以任何顺序运行。单个测试文件内的顺序是保证的。使用<strong>Date.now()</strong>返回当前时间的毫秒数,只要我们不过度并行运行太多测试,它就基本上是防碰撞的。为了获得更安全的防碰撞解决方案,您可以使用 UUID 生成器。</p> <ol> <li> <p>将<strong>page.goto()</strong>的 URL 更改为<strong>/</strong>,以确保它使用我们之前设置的<strong>baseURL</strong>:</p> <pre><code class="language-js">  await page.goto('/') </code></pre> </li> <li> <p>在注册时填写生成的用户名:</p> <pre><code class="language-js">  await page.getByLabel('Username:').fill(testUser) </code></pre> </li> <li> <p>在点击 <strong>注册</strong> 按钮后,使用以下函数等待 URL 更新:</p> <pre><code class="language-js">  await page.getByRole('button', { name: 'Sign Up' }).click()   await page.waitForURL('**/login') </code></pre> <p>等待下一页加载是必要的,因为当前录制不支持页面加载检测,否则它会在旧页面上或重定向期间触发命令,这会导致测试失败。</p> </li> <li> <p>对于登录,我们也会填写生成的用户名:</p> <pre><code class="language-js">  await page.getByLabel('Username:').fill(testUser) </code></pre> </li> <li> <p>之后,让测试点击 <strong>登录</strong> 按钮并等待 URL 再次更新:</p> <pre><code class="language-js">  await page.getByRole('button', { name: 'Log In' }).click()   await page.waitForURL('**/') </code></pre> </li> <li> <p>为了更方便地匹配 <strong>Header</strong> React 组件,编辑 <strong>src/components/Header.jsx</strong> 并将 <strong><div></strong> 元素转换为 <strong><nav></strong> 元素:</p> <pre><code class="language-js">export function Header() {   const [token, setToken] = useAuth()   if (token) {     const { sub } = jwtDecode(token)     return (       <nav>         Logged in as <User id={sub} />         <br />         <button onClick={() => setToken(null)}>Logout</button>       </nav>     )   }   return (     <nav>       <Link to='/login'>Log In</Link> | <Link to='/signup'>Sign Up</Link>     </nav>   ) } </code></pre> </li> <li> <p>在测试结束时,我们现在添加一个断言来检查 Header(<strong><nav></strong> 元素)是否包含文本 <strong>Logged in as</strong> 和生成的用户名:</p> <pre><code class="language-js">  await expect(page.locator('nav')).toContainText('Logged in as ' + testUser) }) </code></pre> <p>使用 <code>toContainText</code> 而不是 <code>toHaveText</code> 确保文本不必与提供的字符串完全匹配。在我们的例子中,是 <code><nav></code> 元素,所以完整的文本将是 <strong>Logged in</strong> <strong>as testXXXXLogout</strong>。</p> </li> <li> <p>使用 VS Code 扩展或通过在终端中运行 <strong>npx playwright test</strong> 命令(根据你的喜好)运行测试,你现在会看到它已经成功通过!</p> </li> </ol> <p>注意</p> <p>如果测试没有成功执行,你可能不小心记录了一些额外的操作并且没有正确清理。将你的测试与本书提供的代码示例进行比较,以确保测试被正确定义并清理。</p> <p>现在我们已经知道了在 Playwright 中定义基本测试的工作方式,让我们学习如何使用固定装置进行可重复的测试设置。</p> <h1 id="使用固定装置的可重复测试设置">使用固定装置的可重复测试设置</h1> <p>在创建认证测试后,你可能正在想:如果我想定义一个创建新帖子的测试怎么办?我们首先必须注册,然后登录,然后创建帖子。这相当繁琐,并且随着测试变得越来越复杂,定义测试也会变得越来越繁琐。幸运的是,Playwright 为这类问题提供了解决方案。Playwright 引入了一个称为固定装置的概念,它是测试的上下文,可以包含可重复使用的函数。例如,我们可以定义一个 <code>auth</code> 固定装置,为所有测试提供注册和登录函数。</p> <p>当我们使用 Jest 时,我们使用 before/after 钩子来为多个测试准备公共环境。固定装置相对于 before/after 钩子有一些优势。主要的是,它们将设置和清理封装在同一个地方,并且可以在测试文件之间重复使用,可组合,并且更灵活。此外,固定装置是按需提供的,这意味着 Playwright 将只为运行某个特定测试设置必要的固定装置。</p> <p>Playwright 还包含一些开箱即用的固定装置,我们现在将要学习这些内容。</p> <h2 id="内置固定装置概述">内置固定装置概述</h2> <p>Playwright 随带一些内置固定装置,其中之一我们已经学习过:<code>page</code> 固定装置。我们现在将简要介绍 Playwright 提供的一些最重要的内置固定装置:</p> <ul> <li> <p><strong>浏览器</strong>:允许控制浏览器功能,例如打开新页面</p> </li> <li> <p><strong>browserName</strong>:包含当前运行测试的浏览器的名称</p> </li> <li> <p><strong>page</strong>:迄今为止最重要的内置固定装置,用于控制与页面的交互、访问 URL、匹配元素、执行操作等</p> </li> <li> <p><strong>上下文</strong>:当前测试运行的独立上下文</p> </li> <li> <p><strong>请求</strong>:用于从 Playwright 发送 API 请求</p> </li> </ul> <p>现在我们已经了解了 Playwright 提供的内置固定装置,让我们继续定义我们自己的固定装置。</p> <h2 id="编写我们自己的固定装置">编写我们自己的固定装置</h2> <p>注册和登录是我们将在端到端测试中经常需要执行的操作,因此它们是创建固定装置的完美案例。按照以下步骤创建一个新的 <code>auth</code> 固定装置:</p> <ol> <li> <p>创建一个新的 <strong>tests/fixtures/</strong> 文件夹。</p> </li> <li> <p>在其中,创建一个新的 <strong>tests/fixtures/AuthFixture.js</strong> 文件,在那里我们定义一个 <strong>AuthFixture</strong> 类:</p> <pre><code class="language-js">export class AuthFixture { </code></pre> </li> <li> <p>这个类将在构造函数中接收 <strong>page</strong> 固定装置:</p> <pre><code class="language-js">  constructor(page) {     this.page = page   } </code></pre> </li> <li> <p>定义一个 <strong>signUpAndLogIn</strong> 方法,该方法遵循 auth 测试中的操作以生成一个唯一的用户名,然后注册并登录用户:</p> <pre><code class="language-js">  async signUpAndLogIn() {     const testUser = 'test' + Date.now()     await this.page.goto('/signup')     await this.page.getByLabel('Username:').fill(testUser)     await this.page.getByLabel('Password:').fill('password')     await this.page.getByRole('button', { name: 'Sign Up' }).click()     await this.page.waitForURL('**/login')     await this.page.getByLabel('Username:').fill(testUser)     await this.page.getByLabel('Password:').fill('password')     await this.page.getByRole('button', { name: 'Log In' }).click()     await this.page.waitForURL('**/')     return testUser   } } </code></pre> </li> <li> <p>创建一个新的 <strong>tests/fixtures/index.js</strong> 文件。在其中,从 Playwright 导入 <strong>test</strong> 函数(将其重命名为 <strong>baseTest</strong>)和刚刚定义的 <strong>AuthFixture</strong>:</p> <pre><code class="language-js">import { test as baseTest } from '@playwright/test' import { AuthFixture } from './AuthFixture.js' </code></pre> </li> <li> <p>然后,定义并导出一个新的 <strong>test</strong> 函数,通过在其中定义一个新的 <strong>auth</strong> 固定装置来扩展 Playwright 的 <strong>baseTest</strong> 函数:</p> <pre><code class="language-js">export const test = baseTest.extend({   auth: async ({ page }, use) => {     const authFixture = new AuthFixture(page)     await use(authFixture)   }, }) </code></pre> </li> </ol> <p>小贴士</p> <p>在调用 <strong>use()</strong> 函数之前,也可以对固定装置上下文进行额外的设置,在调用之后进行额外的清理。这可以用于,例如,在执行测试之前创建一组示例帖子,然后在测试之后再次删除它们。如果后端有删除用户的方法,在使用固定装置后创建一个临时用户,并在使用固定装置后再次删除创建的用户名,将是一个更好的选项来处理用户名冲突的问题。</p> <ol> <li> <p>此外,重新导出 Playwright 的 <strong>expect</strong> 函数,以便更容易从我们的固定装置中导入:</p> <pre><code class="language-js">export { expect } from '@playwright/test' </code></pre> </li> </ol> <p>现在我们已经定义了我们的自定义固定装置,让我们在创建新测试时使用它!</p> <h2 id="使用自定义固定装置">使用自定义固定装置</h2> <p>我们现在将定义一个创建新帖子的端到端测试。要创建帖子,我们需要登录,以便我们可以使用我们的 <code>auth</code> 固定装置来准备环境。按照以下步骤定义新测试并使用我们的自定义固定装置:</p> <ol> <li> <p>创建一个新的 <strong>tests/create-post.spec.js</strong> 文件。为了使用自定义固定装置,我们现在需要从 <strong>fixtures/index.js</strong> 文件中导入 <strong>test</strong> 和 <strong>expect</strong> 函数:</p> <pre><code class="language-js">import { test, expect } from './fixtures/index.js' </code></pre> </li> <li> <p>定义一个新的测试来验证帖子创建功能,使用 <strong>page</strong> 和 <strong>auth</strong> 固定装置:</p> <pre><code class="language-js">test('allows creating a new post', async ({ page, auth }) => { </code></pre> </li> <li> <p>我们现在可以使用自定义 <strong>auth</strong> 固定装置中的 <strong>signUpAndLogIn</strong> 方法来创建和登录新用户:</p> <pre><code class="language-js">  const testUser = await auth.signUpAndLogIn() }) </code></pre> </li> <li> <p>我们可以再次使用 Playwright 代码生成来记录我们的测试。首先,保存文件并启用 <strong>Show</strong> <strong>browser</strong> 执行 <strong>create-post.spec.js</strong> 测试。</p> </li> <li> <p>然后,在调用<strong>auth.signUpAndLogIn</strong>函数后创建一个新行,并按<strong>光标处</strong>的<strong>Record</strong>。</p> </li> <li> <p>现在我们可以从已经打开的浏览器窗口(也已经登录,因为已经调用了固定装置方法!)中记录操作。点击标题字段,将<strong>Test Post</strong>作为帖子标题输入,然后按<em>Tab</em>键跳到下一个字段,输入<strong>Hello World</strong>作为帖子内容,然后再次按<em>Tab</em>键并按<em>Return/Enter</em>键创建一个新的帖子。</p> </li> </ol> <p>注意</p> <p>实际上并没有创建帖子,因为 Playwright 在运行完毕后立即关闭了后端,所以在录制时后端已经关闭。如果你想在后端运行时录制,请探索<strong>playwright.config.js</strong>中的<strong>webServer.reuseExistingServer</strong>设置。</p> <ol> <li> <p>返回到文件,你会看到所有操作都已正确记录!以下代码应该已被记录:</p> <pre><code class="language-js">  await page.getByLabel('Title:').click()   await page.getByLabel('Title:').fill('Test Post')   await page.getByLabel('Title:').press('Tab')   await page.locator('textarea').fill('Hello World!')   await page.locator('textarea').press('Tab')   await page.getByRole('button', { name: 'Create' }).press('Enter') </code></pre> </li> <li> <p>现在,我们只需要添加一个检查以确保帖子已成功创建:</p> <pre><code class="language-js">  await expect(page.getByText(`Test PostWritten by ${testUser}`)).toBeVisible() }) </code></pre> <p>由于我们控制测试环境,检查文本<strong>Test PostWritten by testXXX</strong>(在“Post”和“Written”之间没有空格)在页面上可见就足够了。这将告诉我们帖子已在列表中创建。</p> </li> <li> <p>运行测试,你会看到它成功通过!</p> </li> </ol> <p>我们可以为处理帖子(创建、编辑、删除)创建额外的固定装置,并使用这些装置,例如,验证单个帖子的链接是否正常工作并相应地调整标题。然而,像这样扩展端到端测试与我们已经做过的类似,因此留作你的练习。</p> <h1 id="查看测试报告和在-ci-中运行">查看测试报告和在 CI 中运行</h1> <p>在成功创建我们博客应用的端到端测试之后,让我们通过学习如何查看 HTML 测试报告以及如何在 CI 中运行 Playwright 来结束本章。</p> <h2 id="查看-html-报告">查看 HTML 报告</h2> <p>Playwright 会自动生成测试运行的 HTML 报告。我们可以通过执行以下命令来运行所有测试:</p> <pre><code class="language-js">$ npx playwright test </code></pre> <p>然后,运行以下命令来提供和查看最后一次运行的 HTML 报告:</p> <pre><code class="language-js">$ npx playwright show-report </code></pre> <p>报告应在新的浏览器窗口中打开,如下所示:</p> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_09_5.jpg" alt="图 9.5 – 由 Playwright 生成的 HTML 测试报告" loading="lazy"></p> <p>图 9.5 – 由 Playwright 生成的 HTML 测试报告</p> <p>如我们所见,我们的三个测试在所有三个浏览器上成功运行。点击其中一个测试运行,以查看所有执行测试步骤的详细信息。</p> <h2 id="在-ci-中运行-playwright-测试">在 CI 中运行 Playwright 测试</h2> <p>当我们初始化 Playwright 时,我们被问及是否想要生成一个 GitHub Actions CI 文件。我们同意了,因此 Playwright 自动在<code>.github/workflows/playwright.yml</code>文件中为我们生成了一个 CI 配置。此工作流程会检出仓库,安装所有依赖项,安装 Playwright 浏览器,运行所有 Playwright 测试,然后将报告作为工件上传,以便可以从 CI 运行中查看。我们仍然需要调整 CI 工作流程,以便也安装后端依赖项,所以现在让我们来做这件事:</p> <ol> <li> <p>编辑<strong>.github/workflows/playwright.yml</strong>,并向其中添加以下步骤:</p> <pre><code class="language-js">      - name: Install dependencies         run: npm ci       - name: Install backend dependencies npm ci command ensures that the project already has a package-lock.json file and does not write a lock file, ensuring a clean state for CI to run on. </code></pre> </li> <li> <p>将所有内容添加、提交并推送到 GitHub 仓库,以查看 Playwright 在 CI 中的运行情况。</p> </li> </ol> <p>注意</p> <p>确保仅从<strong>ch9</strong>文件夹的内容(而不是整个<strong>Full-Stack-React-Projects</strong>文件夹)创建一个新的仓库,否则 GitHub Actions 将无法检测到<strong>.****github</strong>文件夹。</p> <ol> <li> <p>前往 GitHub,点击<strong>操作</strong>标签,在侧边栏中选择<strong>Playwright Tests</strong>工作流程,然后点击最新的工作流程运行。</p> </li> <li> <p>在运行的底部,有一个<strong>工件</strong>部分,其中包含一个<strong>playwright-report</strong>对象,可以下载以查看 HTML 报告。</p> </li> </ol> <p>以下截图显示了在 GitHub Actions 中运行的 Playwright 测试,报告作为工件提供:</p> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_09_6.jpg" alt="图 9.6 – Playwright 在 GitHub Actions 中运行" loading="lazy"></p> <p>图 9.6 – 在 GitHub Actions 中运行的 Playwright</p> <p>如我们所见,通过提供的模板在 CI 中运行 Playwright 既简单又直接。</p> <h1 id="摘要-7">摘要</h1> <p>在本章中,我们学习了如何使用 Playwright 进行端到端测试。我们首先在我们的项目中设置 Playwright,并为端到端测试准备了我们的后端。然后,我们编写并运行了我们的第一个测试。接下来,我们学习了关于固定装置的内容,以便创建可重用的测试上下文。最后,我们查看了生成的 HTML 报告,并设置了 CI 以运行 Playwright,生成报告,并将其作为工件保存在管道中。</p> <p>在下一章<em>第十章**,使用 MongoDB 和 Victory 聚合和可视化统计信息</em>,我们将学习如何使用 MongoDB 聚合数据,并通过后端公开这些聚合数据。然后,我们将在前端消费这些聚合数据,并使用 Victory 和各种可视化类型来可视化它。</p> <h1 id="第十章使用-mongodb-和-victory-聚合和可视化统计数据">第十章:使用 MongoDB 和 Victory 聚合和可视化统计数据</h1> <p>在本章中,我们将学习如何使用 MongoDB 和 Victory 收集、聚合和可视化博客应用的统计数据。我们首先学习如何从查看博客帖子的用户那里收集事件。然后,我们随机生成一些事件以获得可工作的数据集。我们使用这个数据集来学习如何使用 MongoDB 聚合数据并生成汇总统计数据,例如每篇帖子的观看次数或平均会话持续时间。这类信息将帮助作者了解他们的帖子表现如何。最后,我们使用 Victory 库创建一些图表来可视化这些聚合统计数据。</p> <p>在本章中,我们将涵盖以下主要主题:</p> <ul> <li> <p>收集和模拟事件</p> </li> <li> <p>使用 MongoDB 聚合数据</p> </li> <li> <p>在后端实现数据聚合</p> </li> <li> <p>使用 Victory 在前端集成和可视化数据</p> </li> </ul> <h1 id="技术要求-9">技术要求</h1> <p>在开始之前,请从 <em>第 1</em> 章准备全栈开发 和 <em>第 2</em> 章了解 Node.js 及 MongoDB 安装所有要求。</p> <p>那些章节中列出的版本是书中使用的版本。虽然安装较新版本可能不会有问题,但请注意,某些步骤在较新版本上可能有所不同。如果您在使用本书提供的代码和步骤时遇到问题,请尝试使用第 <em>1</em> 章和 <em>2</em> 章中提到的版本。<em>第 1</em> 章和 <em>第 2</em> 章分别介绍了<em>为全栈开发做准备</em>和<em>了解 Node.js 及 MongoDB</em>。</p> <p>您可以在 GitHub 上找到本章的代码:<a href="https://github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch10" target="_blank"><code>github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch10</code></a>。</p> <p>如果您克隆了本书的完整仓库,当运行 <code>npm install</code> 时,Husky 可能找不到 <code>.git</code> 目录。在这种情况下,只需在相应章节文件夹的根目录下运行 <code>git init</code>。</p> <p>本章的 CiA 视频可以在以下网址找到:<a href="https://youtu.be/DmSq2P_IQQs" target="_blank"><code>youtu.be/DmSq2P_IQQs</code></a>。</p> <h1 id="收集和模拟事件">收集和模拟事件</h1> <p>在我们开始聚合和可视化统计数据之前,我们首先需要收集(稍后模拟)事件,我们将使用这些事件来创建统计数据。我们将首先考虑我们想要收集哪些数据,以及哪些数据对我们有用。现在我们将专注于帖子观看,因此我们希望按以下方式显示每篇帖子的统计数据:</p> <ul> <li> <p>帖子的总观看次数</p> </li> <li> <p>帖子的每日观看次数</p> </li> <li> <p>帖子的每日平均观看时长</p> </li> </ul> <p>让我们先创建一个事件数据库模型,这将使我们能够展示这些统计数据。</p> <h2 id="创建事件模型">创建事件模型</h2> <p>为了创建这些统计数据,我们需要从用户那里收集事件。事件将包含对帖子的引用、用于跟踪同一观看事件的会话 ID、一个动作(开始观看、结束观看)以及事件发生的时间。</p> <p>让我们开始定义事件的数据库模型:</p> <ol> <li> <p>将现有的 <strong>ch9</strong> 文件夹复制到一个新的 <strong>ch10</strong> 文件夹中,如下所示:</p> <pre><code class="language-js">$ cp -R ch9 ch10 </code></pre> </li> <li> <p>在 VS Code 中打开 <strong>ch10</strong> 文件夹。</p> </li> <li> <p>创建一个新的 <strong>backend/src/db/models/event.js</strong> 文件。在此文件中,定义一个包含对帖子引用的模式:</p> <pre><code class="language-js">import mongoose, { Schema } from 'mongoose' const eventsSchema = new Schema(   {     post: { type: Schema.Types.ObjectId, ref: 'post', required: true }, </code></pre> </li> <li> <p>然后定义 <strong>session</strong>、<strong>action</strong> 和 <strong>date</strong>:</p> <pre><code class="language-js">    session: { type: String, required: true },     action: { type: String, required: true },     date: { type: Date, required: true },   },   { timestamps: true }, ) </code></pre> </li> <li> <p>最后,导出模型:</p> <pre><code class="language-js">export const Event = mongoose.model('events', eventsSchema) </code></pre> </li> </ol> <p>现在我们已经定义了数据库模型,让我们继续定义一个服务函数和路由来跟踪事件。</p> <h2 id="定义一个服务函数和路由来跟踪事件">定义一个服务函数和路由来跟踪事件</h2> <p>现在我们已经成功定义了事件的数据库模型,让我们创建一个服务函数和路由来跟踪新事件,如下所示:</p> <ol> <li> <p>为了生成会话 ID,我们将使用 <strong>uuid</strong> 库,它为我们生成 <strong>全球唯一标识符(UUIDs)</strong>。通过运行以下命令来安装它:</p> <pre><code class="language-js">$ cd backend/ $ npm install uuid@9.0.1 </code></pre> </li> <li> <p>创建一个新的 <strong>backend/src/services/events.js</strong> 文件。在此文件中,从 <strong>uuid</strong> 中导入 <strong>v4</strong> 函数和 <strong>Event</strong> 模型,并定义一个创建新事件文档的函数,如下所示:</p> <pre><code class="language-js">import { v4 as uuidv4 } from 'uuid' import { Event } from '../db/models/event.js' export async function trackEvent({   postId,   action,   session = uuidv4(),   date = Date.now(), }) {   const event = new Event({ post: postId, action, session, date })   return await event.save() } </code></pre> <p>在函数的参数中,我们将默认的会话 ID 设置为随机生成的 UUID,并将日期设置为当前日期。</p> </li> <li> <p>创建一个新的 <strong>backend/src/routes/events.js</strong> 文件。在此文件中,导入 <strong>trackEvent</strong> 函数和 <strong>getPostById</strong> 函数:</p> <pre><code class="language-js">import { trackEvent } from '../services/events.js' import { getPostById } from '../services/posts.js' </code></pre> </li> <li> <p>定义一个新的 <strong>POST /api/v1/events</strong> 路由,其中我们从请求体中获取 <strong>postId</strong>、<strong>session</strong> 和 <strong>action</strong>:</p> <pre><code class="language-js">export function eventRoutes(app) {   app.post('/api/v1/events', async (req, res) => {     try {       const { postId, session, action } = req.body </code></pre> </li> <li> <p>然后,我们检查给定 ID 的帖子是否存在于数据库中。如果不存在,我们返回一个 <strong>400 Bad Request</strong> 状态码:</p> <pre><code class="language-js">      const post = await getPostById(postId)       if (post === null) return res.status(400).end() </code></pre> </li> <li> <p>如果帖子存在,我们获取会话 ID 并使用 <strong>trackEvent</strong> 函数创建一个新的事件:</p> <pre><code class="language-js">      const event = await trackEvent({ postId, session, action })       return res.json({ session: event.session })     } catch (err) {       console.error('error tracking action', err)       return res.status(500).end()     }   }) } </code></pre> </li> <li> <p>编辑 <strong>backend/src/app.js</strong> 并导入 <strong>eventRoutes</strong>:</p> <pre><code class="language-js">import { eventRoutes } from './routes/events.js' </code></pre> </li> <li> <p>然后将路由挂载到应用上:</p> <pre><code class="language-js">postRoutes(app) userRoutes(app) eventRoutes(app) </code></pre> </li> <li> <p>按照以下方式启动后端(并保持运行以供未来开发使用):</p> <pre><code class="language-js">$ cd backend/ $ npm run dev </code></pre> </li> </ol> <p>现在我们已经成功定义了一个后端路由来跟踪事件,让我们在前端实现事件收集。</p> <h2 id="在前端收集事件">在前端收集事件</h2> <p>在定义好路由之后,让我们为前端创建一个 API 函数,并定义一种跟踪用户何时开始和结束查看帖子的方法。按照以下步骤在前端收集事件:</p> <ol> <li> <p>创建一个新的 <strong>src/api/events.js</strong> 文件,并定义一个 <strong>postTrackEvent</strong> 函数,它接受一个事件对象并将其发送到之前定义的路由:</p> <pre><code class="language-js">export const postTrackEvent = (event) =>   fetch(`${import.meta.env.VITE_BACKEND_URL}/events`, {     method: 'POST',     headers: {       'Content-Type': 'application/json',     },     body: JSON.stringify(event),   }).then((res) => res.json()) </code></pre> </li> <li> <p>编辑 <strong>src/pages/ViewPost.jsx</strong> 并导入 <strong>useEffect</strong>、<strong>useState</strong> 和 <strong>useMutation</strong> 钩子:</p> <pre><code class="language-js">import { useEffect, useState } from 'react' import { useQuery, useMutation } from '@tanstack/react-query' </code></pre> </li> <li> <p>此外,导入 <strong>postTrackEvent</strong> API 函数:</p> <pre><code class="language-js">import { postTrackEvent } from '../api/events.js' </code></pre> </li> <li> <p>现在,在 <strong>ViewPost</strong> 函数内部,定义一个新的状态钩子来存储会话 ID,以及一个突变来跟踪事件。当事件成功跟踪后,我们从后端获取会话 ID,并将其存储在状态钩子中:</p> <pre><code class="language-js">  const [session, setSession] = useState()   const trackEventMutation = useMutation({     mutationFn: (action) => postTrackEvent({ postId, action, session }),     onSuccess: (data) => setSession(data?.session),   }) </code></pre> </li> <li> <p>然后,定义一个新的效果钩子,在用户打开帖子后一秒跟踪一个<strong>startView</strong>事件(以防止跟踪意外事件,例如快速刷新),当效果钩子卸载时跟踪<strong>endView</strong>事件。我们不给它任何依赖项,以确保效果钩子仅在页面挂载和卸载时触发:</p> <pre><code class="language-js">  useEffect(() => {     let timeout = setTimeout(() => {       trackEventMutation.mutate('startView')       timeout = null     }, 1000)     return () => {       if (timeout) clearTimeout(timeout)       else trackEventMutation.mutate('endView')     }   }, []) </code></pre> </li> <li> <p>按照以下步骤启动前端(并保持运行以供未来开发使用):</p> <pre><code class="language-js">ch10 folder, not inside the backend folder. </code></pre> </li> </ol> <p>如果你现在在浏览器中打开一个帖子并查看是否跟踪了<code>startView</code>事件。当我们离开页面时,会跟踪<code>endView</code>事件。</p> <p>让我们现在继续模拟事件,以便我们以后有更多数据可以聚合和可视化。</p> <h2 id="模拟事件">模拟事件</h2> <p>模拟事件是生成用于测试聚合和可视化的样本数据的好方法。在我们的模拟中,我们首先从数据库中清除所有当前用户,然后创建一组样本用户。我们对帖子重复相同的步骤,然后对事件进行模拟,模拟一个随机用户创建帖子,以及有人随机查看随机帖子一段时间。</p> <p>按照以下步骤实现模拟:</p> <ol> <li> <p>首先,我们应该更改数据库以避免丢失我们在其他章节中创建的任何数据。编辑<strong>backend/.env</strong>并将以下行从<strong>blog</strong>更改为<strong>blog-simulated</strong>:</p> <pre><code class="language-js">DATABASE_URL=mongodb://localhost:27017/blog-simulated </code></pre> </li> <li> <p>现在,创建一个新的<strong>backend/simulateEvents.js</strong>文件,在其中我们导入<strong>dotenv</strong>、<strong>initDatabase</strong>函数以及所有相关的模型和服务函数:</p> <pre><code class="language-js">import dotenv from 'dotenv' dotenv.config() import { initDatabase } from './src/db/init.js' import { Post } from './src/db/models/post.js' import { User } from './src/db/models/user.js' import { Event } from './src/db/models/event.js' import { createUser } from './src/services/users.js' import { createPost } from './src/services/posts.js' import { trackEvent } from './src/services/events.js' </code></pre> </li> <li> <p>定义模拟的开始时间,这里设置为 30 天前(30 天 * 24 小时 * 60 分钟 * 60 秒 * 1000 毫秒),以及结束时间,即现在:</p> <pre><code class="language-js">const simulationStart = Date.now() - 1000 * 60 * 60 * 24 * 30 const simulationEnd = Date.now() </code></pre> </li> <li> <p>我们还定义了要模拟的用户、帖子数和查看次数:</p> <pre><code class="language-js">const simulatedUsers = 5 const simulatedPosts = 10 const simulatedViews = 10000 </code></pre> </li> <li> <p>然后,定义<strong>simulateEvents</strong>函数,在其中我们首先初始化数据库:</p> <pre><code class="language-js">async function simulateEvents() {   const connection = await initDatabase() </code></pre> </li> <li> <p>接下来,<em>删除</em>所有现有用户,并通过初始化一个包含要模拟的用户数的空数组并映射它来创建新用户:</p> <pre><code class="language-js">  await User.deleteMany({})   const createdUsers = await Promise.all(     Array(simulatedUsers)       .fill(null)       .map(         async (_, u) =>           await createUser({             username: `user-${u}`,             password: `password-${u}`,           }),       ),   )   console.log(`created ${createdUsers.length} users`) </code></pre> </li> </ol> <p>信息</p> <p><strong>Array(X)</strong>函数可以用来创建一个包含<strong>X</strong>个条目的数组,然后需要用初始值填充它,才能对其进行迭代。</p> <ol> <li> <p>现在,为帖子重复相同的步骤:</p> <pre><code class="language-js">  await Post.deleteMany({})   const createdPosts = await Promise.all(     Array(simulatedPosts)       .fill(null)       .map(async (_, p) => {         const randomUser =           createdUsers[Math.floor(Math.random() * simulatedUsers)]         return await createPost(randomUser._id, {           title: `Test Post ${p}`,           contents: `This is a test post ${p}`,         })       }),   )   console.log(`created ${createdPosts.length} posts`) </code></pre> </li> </ol> <p>信息</p> <p>我们使用<strong>Math.floor(Math.random() * maxNumber)</strong>来创建一个介于<strong>0</strong>和<strong>maxNumber</strong>(不包括<strong>maxNumber</strong>)之间的随机整数,这对于索引数组是完美的。</p> <ol> <li> <p>最后,我们对事件重复相同的步骤:</p> <pre><code class="language-js">  await Event.deleteMany({})   const createdViews = await Promise.all(     Array(simulatedViews)       .fill(null)       .map(async () => {         const randomPost =           createdPosts[Math.floor(Math.random() * simulatedPosts)] </code></pre> </li> <li> <p>在这里,我们在定义的模拟日期内随机开始会话:</p> <pre><code class="language-js">        const sessionStart =           simulationStart + Math.random() * (simulationEnd - simulationStart) </code></pre> </li> <li> <p>然后我们随机在 0 到 5 分钟之后结束:</p> <pre><code class="language-js">        const sessionEnd =           sessionStart + 1000 * Math.floor(Math.random() * 60 * 5) </code></pre> </li> <li> <p>现在,我们通过创建一个<strong>startView</strong>事件来模拟事件收集:</p> <pre><code class="language-js">        const event = await trackEvent({           postId: randomPost._id,           action: 'startView',           date: new Date(sessionStart),         }) </code></pre> </li> <li> <p>然后我们模拟一个<strong>endView</strong>事件,其中我们使用从第一个事件返回的会话 ID:</p> <pre><code class="language-js">        await trackEvent({           postId: randomPost._id,           session: event.session,           action: 'endView',           date: new Date(sessionEnd),         })       }),   )   console.log(`successfully simulated ${createdViews.length} views`) </code></pre> </li> <li> <p>最后,我们从数据库断开连接并调用函数:</p> <pre><code class="language-js">  await connection.disconnect() } simulateEvents() </code></pre> </li> <li> <p>我们的模拟现在已准备好使用!执行以下命令以启动它:</p> <pre><code class="language-js">$ cd backend/ $ node simulateEvents.js </code></pre> </li> </ol> <p>你会看到模拟首先创建 5 个用户,然后是 10 个帖子,最后模拟了 10,000 次查看。</p> <p>在下一节中,我们将使用这个数据集来尝试使用 MongoDB 进行一些聚合操作!</p> <h1 id="使用-mongodb-聚合数据">使用 MongoDB 聚合数据</h1> <p>有时候,我们不仅仅想要简单地从数据库中检索数据,而是想要通过组合和汇总数据来创建一些统计数据。这个过程称为 <strong>数据聚合</strong>,它可以帮助我们更好地了解数据。例如,我们可以计算每篇帖子的总浏览量,获取每篇帖子的每日浏览量,或者计算查看帖子时的平均会话时长。</p> <p>MongoDB 支持使用集合上的 <code>.aggregate()</code> 函数进行特殊聚合语法。使用 MongoDB 的聚合功能允许我们高效地查询和处理文档。它提供的操作类似于可以使用 <strong>结构化查询语言</strong>(<strong>SQL</strong>)查询完成的操作。我们主要将使用以下聚合操作:</p> <ul> <li> <p><strong>$match</strong>:用于过滤文档</p> </li> <li> <p><strong>$group</strong>:用于按某个属性对文档进行分组</p> </li> <li> <p><strong>$project</strong>:用于将属性映射到不同的属性,或对其进行处理</p> </li> <li> <p><strong>$sort</strong>:用于对文档进行排序</p> </li> </ul> <p>信息</p> <p>MongoDB 提供了许多更高级的聚合操作,所有这些都可以在他们的文档中找到(<a href="https://www.mongodb.com/docs/manual/aggregation/" target="_blank"><code>www.mongodb.com/docs/manual/aggregation/</code></a>)。他们也在不断添加更多操作,使聚合功能更加强大。</p> <p><code>aggregate</code> 函数通过提供一个对象数组来工作,每个对象定义了 <strong>聚合管道</strong> 的一个 <strong>阶段</strong>。我们将通过实际使用它们来学习本章中的聚合,以了解更多关于聚合的信息。</p> <h2 id="获取每篇帖子的总浏览量">获取每篇帖子的总浏览量</h2> <p>我们将要定义的第一个聚合是获取每篇帖子的总浏览量的方法。对于这样的聚合,我们需要 <code>$match</code> 来过滤所有 <code>startView</code> 动作(否则我们会重复计算浏览量,因为每个博客帖子的查看都有一个 <code>endView</code> 动作),以及 <code>$group</code> 来按帖子 ID 分组结果,然后使用 <code>$count</code> 返回文档数量。</p> <p>按照以下步骤创建您的第一个聚合管道:</p> <ol> <li> <p>为我们的游乐场脚本创建一个新的 <strong>backend/playground/</strong> 文件夹。</p> </li> <li> <p>在 VS Code 的侧边栏中点击 MongoDB 扩展(叶子图标)。</p> </li> <li> <p>连接到数据库,然后展开 <strong>Playgrounds</strong> 部分(如果尚未展开),然后点击 <strong>创建新</strong> <strong>Playground</strong> 按钮。</p> <p>将打开一个新文件,其中已经预定义了一些代码。<em>删除</em>所有预定义的代码,因为我们将要<em>替换</em>它们为我们自己的代码。</p> </li> <li> <p>首先,定义 <strong>use</strong> 和 <strong>db</strong> 全局变量,MongoDB 游乐场为我们提供了这些变量:</p> <pre><code class="language-js">/* global use, db */ </code></pre> </li> <li> <p>然后,使用 <strong>blog-simulated</strong> 数据库:</p> <pre><code class="language-js">use('blog-simulated') </code></pre> </li> <li> <p>现在,执行以下聚合函数:</p> <pre><code class="language-js">db.getCollection('events').aggregate([ </code></pre> </li> <li> <p>管道的第一阶段将是匹配所有 <strong>startView</strong> 动作:</p> <pre><code class="language-js">  {     $match: { action: 'startView' },   }, </code></pre> </li> <li> <p>然后,我们按 post 进行分组。<strong><span class="math inline">\(group** 阶段要求我们定义一个 **_id**,它包含要分组的属性。我们需要使用 **\)</span></strong> 操作符来解析要使用的变量,因此 <strong>$post</strong> 将访问 <strong>event.post</strong> 属性(它包含一个帖子 ID):</p> <pre><code class="language-js">  {     $group: {       _id: '$post',       views: { $count: {} },     },   }, ]) </code></pre> </li> <li> <p>将脚本保存为 <strong>backend/playground/views-per-post.mongodb.js</strong> 文件。</p> </li> <li> <p>点击右上角的 <strong>Play</strong> 图标来运行脚本。将打开一个新标签页,显示聚合的结果:</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_10_1.jpg" alt="图 10.1 – 我们第一个 MongoDB 聚合结果!" loading="lazy"></p> <p>图 10.1 – 我们第一个 MongoDB 聚合结果!</p> <p>在创建和执行了我们的第一个简单聚合之后,让我们通过编写更高级的聚合来继续练习。</p> <h2 id="获取每帖每日的查看次数">获取每帖每日的查看次数</h2> <p>既然我们已经熟悉了编写 MongoDB 聚合的一般过程,让我们尝试编写一个更复杂的聚合:获取每帖每日的查看次数。按照以下步骤创建它:</p> <ol> <li> <p>如前所述,创建一个新的 playground 文件,使用以下聚合函数:</p> <pre><code class="language-js">/* global use, db */ use('blog-simulated') db.getCollection('events').aggregate([ </code></pre> </li> <li> <p>再次,我们首先匹配只有 <strong>startView</strong> 动作:</p> <pre><code class="language-js">  {     $match: { action: 'startView' },   }, </code></pre> </li> <li> <p>然后,我们使用 <strong><span class="math inline">\(project** 保留 **post** 属性,并定义一个新的 **day** 属性,它使用 **\)</span>dateTrunc</strong> 函数将 <strong>date</strong> 属性简化为仅覆盖日期(而不是包含完整的时间戳):</p> <pre><code class="language-js">  {     $project: {       post: '$post',       day: { $dateTrunc: { date: '$date', unit: 'day' } },     },   }, </code></pre> <p>在使用 <code>$project</code> 时需要注意的一个重要事项是,只有这里列出的属性才会传递到管道中的后续阶段,因此我们需要在这里列出我们稍后仍然需要的所有属性!</p> </li> <li> <p>最后,我们使用 <strong><span class="math inline">\(group** 来通过传递一个对象到 **_id** 属性,按 **post** 和 **day** 对文档进行分组。我们再次使用 **\)</span>count</strong> 来计算每个组中的文档数量:</p> <pre><code class="language-js">  {     $group: {       _id: { post: '$post', day: '$day' },       views: { $count: {} },     },   }, ]) </code></pre> </li> <li> <p>将脚本保存为 <strong>backend/playground/views-per-post-per-day.mongodb.js</strong> 文件。</p> </li> <li> <p>通过点击 <strong>Play</strong> 按钮运行此脚本,你会看到我们现在正在按 post 和 day 分组获取文档列表,以及某个特定日期某个特定帖子的相应查看次数:</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_10_2.jpg" alt="图 10.2 – 每日每帖的查看次数" loading="lazy"></p> <p>图 10.2 – 每日每帖的查看次数</p> <p>在获取每帖每日的查看次数之后,让我们继续练习,计算平均会话持续时间。</p> <h2 id="计算平均会话持续时间">计算平均会话持续时间</h2> <p>如你所记,我们首先发送一个 <code>startView</code> 动作,然后稍后发送一个 <code>endView</code> 动作,这两个动作都有一个单独的 <code>date</code>。让我们使用聚合将这些两个动作组合成一个单独的文档,并计算会话的持续时间:</p> <ol> <li> <p>创建一个新的 playground 文件,并开始编写一个聚合,首先使用 <strong>$project</strong> 创建一些新的属性,并保留 <strong>session</strong> 属性,因为我们稍后会用到它:</p> <pre><code class="language-js">/* global use, db */ use('blog-simulated') db.getCollection('events').aggregate([   {     $project: {       session: '$session',       startDate: {         $cond: [{ $eq: ['$action', 'startView'] }, '$date', undefined],       },       endDate: { $cond: [{ $eq: ['$action', 'endView'] }, '$date', undefined] },     },   }, </code></pre> <p>在这里,我们使用 <code>$cond</code> 操作符来创建一个条件(类似于三元/if 语句)。它接受一个包含三个元素的数组:第一个是条件,接下来是条件匹配的结果,最后是条件不匹配的结果。在我们的例子中,我们检查 <code>action</code> 属性是否为 <code>startView</code>(使用 <code>$eq</code> 操作符)。如果是真的,那么我们将日期设置为 <code>startDate</code> 属性。否则,我们不定义 <code>startDate</code> 属性。同样,如果操作是 <code>endView</code>,我们创建一个 <code>endDate</code> 属性。</p> </li> <li> <p>现在,我们可以按会话 ID 对文档进行分组,并选择会话的最低起始日期和最高结束日期:</p> <pre><code class="language-js">  {     $group: {       _id: '$session',       startDate: { $min: '$startDate' },       endDate: { $max: '$endDate' },     },   }, </code></pre> <p>每个会话应该只有一个 <code>startView</code> 和 <code>endView</code> 操作,但我们不能保证这一点,因此我们需要将它们聚合为一个单一值!</p> </li> <li> <p>最后,我们再次使用 <strong>$project</strong> 来将 <strong>_id</strong> 属性重命名为 <strong>session</strong>,并通过从 <strong>endDate</strong> 减去 <strong>startDate</strong> 来计算 <strong>duration</strong>:</p> <pre><code class="language-js">  {     $project: {       session: '$_id',       duration: { $subtract: ['$endDate', '$startDate'] },     },   }, ]) </code></pre> </li> <li> <p>将脚本保存为 <strong>backend/playground/session-duration.mongodb.js</strong> 文件。</p> </li> <li> <p>运行脚本,您将看到包含会话 ID 和相应毫秒数的持续时间的一个文档列表:</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_10_3.jpg" alt="图 10.3 – 会话持续时间的聚合结果" loading="lazy"></p> <p>图 10.3 – 会话持续时间的聚合结果</p> <p>现在我们对 MongoDB 中的数据聚合工作更加熟悉了,让我们在我们的后端实现类似的聚合!</p> <h1 id="在后端实现数据聚合">在后端实现数据聚合</h1> <p>对于我们的后端,我们将使用非常相似的聚合管道。然而,我们需要稍作调整,因为我们总是只想获取单个帖子的数据。因此,我们首先将使用 <code>$match</code> 来过滤我们的文档。这也确保了即使我们的数据库中有数百万个事件,聚合操作也能保持快速,因为我们首先将所有事件过滤为单个帖子的所有事件!</p> <h2 id="定义聚合服务函数">定义聚合服务函数</h2> <p>按照以下步骤在后台实现聚合函数:</p> <ol> <li> <p>编辑 <strong>backend/src/services/events.js</strong> 并定义一个新函数来获取帖子的总浏览次数。在这种情况下,我们可以通过使用 <strong>countDocuments</strong> 函数而不是聚合函数来简化我们的代码:</p> <pre><code class="language-js">export async function getTotalViews(postId) {   return {     views: await Event.countDocuments({ post: postId, action: 'startView' }),   } } </code></pre> </li> <li> <p>接下来,定义一个新函数来获取具有给定 ID 的帖子的每日浏览次数。我们现在使用 <strong>$match</strong> 操作来仅获取特定帖子的 <strong>startView</strong> 操作:</p> <pre><code class="language-js">export async function getDailyViews(postId) {   return await Event.aggregate([     {       $match: {         post: postId,         action: 'startView',       },     }, </code></pre> </li> <li> <p>然后,我们使用 <strong><span class="math inline">\(group** 操作与 **\)</span>dateTrunc</strong> 结合来获取每日的浏览次数,就像我们在 MongoDB Playground 脚本中之前所做的那样:</p> <pre><code class="language-js">    {       $group: {         _id: {           $dateTrunc: { date: '$date', unit: 'day' },         },         views: { $count: {} },       },     }, </code></pre> </li> <li> <p>最后,我们使用 <strong>$sort</strong> 操作来按 <strong>_id</strong>(包含天数)对结果文档进行排序:</p> <pre><code class="language-js">    {       $sort: { _id: 1 },     },   ]) } </code></pre> </li> <li> <p>对于最后一个函数,我们使用会话持续时间聚合,但稍作扩展以给出每天的平均持续时间。我们首先需要匹配一个帖子 ID:</p> <pre><code class="language-js">export async function getDailyDurations(postId) {   return await Event.aggregate([     {       $match: {         post: postId,       },     }, </code></pre> </li> <li> <p>然后,我们使用相同的<strong><span class="math inline">\(project**和**\)</span>group</strong>操作来获取<strong>session</strong>、<strong>startDate</strong>和<strong>endDate</strong>,就像我们之前做的那样:</p> <pre><code class="language-js">    {       $project: {         session: '$session',         startDate: {           $cond: [{ $eq: ['$action', 'startView'] }, '$date', undefined],         },         endDate: {           $cond: [{ $eq: ['$action', 'endView'] }, '$date', undefined],         },       },     },     {       $group: {         _id: '$session',         startDate: { $min: '$startDate' },         endDate: { $max: '$endDate' },       },     }, </code></pre> </li> <li> <p>现在,我们使用<strong>$project</strong>操作从我们的<strong>startDate</strong>获取<strong>day</strong>,就像我们在之前的聚合中获取帖子每日观看次数时做的那样:</p> <pre><code class="language-js">    {       $project: {         day: { $dateTrunc: { date: '$startDate', unit: 'day' } },         duration: { $subtract: ['$endDate', '$startDate'] },       },     }, </code></pre> </li> <li> <p>我们按日分组结果,并计算每日的平均时长:</p> <pre><code class="language-js">    {       $group: {         _id: '$day',         averageDuration: { $avg: '$duration' },       },     }, </code></pre> </li> <li> <p>最后,我们按日排序结果:</p> <pre><code class="language-js">    {       $sort: { _id: 1 },     },   ]) } </code></pre> </li> </ol> <p>如我们所见,聚合管道非常强大,允许我们在数据库中直接进行大量数据处理!在下一节中,我们将为这些聚合函数创建路由。</p> <h2 id="定义路由">定义路由</h2> <p>定义路由相当直接;我们只需检查给定 ID 的帖子是否存在,如果存在,就返回相应聚合服务函数的结果。让我们开始定义路由:</p> <ol> <li> <p>编辑<strong>backend/src/routes/events.js</strong>并导入<strong>getTotalViews</strong>、<strong>getDailyViews</strong>和<strong>getDailyDurations</strong>函数:</p> <pre><code class="language-js">import {   trackEvent,   getTotalViews,   getDailyViews,   getDailyDurations, } from '../services/events.js' </code></pre> </li> <li> <p>接下来,在<strong>eventRoutes</strong>函数内部,定义一个新的路由以获取帖子的总观看次数,如下所示:</p> <pre><code class="language-js">  app.get('/api/v1/events/totalViews/:postId', async (req, res) => {     try {       const { postId } = req.params       const post = await getPostById(postId)       if (post === null) return res.status(400).end()       const stats = await getTotalViews(post._id)       return res.json(stats)     } catch (err) {       console.error('error getting stats', err)       return res.status(500).end()     }   }) </code></pre> </li> <li> <p>然后定义一个类似的路由以获取帖子的每日观看次数:</p> <pre><code class="language-js">  app.get('/api/v1/events/dailyViews/:postId', async (req, res) => {     try {       const { postId } = req.params       const post = await getPostById(postId)       if (post === null) return res.status(400).end()       const stats = await getDailyViews(post._id)       return res.json(stats)     } catch (err) {       console.error('error getting stats', err)       return res.status(500).end()     }   }) </code></pre> </li> <li> <p>最后,定义一个用于获取帖子每日平均观看时长的路由:</p> <pre><code class="language-js">  app.get('/api/v1/events/dailyDurations/:postId', async (req, res) => {     try {       const { postId } = req.params       const post = await getPostById(postId)       if (post === null) return res.status(400).end()       const stats = await getDailyDurations(post._id)       return res.json(stats)     } catch (err) {       console.error('error getting stats', err)       return res.status(500).end()     }   }) </code></pre> </li> </ol> <p>现在我们已经成功定义了聚合函数的路由,是时候将它们集成到前端并开始可视化我们所模拟和收集的数据了!</p> <h1 id="使用-victory-在前端集成和可视化数据">使用 Victory 在前端集成和可视化数据</h1> <p>在本节的最后,我们将集成我们之前定义的聚合端点。然后,我们将在前端引入 Victory 库来创建图表以可视化我们的聚合数据!</p> <h2 id="集成聚合-api">集成聚合 API</h2> <p>首先,我们需要在前端集成 API 路由,如下所示:</p> <ol> <li> <p>编辑<strong>src/api/events.js</strong>文件并添加三个新的 API 函数以获取帖子的总观看次数、每日观看次数和每日时长:</p> <pre><code class="language-js">export const getTotalViews = (postId) =>   fetch(`${import.meta.env.VITE_BACKEND_URL}/events/totalViews/${postId}`).then(     (res) => res.json(),   ) export const getDailyViews = (postId) =>   fetch(`${import.meta.env.VITE_BACKEND_URL}/events/dailyViews/${postId}`).then(     (res) => res.json(),   ) export const getDailyDurations = (postId) =>   fetch(     `${import.meta.env.VITE_BACKEND_URL}/events/dailyDurations/${postId}`,   ).then((res) => res.json()) </code></pre> </li> <li> <p>创建一个新的<strong>src/components/PostStats.jsx</strong>文件,在其中我们将查询这些新的 API 路由。首先导入<strong>useQuery</strong>、<strong>PropTypes</strong>和三个 API 函数:</p> <pre><code class="language-js">import { useQuery } from '@tanstack/react-query' import PropTypes from 'prop-types' import {   getTotalViews,   getDailyViews,   getDailyDurations, } from '../api/events.js' </code></pre> </li> <li> <p>定义一个新的组件,它接受<strong>postId</strong>并使用查询钩子获取我们在后端聚合的所有统计数据:</p> <pre><code class="language-js">export function PostStats({ postId }) {   const totalViews = useQuery({     queryKey: ['totalViews', postId],     queryFn: () => getTotalViews(postId),   })   const dailyViews = useQuery({     queryKey: ['dailyViews', postId],     queryFn: () => getDailyViews(postId),   })   const dailyDurations = useQuery({     queryKey: ['dailyDurations', postId],     queryFn: () => getDailyDurations(postId),   }) </code></pre> </li> <li> <p>当统计数据正在加载时,我们显示一个简单的加载消息:</p> <pre><code class="language-js">  if (     totalViews.isLoading ||     dailyViews.isLoading ||     dailyDurations.isLoading   ) {     return <div>loading stats...</div>   } </code></pre> </li> <li> <p>一旦统计数据加载完成,我们就可以显示它们。目前,我们只显示总观看次数和其他两个 API 请求的 JSON 响应:</p> <pre><code class="language-js">  return (     <div>       <b>{totalViews.data?.views} total views</b>       <pre>{JSON.stringify(dailyViews.data)}</pre>       <pre>{JSON.stringify(dailyDurations.data)}</pre>     </div>   ) } </code></pre> </li> <li> <p>我们仍然需要为此组件定义属性类型,如下所示:</p> <pre><code class="language-js">PostStats.propTypes = {   postId: PropTypes.string.isRequired, } </code></pre> </li> <li> <p>现在,我们可以在我们的<strong>ViewPost</strong>页面组件中渲染<strong>PostStats</strong>组件。编辑<strong>src/pages/ViewPost.jsx</strong>并导入<strong>PostStats</strong>组件:</p> <pre><code class="language-js">import { PostStats } from '../components/PostStats.jsx' </code></pre> </li> <li> <p>然后,在组件底部,按照以下方式渲染统计数据:</p> <pre><code class="language-js">      {post ? (         <div>           <Post {...post} fullPost />           <hr />           <PostStats postId={postId} />         </div>       ) : (         `Post with id ${postId} not found.`       )}     </div>   ) } </code></pre> </li> </ol> <p>如果您现在在前端打开一个帖子(如果您看到错误,可能需要刷新前端),您将看到所有统计数据都已正确获取!现在,剩下的就是使用 Victory 可视化每日统计数据了!</p> <h2 id="使用-victory-可视化数据">使用 Victory 可视化数据</h2> <p>Victory 是一个 React 库,它提供模块化组件,可用于创建图表和各种数据可视化。它甚至支持交互式可视化工具,例如刷选和分组(例如,您可以选择图表的某个部分,以便在其他图表上更仔细地检查它)。在本章中,我们只将触及 Victory 能做什么的皮毛,因为 React 中的数据可视化本身就是一个很大的主题。</p> <p>您可以在他们的官方网站上找到有关 Victory 的更多信息:<a href="https://commerce.nearform.com/open-source/victory/" target="_blank"><code>commerce.nearform.com/open-source/victory/</code></a></p> <h3 id="创建柱状图">创建柱状图</h3> <p>现在让我们开始使用 Victory 可视化我们的数据:</p> <ol> <li> <p>在项目的根目录中执行以下命令来安装库:</p> <pre><code class="language-js">$ npm install victory@36.9.1 </code></pre> </li> <li> <p>编辑 <strong>src/components/PostStats.jsx</strong> 并从 Victory 导入以下组件:</p> <pre><code class="language-js">import {   VictoryChart,   VictoryTooltip,   VictoryBar,   VictoryLine,   VictoryVoronoiContainer, } from 'victory' </code></pre> </li> <li> <p><em>替换</em> 组件末尾的 <strong><pre></strong> 标签,开始使用以下图表,首先是每日观看时长图表:</p> <pre><code class="language-js">  return (     <div>       <b>{totalViews.data?.views} total views</b>       <div style={{ width: 512 }}>         <h3>Daily Views</h3> VictoryChart component is a wrapper, used to combine all elements of a Victory chart. We set domainPadding to 16 pixels, which is a padding inside of the graph. It makes sure that the lines and bar charts do not stick to the edges of the graph, making it look slightly better. </code></pre> </li> <li> <p>然后,使用 <strong>VictoryBar</strong> 定义一个柱状图,并使用 <strong>VictoryTooltip</strong> 显示标签:</p> <pre><code class="language-js">          <VictoryBar             labelComponent={<VictoryTooltip />} </code></pre> <p>提示信息看起来如下:</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_10_4.jpg" alt="图 10.4 – Victory 中柱状图上的提示信息" loading="lazy"></p> <p>图 10.4 – Victory 中柱状图上的提示信息</p> <ol> <li> <p>现在我们来到了最重要的部分,数据。在这里,我们遍历查询钩子返回的 <strong>dailyViews</strong> 数据,将其转换为 Victory 能够理解的格式:</p> <pre><code class="language-js">            data={dailyViews.data?.map((d) => ({ </code></pre> </li> <li> <p>我们将 <strong>_id</strong> 属性映射到 <strong>x</strong>-轴值(将其解析为日期),并将 <strong>views</strong> 属性映射到 <strong>y</strong>-轴值:</p> <pre><code class="language-js">              x: new Date(d._id),               y: d.views, </code></pre> </li> <li> <p>然后我们创建一个标签,其中我们将日期转换为本地日期字符串,然后显示给定日期的观看次数:</p> <pre><code class="language-js">              label: `${new Date(d._id).toLocaleDateString()}: ${d.views} views`,             }))}           />         </VictoryChart>       </div>     </div>   ) } </code></pre> </li> </ol> <p>我们已经成功使用 Victory 创建了第一个可视化!图表现在将如下所示:</p> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_10_5.jpg" alt="图 10.5 – Victory 中的第一个图表 – 一个柱状图!" loading="lazy"></p> <p>图 10.5 – Victory 中的第一个图表 – 一个柱状图!</p> <p>如您所见,Victory 自动为我们格式化了日期,并调整了坐标轴以适应图表所占的空间!</p> <p>接下来,让我们可视化每日平均观看时长。</p> <h3 id="创建折线图">创建折线图</h3> <p>在 Victory 中创建折线图与创建柱状图非常相似,唯一的区别是工具提示。在折线图中,我们无法直接使用工具提示,因为线条在理论上可能是连续的(而不是离散的数据块),所以不清楚工具提示应该放在哪里。相反,我们在 Victory 中为折线图使用<strong>Voronoi 容器</strong>来显示工具提示。Voronoi 这个名字来源于数学,其中 Voronoi 图将区域划分为多个部分。简单来说,Voronoi 容器在鼠标位置和折线图之间创建一个交点,从该交点获取数据,然后在那里显示工具提示。</p> <p>考虑到这一点,我们现在开始创建每日平均观看时长的折线图:</p> <ol> <li> <p>编辑<strong>src/components/PostStats.jsx</strong>,并继续处理其他图表,在柱状图的容器之后添加一个新的<strong>VictoryChart</strong>:</p> <pre><code class="language-js">        </VictoryChart>       </div>       <div style={{ width: 512 }}>         <h4>Daily Average Viewing Duration</h4>         <VictoryChart           domainPadding={16} </code></pre> </li> <li> <p>在<strong>VictoryChart</strong>组件中,我们现在定义<strong>containerComponent</strong>,它将包含我们的<strong>VictoryVoronoiContainer</strong>:</p> <pre><code class="language-js">          containerComponent={             <VictoryVoronoiContainer               voronoiDimension='x' </code></pre> <p>我们将其定义为仅与<code>x</code>轴上的值相交,这意味着鼠标指针将仅与图表上的日期相交。</p> </li> <li> <p>我们现在可以使用<strong>datum</strong>属性定义容器的标签,以获取与鼠标指针相交的数据条目来创建标签。我们的标签应显示当前日期和分钟数,固定到小数点后两位:</p> <pre><code class="language-js">              labels={({ datum }) =>                 `${datum.x.toLocaleDateString()}: ${datum.y.toFixed(2)} minutes`               } </code></pre> </li> <li> <p>再次,我们使用<strong>VictoryTooltip</strong>来显示这些标签:</p> <pre><code class="language-js">              labelComponent={<VictoryTooltip />}             />           }         > </code></pre> </li> <li> <p>现在我们可以最终定义<strong>VictoryLine</strong>图表,在其中再次映射数据,解析日期并将平均持续时间从毫秒转换为分钟:</p> <pre><code class="language-js">          <VictoryLine             data={dailyDurations.data?.map((d) => ({               x: new Date(d._id),               y: d.averageDuration / (60 * 1000),             }))}           />         </VictoryChart>       </div>     </div>   ) } </code></pre> </li> </ol> <p>如您所见,其余部分相当简单,类似于创建柱状图!它看起来如下:</p> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_10_6.jpg" alt="图 10.6 – 使用 Victory 创建的折线图,显示帖子的每日平均观看时长" loading="lazy"></p> <p>图 10.6 – 使用 Victory 创建的折线图,显示帖子的每日平均观看时长</p> <p>如您所见,Victory 是一个相当强大的库,用于使用 React 创建图表,而我们只是触及了它所能做到的一小部分!您仍然可以自定义图表的主题并创建各种复杂的可视化。然而,在本章中,我们专注于最基本且最广泛使用的图表:柱状图和折线图。</p> <h1 id="摘要-8">摘要</h1> <p>在本章中,我们学习了使用后端和前端跟踪事件。然后,我们模拟事件作为样本数据集用于我们的聚合和可视化。接下来,我们学习了如何使用 MongoDB Playground 使用 MongoDB 进行数据聚合。然后,我们在后端实现了数据聚合函数。最后,我们使用 Victory 在前端集成并可视化数据。</p> <p>在下一章,<em>第十一章</em>,<em>使用 GraphQL API 构建后端</em>,我们将学习如何使用 REST 的替代方案,即 GraphQL,以便更轻松地查询深层嵌套的对象。</p> <h1 id="第十一章使用-graphql-api-构建后端">第十一章:使用 GraphQL API 构建后端</h1> <p>到目前为止,我们只与 REST API 进行交互。对于具有深层嵌套对象的更复杂 API,我们可以使用 GraphQL 来允许对大型对象的部分进行选择性访问。在本章中,我们首先将学习什么是 GraphQL 以及它在何时有用。然后,我们将尝试制作 GraphQL 查询和突变。之后,我们将实现后端的 GraphQL。最后,我们将简要介绍高级 GraphQL 概念。</p> <p>在本章中,我们将涵盖以下主要主题:</p> <ul> <li> <p>什么是 GraphQL?</p> </li> <li> <p>在后端实现 GraphQL API</p> </li> <li> <p>实现 GraphQL 身份验证和突变</p> </li> <li> <p>高级 GraphQL 概念概述</p> </li> </ul> <h1 id="技术要求-10">技术要求</h1> <p>在我们开始之前,请从<em>第一章**,准备全栈开发</em>和<em>第二章**,了解 Node.js</em>和<em>MongoDB</em>中安装所有要求。</p> <p>那些章节中列出的版本是书中使用的版本。虽然安装较新版本不应有问题,但请注意,某些步骤在较新版本上可能有所不同。如果您在这本书提供的代码和步骤中遇到问题,请尝试使用<em>第一章</em>和<em>第二章</em>中提到的版本。</p> <p>您可以在 GitHub 上找到本章的代码:<a href="https://github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch11" target="_blank"><code>github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch11</code></a>。</p> <p>如果您克隆了本书的完整仓库,Husky 在运行<code>npm install</code>时可能找不到<code>.git</code>目录。在这种情况下,只需在相应章节文件夹的根目录中运行<code>git init</code>。</p> <p>本章的 CiA 视频可以在:<a href="https://youtu.be/6gP0uM-XaVo" target="_blank"><code>youtu.be/6gP0uM-XaVo</code></a>找到。</p> <h1 id="什么是-graphql">什么是 GraphQL?</h1> <p>在我们学习如何使用 GraphQL 之前,让我们首先关注什么是 GraphQL。像 REST 一样,它是一种查询 API 的方式。然而,它远不止于此。GraphQL 包括一个服务器端运行时来执行查询,以及一个类型系统来定义你的数据。它与许多数据库引擎兼容,并且可以集成到现有的后端中。</p> <p>GraphQL 服务是通过定义类型(如<code>User</code>类型)、类型上的字段(如<code>username</code>字段)以及解析字段值的函数来创建的。假设我们已定义以下带有获取用户名的函数的<code>User</code>类型:</p> <pre><code class="language-js">type User {   username: String } function User_username(user) {   return user.getUsername() } </code></pre> <p>我们可以定义一个<code>Query</code>类型和一个获取当前用户的函数:</p> <pre><code class="language-js">type Query {   currentUser: User } function Query_currentUser(req) {   return req.auth.user } </code></pre> <p>信息</p> <p><strong>查询</strong>类型是一个特殊类型,它定义了 GraphQL 模式的“入口点”。它允许我们定义哪些字段可以使用 GraphQL API 进行查询。</p> <p>现在我们已经定义了带有字段和解析这些字段的功能的类型,我们可以进行一个 GraphQL 查询来获取当前用户的用户名。GraphQL 查询看起来像 JavaScript 对象,但它们只列出你想要查询的字段名。然后,GraphQL API 将返回一个具有与查询相同结构的 JavaScript 对象,但填充了值。让我们看看获取当前用户用户名的查询将是什么样子:</p> <pre><code class="language-js">{   currentUser {     username   } } </code></pre> <p>那个查询将返回一个类似以下的 JSON 结果:</p> <pre><code class="language-js">{   "data": {     "currentUser": {       "username": "dan"     }   } } </code></pre> <p>如您所见,结果与查询具有相同的形状。这是 GraphQL 的一个基本概念:客户端可以具体请求它需要的字段,服务器将返回确切的这些字段。如果我们需要更多关于用户的数据,我们只需向类型中添加新的字段并查询即可。</p> <p>GraphQL 会验证查询和结果是否符合定义的类型。这确保了我们不会破坏客户端和服务器之间的契约。GraphQL 类型充当客户端和服务器之间的契约。在验证查询后,它由 GraphQL 服务器执行,然后返回一个与查询请求的形状完全相同的结果。每个请求的字段在服务器上执行一个函数。这些函数被称为<strong>解析器</strong>。</p> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_11_1.jpg" alt="图 11.1 – GraphQL 客户端与服务器之间的交互" loading="lazy"></p> <p>图 11.1 – GraphQL 客户端与服务器之间的交互</p> <p>类型和方法也可以深层嵌套。例如,一个用户可能有一个字段,返回该用户是作者的帖子。然后我们可以对那些帖子对象中的字段进行子选择。这对于对象中的对象以及对象中的对象数组的嵌套级别也是适用的。GraphQL 将继续解析字段,直到只剩下简单的值(标量),如字符串和数字。例如,以下查询可以获取当前用户创建的所有帖子的 ID 和标题:</p> <pre><code class="language-js">{   currentUser {     username     posts {       id       title     }   } } </code></pre> <p>此外,GraphQL 允许我们为字段定义参数,这些参数将被传递到解析我们字段的函数中。我们可以使用参数,例如,获取所有带有特定标签的帖子。在 GraphQL 中,我们可以向任何字段传递参数,即使它们是深层嵌套的。参数甚至可以传递给单个值字段,例如,用于转换值。例如,以下查询将根据 ID 获取帖子并返回帖子标题:</p> <pre><code class="language-js">{   postById(id: "1234") {     title   } } </code></pre> <p>如果你自己构建后端或者有这个想法,GraphQL 特别有用,因为它可以允许查询深层嵌套和相互关联的数据。然而,如果你无法控制现有的 REST 后端,通常不值得添加 GraphQL 作为单独的、独立的层,因为它的基于模式的限制。</p> <p>在了解了查询之后,让我们继续学习突变。</p> <h2 id="突变">突变</h2> <p>在 REST 中,任何请求都可能导致副作用(例如将数据写入数据库)。但是,正如我们所学的,GET 请求应该只返回数据,而不应引起此类副作用。只有 POST/PUT/PATCH/DELETE 请求应该导致数据库中的数据发生变化。在 GraphQL 中,有一个类似的概念:理论上,任何字段函数都可能改变数据库状态。然而,在 GraphQL 中,我们定义一个突变而不是查询来明确表示我们想要改变数据库状态。除了用 <code>mutation</code> 关键字定义外,突变与查询具有相同的结构。尽管如此,有一个区别:查询并行获取字段,而突变按顺序运行,首先执行第一个字段函数,然后是下一个,依此类推。这种行为确保我们在突变中不会出现竞争条件。</p> <p>信息</p> <p>除了内置的 <strong>Query</strong> 类型外,还有一个 <strong>Mutation</strong> 类型来定义允许的突变字段。</p> <p>现在我们已经了解了 GraphQL 是什么以及它是如何工作的基础知识,让我们开始在实际的博客应用程序后端中实现 GraphQL!</p> <h1 id="在后端实现-graphql-api">在后端实现 GraphQL API</h1> <p>现在我们将设置 GraphQL 在我们现有的博客应用程序后端,除了 REST API 之外。这样做将允许我们看到 GraphQL 与 REST API 的比较和差异。按照以下步骤开始设置后端上的 GraphQL:</p> <ol> <li> <p>将现有的 <strong>ch10</strong> 文件夹复制到一个新的 <strong>ch11</strong> 文件夹中,如下所示:</p> <pre><code class="language-js">$ cp -R ch10 ch11 </code></pre> </li> <li> <p>在 VS Code 中打开 <strong>ch11</strong> 文件夹。</p> </li> <li> <p>首先,让我们安装一个 VS Code 扩展来添加 GraphQL 语言支持。转到 <strong>Extensions</strong> 选项卡,搜索由 GraphQL 基金会开发的 <strong>GraphQL.vscode-graphql</strong> 扩展。安装扩展。</p> </li> <li> <p>接下来,使用以下命令在后端安装 <strong>graphql</strong> 和 <strong>@apollo/server</strong> 库:</p> <pre><code class="language-js">$ cd backend/ $ npm install graphql@16.8.1 @apollo/server@4.10.0 </code></pre> <p>Apollo Server 是一个生产就绪的 GraphQL 服务器实现,支持多个后端 Web 框架,包括 Express。</p> </li> <li> <p>创建一个新的 <strong>backend/src/graphql/</strong> 文件夹。在其内部,创建一个 <strong>backend/src/graphql/query.js</strong> 文件,在其中我们定义一个 <strong>Query</strong> 模式,这是我们的 GraphQL API 的入口点(列出后端支持的所有查询),如下所示:</p> <pre><code class="language-js">export const querySchema = `#graphql   type Query {     test: String   } ` </code></pre> <p>在模板字符串的开头添加一个 <code>#graphql</code> 指令是很重要的,这样字符串就会被识别为 GraphQL 语法,并在代码编辑器中正确高亮显示。在我们的模式中,我们定义了一个 <code>test</code> 字段,现在我们为它定义一个解析器。</p> </li> <li> <p>定义一个包含将 <strong>test</strong> 字段解析为静态字符串的函数的 <strong>queryResolver</strong> 对象:</p> <pre><code class="language-js">export const queryResolver = {   Query: {     test: () => {       return 'Hello World from GraphQL!'     },   }, } </code></pre> </li> <li> <p>创建一个新的 <strong>backend/src/graphql/index.js</strong> 文件,并在其中导入 <strong>querySchema</strong> 和 <strong>queryResolver</strong>:</p> <pre><code class="language-js">import { querySchema, queryResolver } from './query.js' </code></pre> </li> <li> <p>然后,导出一个名为 <strong>typeDefs</strong> 的数组,它包含所有模式(目前只包含查询模式),以及一个名为 <strong>resolvers</strong> 的数组,它包含所有解析器(目前只包含查询解析器):</p> <pre><code class="language-js">export const typeDefs = [querySchema] export const resolvers = [queryResolver] </code></pre> </li> <li> <p>编辑 <strong>backend/src/app.js</strong> 并从 <strong>@apollo/server</strong> 库导入 <strong>ApolloServer</strong> 和 <strong>expressMiddleware</strong>:</p> <pre><code class="language-js">import { ApolloServer } from '@apollo/server' import { expressMiddleware } from '@apollo/server/express4' </code></pre> </li> <li> <p>然后,导入 <strong>typeDefs</strong> 和 <strong>resolvers</strong>:</p> <pre><code class="language-js">import { typeDefs, resolvers } from './graphql/index.js' </code></pre> </li> <li> <p>在所有其他中间件和路由定义之前,使用模式类型定义和定义的解析器创建一个新的 Apollo 服务器:</p> <pre><code class="language-js">const apolloServer = new ApolloServer({   typeDefs,   resolvers, }) </code></pre> </li> <li> <p>然后,在服务器准备就绪后,将 <strong>expressMiddleware</strong> 挂载到 <strong>/graphql</strong> 路由上,如下所示:</p> <pre><code class="language-js">apolloServer   .start()   .then(() => app.use('/graphql', expressMiddleware(apolloServer))) </code></pre> </li> <li> <p>通过运行以下命令以开发模式启动后端:</p> <pre><code class="language-js">$ npm run dev </code></pre> </li> <li> <p>在您的浏览器中转到 <strong><a href="http://localhost:3001/graphql" target="_blank">http://localhost:3001/graphql</a></strong>;您应该看到左侧的 Apollo 接口,可以输入查询,以及右侧的结果。</p> </li> <li> <p>从左侧编辑器的所有注释中删除并输入以下 GraphQL 查询:</p> <pre><code class="language-js">query ExampleQuery {   test } </code></pre> </li> <li> <p>按下 <strong>Play</strong> 按钮,运行查询,您将看到以下结果:</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_11_2.jpg" alt="图 11.2 – 我们第一次 GraphQL 查询的成功执行!" loading="lazy"></p> <p>图 11.2 – 我们第一次 GraphQL 查询的成功执行!</p> <p>如您所见,我们对 <code>test</code> 字段的查询返回了我们之前定义的静态字符串!</p> <p>在实现基本字段之后,让我们实现一些访问我们的服务函数并从 MongoDB 获取数据的字段。</p> <h2 id="实现查询帖子的字段">实现查询帖子的字段</h2> <p>按照以下步骤实现查询帖子的字段:</p> <ol> <li> <p>编辑 <strong>backend/src/graphql/query.js</strong> 并导入相关的服务函数:</p> <pre><code class="language-js">import {   getPostById,   listAllPosts,   listPostsByAuthor,   listPostsByTag, } from '../services/posts.js' </code></pre> </li> <li> <p>调整模式以包含一个 <strong>posts</strong> 字段,它返回一个帖子数组:</p> <pre><code class="language-js">export const querySchema = `#graphql   type Query {     test: String [Type] syntax means that something is an array of Type. We will define the Post type later. Type! is the non-null modifier and means that a type is not null (required), so [Type!] means that each element is a Type, and not null (the array can still be empty, though). [Type!]! means that the array will always exist and never be null (but the array can still be empty). </code></pre> </li> <li> <p>此外,定义用于通过 <strong>author</strong> 和 <strong>tag</strong> 查询帖子的字段,这两个字段都接受一个必需的参数:</p> <pre><code class="language-js">    postsByAuthor(username: String!): [Post!]!     postsByTag(tag: String!): [Post!]! </code></pre> </li> <li> <p>最后,定义一个通过 <strong>id</strong> 查询帖子的字段:</p> <pre><code class="language-js">    postById(id: ID!): Post   } ` </code></pre> </li> <li> <p>现在我们已经定义了模式,我们仍然需要为所有这些字段提供解析器。多亏了我们的服务函数,这相当简单:我们可以在 <strong>async</strong> 函数中简单地调用我们的服务函数,并使用相关参数,如下所示:</p> <pre><code class="language-js">export const queryResolver = {   Query: {     test: () => {       return 'Hello World from GraphQL!'     },     posts: async () => {       return await listAllPosts()     },     postsByAuthor: async (parent, { username }) => {       return await listPostsByAuthor(username)     },     postsByTag: async (parent, { tag }) => {       return await listPostsByTag(tag)     },     postById: async (parent, { id }) => {       return await getPostById(id)     },   }, } </code></pre> <p>解析器函数始终将 <code>parent</code> 对象作为第一个参数,将所有参数作为第二个参数的对象接收。</p> </li> </ol> <p>现在我们已经成功定义了查询帖子的字段。然而,<code>Post</code> 类型尚未定义,因此我们的 GraphQL 查询目前还不能工作。让我们接下来做这件事。</p> <h2 id="定义-post-类型">定义 Post 类型</h2> <p>在定义 <code>Query</code> 类型之后,我们继续定义 <code>Post</code> 类型,如下所示:</p> <ol> <li> <p>创建一个新的 <strong>backend/src/graphql/post.js</strong> 文件,其中我们导入 <strong>getUserInfoById</strong> 函数以稍后解析帖子的作者:</p> <pre><code class="language-js">import { getUserInfoById } from '../services/users.js' </code></pre> </li> <li> <p>然后,定义 <strong>postSchema</strong>。注意,<strong>Post</strong> 由 <strong>id</strong>、<strong>title</strong>、<strong>author</strong>、<strong>contents</strong>、<strong>tags</strong> 以及 <strong>createdAt</strong> 和 <strong>updatedAt</strong> 时间戳组成:</p> <pre><code class="language-js">export const postSchema = `#graphql   type Post {     id: ID!     title: String!     author: User     contents: String     tags: [String!]     createdAt: Float     updatedAt: Float   } ` </code></pre> <p>在这种情况下,我们使用 <code>[String!]</code> 作为标签,而不是 <code>[String!]!</code>,因为 <code>tags</code> 字段也可以不存在/<code>null</code>。</p> <p><code>createdAt</code> 和 <code>updatedAt</code> 时间戳太大,无法放入 32 位有符号整数中,因此它们的类型需要是 <code>Float</code> 而不是 <code>Int</code>。</p> </li> <li> <p>接下来,定义一个用于获取用户的服务函数的 <strong>author</strong> 字段的解析器:</p> <pre><code class="language-js">export const postResolver = {   Post: {     author: async (post) => {       return await getUserInfoById(post.author)     },   }, } </code></pre> <p>获取帖子的解析器已经是 <code>Query</code> 模式的组成部分,所以我们不需要在这里定义如何获取帖子。GraphQL 知道查询字段返回 <code>Post</code> 数组,然后允许我们进一步解析帖子上的字段。</p> </li> <li> <p>编辑 <strong>backend/src/graphql/index.js</strong> 并添加 <strong>postSchema</strong> 和 <strong>postResolver</strong>:</p> <pre><code class="language-js">import { querySchema, queryResolver } from './query.js' import { postSchema, postResolver } from './post.js' export const typeDefs = [querySchema, postSchema] export const resolvers = [queryResolver, postResolver] </code></pre> </li> </ol> <p>在定义了 <code>Post</code> 类型之后,让我们继续定义 <code>User</code> 类型。</p> <h2 id="定义用户类型">定义用户类型</h2> <p>当定义 <code>Post</code> 类型时,我们使用了 <code>User</code> 类型来定义帖子的作者。然而,我们尚未定义 <code>User</code> 类型。现在让我们来做这件事:</p> <ol> <li> <p>创建一个新的 <strong>backend/src/graphql/user.js</strong> 文件,并将 <strong>listPostsByAuthor</strong> 函数导入到这里,因为我们将要添加一种解析用户对象时获取用户帖子的方式,以展示 GraphQL 如何处理深度嵌套的关系:</p> <pre><code class="language-js">import { listPostsByAuthor } from '../services/posts.js' </code></pre> </li> <li> <p>定义 <strong>userSchema</strong>。在我们的 GraphQL 模式中,每个 <strong>User</strong> 都有 <strong>username</strong> 和一个 <strong>posts</strong> 字段,我们将解析用户所写的所有帖子:</p> <pre><code class="language-js">export const userSchema = `#graphql   type User {     username: String!     posts: [Post!]!   } ` </code></pre> </li> </ol> <p>信息</p> <p>我们在这里没有指定任何其他属性,因为我们只在我们 <strong>getUserInfoById</strong> 服务函数中返回用户名。如果我们还想在这里获取用户 ID,我们就必须从该函数中返回它。我们不是返回完整的用户对象,因为这可能是一个潜在的安全漏洞,暴露内部数据,如密码(或某些应用程序中的账单信息)。</p> <ol> <li> <p>接下来,定义 <strong>userResolver</strong>,它获取当前用户的所有帖子:</p> <pre><code class="language-js">export const userResolver = {   User: {     posts: async (user) => {       return await listPostsByAuthor(user.username)     },   }, } </code></pre> </li> <li> <p>编辑 <strong>backend/src/graphql/index.js</strong> 并添加 <strong>userSchema</strong> 和 <strong>userResolver</strong>:</p> <pre><code class="language-js">import { querySchema, queryResolver } from './query.js' import { postSchema, postResolver } from './post.js' import { userSchema, userResolver } from './user.js' export const typeDefs = [querySchema, postSchema, userSchema] export const resolvers = [queryResolver, postResolver, userResolver] </code></pre> </li> </ol> <p>在定义了 <code>User</code> 类型之后,让我们尝试一些深度嵌套的查询!</p> <h2 id="尝试深度嵌套的查询">尝试深度嵌套的查询</h2> <p>现在我们已经成功定义了我们的 GraphQL 模式和解析器,我们可以开始使用 GraphQL 查询我们的数据库了!</p> <p>例如,我们现在可以获取所有帖子的完整列表,包括它们的 ID、标题和作者的用户名,如下所示:</p> <pre><code class="language-js">query GetPostsOverview {   posts {     id     title     author {       username     }   } } </code></pre> <p>在 Apollo 接口中执行前面的查询。正如我们所看到的,查询获取所有帖子,为每个帖子选择 <code>id</code>、<code>title</code> 和 <code>author</code>,然后为每个 <code>author</code> 实例解析 <code>username</code>。这个查询允许我们在单个请求中获取我们需要的所有数据,我们不再需要单独的请求来解析作者的用户名了!</p> <p>信息</p> <p>我们没有在 <code>User</code> 类型中指定 <strong>密码</strong> 字段,所以 GraphQL 不会允许我们访问它,即使解析器函数返回一个包含密码的用户对象。</p> <p>现在,让我们尝试一个通过 ID 获取帖子并然后找到相同作者的其他帖子的查询。这可以用来,例如,在某人阅读完帖子后推荐同一作者的其他文章查看:</p> <ol> <li> <p>我们可以通过清空<strong>操作</strong>文本框的内容,然后在左侧的<strong>文档</strong>侧边栏中选择<strong>根类型</strong>下的<strong>查询</strong>,在 Apollo 接口中自动生成一个查询。现在点击左侧<strong>postById</strong>字段旁边的<strong>+</strong>按钮,它会自动为我们定义一个查询变量,看起来如下所示:</p> <pre><code class="language-js">query PostById($postByIdId: ID!) {   postById(id: $postByIdId) { </code></pre> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_11_3.jpg" alt="图 11.3 – 使用 Apollo 接口自动生成查询" loading="lazy"></p> <p>图 11.3 – 使用 Apollo 接口自动生成查询</p> <ol> <li> <p>在帖子内部,我们现在可以获取帖子的<strong>标题</strong>、<strong>内容</strong>和<strong>作者</strong>值:</p> <pre><code class="language-js">    title     contents     author { </code></pre> </li> <li> <p>在<strong>作者</strong>字段内部,我们获取<strong>用户名</strong>以及他们的帖子 ID 和标题:</p> <pre><code class="language-js">      username       posts {         id         title       }     }   } } </code></pre> </li> <li> <p>在 Apollo 接口的底部,有一个<strong>变量</strong>部分,我们需要在其中填写数据库中存在的 ID:</p> <pre><code class="language-js">{   "postByIdId": "<ENTER ID FROM DATABASE>" } </code></pre> </li> <li> <p>运行查询,你会看到帖子及其作者被解析,并且该作者所写的所有帖子也被正确列出,如下面的截图所示:</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_11_4.jpg" alt="图 11.4 – 在 GraphQL 中运行深度嵌套查询" loading="lazy"></p> <p>图 11.4 – 在 GraphQL 中运行深度嵌套查询</p> <p>接下来,让我们学习如何通过定义输入类型为字段提供参数。</p> <h2 id="实现输入类型">实现输入类型</h2> <p>我们已经学习了如何在 GraphQL 中定义常规类型,但如果我们有一个通用的方式为字段提供参数呢?例如,查询帖子的选项总是相同的(<code>sortBy</code> 和 <code>sortOrder</code>)。我们不能使用常规类型,相反,我们需要定义一个输入类型。按照以下步骤在 GraphQL 中实现查询选项:</p> <ol> <li> <p>编辑 <strong>backend/src/graphql/query.js</strong> 并在模式中定义一个输入类型:</p> <pre><code class="language-js">export const querySchema = `#graphql   input PostsOptions {     sortBy: String     sortOrder: String   } </code></pre> </li> <li> <p>然后,使用输入类型作为字段的参数,如下所示:</p> <pre><code class="language-js">  type Query {     test: String     posts(options: PostsOptions): [Post!]!     postsByAuthor(username: String!, options: PostsOptions): [Post!]!     postsByTag(tag: String!, options: PostsOptions): [Post!]!     postById(id: ID!, options: PostsOptions): Post   } ` </code></pre> </li> <li> <p>现在,编辑解析器以传递<strong>选项</strong>到服务函数:</p> <pre><code class="language-js">    posts: async (parent, { options }) => {       return await listAllPosts(options)     },     postsByAuthor: async (parent, { username, options }) => {       return await listPostsByAuthor(username, options)     },     postsByTag: async (parent, { tag, options }) => {       return await listPostsByTag(tag, options)     }, </code></pre> </li> <li> <p>尝试以下查询以查看帖子是否按正确顺序排序:</p> <pre><code class="language-js">query SortedPosts($options: PostsOptions) {   posts(options: $options) {     id     title     createdAt     updatedAt   } } </code></pre> </li> <li> <p>设置以下变量:</p> <pre><code class="language-js">{   "options": {     "sortBy": "updatedAt",     "sortOrder": "ascending"   } } </code></pre> </li> <li> <p>通过按下<strong>播放</strong>按钮运行查询,你应该会看到响应按<strong>updatedAt</strong>时间戳升序排序!</p> </li> </ol> <p>现在我们已经成功实现了使用 GraphQL 查询数据库的功能,接下来让我们继续实现使用 GraphQL 创建新帖子的方法。</p> <h1 id="实现-graphql-认证和突变">实现 GraphQL 认证和突变</h1> <p>我们现在将实现使用 GraphQL 创建新帖子的方法。为了定义改变数据库状态的字段,我们需要在 <code>mutation</code> 类型下创建它们。然而,在我们这样做之前,我们首先需要在 GraphQL 中实现认证,这样我们就可以在创建帖子时访问当前登录的用户。</p> <h2 id="将认证添加到-graphql">将认证添加到 GraphQL</h2> <p>因为我们在使用 Express 与 GraphQL,所以我们可以使用任何 Express 中间件与 GraphQL,并将其传递给我们的解析器作为 <code>context</code>。因此,我们可以使用现有的 <code>express-jwt</code> 中间件来解析 JWT。现在让我们开始为 GraphQL 添加认证:</p> <ol> <li> <p>我们当前的<strong>requireAuth</strong>中间件配置确保用户已登录,如果他们未登录则抛出错误。然而,当将<strong>auth</strong>上下文传递给 GraphQL 时,这是一个问题,因为并非所有查询都需要身份验证。我们现在将创建一个新的<strong>optionalAuth</strong>中间件,它不需要凭证来处理请求。编辑<strong>backend/src/middleware/jwt.js</strong>并定义以下新中间件:</p> <pre><code class="language-js">export const optionalAuth = expressjwt({   secret: () => process.env.JWT_SECRET,   algorithms: ['HS256'],   credentialsRequired: false, }) </code></pre> </li> <li> <p>现在,编辑<strong>backend/src/app.js</strong>,并在其中导入<strong>optionalAuth</strong>中间件:</p> <pre><code class="language-js">import { optionalAuth } from './middleware/jwt.js' </code></pre> </li> <li> <p>编辑我们定义<strong>/graphql</strong>路由的<strong>app.use()</strong>调用,并向其中添加<strong>optionalAuth</strong>中间件,类似于我们对路由所做的那样:</p> <pre><code class="language-js">apolloServer.start().then(() =>   app.use(     '/graphql',     optionalAuth, </code></pre> </li> <li> <p>然后,向 Apollo <strong>expressMiddleware</strong>添加第二个参数,定义一个<strong>context</strong>函数,该函数将<strong>req.auth</strong>作为上下文提供给 GraphQL 解析器:</p> <pre><code class="language-js">    expressMiddleware(apolloServer, {       context: async ({ req }) => {         return { auth: req.auth }       },     }),   ), ) </code></pre> </li> </ol> <p>接下来,让我们继续在 GraphQL 中实现突变。</p> <h2 id="实现突变">实现突变</h2> <p>现在我们已经为 GraphQL 添加了身份验证,我们可以定义我们的突变。按照以下步骤创建注册、登录和创建帖子的突变:</p> <ol> <li> <p>创建一个新的<strong>backend/src/graphql/mutation.js</strong>文件,并导入<strong>GraphQLError</strong>(在用户未登录时抛出<strong>UNAUTHORIZED</strong>错误),以及<strong>createUser</strong>、<strong>loginUser</strong>和<strong>createPost</strong>函数:</p> <pre><code class="language-js">import { GraphQLError } from 'graphql' import { createUser, loginUser } from '../services/users.js' import { createPost } from '../services/posts.js' </code></pre> </li> <li> <p>定义<strong>mutationSchema</strong>,在其中我们首先定义注册和登录用户的字段。<strong>signupUser</strong>字段返回一个用户对象,而<strong>loginUser</strong>字段返回一个 JWT:</p> <pre><code class="language-js">export const mutationSchema = `#graphql type Mutation {       signupUser(username: String!, password: String!): User       loginUser(username: String!, password: String!): String </code></pre> </li> <li> <p>然后,定义一个字段,用于从给定的<strong>标题</strong>、<strong>内容</strong>(可选)和<strong>标签</strong>(可选)创建一个新的帖子。它返回一个新创建的帖子:</p> <pre><code class="language-js">      createPost(title: String!, contents: String, tags: [String]): Post     } ` </code></pre> </li> <li> <p>定义解析器,在其中我们首先定义<strong>signupUser</strong>和<strong>loginUser</strong>字段,它们相当直接:</p> <pre><code class="language-js">export const mutationResolver = {   Mutation: {     signupUser: async (parent, { username, password }) => {       return await createUser({ username, password })     },     loginUser: async (parent, { username, password }) => {       return await loginUser({ username, password })     }, </code></pre> </li> <li> <p>接下来,我们定义<strong>createPost</strong>字段。在这里,我们首先访问传递给字段的参数,并且作为解析函数的第三个参数,我们得到之前创建的上下文:</p> <pre><code class="language-js">    createPost: async (parent, { title, contents, tags }, { auth }) => { </code></pre> </li> <li> <p>如果用户未登录,<strong>auth</strong>上下文将为<strong>null</strong>。在这种情况下,我们会抛出一个错误,并且不会创建新的帖子:</p> <pre><code class="language-js">      if (!auth) {         throw new GraphQLError(           'You need to be authenticated to perform this action.',           {             extensions: {               code: 'UNAUTHORIZED',             },           },         )       } </code></pre> </li> <li> <p>否则,我们使用<strong>auth.sub</strong>(其中包含用户 ID)和提供的参数来创建一个新的帖子:</p> <pre><code class="language-js">      return await createPost(auth.sub, { title, contents, tags })     },   }, } </code></pre> </li> <li> <p>编辑<strong>backend/src/graphql/index.js</strong>,并添加<strong>mutationSchema</strong>和<strong>mutationResolver</strong>:</p> <pre><code class="language-js">import { querySchema, queryResolver } from './query.js' import { postSchema, postResolver } from './post.js' import { userSchema, userResolver } from './user.js' import { mutationSchema, mutationResolver } from './mutation.js' export const typeDefs = [querySchema, postSchema, userSchema, mutationSchema] export const resolvers = [   queryResolver,   postResolver,   userResolver,   mutationResolver, ] </code></pre> </li> </ol> <p>在实现突变之后,让我们学习如何使用它们。</p> <h2 id="使用突变">使用突变</h2> <p>在定义可能的突变之后,我们可以在 Apollo 界面中运行它们。按照以下步骤首先注册一个用户,然后登录,最后创建一个帖子——所有这些操作都使用 GraphQL:</p> <ol> <li> <p>前往<strong><a href="http://localhost:3001/graphql" target="_blank">http://localhost:3001/graphql</a></strong>查看 Apollo 界面。定义一个新的突变,用于使用给定的用户名和密码注册用户,并在注册成功时返回用户名:</p> <pre><code class="language-js">mutation SignupUser($username: String!, $password: String!) {   signupUser(username: $username, password: $password) {     username   } } </code></pre> </li> </ol> <p>小贴士</p> <p>你可以通过回到 <strong>Root Types</strong>,点击 <strong>Mutation</strong>,然后点击 <strong>signupUser</strong> 旁边的 <strong>+</strong> 图标来使用左侧的 <strong>Documentation</strong> 部分。然后,点击 <strong>username</strong> 字段旁边的 <strong>+</strong> 图标。这将自动创建前面的代码。</p> <ol> <li> <p>编辑底部的变量并输入用户名和密码:</p> <pre><code class="language-js">{   "username": "graphql",   "password": "gql" } </code></pre> </li> <li> <p>通过按播放按钮执行 <strong>SignupUser</strong> 突变。</p> </li> <li> <p>接下来,创建一个新的突变来登录用户:</p> <pre><code class="language-js">mutation LoginUser($username: String!, $password: String!) {   loginUser(username: $username, password: $password) } </code></pre> </li> <li> <p>输入与之前相同的变量并按播放按钮,响应包含 JWT。复制并存储 JWT 以供以后使用。</p> </li> <li> <p>定义一个新的突变来创建帖子。这个突变返回 <strong>Post</strong>,因此我们可以获取 <strong>id</strong>、<strong>title</strong> 和 <strong>author</strong> 的 <strong>username</strong> 值:</p> <pre><code class="language-js">mutation CreatePost($title: String!, $contents: String, $tags: [String]) {   createPost(title: $title, contents: $contents, tags: $tags) {     id     title     author {       username     }   } } </code></pre> <p>这就是 GraphQL 真正发光的地方。在创建帖子后,我们可以解析作者的用户名,以查看它是否真的是由正确的用户创建的,因为我们可以访问为 <code>Post</code> 定义的解析器,即使在突变中也可以!正如你所看到的,GraphQL 非常灵活。</p> </li> <li> <p>输入以下变量:</p> <pre><code class="language-js">{   "title": "GraphQL Post",   "contents": "This is posted from GraphQL!" } </code></pre> </li> <li> <p>选择 <strong>Headers</strong> 选项卡,点击 <strong>New header</strong> 按钮,输入 <strong>Authorization</strong> 作为 <strong>header key</strong>,并将 <strong>Bearer <粘贴之前复制的 JWT</strong>> 作为 <strong>value</strong>。然后点击 <strong>Play</strong> 按钮提交突变。</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_11_5.jpg" alt="图 11.5 – 在 Apollo 接口中添加授权头" loading="lazy"></p> <p>图 11.5 – 在 Apollo 接口中添加授权头</p> <ol> <li>在响应中,你可以看到帖子已成功创建,作者已正确设置和解析!</li> </ol> <p>在为我们的博客应用程序实现了 GraphQL 查询和突变之后,让我们通过概述高级 GraphQL 概念来结束本章。</p> <h1 id="高级-graphql-概念概述">高级 GraphQL 概念概述</h1> <p>默认情况下,GraphQL 附带一组标量类型:</p> <ul> <li> <p><strong>Int</strong>:有符号的 32 位整数</p> </li> <li> <p><strong>Float</strong>:有符号的双精度浮点值</p> </li> <li> <p><strong>String</strong>:UTF-8 编码的字符序列</p> </li> <li> <p><strong>Boolean</strong>:可以是 true 或 false</p> </li> <li> <p><strong>ID</strong>:一个唯一的标识符,序列化为 <strong>String</strong>,但表示它不是人类可读的</p> </li> </ul> <p>GraphQL 还允许定义枚举,枚举是一种特殊的标量类型。它们被限制在特定的值范围内。例如,我们可以定义以下枚举来区分不同类型的帖子:</p> <pre><code class="language-js">enum PostType {   UNPUBLISHED,   UNLISTED,   PUBLIC } </code></pre> <p>在 Apollo 中,枚举将被处理为只能具有特定值的字符串,但在其他 GraphQL 实现中可能会有所不同。</p> <p>许多 GraphQL 实现也允许定义自定义标量类型。例如,Apollo 支持自定义标量类型的定义。</p> <h2 id="片段">片段</h2> <p>当同类型的字段经常被访问时,我们可以创建一个片段来简化并标准化对这些字段的访问。例如,如果我们经常解析用户,并且用户有 <code>username</code>、<code>profilePicture</code>、<code>fullName</code> 和 <code>biography</code> 等字段,我们可以创建以下片段:</p> <pre><code class="language-js">fragment UserInfo on User {   username   profilePicture   fullName   biography } </code></pre> <p>这个片段可以在查询中使用。例如,看看这个片段:</p> <pre><code class="language-js">{   posts {     author {       ...UserInfo     }   } } </code></pre> <p>当相同的字段结构在同一个查询中多次使用时,片段特别有用。例如,如果一个作者有 <code>followedBy</code> 和 <code>follows</code> 字段,我们可以这样解析所有用户:</p> <pre><code class="language-js">{   posts {     author {       ...UserInfo       followedBy {         ...UserInfo       }       follows {         ...UserInfo       }     }   } } </code></pre> <h2 id="查询反射">查询反射</h2> <p>查询反射使我们能够查询定义的架构本身,以了解服务器为我们提供的数据。本质上,这是查询由 GraphQL 服务器定义的架构。我们可以使用 <code>__schema</code> 字段来获取所有架构。架构由 <code>types</code> 组成,这些 <code>types</code> 有 <code>name</code> 值。</p> <p>例如,我们可以使用以下查询来获取我们服务器上定义的所有类型:</p> <pre><code class="language-js">{   __schema {     types {       name     }   } } </code></pre> <p>如果你在我们服务器上执行此查询,你将获得(包括其他类型)我们定义的 <code>Query</code>、<code>Post</code>、<code>User</code> 和 <code>Mutation</code> 类型。</p> <p>查询反射非常强大,你可以从中获取有关可能查询和突变的大量信息。实际上,Apollo 接口使用反射来渲染 <strong>文档</strong> 侧边栏,并为我们自动完成字段!</p> <h1 id="摘要-9">摘要</h1> <p>在本章中,我们学习了 GraphQL 是什么以及它如何比 REST 更灵活,同时需要更少的样板代码,尤其是在查询深度嵌套对象时。然后,我们在后端实现了 GraphQL 并创建了各种类型、查询和突变。我们还学习了如何在 GraphQL 中集成 JWT 身份验证。最后,我们通过学习高级概念,如类型系统、片段和查询反射来结束本章。</p> <p>在下一章,<em>第十二章</em>“使用 Apollo 客户端在前端与 GraphQL 交互”,我们将学习如何使用 React 和 Apollo 客户端库访问和集成 GraphQL。</p> <h1 id="第十二章使用-apollo-客户端在前端与-graphql-交互">第十二章:使用 Apollo 客户端在前端与 GraphQL 交互</h1> <p>在上一章成功实现使用 Apollo Server 的 GraphQL 后端之后,我们现在将使用 Apollo 客户端在前端与新的 GraphQL API 进行交互。Apollo 客户端是一个库,使得与 GraphQL API 交互变得更加容易和方便。我们将首先用 GraphQL 查询替换获取帖子列表的操作,然后无需额外的查询即可解析作者用户名,展示 GraphQL 的强大功能。接下来,我们将向查询中添加变量以允许设置过滤和排序选项。最后,我们将学习如何在前端使用突变。</p> <p>在本章中,我们将涵盖以下主要主题:</p> <ul> <li> <p>设置 Apollo 客户端并执行我们的第一个查询</p> </li> <li> <p>在 GraphQL 查询中使用变量</p> </li> <li> <p>在前端使用突变</p> </li> </ul> <h1 id="技术要求-11">技术要求</h1> <p>在我们开始之前,请从 <em>第一章</em> <em>为全栈开发做准备</em> 和 <em>第二章</em> <em>了解 Node.js 和 MongoDB</em> 中安装所有要求。</p> <p>那些章节中列出的版本是书中使用的版本。虽然安装较新版本通常不会有问题,但请注意,某些步骤在较新版本上可能有所不同。如果本书中提供的代码和步骤存在问题,请尝试使用第 <em>1</em> 章和 <em>2</em> 章中提到的版本。</p> <p>你可以在 GitHub 上找到本章的代码:<a href="https://github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch12" target="_blank"><code>github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch12</code></a>。</p> <p>本章的 CiA 视频可以在以下网址找到:<a href="https://youtu.be/Gl_5i9DR_xA" target="_blank"><code>youtu.be/Gl_5i9DR_xA</code></a>。</p> <p>如果你克隆了本书的完整仓库,Husky 在运行 <code>npm install</code> 时可能找不到 <code>.git</code> 目录。在这种情况下,只需在相应章节文件夹的根目录中运行 <code>git init</code>。</p> <h1 id="设置-apollo-客户端并执行我们的第一个查询">设置 Apollo 客户端并执行我们的第一个查询</h1> <p>在我们开始在前端进行 GraphQL 查询之前,我们首先需要设置 Apollo 客户端。<code>POST</code> 请求到 <code>/graphql</code> 端点),Apollo 客户端使得与 GraphQL 交互变得更加容易和方便。它还包括一些额外的功能,如开箱即用的缓存。</p> <p>按照以下步骤设置 Apollo 客户端:</p> <ol> <li> <p>将现有的 <strong>ch11</strong> 文件夹复制到新的 <strong>ch12</strong> 文件夹,如下所示:</p> <pre><code class="language-js">$ cp -R ch11 ch12 </code></pre> </li> <li> <p>在 VS Code 中打开 <strong>ch12</strong> 文件夹。</p> </li> <li> <p>安装 <strong>@apollo/client</strong> 和 <strong>graphql</strong> 依赖项:</p> <pre><code class="language-js">$ npm install @apollo/client@3.9.5 graphql@16.8.1 </code></pre> </li> <li> <p>编辑 <strong>.env</strong> 并添加一个新的环境变量,指向我们的 GraphQL 服务器端点:</p> <pre><code class="language-js">VITE_GRAPHQL_URL="http://localhost:3001/graphql" </code></pre> </li> <li> <p>编辑 <strong>src/App.jsx</strong> 并从 <strong>@apollo/client</strong> 包中导入 <strong>ApolloClient</strong>、<strong>InMemoryCache</strong> 和 <strong>ApolloProvider</strong>:</p> <pre><code class="language-js">import { ApolloProvider } from '@apollo/client/react/index.js' import { ApolloClient, InMemoryCache } from '@apollo/client/core/index.js' </code></pre> <p>在撰写本文时,Apollo 客户端中存在 ESM 导入问题,因此我们需要直接从 <code>index.js</code> 文件中导入。</p> </li> <li> <p>创建一个指向 GraphQL 端点并使用 <strong>InMemoryCache</strong> 的新实例的 Apollo Client:</p> <pre><code class="language-js">const apolloClient = new ApolloClient({   uri: import.meta.env.VITE_GRAPHQL_URL,   cache: new InMemoryCache(), }) </code></pre> </li> <li> <p>调整 <strong>App</strong> 组件以添加 <strong>ApolloProvider</strong>,为我们的整个应用提供 Apollo Client 上下文:</p> <pre><code class="language-js">export function App({ children }) {   return (     <HelmetProvider>       <ApolloProvider client={apolloClient}>         <QueryClientProvider client={queryClient}>           <AuthContextProvider>{children}</AuthContextProvider>         </QueryClientProvider>       </ApolloProvider>     </HelmetProvider>   ) } </code></pre> </li> <li> <p>我们现在还将创建一个 GraphQL 配置文件,以便 VS Code GraphQL 扩展可以为我们自动完成和验证查询。在项目的根目录中创建一个新的 <strong>graphql.config.json</strong> 文件,内容如下:</p> <pre><code class="language-js">{   "schema": "http://localhost:3001/graphql",   "documents": "src/api/graphql/**/*.{js,jsx}" } </code></pre> <p><code>schema</code> 定义了 GraphQL 端点的 URL,而 <code>documents</code> 定义了包含 GraphQL 查询的文件的位置。我们稍后将在 <code>src/api/graphql/</code> 文件夹中放置 GraphQL 查询。</p> </li> <li> <p>确保 Docker 和数据库容器正在运行,然后按照以下方式启动后端:</p> <pre><code class="language-js">$ cd backend/ $ npm run dev </code></pre> <p>在本章中保持后端运行,以便 GraphQL 扩展可以访问 GraphQL 端点。</p> </li> <li> <p>重新启动 VS Code GraphQL 扩展。您可以通过访问 VS Code 命令面板(在 Windows/Linux 上为 <em>Ctrl</em> + <em>Shift</em> + <em>P</em>,在 macOS 上为 <em>Cmd</em> + <em>Shift</em> + <em>P</em>)并输入 <strong>GraphQL:</strong> <strong>Manual Restart</strong> 来这样做。</p> </li> </ol> <h2 id="使用-graphql-从前端查询帖子">使用 GraphQL 从前端查询帖子</h2> <p>现在 Apollo Client 已设置并准备好使用,让我们定义我们的第一个 GraphQL 查询:一个简单的查询来获取所有帖子。</p> <p>按照以下步骤定义查询并在我们的应用中使用它:</p> <ol> <li> <p>在 <strong>src/api/graphql/</strong> 文件夹中创建一个新的文件夹,我们将在这里放置我们的 GraphQL 查询。</p> </li> <li> <p>在此文件夹内,创建一个新的 <strong>src/api/graphql/posts.js</strong> 文件。</p> </li> <li> <p>在 <strong>src/api/graphql/posts.js</strong> 文件中,从 <strong>@apollo/client</strong> 导入 <strong>gql</strong> 函数:</p> <pre><code class="language-js">import { gql } from '@apollo/client/core/index.js' </code></pre> </li> <li> <p>定义一个新的 <strong>GET_POSTS</strong> 查询,它检索帖子的所有相关属性(除了作者,稍后添加):</p> <pre><code class="language-js">export const GET_POSTS = gql`   query getPosts {     posts {       id       title       contents       tags       updatedAt       createdAt     }   } ` </code></pre> <p>你应该会看到 GraphQL 扩展为我们提供了我们定义在后端的类型的自动完成选项!如果我们输入错误的字段名,它也会警告我们该字段在类型上不存在。</p> </li> <li> <p>编辑 <strong>src/pages/Blog.jsx</strong> 并从 <strong>@apollo/client</strong> 导入 <strong>useQuery</strong> 钩子:</p> <pre><code class="language-js">import { useQuery as useGraphQLQuery } from '@apollo/client/react/index.js' </code></pre> <p>我们将 Apollo Client 的 <code>useQuery</code> 钩子重命名为 <code>useGraphQLQuery</code> 以避免与 TanStack React Query 的 <code>useQuery</code> 钩子混淆。</p> </li> <li> <p>导入之前定义的 <strong>GET_POSTS</strong> 查询:</p> <pre><code class="language-js">import { GET_POSTS } from '../api/graphql/posts.js' </code></pre> </li> <li> <p><em>移除</em> 用于 <strong>useQuery</strong> 和 <strong>getPosts</strong> 的导入:</p> <pre><code class="language-js">import { useQuery } from '@tanstack/react-query' import { getPosts } from '../api/posts.js' </code></pre> </li> <li> <p><em>移除</em> 现有的 <strong>useQuery</strong> 钩子:</p> <pre><code class="language-js">  const postsQuery = useQuery({     queryKey: ['posts', { author, sortBy, sortOrder }],     queryFn: () => getPosts({ author, sortBy, sortOrder }),   })   const posts = postsQuery.data ?? [] </code></pre> </li> <li> <p><em>替换</em> 为以下钩子:</p> <pre><code class="language-js">  const postsQuery = useGraphQLQuery(GET_POSTS)   const posts = postsQuery.data?.posts ?? [] </code></pre> </li> <li> <p>确保您位于项目的根目录中,然后按照以下方式运行前端:</p> <pre><code class="language-js">$ npm run dev </code></pre> </li> </ol> <p>现在,在 <code>http://localhost:5173/</code> 上打开前端,你会看到帖子标题被正确显示。然而,帖子链接不起作用,控制台中有错误。GraphQL 和 REST API 的结果略有不同:REST API 将帖子的 ID 作为 <code>_id</code> 属性返回,而 GraphQL 将它们作为 <code>id</code> 属性返回。</p> <p>让我们调整我们的代码以适应这个变化:</p> <ol> <li> <p>编辑 <strong>src/components/Post.jsx</strong> 并将 <strong>_id</strong> 属性更改为 <strong>id</strong>:</p> <pre><code class="language-js">export function Post({   title,   contents,   author,   id, </code></pre> </li> <li> <p>同时,更新使用的地方的变量名:</p> <pre><code class="language-js">        <Link to={`/posts/${id}/${slug(title)}`}> </code></pre> </li> <li> <p>确保更新 <strong>propTypes</strong>:</p> <pre><code class="language-js">Post.propTypes = {   title: PropTypes.string.isRequired,   contents: PropTypes.string,   author: PropTypes.string,   id: PropTypes.string.isRequired, </code></pre> </li> <li> <p>现在属性已更改,编辑 <strong>src/pages/ViewPost.jsx</strong> 并按照以下方式传递新属性:</p> <pre><code class="language-js">      {post ? (         <Post {...post} id={postId} fullPost />       ) : (         `Post with id ${postId} not found.`       )} </code></pre> </li> </ol> <p>保存所有文件后,前端应该刷新并正确渲染所有帖子列表,并带有正常工作的链接。现在要恢复原始功能,只剩下显示作者用户名。</p> <h2 id="在单个查询中解析作者用户名">在单个查询中解析作者用户名</h2> <p>由于 GraphQL 的强大功能,我们现在可以一次性在单个查询中获取所有作者的用户名,而不是分别解析每个作者的用户名!让我们利用这个功能来重构我们的代码,使其更简单并提高性能:</p> <ol> <li> <p>首先,编辑 <strong>src/api/graphql/posts.js</strong> 中的 GraphQL 查询,添加 <strong>author.username</strong> 字段,如下所示:</p> <pre><code class="language-js">export const GET_POSTS = gql`   query getPosts {     posts {       author {         username       } </code></pre> </li> <li> <p>然后,编辑 <strong>src/components/User.jsx</strong> 组件。<em>替换</em>整个组件为以下更简单的组件:</p> <pre><code class="language-js">import PropTypes from 'prop-types' export function User({ username }) {   return <b>{username}</b> } User.propTypes = {   username: PropTypes.string.isRequired, } </code></pre> <p>现在在这里获取用户信息不再必要,因为我们可以直接从 GraphQL 响应中显示用户名。</p> </li> <li> <p>接下来,编辑 <strong>src/components/Post.jsx</strong> 并按照以下方式将整个 <strong>author</strong> 对象传递给 <strong>User</strong> 组件:</p> <pre><code class="language-js">          Written by <User {...author} /> </code></pre> </li> <li> <p>我们还需要调整 <strong>propTypes</strong> 以接受 <strong>Post</strong> 组件的完整 <strong>author</strong> 对象,而不是用户 ID:</p> <pre><code class="language-js">  author: PropTypes.shape(User.propTypes), </code></pre> </li> <li> <p>编辑 <strong>src/pages/ViewPost.jsx</strong> 并将整个 <strong>author</strong> 对象传递给 <strong>Post</strong> 组件:</p> <pre><code class="language-js">        <Post {...post} id={postId} src/components/Header.jsx and import the useQuery hook and the getUserInfo API function: </code></pre> <p>导入 <code>{ useQuery }</code> 从 <code>@tanstack/react-query</code></p> <p>导入 <code>{ getUserInfo }</code> 从 <code>../api/users.js</code></p> <pre><code class="language-js"> </code></pre> </li> <li> <p>然后,调整组件以从令牌(JWT 的 <strong>sub</strong> 字段)中获取用户 ID 并对用户信息进行查询:</p> <pre><code class="language-js">export function Header() {   const [token, setToken] = useAuth()   const { sub } = token ? jwtDecode(token) : {}   const userInfoQuery = useQuery({     queryKey: ['users', sub],     queryFn: () => getUserInfo(sub),     enabled: Boolean(sub),   })   const userInfo = userInfoQuery.data </code></pre> </li> <li> <p>最后,我们检查是否能够解析用户信息查询(而不是仅仅检查 <strong>token</strong>)。如果是这样,我们将用户信息传递给 <strong>User</strong> 组件:</p> <pre><code class="language-js">  if (token && userInfo) {     return (       <nav>         Logged in as <User {...userInfo} /> </code></pre> <p>我们还像之前一样移除了令牌解码。</p> </li> </ol> <p>现在我们正在使用 GraphQL 来获取帖子列表并在单个请求中解析作者用户名!然而,过滤和排序不再工作,因为我们还没有将此信息传递给 GraphQL 查询。</p> <p>在下一节中,我们将介绍用于过滤和排序 GraphQL 查询的变量。</p> <h1 id="在-graphql-查询中使用变量">在 GraphQL 查询中使用变量</h1> <p>要添加对过滤和排序的支持,我们需要在我们的 GraphQL 查询中添加变量。然后,在执行查询时我们可以填写这些变量。</p> <p>按照以下步骤向查询中添加变量:</p> <ol> <li> <p>编辑 <strong>src/api/graphql/posts.js</strong> 并调整查询以接受一个 <strong>$options</strong> 变量:</p> <pre><code class="language-js">export const GET_POSTS = gql`   query getPosts($options: PostsOptions) { </code></pre> </li> <li> <p>然后,将 <strong>$options</strong> 变量传递给 <strong>posts</strong> 解析器,因为我们已经在上一章中实现了 <strong>options</strong> 参数:</p> <pre><code class="language-js">    posts(options: $options) { </code></pre> </li> <li> <p>现在,我们只需在执行查询时传递这些选项。编辑 <strong>src/pages/Blog.jsx</strong> 并按照以下方式传递变量:</p> <pre><code class="language-js">  const postsQuery = useGraphQLQuery(GET_POSTS, {     variables: { options: { sortBy, sortOrder } },   }) </code></pre> </li> <li> <p>前往博客前端并将排序顺序更改为升序,以查看变量的实际效果!</p> </li> </ol> <h2 id="使用片段重用查询的部分">使用片段重用查询的部分</h2> <p>现在排序功能已经正常工作,我们只需要添加按作者过滤的功能。为此,我们需要为 <code>postsByAuthor</code> 添加第二个查询。正如你所想象的那样,这个查询应该返回与 <code>posts</code> 查询相同的字段。我们可以利用片段来重用这两个查询的字段,如下所示:</p> <ol> <li> <p>编辑 <strong>src/api/graphql/posts.js</strong> 并在 GraphQL 中定义一个新的片段,其中包含我们从帖子中需要的所有字段:</p> <pre><code class="language-js">export const POST_FIELDS = gql`   fragment PostFields on Post {     id     title     contents     tags     updatedAt     createdAt     author {       username     }   } ` </code></pre> <p>该片段通过给它一个名称(<code>PostFields</code>)并指定它可以用于哪种类型(<code>on Post</code>)来定义。然后,可以在片段中查询指定类型的所有字段。</p> </li> <li> <p>要使用片段,我们首先必须将其定义包含在 <strong>GET_POSTS</strong> 查询中:</p> <pre><code class="language-js">export const GET_POSTS = gql`   ${POST_FIELDS}   query getPosts($options: PostsOptions) { </code></pre> </li> <li> <p>现在,我们不再需要手动列出所有字段,我们可以使用片段:</p> <pre><code class="language-js">    posts(options: $options) {       ...PostFields     }   } ` </code></pre> <p>使用片段的语法类似于 JavaScript 中的对象解构,其中对象中定义的所有属性都会扩展到另一个对象中。</p> </li> </ol> <p>注意</p> <p>有时需要重新启动 VS Code GraphQL 扩展才能正确检测片段。您可以通过访问 VS Code 命令面板(在 Windows/Linux 上为 <em>Ctrl</em> + <em>Shift</em> + <em>P</em>,在 macOS 上为 <em>Cmd</em> + <em>Shift</em> + <em>P</em>)并输入 <strong>GraphQL:</strong> <strong>Manual Restart</strong> 来这样做。</p> <ol> <li> <p>接下来,我们定义第二个查询,通过作者查询帖子,并使用片段获取所有必要的字段:</p> <pre><code class="language-js">export const GET_POSTS_BY_AUTHOR = gql`   ${POST_FIELDS}   query getPostsByAuthor($author: String!, $options: PostsOptions) {     postsByAuthor(username: $author, options: $options) {       ...PostFields     }   } ` </code></pre> <p>我们将 <code>$author</code> 变量定义为该查询所必需的(通过在类型后使用感叹号)。我们需要这样做,因为 <code>postsByAuthor</code> 字段也要求设置第一个参数(<code>username</code>)。</p> </li> <li> <p>编辑 <strong>src/pages/Blog.jsx</strong> 并导入新定义的查询:</p> <pre><code class="language-js">import { GET_POSTS, GET_POSTS_BY_AUTHOR } from '../api/graphql/posts.js' </code></pre> </li> <li> <p>然后,调整钩子以使用 <strong>GET_POSTS_BY_AUTHOR</strong> 查询,如果 <strong>author</strong> 已定义:</p> <pre><code class="language-js">  const postsQuery = useGraphQLQuery(author ? GET_POSTS_BY_AUTHOR : GET_POSTS, { </code></pre> </li> <li> <p>将 <strong>author</strong> 变量传递给查询:</p> <pre><code class="language-js">    variables: { author, options: { sortBy, sortOrder } },   }) </code></pre> </li> <li> <p>最后,我们需要调整选择结果的方式,因为 <strong>GET_POSTS_BY_AUTHOR</strong> 查询中的 <strong>postsByAuthor</strong> 字段将结果返回在 <strong>data.postsByAuthor</strong> 中,而 <strong>GET_POSTS</strong> 查询使用 <strong>posts</strong> 字段,结果返回在 <strong>data.posts</strong> 中。由于没有同时返回这两个字段的情况,我们可以简单地这样做:</p> <pre><code class="language-js">  const posts = postsQuery.data?.postsByAuthor ?? postsQuery.data?.posts ?? [] </code></pre> </li> <li> <p>前往前端尝试按作者过滤。现在过滤器又正常工作了!</p> </li> </ol> <p>如我们所见,片段对于重复使用相同字段进行多个查询非常有用!现在我们的帖子列表已经完全重构为使用 GraphQL,让我们继续在前端使用突变,这样我们就可以将注册、登录和创建帖子功能迁移到 GraphQL。</p> <h1 id="在前端使用突变">在前端使用突变</h1> <p>如我们在上一章所学,GraphQL 中的突变用于更改后端的状态(类似于 REST 中的 <code>POST</code> 请求)。我们现在将实现注册和登录的突变。</p> <p>按照以下步骤操作:</p> <ol> <li> <p>创建一个新的 <strong>src/api/graphql/users.js</strong> 文件并导入 <strong>gql</strong>:</p> <pre><code class="language-js">import { gql } from '@apollo/client/core/index.js' </code></pre> </li> <li> <p>然后,定义一个新的 <strong>SIGNUP_USER</strong> 突变,它接受用户名和密码并调用 <strong>signupUser</strong> 突变字段:</p> <pre><code class="language-js">export const SIGNUP_USER = gql`   mutation signupUser($username: String!, $password: String!) {     signupUser(username: $username, password: $password) {       username     }   } ` </code></pre> </li> <li> <p>编辑 <strong>src/pages/Signup.jsx</strong> 并将当前来自 TanStack React Query 的 <strong>useMutation</strong> hook 替换为来自 Apollo Client 的一个。正如我们之前为 <strong>useQuery</strong> 所做的那样,我们也将把这个 hook 重命名为 <strong>useGraphQLMutation</strong> 以避免混淆:</p> <pre><code class="language-js">import { useMutation as useGraphQLMutation } from '@apollo/client/react/index.js' </code></pre> </li> <li> <p>此外,<em>替换</em> <strong>signup</strong> 函数的导入为 <strong>SIGNUP_USER</strong> mutation 的导入:</p> <pre><code class="language-js">import { SIGNUP_USER } from '../api/graphql/users.js' </code></pre> </li> <li> <p><em>替换</em> 现有的 mutation hook 为以下内容:</p> <pre><code class="language-js">  const [signupUser, { loading }] = useGraphQLMutation(SIGNUP_USER, {     variables: { username, password },     onCompleted: () => navigate('/login'),     onError: () => alert('failed to sign up!'),   }) </code></pre> <p>如所见,Apollo Client 的 mutation hook 与 TanStack React Query 的 mutation hook 有略微不同的 API。它返回一个包含调用 mutation 的函数以及包含加载状态、错误状态和数据的对象的数组。类似于 <code>useGraphQLQuery</code> hook,它也接受 mutation 作为第一个参数,以及包含变量的对象作为第二个参数。此外,Apollo Client 中的 <code>onSuccess</code> 函数被命名为 <code>onCompleted</code>。</p> </li> <li> <p>按如下方式更改 <strong>handleSubmit</strong> 函数:</p> <pre><code class="language-js">  const handleSubmit = (e) => {     e.preventDefault()     signupUser()   } </code></pre> </li> <li> <p>最后,按如下方式更改提交按钮:</p> <pre><code class="language-js">      <input         type='submit'         value={loading ? 'Signing up...' : 'Sign Up'}         disabled={!username || !password || loading}       /> </code></pre> </li> </ol> <p>现在注册功能已成功迁移到 GraphQL。接下来,让我们迁移登录功能。</p> <h2 id="将登录迁移到-graphql">将登录迁移到 GraphQL</h2> <p>将登录功能重构为 GraphQL 与注册功能非常相似,所以让我们快速浏览一下步骤:</p> <ol> <li> <p>编辑 <strong>src/api/graphql/users.js</strong> 并为登录定义一个 mutation:</p> <pre><code class="language-js">export const LOGIN_USER = gql`   mutation loginUser($username: String!, $password: String!) {     loginUser(username: $username, password: $password)   } ` </code></pre> </li> <li> <p>编辑 <strong>src/pages/Login.jsx</strong> 并将导入 TanStack React Query 和 <strong>login</strong> 函数替换为以下内容:</p> <pre><code class="language-js">import { useMutation as useGraphQLMutation } from '@apollo/client/react/index.js' import { LOGIN_USER } from '../api/graphql/users.js' </code></pre> </li> <li> <p>更新 hook:</p> <pre><code class="language-js">  const [loginUser, { loading }] = useGraphQLMutation(LOGIN_USER, {     variables: { username, password },     onCompleted: (data) => {       setToken(data.loginUser)       navigate('/')     },     onError: () => alert('failed to login!'),   }) </code></pre> </li> <li> <p>更新 <strong>handleSubmit</strong> 函数:</p> <pre><code class="language-js">  const handleSubmit = (e) => {     e.preventDefault()     loginUser()   } </code></pre> </li> <li> <p>最后,更新提交按钮:</p> <pre><code class="language-js">      <input         type='submit'         value={loading ? 'Logging in...' : 'Log In'}         disabled={!username || !password || loading}       /> </code></pre> </li> </ol> <p>现在,注册和登录都使用 GraphQL mutation,让我们继续迁移创建帖子功能到 GraphQL。</p> <h2 id="将创建帖子迁移到-graphql">将创建帖子迁移到 GraphQL</h2> <p>创建帖子功能实现起来有点复杂,因为它要求我们登录(这意味着我们需要发送 JWT 标头),并使帖子列表查询失效,以便在创建新帖子后更新列表。</p> <p>现在让我们开始使用 Apollo Client 来实现这个功能:</p> <ol> <li> <p>首先,让我们定义 mutation。编辑 <strong>src/api/graphql/posts.js</strong> 并添加以下代码:</p> <pre><code class="language-js">export const CREATE_POST = gql`   mutation createPost($title: String!, $contents: String, $tags: [String!]) {     createPost(title: $title, contents: $contents, tags: $tags) {       id       title     }   } ` </code></pre> <p>对于这个 mutation,我们将使用响应来获取创建的帖子的 <code>id</code> 和 <code>title</code>。我们将利用这些数据在成功创建后显示帖子的链接。</p> </li> <li> <p>然后,编辑 <strong>src/components/CreatePost.jsx</strong> 并将 TanStack React Query 的导入替换为 mutation hook 的导入:</p> <pre><code class="language-js">import { useMutation as useGraphQLMutation } from '@apollo/client/react/index.js' </code></pre> </li> <li> <p>此外,导入 <strong>Link</strong> 组件和 <strong>slug</strong> 函数以显示创建的帖子链接:</p> <pre><code class="language-js">import { Link } from 'react-router-dom' import slug from 'slug' </code></pre> </li> <li> <p><em>替换</em> <strong>createPost</strong> 函数的导入为 <strong>CREATE_POST</strong> mutation 和 <strong>GET_POSTS</strong> 以及 <strong>GET_POSTS_BY_AUTHOR</strong> 查询的导入。我们将使用这些查询定义让 Apollo Client 在稍后为我们重新获取它们:</p> <pre><code class="language-js">import {   CREATE_POST,   GET_POSTS,   GET_POSTS_BY_AUTHOR, } from '../api/graphql/posts.js' </code></pre> </li> <li> <p><em>替换</em> 现有的查询客户端和 mutation hook 为以下 GraphQL mutation,其中我们传递 <strong>title</strong> 和 <strong>contents</strong> 变量:</p> <pre><code class="language-js">  const [createPost, { loading, data }] = useGraphQLMutation(CREATE_POST, {     variables: { title, contents }, </code></pre> </li> <li> <p>接下来,我们将 JWT 标头作为 <strong>context</strong> 传递给 mutation:</p> <pre><code class="language-js">    context: { headers: { Authorization: `Bearer ${token}` } }, </code></pre> </li> <li> <p>然后,我们将 <strong>refetchQueries</strong> 选项提供给突变,告诉 Apollo Client 在调用突变后重新获取某些查询:</p> <pre><code class="language-js">    refetchQueries: [GET_POSTS, GET_POSTS_BY_AUTHOR],   }) </code></pre> </li> </ol> <p>注意</p> <p>由于突变后的重新获取是一个常见的操作,Apollo Client 提供了一种简单的方法在突变钩子中执行此操作。只需将所有应重新获取的查询传递到那里,Apollo Client 将负责处理。</p> <ol> <li> <p>调整 <strong>handleSubmit</strong> 函数:</p> <pre><code class="language-js">  const handleSubmit = (e) => {     e.preventDefault()     createPost()   } </code></pre> </li> <li> <p>调整提交按钮:</p> <pre><code class="language-js">      <input         type='submit'         value={loading ? 'Creating...' : 'Create'}         disabled={!title || loading}       /> </code></pre> </li> <li> <p>最后,我们将更改成功消息,显示创建的帖子的链接:</p> <pre><code class="language-js">      {data?.createPost ? (         <>           <br />           Post{' '}           <Link             to={`/posts/${data.createPost.id}/${slug(data.createPost.title)}`}           >             {data.createPost.title}           </Link>{' '}           created successfully!         </>       ) : null} </code></pre> <p>由于 GraphQL 中类型和解析器的工作方式,它使我们能够轻松地访问突变结果的字段,就像我们正在获取单个帖子一样。例如,我们甚至可以告诉 GraphQL 获取创建的帖子的作者的用户名!</p> </li> <li> <p>尝试创建一个新的帖子,你会看到成功消息现在显示了创建的帖子的链接,帖子列表也会自动为我们重新获取!</p> <p>以下截图显示了一个新帖子成功创建,成功消息中显示了新帖子的链接,以及帖子列表中的新帖子(由 Apollo Client 自动重新获取):</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_12_1.jpg" alt="图 12.1:使用 GraphQL 突变创建帖子,并重新获取帖子列表" loading="lazy"></p> <p>图 12.1:使用 GraphQL 突变创建帖子,并重新获取帖子列表</p> <p>现在我们已经成功实现了使用 GraphQL 创建帖子,我们的博客应用已经完全连接到我们的 GraphQL 服务器。</p> <p>在这本书中,我们还没有涵盖 GraphQL 的许多更高级的概念,例如高级重新获取、订阅(从 GraphQL 服务器获取实时更新)、错误处理、suspense、分页和缓存。本书中的 GraphQL 章节仅作为 GraphQL 的入门介绍。</p> <p>如果你希望了解更多关于 GraphQL 和 Apollo 的信息,我建议查看广泛的 Apollo 文档(<a href="https://www.apollographql.com/docs/" target="_blank"><code>www.apollographql.com/docs/</code></a>),其中包含有关使用 Apollo Server 和 Apollo Client 的详细信息和实践示例。</p> <h1 id="摘要-10">摘要</h1> <p>在本章中,我们使用 Apollo Client 将之前创建的 GraphQL 后端连接到前端。我们首先设置 Apollo Client 并执行一个 GraphQL 查询以获取所有帖子。然后,我们通过在单个请求中获取作者用户名来提高帖子列表的性能,利用 GraphQL 的强大功能。</p> <p>接下来,我们在查询中引入了变量,并重新实现了按作者排序和过滤。我们还引入了查询中的片段以重用相同的字段。最后,我们在前端实现了 GraphQL 突变以注册、登录和创建帖子。我们还沿途了解了 Apollo Client 中的查询重新获取,并简要介绍了 GraphQL 和 Apollo 的高级概念。</p> <p>在下一章,<em>第十三章</em>,<em>使用 Express 和 Socket.IO 构建基于事件驱动的后端</em>,我们将从传统的全栈架构中跳出来,并使用一种特殊类型的全栈架构:基于事件的程序来构建一个新应用。</p> <h1 id="第四部分探索基于事件的完整栈架构">第四部分:探索基于事件的完整栈架构</h1> <p>在本书的这一部分,我们将摆脱传统的全栈架构,探索一种特殊类型的全栈架构:<strong>基于事件的架构</strong>。基于事件的架构的例子包括处理实时数据的软件,例如协作应用(例如,Google Docs、在线白板等)或金融应用(例如,Kraken 加密货币交易所)。我们首先将使用<strong>Express</strong>和<strong>Socket.IO</strong>开发一个基于事件的后端。然后,我们将创建一个前端来消费和发送事件。最后,我们将使用<strong>MongoDB</strong>为我们的应用添加持久性和功能,以便回放事件。</p> <p>本部分包括以下章节:</p> <ul> <li> <p><em>第十三章</em>,<em>使用 Express 和 Socket.IO 构建基于事件的后端</em></p> </li> <li> <p><em>第十四章</em>,<em>创建一个前端以消费和发送事件</em></p> </li> <li> <p><em>第十五章</em>,<em>使用 MongoDB 为 Socket.IO 添加持久性</em></p> </li> </ul> <h1 id="第十三章使用-express-和-socketio-构建事件驱动后端">第十三章:使用 Express 和 Socket.IO 构建事件驱动后端</h1> <p>在本章中,我们将学习关于事件驱动应用程序以及使用这种架构与更传统架构相比的权衡。然后,我们将学习关于 WebSockets 以及它们是如何工作的。之后,我们将使用 Socket.IO 和 Express 实现后端。最后,我们将学习如何通过使用 JWT 与 Socket.IO 集成来实现身份验证。</p> <p>在本章中,我们将涵盖以下主要主题:</p> <ul> <li> <p>什么是事件驱动应用程序?</p> </li> <li> <p>设置 Socket.IO</p> </li> <li> <p>使用 Socket.IO 创建聊天应用的后端</p> </li> <li> <p>通过将 JWT 与 Socket.IO 集成来添加身份验证</p> </li> </ul> <h1 id="技术要求-12">技术要求</h1> <p>在我们开始之前,请从<em>第一章</em>,<em>为全栈开发做准备</em>,以及<em>第二章</em>,<em>了解 Node.js 和 MongoDB</em>中安装所有要求。</p> <p>那些章节中列出的版本是本书中使用的版本。虽然安装较新版本不应有问题,但请注意,某些步骤可能有所不同。如果你在使用本书中提供的代码和步骤时遇到问题,请尝试使用 <em>第一章</em> 和 <em>第二章</em> 中提到的版本。</p> <p>你可以在 GitHub 上找到本章的代码:<a href="https://github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch13" target="_blank"><code>github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch13</code></a>。</p> <p>如果你克隆了本书的完整仓库,在运行 <code>npm install</code> 时 Husky 可能找不到 <code>.git</code> 目录。在这种情况下,只需在相应章节文件夹的根目录下运行 <code>git init</code>。</p> <p>本章的 CiA 视频可以在以下位置找到:<a href="https://youtu.be/kHGvkopIHf4" target="_blank"><code>youtu.be/kHGvkopIHf4</code></a>。</p> <h1 id="什么是事件驱动应用程序">什么是事件驱动应用程序?</h1> <p>与传统的基于请求-响应模式的 Web 应用程序相比,在事件驱动应用程序中,我们处理的是事件。服务器和客户端保持连接,每一方都可以发送事件,另一方监听并做出反应。</p> <p>以下图表显示了在请求-响应模式与事件驱动模式之间实现聊天应用的区别:</p> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_13_1.jpg" alt="图 13.1 – 使用请求-响应和事件驱动模式的聊天应用实现" loading="lazy"></p> <p>图 13.1 – 使用请求-响应和事件驱动模式的聊天应用实现</p> <p>例如,在请求-响应模式中实现聊天应用,我们需要定期向<code>GET /chat/messages</code>端点发送请求以刷新聊天室中发送的消息列表。这种定期发送请求的过程称为短轮询。要发送聊天消息,我们会向<code>POST /chat/messages</code>发送请求。在事件驱动模式中,我们可以从客户端向服务器发送<code>chat.message</code>事件,然后服务器将<code>chat.message</code>事件发送给所有已连接的用户。然后客户端监听<code>chat.message</code>事件,并在消息到来时显示它们;不需要定期请求!</p> <p>当然,每种模式都有其优缺点:</p> <ul> <li> <p>REST/请求-响应:</p> <ul> <li> <p>当数据不经常变化时很有用</p> </li> <li> <p>响应可以轻松缓存</p> </li> <li> <p>请求是无状态的,这使得扩展后端变得容易</p> </li> <li> <p>在实时更新方面表现不佳(需要定期轮询)</p> </li> <li> <p>每个请求的开销更大(在发送许多短响应时不好)</p> </li> </ul> </li> <li> <p>WebSocket/事件驱动:</p> <ul> <li> <p>对于需要频繁更新的应用很有用</p> </li> <li> <p>更高效,因为客户端和服务器之间的持久连接被重复用于多个请求</p> </li> <li> <p>每个请求的开销较小</p> </li> <li> <p>可能会与(企业)代理存在连接问题</p> </li> <li> <p>它是有状态的,这可能会使扩展应用更困难</p> </li> </ul> </li> </ul> <p>如我们所见,对于获取不经常变化(并且可以缓存)的数据(如博客文章),请求-响应模式更合适。对于数据频繁变化的应用(如聊天室),事件驱动模式更合适。</p> <h2 id="什么是-websockets">什么是 WebSockets?</h2> <p>WebSocket API 是一个浏览器功能,允许 Web 应用程序在客户端和服务器之间创建一个开放的连接,类似于 Unix 风格的套接字。使用 WebSockets,通信可以同时双向进行。这与 HTTP 请求形成对比,在 HTTP 请求中,双方可以通信,但不能同时进行。</p> <p>WebSockets 使用 HTTP 在客户端和服务器之间建立连接,然后将协议从 HTTP 升级到 WebSocket 协议。虽然 HTTP 和 WebSockets 都依赖于<strong>传输控制协议</strong>(<strong>TCP</strong>),但它们是<strong>开放系统互联</strong>(<strong>OSI</strong>)模型应用层(第 7 层)上的不同协议。</p> <p>通过发送带有<code>Upgrade: websocket</code>头和其他参数的 HTTP 请求来建立 WebSocket 连接。然后服务器响应以<code>HTTP 101 Switching Protocols</code>响应代码和建立连接的信息。然后,客户端和服务器继续在 WebSocket 协议上进行通信。</p> <h2 id="什么是-socketio">什么是 Socket.IO?</h2> <p>Socket.IO 是一个基于事件的客户端和服务器库的实现。在大多数情况下,它使用 WebSocket 连接到服务器。如果 WebSocket 连接不可行(由于浏览器支持不足或防火墙设置),Socket.IO 也可以回退到 HTTP 长轮询。然而,Socket.IO 并不是一个纯 WebSocket 实现,因为它为每个数据包添加了额外的元数据。它仅在内部使用 WebSocket 传输数据。</p> <p>除了提供客户端和服务器之间发送事件的方式之外,Socket.IO 还在普通 WebSocket 之上提供了以下功能:</p> <ul> <li> <p><strong>回退到 HTTP 长轮询</strong>:如果 WebSocket 连接无法建立,则会发生这种情况。这对于使用代理或防火墙且阻止 WebSocket 连接的公司来说特别有用。</p> </li> <li> <p><strong>自动重连</strong>:如果 WebSocket 连接中断。</p> </li> <li> <p><strong>缓冲数据包</strong>:当客户端断开连接时,数据包可以在重新连接时自动重新发送。</p> </li> <li> <p><strong>确认</strong>:在请求-响应模式中发送事件的一种便捷方式,这在某些情况下甚至在基于事件的程序中也可能很有用。</p> </li> <li> <p><strong>广播</strong>:向所有(或所有连接客户端的子集)发送事件。</p> </li> <li> <p><strong>多路复用</strong>:Socket.IO 实现了命名空间,可以用来创建“频道”,只有特定用户可以发送事件并接收事件,例如“仅管理员频道”。</p> </li> </ul> <p>现在我们已经学习了 Socket.IO 的基本知识,让我们深入了解连接以及发送/接收事件的工作原理。</p> <h2 id="连接到-socketio">连接到 Socket.IO</h2> <p>以下图示展示了如何使用 Socket.IO 建立连接:</p> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_13_2.jpg" alt="图 13.2 – 使用 Socket.IO 建立连接" loading="lazy"></p> <p>图 13.2 – 使用 Socket.IO 建立连接</p> <p>首先,Socket.IO 从客户端(前端)向服务器(后端)发送一个握手信号,这个握手信号可以包含用于与服务器进行身份验证的信息,或者查询参数,以便在建立连接时提供额外的信息。</p> <p>如果无法通过 WebSocket 建立连接,Socket.IO 将通过 HTTP 长轮询连接到服务器,这意味着向服务器发送一个保持活跃的请求,直到发生事件,此时服务器向请求发送响应。这允许等待事件,而无需定期发送请求以查看是否有新事件。当然,这不如 WebSocket 性能好,但它是当 WebSocket 不可用时的一个很好的回退方案。</p> <h2 id="发送和接收事件">发送和接收事件</h2> <p>一旦连接到 Socket.IO,我们就可以开始 <strong>发射</strong>(发送)和接收事件。事件通过注册事件处理函数来处理,当客户端或服务器接收到某种类型的事件时,这些函数会被调用。客户端和服务器都可以发射和接收事件。此外,事件还可以从服务器广播到多个客户端。以下图示展示了在聊天应用程序中事件是如何发射和接收的:</p> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_13_3.jpg" alt="图 13.3 – 使用 Socket.IO 发射和接收事件" loading="lazy"></p> <p>图 13.3 – 使用 Socket.IO 发射和接收事件</p> <p>正如我们所见,<strong>用户 1</strong> 发送了一条 <strong>大家好</strong> 的消息,服务器(后端)随后将其广播给所有其他客户端(前端)。在这种情况下,消息被广播回 <strong>用户 1</strong>,以及 <strong>用户 2</strong>。</p> <p>如果我们想限制接收特定事件的客户端,Socket.IO 允许创建 <strong>rooms</strong>。客户端可以加入一个房间,在服务器上,我们也可以向特定房间广播事件。这个概念可以用于聊天室,也可以用于在特定项目上协作(例如实时共同编辑文档)。</p> <p>除了异步地发射和接收事件外,Socket.IO 还提供了一种通过 <code>user.info</code> 事件发送期望响应的事件的方式,并同步等待服务器响应(确认)。我们可以在前面的图中看到这一点,其中 <strong>用户 2</strong> 请求有关某个用户的信息,然后收到包含用户信息的响应。</p> <p>现在我们已经了解了基于事件的程序、WebSockets 和 Socket.IO,让我们将这个理论付诸实践并设置 Socket.IO。</p> <h1 id="设置-socketio">设置 Socket.IO</h1> <p>要设置 Socket.IO 服务器,我们将基于我们在 <em>第六章</em> 中所学的代码,<em>使用 JWT 添加身份验证和角色</em>,因为它已经包含了一些后端和前端 JWT 身份验证的样板代码。在本章的 <em>通过将 JWT 与 Socket.IO 集成来添加身份验证</em> 部分,我们将使用 JWT 为 Socket.IO 添加身份验证:</p> <ol> <li> <p>将现有的 <strong>ch6</strong> 文件夹复制到新的 <strong>ch13</strong> 文件夹中,如下所示:</p> <pre><code class="language-js">$ cp -R ch6 ch13 </code></pre> </li> <li> <p>在 VS Code 中打开 <strong>ch13</strong> 文件夹。</p> </li> <li> <p>现在,我们可以开始设置 Socket.IO。首先,通过运行以下命令在后端文件夹中安装 <strong>socket.io</strong> 包:</p> <pre><code class="language-js">$ cd backend/ $ npm install socket.io@4.7.2 </code></pre> </li> <li> <p>编辑 <strong>backend/.env</strong> 并更改 <strong>DATABASE_URL</strong>,使其指向一个新的 <strong>chat</strong> 数据库:</p> <pre><code class="language-js">DATABASE_URL=mongodb://localhost:27017/chat </code></pre> </li> <li> <p>编辑 <strong>backend/src/app.js</strong> 并从 <strong>node:http</strong> 导入 <strong>createServer</strong> 函数,从 <strong>socket.io</strong> 导入 <strong>Server</strong> 函数:</p> <pre><code class="language-js">import { createServer } from 'node:http' import { Server } from 'socket.io' </code></pre> <p>我们需要创建一个 <code>node:http</code> 服务器,因为我们不能直接将 Socket.IO 连接到 Express。相反,Socket.IO 附加到 <code>node:http</code> 服务器上。</p> </li> <li> <p>幸运的是,Express 也很容易附加到 <strong>node:http</strong> 服务器上。编辑 <strong>backend/src/app.js</strong> 并在 <strong>app</strong> 导出之前,从 Express 应用程序创建一个新的 <strong>node:http</strong> 服务器,如下所示:</p> <pre><code class="language-js">const server = createServer(app) </code></pre> </li> <li> <p>现在,从 <strong>node:http</strong> 服务器创建一个新的 Socket.IO 服务器:</p> <pre><code class="language-js">const io = new Server(server, {   cors: {     origin: '*',   }, }) </code></pre> </li> </ol> <p>警告</p> <p>设置原点为 ***** 使得钓鱼网站可以模仿你的网站并向你的后端发送请求。在生产环境中,原点应设置为前端部署的 URL。</p> <ol> <li> <p>我们可以使用 Socket.IO 服务器来监听来自客户端的连接并打印一条消息:</p> <pre><code class="language-js">io.on('connection', (socket) => {   console.log('user connected:', socket.id) </code></pre> </li> <li> <p>可以通过使用 <strong>socket</strong> 对象来跟踪活跃的客户端连接。例如,我们可以像这样监听来自客户端的断开事件:</p> <pre><code class="language-js">  socket.on('disconnect', () => {     console.log('user disconnected:', socket.id)   }) }) </code></pre> </li> <li> <p>最后,更改导出,使其使用 <strong>node:http</strong> 服务器而不是直接使用 Express 应用:</p> <pre><code class="language-js">export { server as app } </code></pre> </li> <li> <p>通过运行以下命令来启动后端:</p> <pre><code class="language-js">$ cd backend/ $ npm run dev </code></pre> <p>在启动后端之前,别忘了启动 Docker 和数据库容器。保持后端在本章的剩余部分运行。</p> </li> </ol> <p>现在我们已经设置了一个简单的 Socket.IO 服务器,让我们继续设置客户端。</p> <h2 id="设置简单的-socketio-客户端">设置简单的 Socket.IO 客户端</h2> <p>我们现在将使用现有的前端。在下一章,<em>第十四章</em>,<em>创建一个用于消费和发送事件的客户端前端</em>,我们将移除博客组件并为我们的聊天应用创建一个新的 React 前端。让我们开始设置一个简单的 Socket.IO 客户端:</p> <ol> <li> <p>在项目的根目录中,通过运行以下命令为前端安装 <strong>socket.io-client</strong> 包:</p> <pre><code class="language-js">backend folder anymore! </code></pre> </li> <li> <p>编辑 <strong>src/App.jsx</strong> 并从 <strong>socket.io-client</strong> 中导入 <strong>io</strong> 函数:</p> <pre><code class="language-js">import { io } from 'socket.io-client' </code></pre> </li> <li> <p>通过使用 <strong>io</strong> 函数并传递主机名和端口号来定义一个新的 Socket.IO 客户端实例:</p> <pre><code class="language-js">const socket = io(import.meta.env.VITE_SOCKET_HOST) </code></pre> <p>在这里,我们将通过环境变量传递 <code>localhost:3001</code>。我们无法在这里传递 HTTP URL,因为 Socket.IO 将尝试使用 WebSockets 连接到主机名和端口号。</p> </li> <li> <p>监听 <strong>connect</strong> 事件,并在成功连接到 Socket.IO 服务器时打印一条消息:</p> <pre><code class="language-js">socket.on('connect', () => {   console.log('connected to socket.io as', socket.id) }) </code></pre> </li> <li> <p>此外,监听 <strong>connect_error</strong> 事件,并在连接到 Socket.IO 服务器失败时记录错误消息:</p> <pre><code class="language-js">socket.on('connect_error', (err) => {   console.error('socket.io connect error:', err) }) </code></pre> </li> <li> <p>编辑 <strong>.env</strong> 并添加以下环境变量:</p> <pre><code class="language-js">VITE_SOCKET_HOST="localhost:3001" </code></pre> </li> <li> <p>按照以下方式运行前端:</p> <pre><code class="language-js">$ npm run dev </code></pre> </li> <li> <p>现在,通过访问 <strong><a href="http://localhost:5173/" target="_blank">http://localhost:5173/</a></strong> 在浏览器中打开前端。保持前端在本章的剩余部分运行。</p> </li> </ol> <p>你将在浏览器控制台中看到一个表示 <strong>已连接到 socket.io</strong> 的消息。在服务器输出中,你会看到客户端已成功连接。尝试刷新页面以查看它断开连接并再次连接(使用新的 socket ID):</p> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_13_4.jpg" alt="图 13.4 – 观察 Socket.IO 客户端连接到并断开我们的服务器" loading="lazy"></p> <p>图 13.4 – 观察 Socket.IO 客户端连接到并断开我们的服务器</p> <p>现在我们已经成功设置了 Socket.IO 服务器,让我们继续创建一个使用 Socket.IO 的聊天应用的后端。</p> <h1 id="使用-socketio-为聊天应用创建后端">使用 Socket.IO 为聊天应用创建后端</h1> <p>我们现在可以开始使用 Socket.IO 实现聊天应用了。我们将为我们的聊天应用开发以下功能:</p> <ul> <li> <p><strong>从客户端向服务器发射</strong> 聊天消息的事件</p> </li> <li> <p><strong>从服务器向所有客户端广播</strong> 聊天消息</p> </li> <li> <p>加入 <strong>房间</strong> 以发送消息</p> </li> <li> <p>使用 <strong>确认</strong> 获取关于用户的信息</p> </li> </ul> <p>让我们开始吧!</p> <h2 id="从客户端向服务器发送聊天消息的事件发射">从客户端向服务器发送聊天消息的事件发射</h2> <p>我们将从客户端到服务器的 <code>chat.message</code> 事件开始。目前,我们将在这个连接后立即发射此事件。稍后,我们将将其集成到前端。按照以下步骤从客户端发送聊天消息并在服务器上接收:</p> <ol> <li> <p>编辑 <strong>backend/src/app.js</strong> 并 <em>剪切</em>/<em>删除</em> 以下代码:</p> <pre><code class="language-js">io.on('connection', (socket) => {   console.log('user connected:', socket.id)   socket.on('disconnect', () => {     console.log('user disconnected:', socket.id)   }) }) </code></pre> </li> <li> <p>创建一个新的 <strong>backend/src/socket.js</strong> 文件,在那里定义一个 <strong>handleSocket</strong> 函数,并在其中粘贴以下代码:</p> <pre><code class="language-js">export function handleSocket(io) {   io.on('connection', (socket) => {     console.log('user connected:', socket.id)     socket.on('disconnect', () => {       console.log('user disconnected:', socket.id)     }) </code></pre> </li> <li> <p>现在添加一个新的监听器,它监听 <strong>chat.message</strong> 事件并记录客户端发送的消息:</p> <pre><code class="language-js">    socket.on('chat.message', (message) => {       console.log(`${socket.id}: ${message}`)     })   }) } </code></pre> </li> <li> <p>编辑 <strong>backend/src/app.js</strong> 并导入 <strong>handleSocket</strong> 函数:</p> <pre><code class="language-js">import { handleSocket } from './socket.js' </code></pre> </li> <li> <p>一旦创建好 Socket.IO 服务器,调用 <strong>handleSocket</strong> 函数:</p> <pre><code class="language-js">const io = new Server(server, {   cors: {     origin: '*',   }, }) handleSocket(io) </code></pre> </li> <li> <p>编辑 <strong>src/App.jsx</strong> 并发射一个包含一些文本的 <strong>chat.message</strong> 事件,如下所示:</p> <pre><code class="language-js">socket.on('connect', () => {   console.log('connected to socket.io as', socket.id)   socket.emit('chat.message', 'hello from client') }) </code></pre> </li> </ol> <p>信息</p> <p>Socket.IO 允许我们在事件中发送任何可序列化的数据结构,而不仅仅是字符串!例如,可以发送对象和数组。</p> <p>后端和前端应该自动刷新,服务器将记录以下消息:</p> <pre><code class="language-js">XXmWHjA_5zew70VIAAAM: hello from client </code></pre> <p>如果没有,请确保你(重新)启动后端和前端,并手动刷新页面。</p> <p>如您所见,使用 Socket.IO 异步实时发送和接收事件相当简单。</p> <h2 id="从服务器向所有客户端广播聊天消息">从服务器向所有客户端广播聊天消息</h2> <p>现在后端服务器可以接收来自客户端的消息,我们需要将这些消息 <strong>广播</strong> 给所有其他客户端,以便其他人可以看到发送的聊天消息。让我们现在就来做这件事:</p> <ol> <li> <p>编辑 <strong>backend/src/socket.js</strong> 并扩展 <strong>chat.message</strong> 事件监听器,使其调用 <strong>io.emit</strong> 并将聊天消息发送给所有人:</p> <pre><code class="language-js">    socket.on('chat.message', (message) => {       console.log(`${socket.id}: ${message}`)       io.emit('chat.message', {         username: socket.id,         message,       })     }) </code></pre> </li> </ol> <p>注意</p> <p>或者,你可以使用 <strong>socket.broadcast.emit</strong> 向除了发送消息的那个客户端以外的所有客户端发送事件。</p> <ol> <li> <p>我们还需要在客户端添加一个用于聊天消息的监听器。这与服务器上的方式相同。编辑 <strong>src/App.jsx</strong> 并添加以下事件监听器:</p> <pre><code class="language-js">socket.on('chat.message', (msg) => {   console.log(`${msg.username}: ${msg.message}`) }) </code></pre> </li> <li> <p>现在,你应该能在服务器和客户端看到消息被记录。尝试打开第二个窗口;你将在浏览器中看到来自两个客户端的消息!</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_13_5.jpg" alt="图 13.5 – 从另一个客户端接收消息" loading="lazy"></p> <p>图 13.5 – 从另一个客户端接收消息</p> <h2 id="加入房间以发送消息">加入房间以发送消息</h2> <p>虽然有一个可以传递消息给所有人的工作聊天室是件好事,但通常我们不想将我们的消息广播给所有人。相反,我们可能只想将消息发送给特定的一组人。为了实现这一点,Socket.IO 提供了 <strong>rooms</strong>。房间可以用来将客户端分组,以便只将事件发送到房间中的所有其他客户端。这个功能可以用来创建聊天室,也可以用来共同协作完成项目(通过为每个项目创建一个新的房间)。让我们学习如何在 Socket.IO 中使用房间:</p> <ol> <li> <p>Socket.IO 允许我们在握手过程中传递查询字符串。我们可以访问这个查询字符串来获取客户端想要加入的房间。编辑 <strong>backend/src/socket.js</strong> 并从握手查询中获取房间:</p> <pre><code class="language-js">  io.on('connection', (socket) => {     console.log('user connected:', socket.id)     const room = socket.handshake.query?.room ?? 'public' </code></pre> </li> <li> <p>现在,使用 <strong>socket.join</strong> 将客户端加入所选房间:</p> <pre><code class="language-js">    socket.join(room)     console.log(socket.id, 'joined room:', room) </code></pre> </li> <li> <p>然后,在 <strong>chat.message</strong> 处理程序内部,使用 <strong>.to(room)</strong> 确保来自该客户端的聊天消息只发送到特定的房间:</p> <pre><code class="language-js">      io.to(room).emit('chat.message', {         username: socket.id,         message,       }) </code></pre> </li> <li> <p>在客户端,我们需要传递一个查询字符串来告诉服务器我们想要加入哪个房间。编辑 <strong>src/App.jsx</strong>,如下所示:</p> <pre><code class="language-js">const socket = io(import.meta.env.VITE_SOCKET_HOST, {   query: window.location.search.substring(1), ? at the beginning of the string). </code></pre> </li> <li> <p>在两个不同的浏览器窗口中打开 <strong><a href="http://localhost:5173/" target="_blank">http://localhost:5173/</a></strong> 和 <strong><a href="http://localhost:5173/?room=test" target="_blank">http://localhost:5173/?room=test</a></strong>,并从两个窗口发送消息。你会看到第二个窗口的消息没有发送到第一个窗口。然而,如果你打开另一个带有 <strong>?room=test</strong> 查询字符串的窗口并发送消息,你会看到消息被转发到第二个窗口(但不是第一个窗口)。</p> </li> </ol> <p>如我们所见,我们可以使用房间来对哪些客户端接收特定事件有更细粒度的控制。因为服务器控制客户端加入哪些房间,我们也可以在允许客户端加入房间之前添加权限检查。</p> <h2 id="使用确认信息来获取用户信息">使用确认信息来获取用户信息</h2> <p>正如我们所见,事件是发送异步消息的好方法。然而,有时我们想要一个更传统的同步请求-响应 API,就像我们之前在 REST 中所做的那样。在 Socket.IO 中,我们可以通过使用 <strong>acknowledgments</strong> 来实现同步事件。我们可以使用确认信息,例如,获取当前聊天室中用户的更多信息。目前,我们只将返回用户所在的房间。稍后,当我们添加身份验证时,我们将在这里从数据库中获取用户对象。让我们开始实现确认信息:</p> <ol> <li> <p>编辑 <strong>backend/src/socket.js</strong> 并定义一个新的事件监听器:</p> <pre><code class="language-js">    socket.on('user.info', async (socketId, callback) => { </code></pre> <p>注意我们是如何将回调函数作为最后一个参数传递的。这就是使事件成为确认信息的原因。</p> </li> <li> <p>在这个事件监听器中,我们将获取具有我们 socket ID 的房间中的所有 socket:</p> <pre><code class="language-js">      const sockets = await io.in(socketId).fetchSockets() </code></pre> <p>内部,Socket.IO 为每个连接的 socket 创建一个房间,以便能够向单个 socket 发送事件。</p> </li> </ol> <p>注意</p> <p>我们可以直接访问当前实例的 socket,但当我们把我们的服务扩展到集群中的多个实例时,这就不起作用了。为了使其即使在集群中也能工作,我们需要使用房间功能通过 ID 获取 socket。</p> <ol> <li> <p>现在,我们必须检查是否找到了具有给定 ID 的 socket。如果没有找到,我们返回 <strong>null</strong>:</p> <pre><code class="language-js">      if (sockets.length === 0) return callback(null) </code></pre> </li> <li> <p>否则,我们返回 socket ID 和用户所在的房间列表:</p> <pre><code class="language-js">      const socket = sockets[0]       const userInfo = {         socketId,         rooms: Array.from(socket.rooms),       }       return callback(userInfo)     }) </code></pre> </li> <li> <p>现在,我们可以在客户端上发出 <strong>user.info</strong> 事件。编辑 <strong>src/App.jsx</strong> 并首先将 <strong>connect</strong> 事件监听器改为 <strong>async</strong> 函数:</p> <pre><code class="language-js">socket.on('connect', async () => {   console.log('connected to socket.io as', socket.id)   socket.emit('chat.message', 'hello from client') </code></pre> </li> <li> <p>要使用确认发出事件,我们可以使用 <strong>emitWithAck</strong> 函数,它返回一个可以 <strong>await</strong> 的 Promise:</p> <pre><code class="language-js">  const userInfo = await socket.emitWithAck('user.info', socket.id)   console.log('user info', userInfo) }) </code></pre> </li> <li> <p>保存代码后,转到浏览器窗口;你将看到用户信息在控制台中记录下来:</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_13_6.jpg" alt="图 13.6 – 使用确认获取用户信息" loading="lazy"></p> <p>图 13.6 – 使用确认获取用户信息</p> <p>现在我们已经学习了如何发送各种类型的事件,让我们进入一个更高级的主题:使用 Socket.IO 进行身份验证。</p> <h1 id="通过将-jwt-与-socketio-集成添加身份验证">通过将 JWT 与 Socket.IO 集成添加身份验证</h1> <p>到目前为止,所有聊天消息都是使用 socket ID 作为“用户名”发送的。这不是在聊天室中识别用户的好方法。为了解决这个问题,我们将通过使用 JWT 验证 socket 来引入用户账户。按照以下步骤在 Socket.IO 中实现 JWT:</p> <ol> <li> <p>编辑 <strong>backend/src/socket.js</strong> 并从 <strong>jsonwebtoken</strong> 包中导入 <strong>jwt</strong>,以及从我们的服务函数中导入 <strong>getUserInfoById</strong>:</p> <pre><code class="language-js">import jwt from 'jsonwebtoken' import { getUserInfoById } from './services/users.js' </code></pre> </li> <li> <p>在 <strong>handleSocket</strong> 函数内部,使用 <strong>io.use()</strong> 定义一个新的 Socket.IO 中间件。Socket.IO 中的中间件与 Express 中的中间件类似 – 我们定义一个在请求处理之前运行的函数,如下所示:</p> <pre><code class="language-js">export function handleSocket(io) {   io.use((socket, next) => { </code></pre> </li> <li> <p>在这个函数内部,我们检查令牌是否通过 <strong>auth</strong> 对象发送(类似于我们之前如何通过查询字符串发送 <strong>room</strong>)。如果没有传递令牌,我们将错误传递给 <strong>next()</strong> 函数并导致连接失败:</p> <pre><code class="language-js">    if (!socket.handshake.auth?.token) {       return next(new Error('Authentication failed: no token provided'))     } </code></pre> </li> </ol> <p>注意</p> <p>重要的是不要通过查询字符串传递 JWT,因为这是 URL 的一部分。它在浏览器地址栏中暴露,因此可能被潜在攻击者存储在浏览器历史记录中。相反,<strong>auth</strong> 对象在握手过程中通过请求有效载荷发送,这不会在地址栏中暴露。</p> <ol> <li> <p>否则,我们调用 <strong>jwt.verify</strong> 通过现有的 <strong>JWT_SECRET</strong> 环境变量来验证令牌:</p> <pre><code class="language-js">    jwt.verify(       socket.handshake.auth.token,       process.env.JWT_SECRET, </code></pre> </li> <li> <p>如果令牌无效,我们再次在 <strong>next()</strong> 函数中返回一个错误:</p> <pre><code class="language-js">      async (err, decodedToken) => {         if (err) {           return next(new Error('Authentication failed: invalid token'))         } </code></pre> </li> <li> <p>否则,我们将解码的令牌保存到 <strong>socket.auth</strong>:</p> <pre><code class="language-js">        socket.auth = decodedToken </code></pre> </li> <li> <p>此外,我们从数据库中获取用户信息,为了方便,将其存储在 <strong>socket.user</strong>:</p> <pre><code class="language-js">        socket.user = await getUserInfoById(socket.auth.sub)         return next()       },     )   }) </code></pre> </li> </ol> <p>注意</p> <p>确保在 Socket.IO 中间件中始终调用 <strong>next()</strong>。否则,Socket.IO 将保持连接打开,直到在给定超时后关闭。</p> <ol> <li> <p><strong>user</strong>对象包含一个<strong>username</strong>值。现在,我们可以<strong>替换</strong>聊天消息中的 socket ID 为用户名:</p> <pre><code class="language-js">         socket.on('chat.message', (message) => {       console.log(`${socket.id}: ${message}`)       io.to(room).emit('chat.message', {         username: socket.user.username,         message,       })     }) </code></pre> </li> <li> <p>我们还可以从<strong>user.info</strong>事件返回用户信息:</p> <pre><code class="language-js">      const userInfo = {         socketId,         rooms: Array.from(socket.rooms),         user: socket.user,       } </code></pre> </li> <li> <p>我们仍然需要从客户端发送身份验证对象,编辑<strong>src/App.jsx</strong>,并从<strong>localStorage</strong>中获取令牌,如下所示:</p> <pre><code class="language-js">const socket = io(import.meta.env.VITE_SOCKET_HOST, {   query: window.location.search.substring(1),   auth: {     token: window.localStorage.getItem('token'),   }, }) </code></pre> </li> </ol> <p>注意</p> <p>为了简单起见,我们在这个例子中将 JWT 存储和读取到<strong>localStorage</strong>中。然而,在生产环境中将 JWT 以这种方式存储并不是一个好主意,因为如果攻击者找到了注入 JavaScript 的方法,<strong>localStorage</strong>可能会被读取。存储 JWT 的更好方式是使用具有<strong>Secure</strong>、<strong>HttpOnly</strong>和<strong>SameSite="Strict"</strong>属性的 cookie。</p> <ol> <li>现在服务器端已设置好,我们可以在客户端尝试登录。最初,我们会看到一个错误消息:</li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_13_7.jpg" alt="图 13.7 – 由于未提供 JWT 而从 Socket.IO 发出的错误消息" loading="lazy"></p> <p>图 13.7 – 由于未提供 JWT 而从 Socket.IO 发出的错误消息</p> <ol> <li>要获取令牌,我们可以使用现有的博客前端正常注册和登录。然后,我们可以检查检查器的<strong>网络</strong>选项卡,以找到响应中包含令牌的<strong>/login</strong>请求:</li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_13_8.jpg" alt="图 13.8 – 从网络选项卡复制 JWT" loading="lazy"></p> <p>图 13.8 – 从网络选项卡复制 JWT</p> <ol> <li>将此令牌复制并添加到<strong>localStorage</strong>中,通过在浏览器控制台中运行<strong>localStorage.setItem('token', '<JWT>')</strong>(将<strong><JWT></strong>替换为复制的令牌)。刷新页面后,它应该可以工作!正如我们所见,当我们用两个不同的用户登录时,我们可以看到他们带有各自用户名的消息:</li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_13_9.jpg" alt="图 13.9 – 从不同用户接收消息" loading="lazy"></p> <p>图 13.9 – 从不同用户接收消息</p> <p>我们聊天应用的后端现在完全功能正常!在下一章中,我们将创建一个前端来完善我们的聊天应用。</p> <h1 id="摘要-11">摘要</h1> <p>在本章中,我们学习了基于事件的应用程序、WebSockets 和 Socket.IO。然后,我们在后端(服务器)和前端(客户端)上设置了 Socket.IO。之后,我们学习了如何在服务器和客户端之间发送消息,如何加入房间,以及如何广播消息。我们还使用确认来获取有关请求-响应模式中用户的信息。最后,我们在 Socket.IO 中实现了使用 JWT 的身份验证,完成了我们的聊天应用后端。</p> <p>在下一章<em>第十四章</em>,<em>创建用于消费和发送事件的客户端</em>,我们将创建我们的聊天应用的客户端,它将与我们本章创建的后端交互。</p> <h1 id="第十四章创建一个用于消费和发送事件的前端">第十四章:创建一个用于消费和发送事件的前端</h1> <p>在上一章成功创建 Socket.IO 后端,并进行了第一次 Socket.IO 客户端实验后,现在让我们专注于实现一个前端来连接后端并消费和发送事件。</p> <p>我们首先将清理我们的项目,通过从之前创建的博客应用中删除文件。然后,我们将实现一个 React Context 来初始化和存储我们的 Socket.IO 实例,利用现有的 <code>AuthProvider</code> 为与后端进行身份验证提供令牌。之后,我们将实现一个用于我们的聊天应用的接口,以及发送聊天消息和显示接收到的聊天消息的方法。最后,我们将实现带有确认的聊天命令,以显示我们当前所在的房间。</p> <p>在本章中,我们将涵盖以下主要主题:</p> <ul> <li> <p>将 Socket.IO 客户端集成到 React 中</p> </li> <li> <p>实现聊天功能</p> </li> <li> <p>实现带有确认的聊天命令</p> </li> </ul> <h1 id="技术要求-13">技术要求</h1> <p>在我们开始之前,请安装来自 <em>第一章</em> <em>为全栈开发做准备</em> 和 <em>第二章</em> <em>了解 Node.js 和 MongoDB</em> 的所有要求。</p> <p>那些章节中列出的版本是书中使用的版本。虽然安装较新版本可能不会出现问题,但请注意,某些步骤在较新版本上可能有所不同。如果您在这本书提供的代码和步骤中遇到问题,请尝试使用 <em>第一章</em> 和 <em>第二章</em> 中列出的版本。</p> <p>您可以在 GitHub 上找到本章的代码:<a href="https://github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch14" target="_blank"><code>github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch14</code></a>。</p> <p>如果您克隆了本书的完整仓库,Husky 在运行 <code>npm install</code> 时可能找不到 <code>.git</code> 目录。在这种情况下,只需在相应章节文件夹的根目录中运行 <code>git init</code>。</p> <p>本章的 CiA 视频可以在:<a href="https://youtu.be/d_TZK6S_XDU" target="_blank"><code>youtu.be/d_TZK6S_XDU</code></a> 找到。</p> <h1 id="将-socketio-客户端集成到-react-中">将 Socket.IO 客户端集成到 React 中</h1> <p>让我们先清理项目,删除从博客应用中复制过来的所有旧文件。然后,我们将设置一个 Socket.IO 上下文,以便在 React 组件中更容易地初始化和使用 Socket.IO。最后,我们将创建第一个利用此上下文来显示我们的 Socket.IO 连接状态的组件。</p> <h2 id="清理项目">清理项目</h2> <p>让我们先删除我们之前创建的博客应用中的文件夹和文件:</p> <ol> <li> <p>将现有的 <strong>ch13</strong> 文件夹复制到新的 <strong>ch14</strong> 文件夹中,如下所示:</p> <pre><code class="language-js">$ cp -R ch13 ch14 </code></pre> </li> <li> <p>在 VS Code 中打开 <strong>ch14</strong> 文件夹。</p> </li> <li> <p><em>删除</em> 以下文件夹和文件,因为它们仅适用于博客应用的后端:</p> <ul> <li> <p><strong>backend/src/<strong>tests</strong>/</strong></p> </li> <li> <p><strong>backend/src/example.js</strong></p> </li> <li> <p><strong>backend/src/db/models/post.js</strong></p> </li> <li> <p><strong>backend/src/routes/posts.js</strong></p> </li> <li> <p><strong>backend/src/services/posts.js</strong></p> </li> </ul> </li> <li> <p>在 <strong>backend/src/app.js</strong> 中,<em>移除</em> 以下导入:</p> <pre><code class="language-js">import postRoutes from './routes/posts.js' </code></pre> </li> <li> <p>此外,<em>移除</em> <strong>postRoutes</strong>:</p> <pre><code class="language-js">postRoutes(app) </code></pre> </li> <li> <p><em>删除</em> 以下文件夹和文件,因为它们仅用于博客应用的前端:</p> <ul> <li> <p><strong>src/api/posts.js</strong></p> </li> <li> <p><strong>src/components/CreatePost.jsx</strong></p> </li> <li> <p><strong>src/components/Post.jsx</strong></p> </li> <li> <p><strong>src/components/PostFilter.jsx</strong></p> </li> <li> <p><strong>src/components/PostList.jsx</strong></p> </li> <li> <p><strong>src/components/PostSorting.jsx</strong></p> </li> <li> <p><strong>src/pages/Blog.jsx</strong></p> </li> </ul> </li> </ol> <p>现在我们已经清理了我们的项目,让我们开始实现我们新聊天应用的 Socket.IO 上下文。</p> <h2 id="创建-socketio-上下文">创建 Socket.IO 上下文</h2> <p>到目前为止,我们一直在 <code>src/App.jsx</code> 组件中初始化 Socket.IO 客户端实例。然而,这样做有一些缺点:</p> <ul> <li> <p>要在其他组件中访问套接字,我们需要通过属性传递它。</p> </li> <li> <p>我们在整个应用中只能有一个套接字连接。</p> </li> <li> <p>从 <strong>AuthContext</strong> 中动态获取令牌是不可能的,这迫使我们将其存储在本地存储中。</p> </li> <li> <p>我们的应用需要完全刷新才能加载新的令牌并与之连接。</p> </li> <li> <p>我们仍然尝试连接,并在未登录时获取错误。</p> </li> </ul> <p>为了解决这些问题,我们可以创建一个 Socket.IO 上下文。然后我们可以使用提供者组件执行以下操作:</p> <ul> <li> <p>只有在 <strong>AuthContext</strong> 中有可用令牌时才连接到 Socket.IO。</p> </li> <li> <p>存储 Socket.IO 连接的状态,并在组件中使用它,例如,仅在登录时显示聊天界面。</p> </li> <li> <p>存储错误对象并在用户界面中显示错误。</p> </li> </ul> <p>以下图表显示了我们的连接状态将如何被跟踪:</p> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_14_1.jpg" alt="图 14.1 – 连接的不同状态" loading="lazy"></p> <p>图 14.1 – 连接的不同状态</p> <p>如所见,套接字连接最初正在等待用户登录。一旦可用令牌,我们尝试建立套接字连接。如果成功,状态变为<code>connected</code>,否则变为<code>error</code>。如果套接字断开连接(例如,当互联网连接丢失时),状态设置为<code>disconnected</code>。</p> <p>现在,让我们开始创建 Socket.IO 上下文:</p> <ol> <li> <p>创建一个新的 <strong>src/contexts/SocketIOContext.jsx</strong> 文件。</p> </li> <li> <p>在此文件中,从 <strong>react</strong>、<strong>socket.io-client</strong> 和 <strong>prop-types</strong> 中导入以下函数:</p> <pre><code class="language-js">import { createContext, useState, useContext, useEffect } from 'react' import { io } from 'socket.io-client' import PropTypes from 'prop-types' </code></pre> </li> <li> <p>此外,从 <strong>AuthContext</strong> 中导入 <strong>useAuth</strong> 钩子以获取当前令牌:</p> <pre><code class="language-js">import { useAuth } from './AuthContext.jsx' </code></pre> </li> <li> <p>现在,定义一个带有一些初始值(<strong>socket</strong>、<strong>status</strong> 和 <strong>error</strong>)的 React 上下文:</p> <pre><code class="language-js">export const SocketIOContext = createContext({   socket: null,   status: 'waiting',   error: null, }) </code></pre> </li> <li> <p>接下来,定义一个提供者组件,在其中我们首先为上下文的不同值创建状态钩子:</p> <pre><code class="language-js">export const SocketIOContextProvider = ({ children }) => {   const [socket, setSocket] = useState(null)   const [status, setStatus] = useState('waiting')   const [error, setError] = useState(null) </code></pre> </li> <li> <p>然后,使用 <strong>useAuth</strong> 钩子获取 JWT(如果可用):</p> <pre><code class="language-js">  const [token] = useAuth() </code></pre> </li> <li> <p>创建一个效果钩子,检查令牌是否可用,如果可用,则尝试连接到 Socket.IO 后端:</p> <pre><code class="language-js">  useEffect(() => {     if (token) {       const socket = io(import.meta.env.VITE_SOCKET_HOST, {         query: window.location.search.substring(1),         auth: { token },       }) </code></pre> <p>就像之前一样,我们传递主机、<code>query</code> 字符串和 <code>auth</code> 对象。然而,现在我们从 <code>useAuth</code> 钩子而不是本地存储中获取令牌。</p> </li> <li> <p>为<strong>connect</strong>、<strong>connect_error</strong>和<strong>disconnect</strong>事件创建处理程序,并分别设置<strong>status</strong>字符串和<strong>error</strong>对象:</p> <pre><code class="language-js">      socket.on('connect', () => {         setStatus('connected')         setError(null)       })       socket.on('connect_error', (err) => {         setStatus('error')         setError(err)       })       socket.on('disconnect', () => setStatus('disconnected')) </code></pre> </li> <li> <p>设置<strong>socket</strong>对象并列出 effect 钩子所需的所有必要依赖项:</p> <pre><code class="language-js">      setSocket(socket)     }   }, [token, setSocket, setStatus, setError]) </code></pre> </li> <li> <p>现在,我们可以返回提供者,将状态钩子中的所有值传递给它:</p> <pre><code class="language-js">  return (     <SocketIOContext.Provider value={{ socket, status, error }}>       {children}     </SocketIOContext.Provider>   ) } </code></pre> </li> <li> <p>最后,我们为上下文提供者组件设置<strong>PropTypes</strong>,并定义一个将简单地返回整个上下文的<strong>useSocket</strong>钩子:</p> <pre><code class="language-js">SocketIOContextProvider.propTypes = {   children: PropTypes.element.isRequired, } export function useSocket() {   return useContext(SocketIOContext) } </code></pre> </li> </ol> <p>现在我们有一个上下文来初始化我们的 Socket.IO 客户端,让我们将其连接并显示套接字连接的状态。</p> <h2 id="连接上下文并显示状态">连接上下文并显示状态</h2> <p>我们现在可以从<code>App</code>组件中删除连接到 Socket.IO 的代码,并使用提供者,如下所示:</p> <ol> <li> <p>编辑<strong>src/App.jsx</strong>并<em>删除</em>以下导入:</p> <pre><code class="language-js">import { io } from 'socket.io-client' </code></pre> </li> <li> <p>向<strong>SocketIOContextProvider</strong>添加导入:</p> <pre><code class="language-js">import { SocketIOContextProvider } from './contexts/SocketIOContext.jsx' </code></pre> </li> <li> <p>然后,<em>删除</em>与 Socket.IO 连接相关的以下代码:</p> <pre><code class="language-js">const socket = io(import.meta.env.VITE_SOCKET_HOST, {   query: window.location.search.substring(1),   auth: {     token: window.localStorage.getItem('token'),   }, }) socket.on('connect', async () => {   console.log('connected to socket.io as', socket.id)   socket.emit('chat.message', 'hello from client')   const userInfo = await socket.emitWithAck('user.info', socket.id)   console.log('user info', userInfo) }) socket.on('connect_error', (err) => {   console.error('socket.io connect error:', err) }) socket.on('chat.message', (message) => {   console.log(message) }) </code></pre> </li> <li> <p>在<strong>App</strong>组件内部,渲染上下文提供者:</p> <pre><code class="language-js">export function App() {   return (     <QueryClientProvider client={queryClient}>       <AuthContextProvider>         <SocketIOContextProvider>           <RouterProvider router={router} />         </SocketIOContextProvider>       </AuthContextProvider>     </QueryClientProvider>   ) } </code></pre> </li> </ol> <p>在连接 Socket.IO 上下文之后,让我们继续创建一个用于显示状态的<code>Status</code>组件。</p> <h3 id="创建一个状态组件">创建一个状态组件</h3> <p>现在,让我们创建一个<code>Status</code>组件来显示套接字当前的状态:</p> <ol> <li> <p>创建一个新的<strong>src/components/Status.jsx</strong>文件。</p> </li> <li> <p>在其中,从我们的<strong>SocketIOContext</strong>导入<strong>useSocket</strong>钩子:</p> <pre><code class="language-js">import { useSocket } from '../contexts/SocketIOContext.jsx' </code></pre> </li> <li> <p>定义一个<strong>Status</strong>组件,其中我们从钩子中获取<strong>status</strong>字符串和<strong>error</strong>对象:</p> <pre><code class="language-js">export function Status() {   const { status, error } = useSocket() </code></pre> </li> <li> <p>渲染套接字状态:</p> <pre><code class="language-js">  return (     <div>       Socket status: <b>{status}</b> </code></pre> </li> <li> <p>如果我们有一个<strong>error</strong>对象,我们现在还可以显示错误信息:</p> <pre><code class="language-js">      {error && <i> - {error.message}</i>}     </div>   ) } </code></pre> </li> </ol> <p>现在我们有一个<code>Status</code>组件,让我们创建一个<code>Chat</code>页面组件,在其中渲染<code>Header</code>和<code>Status</code>组件。</p> <h3 id="创建一个聊天页面组件">创建一个聊天页面组件</h3> <p>我们之前在我们的博客应用中有一个<code>Blog</code>页面,我们在本章早期删除了它。现在,让我们为我们的聊天应用创建一个新的<code>Chat</code>页面组件:</p> <ol> <li> <p>创建一个新的<strong>src/pages/Chat.jsx</strong>文件。</p> </li> <li> <p>在其中,导入<strong>Header</strong>组件(我们将从<strong>Blog</strong>应用中重用)和<strong>Status</strong>组件:</p> <pre><code class="language-js">import { Header } from '../components/Header.jsx' import { Status } from '../components/Status.jsx' </code></pre> </li> <li> <p>渲染一个<strong>Chat</strong>组件,在其中显示<strong>Header</strong>和<strong>Status</strong>组件:</p> <pre><code class="language-js">export function Chat() {   return (     <div style={{ padding: 8 }}>       <Header />       <br />       <hr />       <br />       <Status />     </div>   ) } </code></pre> </li> <li> <p>编辑<strong>src/App.jsx</strong>并定位到以下导入:</p> <pre><code class="language-js">import { Blog } from './pages/Blog.jsx' </code></pre> <p><em>替换</em>为对<code>Chat</code>组件的导入:</p> <pre><code class="language-js">import { Chat } from './pages/Chat.jsx' </code></pre> </li> <li> <p>最后,<em>替换</em>主路径中的<strong><Blog /></strong>组件为<strong><Chat /></strong>组件:</p> <pre><code class="language-js">const router = createBrowserRouter(   {     path: '/',     element: <Chat />,   }, </code></pre> </li> </ol> <h3 id="启动和测试我们的聊天应用前端">启动和测试我们的聊天应用前端</h3> <p>我们现在可以启动并测试我们的聊天应用前端:</p> <ol> <li> <p>按照以下方式运行前端:</p> <pre><code class="language-js">$ npm run dev </code></pre> </li> <li> <p>按照以下方式运行后端(确保 Docker 和数据库容器正在运行!):</p> <pre><code class="language-js">$ cd backend/ $ npm run dev </code></pre> </li> <li> <p>现在转到<strong><a href="http://localhost:5173/" target="_blank">http://localhost:5173/</a></strong>,你应该看到以下界面:</p> </li> </ol> <p>![图 14.2 – 套接字连接等待用户登录图 14.2 – 套接字连接等待用户登录 1. 登录(如果您还没有,请创建一个新用户),套接字应该成功连接:<img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_14_3.jpg" alt="图 14.3 – 用户登录后套接字已连接" loading="lazy"></p> <p>图 14.3 – 用户登录后套接字已连接</p> <h2 id="注销时断开套接字">注销时断开套接字</h2> <p>你可能已经注意到,当按下 <strong>注销</strong> 时,套接字仍然保持连接。现在,让我们修复这个问题,通过在注销时断开套接字。</p> <ol> <li> <p>编辑 <strong>src/components/Header.jsx</strong> 并导入 <strong>useSocket</strong> 钩子:</p> <pre><code class="language-js">import { useSocket } from '../contexts/SocketIOContext.jsx' </code></pre> </li> <li> <p>从 <strong>useSocket</strong> 钩子中获取套接字,如下所示:</p> <pre><code class="language-js">export function Header() {   const [token, setToken] = useAuth()   const { socket } = useSocket() </code></pre> </li> <li> <p>定义一个新的 <strong>handleLogout</strong> 函数,它断开套接字并重置令牌:</p> <pre><code class="language-js">  const handleLogout = () => {     socket.disconnect()     setToken(null)   } </code></pre> </li> <li> <p>最后,将 <strong>onClick</strong> 处理器设置为 <strong>handleLogout</strong> 函数:</p> <pre><code class="language-js">        <button onClick={handleLogout}>Logout</button> </code></pre> </li> </ol> <p>现在,当你注销时,套接字将会断开连接,如下面的截图所示:</p> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_14_4.jpg" alt="图 14.4 – 注销后套接字已断开" loading="lazy"></p> <p>图 14.4 – 注销后套接字已断开</p> <p>现在 Socket.IO 客户端已成功集成到我们的 React 前端,我们可以继续在前端实现聊天功能。</p> <h1 id="实现-chat-功能">实现 chat 功能</h1> <p>我们现在将实现发送和接收消息的功能。首先,我们将实现所有需要的组件。然后,我们将创建一个 <code>useChat</code> 钩子来实现与套接字连接的接口并提供发送/接收消息的函数。最后,我们将通过创建聊天室来将这些功能组合在一起。</p> <h2 id="实现-chat-组件">实现 chat 组件</h2> <p>我们将实现以下聊天组件:</p> <ul> <li> <p><strong>ChatMessage</strong>:用于显示聊天消息</p> </li> <li> <p><strong>EnterMessage</strong>:一个输入新消息的字段和一个发送它们的按钮</p> </li> </ul> <h3 id="实现-chatmessage-组件">实现 ChatMessage 组件</h3> <p>让我们先实现 <code>ChatMessage</code> 组件:</p> <ol> <li> <p>创建一个新的 <strong>src/components/ChatMessage.jsx</strong> 文件,它将渲染聊天消息。</p> </li> <li> <p>导入 <strong>PropTypes</strong> 并定义一个新的函数,带有 <strong>username</strong> 和 <strong>message</strong> 属性:</p> <pre><code class="language-js">import PropTypes from 'prop-types' export function ChatMessage({ username, message }) { </code></pre> </li> <li> <p>以粗体形式渲染用户名,并在其旁边显示消息:</p> <pre><code class="language-js">  return (     <div>       <b>{username}</b>: {message}     </div>   ) } </code></pre> </li> <li> <p>定义属性类型,如下所示:</p> <pre><code class="language-js">ChatMessage.propTypes = {   username: PropTypes.string.isRequired,   message: PropTypes.string.isRequired, } </code></pre> </li> </ol> <h3 id="实现-entermessage-组件">实现 EnterMessage 组件</h3> <p>现在,让我们创建 <code>EnterMessage</code> 组件,它将允许用户发送新的聊天消息:</p> <ol> <li> <p>创建一个新的 <strong>src/components/EnterMessage.jsx</strong> 文件。</p> </li> <li> <p>导入 <strong>useState</strong> 钩子和 <strong>PropTypes</strong>:</p> <pre><code class="language-js">import { useState } from 'react' import PropTypes from 'prop-types' </code></pre> </li> <li> <p>定义一个新的 <strong>EnterMessage</strong> 组件,它接收一个 <strong>onSend</strong> 函数作为属性:</p> <pre><code class="language-js">export function EnterMessage({ onSend }) { </code></pre> </li> <li> <p>我们存储输入的消息的当前状态:</p> <pre><code class="language-js">  const [message, setMessage] = useState('') </code></pre> </li> <li> <p>然后,我们定义一个函数来处理发送请求并在之后清除字段:</p> <pre><code class="language-js">  function handleSend(e) {     e.preventDefault()     onSend(message)     setMessage('')   } </code></pre> </li> </ol> <p>提醒</p> <p>因为我们是使用 <strong>submit</strong> 按钮提交表单,所以我们需要调用 <strong>e.preventDefault()</strong> 来防止表单刷新页面。</p> <ol> <li> <p>渲染一个表单,包含一个输入字段来输入消息和一个按钮来发送它:</p> <pre><code class="language-js">  return (     <form onSubmit={handleSend}>       <input         type='text'         value={message}         onChange={(e) => setMessage(e.target.value)}       />       <input type='submit' value='Send' />     </form>   ) } </code></pre> </li> <li> <p>定义属性类型,如下所示:</p> <pre><code class="language-js">EnterMessage.propTypes = {   onSend: PropTypes.func.isRequired, } </code></pre> </li> </ol> <h2 id="实现-usechat-钩子">实现 useChat 钩子</h2> <p>为了将所有逻辑组合在一起,我们将实现一个 <code>useChat</code> 钩子,它将处理发送和接收消息,以及将所有当前消息存储在状态钩子中。按照以下步骤实现它:</p> <ol> <li> <p>创建一个新的 <strong>src/hooks/</strong> 文件夹。在其内部,创建一个新的 <strong>src/hooks/useChat.js</strong> 文件。</p> </li> <li> <p>从 React 中导入 <strong>useState</strong> 和 <strong>useEffect</strong> 钩子:</p> <pre><code class="language-js">import { useState, useEffect } from 'react' </code></pre> </li> <li> <p>从我们的上下文中导入 <strong>useSocket</strong> 钩子:</p> <pre><code class="language-js">import { useSocket } from '../contexts/SocketIOContext.jsx' </code></pre> </li> <li> <p>定义一个新的 <strong>useChat</strong> 函数,其中我们从 <strong>useSocket</strong> 钩子获取套接字,并定义一个状态钩子来存储消息数组:</p> <pre><code class="language-js">export function useChat() {   const { socket } = useSocket()   const [messages, setMessages] = useState([]) </code></pre> </li> <li> <p>接下来,定义一个 <strong>receiveMessage</strong> 函数,该函数将新消息追加到数组中:</p> <pre><code class="language-js">  function receiveMessage(message) {     setMessages((messages) => [...messages, message])   } </code></pre> </li> <li> <p>现在,创建一个效果钩子,在其中我们使用 <strong>socket.on</strong> 创建一个监听器:</p> <pre><code class="language-js">  useEffect(() => {     socket.on('chat.message', receiveMessage) </code></pre> </li> <li> <p>我们需要确保在效果钩子卸载时再次使用 <strong>socket.off</strong> 移除监听器,否则在组件重新渲染或卸载时我们可能会得到多个监听器:</p> <pre><code class="language-js">    return () => socket.off('chat.message', receiveMessage)   }, []) </code></pre> </li> <li> <p>现在,接收消息应该可以正常工作。让我们继续发送消息。为此,我们创建一个 <strong>sendMessage</strong> 函数,该函数使用 <strong>socket.emit</strong> 来发送消息:</p> <pre><code class="language-js">  function sendMessage(message) {     socket.emit('chat.message', message)   } </code></pre> </li> <li> <p>最后,返回 <strong>messages</strong> 数组和 <strong>sendMessage</strong> 函数,以便我们可以在我们的组件中使用它们:</p> <pre><code class="language-js">  return { messages, sendMessage } } </code></pre> </li> </ol> <p>现在我们已经成功实现了 <code>useChat</code> 钩子,让我们使用它!</p> <h2 id="实现-chatroom-组件">实现 ChatRoom 组件</h2> <p>最后,我们可以把它们全部放在一起,并实现一个 <code>ChatRoom</code> 组件。按照以下步骤开始:</p> <ol> <li> <p>创建一个新的 <strong>src/components/ChatRoom.jsx</strong> 文件。</p> </li> <li> <p>导入 <strong>useChat</strong> 钩子和 <strong>EnterMessage</strong> 以及 <strong>ChatMessage</strong> 组件:</p> <pre><code class="language-js">import { useChat } from '../hooks/useChat.js' import { EnterMessage } from './EnterMessage.jsx' import { ChatMessage } from './ChatMessage.jsx' </code></pre> </li> <li> <p>定义一个新的组件,该组件从 <strong>useChat</strong> 钩子获取 <strong>messages</strong> 数组和 <strong>sendMessage</strong> 函数:</p> <pre><code class="language-js">export function ChatRoom() {   const { messages, sendMessage } = useChat() </code></pre> </li> <li> <p>然后,将消息列表渲染为 <strong>ChatMessage</strong> 组件:</p> <pre><code class="language-js">  return (     <div>       {messages.map((message, index) => (         <ChatMessage key={index} {...message} />       ))} </code></pre> </li> <li> <p>接下来,渲染 <strong>EnterMessage</strong> 组件,并将 <strong>sendMessage</strong> 函数作为 <strong>onSend</strong> 属性传递:</p> <pre><code class="language-js">      <EnterMessage onSend={sendMessage} />     </div>   ) } </code></pre> </li> <li> <p>编辑 <strong>src/pages/Chat.jsx</strong> 并导入 <strong>ChatRoom</strong> 组件和 <strong>useSocket</strong> 钩子:</p> <pre><code class="language-js">import { ChatRoom } from '../components/ChatRoom.jsx' import { useSocket } from '../contexts/SocketIOContext.jsx' </code></pre> </li> <li> <p>从 <strong>Chat</strong> 页面组件中的 <strong>useSocket</strong> 钩子获取状态:</p> <pre><code class="language-js">export function Chat() {   const { status } = useSocket() </code></pre> </li> <li> <p>如果状态是 <strong>已连接</strong>,我们显示 <strong>ChatRoom</strong> 组件:</p> <pre><code class="language-js">  return (     <div style={{ padding: 8 }}>       <Header />       <br />       <hr />       <br />       <Status />       <br />       <hr />       <br />       {status === 'connected' && <ChatRoom />} </code></pre> </li> <li> <p>现在,在您的浏览器中转到 <strong><a href="http://localhost:5173/" target="_blank">http://localhost:5173/</a></strong> 并使用用户名和密码登录。套接字连接并渲染聊天室。输入一条聊天消息,并通过按 <em>Return/Enter</em> 或点击 <strong>发送</strong> 按钮发送它。您将看到消息被接收并显示出来!</p> </li> <li> <p>打开第二个浏览器窗口并使用第二个用户登录。在那里发送另一条消息。您将看到消息被两个用户接收,如下面的截图所示:</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_14_5.jpg" alt="图 14.5 – 从不同用户发送和接收消息" loading="lazy"></p> <p>图 14.5 – 从不同用户发送和接收消息</p> <p>现在我们有一个基本的聊天应用正在运行,让我们探索如何使用确认来实现聊天命令。</p> <h1 id="使用确认实现聊天命令">使用确认实现聊天命令</h1> <p>除了发送和接收消息外,聊天应用通常还提供了一种向客户端和/或服务器发送命令的方式。例如,我们可以发送一个 <code>/clear</code> 命令来清除我们的本地消息列表。或者,我们可以发送一个 <code>/rooms</code> 命令来获取我们所在的房间列表。按照以下步骤实现聊天命令:</p> <ol> <li> <p>编辑<strong>src/hooks/useChat.js</strong>并调整其中的<strong>sendMessage</strong>函数。首先,让我们将其改为<strong>async</strong>函数:</p> <pre><code class="language-js">  async function sendMessage(message) { </code></pre> </li> <li> <p><em>替换</em>函数的内容如下。我们首先检查消息是否以斜杠(<strong>/</strong>)开头。如果是,那么我们通过删除斜杠来获取命令,并使用<strong>switch</strong>语句:</p> <pre><code class="language-js">    if (message.startsWith('/')) {       const command = message.substring(1)       switch (command) { </code></pre> </li> <li> <p>对于<strong>clear</strong>命令,我们只需将消息数组设置为空数组:</p> <pre><code class="language-js">        case 'clear':           setMessages([])           break </code></pre> </li> <li> <p>对于<strong>rooms</strong>命令,我们通过使用<strong>socket.emitWithAck</strong>和我们的<strong>socket.id</strong>来获取用户信息:</p> <pre><code class="language-js">        case 'rooms': {           const userInfo = await socket.emitWithAck('user.info', socket.id) </code></pre> </li> <li> <p>然后,我们获取房间列表,过滤掉我们自动加入的带有我们<strong>socket.id</strong>名称的房间:</p> <pre><code class="language-js">          const rooms = userInfo.rooms.filter((room) => room !== socket.id) </code></pre> </li> <li> <p>我们重用<strong>receiveMessage</strong>函数从服务器发送消息,告诉我们我们所在的房间:</p> <pre><code class="language-js">          receiveMessage({             message: `You are in: ${rooms.join(', ')}`,           })           break         } </code></pre> <p>注意,这里我们没有发送用户名,只是发送消息。我们稍后必须调整<code>ChatMessage</code>组件以适应这一点。</p> </li> <li> <p>如果我们收到任何其他命令,我们将显示一个错误消息:</p> <pre><code class="language-js">        default:           receiveMessage({             message: `Unknown command: ${command}`,           })           break       } </code></pre> </li> <li> <p>否则(如果消息没有以斜杠开头),我们就像之前一样简单地发出聊天消息:</p> <pre><code class="language-js">    } else {       socket.emit('chat.message', message)     }   } </code></pre> </li> <li> <p>最后,编辑<strong>src/components/ChatMessage.jsx</strong>并调整组件以在未提供用户名时渲染系统消息:</p> <pre><code class="language-js">export function ChatMessage({ username, message }) {   return (     <div>       {username ? (         <span>           <b>{username}</b>: {message}         </span>       ) : (         <i>{message}</i>       )}     </div>   ) } </code></pre> </li> <li> <p>不要忘记调整<strong>PropTypes</strong>以使用户名可选(通过从<strong>username</strong>属性中<em>移除</em><strong>.isRequired</strong>):</p> <pre><code class="language-js">ChatMessage.propTypes = {   username: PropTypes.string, </code></pre> </li> <li> <p>在您的浏览器中转到<strong><a href="http://localhost:5173/" target="_blank">http://localhost:5173/</a></strong>并尝试发送几条消息。然后,键入<strong>/clear</strong>,您将看到所有消息都被清除了。接下来,键入<strong>/rooms</strong>以获取您所在的房间列表,如下面的截图所示:</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_14_6.jpg" alt="图 14.6 – 发送/rooms 命令" loading="lazy"></p> <p>图 14.6 – 发送/rooms 命令</p> <p>注意</p> <p>由于登录后查询参数被清除,目前无法加入不同的房间。在下一章中,我们将重构聊天应用并实现<strong>/join</strong>命令以加入不同的房间。</p> <h1 id="摘要-12">摘要</h1> <p>在本章中,我们为我们的聊天应用后端实现了一个前端。我们首先通过创建一个上下文和自定义钩子来集成 Socket.IO 客户端和 React。然后,我们使用<code>AuthProvider</code>获取令牌以在连接到 socket 时验证用户。之后,我们显示了我们的 socket 状态。然后,我们实现了聊天应用界面以发送和接收消息。最后,我们通过使用确认来获取我们所在的房间实现了聊天命令。</p> <p>在下一章,<em>第十五章</em>,<em>使用 MongoDB 为 Socket.IO 添加持久性</em>,我们将学习如何使用 MongoDB 和 Socket.IO 存储和回放之前发送的消息。</p> <h1 id="第十五章在-socketio-中使用-mongodb-添加持久性">第十五章:在 Socket.IO 中使用 MongoDB 添加持久性</h1> <p>现在我们已经实现了 Socket.IO 后端和前端,让我们花些时间将其与 MongoDB 数据库集成,通过在数据库中临时存储消息并在新用户加入时回放它们,这样用户在加入后可以看到聊天历史。此外,我们将重构我们的聊天应用程序,使其为未来的扩展和维护做好准备。最后,我们将通过实现新的加入和切换房间的命令来测试新的结构。</p> <p>在本章中,我们将涵盖以下主要主题:</p> <ul> <li> <p>使用 MongoDB 存储和回放消息</p> </li> <li> <p>重构应用程序以使其更具可扩展性</p> </li> <li> <p>实现加入和切换房间的命令</p> </li> </ul> <h1 id="技术要求-14">技术要求</h1> <p>在我们开始之前,请从<em>第一章**,准备全栈开发</em>和<em>第二章**,了解 Node.js</em>和 MongoDB*中安装所有要求。</p> <p>那些章节中列出的版本是书中使用的版本。虽然安装较新版本可能不会有问题,但请注意,某些步骤在较新版本上可能有所不同。如果您在这本书提供的代码和步骤中遇到问题,请尝试使用<em>第一章</em>和<em>第二章</em>中提到的版本。</p> <p>您可以在 GitHub 上找到本章的代码:<a href="https://github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch15" target="_blank"><code>github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch15</code></a>。</p> <p>如果您克隆了本书的完整仓库,Husky 在运行 <code>npm install</code> 时可能找不到 <code>.git</code> 目录。在这种情况下,只需在相应章节文件夹的根目录中运行 <code>git init</code>。</p> <p>本章的 CiA 视频可以在以下网址找到:<a href="https://youtu.be/Mi7Wj_jxjhM" target="_blank"><code>youtu.be/Mi7Wj_jxjhM</code></a>。</p> <h1 id="使用-mongodb-存储和回放消息">使用 MongoDB 存储和回放消息</h1> <p>目前,如果新用户加入聊天,他们将看不到任何消息,直到有人主动发送消息。因此,新用户将无法很好地参与正在进行中的讨论。为了解决这个问题,我们可以将消息存储在数据库中,并在用户加入时回放它们。</p> <h2 id="创建-mongoose-模式">创建 Mongoose 模式</h2> <p>按照以下步骤创建用于存储聊天消息的 Mongoose 模式:</p> <ol> <li> <p>将现有的 <strong>ch14</strong> 文件夹复制到新的 <strong>ch15</strong> 文件夹,如下所示:</p> <pre><code class="language-js">$ cp -R ch14 ch15 </code></pre> </li> <li> <p>在 VS Code 中打开新的 <strong>ch15</strong> 文件夹。</p> </li> <li> <p>创建一个新的 <strong>backend/src/db/models/message.js</strong> 文件。</p> </li> <li> <p>在其中,定义一个新的 <strong>messageSchema</strong>,我们将使用它来在数据库中存储聊天消息:</p> <pre><code class="language-js">import mongoose, { Schema } from 'mongoose' const messageSchema = new Schema({ </code></pre> </li> <li> <p>消息模式应包含 <strong>username</strong>(发送消息的人)、<strong>message</strong>、一个 <strong>room</strong>(消息发送的房间)和 <strong>sent</strong> 日期(消息发送的时间):</p> <pre><code class="language-js">  username: { type: String, required: true },   message: { type: String, required: true },   room: { type: String, required: true },   sent: { type: Date, expires: 5 * 60, default: Date.now, required: true }, }) </code></pre> <p>对于<code>发送</code>日期,我们指定<code>expires</code>以使消息在 5 分钟后自动过期(<code>5 * 60</code>秒)。这确保我们的数据库不会因为大量的聊天消息而变得杂乱。我们还设置了<code>default</code>值为<code>Date.now</code>,以便所有消息默认标记为在当前时间发送。</p> </li> </ol> <p>信息</p> <p>MongoDB 实际上只在每分钟检查一次数据过期,因此过期的文档可能会在其定义的过期时间后持续一分钟。</p> <ol> <li> <p>从模式创建一个模型并导出它:</p> <pre><code class="language-js">export const Message = mongoose.model('message', messageSchema) </code></pre> </li> </ol> <p>在创建 Mongoose 模式和模型后,让我们继续创建处理聊天消息的服务函数。</p> <h2 id="创建服务函数">创建服务函数</h2> <p>我们需要创建服务函数来在数据库中保存一条新消息,并获取在给定房间中发送的所有消息,按<code>发送</code>日期排序,首先显示最旧的消息。按照以下步骤实现服务函数:</p> <ol> <li> <p>创建一个新的<strong>backend/src/services/messages.js</strong>文件。</p> </li> <li> <p>在其中,导入<strong>Message</strong>模型:</p> <pre><code class="language-js">import { Message } from '../db/models/message.js' </code></pre> </li> <li> <p>然后,定义一个函数在数据库中创建一个新的<strong>Message</strong>对象:</p> <pre><code class="language-js">export async function createMessage({ username, message, room }) {   const messageDoc = new Message({ username, message, room })   return await messageDoc.save() } </code></pre> </li> <li> <p>此外,定义一个函数以获取某个房间的所有消息,按最旧的消息列表显示:</p> <pre><code class="language-js">export async function getMessagesByRoom(room) {   return await Message.find({ room }).sort({ sent: 1 }) } </code></pre> </li> </ol> <p>接下来,我们将在我们的聊天服务器中使用这些服务函数。</p> <h2 id="存储和回放消息">存储和回放消息</h2> <p>现在我们有了所有函数,我们需要在我们的聊天服务器中实现存储和回放消息。按照以下步骤实现功能:</p> <ol> <li> <p>编辑<strong>backend/src/socket.js</strong>并导入我们之前定义的服务函数:</p> <pre><code class="language-js">import { createMessage, getMessagesByRoom } from './services/messages.js' </code></pre> </li> <li> <p>当新用户连接时,获取当前房间的所有消息,并使用<strong>socket.emit</strong>将它们发送(回放)给用户:</p> <pre><code class="language-js">export function handleSocket(io) {   io.on('connection', async (socket) => {     const room = socket.handshake.query?.room ?? 'public'     socket.join(room)     console.log(socket.id, 'joined room:', room)     const messages = await getMessagesByRoom(room)     messages.forEach(({ username, message }) =>       socket.emit('chat.message', { username, message }),     ) </code></pre> </li> <li> <p>此外,当用户发送消息时,将其存储在数据库中:</p> <pre><code class="language-js">    socket.on('chat.message', (message) => {       console.log(`${socket.id}: ${message}`)       io.to(room).emit('chat.message', {         username: socket.user.username,         message,       })       createMessage({ username: socket.user.username, message, room })     }) </code></pre> </li> <li> <p>按照以下方式启动前端服务器:</p> <pre><code class="language-js">$ npm run dev </code></pre> </li> <li> <p>然后,启动后端服务器(不要忘记启动数据库的 Docker 容器!):</p> <pre><code class="language-js">$ cd backend/ $ npm run dev </code></pre> </li> <li> <p>前往<strong><a href="http://localhost:5173/" target="_blank">http://localhost:5173/</a></strong>,登录并发送一些消息。然后,打开一个新标签页,用不同的用户登录,您将看到之前发送的消息被回放:</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_15_1.jpg" alt="图 15.1 – 成功回放存储的消息" loading="lazy"></p> <p>图 15.1 – 成功回放存储的消息</p> <p>注意</p> <p><em>图 15.1</em>中的截图是应用程序的较晚版本,其中我们在用户加入房间时显示消息(我们将在本章后面实现这些消息)。在这里,我们使用这些消息来显示当用户在发送消息后加入时,回放是有效的。</p> <p>如果您等待 5 分钟然后再次加入聊天,您将看到现有的消息已过期并且不再被回放。</p> <p>现在,让我们让用户界面更清晰地显示哪些消息被回放了。</p> <h2 id="可视区分回放消息">可视区分回放消息</h2> <p>目前看来,其他用户似乎在我们加入后立即发送了消息。这并不明显表明消息是从服务器回放的。为了解决这个问题,我们可以通过例如使它们稍微灰一些来在视觉上区分回放的消息。现在让我们这样做,如下所示:</p> <ol> <li> <p>编辑 <strong>backend/src/socket.js</strong> 并为回放消息添加一个 <strong>replayed</strong> 标志:</p> <pre><code class="language-js">    const messages = await getMessagesByRoom(room)     messages.forEach(({ username, message }) =>       socket.emit('chat.message', { username, message, replayed: true }),     ) </code></pre> </li> <li> <p>现在,编辑 <strong>src/components/ChatMessage.jsx</strong>,如果设置了 <strong>replayed</strong> 标志,则以较低的透明度显示消息:</p> <pre><code class="language-js">export function ChatMessage({ username, message, replayed }) {   return (     <div style={{ opacity: replayed ? 0.5 : 1.0 }}> </code></pre> </li> <li> <p>不要忘记更新 <strong>propTypes</strong> 并添加 <strong>replayed</strong> 标志:</p> <pre><code class="language-js">ChatMessage.propTypes = {   username: PropTypes.string,   message: PropTypes.string.isRequired,   replayed: PropTypes.bool, } </code></pre> </li> <li> <p>再次访问 <strong><a href="http://localhost:5173/" target="_blank">http://localhost:5173/</a></strong> 并重复相同的程序(从一个用户发送消息,然后在另一个标签页中用不同的用户登录),你将看到回放的消息现在很容易与新的消息区分开来:</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_15_2.jpg" alt="图 15.2 – 回放的消息现在以较浅的颜色显示" loading="lazy"></p> <p>图 15.2 – 回放的消息现在以较浅的颜色显示</p> <p>现在我们已经成功将消息历史存储在数据库中,让我们稍微关注一下重构聊天应用程序,使其在未来更具可扩展性和可维护性。</p> <h1 id="将应用程序重构为更易于扩展">将应用程序重构为更易于扩展</h1> <p>对于重构,我们将首先定义所有由我们的服务器提供的聊天功能的服务函数。</p> <h2 id="定义服务函数">定义服务函数</h2> <p>按照以下步骤开始定义聊天功能的服务函数:</p> <ol> <li> <p>创建一个新的 <strong>backend/src/services/chat.js</strong> 文件。</p> </li> <li> <p>在其中,导入与消息相关的服务函数:</p> <pre><code class="language-js">import { createMessage, getMessagesByRoom } from './messages.js' </code></pre> </li> <li> <p>定义一个新函数,直接向用户发送私密消息:</p> <pre><code class="language-js">export function sendPrivateMessage(   socket,   { username, room, message, replayed }, ) {   socket.emit('chat.message', { username, message, room, replayed }) } </code></pre> <p>私信将被用于,例如,将消息回放给特定用户,并且不会存储在数据库中。</p> </li> <li> <p>此外,定义一个函数来发送系统消息:</p> <pre><code class="language-js">export function sendSystemMessage(io, { room, message }) {   io.to(room).emit('chat.message', { message, room }) } </code></pre> <p>系统消息将被用于,例如,宣布用户加入了房间。我们也不希望将这些存储在数据库中。</p> </li> <li> <p>然后,定义一个函数来发送公共消息:</p> <pre><code class="language-js">export function sendPublicMessage(io, { username, room, message }) {   io.to(room).emit('chat.message', { username, message, room })   createMessage({ username, message, room }) } </code></pre> <p>公共消息将被用于向房间发送常规聊天消息。这些消息存储在数据库中,以便我们稍后回放。</p> </li> <li> <p>我们还定义了一个新函数来将给定的 <strong>socket</strong> 加入到 <strong>room</strong> 中:</p> <pre><code class="language-js">export async function joinRoom(io, socket, { room }) {   socket.join(room) </code></pre> </li> <li> <p>在此函数内部,发送一个系统消息,告诉房间内所有人有人加入了:</p> <pre><code class="language-js">  sendSystemMessage(io, {     room,     message: `User "${socket.user.username}" joined room "${room}"`,   }) </code></pre> </li> <li> <p>然后,将房间中发送的私密消息回放给刚刚加入的用户:</p> <pre><code class="language-js">  const messages = await getMessagesByRoom(room)   messages.forEach(({ username, message }) =>     sendPrivateMessage(socket, { username, message, room, replayed: true })   ) } </code></pre> </li> <li> <p>最后,定义一个服务函数来从 <strong>socketId</strong> 获取用户信息。我们只需将之前在 <strong>backend/src/socket.js</strong> 中已有的代码复制粘贴到这里:</p> <pre><code class="language-js">export async function getUserInfoBySocketId(io, socketId) {   const sockets = await io.in(socketId).fetchSockets()   if (sockets.length === 0) return null   const socket = sockets[0]   const userInfo = {     socketId,     rooms: Array.from(socket.rooms),     user: socket.user,   }   return userInfo } </code></pre> </li> </ol> <p>现在我们已经为聊天功能创建了服务函数,让我们在 Socket.IO 服务器中使用它们。</p> <h2 id="将-socketio-服务器重构为使用服务函数">将 Socket.IO 服务器重构为使用服务函数</h2> <p>现在我们已经定义了服务函数,让我们重构聊天服务器代码以使用它们。按照以下步骤进行操作:</p> <ol> <li> <p>打开 <strong>backend/src/socket.js</strong> 并找到以下导入:</p> <pre><code class="language-js">import { createMessage, getMessagesByRoom } from './services/messages.js' </code></pre> <p>用以下导入替换前面的导入以使用新的聊天服务函数:</p> <pre><code class="language-js">import {   joinRoom,   sendPublicMessage,   getUserInfoBySocketId, } from './services/chat.js' </code></pre> </li> <li> <p><em>替换</em>整个<strong>handleSocket</strong>函数为以下新代码。当建立连接时,我们自动使用<strong>joinRoom</strong>服务函数加入公共房间:</p> <pre><code class="language-js">export function handleSocket(io) {   io.on('connection', (socket) => {     joinRoom(io, socket, { room: 'public' }) </code></pre> </li> <li> <p>然后,定义一个监听<strong>chat.message</strong>事件的监听器,并使用<strong>sendPublicMessage</strong>服务函数将事件发送到指定的房间:</p> <pre><code class="language-js">    socket.on('chat.message', (room, message) =>       sendPublicMessage(io, { username: socket.user.username, room, message }),     ) </code></pre> </li> </ol> <p>注意</p> <p>我们将<strong>chat.message</strong>事件的签名更改为现在需要传递一个房间,这样我们就可以在以后实现一种更好的处理多个房间的方法。稍后,我们需要确保调整客户端代码以适应这一点。</p> <ol> <li> <p>接下来,定义一个监听<strong>user.info</strong>事件的监听器,在其中我们使用<strong>async</strong>服务函数<strong>getUserInfoBySocketId</strong>并在<strong>callback</strong>中返回其结果,将此事件转换为确认:</p> <pre><code class="language-js">    socket.on('user.info', async (socketId, callback) =>       callback(await getUserInfoBySocketId(io, socketId)),     )   }) </code></pre> </li> <li> <p>最后,我们可以重新使用之前的身份验证中间件:</p> <pre><code class="language-js">  io.use((socket, next) => {     if (!socket.handshake.auth?.token) {       return next(new Error('Authentication failed: no token provided'))     }     jwt.verify(       socket.handshake.auth.token,       process.env.JWT_SECRET,       async (err, decodedToken) => {         if (err) {           return next(new Error('Authentication failed: invalid token'))         }         socket.auth = decodedToken         socket.user = await getUserInfoById(socket.auth.sub)         return next()       },     )   }) } </code></pre> </li> </ol> <p>现在我们已经重构了聊天服务器,让我们继续重构客户端代码。</p> <h2 id="重构客户端代码">重构客户端代码</h2> <p>现在由于我们的服务器端代码使用服务函数来封装聊天应用的功能,让我们通过将客户端命令提取到单独的函数中来对客户端代码进行类似的重构,如下所示:</p> <ol> <li> <p>编辑<strong>src/hooks/useChat.js</strong>并在<strong>useChat</strong>钩子中定义一个新的函数来清除消息:</p> <pre><code class="language-js">  function clearMessages() {     setMessages([])   } </code></pre> </li> <li> <p>然后,定义一个<strong>async</strong>函数来获取用户所在的全部房间:</p> <pre><code class="language-js">  async function getRooms() {     const userInfo = await socket.emitWithAck('user.info', socket.id)     const rooms = userInfo.rooms.filter((room) => room !== socket.id)     return rooms   } </code></pre> </li> <li> <p>我们现在可以在<strong>sendMessage</strong>函数中使用这些函数,如下所示:</p> <pre><code class="language-js">  async function sendMessage(message) {     if (message.startsWith('/')) {       const command = message.substring(1)       switch (command) {         case 'clear':           clearMessages()           break         case 'rooms': {           const rooms = await getRooms()           receiveMessage({             message: `You are in: ${rooms.join(', ')}`,           })           break         } </code></pre> </li> <li> <p>最后,我们调整<strong>chat.message</strong>事件以发送<strong>room</strong>和<strong>message</strong>。目前,我们总是向<strong>'****public'</strong>房间发送消息:</p> <pre><code class="language-js">        default:           receiveMessage({             message: `Unknown command: ${command}`,           })           break       }     } else {       socket.emit('chat.message', 'public', message)     }   } </code></pre> <p>在下一节中,我们将扩展它以能够在不同的房间之间切换。</p> </li> <li> <p>访问<strong><a href="http://localhost:5173/" target="_blank">http://localhost:5173/</a></strong>并验证聊天应用是否仍然像以前一样工作。</p> </li> </ol> <p>现在我们已经成功重构了聊天应用以使其更具可扩展性,让我们通过实现新的加入和切换房间的命令来测试新结构的灵活性。</p> <h1 id="实现加入和切换房间的命令">实现加入和切换房间的命令</h1> <p>让我们现在通过在聊天应用中实现加入和切换房间的命令来测试新结构,如下所示:</p> <ol> <li> <p>编辑<strong>backend/src/socket.js</strong>并在<strong>chat.message</strong>监听器下方定义一个新的监听器,当从客户端接收到<strong>chat.join</strong>事件时,它将调用<strong>joinRoom</strong>服务函数:</p> <pre><code class="language-js">    socket.on('chat.join', (room) => joinRoom(io, socket, { room })) </code></pre> <p>如我们所见,有一个<strong>joinRoom</strong>服务函数使得在这里重新使用代码加入新房间变得非常简单。它已经发送了一条系统消息告诉每个人有人加入了房间,就像用户在连接时默认加入<code>public</code>房间时一样。</p> </li> <li> <p>编辑<strong>src/components/ChatMessage.jsx</strong>并显示<strong>room</strong>:</p> <pre><code class="language-js">export function ChatMessage({ room, username, message, replayed }) {   return (     <div style={{ opacity: replayed ? 0.5 : 1.0 }}>       {username ? (         <span>           <code>[{room}]</code> <b>{username}</b>: {message}         </span> </code></pre> </li> <li> <p>将<strong>room</strong>属性添加到<strong>propTypes</strong>定义中:</p> <pre><code class="language-js">ChatMessage.propTypes = {   username: PropTypes.string,   message: PropTypes.string.isRequired,   replayed: PropTypes.bool,   room: PropTypes.string, } </code></pre> </li> <li> <p>现在,编辑<strong>src/hooks/useChat.js</strong>并定义一个状态钩子来存储我们当前所在的房间:</p> <pre><code class="language-js">export function useChat() {   const { socket } = useSocket()   const [messages, setMessages] = useState([]) public room. </code></pre> </li> <li> <p>定义一个新函数来切换房间:</p> <pre><code class="language-js">  function switchRoom(room) {     setCurrentRoom(room)   } </code></pre> <p>目前,我们在这里只调用了<code>setCurrentRoom</code>,但我们可能希望在以后扩展这个功能,所以提前将其抽象成一个单独的函数是一个好的实践。</p> </li> <li> <p>定义一个新的函数,通过发送<strong>chat.join</strong>事件和切换当前房间来加入一个房间:</p> <pre><code class="language-js">  function joinRoom(room) {     socket.emit('chat.join', room)     switchRoom(room)   } </code></pre> </li> <li> <p>将<strong>sendMessage</strong>函数修改为接受命令参数,如下所示:</p> <pre><code class="language-js">  async function sendMessage(message) {     if (message.startsWith('/')) {       const [command, ...args] = message.substring(1).split(' ')       switch (command) { </code></pre> <p>我们现在可以发送如<code>/join <room-name></code>之类的命令,房间名称将存储在<code>args[0]</code>中。</p> </li> <li> <p>定义一个新的命令来加入一个房间,其中我们首先检查是否向命令传递了参数:</p> <pre><code class="language-js">        case 'join': {           if (args.length === 0) {             return receiveMessage({               message: 'Please provide a room name: /join <room>',             })           } </code></pre> </li> <li> <p>然后,我们使用<strong>getRooms</strong>函数确保我们没有已经加入房间:</p> <pre><code class="language-js">          const room = args[0]           const rooms = await getRooms()           if (rooms.includes(room)) {             return receiveMessage({               message: `You are already in room "${room}".`,             })           } </code></pre> </li> <li> <p>最后,我们可以通过使用<strong>joinRoom</strong>函数加入房间:</p> <pre><code class="language-js">          joinRoom(room)           break         } </code></pre> </li> <li> <p>类似地,我们可以实现<strong>/switch</strong>命令,如下所示:</p> <pre><code class="language-js">        case 'switch': {           if (args.length === 0) {             return receiveMessage({               message: 'Please provide a room name: /switch <room>',             })           }           const room = args[0]           const rooms = await getRooms()           if (!rooms.includes(room)) {             return receiveMessage({               message: `You are not in room "${room}". Type "/join ${room}" to join it first.`,             })           }           switchRoom(room)           receiveMessage({             message: `Switched to room "${room}".`,           })           break         } </code></pre> <p>在这种情况下,我们正在检查用户是否已经在房间中。如果没有,我们告诉他们他们必须先加入房间,然后再切换到它。</p> </li> <li> <p>调整<strong>chat.message</strong>事件,使其发送到<strong>currentRoom</strong>,如下所示:</p> <pre><code class="language-js">    } else {       socket.emit('chat.message', currentRoom, message)     } </code></pre> </li> <li> <p>访问<strong><a href="http://localhost:5173/" target="_blank">http://localhost:5173/</a></strong>,向公共房间发送一条消息,然后通过执行<strong>/join react</strong>命令加入<strong>react</strong>房间。向该房间发送不同的消息。</p> </li> <li> <p>打开另一个浏览器窗口,用不同的用户登录,你会看到来自<strong>公共</strong>房间的第一条消息被回放了。然而,我们看不到来自<strong>react</strong>房间的消息,因为我们还没有加入它!</p> </li> <li> <p>现在,在第二个浏览器窗口中,也调用<strong>/join react</strong>。你会看到现在第二个消息被回放了。</p> </li> <li> <p>尝试使用<strong>/switch public</strong>来切换回<strong>公共</strong>房间并发送另一条消息。你会看到两个客户端都收到了这条消息,因为他们都在<strong>公共</strong>房间中。</p> </li> </ol> <p>这些操作的结果可以在以下屏幕截图中看到:</p> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_15_3.jpg" alt="图 15.3 – 在不同房间聊天" loading="lazy"></p> <p>图 15.3 – 在不同房间聊天</p> <h1 id="概述">概述</h1> <p>在本章中,我们首先通过将消息存储在 MongoDB 中来将我们的聊天应用连接到数据库。我们还学习了如何使文档在一段时间后过期。然后,我们实现了当新用户加入聊天时回放消息的功能。接下来,我们花了一些时间重构聊天应用,使其在未来更具可扩展性和可维护性。最后,我们实现了加入新房间和在不同房间之间切换的方法。</p> <p>到目前为止,我们只使用库来开发我们的应用。在下一章<em>第十六章**,使用 Next.js 入门</em>中,我们将学习如何使用全栈 React 框架来开发应用。框架,如 Next.js,为我们提供了更多的应用结构,并提供了许多功能,例如服务器端渲染等。</p> <h1 id="第五部分迈向企业级全栈应用">第五部分:迈向企业级全栈应用</h1> <p>在这部分,我们将介绍<strong>Next.js</strong>作为一个企业级全栈应用框架。我们将学习它是如何工作的以及它相对于单独使用<strong>React</strong>的优势。然后,我们将使用 Next.js 和新的<strong>App Router</strong>范式创建一个应用。之后,我们将介绍<strong>React Server Components</strong>和<strong>Server Actions</strong>,作为直接与数据库接口的一种方式,无需使用 REST 或 GraphQL API。接着,我们将更深入地探讨 Next.js 框架,了解<strong>缓存</strong>、<strong>API 路由</strong>、添加元数据和如何最优地加载图片和字体。接下来,我们将学习如何使用<strong>Vercel</strong>和自定义部署设置使用<strong>Docker</strong>来部署 Next.js 应用。最后,我们将概述并简要介绍全栈开发中尚未在本书中涵盖的各种高级主题。这包括维护大型项目、优化包大小、UI 库概述以及高级状态管理解决方案等概念。</p> <p>本部分包括以下章节:</p> <ul> <li> <p><em>第十六章</em>, <em>Next.js 入门</em></p> </li> <li> <p><em>第十七章</em>, <em>介绍 React Server Components</em></p> </li> <li> <p><em>第十八章</em>, <em>高级 Next.js 概念和优化</em></p> </li> <li> <p><em>第十九章</em>, <em>部署 Next.js 应用</em></p> </li> <li> <p><em>第二十章</em>, <em>深入全栈开发</em></p> </li> </ul> <h1 id="第十六章开始使用-nextjs">第十六章:开始使用 Next.js</h1> <p>到目前为止,我们一直在使用各种库和工具来开发全栈 Web 应用程序。现在,我们介绍 Next.js 作为一款企业级全栈 Web 应用程序框架,适用于 React。Next.js 将您需要的所有全栈 Web 开发功能和工具集成在一个包中。在这本书中,我们使用 Next.js,因为它是目前最受欢迎的框架,支持所有新的 React 特性,例如 React Server Components 和 Server Actions,这些是全栈 React 开发的未来。然而,还有其他全栈 React 框架,如 Remix,最近也开始支持新的 React 特性。</p> <p>在本章中,我们将学习 Next.js 的工作原理及其优势。然后,我们将使用 Next.js 重新创建我们的博客项目,以突出使用简单的打包器(如 Vite)和全框架(如 Next.js)之间的差异。在这个过程中,我们将学习 Next.js App Router 的工作原理。最后,我们将通过创建组件和页面以及定义它们之间的链接来重新创建我们的(静态)博客应用程序。</p> <p>在本章中,我们将涵盖以下主要主题:</p> <ul> <li> <p>什么是 Next.js?</p> </li> <li> <p>设置 Next.js</p> </li> <li> <p>介绍 App Router</p> </li> <li> <p>创建静态组件和页面</p> </li> </ul> <h1 id="技术要求-15">技术要求</h1> <p>在我们开始之前,请安装从<em>第一章**,准备全栈开发</em>和<em>第二章**,了解 Node.js</em>和 MongoDB*中提到的所有要求。</p> <p>那些章节中列出的版本是本书中使用的版本。虽然安装较新版本不应有问题,但请注意,某些步骤在较新版本上可能有所不同。如果您在使用本书中提供的代码和步骤时遇到问题,请尝试使用<em>第一章</em>和<em>第二章</em>中提到的版本。</p> <p>您可以在 GitHub 上找到本章的代码:<a href="https://github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch16" target="_blank"><code>github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch16</code></a>。</p> <p>本章的 CiA 视频可在以下链接找到:<a href="https://youtu.be/jQFCZqCspoc" target="_blank"><code>youtu.be/jQFCZqCspoc</code></a>。</p> <h1 id="什么是-nextjs">什么是 Next.js?</h1> <p>Next.js 是一个 React 框架,它将您创建全栈 Web 应用程序所需的一切整合在一起。其主要特性如下:</p> <ul> <li> <p>原生提供良好的开发者体验,包括热模块重载、错误处理等。</p> </li> <li> <p>基于文件的路由和嵌套布局,使用 Next.js 定义 API 端点的路由处理器。</p> </li> <li> <p>在路由中支持<strong>国际化</strong>(<strong>i18n</strong>),允许我们创建国际化路由。</p> </li> <li> <p>原生支持增强的服务端和客户端数据获取,带有缓存功能。</p> </li> <li> <p>中间件,在请求完成前运行代码。</p> </li> <li> <p>在无服务器运行时上运行 API 端点的选项。</p> </li> <li> <p>原生支持页面静态生成。</p> </li> <li> <p>当组件需要时动态流式传输组件,使我们能够快速显示初始页面,然后稍后加载其他组件。</p> </li> <li> <p>高级客户端和服务器渲染,使我们不仅能够在服务器端渲染 React 组件(<strong>服务器端渲染</strong>(<strong>SSR</strong>)),还可以使用<strong>React Server Components</strong>,这允许我们在服务器端专门渲染 React 组件,而不需要向客户端发送额外的 JavaScript。</p> </li> <li> <p><strong>服务器操作</strong>用于逐步增强从客户端发送到服务器的表单和操作,使我们能够在客户端没有 JavaScript 的情况下提交表单。</p> </li> <li> <p>内置对图像、字体和脚本的优化,以改善 Core Web Vitals。</p> </li> <li> <p>此外,Next.js 提供了一个平台,使我们能够轻松地将我们的应用部署到 – Vercel。</p> </li> </ul> <p>总的来说,Next.js 将本书中学到的所有全栈开发知识整合在一起,对每个概念进行精炼,使其更加高级和可定制,并将所有这些内容封装在一个单独的包中。我们现在将从头开始使用 Next.js 重新创建之前章节中的博客应用。这样做将使我们能够看到使用和未使用全栈框架开发应用之间的差异。</p> <h1 id="设置-nextjs">设置 Next.js</h1> <p>现在我们将使用<code>create-next-app</code>工具设置一个新的项目,该工具会自动为我们设置一切。按照以下步骤开始:</p> <ol> <li> <p>打开一个新的终端窗口。确保您不在任何项目文件夹中。运行以下命令以创建一个新的文件夹并在其中初始化一个 Next.js 项目:</p> <pre><code class="language-js">$ npx create-next-app@14.1.0 </code></pre> </li> <li> <p>当被问及是否<strong>可以继续</strong>时,按<strong>y</strong>键并按<em>Return/Enter</em>键确认。</p> </li> <li> <p>给项目起一个名字,例如<strong>ch16</strong>。</p> </li> <li> <p>按照以下方式回答问题:</p> <ul> <li> <p><strong>您想使用 TypeScript 吗?</strong>:<strong>否</strong></p> </li> <li> <p><strong>您想使用 ESLint 吗?</strong>:<strong>是</strong></p> </li> <li> <p><strong>您想使用 Tailwind CSS 吗?</strong>:<strong>否</strong></p> </li> <li> <p><strong>您想使用<code>src/</code>目录吗?</strong>:<strong>是</strong></p> </li> <li> <p><strong>您想使用 App Router 吗?</strong>:<strong>是</strong></p> </li> <li> <p><strong>您想自定义默认导入别名吗?</strong>:<strong>否</strong></p> </li> </ul> </li> <li> <p>在回答完所有问题后,将在<strong>ch16</strong>文件夹中创建一个新的 Next.js 应用。输出结果应如下所示:</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_16_1.jpg" alt="图 16.1 – 创建新的 Next.js 项目" loading="lazy"></p> <p>图 16.1 – 创建新的 Next.js 项目</p> <ol> <li> <p>在 VS Code 中打开新创建的<strong>ch16</strong>文件夹。</p> </li> <li> <p>在新的 VS Code 窗口中,打开一个终端并使用以下命令运行项目:</p> <pre><code class="language-js">$ npm run dev </code></pre> </li> <li> <p>在浏览器中打开<strong><a href="http://localhost:3000" target="_blank">http://localhost:3000</a></strong>以查看运行的 Next.js 应用!应用应如下所示:</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_16_2.jpg" alt="图 16.2 – 在浏览器中运行的我们新创建的 Next.js 应用" loading="lazy"></p> <p>图 16.2 – 在浏览器中运行的我们新创建的 Next.js 应用</p> <ol> <li> <p>不幸的是,<strong>create-next-app</strong>没有为我们设置 Prettier,所以让我们现在快速设置一下。通过运行以下命令安装 Prettier:</p> <pre><code class="language-js">$ npm install --save-dev prettier@2.8.4 \   eslint-config-prettier@8.6.0 </code></pre> </li> <li> <p>在项目的根目录中创建一个新的<strong>.prettierrc.json</strong>文件,内容如下:</p> <pre><code class="language-js">{   "trailingComma": "all",   "tabWidth": 2,   "printWidth": 80,   "semi": false,   "jsxSingleQuote": true,   "singleQuote": true } </code></pre> </li> <li> <p>编辑现有的 <strong>.eslintrc.json</strong> 文件,以便从 <strong>prettier</strong> 扩展,如下所示:</p> <pre><code class="language-js">{   "extends": ["next/core-web-vitals", "prettier"] } </code></pre> </li> <li> <p>前往 VS Code 工作区设置,将 <strong>Editor: Default Formatter</strong> 设置更改为 <strong>Prettier</strong>,并勾选 <strong>Editor: Format</strong> <strong>On Save</strong> 复选框。</p> </li> </ol> <p>现在我们已经成功创建了一个新的 Next.js 项目,并集成了 ESLint 和 Prettier!我们仍然可以设置 Husky 和 lint-staged,就像我们之前做的那样,但现在我们将坚持这个简单的设置。接下来,我们将学习更多关于 Next.js 中应用程序结构的内容。</p> <h1 id="介绍-app-router">介绍 App Router</h1> <p>Next.js 携带一种特殊的结构化应用程序的范式,称为 App Router。App Router 利用 <code>src/app/</code> 文件夹中的文件夹结构来为我们的应用程序创建路由。根文件夹(<code>/</code> 路径)是 <code>src/app/</code>。如果我们想定义一个路径,例如 <code>/posts</code>,我们需要创建一个 <code>src/app/posts/</code> 文件夹。为了使这个文件夹成为一个有效的路由,我们需要在其中放置一个 <code>page.js</code> 文件,该文件包含在访问该路由时将被渲染的页面组件。</p> <p>注意</p> <p>或者,我们可以将一个 <strong>route.js</strong> 文件放入一个文件夹中,将其转换为 API 路由而不是渲染页面。我们将在 <em>第十八章</em> <em>高级 Next.js 概念和优化</em> 中了解更多关于 API 路由的内容。</p> <p>此外,Next.js 允许我们定义一个 <code>layout.js</code> 文件,它将被用作特定路径的布局。布局组件接受子组件,可以包含其他布局或页面。这种灵活性允许我们定义带有子布局的嵌套路由。</p> <p>在 App Router 范式中还有其他特殊文件,例如 <code>error.js</code> 文件,当页面发生错误时将被渲染,以及 <code>loading.js</code> 文件,在页面加载时(使用 React Suspense)将被渲染。</p> <p>查看以下带有 App Router 的文件夹结构示例:</p> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_16_3.jpg" alt="图 16.3 – 带有 App Router 的文件夹结构示例" loading="lazy"></p> <p>图 16.3 – 带有 App Router 的文件夹结构示例</p> <p>在前面的示例中,我们有一个 <code>dashboard/settings/</code> 路由,由 <code>dashboard</code> 和 <code>settings</code> 文件夹定义。<code>dashboard</code> 文件夹没有 <code>page.js</code> 文件,所以访问 <code>dashboard/</code> 将导致 <code>404 Not Found</code> 错误。然而,<code>dashboard</code> 文件夹有一个 <code>layout.js</code> 文件,它定义了仪表板的主要布局。<code>settings</code> 文件夹有一个另一个 <code>layout.js</code> 文件,它定义了仪表板上的设置页面布局。它还有一个 <code>page.js</code> 文件,当访问 <code>dashboard/settings/</code> 路由时将被渲染。此外,它还有一个 <code>loading.js</code> 文件,在设置页面加载时在设置布局内部渲染。它还包含一个 <code>error.js</code> 文件,如果在加载设置页面时发生错误,它将在设置布局内部渲染。</p> <p>如我们所见,App Router 使得实现常见用例变得容易,例如嵌套路由、布局、错误和加载组件。现在让我们开始定义博客应用程序的文件夹结构。</p> <h2 id="定义文件夹结构">定义文件夹结构</h2> <p>让我们回顾并精炼博客应用程序从上一章中的路由结构:</p> <ul> <li> <p><strong>/</strong> – 我们博客的首页,包含文章列表</p> </li> <li> <p><strong>/login</strong> – 登录现有账户的登录页面</p> </li> <li> <p><strong>/signup</strong> – 创建新账户的注册页面</p> </li> <li> <p><strong>/create</strong> – 创建新博客文章的页面(此路由为新)</p> </li> <li> <p><strong>/posts/:id</strong> – 查看单个博客文章的页面</p> </li> </ul> <p>所有这些页面都共享一个带有顶部导航栏的通用布局,使我们能够在应用程序的各个页面之间导航。</p> <p>让我们现在创建这个路由结构作为 App Router 中的文件夹结构:</p> <ol> <li> <p><em>删除</em>现有的<strong>src/app/</strong>文件夹。</p> </li> <li> <p>创建一个新的<strong>src/app/</strong>文件夹。在其内部,创建一个<strong>src/app/layout.js</strong>文件,内容如下:</p> <pre><code class="language-js">export const metadata = {   title: 'Full-Stack Next.js Blog',   description: 'A blog about React and Next.js', } export default function RootLayout({ children }) {   return (     <html lang="en">       <body>         <main>{children}</main>       </body>     </html>   ) } </code></pre> <p><code>metadata</code>对象是 Next.js 中一个特殊的导出对象,用于提供元标签,如<code><title></code>和<code><meta name="description"></code>标签。</p> <p>App Router 中文件的默认导出需要是应该为相应布局/页面渲染的组件。</p> </li> <li> <p>创建一个新的<strong>src/app/page.js</strong>文件,内容如下:</p> <pre><code class="language-js">export default function HomePage() {   return <strong>Blog home page</strong> } </code></pre> </li> <li> <p>创建一个新的<strong>src/app/login/</strong>文件夹。在其内部,创建一个<strong>src/app/login/page.js</strong>文件,内容如下:</p> <pre><code class="language-js">export default function LoginPage() {   return <strong>Login</strong> } </code></pre> </li> <li> <p>创建一个新的<strong>src/app/signup/</strong>文件夹。在其内部,创建一个<strong>src/app/signup/page.js</strong>文件,内容如下:</p> <pre><code class="language-js">export default function SignupPage() {   return <strong>Signup</strong> } </code></pre> </li> <li> <p>创建一个新的<strong>src/app/create/</strong>文件夹。在其内部,创建一个<strong>src/app/create/page.js</strong>文件,内容如下:</p> <pre><code class="language-js">export default function CreatePostPage() {   return <strong>CreatePost</strong> } </code></pre> </li> <li> <p>创建一个新的<strong>src/app/posts/</strong>文件夹。在其内部,创建一个新的<strong>src/app/posts/[id]/</strong>文件夹。这是一个特殊的文件夹,包含一个路由参数<strong>id</strong>,我们可以在渲染页面时使用它。</p> </li> <li> <p>创建一个新的<strong>src/app/posts/[id]/page.js</strong>文件,内容如下:</p> <pre><code class="language-js">export default function ViewPostPage({ params }) {   return <strong>ViewPost {params.id}</strong> } </code></pre> <p>如您所见,我们从 Next.js 提供的<code>params</code>对象中获取<code>id</code>。</p> </li> <li> <p>如果它已经停止运行,请使用以下命令启动 Next.js 开发服务器:</p> <pre><code class="language-js">$ npm run dev </code></pre> </li> <li> <p>然后在浏览器中转到<strong><a href="http://localhost:3000/" target="_blank">http://localhost:3000/</a></strong>(或刷新页面)以查看主路由是否正常工作。转到不同的路由,如<strong>/login</strong>和<strong>/posts/123</strong>,以查看不同页面被渲染以及<strong>路由</strong>参数是否正常工作!</p> </li> </ol> <p>现在我们已经定义了项目的文件夹结构,让我们继续创建静态组件和页面。</p> <h1 id="创建静态组件和页面">创建静态组件和页面</h1> <p>对于我们博客的组件,我们可以重用前几章中编写的大部分代码,因为 Next.js 与纯 React 相比并没有太大的不同。只有特定的组件,如导航栏,会有所不同,因为 Next.js 有自己的路由器。我们将大多数组件创建在单独的<code>src/components/</code>文件夹中。这个文件夹将只包含可以在多个页面之间重用的 React 组件。所有页面和布局组件仍然在<code>src/app/</code>。</p> <p>注意</p> <p>在 Next.js 中,也可以将常规组件与页面和布局组件一起放置,对于仅在特定页面上使用的组件,在大规模项目中应该这样做。在小项目中,这并不是很重要,我们只需将所有常规组件放在一个单独的文件夹中,以便更容易地将它们与页面和布局组件区分开来。</p> <h2 id="定义组件">定义组件</h2> <p>现在我们开始创建我们博客应用的组件:</p> <ol> <li> <p>创建一个新的<strong>src/components/</strong>文件夹。</p> </li> <li> <p>创建一个新的<strong>src/components/Login.jsx</strong>文件。在其中,定义一个包含<strong>用户名</strong>字段、<strong>密码</strong>字段和提交按钮的<strong><form></strong>:</p> <pre><code class="language-js">export function Login() {   return (     <form>       <div>         <label htmlFor='username'>Username: </label>         <input type='text' name='username' id='username' />       </div>       <br />       <div>         <label htmlFor='password'>Password: </label>         <input type='password' name='password' id='password' />       </div>       <br />       <input type='submit' value='Log In' />     </form>   ) } </code></pre> </li> </ol> <p>注意</p> <p>我们故意使用非受控输入字段(因此,没有<strong>useState</strong>钩子),因为在下一章将要学习的使用服务器操作的表单中,没有必要创建受控输入字段,我们将学习的内容是<em>第十七章**,介绍 React Server Components</em>。然而,正确定义输入字段的<strong>name</strong>属性很重要,因为当表单提交时,将使用该属性来识别字段。</p> <ol> <li> <p>以类似的方式,创建一个新的<strong>src/components/Signup.jsx</strong>文件,并定义具有相同字段的表单:</p> <pre><code class="language-js">export function Signup() {   return (     <form>       <div>         <label htmlFor='username'>Username: </label>         <input type='text' name='username' id='username' />       </div>       <br />       <div>         <label htmlFor='password'>Password: </label>         <input type='password' name='password' id='password' />       </div>       <br />       <input type='submit' value='Sign Up' />     </form>   ) } </code></pre> </li> <li> <p>创建一个新的<strong>src/components/CreatePost.jsx</strong>文件,并定义一个包含必需的<strong>标题</strong>输入字段、用于定义<strong>内容</strong>的<strong>textarea</strong>和一个提交按钮的表单:</p> <pre><code class="language-js">export function CreatePost() {   return (     <form>       <div>         <label htmlFor='title'>Title: </label>         <input type='text' name='title' id='title' required />       </div>       <br />       <textarea name='contents' id='contents' />       <br />       <br />       <input type='submit' value='Create' />     </form>   ) } </code></pre> </li> <li> <p>创建一个新的<strong>src/components/Post.jsx</strong>文件。作为对前几章结构的改进,<strong>Post</strong>组件将在<strong>PostList</strong>中使用,并且只显示博客文章的<strong>标题</strong>和<strong>作者</strong>,以及一个链接到完整文章:</p> <pre><code class="language-js">import PropTypes from 'prop-types' export function Post({ _id, title, author }) {   return (     <article>       <h3>{title}</h3>       <em>         Written by <strong>{author.username}</strong>       </em>     </article>   ) } </code></pre> </li> <li> <p>我们还需要定义<strong>propTypes</strong>。在这种情况下,我们将使用类似于数据库查询结果的架构,因为我们将在下一章介绍 React Server Components 时能够直接使用数据库结果:</p> <pre><code class="language-js">Post.propTypes = {   _id: PropTypes.string.isRequired,   title: PropTypes.string.isRequired,   author: PropTypes.shape({     username: PropTypes.string.isRequired,   }).isRequired,   contents: PropTypes.string, } </code></pre> </li> <li> <p>创建一个新的<strong>src/components/PostList.jsx</strong>文件。在这里,我们将重用<strong>Post</strong>组件的<strong>propTypes</strong>,所以让我们也导入<strong>Post</strong>组件:</p> <pre><code class="language-js">import { Fragment } from 'react' import PropTypes from 'prop-types' import { Post } from './Post.jsx' </code></pre> </li> <li> <p>然后,我们定义<strong>PostList</strong>组件,它使用<strong>Post</strong>组件渲染每个博客文章:</p> <pre><code class="language-js">export function PostList({ posts = [] }) {   return (     <div>       {posts.map((post) => (         <Fragment key={`post-${post._id}`}>           <Post _id={post._id} title={post.title} author={post.author} />           <hr />         </Fragment>       ))}     </div>   ) } </code></pre> </li> </ol> <p>注意</p> <p>使用唯一的 ID 作为<strong>key</strong>属性是一个最佳实践,例如数据库 ID,这样 React 可以跟踪列表中变化的项目。</p> <ol> <li> <p>我们现在通过使用现有的<strong>Post.propTypes</strong>来定义<strong>PostList</strong>组件的<strong>propTypes</strong>:</p> <pre><code class="language-js">PostList.propTypes = {   posts: PropTypes.arrayOf(     PropTypes.shape(Post.propTypes)   ).isRequired, } </code></pre> </li> <li> <p>最后,我们创建一个新的<strong>src/components/FullPost.jsx</strong>文件,在其中显示包含所有内容的完整帖子:</p> <pre><code class="language-js">import PropTypes from 'prop-types' export function FullPost({ title, contents, author }) {   return (     <article>       <h3>{title}</h3>       <div>{contents}</div>       <br />       <em>         Written by <strong>{author.username}</strong>       </em>     </article>   ) } </code></pre> </li> <li> <p>我们不是从<strong>Post</strong>组件中重用<strong>propTypes</strong>,而是在这里重新定义它们,因为<strong>FullPost</strong>组件需要与<strong>Post</strong>组件不同的属性(它没有<strong>_id</strong>属性,而是有<strong>contents</strong>属性):</p> <pre><code class="language-js">FullPost.propTypes = {   title: PropTypes.string.isRequired,   author: PropTypes.shape({     username: PropTypes.string.isRequired,   }).isRequired,   contents: PropTypes.string, } </code></pre> </li> </ol> <p>现在我们已经定义了我们博客应用所需的全部组件,让我们继续正确地定义页面组件。</p> <h2 id="定义页面">定义页面</h2> <p>在创建我们博客应用所需的各个组件后,现在让我们用适当的页面替换占位符页面组件,这些页面将渲染适当的组件。按照以下步骤开始:</p> <ol> <li> <p>编辑<strong>src/app/login/page.js</strong>并导入<strong>Login</strong>组件,然后渲染它:</p> <pre><code class="language-js">import { Login } from '@/components/Login' export default function LoginPage() {   return <Login /> } </code></pre> </li> </ol> <p>注意</p> <p>记得当我们设置 Next.js 时,是否被问及是否想要自定义默认导入别名吗?这个导入别名允许我们引用项目的<strong>src/</strong>文件夹,使我们的导入是绝对的而不是相对的。默认情况下,这是使用<strong>@</strong>别名完成的。因此,我们现在可以从<strong>@/components/Login</strong>导入,而不是必须从<strong>../../components/Login.jsx</strong>导入。在大型项目中,使用导入别名进行绝对导入变得特别有用,并且可以轻松地在以后重构项目。</p> <ol> <li> <p>编辑<strong>src/app/signup/page.js</strong>,以类似的方式导入并渲染<strong>Signup</strong>组件:</p> <pre><code class="language-js">import { Signup } from '@/components/Signup' export default function SignupPage() {   return <Signup /> } </code></pre> </li> <li> <p>通过编辑<strong>src/app/create/page.js</strong>文件重复此过程:</p> <pre><code class="language-js">import { CreatePost } from '@/components/CreatePost' export default function CreatePostPage() {   return <CreatePost /> } </code></pre> </li> <li> <p>现在,编辑<strong>src/app/posts/[id]/page.js</strong>文件并导入<strong>FullPost</strong>组件:</p> <pre><code class="language-js">import { FullPost } from '@/components/FullPost' </code></pre> </li> <li> <p>然后,定义一个示例<strong>post</strong>对象:</p> <pre><code class="language-js">export default function ViewPostPage({ params }) {   const post = {     title: `Hello Next.js (${params.id})`,     contents: 'This will be fetched from the database later',     author: { username: 'Daniel Bugl' }, id into the title. </code></pre> </li> <li> <p>按照以下方式渲染<strong>FullPost</strong>组件:</p> <pre><code class="language-js">  return (     <FullPost       title={post.title}       contents={post.contents}       author={post.author}     />   ) } </code></pre> </li> <li> <p>最后,通过导入<strong>PostList</strong>组件、创建一个示例<strong>posts</strong>数组并渲染<strong>PostList</strong>组件来编辑<strong>src/app/page.js</strong>:</p> <pre><code class="language-js">import { PostList } from '@/components/PostList' export default function HomePage() {   const posts = [     { _id: '123', title: 'Hello Next.js', author: { username: 'Daniel Bugl' } },   ]   return <PostList posts={posts} /> } </code></pre> </li> <li> <p>前往<strong><a href="http://localhost:3000/posts/123" target="_blank">http://localhost:3000/posts/123</a></strong>查看使用标题中的<strong>id</strong>参数渲染的<strong>FullPost</strong>组件。您可以随意更改 URL 中的<strong>id</strong>以查看标题如何变化。以下截图显示了在<strong>/****posts/123</strong>路径上渲染的<strong>FullPost</strong>组件:</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_16_4.jpg" alt="图 16.4 – 使用 Next.js 路由参数在标题中渲染 FullPost 组件" loading="lazy"></p> <p>图 16.4 – 使用 Next.js 路由参数在标题中渲染 FullPost 组件</p> <p>在成功定义所有页面后,我们仍然需要一个在它们之间导航的方法,所以让我们继续通过在页面之间添加链接来继续:</p> <h2 id="在页面之间添加链接">在页面之间添加链接</h2> <p>如本章前面所述,Next.js 提供了自己的路由解决方案——App Router。路由由<code>src/app/</code>目录中的文件夹结构定义,并且它们都已经准备好了。现在我们唯一要做的就是添加它们之间的链接。为此,我们需要使用来自<code>next/link</code>的<code>Link</code>组件。按照以下步骤开始实现导航栏:</p> <ol> <li> <p>创建一个新的<strong>src/components/Navigation.jsx</strong>文件,其中我们导入<strong>Link</strong>组件和<strong>PropTypes</strong>:</p> <pre><code class="language-js">import Link from 'next/link' import PropTypes from 'prop-types' </code></pre> </li> <li> <p>定义一个<strong>UserBar</strong>组件,当用户登录时将被渲染,并允许用户访问<strong>创建帖子</strong>页面和注销:</p> <pre><code class="language-js">export function UserBar({ username }) {   return (     <form>       <Link href='/create'>Create Post</Link> | Logged in as{' '}       <strong>{username}</strong> <button>Logout</button>     </form>   ) } UserBar.propTypes = {   username: PropTypes.string.isRequired, } </code></pre> </li> <li> <p>然后,定义一个<strong>LoginSignupLinks</strong>组件,当用户尚未登录时将被渲染。它提供了链接到<strong>/login</strong>和<strong>/signup</strong>页面,允许用户在我们的应用中注册和登录:</p> <pre><code class="language-js">export function LoginSignupLinks() {   return (     <div>       <Link href='/login'>Log In</Link> | <Link href='/signup'>Sign Up</Link>     </div>   ) } </code></pre> </li> <li> <p>接下来,定义一个<strong>Navigation</strong>组件,它添加了一个链接到主页,然后根据用户是否登录有条件地渲染<strong>UserBar</strong>组件或<strong>LoginSignupLinks</strong>组件:</p> <pre><code class="language-js">export function Navigation({ username }) {   return (     <>       <Link href='/'>Home</Link>       {username ? <UserBar username={username} /> : <LoginSignupLinks />}     </>   ) } Navigation.propTypes = {   username: PropTypes.string, } </code></pre> </li> <li> <p>现在,我们只需要渲染<strong>Navigation</strong>组件。为了确保它在博客应用的所有页面上显示,我们将它放在根布局中。编辑<strong>src/app/layout.js</strong>并导入<strong>Navigation</strong>组件:</p> <pre><code class="language-js">import { Navigation } from '@/components/Navigation' </code></pre> </li> <li> <p>然后,定义一个示例<strong>user</strong>对象来模拟用户登录:</p> <pre><code class="language-js">export default function RootLayout({ children }) {   const user = { username: 'dan' } </code></pre> </li> <li> <p>按照以下方式渲染<strong>Navigation</strong>组件:</p> <pre><code class="language-js">  return (     <html lang='en'>       <body>         <nav>           <Navigation username={user?.username} />         </nav>         <br />         <main>{children}</main>       </body>     </html>   ) } </code></pre> </li> <li> <p>我们还需要从列表中的单个帖子添加一个链接到完整的帖子页面。编辑<strong>src/components/Post.jsx</strong>并导入<strong>Link</strong>组件:</p> <pre><code class="language-js">import Link from 'next/link' </code></pre> </li> <li> <p>然后,添加一个链接到标题,如下所示:</p> <pre><code class="language-js">export function Post({ _id, title, author }) {   return (     <article>       <h3>         <Link href={`/posts/${_id}`}>{title}</Link>       </h3> </code></pre> </li> <li> <p>访问<strong><a href="http://localhost:3000/" target="_blank">http://localhost:3000/</a></strong>,您将看到渲染了<strong>UserBar</strong>组件的导航栏。</p> </li> <li> <p>点击<strong>创建帖子</strong>链接进入相应的页面,然后使用<strong>主页</strong>链接返回。也可以尝试通过点击主页上的博客帖子标题来访问完整的帖子页面。</p> </li> </ol> <p>以下截图显示了在添加了导航栏后渲染的<strong>主页</strong>:</p> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_16_5.jpg" alt="图 16.5 – 在 Next.js 中重新创建的我们的(静态)博客应用!" loading="lazy"></p> <p>图 16.5 – 在 Next.js 中重新创建的我们的(静态)博客应用!</p> <h1 id="摘要-13">摘要</h1> <p>在本章中,我们首先学习了 Next.js 是什么以及它如何对全栈开发有用。然后,我们设置了一个新的 Next.js 项目,并了解了 App Router 范式。最后,我们通过创建组件、页面和导航栏,利用 Next.js 的<code>Link</code>组件在应用的不同页面间导航,重新创建了 Next.js 中的博客应用。</p> <p>在下一章<em>第十七章**介绍 React 服务器组件</em>中,我们将学习如何通过创建 React 服务器组件来使我们的博客应用变得交互式,这些组件在服务器上运行,例如可以执行数据库查询。此外,我们还将学习关于服务器操作的知识,这些操作用于提交表单,例如登录、注册和创建帖子表单。</p> <h1 id="第十七章介绍-react-服务器组件">第十七章:介绍 React 服务器组件</h1> <p>在 Next.js 中实现我们的静态博客应用之后,是时候给它添加一些交互性了。我们不会使用传统的模式,即编写一个单独的后端服务器,前端从该服务器获取数据并发出请求,而是将使用一种名为<strong>React 服务器组件</strong>(<strong>RSCs</strong>)的新模式。这种新模式允许我们通过仅在某些 React 组件(所谓的服务器组件)上执行来直接从 React 组件访问数据库。结合服务器操作(一种从客户端调用服务器上函数的方法),这种新模式使我们能够轻松快速地开发全栈应用。在本章中,我们将学习 RSCs 和服务器操作是什么,为什么它们很重要,它们的优点是什么,以及如何正确且安全地实现它们。</p> <p>在本章中,我们将涵盖以下主要主题:</p> <ul> <li> <p>什么是 RSCs?</p> </li> <li> <p>为我们的 Next.js 应用添加数据层</p> </li> <li> <p>使用 RSCs 从数据库获取数据</p> </li> <li> <p>使用服务器操作进行注册、登录和创建新帖子</p> </li> </ul> <h1 id="技术要求-16">技术要求</h1> <p>在我们开始之前,请安装从<em>第一章</em>“为全栈开发做准备”和<em>第二章</em>“了解 Node.js 和 MongoDB”中提到的所有要求。</p> <p>那些章节中列出的版本是本书中使用的版本。虽然安装较新版本不应成问题,但请注意,某些步骤可能会有所不同。如果你在使用本书中提供的代码和步骤时遇到问题,请尝试使用<em>第一章</em>和<em>第二章</em>中提到的版本。</p> <p>你可以在 GitHub 上找到本章的代码:<a href="https://github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch17" target="_blank"><code>github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch17</code></a>。</p> <p>本章的 CiA 视频可以在以下位置找到:<a href="https://youtu.be/4hGZJRmZW6E" target="_blank"><code>youtu.be/4hGZJRmZW6E</code></a>。</p> <h1 id="什么是-rscs">什么是 RSCs?</h1> <p>到目前为止,我们一直在使用传统的 React 架构,其中所有组件都是<strong>客户端组件</strong>。我们是从客户端渲染开始的。然而,客户端渲染有一些缺点:</p> <ul> <li> <p>在客户端开始渲染任何内容之前,必须从服务器下载 JavaScript 客户端包,这会延迟用户的<strong>首次内容绘制</strong>(<strong>FCP</strong>)。</p> </li> <li> <p>必须从服务器获取数据(在下载并执行 JavaScript 之后)才能显示任何有意义的内容,这会延迟用户的<strong>首次有意义的绘制</strong>(<strong>FMP</strong>)。</p> </li> <li> <p>大部分负载都在客户端,即使是那些非交互式的页面也是如此,这对处理器较慢的客户端来说尤其成问题,例如低端移动设备或旧笔记本电脑。它还需要更多的电池来加载重量级的客户端渲染页面。</p> </li> <li> <p>在某些情况下,数据是顺序获取的(例如,首先加载帖子,然后解析每个帖子的作者),这对于具有高延迟的慢速连接来说尤其是一个问题。</p> </li> </ul> <p>为了解决这些问题,<strong>服务器端渲染</strong>(<strong>SSR</strong>)被引入,但它仍然有一个很大的缺点:由于所有内容都在服务器上渲染,初始页面加载可能会很慢。这种减速发生的原因如下:</p> <ul> <li> <p>在显示任何数据之前,必须从服务器获取数据。</p> </li> <li> <p>在客户端使用它进行水合之前,必须从服务器下载 JavaScript 客户端包。水合意味着页面已准备好供用户交互。为了刷新你对水合工作原理的了解,请查看<em>第七章</em>。</p> </li> <li> <p>水合作用必须在客户端完成,之后才能与任何内容进行交互。</p> </li> </ul> <p>即使客户端组件在服务器上进行了预渲染,其代码也会被打包并发送到客户端进行水合。这意味着客户端组件可以在服务器(用于 SSR)和客户端上运行,但它们至少需要在客户端上能够运行。</p> <p>在仅包含客户端组件的传统全栈 React 架构中,如果我们需要访问服务器的文件系统或数据库,我们需要编写一个单独的后端使用 Node.js 并公开一个 API(例如 REST API)。然后,这个 API 在客户端组件中被查询,例如,使用 TanStack Query。这些查询也可以在服务器端进行(如我们在<em>第七章</em>,<em>使用服务器端渲染提高加载时间</em>)中看到),但它们至少需要在客户端可执行。这意味着我们无法直接从 React 组件中访问文件系统或数据库,即使该代码可以在服务器上运行;它会被打包并发送到客户端,在那里运行将不会工作(或者会将内部信息,如凭证,暴露给数据库):</p> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_17_1.jpg" alt="图 17.1 – 无 RSCs 和有 RSCs 的全栈应用架构" loading="lazy"></p> <p>图 17.1 – 无 RSCs 和有 RSCs 的全栈应用架构</p> <p>React 18 引入了一个名为 RSCs 的新功能,允许我们定义仅将在服务器上执行组件,只将输出发送到客户端。服务器组件可以,例如,从数据库或文件系统中获取数据,然后渲染交互式客户端组件,并将这些数据作为 props 传递给它们。这个新功能允许我们构建一个架构,我们可以更轻松地仅使用 React 编写全栈应用程序,而无需处理定义 REST API 的开销。</p> <p>注意</p> <p>对于某些应用程序,定义 REST API 可能仍然有意义,特别是如果后端是由更大规模项目中的另一个团队开发,或者如果它被其他服务和前端消费。</p> <p>RSC 通过允许我们在服务器上独家执行代码(客户端无需水合!)和选择性地流式传输组件(这样我们就不必等待所有内容预渲染后再向客户端提供组件)来解决客户端渲染和 SSR 中的上述问题。</p> <p>下图比较了 <strong>客户端渲染 (CSR</strong>) 与 SSR 和 RSC:</p> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_17_2.jpg" alt="图 17.2 – CSR、SSR 和 RSC 的比较" loading="lazy"></p> <p>图 17.2 – CSR、SSR 和 RSC 的比较</p> <p>正如你所见,RSC 不仅整体上更快(由于网络往返次数更少),而且可以在等待其他组件加载的同时立即显示应用程序的布局。</p> <p>让我们总结一下 RSC 的最重要的特性:</p> <ul> <li> <p>它们可以在构建之前运行,并且不会被包含在 JavaScript 包中,从而减少包大小并提高性能。</p> </li> <li> <p>它们可以在构建时运行(生成静态 HTML)或当请求到来时即时执行。有趣的是,服务器组件也可以在构建时独家执行,从而生成静态 HTML 包。这对于静态构建的 CMS 应用或个人博客可能很有用。RSC 还允许混合使用,其中初始缓存通过静态构建进行预填充,然后通过服务器操作或 Webhooks 进行后续验证。我们将在 <em>第十八章**,高级 Next.js 概念和优化</em> 中了解更多关于缓存的内容。</p> </li> <li> <p>它们可以将(可序列化)数据传递给客户端组件。此外,客户端组件仍然可以被服务器端渲染,以进一步提高性能!</p> </li> <li> <p>在服务器组件内部,其他服务器组件可以作为 props 传递给客户端组件,允许使用组合模式,其中服务器组件被“嵌入”到交互式客户端组件中。然而,所有在客户端组件内部导入的组件都将被视为客户端组件;它们不能再是服务器组件。</p> </li> </ul> <p>在像 Next.js 这样的框架中,默认情况下,React 组件被视为服务器组件。如果我们想将其转换为客户端组件,我们需要在文件开头写入 <code>"use client"</code> 指令。我们需要这样做是为了使其能够添加交互性(事件监听器)或使用状态/生命周期效果和仅浏览器 API。</p> <p>注意</p> <p><strong>"use client"</strong> 指令定义了服务器组件和客户端组件之间的网络边界。所有从服务器组件发送到客户端组件的数据都将被序列化并通过网络发送。当在文件中使用 <strong>"use client"</strong> 指令时,所有导入到该文件的其他模块,包括子组件,都被视为客户端包的一部分。</p> <p>下图概述了何时使用服务器组件或客户端组件:</p> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_17_3.jpg" alt="图 17.3 – 何时使用服务器组件和客户端组件概述" loading="lazy"></p> <p>图 17.3 – 何时使用服务器组件和客户端组件概述</p> <p>通常,RSC 是对客户端组件的一种优化。你可以在每个文件的顶部简单地写上<code>"use client"</code>并完成,但你将放弃 RSC 的所有优势!所以,尽可能使用服务器组件,但如果你发现将其拆分为服务器端和客户端部分过于复杂,不要犹豫将其定义为客户端组件。它总是可以在以后进行优化。</p> <p>这种编写全栈 React 应用的新方法在理论上可能难以理解,所以请随时在本章结束时再次回到这一节。现在,我们将继续前进,并在我们的 Next.js 应用中实现 RSC,这将帮助我们理解新概念在实际中的工作方式。首先,我们将从向我们的 Next.js 应用添加数据层开始,这将允许我们稍后从 RSC 中访问数据库。</p> <h1 id="向我们的-nextjs-应用添加数据层">向我们的 Next.js 应用添加数据层</h1> <p>在传统的后端结构中,我们有数据库层、服务层和路由层。在现代的全栈 Next.js 应用中,我们不需要后端的路由层,因为我们可以直接在 RSC 中与之交互。因此,我们只需要数据库层和一个数据层来提供访问数据库的功能。理论上,我们可以在 RSC 中直接访问数据库,但最佳实践是定义特定的函数以特定方式访问它。定义这样的函数使我们能够清楚地定义哪些数据是可访问的(从而避免意外泄露过多信息)。它们也更易于重用,并使得单元测试和发现数据层中的潜在漏洞(例如,通过渗透测试)更加容易。</p> <p>总结一下,主要有三种数据处理方法:</p> <ul> <li> <p><strong>HTTP APIs</strong>:我们在前几章中使用这些 API 来实现我们的博客应用。当后端和前端由不同的团队工作时,这些 API 非常有用。因此,这种方法推荐用于现有的大型项目和组织。</p> </li> <li> <p><strong>数据访问层</strong>:这是我们将在本节中使用的模式。对于使用 RSC 架构的新项目来说,这是一个推荐的选择,因为它通过分离处理数据(以及与之相关的所有安全挑战)和用户界面(在 React 组件中显示数据)的职责,使得实现全栈项目更加容易。单独处理每个问题比同时处理两者的复杂性更容易解决且错误率更低。</p> </li> <li> <p><strong>组件级数据访问</strong>:这是一种在 RSC 中直接查询数据库的模式。这种方法对于快速原型设计和学习很有用。然而,由于可扩展性问题以及可能引入的安全问题,它不应在生产应用中使用。</p> </li> </ul> <p>不建议混合这些方法,所以最好选择一个并坚持下去。在我们的情况下,我们选择“数据访问层”方法,因为它是对现代 RSC 架构最安全的做法。</p> <h2 id="设置数据库连接">设置数据库连接</h2> <p>让我们先设置必要的包和初始化数据库连接:</p> <ol> <li> <p>将现有的 <strong>ch16</strong> 文件夹复制到一个新的 <strong>ch17</strong> 文件夹,如下所示:</p> <pre><code class="language-js">$ cp -R ch16 ch17 </code></pre> </li> <li> <p>在 VS Code 中打开 <strong>ch17</strong> 文件夹并打开一个终端。</p> </li> <li> <p>我们将使用一个名为 <strong>server-only</strong> 的包来确保数据库和数据层的代码仅在服务器端执行,而不会意外地导入客户端。按照以下步骤安装它:</p> <pre><code class="language-js">$ npm install server-only@0.0.1 </code></pre> </li> <li> <p>我们还需要 <strong>mongoose</strong> 包来连接到数据库并创建数据库模式和模型。运行以下命令来安装它:</p> <pre><code class="language-js">$ npm install mongoose@8.0.2 </code></pre> </li> <li> <p>创建一个新的 <strong>src/db/</strong> 文件夹。</p> </li> <li> <p>在这个文件夹内,创建一个新的 <strong>src/db/init.js</strong> 文件,在其中我们首先导入 <strong>server-only</strong> 包以确保代码仅在服务器上执行:</p> <pre><code class="language-js">import 'server-only' </code></pre> </li> <li> <p>接下来,导入 <strong>mongoose</strong>:</p> <pre><code class="language-js">import mongoose from 'mongoose' </code></pre> </li> <li> <p>定义并导出一个 <strong>async</strong> 函数以初始化数据库:</p> <pre><code class="language-js">export async function initDatabase() {   const connection = await mongoose.connect(process.env.DATABASE_URL)   return connection } </code></pre> </li> <li> <p>现在,我们需要在 <strong>.env</strong> 文件中定义 <strong>DATABASE_URL</strong>。因此,在项目的根目录中创建一个新的 <strong>.env</strong> 文件并添加以下行:</p> <pre><code class="language-js">DATABASE_URL=mongodb://localhost:27017/blog </code></pre> </li> </ol> <p>现在数据库连接已经设置好,我们可以继续创建数据库模型。</p> <h2 id="创建数据库模型">创建数据库模型</h2> <p>现在,我们将为帖子用户创建数据库模型。这些模型将与我们之前章节中为我们的博客应用创建的模型非常相似。按照以下步骤开始创建数据库模型:</p> <ol> <li> <p>创建一个新的 <strong>src/db/models/</strong> 文件夹。</p> </li> <li> <p>在其中,创建一个新的 <strong>src/db/models/user.js</strong> 文件,在其中我们首先导入 <strong>server-only</strong> 和 <strong>mongoose</strong> 包:</p> <pre><code class="language-js">import 'server-only' import mongoose, { Schema } from 'mongoose' </code></pre> </li> <li> <p>定义 <strong>userSchema</strong>,它由一个唯一的必需的 <strong>username</strong> 和一个必需的 <strong>password</strong> 组成:</p> <pre><code class="language-js">const userSchema = new Schema({   username: { type: String, required: true, unique: true },   password: { type: String, required: true }, }) </code></pre> </li> <li> <p>如果模型尚未创建,我们创建 Mongoose 模型:</p> <pre><code class="language-js">export const User = mongoose.models.user ?? mongoose.model('user', userSchema) </code></pre> </li> </ol> <p>注意</p> <p>如果模型已经存在,则返回模型,如果不存在,则创建一个新的模型,这是必要的,以避免 <strong>OverwriteModelError</strong> 问题,该问题发生在模型被导入(因此重新定义)多次时。</p> <ol> <li> <p>创建一个新的 <strong>src/db/models/post.js</strong> 文件,在其中我们首先导入 <strong>server-only</strong> 和 <strong>mongoose</strong> 包:</p> <pre><code class="language-js">import 'server-only' import mongoose, { Schema } from 'mongoose' </code></pre> </li> <li> <p>定义 <strong>postSchema</strong>,它由一个必需的 <strong>title</strong> 和 <strong>author</strong>(引用 <strong>user</strong> 模型)以及可选的 <strong>contents</strong> 组成:</p> <pre><code class="language-js">const postSchema = new Schema(   {     title: { type: String, required: true },     author: { type: Schema.Types.ObjectId, ref: 'user', required: true },     contents: String,   },   { timestamps: true }, ) </code></pre> </li> <li> <p>如果模型尚未创建,我们创建 Mongoose 模型:</p> <pre><code class="language-js">export const Post = mongoose.models.post ?? mongoose.model('post', postSchema) </code></pre> </li> <li> <p>创建一个新的 <strong>src/db/models/index.js</strong> 文件并重新导出模型:</p> <pre><code class="language-js">import 'server-only' export * from './user' export * from './post' </code></pre> <p>我们从这个文件夹重新导出模型,以确保我们可以,例如,通过查询相应的用户来加载一个帖子并解析 <code>author</code>。这需要定义 <code>user</code> 模型,尽管它不是直接使用的。为了避免这些问题,我们简单地从定义所有模型的文件中加载模型。</p> </li> </ol> <p>在定义数据库模型之后,我们可以定义数据层函数,这些函数将提供各种访问数据库的方式。</p> <h2 id="定义数据层函数">定义数据层函数</h2> <p>现在我们已经有了数据库连接和架构,让我们开始定义访问数据库的数据层函数。</p> <h3 id="定义帖子数据层">定义帖子数据层</h3> <p>我们将首先定义帖子数据层。这允许我们访问我们应用中处理帖子的所有相关函数:</p> <ol> <li> <p>创建一个新的 <strong>src/data/</strong> 文件夹。</p> </li> <li> <p>在其中,创建一个新的 <strong>src/data/posts.js</strong> 文件,我们将导入 <strong>server-only</strong> 包和 <strong>Post</strong> 模型:</p> <pre><code class="language-js">import 'server-only' import { Post } from '@/db/models' </code></pre> </li> <li> <p>定义一个 <strong>createPost</strong> 函数,它接受 <strong>userId</strong>、<strong>title</strong> 和 <strong>contents</strong> 并创建一个新的帖子:</p> <pre><code class="language-js">export async function createPost(userId, { title, contents }) {   const post = new Post({ author: userId, title, contents })   return await post.save() } </code></pre> </li> <li> <p>接下来,定义一个 <strong>listAllPosts</strong> 函数,该函数首先从数据库中获取所有帖子,按创建日期降序排序(首先显示最新帖子):</p> <pre><code class="language-js">export async function listAllPosts() {   return await Post.find({})     .sort({ createdAt: 'descending' }) </code></pre> </li> <li> <p>然后,我们必须通过解析 <strong>user</strong> 模型并从中获取 <strong>username</strong> 值来填充 <strong>author</strong> 字段:</p> <pre><code class="language-js">    .populate('author', 'username') </code></pre> <p>在 Mongoose 中,<code>populate</code> 函数类似于 SQL 中的 <code>JOIN</code> 语句:它获取存储在 <code>author</code> 字段中的 ID,然后通过查看 <code>post</code> 架构来确定该 ID 引用了哪个模型。在 <code>post</code> 架构中,我们定义了 <code>author</code> 字段引用 <code>user</code> 架构,因此 Mongoose 将查询 <code>user</code> 模型以获取给定的 ID 并返回一个用户对象。通过提供第二个参数,我们指定我们只想从用户对象(ID 总是会返回)中获取 <code>username</code> 值。这样做是为了避免泄露内部信息,例如用户的(散列的)密码。</p> </li> <li> <p>在填充帖子对象后,我们使用 <strong>.lean()</strong> 将其转换为纯的、可序列化的 JavaScript 对象:</p> <pre><code class="language-js">    .lean() } </code></pre> <p>拥有一个可序列化的对象是必要的,以便能够将数据从 RSC 传递到常规客户端组件,因为所有传递给客户端的数据都需要跨越网络边界,因此需要可序列化。</p> </li> <li> <p>最后,我们必须定义一个 <strong>getPostById</strong> 函数,该函数通过 ID 查找一个单独的帖子,填充 <strong>author</strong> 字段,并使用 <strong>lean()</strong> 将结果转换为纯 JavaScript 对象:</p> <pre><code class="language-js">export async function getPostById(postId) {   return await Post.findById(postId)     .populate('author', 'username')     .lean() } </code></pre> </li> </ol> <h3 id="定义用户数据层">定义用户数据层</h3> <p>我们现在将定义用户数据层。这将涉及创建 JWT 进行身份验证。再次强调,大部分代码将与我们在博客应用中之前实现的内容非常相似。按照以下步骤开始定义用户数据层:</p> <ol> <li> <p>安装 <strong>bcrypt</strong>(用于散列用户密码)和 <strong>jsonwebtoken</strong>(用于处理 JWT):</p> <pre><code class="language-js">$ npm install bcrypt@5.1.1 jsonwebtoken@9.0.2 </code></pre> </li> <li> <p>创建一个新的 <strong>src/data/users.js</strong> 文件,我们将导入 <strong>server-only</strong>、<strong>bcrypt</strong>、<strong>jwt</strong> 和 <strong>User</strong> 模型:</p> <pre><code class="language-js">import 'server-only' import bcrypt from 'bcrypt' import jwt from 'jsonwebtoken' import { User } from '@/db/models' </code></pre> </li> <li> <p>定义一个 <strong>createUser</strong> 函数,其中我们散列给定的密码,然后创建一个新的 <strong>User</strong> 模型实例并将其保存:</p> <pre><code class="language-js">export async function createUser({ username, password }) {   const hashedPassword = await bcrypt.hash(password, 10)   const user = new User({ username, password: hashedPassword })   return await user.save() } </code></pre> </li> <li> <p>接下来,定义一个 <strong>loginUser</strong> 函数,该函数首先尝试找到具有给定用户名的用户,如果没有找到用户则抛出错误:</p> <pre><code class="language-js">export async function loginUser({ username, password }) {   const user = await User.findOne({ username })   if (!user) {     throw new Error('invalid username!')   } </code></pre> </li> </ol> <p>备注</p> <p>根据您的安全需求,您可能希望考虑不要告诉潜在的攻击者存在用户名,而是返回一个通用消息,例如“无效的用户名或密码”。然而,在我们的情况下,假设用户名是公开信息,因为每个用户都是博客的作者,并且他们的用户名与文章一起发布。</p> <ol> <li> <p>然后,使用<strong>bcrypt</strong>将提供的密码与数据库中的哈希密码进行比较,如果密码无效则抛出一个错误:</p> <pre><code class="language-js">  const isPasswordCorrect = await bcrypt.compare(password, user.password)   if (!isPasswordCorrect) {     throw new Error('invalid password!')   } </code></pre> </li> <li> <p>最后,生成、签名并返回一个 JWT:</p> <pre><code class="language-js">  const token = jwt.sign({ sub: user._id }, process.env.JWT_SECRET, {     expiresIn: '24h',   })   return token } </code></pre> </li> <li> <p>现在,我们将定义一个函数从用户 ID 中获取用户信息(目前我们只获取用户名,但以后可以扩展这个功能)。如果用户 ID 不存在,我们抛出一个错误:</p> <pre><code class="language-js">export async function getUserInfoById(userId) {   const user = await User.findById(userId)   if (!user) throw new Error('user not found!')   return { username: user.username } } </code></pre> </li> <li> <p>接下来,定义一个函数从令牌中获取用户 ID,确保在解码 JWT 的同时验证令牌签名,使用<strong>jwt.verify</strong>:</p> <pre><code class="language-js">export function getUserIdByToken(token) {   if (!token) return null   const decodedToken = jwt.verify(token, process.env.JWT_SECRET)   return decodedToken.sub } </code></pre> </li> <li> <p>最后,定义一个函数通过组合<strong>getUserIdByToken</strong>和<strong>getUserInfoById</strong>函数从令牌中获取用户信息:</p> <pre><code class="language-js">export async function getUserInfoByToken(token) {   const userId = getUserIdByToken(token)   if (!userId) return null   const user = await getUserInfoById(userId)   return user } </code></pre> </li> <li> <p>我们仍然需要定义<strong>JWT_SECRET</strong>环境变量,以便我们的代码能够工作。编辑<strong>.env</strong>并添加它,如下所示:</p> <pre><code class="language-js">JWT_SECRET=replace-with-random-secret </code></pre> </li> </ol> <p>注意</p> <p>这是非常基础的 Next.js 身份验证实现。对于大型项目,建议考虑一个完整的身份验证解决方案,如 Auth.js(以前称为 next-auth)、Auth0 或 Supabase。查看 Next.js 文档以获取有关 Next.js 身份验证的更多信息:<a href="https://nextjs.org/docs/app/building-your-application/authentication" target="_blank"><code>nextjs.org/docs/app/building-your-application/authentication</code></a>。</p> <p>现在我们有了数据层来访问数据库,我们可以开始实现 RSCs 和 Server Actions,这些将调用数据层中的函数来访问数据库中的信息并渲染显示这些信息的 React 组件,将我们的静态博客应用转变为一个完全功能的博客。</p> <h1 id="使用-rscs-从数据库中获取数据">使用 RSCs 从数据库中获取数据</h1> <p>正如我们所学的,在使用 Next.js 时,React 组件默认被认为是服务器组件,所以所有页面组件都已经执行并在服务器上渲染。只有当我们需要使用仅客户端函数,如 hooks 或输入字段时,我们才需要通过使用“<code>use client</code>”指令将我们的组件转换为客户端组件。对于所有不需要用户交互的组件,我们可以简单地保持它们作为服务器组件,并且它们将仅作为静态 HTML(编码在 RSC 有效载荷中)渲染和提供,不会在客户端进行激活。对于客户端(浏览器),这些 React 组件似乎根本不存在,因为浏览器只会看到静态 HTML 代码。这种模式大大提高了我们 Web 应用程序的性能,因为客户端不需要加载 JavaScript 来渲染这些组件。它还减少了包的大小,因为需要加载我们的 Web 应用程序的 JavaScript 代码更少。</p> <p>现在,让我们实现 RSCs 以从数据库中获取数据。</p> <h2 id="获取帖子列表">获取帖子列表</h2> <p>我们将首先实现 <code>HomePage</code>,其中我们获取并渲染帖子列表:</p> <ol> <li> <p>编辑 <strong>src/app/page.js</strong> 并导入 <strong>initDatabase</strong> 和 <strong>listAllPosts</strong> 函数:</p> <pre><code class="language-js">import { initDatabase } from '@/db/init' import { listAllPosts } from '@/data/posts' </code></pre> </li> <li> <p>将 <strong>HomePage</strong> 组件转换为 <strong>async</strong> 函数,这允许我们在渲染组件之前等待数据获取:</p> <pre><code class="language-js">export default async function HomePage() { </code></pre> </li> <li> <p><em>替换</em> 样本的 <strong>posts</strong> 数组为以下代码:</p> <pre><code class="language-js">  await initDatabase()   const posts = await listAllPosts() </code></pre> </li> </ol> <h2 id="获取单个帖子">获取单个帖子</h2> <p>现在,我们可以查看帖子列表,接下来让我们继续实现 <code>ViewPostPage</code> 的获取单个帖子的过程。按照以下步骤开始:</p> <ol> <li> <p>编辑 <strong>src/app/posts/[id]/page.js</strong> 并导入 <strong>notFound</strong>、<strong>getPostById</strong> 和 <strong>initDatabase</strong> 函数:</p> <pre><code class="language-js">import { notFound } from 'next/navigation' import { getPostById } from '@/data/posts' import { initDatabase } from '@/db/init' </code></pre> </li> <li> <p>将页面组件转换为 <strong>async</strong> 函数:</p> <pre><code class="language-js">export default async function ViewPostPage({ params }) { </code></pre> </li> <li> <p><em>替换</em> 样本的 <strong>post</strong> 对象为对 <strong>initDatabase</strong> 和 <strong>getPostById</strong> 的调用:</p> <pre><code class="language-js">  await initDatabase()   const post = await getPostById(params.id)   if (!post) notFound() </code></pre> <p>现在,我们需要创建一个 <code>not-found.js</code> 文件来捕获错误并渲染不同的组件。</p> </li> <li> <p>创建一个新的 <strong>src/app/posts/[id]/not-found.js</strong> 文件,其中我们渲染“帖子未找到!”信息,如下所示:</p> <pre><code class="language-js">export default function ViewPostError() {   return <strong>Post not found!</strong> } </code></pre> </li> </ol> <p>提示</p> <p>我们还可以添加一个 <strong>app/not-found.js</strong> 文件来处理整个应用程序中不匹配的 URL。如果用户访问应用程序未定义的路径,该文件中定义的组件将被渲染。</p> <ol> <li> <p>此外,我们还可以创建一个错误组件,用于渲染任何错误,例如无法连接到数据库。创建一个新的 <strong>src/app/posts/[id]/error.js</strong> 文件,其中我们渲染“加载帖子时出错!”信息,如下所示:</p> <pre><code class="language-js">'use client' export default function ViewPostError() {   return <strong>Error while loading the post!</strong> } </code></pre> <p>错误页面需要是客户端组件,因此我们添加了 <code>'use</code> <code>client'</code> 指令。</p> </li> </ol> <p>信息</p> <p>错误页面需要是客户端组件的原因是它们使用了 React <strong>ErrorBoundary</strong> 功能,该功能作为类组件实现(使用 <strong>componentDidCatch</strong>)。React 类组件不能是服务器组件,因此我们需要将错误页面作为客户端组件。</p> <ol> <li> <p>我们仍然需要对 <strong>Post</strong> 组件进行小幅调整,因为 <strong>_id</strong> 现在实际上不再是字符串了;相反,它是一个 <strong>ObjectId</strong> 对象。编辑 <strong>src/components/Post.jsx</strong> 并更改类型,如下所示:</p> <pre><code class="language-js">Post.propTypes = {   _id: PropTypes.object.isRequired, </code></pre> </li> <li> <p>确保 Docker 和 MongoDB 容器正常运行!</p> </li> <li> <p>按照以下步骤运行开发服务器:</p> <pre><code class="language-js">$ npm run dev </code></pre> </li> <li> <p>前往 <strong><a href="http://localhost:3000" target="_blank">http://localhost:3000</a></strong> 并点击列表中的任意帖子;您将看到帖子成功加载。如果帖子不存在(例如,如果您更改了 ID 中的单个数字),将显示“帖子未找到!”信息。如果发生任何其他错误(例如,无效的 ID),将显示“加载帖子时出错!”信息:</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_17_4.jpg" alt="图 17.4 – 显示帖子以及未找到/错误组件" loading="lazy"></p> <p>图 17.4 – 显示帖子以及未找到/错误组件</p> <p>注意</p> <p>如果您的数据库中还没有帖子,您可以通过使用前面章节中的博客应用创建一个新的帖子,或者等待我们在本章末尾使用 Next.js 实现创建帖子功能。</p> <p>在实现用于获取帖子的 RSC(React Server Components)之后,我们的博客应用现在已连接到数据库。然而,目前它只能显示帖子;用户还无法与该应用进行交互。让我们继续通过添加服务器操作(Server Actions)来使我们的博客应用变得交互式。</p> <h1 id="使用服务器操作进行注册登录和创建新帖子">使用服务器操作进行注册、登录和创建新帖子</h1> <p>到目前为止,我们只从服务器上的数据库获取数据并发送给客户端,但为了实现用户交互,我们需要能够从客户端将数据发送回服务器。为了能够做到这一点,React 引入了一种称为服务器操作的模式。</p> <p><code>"use server"</code>指令,然后要么将它们导入到客户端组件中,要么通过 props 将它们传递给客户端组件。虽然常规 JavaScript 函数不能传递给客户端组件(因为它们不可序列化),但服务器操作可以。</p> <p>注意</p> <p>您可以通过在文件开头添加<strong>"use server"</strong>指令来定义一个充满服务器操作的整个文件。这将告诉打包器该文件中的所有函数都是服务器操作;它<strong>不</strong>定义文件内的组件为服务器组件(为了强制在服务器上执行某些操作,请使用如上所述的<strong>server-only</strong>包,而不是使用服务器组件)。然后您可以从这样的文件中导入函数到客户端组件中。</p> <p>在客户端组件中,我们可以使用<code>useFormState</code>钩子,它的签名与<code>useState</code>类似,但允许我们执行服务器操作(在服务器上)并在客户端获取结果。<code>useFormState</code>钩子的签名如下:</p> <pre><code class="language-js">const [state, formAction] = useFormState(fn, initialState) </code></pre> <p>注意</p> <p>在 React 19 版本中,<strong>useFormState</strong>钩子将被重命名为<strong>useActionState</strong>。有关更多信息,请参阅<a href="https://react.dev/reference/react/useActionState" target="_blank"><code>react.dev/reference/react/useActionState</code></a>。</p> <p>如我们所见,我们传递一个函数(服务器操作)和一个初始状态。钩子随后返回当前状态和一个<code>formAction</code>函数。状态最初设置为初始状态,并在调用<code>formAction</code>函数后更新为服务器操作的结果。在服务器端,服务器操作的签名如下:</p> <pre><code class="language-js">function exampleServerAction(previousState, formData) {   "use server"   // …do something… } </code></pre> <p>如我们所见,服务器操作函数接受<code>previousState</code>(最初将从客户端设置为<code>initialState</code>)和一个<code>formData</code>对象(这是一个来自 XMLHttpRequest API 网络标准的常规<code>formData</code>对象)。<code>formData</code>对象包含表单字段中提交的所有信息。这使得我们能够轻松地提交表单以在服务器上执行操作并将结果返回给客户端。</p> <p>现在,让我们开始使用服务器操作来实现我们博客应用中的注册页面。</p> <h2 id="实现注册页面">实现注册页面</h2> <p>用户与博客应用交互需要采取的第一个操作是注册,因此让我们从实现这个功能开始。按照以下步骤开始:</p> <ol> <li> <p>我们首先实现客户端组件。编辑<strong>src/components/Signup.jsx</strong>,将其标记为客户端组件,然后导入<strong>useFormState</strong>钩子和<strong>PropTypes</strong>:</p> <pre><code class="language-js">'use client' import { useFormState } from 'react-dom' import PropTypes from 'prop-types' </code></pre> </li> <li> <p><strong>注册</strong>组件现在需要接受一个<strong>注册操作</strong>,我们将在稍后服务器端定义:</p> <pre><code class="language-js">export function Signup({ signupAction }) { </code></pre> </li> <li> <p>定义一个<strong>useFormState</strong>钩子,它接受一个服务器操作和一个初始状态(在我们的情况下,是一个空对象),并返回当前状态和一个操作:</p> <pre><code class="language-js">  const [state, formAction] = useFormState(signupAction, {}) </code></pre> </li> <li> <p>现在,我们可以在<strong><form></strong>标签中添加<strong>action</strong>,如下所示:</p> <pre><code class="language-js">  return (     <form await formAction() inside an onClick handler function. </code></pre> </li> <li> <p>此外,如果我们从服务器收到<strong>state.error</strong>消息,我们可以在“注册”按钮下方显示一个错误消息:</p> <pre><code class="language-js">      <input type='submit' value='Sign Up' />       {state.error ? <strong> Error signing up: {state.error}</strong> : null}     </form>   ) } </code></pre> </li> <li> <p>我们不要忘记为<strong>注册</strong>组件定义<strong>propTypes</strong>。<strong>注册操作</strong>是一个函数:</p> <pre><code class="language-js">Signup.propTypes = {   signupAction: PropTypes.func.isRequired, } </code></pre> </li> <li> <p>现在,我们可以开始实现实际的服务器操作。编辑<strong>src/app/signup/page.js</strong>,并从<strong>next/navigation</strong>导入<strong>redirect</strong>函数(在成功注册后导航到登录页面),以及<strong>createUser</strong>和<strong>initDatabase</strong>函数:</p> <pre><code class="language-js">import { redirect } from 'next/navigation' import { createUser } from '@/data/users' import { initDatabase } from '@/db/init' import { Signup } from '@/components/Signup' </code></pre> </li> <li> <p>然后,在<strong>注册页面</strong>组件外部,定义一个新的<strong>async</strong>函数,该函数接受前一个状态(在我们的情况下,这是我们定义的初始状态,即空对象,因此我们可以忽略它)和一个<strong>formData</strong>对象:</p> <pre><code class="language-js">async function signupAction(prevState, formData) { </code></pre> </li> <li> <p>我们需要给函数加上<strong>'use server'</strong>指令,将其转换为服务器操作:</p> <pre><code class="language-js">  'use server' </code></pre> </li> <li> <p>然后,我们可以初始化数据库并尝试创建用户:</p> <pre><code class="language-js">  try {     await initDatabase()     await createUser({       username: formData.get('username'),       password: formData.get('password'),     }) </code></pre> <p>如您所见,服务器操作建立在现有的 Web API 之上,并使用<code>FormData</code> API 进行表单提交。我们可以简单地使用<code>name</code>属性调用<code>.get()</code>,它将包含相应输入字段中提供的值。</p> </li> <li> <p>如果有错误,我们返回错误消息(然后将在<strong>注册</strong>客户端组件中显示):</p> <pre><code class="language-js">  } catch (err) {     return { error: err.message }   } </code></pre> </li> <li> <p>否则,如果一切顺利,我们重定向到登录页面:</p> <pre><code class="language-js">  redirect('/login') } </code></pre> </li> <li> <p>在定义服务器操作后,我们可以将其传递给<strong>注册</strong>组件,如下所示:</p> <pre><code class="language-js">export default function SignupPage() {   return <Signup signupAction={signupAction} /> } </code></pre> <p>或者,客户端组件可以直接从文件中导入<code>signupAction</code>函数。只要函数有<code>'use server'</code>指令,它就会在服务器上执行。在这种情况下,我们只需要在这个特定页面上使用该函数,因此将其定义在页面上并传递给组件更有意义。</p> </li> <li> <p>运行开发服务器,如下所示:</p> <pre><code class="language-js">$ npm run dev </code></pre> </li> <li> <p>再次访问<strong><a href="http://localhost:3000/signup" target="_blank">http://localhost:3000/signup</a></strong>并尝试输入用户名和密码。它应该成功并重定向到登录屏幕(变化微妙,但提交按钮从<strong>注册</strong>变为<strong>登录</strong>)。</p> </li> <li> <p>再次访问<strong><a href="http://localhost:3000/signup" target="_blank">http://localhost:3000/signup</a></strong>并尝试输入相同的用户名。你会得到以下错误:</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_17_5.jpg" alt="图 17.5 – 当用户名已存在时显示错误" loading="lazy"></p> <p>图 17.5 – 当用户名已存在时显示错误</p> <p>当然,这个错误信息并不非常友好,所以我们可以做一些工作来改进这里的错误信息。但到目前为止,这已经足够作为一个示例来展示服务器操作是如何工作的。</p> <p>如您所见,RSCs 和服务器操作使实现与数据库交互的功能变得简单。作为额外的奖励,通过<code><form></code>提交的所有服务器操作即使在禁用 JavaScript 的情况下也能正常工作——尝试通过禁用 JavaScript 重复<em>步骤 15</em>和<em>16</em>来试试!</p> <h2 id="实现登录页面和-jwt-处理">实现登录页面和 JWT 处理</h2> <p>现在用户可以注册,我们需要一种方式让他们登录。这也意味着我们需要实现创建和存储 JWT 的功能。现在,由于我们对 Next.js 中的服务器-客户端交互有了更多的控制,我们可以将 JWT 存储在 cookie 中而不是内存中。这意味着用户会话将在他们刷新页面时持续存在。</p> <p>让我们开始实现登录页面和 JWT 处理:</p> <ol> <li> <p>我们首先实现客户端组件。编辑<strong>src/components/Login.jsx</strong>并将其转换为客户端组件:</p> <pre><code class="language-js">'use client' </code></pre> </li> <li> <p>然后,导入<strong>useFormState</strong>钩子和<strong>PropTypes</strong>:</p> <pre><code class="language-js">import { useFormState } from 'react-dom' import PropTypes from 'prop-types' </code></pre> </li> <li> <p>接受<strong>loginAction</strong>作为 props。我们将使用它来定义<strong>useFormState</strong>钩子:</p> <pre><code class="language-js">export function Login({ loginAction }) {   const [state, formAction] = useFormState(loginAction, {}) </code></pre> </li> <li> <p>将从钩子返回的<strong>formAction</strong>传递给<code>**<form>**</code>元素:</p> <pre><code class="language-js">  return (     <form action={formAction}> </code></pre> </li> <li> <p>现在,我们可以在组件末尾显示潜在的错误:</p> <pre><code class="language-js">      <input type='submit' value='Log In' />       {state.error ? <strong> Error logging in: {state.error}</strong> : null}     </form>   ) } </code></pre> </li> <li> <p>最后,定义<strong>propTypes</strong>,如下所示:</p> <pre><code class="language-js">Login.propTypes = {   loginAction: PropTypes.func.isRequired, } </code></pre> </li> <li> <p>现在,我们可以创建<strong>loginAction</strong>服务器操作。编辑<strong>src/app/login/page.js</strong>并从 Next.js 导入<strong>cookies</strong>和<strong>redirect</strong>函数,以及从我们的数据层导入<strong>loginUser</strong>和<strong>initDatabase</strong>函数:</p> <pre><code class="language-js">import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { loginUser } from '@/data/users' import { initDatabase } from '@/db/init' import { Login } from '@/components/Login' </code></pre> </li> <li> <p>在<strong>LoginPage</strong>组件外部定义一个新的<strong>loginAction</strong>,在其中我们尝试使用给定的用户名和密码进行登录:</p> <pre><code class="language-js">async function loginAction(prevState, formData) {   'use server'   let token   try {     await initDatabase()     token = await loginUser({       username: formData.get('username'),       password: formData.get('password'),     }) </code></pre> </li> <li> <p>如果失败,我们返回错误信息:</p> <pre><code class="language-js">  } catch (err) {     return { error: err.message }   } </code></pre> </li> <li> <p>否则,我们设置一个有效期 24 小时的<strong>AUTH_TOKEN</strong>cookie(与创建的 JWT 的有效期相同),并使其<strong>安全</strong>和<strong>httpOnly</strong>:</p> <pre><code class="language-js">  cookies().set({     name: 'AUTH_TOKEN',     value: token,     path: '/',     maxAge: 60 * 60 * 24,     secure: true,     httpOnly: true,   }) </code></pre> </li> </ol> <p>注意</p> <p><strong>httpOnly</strong>属性确保 cookie 不能被客户端 JavaScript 访问,从而减少我们应用中跨站脚本攻击的可能性。<strong>secure</strong>属性确保 cookie 在网站的 HTTPS 版本上设置。为了提高开发体验,这不会应用于 localhost。</p> <ol> <li> <p>在设置 cookie 后,我们重定向到主页:</p> <pre><code class="language-js">  redirect('/') } </code></pre> </li> <li> <p>最后,我们将<strong>loginAction</strong>传递给<strong>Login</strong>组件:</p> <pre><code class="language-js">export default function LoginPage() {   return <Login loginAction={loginAction} /> } </code></pre> </li> <li> <p>前往<strong><a href="http://localhost:3000/login" target="_blank">http://localhost:3000/login</a></strong>并尝试输入一个不存在的用户名;你会得到一个错误。然后,尝试输入你之前注册时使用的相同用户名和密码。它应该可以成功并重定向你到主页。</p> </li> </ol> <h2 id="检查用户是否已登录">检查用户是否已登录</h2> <p>你可能已经注意到,在用户登录后,导航栏没有改变。我们仍然需要检查用户是否已登录,然后相应地调整导航栏。现在让我们来做这件事:</p> <ol> <li> <p>编辑<strong>src/app/layout.js</strong>并从 Next.js 导入<strong>cookies</strong>函数,从我们的数据层导入<strong>getUserInfoByToken</strong>函数:</p> <pre><code class="language-js">import { cookies } from 'next/headers' import { getUserInfoByToken } from '@/data/users' import { Navigation } from '@/components/Navigation' </code></pre> </li> <li> <p>将<strong>RootLayout</strong>转换为<strong>async</strong>函数:</p> <pre><code class="language-js">export default async function RootLayout({ children }) { </code></pre> </li> <li> <p>获取<strong>AUTH_TOKEN</strong>cookie 并将其值传递给<strong>getUserInfoByToken</strong>函数以获取<strong>user</strong>对象,<em>替换</em>我们之前定义的示例<strong>user</strong>对象:</p> <pre><code class="language-js">  const token = cookies().get('AUTH_TOKEN')   const user = await getUserInfoByToken(token?.value) </code></pre> </li> <li> <p>如果你之前还打开了主页,它应该会自动热重载并显示你的用户名和注销按钮。</p> </li> </ol> <p>我们已经将<code>user?.username</code>传递给<code>Navigation</code>组件,所以这就完成了!</p> <h2 id="实现注销">实现注销</h2> <p>现在我们可以根据用户是否登录显示不同的导航栏,我们终于可以看到注销按钮了。然而,它现在还不工作。我们现在将实现注销按钮:</p> <ol> <li> <p>编辑<strong>src/app/layout.js</strong>并在<strong>RootLayout</strong>组件外部定义一个<strong>logoutAction</strong>服务器操作:</p> <pre><code class="language-js">async function logoutAction() {   'use server' </code></pre> </li> <li> <p>在这个操作中,我们简单地删除了<strong>AUTH_TOKEN</strong>cookie:</p> <pre><code class="language-js">  cookies().delete('AUTH_TOKEN') } </code></pre> </li> <li> <p>按如下方式将<strong>logoutAction</strong>传递给<strong>Navigation</strong>组件:</p> <pre><code class="language-js">          <Navigation             username={user?.username}             logoutAction={logoutAction}           /> </code></pre> </li> <li> <p>编辑<strong>src/components/Navigation.jsx</strong>并在<strong>UserBar</strong>和注销表单中添加<strong>logoutAction</strong>:</p> <pre><code class="language-js">export function UserBar({ username, logoutAction }) {   return (     <form action={logoutAction}> </code></pre> </li> <li> <p>将操作添加到<strong>UserBar</strong>组件的<strong>propTypes</strong>中,如下所示:</p> <pre><code class="language-js">UserBar.propTypes = {   username: PropTypes.string.isRequired,   logoutAction: PropTypes.func.isRequired, } </code></pre> </li> <li> <p>然后,将<strong>logoutAction</strong>作为 props 添加到<strong>Navigation</strong>组件,并传递给<strong>UserBar</strong>组件:</p> <pre><code class="language-js">export function Navigation({ username, logoutAction }) {   return (     <>       <Link href='/'>Home</Link>       {username ? (         <UserBar           username={username}           logoutAction={logoutAction}         />       ) : (         <LoginSignupLinks />       )}     </>   ) } </code></pre> </li> <li> <p>最后,更改<strong>Navigation</strong>组件的<strong>propTypes</strong>,如下所示:</p> <pre><code class="language-js">Navigation.propTypes = {   username: PropTypes.string,   logoutAction: PropTypes.func.isRequired, } </code></pre> </li> <li> <p>点击<strong>Logout</strong>按钮以看到导航栏变回显示<strong>登录</strong>和<strong>注册</strong>链接。</p> </li> </ol> <p>现在,我们的用户可以最终成功登录和注销。让我们继续实现帖子创建。</p> <h2 id="实现帖子创建">实现帖子创建</h2> <p>我们博客应用中缺少的最后一个功能是帖子创建。我们可以使用服务器操作和 JWT 来验证用户身份,并允许他们创建帖子。按照以下步骤实现帖子创建:</p> <ol> <li> <p>这次,我们首先实现服务器操作。编辑<strong>src/app/create/page.js</strong>并导入<strong>cookies</strong>、<strong>redirect</strong>、<strong>createPost</strong>、<strong>getUserIdByToken</strong>和<strong>initDatabase</strong>函数:</p> <pre><code class="language-js">import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { createPost } from '@/data/posts' import { getUserIdByToken } from '@/data/users' import { initDatabase } from '@/db/init' import { CreatePost } from '@/components/CreatePost' </code></pre> </li> <li> <p>在<strong>CreatePostPage</strong>组件内部,从 cookie 中获取令牌:</p> <pre><code class="language-js">export default function CreatePostPage() {   const token = cookies().get('AUTH_TOKEN') </code></pre> </li> <li> <p>仍然在<strong>CreatePostPage</strong>组件内部,定义一个服务器操作:</p> <pre><code class="language-js">  async function createPostAction(formData) {     'use server' </code></pre> <p>这次我们不会使用<code>useFormState</code>钩子,因为我们不需要在客户端处理操作的 state 或 result。因此,服务器操作没有<code>(prevState, formData)</code>签名,而是有<code>(``formData)</code>签名。</p> </li> <li> <p>在服务器操作中,我们从令牌中获取<strong>userId</strong>值,然后初始化数据库连接并创建一个新的帖子:</p> <pre><code class="language-js">    const userId = getUserIdByToken(token?.value)     await initDatabase()     const post = await createPost(userId, {       title: formData.get('title'),       contents: formData.get('contents'),     }) </code></pre> </li> <li> <p>最后,我们将重定向到新创建的帖子的<strong>ViewPost</strong>页面:</p> <pre><code class="language-js">    redirect(`/posts/${post._id}`)   } </code></pre> </li> <li> <p>如果用户未登录,我们现在可以显示一个错误消息:</p> <pre><code class="language-js">  if (!token?.value) {     return <strong>You need to be logged in to create posts!</strong>   } </code></pre> </li> <li> <p>否则,我们渲染<strong>CreatePost</strong>组件,并将<strong>createPostAction</strong>传递给它:</p> <pre><code class="language-js">  return <CreatePost createPostAction={createPostAction} /> } </code></pre> </li> <li> <p>现在,我们可以调整<strong>CreatePost</strong>组件。这次我们<em>不需要</em>将其转换为客户端组件,因为我们不会使用<strong>useFormState</strong>钩子。编辑<strong>src/components/CreatePost.jsx</strong>并导入<strong>PropTypes</strong>:</p> <pre><code class="language-js">import PropTypes from 'prop-types' </code></pre> </li> <li> <p>然后,将<strong>createPostAction</strong>作为属性传递给表单元素:</p> <pre><code class="language-js">export function CreatePost({ createPostAction }) {   return (     <form action={createPostAction}> </code></pre> </li> <li> <p>最后,定义<strong>propTypes</strong>,如下所示:</p> <pre><code class="language-js">CreatePost.propTypes = {   createPostAction: PropTypes.func.isRequired, } </code></pre> </li> <li> <p>前往<strong><a href="http://localhost:3000" target="_blank">http://localhost:3000</a></strong>,再次登录,然后点击<strong>创建帖子</strong>链接。输入标题和一些内容,然后点击<strong>创建</strong>按钮;你应该会被重定向到新创建的博客帖子的<strong>查看帖子</strong>页面!</p> </li> </ol> <h1 id="摘要-14">摘要</h1> <p>在本章中,我们学习了 RSCs(React Server Components),为什么引入它们,它们的优点是什么,以及它们如何融入我们的全栈架构。然后,我们通过在应用程序中引入数据层来安全地实现 RSCs。之后,我们使用 RSCs 从数据库中获取数据并渲染组件。最后,我们学习了服务器操作,并为我们博客应用程序添加了交互功能。现在,我们的博客应用程序再次完全功能正常!</p> <p>在下一章,<em>第十八章</em>,<em>高级 Next.js 概念和优化</em>,我们将深入探讨 Next.js 的工作原理以及在使用它时如何进一步优化我们的应用程序。我们将学习关于缓存、图像和字体优化,以及如何定义 SEO 优化的元数据。</p> <h1 id="第十八章高级-nextjs-概念和优化">第十八章:高级 Next.js 概念和优化</h1> <p>现在我们已经了解了 Next.js 和 <strong>React 服务器组件</strong>(<strong>RSCs</strong>)的基本功能,让我们更深入地探讨 Next.js 框架。在本章中,我们将学习 Next.js 中的缓存工作原理以及如何利用它来优化我们的应用程序。我们还将学习如何在 Next.js 中实现 API 路由。然后,我们将学习如何通过添加元数据来优化 Next.js 应用程序以适应搜索引擎和社交媒体。最后,我们将学习如何在 Next.js 中最优地加载图片和字体。</p> <p>在本章中,我们将涵盖以下主要主题:</p> <ul> <li> <p>在 Next.js 中定义 API 路由</p> </li> <li> <p>Next.js 中的缓存</p> </li> <li> <p><strong>搜索引擎优化</strong>(<strong>SEO</strong>)与 Next.js</p> </li> <li> <p>Next.js 中优化的图片和字体加载</p> </li> </ul> <h1 id="技术要求-17">技术要求</h1> <p>在我们开始之前,请安装来自 <em>第一章</em> <em>为全栈开发做准备</em> 和 <em>第二章</em> <em>了解 Node.js 和 MongoDB</em> 的所有要求。</p> <p>那些章节中列出的版本是本书中使用的版本。虽然安装较新版本可能不会有问题,但请注意,某些步骤可能会有所不同。如果你在使用本书中提供的代码和步骤时遇到问题,请尝试使用 <em>第一章</em> 和 <em>2</em> 中提到的版本。</p> <p>你可以在 GitHub 上找到本章的代码:<a href="https://github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch18" target="_blank"><code>github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch18</code></a>。</p> <p>本章的 CiA 视频可在以下网址找到:<a href="https://youtu.be/jzCRoJPGoG0" target="_blank"><code>youtu.be/jzCRoJPGoG0</code></a>。</p> <h1 id="在-nextjs-中定义-api-路由">在 Next.js 中定义 API 路由</h1> <p>在上一章中,我们使用 RSCs 通过数据层访问我们的数据库;为此不需要 API 路由!然而,有时公开外部 API 仍然是有意义的。例如,我们可能希望允许第三方应用程序查询博客文章。幸运的是,Next.js 也提供了一个名为路由处理器(Route Handlers)的功能来定义 API 路由。</p> <p>路由处理器也定义在 <code>src/app/</code> 目录下,但是在一个 <code>route.js</code> 文件中而不是 <code>page.js</code> 文件中(一个路径只能是路由或页面,所以文件夹中只能放置这些文件中的一个)。我们不需要导出一个页面组件,而是需要导出处理各种类型请求的函数。例如,要处理 <code>GET</code> 请求,我们必须定义并导出以下函数:</p> <pre><code class="language-js">export async function GET() { </code></pre> <p>Next.js 支持以下 HTTP 方法用于路由处理器:<code>GET</code>、<code>POST</code>、<code>PUT</code>、<code>PATCH</code>、<code>DELETE</code>、<code>HEAD</code> 和 <code>OPTIONS</code>。对于不支持的方法,Next.js 将返回 <code>405 Method Not Allowed</code> 响应。</p> <p>Next.js 支持原生的<code>Request</code>(<a href="https://developer.mozilla.org/en-US/docs/Web/API/Request" target="_blank"><code>developer.mozilla.org/en-US/docs/Web/API/Request</code></a>)和<code>Response</code>(<a href="https://developer.mozilla.org/en-US/docs/Web/API/Response" target="_blank"><code>developer.mozilla.org/en-US/docs/Web/API/Response</code></a>)网络 API,但将它们扩展为<code>NextRequest</code>和<code>NextResponse</code> API,这使得处理 cookie 和头部信息变得更容易。我们在上一章中使用了 Next.js 的<code>cookies()</code>函数来轻松创建、获取和删除 JWT 的 cookie。<code>headers()</code>函数使得从请求中获取头部信息变得容易。这些函数可以在 RSCs 和路由处理器中以相同的方式使用。</p> <h2 id="为列出博客文章创建-api-路由">为列出博客文章创建 API 路由</h2> <p>让我们先定义一个用于列出博客文章的 API 路由:</p> <ol> <li> <p>按照以下步骤将现有的<strong>ch17</strong>文件夹复制到新的<strong>ch18</strong>文件夹:</p> <pre><code class="language-js">$ cp -R ch17 ch18 </code></pre> </li> <li> <p>在 VS Code 中打开<strong>ch18</strong>文件夹。</p> </li> <li> <p>为了使 API 路由更容易与我们的应用页面区分开来,创建一个新的<strong>src/app/api/</strong>文件夹。</p> </li> <li> <p>在<strong>src/app/api/</strong>文件夹内,创建一个新的<strong>src/app/api/v1/</strong>文件夹,以确保我们的 API 在将来可能对 API 进行更改时进行了版本控制。</p> </li> <li> <p>接下来,为<strong>/****api/v1/posts</strong>路由创建一个<strong>src/app/api/v1/posts/</strong>文件夹。</p> </li> <li> <p>创建一个新的<strong>src/app/api/posts/route.js</strong>文件,其中我们从数据层导入<strong>initDatabase</strong>函数和<strong>listAllPosts</strong>函数:</p> <pre><code class="language-js">import { initDatabase } from '@/db/init' import { listAllPosts } from '@/data/posts' </code></pre> </li> <li> <p>然后,定义并导出一个<strong>GET</strong>函数。这个函数将处理对<strong>/****api/v1/posts</strong>路由的 HTTP GET 请求:</p> <pre><code class="language-js">export async function GET() { </code></pre> </li> <li> <p>在其中,我们必须初始化数据库并获取所有文章的列表:</p> <pre><code class="language-js">  await initDatabase()   const posts = await listAllPosts() </code></pre> </li> <li> <p>使用<strong>Response</strong>网络 API 生成 JSON 响应:</p> <pre><code class="language-js">  return Response.json({ posts }) } </code></pre> </li> <li> <p>确保 Docker 和 MongoDB 容器运行正常!</p> </li> <li> <p>按照以下步骤启动 Next.js 应用:</p> <pre><code class="language-js">$ npm run dev </code></pre> </li> <li> <p>现在,前往<strong><a href="http://localhost:3000/api/v1/posts" target="_blank">http://localhost:3000/api/v1/posts</a></strong>查看返回的 JSON 格式的文章,如下所示:</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_18_1.jpg" alt="图 18.1 – 由 Next.js 路由处理器生成的 JSON 响应" loading="lazy"></p> <p>图 18.1 – 由 Next.js 路由处理器生成的 JSON 响应</p> <p>现在,第三方应用也可以通过我们的 API 获取文章!让我们继续学习更多关于 Next.js 中的缓存知识。</p> <h1 id="nextjs-中的缓存">Next.js 中的缓存</h1> <p>到目前为止,我们一直都在使用 Next.js 的 dev 模式。在 dev 模式下,Next.js 所做的大多数缓存都被关闭,以便我们能够使用热重载和始终更新的数据来开发我们的应用。然而,一旦我们切换到生产模式,静态渲染和缓存默认开启。静态渲染意味着如果一个页面只包含静态组件(例如“关于我们”或“版权声明”页面,这些页面只包含静态内容),它将被静态渲染并作为 HTML 或作为静态文本/JSON 为路由提供服务。此外,Next.js 会尽可能缓存数据和服务器端渲染的组件,以保持应用性能。</p> <p>Next.js 有四种主要的缓存类型:</p> <ul> <li> <p><strong>数据缓存</strong>:用于在用户请求和部署之间存储数据的服务器端缓存。这是持久的,但可以进行验证。</p> </li> <li> <p><strong>请求记忆化</strong>:如果函数在单个请求中多次调用,则为函数的返回值提供服务器端缓存。</p> </li> <li> <p><strong>完整路由缓存</strong>:Next.js 路由的服务器端缓存。此缓存是持久的,但可以进行验证。</p> </li> <li> <p><strong>路由缓存</strong>:一种客户端缓存,用于存储路由以减少导航时的服务器请求,适用于单个用户会话或基于时间的。</p> </li> </ul> <p>前两种缓存类型(数据缓存和请求记忆化)主要适用于在服务器端使用 <code>fetch()</code> 函数,例如从第三方 API 获取数据。然而,最近,也可以通过使用 <code>unstable_cache()</code> 函数将这些两种类型的缓存应用于任何函数。尽管这个名字听起来不稳定,但这个函数已经可以在生产环境中安全使用。它之所以被称为“不稳定”,是因为当发布新的 Next.js 版本时,API 可能会改变并需要代码更改。有关更多信息,请参阅<a href="https://nextjs.org/docs/app/api-reference/functions/unstable_cache" target="_blank"><code>nextjs.org/docs/app/api-reference/functions/unstable_cache</code></a>。</p> <p>注意</p> <p>或者,可以使用 React 的 <strong>cache()</strong> 函数来记忆化函数的返回值,但 Next.js 的 <strong>unstable_cache()</strong> 函数更灵活,允许我们通过路径或标签动态重新验证缓存。我们将在本节的后面部分学习更多关于缓存重新验证的内容。</p> <p>完整路由缓存是一个额外的缓存,确保当数据没有变化时,我们甚至不需要在服务器端重新渲染页面,这样 Next.js 可以直接返回预渲染的静态 HTML 和 RSC 有效负载。然而,验证数据缓存也会使相应的完整路由缓存失效并触发重新渲染。</p> <p>路由缓存是一种客户端缓存,主要用于用户在页面之间导航时,允许我们立即显示他们已经访问过的页面,而无需再次从服务器获取。</p> <p>此外,如果 Next.js 检测到某个页面或路由只包含静态内容,它将预渲染并存储为静态内容。静态内容不能再进行验证,因此我们需要小心并确保我们应用中的所有动态内容都被 Next.js 视为“动态”的,而不是意外地被检测为“静态”内容。</p> <p>注意</p> <p>在这本书中,我们称这个过程为 <strong>静态渲染</strong>。然而,在其他资源中,它也可能被称为“自动静态优化”或“静态站点生成”。</p> <p>在以下情况下,Next.js 将退出静态渲染并考虑页面或路由为动态:</p> <ul> <li> <p>当使用动态函数,如 <strong>cookies()</strong>、<strong>headers()</strong> 或 <strong>searchParams</strong></p> </li> <li> <p>当设置 <strong>export const dynamic = 'force-dynamic'</strong> 或 <strong>export const revalidate = 0</strong></p> </li> <li> <p>当路由处理器处理非 GET 请求时</p> </li> </ul> <p>想要更深入地了解不同类型的缓存信息,请查看 Next.js 关于缓存的文档:<a href="https://nextjs.org/docs/app/building-your-application/caching" target="_blank"><code>nextjs.org/docs/app/building-your-application/caching</code></a>。</p> <p>现在,让我们通过查看我们的路由在生产构建中的应用行为来探索静态渲染在实际中的工作方式。</p> <h2 id="探索-api-路由中的静态渲染">探索 API 路由中的静态渲染</h2> <p>在本章中,我们实现了一个用于获取博客文章的路由处理器。现在,让我们探索这个路由在开发和生产模式下的行为:</p> <ol> <li> <p>编辑<strong>src/app/api/v1/posts/route.js</strong>,并在响应中添加一个<strong>currentTime</strong>值,使用<strong>Date.now()</strong>,如下所示:</p> <pre><code class="language-js">  return Response.json({ posts, currentTime: Date.now() }) </code></pre> </li> <li> <p>在<strong><a href="http://localhost:3000/api/v1/posts" target="_blank">http://localhost:3000/api/v1/posts</a></strong>上刷新页面几次;你会看到<strong>currentTime</strong>总是最新的时间戳。</p> </li> <li> <p>使用<em>Ctrl</em> + <em>C</em>退出 Next.js 开发服务器。</p> </li> <li> <p>按照以下步骤构建 Next.js 应用以用于生产并启动它:</p> <pre><code class="language-js">$ npm run build $ npm start </code></pre> </li> <li> <p>在<strong><a href="http://localhost:3000/api/v1/posts" target="_blank">http://localhost:3000/api/v1/posts</a></strong>上刷新页面几次。现在,<strong>currentTime</strong>一点都没有变化!即使我们重启 Next.js 服务器,<strong>currentTime</strong>仍然不会改变。<strong>GET /api/v1/posts</strong>路由的响应在构建时是静态渲染的。</p> </li> </ol> <p>对于路由和页面,静态渲染的工作方式相似,因此页面也将默认进行静态渲染。这意味着 RSC(React Server Components)本身不需要服务器;它们也可以在构建时运行。如果我们想要有动态的页面/路由,我们才需要一个 Node.js 服务器。这意味着我们可以在 Next.js 中创建一个博客或网站,并导出一个静态包,这样我们就可以将其托管在简单的 Web 服务器上。</p> <p>注意</p> <p>通过在<strong>next.config.js</strong>文件中指定<strong>output: 'export'</strong>选项,可以将 Next.js 应用导出为静态包。</p> <p>有趣的是,如果我们创建一个新的博客文章,我们的主页<em>确实</em>会更新。然而,这种情况只因为<code>RootLayout</code>使用了<code>cookies()</code>来检查用户是否登录,使得我们博客应用上的所有页面都是动态的(因此不是静态渲染)。这也可以通过查看<code>npm run build</code>的输出看到:</p> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_18_2.jpg" alt="图 18.2 – 在构建输出中查看哪些路由是静态和动态渲染的" loading="lazy"></p> <p>图 18.2 – 在构建输出中查看哪些路由是静态和动态渲染的</p> <p>如<em>图 18**.2</em>所示,<code>/api/v1/posts</code>路由是“作为静态内容预渲染”,而所有其他路由则是“使用 Node.js 按需服务器渲染。”</p> <p>注意</p> <p>如果我们想在博客中静态渲染一些页面,我们必须确保用户栏在这些页面上不可见。例如,我们可以为所有带有用户栏的页面创建一个 <strong>路由组</strong> (<a href="https://nextjs.org/docs/app/building-your-application/routing/route-groups" target="_blank"><code>nextjs.org/docs/app/building-your-application/routing/route-groups</code></a>),并使用一个包含用户栏的单独布局。然后,我们可以从根布局中移除用户栏。这样,我们就可以创建一个静态渲染的关于页面,同时保持博客的其他部分动态。</p> <p>正如我们所见,在 Next.js 中,页面和路由默认是静态渲染的(如果可能)。然而,在我们的 API 路由的情况下,这并不是我们想要的!我们希望能够从 API 动态获取帖子。当我们刚开始用 Next.js 开发应用程序时,静态渲染和缓存可能会让人困惑,但它成为了一个强大的工具,可以帮助我们优化应用程序。</p> <p>现在,让我们学习如何正确处理缓存,以便在需要时使我们的页面和路由动态化,同时在可能的情况下保持它们被缓存。</p> <h2 id="使路由动态化">使路由动态化</h2> <p>要使路由动态化,我们需要在它上面设置 <code>export const dynamic = 'force-dynamic'</code> 标志。按照以下步骤操作:</p> <ol> <li> <p>编辑 <strong>src/app/api/v1/posts/route.js</strong> 并添加以下代码:</p> <pre><code class="language-js">export const dynamic = 'force-dynamic' </code></pre> </li> <li> <p>退出当前运行的 Next.js 服务器。</p> </li> <li> <p>按照以下步骤构建 Next.js 应用程序以进行生产并启动它:</p> <pre><code class="language-js">$ npm run build $ npm start </code></pre> </li> <li> <p>在 <strong><a href="http://localhost:3000/api/v1/posts" target="_blank">http://localhost:3000/api/v1/posts</a></strong> 上刷新页面几次。现在,API 路由的行为与开发服务器上的行为相同!</p> </li> </ol> <p>不幸的是,我们现在已经完全禁用了缓存,因此我们也没有使用缓存带来的任何好处。接下来,我们将学习如何为特定函数打开缓存。</p> <h2 id="在数据层中缓存函数">在数据层中缓存函数</h2> <p>要从我们的数据层缓存函数,我们可以使用 Next.js 的 <code>unstable_cache()</code> 函数。<code>unstable_cache(fetchData, keyParts, options)</code> 函数接受三个参数:</p> <ul> <li> <p><strong>fetchData</strong>: 第一个参数是要调用的函数。该函数也可以有参数。</p> </li> <li> <p><strong>keyParts</strong>: 第二个参数是一个唯一键的数组,用于在缓存中标识函数。传递给第一个参数中函数的参数也将自动添加到这个数组中。</p> </li> <li> <p><strong>options</strong>: 第三个参数是一个包含缓存选项的对象,其中我们可以指定 <strong>标签</strong> 以在以后重新验证缓存,以及一个 <strong>重新验证</strong> 超时,在经过一定秒数后自动重新验证缓存。</p> </li> </ul> <p>现在,让我们为所有合适的函数启用这个缓存。按照以下步骤开始:</p> <ol> <li> <p>编辑 <strong>src/data/posts.js</strong> 并导入 <strong>unstable_cache()</strong> 函数,将其别名为 <strong>cache()</strong>:</p> <pre><code class="language-js">import { unstable_cache as cache } from 'next/cache' </code></pre> </li> <li> <p>将 <strong>listAllPosts</strong> 函数用 <strong>cache()</strong> 包装,如下所示:</p> <pre><code class="language-js">export const listAllPosts = cache(   async function listAllPosts() {     return await Post.find({})       .sort({ createdAt: 'descending' })       .populate('author', 'username')       .lean()   },   ['posts', 'listAllPosts'],   { tags: ['posts'] }, posts) and the function name (listAllPosts) to uniquely identify the function in our data layer. Additionally, we added a posts tag, which we are going to use later to revalidate the cache when new posts are created. </code></pre> </li> <li> <p>接下来,包装 <strong>getPostById</strong> 函数:</p> <pre><code class="language-js">export const getPostById = cache(   async function getPostById(postId) {     return await Post.findById(postId).populate('author', 'username').lean()   },   ['posts', 'getPostById'], ) </code></pre> </li> <li> <p>你可能会注意到,在获取帖子时现在出现了错误,因为 MongoDB 中的<strong>ObjectId</strong>被缓存序列化为字符串。编辑<strong>src/components/Post.jsx</strong>并调整<strong>propType</strong>,如下所示:</p> <pre><code class="language-js">Post.propTypes = {   _id: PropTypes.string.isRequired, </code></pre> </li> <li> <p>编辑<strong>src/data/users.js</strong>并在其中导入<strong>unstable_cache</strong>:</p> <pre><code class="language-js">import { unstable_cache as cache } from 'next/cache' </code></pre> </li> <li> <p>包装<strong>getUserInfoById</strong>函数:</p> <pre><code class="language-js">export const getUserInfoById = cache(   async function getUserInfoById(userId) {     const user = await User.findById(userId)     if (!user) throw new Error('user not found!')     return { username: user.username }   },   ['users', 'getUserInfoById'], ) </code></pre> </li> <li> <p>停止当前运行的 Next.js 服务器。</p> </li> <li> <p>在生产环境中重新构建并启动应用。你会注意到在创建新帖子后,它不再更新主页(或 API 路由)了:</p> <pre><code class="language-js">$ npm run build $ npm start </code></pre> <p>那是因为我们的帖子现在被缓存了!</p> </li> <li> <p>这个缓存即使在开发模式下也能工作。按照以下步骤停止 Next.js 服务器并重新启动:</p> <pre><code class="language-js">$ npm run dev </code></pre> </li> <li> <p>创建一个新的帖子;你会看到主页和 API 路由列表中都没有新创建的帖子。</p> </li> </ol> <p>现在缓存已经配置好了,让我们学习如何处理缓存重新验证(导致缓存中的数据更新)。</p> <h2 id="通过-server-actions-重新验证缓存">通过 Server Actions 重新验证缓存</h2> <p>处理过时数据的最佳方式是在新数据到来时重新验证缓存,例如通过 Server Actions。为此,我们有两种选择:</p> <ul> <li> <p>使用<strong>revalidatePath</strong>函数在特定路径上重新验证所有路由段</p> </li> <li> <p>使用<strong>revalidateTag</strong>函数通过特定的标签(从而可能重新验证多个路径)进行重新验证</p> </li> </ul> <p>重新验证意味着下次从缓存的函数请求数据时,该函数将被调用,并将返回新数据并将其缓存(而不是返回之前缓存的旧数据)。这两个函数都会重新验证数据缓存,因此也会重新验证完整的路由缓存和客户端路由缓存。</p> <p>按照以下步骤在创建新帖子后调用<code>revalidateTag</code>函数:</p> <ol> <li> <p>编辑<strong>src/app/create/page.js</strong>并导入<strong>revalidateTag</strong>函数:</p> <pre><code class="language-js">import { revalidateTag } from 'next/cache' </code></pre> </li> <li> <p>在<strong>createPostAction</strong>内部,在创建新帖子后,对<strong>posts</strong>标签调用<strong>revalidateTag</strong>函数:</p> <pre><code class="language-js">  async function createPostAction(formData) {     'use server'     const userId = getUserIdByToken(token?.value)     await initDatabase()     const post = await createPost(userId, {       title: formData.get('title'),       contents: formData.get('contents'),     })     revalidateTag('posts')     redirect(`/posts/${post._id}`)   } </code></pre> </li> <li> <p>现在,创建一个新的帖子并转到主页。你会看到新创建的帖子出现在列表中!API 路由现在也会显示新创建的帖子。</p> </li> </ol> <p>当数据通过 Server Actions 更改时重新验证缓存是更新缓存的最直接方式。然而,有时我们会从第三方 API 获取数据,在这种情况下无法进行重新验证。我们现在将探讨这种情况。</p> <h2 id="通过-webhook-重新验证缓存">通过 Webhook 重新验证缓存</h2> <p>如果数据来自第三方源,我们可以通过 Webhook 重新验证缓存。Webhooks 是可以用作回调的 API。例如,当数据发生变化时,第三方源会调用我们的 API 端点,让我们知道我们需要重新获取数据。</p> <h3 id="集成第三方-api">集成第三方 API</h3> <p>在我们开始实现 Webhook 之前,让我们将第三方 API 集成到我们的应用中。在这个例子中,我们将使用 WorldTimeAPI (<a href="https://worldtimeapi.org/" target="_blank"><code>worldtimeapi.org/</code></a>),但你可以自由选择任何你喜欢的 API。</p> <p>让我们开始实现一个从第三方 API 获取数据的页面:</p> <ol> <li> <p>在 <strong>src/app/time/</strong> 文件夹中创建一个新的文件夹。在其内部,创建一个新的 <strong>src/app/time/page.js</strong> 文件。</p> </li> <li> <p>编辑 <strong>src/app/time/page.js</strong> 并定义一个异步页面组件:</p> <pre><code class="language-js">export default async function TimePage() { </code></pre> </li> <li> <p>在组件内部,从 WorldTimeAPI 获取当前时间并将响应解析为 JSON:</p> <pre><code class="language-js">  const timeRequest = await fetch('https://worldtimeapi.org/api/timezone/UTC')   const time = await timeRequest.json() </code></pre> </li> <li> <p>渲染当前时间戳:</p> <pre><code class="language-js">  return <div>Current timestamp: {time?.datetime}</div> } </code></pre> </li> <li> <p>如果你通过浏览器访问 <strong><a href="http://localhost:3000/time" target="_blank">http://localhost:3000/time</a></strong> 页面,你会看到它显示了当前时间。然而,当刷新时,时间永远不会更新。这是因为使用 <strong>fetch</strong> 的请求默认被缓存,类似于我们在数据层函数中添加 <strong>unstable_cache()</strong> 后发生的情况。</p> </li> </ol> <h3 id="实现钩子">实现钩子</h3> <p>现在,让我们在我们的应用程序中创建一个 Webhook API 端点,当被调用时,重新验证第三方数据的缓存:</p> <ol> <li> <p>在 <strong>src/app/api/v1/webhook/</strong> 文件夹中创建一个新的文件夹。在其内部,创建一个新的 <strong>src/app/api/v1/webhook/route.js</strong> 文件。</p> </li> <li> <p>编辑 <strong>src/app/api/v1/webhook/route.js</strong> 并导入 <strong>revalidatePath</strong> 函数:</p> <pre><code class="language-js">import { revalidatePath } from 'next/cache' </code></pre> </li> <li> <p>现在,定义一个新的 <strong>GET</strong> 路由处理器,它在 <strong>/time</strong> 页面上调用 <strong>revalidatePath</strong>,然后返回一个表示成功的响应:</p> <pre><code class="language-js">export async function GET() {   revalidatePath('/time')   return Response.json({ ok: true }) } export const dynamic = 'force-dynamic' </code></pre> <p>通常,Webhooks 被定义为 <code>POST</code> 路由处理器(因为它们会影响应用程序的状态),但为了简化通过在浏览器中访问页面来触发 Webhook,我们将其定义为 <code>GET</code> 路由处理器。<code>POST</code> 路由将放弃静态渲染,但 <code>GET</code> 路由不会,因此我们需要指定 <code>force-dynamic</code>。</p> </li> <li> <p>在浏览器中访问 <strong><a href="http://localhost:3000/api/v1/webhook" target="_blank">http://localhost:3000/api/v1/webhook</a></strong>,然后再次访问 <strong><a href="http://localhost:3000/time" target="_blank">http://localhost:3000/time</a></strong>;你应该看到时间已经更新了!在现实世界中,我们会将我们的 Webhook URL 添加到提供 API 的第三方网站界面中。</p> </li> </ol> <p>注意</p> <p>或者,我们可以在请求中添加一个标签,通过在 <strong>fetch()</strong> 函数中传递 <strong>next.tags</strong> 选项,如下所示:<strong>fetch('<a href="https://worldtimeapi.org/api/timezone/UTC" target="_blank">https://worldtimeapi.org/api/timezone/UTC</a>', { next: { tags: ['time'] } })</strong>。然后,我们可以通过调用 <strong>revalidateTag('time')</strong> 来重新验证缓存。</p> <p>如我们所见,使用 Webhooks 重新验证缓存效果很好。然而,有时我们甚至无法向第三方 API 添加 Webhook。让我们探讨当我们无法控制第三方 API 时应该做什么。</p> <h2 id="定期重新验证缓存">定期重新验证缓存</h2> <p>如果我们对第三方数据源完全没有控制权,我们可以告诉 Next.js 定期重新验证缓存。现在让我们设置一下:</p> <ol> <li> <p>编辑 <strong>src/app/time/page.js</strong> 并调整 <strong>fetch()</strong> 函数,向其中添加 <strong>next.revalidate</strong> 选项:</p> <pre><code class="language-js">  const timeRequest = await fetch('https://worldtimeapi.org/api/timezone/UTC', {     next: { revalidate: 10 },   }) </code></pre> <p>在这种情况下,我们告诉 Next.js 在下次请求 API 时重新验证数据缓存,如果自上次请求以来至少过去了 10 秒。</p> </li> </ol> <p>注意</p> <p>使用 <strong>unstable_cache()</strong>,我们可以在第三个参数中传递 <strong>revalidate</strong> 选项。对于路由和页面,我们可以指定 <strong>export const revalidate = 10</strong>,这将重新验证相应的路由/页面。</p> <ol> <li>在浏览器中刷新 <strong><a href="http://localhost:3000/time" target="_blank">http://localhost:3000/time</a></strong> 页面。你会看到时间更新。再次刷新页面;时间将不会再次更新。如果你在至少 10 秒后刷新,时间将再次更新。</li> </ol> <p>现在,我们已经了解了定期重新验证缓存的方法,让我们学习如何退出缓存。</p> <h2 id="退出缓存">退出缓存</h2> <p>有时,你可能希望完全退出某些请求的缓存。为此,将以下选项传递给 <code>fetch</code> 函数:</p> <pre><code class="language-js">fetch('<URL>', export const dynamic = 'force-dynamic' to opt out of the full route cache (the data may still be cached though!). Now that we’ve learned how to use the cache in Next.js to optimize our app, let’s learn about SEO with Next.js. SEO with Next.js In *Chapter 8*, we learned about SEO in full-stack apps. Next.js provides functionality for SEO out of the box. Let’s explore this functionality now, starting with adding dynamic titles and meta tags. Adding dynamic titles and meta tags In Next.js, we can statically define metadata by exporting a metadata object from a `page.js` file, or we can dynamically define metadata by exporting a `generateMetadata` function. We have already added static metadata to the root layout, as can be seen in `src/app/layout.js`: </code></pre> <p>导出 const metadata = {</p> <p>title: '全栈 Next.js 博客',</p> <p>description: '关于 React 和 Next.js 的博客',</p> <p>}</p> <pre><code class="language-js"> Now, let’s dynamically generate metadata for our post pages: 1. Edit **src/app/posts/[id]/page.js** and define the following function outside of the page component: ``` 导出异步函数 generateMetadata({ params }) { const id = params.id ```js 2. Fetch the post; if it does not exist, call **notFound()**: ``` const post = 等待 getPostById(id) if (!post) notFound() ```js 3. Otherwise, return a title and description: ``` return { title: `${post.title} | 全栈 Next.js 博客`, description: `由 ${post.author.username} 撰写`, } } ```js That’s all there is to it! Next.js will set the title and meta tags appropriately for us. Note Metadata is inherited from layouts. So, it is possible to define defaults for metadata in the layout and then selectively override it for specific pages. Now that we have successfully added a dynamic title and meta tags, let’s continue by creating a `robots.txt` file so that search engines know they are allowed to index our blog app. Creating a robots.txt file Next.js has two ways of creating a `robots.txt` file: * Creating a static **robots.txt** file in **src/app/robots.txt** * Creating a dynamic **robots.txt** file by creating a **src/app/robots.js** script, which returns a special object that is turned into a **robots.txt** file by Next.js Note If you need a refresher on what a **robots.txt** file is and how search engines work, please check out *Chapter 8*. We are only going to create a static `robots.txt` file as there is no need for a dynamic file for now. Follow these steps to get started: 1. Create a new **src/app/robots.txt** file. 2. Edit **src/app/robots.txt** and add the following contents to allow all crawlers to index all pages: ``` User-agent: * Allow: / ```js Now that we have created a `robots.txt` file, let’s create meaningful URLs. Creating meaningful URLs (slugs) Now, we are going to create slugs for our blog posts, similar to what we did in *Chapter 8*. Let’s get started: 1. Rename the **src/app/posts/[id]/** folder to **src/app/posts/[...path]/**. This turns it into a catch-all route, matching everything that comes after **/posts**. 2. Edit **src/app/posts/[...path]/page.js** and adjust the code to get the first part of the URL (the **id** value) from the **path** param: ``` 导出默认异步函数 ViewPostPage({ params }) { 等待初始化数据库() const [id] = params.path const post = 等待 getPostById(id) ```js 3. Also, adjust the code for the **generateMetadata** function: ``` 导出异步函数 generateMetadata({ params }) { const [id] = params.path ```js With that, our router has been set up to accept an optional slug in the URL. 4. Install the **slug** npm package: ``` $ npm install slug@8.2.3 ```js 5. Edit **src/components/Post.jsx** and import the **slug** function: ``` 导入 slug 从 'slug' ```js 6. Adjust the link to the blog post by adding the slug, as follows: ``` <Link href={`/posts/${_id}/${slug(title)}`}>{title}</Link> ```js 7. Open a link from the post list; you will see that the URL now contains the slug. Now that we’ve made sure our URLs are meaningful, we’ll wrap up this section by creating a sitemap for our blog app. Creating a sitemap As we learned in *Chapter 8*, a sitemap contains a list of URLs that are part of an app so that crawlers can easily detect new content and crawl the app more efficiently, making sure that all content on our blog is found. Follow these steps to set up a dynamic sitemap in Next.js: 1. First, define a **BASE_URL** for our app as an environment variable. Edit **.env** and add the following line: ``` BASE_URL=http://localhost:3000 ```js 2. Create a new **src/app/sitemap.js** file, where we import the **initDatabase**, **listAllPosts**, and **slug** functions: ``` 导入 { initDatabase } 从 '@/db/init' 导入 { listAllPosts } 从 '@/data/posts' 导入 slug 从 'slug' ```js 3. Define and export a new asynchronous function that will generate the sitemap: ``` 导出默认异步函数 sitemap() { ```js 4. First, we list all the static pages: ``` const staticPages = [ { url: `${process.env.BASE_URL}`, }, { url: `${process.env.BASE_URL}/create`, }, { url: `${process.env.BASE_URL}/login`, }, { url: `${process.env.BASE_URL}/signup`, }, { url: `${process.env.BASE_URL}/time`, }, ] ```js 5. Then, we get all the posts from the database: ``` 等待初始化数据库() const posts = 等待 listAllPosts() ```js 6. Generate an entry for each post by building the URL and adding a **lastModified** timestamp: ``` const postsPages = posts.map((post) => ({ url: `${process.env.BASE_URL}/posts/${post._id}/${slug(post.title)}`, lastModified: post.updatedAt, })) ```js 7. Finally, return **staticPages** and **postsPages** in an array: ``` return [...staticPages, ...postsPages] } ```js 8. Go to **http://localhost:3000/sitemap.xml** in your browser; you will see that Next.js generated the XML for us from the array of objects! Note It is best practice to add the sitemap to the **robots.txt** file, but we would need to turn it into a dynamic **robots.js** file so that we can provide the full URL to the sitemap (using the **BASE_URL** environment variable). Doing this is left as an exercise for you. Now that we’ve optimized our blog app for search engines, let’s learn about optimized image and font loading in Next.js. Optimized image and font loading in Next.js Loading images and fonts in an optimized way can be tedious, but Next.js makes it very simple by providing the `Font` and `Image` components. The Font component Often, you’ll want to use a specific font for your page to make it unique and stand out. If your font is on Google Fonts, you can have Next.js automatically self-host it for you. No requests will be sent to Google by your browser if you use this feature. Additionally, the fonts will be loaded optimally with zero layout shift. Let’s find out how Google Fonts can be self-hosted with Next.js: 1. We are going to load the **Inter** font by importing it from **next/font/google**. Edit **src/app/layout.js** and add the following import: ``` 导入 { Inter } 从 'next/font/google' ```js 2. Now, load the font, as follows: ``` const inter = Inter({ subsets: ['latin'], display: 'swap', }) ```js `Inter` is a variable font, so we don’t need to specify the weight that we want to load. If the font isn’t a variable font, don’t forget to specify the weight. The `display: 'swap'` property means that the font gets an extremely small block period to be loaded. If it does not load by then, a fallback font will be used. Once the font has been loaded, it will be swapped in. 3. Specify the font in the **<html>** tag, as follows: ``` <html lang='en' className={inter.className}> ```js 4. Go to **http://localhost:3000/** in your browser; you will see that our blog app is now using the **Inter** font! See the following screenshot for reference: ![Figure 18.3 – Our blog app rendered with the Inter font](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_18_3.jpg) Figure 18.3 – Our blog app rendered with the Inter font As you can see, it’s very simple to use self-hosted Google Fonts with Next.js! Note If you want to use a font that is not on Google Fonts, use the **localFont** function from **next/font/local**. This allows you to load a font from a file in your project. For more information on the **Font** component, check out the Next.js docs: [`nextjs.org/docs/app/building-your-application/optimizing/fonts`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts). Next, we are going to learn about the `Image` component, which allows us to easily load images in an optimized way. The Image component Images make up a large portion of the download size of your web application, and can thus have a big impact on the `Image` component, which extends the `<img>` element by doing the following: * Automatically serving resized images for each device and resolution * Automatically preventing layout shift when images are loading * Only loading images when they enter the viewport (“lazy loading”), with optional blurred placeholder images * Offering on-demand resizing for images, even if they are stored remotely Using the `Image` component is simple – just import it and load your images as you would with the `<img>` element. Let’s try it out now: 1. Get an image to be used as a logo for your blog. Any image can be used, but make sure it is a non-vector format (such as PNG). For vector formats, resizing is not necessary, so you will not see any effect. 2. Save the image as a **src/app/logo.png** file. 3. Edit **src/app/layout.js** and import the **Image** component and the logo: ``` 导入 Image 从 'next/image' 导入 logo 从 './logo.png' ```js 4. Above the **<nav>** element, render the **<Image>** component, as follows: ``` return ( <html lang='en' className={inter.className}> <body> <Image src={logo} alt='全栈 Next.js 博客 Logo' width={500} height={47} /> <nav> <Navigation username={user?.username} logoutAction={logoutAction} /> </nav> ```js It is important to specify the width and height of the image so that Next.js can infer the correct aspect ratio and prevent layout shift when the image loads in. 5. Go to **http://localhost:3000/** in your browser; you will see the logo being displayed properly! See the following screenshot for reference: ![Figure 18.4 – Using the Image component to display a logo for our blog](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_18_4.jpg) Figure 18.4 – Using the Image component to display a logo for our blog If you inspect the image in the browser, you will see that it has the `srcset` property with different sizes provided so that the browser can choose which one to load depending on the screen resolution. Note In this example, we loaded a local image, but the **Image** component also supports loading images from a remote server, and it will still resize them properly! To use external URLs, allow the remote server by using the **images.remotePatterns** setting in the **next.config.js** file, then simply pass a URL instead of a local file to the **Image** component. Summary In this chapter, we learned how to define API routes in Next.js. Then, we learned about caching, how to revalidate the cache, and how to opt out of the cache. Next, we learned about SEO in Next.js by adding metadata to our pages, creating meaningful URLs, defining a `robots.txt` file, and generating a sitemap. Finally, we learned about the `Font` and `Image` components, which allowed us to load fonts and images easily and optimally in our app. There are still many more features that Next.js offers that we have not covered yet in this book, such as the following: * **Internationalization**: Allows us to configure the process of routing and rendering content for multiple languages * **Middleware**: Allows us to run code before requests are completed, similar to how middleware works in Express * **Serverless Node.js and Edge runtimes**: Allow us to scale our apps even more by not running a full Node.js server * **Advanced routing**: Allows us to model complex routing scenarios, such as parallel routes (displaying two pages at once) In the next chapter, *Chapter 19*, *Deploying a Next.js App*, we are going to learn how to deploy a Next.js app using Vercel and a custom deployment setup. </code></pre> <h1 id="第十九章部署-nextjs-应用">第十九章:部署 Next.js 应用</h1> <p>在学习完高级 Next.js 概念之后,现在是时候学习如何部署 Next.js 应用了。部署 Next.js 应用最简单的方式是使用由 Next.js 框架开发公司提供的 Vercel 平台。在学会如何在 Vercel 平台上部署我们的应用之后,我们将学习如何使用 Docker 创建自定义部署设置。</p> <p>在本章中,我们将涵盖以下主要主题:</p> <ul> <li> <p>使用 Vercel 部署 Next.js 应用</p> </li> <li> <p>为 Next.js 应用创建自定义部署设置</p> </li> </ul> <h1 id="技术要求-18">技术要求</h1> <p>在我们开始之前,请安装<em>第一章</em>“为全栈开发做准备”和<em>第二章</em>“了解 Node.js 和 MongoDB”中提到的所有要求。</p> <p>列出那些章节中的版本是书中使用的版本。虽然安装较新版本通常不会有问题,但请注意,某些步骤在较新版本上可能工作方式不同。如果你在使用本书提供的代码和步骤时遇到问题,请尝试使用<em>第一章</em>和<em>第二章</em>中提到的版本。</p> <p>你可以在 GitHub 上找到本章的代码:<a href="https://github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch19" target="_blank"><code>github.com/PacktPublishing/Modern-Full-Stack-React-Projects/tree/main/ch19</code></a>。</p> <p>本章的 CiA 视频可以在以下网址找到:<a href="https://youtu.be/ERBFy5mHwek" target="_blank"><code>youtu.be/ERBFy5mHwek</code></a>。</p> <h1 id="使用-vercel-部署-nextjs-应用">使用 Vercel 部署 Next.js 应用</h1> <p>我们将首先在 Vercel 上部署我们的应用,这是一个我们可以免费简单方便地部署应用的平台。按照以下步骤开始部署我们的 Next.js 应用:</p> <ol> <li> <p>通过运行以下命令将现有的<strong>ch18</strong>文件夹复制到新的<strong>ch19</strong>文件夹:</p> <pre><code class="language-js">$ cp -R ch18 ch19 </code></pre> </li> <li> <p>在 VS Code 中打开<strong>ch19</strong>文件夹。</p> </li> <li> <p>使用以下命令将 Vercel CLI 工具作为全局包安装:</p> <pre><code class="language-js">$ npm install -g vercel@33.5.3 </code></pre> </li> <li> <p>运行 Vercel CLI:</p> <pre><code class="language-js">$ vercel </code></pre> </li> <li> <p>你将被要求登录到 Vercel。选择任意一种登录方式,并按照 Vercel 提供的步骤进行登录。</p> </li> <li> <p>登录成功后,你将被询问有关项目部署的问题,通过按<em>Enter</em>/<em>Return</em>键确认所有问题,直到 Vercel CLI 尝试构建你的项目。</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_19_1.jpg" alt="图 19.1 – 尝试将我们的应用部署到 Vercel" loading="lazy"></p> <p>图 19.1 – 尝试将我们的应用部署到 Vercel</p> <ol> <li>在项目构建过程中,你可以访问 CLI 提供的 URL 查看构建过程的当前状态(确保你在同一浏览器中登录到 Vercel),如下截图所示:</li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_19_2_new.jpg" alt="图 19.2 – 在浏览器中监控构建过程" loading="lazy"></p> <p>图 19.2 – 在浏览器中监控构建过程</p> <ol> <li>很遗憾,构建失败是因为<strong>DATABASE_URL</strong>环境变量设置为<strong>mongodb://localhost:27017/blog</strong>。</li> </ol> <p>现在我们需要调整 Vercel 中的这个环境变量。</p> <h2 id="在-vercel-中设置环境变量">在 Vercel 中设置环境变量</h2> <p>按照以下步骤在 Vercel 中设置必要的环境变量:</p> <ol> <li> <p>重新使用在 MongoDB Atlas 中创建的现有数据库集群,或者按照<em>第五章</em>中“创建 MongoDB Atlas 数据库”部分的步骤创建一个新的数据库集群。你现在应该已经有了数据库的连接字符串。</p> </li> <li> <p>通过执行以下命令来验证连接字符串是否有效:</p> <pre><code class="language-js">$ mongosh "<connection-string>" </code></pre> </li> <li> <p>如果你正在重新使用现有的数据库集群,请确保清除数据库/集合,因为帖子和使用者在<em>第五章</em>中略有不同的格式!在 MongoDB Shell 中运行以下命令以清除集合:</p> <pre><code class="language-js">> db.posts.drop() > db.users.drop() </code></pre> </li> <li> <p>前往 <a href="https://vercel.com/" target="_blank"><code>vercel.com/</code></a> 并使用你之前使用的相同登录提供者登录。</p> </li> <li> <p>你应该能看到你项目的概览,包括我们之前通过 Vercel CLI 创建的<strong>ch19</strong>项目,如下面的截图所示:</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_19_3.jpg" alt="图 19.3 – Vercel 控制台" loading="lazy"></p> <p>图 19.3 – Vercel 控制台</p> <ol> <li>点击<strong>ch19</strong>项目,然后转到<strong>设置</strong>标签,在侧边栏中选择<strong>环境变量</strong>,通过输入<strong>DATABASE_URL</strong>作为<strong>键</strong>和之前获得的连接字符串作为<strong>值</strong>来创建一个新的环境变量,如下面的截图所示:</li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_19_4.jpg" alt="图 19.4 – 在 Vercel 中添加环境变量" loading="lazy"></p> <p>图 19.4 – 在 Vercel 中添加环境变量</p> <p>注意</p> <p>对于生产应用,你还会在这里设置<strong>JWT_SECRET</strong>环境变量为一个随机密钥。此外,你还会设置<strong>BASE_URL</strong>环境变量为你应用的部署生产 URL。例如,如果你的博客的公开 URL 将是<strong><a href="https://ch19-omnidan.vercel.app/" target="_blank">https://ch19-omnidan.vercel.app/</a></strong>,你将设置<strong>BASE_URL</strong>为该 URL。</p> <ol> <li> <p>点击环境变量下方的<strong>保存</strong>按钮以保存你的更改。</p> </li> <li> <p>再次运行 Vercel CLI 以尝试另一次部署:</p> <pre><code class="language-js">$ vercel </code></pre> <p>或者,你可以从 Vercel 网页界面触发重建。</p> </li> <li> <p>你会看到现在部署成功,访问 Vercel CLI 提供的<strong>预览</strong> URL,在你的浏览器中查看我们的博客应用成功加载:</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_19_5.jpg" alt="图 19.5 – 我们应用的“预览”部署工作情况" loading="lazy"></p> <p>图 19.5 – 我们应用的“预览”部署工作情况</p> <p>有趣的是,Vercel CLI 现在为我们提供了<strong>预览</strong>部署功能。这是 Vercel 的默认行为。它首先将应用部署到<strong>预览</strong>环境,在那里我们可以测试一切以确保我们的应用运行正常。<strong>预览</strong>环境只有通过 Vercel 登录才能访问。我们还可以邀请其他人来测试我们的应用,并通过 Vercel 底部的工具栏添加评论。</p> <ol> <li> <p>既然我们已经确认了我们的应用可以工作,我们可以将其部署到生产环境,如下所示:</p> <pre><code class="language-js">$ vercel --prod </code></pre> <p>以下截图显示了使用 Vercel CLI 进行的 <strong>预览</strong> 和 <strong>生产</strong> 部署:</p> </li> </ol> <p><img src="https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/mdn-flstk-rct-proj/img/B19385_19_6.jpg" alt="图 19.6 – 使用 Vercel CLI 将我们的应用部署到“预览”和“生产”环境" loading="lazy"></p> <p>图 19.6 – 使用 Vercel CLI 将我们的应用部署到“预览”和“生产”环境</p> <p>现在我们的应用已部署在 <strong>生产</strong> 环境中,任何人都可以访问,无需通过 Vercel 登录!</p> <p>注意</p> <p>Vercel CLI 输出的 URL 对任何人都不可用;您需要使用 Vercel 控制台 <strong>域名</strong> 部分中指定的域名之一。默认应该是 <strong>https://ch19-<vercel-username>.vercel.app/</strong>。</p> <p>如我们所见,使用 Vercel 部署我们的应用非常简单方便。然而,在某些情况下,我们想在自有的基础设施上部署我们的应用。现在让我们学习如何为 Next.js 应用创建自定义部署设置。</p> <h1 id="为-nextjs-应用创建自定义部署设置">为 Next.js 应用创建自定义部署设置</h1> <p>我们现在将学习如何使用 Docker 为 Next.js 应用设置自定义部署。我们已经在 <em>第五章</em> 中学习了使用 Docker 部署应用的基础知识,所以如果任何内容不清楚,或者需要 Docker 的复习,请参考该章节。现在让我们开始设置我们的 Next.js 应用以进行 Docker 部署:</p> <ol> <li> <p>首先,我们需要将 Next.js 的输出格式更改为 <strong>standalone</strong>。此选项告诉 Next.js 创建一个仅包含生产部署所需文件的 <strong>.next/standalone</strong> 文件夹,包括必要的 <strong>node_modules</strong>。然后,此文件夹可以部署而无需再次安装 <strong>node_modules</strong>。编辑 <strong>next.config.mjs</strong> 并调整配置,如下所示:</p> <pre><code class="language-js">/** @type {import('next').NextConfig} */ const nextConfig = {   output: 'standalone', } export default nextConfig </code></pre> </li> <li> <p>现在,我们创建一个 <strong>.dockerignore</strong> 文件来忽略不应包含在我们的镜像中的某些文件:</p> <pre><code class="language-js">node_modules .env* .vscode .git </code></pre> </li> <li> <p>创建一个新的 <strong>Dockerfile</strong>,首先定义一个来自 <strong>node:20</strong> 的 <strong>base</strong> 镜像:</p> <pre><code class="language-js">FROM node:20 AS base </code></pre> </li> <li> <p>然后,定义一个基于 <strong>base</strong> 镜像构建应用的新镜像:</p> <pre><code class="language-js">FROM base AS build </code></pre> </li> <li> <p>将工作目录设置为 <strong>/app</strong> 文件夹,并复制 <strong>package.json</strong> 和 <strong>package-lock.json</strong> 文件:</p> <pre><code class="language-js">WORKDIR /app COPY package.json . COPY package-lock.json . </code></pre> </li> <li> <p>现在,安装所有依赖项,并额外安装 <strong>sharp</strong>,这是 Next.js 在生产中用于调整大小和优化图像的库:</p> <pre><code class="language-js">RUN npm install RUN npm install sharp </code></pre> </li> <li> <p>复制我们项目中的所有文件:</p> <pre><code class="language-js">COPY . . </code></pre> </li> <li> <p>接下来,定义构建过程的参数。我们将在这里定义所有环境变量,因为 Next.js 在构建过程中也会使用它们来静态构建某些路由:</p> <pre><code class="language-js">ARG DATABASE_URL ARG JWT_SECRET ARG BASE_URL </code></pre> </li> <li> <p>我们现在可以运行 <strong>build</strong> 命令,如下所示:</p> <pre><code class="language-js">RUN npm run build </code></pre> </li> <li> <p>根据基础镜像定义一个新的最终应用镜像:</p> <pre><code class="language-js">FROM base AS final </code></pre> </li> <li> <p>我们还定义了工作目录:</p> <pre><code class="language-js">WORKDIR /app </code></pre> </li> <li> <p>我们设置了权限,使我们的应用以特殊的 <strong>nextjs</strong> 用户身份运行而不是 root:</p> <pre><code class="language-js">RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs </code></pre> </li> <li> <p>现在,从 <strong>build</strong> 镜像复制运行独立 Next.js 服务器所需的必要文件:</p> <pre><code class="language-js">COPY --from=build /app/public ./public RUN mkdir -p .next RUN chown nextjs:nodejs .next COPY --from=build /app/.next/standalone ./ COPY --from=build /app/.next/static ./.next/static </code></pre> </li> <li> <p>我们定义了 <strong>PORT</strong>、<strong>HOSTNAME</strong> 和 <strong>NODE_ENV</strong> 变量:</p> <pre><code class="language-js">EXPOSE 3000 ENV PORT 3000 ENV HOSTNAME "0.0.0.0" ENV NODE_ENV production </code></pre> </li> <li> <p>然后,我们以之前定义的 <strong>nextjs</strong> 用户身份执行独立 Next.js 服务器:</p> <pre><code class="language-js">USER nextjs CMD ["node", "server.js"] </code></pre> </li> <li> <p>确保数据库服务器正在 Docker 容器中运行。</p> </li> <li> <p>现在我们可以通过运行以下命令来构建 Docker 镜像:</p> <pre><code class="language-js">$ docker build \   -t blog-nextjs \   --build-arg "DATABASE_URL=mongodb://host.docker.internal:27017/blog" \   --build-arg "JWT_SECRET=replace-with-random-secret" \   --build-arg "BASE_URL=http://localhost:3000" \ blog-nextjs as the name for our image and the necessary environment variables for building the image. Do not forget the dot (.) at the end of the command, as that is what specifies the build context, including where to look for the Dockerfile! </code></pre> </li> </ol> <p>注意</p> <p>您可以查看 Next.js 的官方示例 <strong>Dockerfile</strong> 以获取最新版本:<a href="https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile" target="_blank"><code>github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile</code></a></p> <ol> <li> <p>最后,运行一个新的 Docker 容器,如下所示:</p> <pre><code class="language-js">$ docker run \   -d \   --name blog-app \   -p 3000:3000 \   -e "DATABASE_URL=mongodb://host.docker.internal:27017/blog" \   -e "JWT_SECRET=replace-with-random-secret" \   -e "BASE_URL=http://localhost:3000" \   --restart unless-stopped \ blog-app in the background (daemon mode) published to port 3000, then specified the environment variables and told Docker to restart the container if it crashes. Lastly, we specified the image name, which is blog-nextjs (the image we built in the previous step). </code></pre> </li> <li> <p>访问 <strong><a href="http://localhost:3000" target="_blank">http://localhost:3000</a></strong>,您将看到博客成功运行!</p> </li> </ol> <p>现在我们有了 Docker 容器,我们可以将其部署到云服务(或我们自己的服务器)上,就像我们在<em>第五章</em>中所做的那样。虽然为 Next.js 应用程序设置自定义部署需要稍微多一点努力,但进行简单的设置仍然相当直接!</p> <p>对于更高级的设置,例如多个实例,您需要在实例之间设置共享卷,以便缓存和优化后的镜像可以共享(在 Vercel 上,这会在幕后自动完成)。然而,这种设置超出了本书的范围。您可以查看 Next.js 关于自托管的文档以获取更多关于如何进行此操作的信息:<a href="https://nextjs.org/docs/app/building-your-application/deploying#self-hosting" target="_blank"><code>nextjs.org/docs/app/building-your-application/deploying#self-hosting</code></a>。</p> <h1 id="摘要-15">摘要</h1> <p>在本章中,我们首先学习了如何使用 Vercel 部署 Next.js 应用程序。然后,我们学习了如何使用 Docker 创建自定义部署设置。</p> <p>在下一章和最后一章,<em>第二十章</em>“深入全栈开发”,我们将简要介绍本书迄今为止未涉及的各种高级全栈开发主题,让您了解如何继续使用 React 学习全栈 Web 开发的旅程。</p> <h1 id="第二十章深入全栈开发">第二十章:深入全栈开发</h1> <p>在学习如何构建和部署 Next.js 应用后,我们就完成了使用 React 的全栈开发之旅。在本章的最后,我想为您概述并简要介绍本书中未涉及的各种高级主题。这包括维护大型项目、优化包大小、<strong>用户界面</strong>(<strong>UI</strong>)库概述以及高级状态管理解决方案等概念。</p> <p>在本章中,我们将介绍以下主要内容:</p> <ul> <li> <p>其他全栈框架概述</p> </li> <li> <p>UI 库概述</p> </li> <li> <p>高级状态管理解决方案概述</p> </li> <li> <p>维护大型项目的要点</p> </li> </ul> <p>注意</p> <p>由于本章仅概述了全栈开发中的高级主题并提供进一步阅读的链接,因此没有代码示例,因此本章也没有技术要求。</p> <h1 id="其他全栈框架概述">其他全栈框架概述</h1> <p>在本书中,我们学习了 Next.js,这是最受欢迎的 React 全栈框架。然而,其他全栈框架可能对您也很有兴趣,每个框架都有其自身的优缺点。</p> <p>在我们比较框架之前,让我们回顾一下 React 中不同的渲染方法:</p> <ul> <li> <p><strong>客户端渲染</strong>(<strong>CSR</strong>):在浏览器中渲染组件</p> </li> <li> <p><strong>服务器端渲染</strong>(<strong>SSR</strong>):在服务器上渲染组件并提供服务结果</p> </li> <li> <p><strong>静态站点生成</strong>(<strong>SSG</strong>):在服务器上渲染组件并将它们存储为静态 HTML,然后提供静态 HTML</p> </li> <li> <p><strong>增量静态生成</strong>(<strong>ISR</strong>):动态执行 SSG 并缓存结果一段时间</p> </li> <li> <p><strong>延迟站点生成</strong>(<strong>DSG</strong>):在构建时间和页面重新渲染时缓存所有数据,并利用这些缓存数据</p> </li> </ul> <p>此外,许多框架(和云服务提供商)支持<strong>Edge 运行时</strong>,这是标准 Web API 的一个子集,用于在“边缘”运行代码。这里的“边缘”指的是可以部署在尽可能靠近客户的位置的无服务器计算环境。例如,如果有人从奥地利访问您的网站,代码将在奥地利或德国的最近的服务器上运行。然而,对于来自美国的人来说,代码将在美国的某个服务器上运行。这减少了网络延迟并使我们的应用加载更快。</p> <p>现在,让我们来看看不同的全栈框架。</p> <h2 id="nextjs">Next.js</h2> <p>我们在本章中已经学习了 Next.js——它是撰写本书时最受欢迎的全栈 Web 框架,支持 CSR、SSR、SSG 和 ISR。最近,Next.js 默认使用 SSG 以保持应用尽可能高效,但它仍然提供重新验证缓存页面并在必要时提供 SSR 的能力。</p> <p>Next.js 也支持 Edge 运行时,但您必须明确选择使用它而不是(默认的)Node.js 运行时。某些功能在 Edge 运行时也不可用。</p> <p>你可以在这里查看 Next.js:<a href="https://nextjs.org/" target="_blank"><code>nextjs.org/</code></a>。</p> <h2 id="remix">Remix</h2> <p>Remix 是一个全栈框架,专注于 Web 标准。</p> <p>与 Next.js 不同,它不提供静态站点生成(SSG),而是专注于提高动态渲染性能和通过服务器端渲染(SSR)与 Web 基础设施的集成。由于 Remix 完全基于 Web 标准,它不需要 Node.js 来运行,因此可以在边缘运行时(如 Cloudflare Workers)本地运行。</p> <p>目前,Remix 不支持 <strong>React 服务器组件</strong>(<strong>RSCs</strong>),但它有自己的模式,可以带来与使用 RSCs 相同的优势。</p> <p>与 Next.js 类似,它支持嵌套路由(带有嵌套布局)、动态路由和服务器端并行渲染。Remix 路由基于 React Router,如果你已经使用过 React Router,那么它很容易理解。它还支持加载/错误状态,以及一种服务器端操作的形式。</p> <p>总体而言,Remix 是 Next.js 的一个非常好的替代品,特别是如果你更喜欢使用标准 Web API 并关心边缘运行时支持。它的目标是尽可能依赖标准,使 Web 开发再次变得简单。</p> <p>你可以在这里查看 Remix:<a href="https://remix.run/" target="_blank"><code>remix.run/</code></a>。</p> <h2 id="gatsby">Gatsby</h2> <p>Gatsby 主要关注 SSG。虽然现在它也可以进行 SSR,但框架作者鼓励尽可能多地使用 SSG。而不是增量静态化(ISR),Gatsby 提供了数据同步生成(DSG),在大网站上使数据更加一致,但代价是可能提供过时的数据。</p> <p>最近,Gatsby 开始提供 RSC 支持,并支持边缘运行时。然而,与 Next.js 一样,它也依赖于 Node.js API,因此只为边缘运行时提供其功能的一个子集。</p> <p>Gatsby 的一个优点是其庞大的插件生态系统,允许开发者轻松集成新功能。</p> <p>然而,Gatsby 的一个缺点是它不支持带有嵌套布局的嵌套路由。</p> <p>虽然 Next.js 和 Remix 都提供了对 REST 和 GraphQL 的支持,但 Gatsby 主要关注 GraphQL,仅将 REST API 作为第二类公民支持。然而,这使 Gatsby 能够提供易于集成各种数据源的插件。</p> <p>总体而言,如果你主要想使用 SSG、集成来自各种来源的数据以及一个易于学习的工具,Gatsby 可以成为一个很好的框架。Gatsby 不是一次性向你展示框架的所有复杂性,而是通过其插件生态系统逐步揭示复杂性。</p> <p>你可以在这里查看 Gatsby:<a href="https://www.gatsbyjs.com/" target="_blank"><code>www.gatsbyjs.com/</code></a>。</p> <p>接下来,我们将概述一些选定的 UI 库。</p> <h1 id="ui-库概述">UI 库概述</h1> <p>在这本书中,我故意省略了 UI 库,因为它们具有强烈的个人观点,不断变化,并且会使代码示例显著变长。在本节中,我想提供一些选定 UI 库的概述。请随意自行探索,并关注该领域的其他选项和新版本!</p> <h2 id="material-ui-mui">Material UI (MUI)</h2> <p>MUI 是 React 最受欢迎的组件库之一。它支持广泛的组件,包括数据表等复杂组件。它还拥有一个非常可扩展的主题系统,允许你调整它以适应你的风格。然而,截至写作时,它的样式引擎与 RSC 不兼容,这是未来版本中将得到改进的地方。如果你通常喜欢它的风格但想自定义颜色、字体和间距以使其成为你自己的,请使用 MUI。</p> <p>你可以在这里查看 MUI:<a href="https://mui.com/" target="_blank"><code>mui.com/</code></a>。</p> <h2 id="tailwind-css">Tailwind CSS</h2> <p>Tailwind CSS 是一个以实用工具为主的 CSS 框架,不需要 React。然而,它与 React 协作良好,允许你轻松地为自定义组件添加样式。由于它仅使用 CSS,你可以根据需要精确地调整 React 组件。这也意味着 RSC 完全受支持,因为 Tailwind 只是一组 CSS 类。如果你想快速简单地实现应用的完全自定义样式,而不是直接使用 CSS,请使用 Tailwind。</p> <p>你可以在这里查看 Tailwind CSS:<a href="https://tailwindcss.com/" target="_blank"><code>tailwindcss.com/</code></a>。</p> <h3 id="tailwind-ui">Tailwind UI</h3> <p>Tailwind CSS 的制作者还提供了一套使用 Tailwind CSS 的预制作仅样式组件,称为 Tailwind UI。如果你需要灵感来创建使用 Tailwind 的组件,请查看它:<a href="https://tailwindui.com/" target="_blank"><code>tailwindui.com/</code></a>。</p> <h2 id="react-aria">React Aria</h2> <p>React Aria 是一套具有出色的可访问性和国际化支持的简单组件。默认情况下,组件是无样式的,允许你构建自定义设计。你还可以将其与 Tailwind 结合使用。如果你想创建一个设计系统但又不想处理创建可访问性组件的挑战,请使用 React Aria。</p> <p>你可以在这里查看 React Aria:<a href="https://react-spectrum.adobe.com/react-aria/" target="_blank"><code>react-spectrum.adobe.com/react-aria/</code></a>。</p> <h2 id="nextui">NextUI</h2> <p>NextUI 是一个使用 Vercel(Next.js 背后的公司)风格的即将推出的 UI 库。它建立在 Tailwind CSS 之上,但提供了基于 React Aria 的各种组件,确保了一流的可访问性支持。像 MUI 一样,它也提供了许多组件,并且可以通过主题进行高度定制。此外,它支持 RSC,因为它基于 Tailwind CSS。如果你喜欢这种风格并想稍作定制,尤其是如果你正在使用具有 RSC 支持的框架进行开发,请使用 NextUI。</p> <p>你可以在这里查看 NextUI:<a href="https://nextui.org/" target="_blank"><code>nextui.org/</code></a>。</p> <p>接下来,我们将提供高级状态管理解决方案的概述。</p> <h1 id="高级状态管理解决方案概述">高级状态管理解决方案概述</h1> <p>在这本书中,我们专注于 React 中的简单状态管理解决方案,如<code>useState</code>和上下文。然而,在大型项目中,使用高级状态管理库来处理复杂状态可能是有意义的。在这里,我将概述一些选定的状态管理库,但请记住,还有许多其他库,所以请随意查看它们并决定哪个最适合你的项目。</p> <h2 id="recoil">Recoil</h2> <p>Recoil 是一个由 Facebook Open Source 构建的 React 状态管理库。因此,它共享了许多 React 的原则。它是一个非常简单但强大的系统,其中状态存储在原子中,然后通过选择器派生。这使得我们能够,例如,仅在原子中存储表单的用户输入,并在选择器中存储结果的有效负载,该选择器从原子中派生其状态。</p> <p>你可以在这里查看 Recoil:<a href="https://recoiljs.org/" target="_blank"><code>recoiljs.org/</code></a>.</p> <h2 id="jotai">Jotai</h2> <p>Jotai 采取与 Recoil 类似的方法,但通过消除选择器并仅处理原子来简化系统。原子可以从其他原子派生状态。如果你需要一个仍然简单但比<code>useState</code>更强大的状态管理解决方案,Jotai 是一个很好的选择。</p> <p>你可以在这里查看 Jotai:<a href="https://jotai.org" target="_blank"><code>jotai.org</code></a>.</p> <h2 id="redux">Redux</h2> <p>Redux 采取了一种不同的方法,提供了一个中心存储,其中包含你所有的状态,然后只允许你通过操作来更改它。这确保了你的应用程序表现一致,并且相同的用户操作总是导致相同的状态变化。Redux 对于操作至关重要且需要撤销/重做功能的应用程序来说非常出色(例如某些编辑器)。</p> <p>你可以在这里查看 Redux:<a href="https://redux.js.org" target="_blank"><code>redux.js.org</code></a>.</p> <h2 id="mobx">MobX</h2> <p>MobX 是一个基于信号的州管理库,它使用可观察性来跟踪状态变化。当一个值被设置为可观察的,它可以被直接修改,就像一个常规的 JavaScript 变量一样,但对其的所有更改都会触发状态观察者的执行和组件的重渲染。</p> <p>你可以在这里查看 MobX:<a href="https://mobx.js.org/" target="_blank"><code>mobx.js.org/</code></a>.</p> <h2 id="xstate">xstate</h2> <p>xstate 是一个状态机库,当你有复杂用户界面和需要跟踪的各种状态时,它非常有用。</p> <p>你可以在这里查看 xstate:<a href="https://stately.ai/docs/xstate" target="_blank"><code>stately.ai/docs/xstate</code></a>.</p> <h2 id="zustand">Zustand</h2> <p>Zustand 是一个小型状态管理库,具有基于 hook 的 API,它结合了存储中的值和更改这些值的函数。每个存储都暴露一个 hook,其中可以使用其值和函数。</p> <p>你可以在这里查看 Zustand:<a href="https://docs.pmnd.rs/zustand/getting-started/introduction" target="_blank"><code>docs.pmnd.rs/zustand/getting-started/introduction</code></a>.</p> <p>现在,让我们通过学习一些维护大型项目的要点来结束。</p> <h1 id="维护大型项目的要点">维护大型项目的要点</h1> <p>为了使这本书尽可能简短、直接,并且面向更广泛的受众,我故意省略了一些主题和技术。然而,当维护大规模项目时,了解这些内容仍然非常重要,因此我想在这里简要介绍它们。</p> <h2 id="使用-typescript">使用 TypeScript</h2> <p>TypeScript 是在 JavaScript 中扩展了类型语法的语言。类型系统可以在早期捕获错误,并在重构大型代码库时提供信心。虽然适应所有类型的输入可能需要一些时间,但当你意识到所有问题都作为类型错误出现在你的代码编辑器中,而不是用户的运行时错误时,它就会变成一种祝福。</p> <p>我建议所有新的项目都使用 TypeScript。当你已经了解 JavaScript 时,它很容易学习,并且与 Next.js 等框架很好地集成。</p> <p>你可以在这里了解更多关于 TypeScript 的信息:<a href="https://www.typescriptlang.org" target="_blank"><code>www.typescriptlang.org</code></a>.</p> <h2 id="设置-monorepo">设置 Monorepo</h2> <p>在这本书中,我们一直是在处理单个应用程序。然而,大规模项目通常由多个应用程序组成,它们之间可能共享多个内部库。例如,你可能有两个应用程序,它们在公共 UI 库中共享 UI 组件。将这些库和应用程序放在单独的 git 仓库中,往往会导致组织上的开销。</p> <p>为了保持简单,开发团队通常会决定设置一个 Monorepo,其中包含所有应用程序和库。这也使得保持代码库的一致性和跟踪大规模重构变得更加容易。</p> <p>你可以在这里了解更多关于 Monorepos 的信息:<a href="https://monorepo.tools" target="_blank"><code>monorepo.tools</code></a>.</p> <p>要设置 Monorepo,请使用支持工作区的包管理器,例如 pnpm (<a href="https://pnpm.io" target="_blank"><code>pnpm.io</code></a>) 或 yarn (<a href="https://yarnpkg.com" target="_blank"><code>yarnpkg.com</code></a>)。某些工具使创建和维护 Monorepos 更加容易,例如 Turborepo。查看 <a href="https://turbo.build" target="_blank"><code>turbo.build</code></a> 上的指南,了解如何使用 Turborepo 设置 Monorepo。</p> <h2 id="优化包大小">优化包大小</h2> <p>随着你的项目增长,发送到浏览器的 JavaScript 包也会增长。这可能会对连接速度较慢或处理器较慢的设备造成问题。有时,某些依赖项会增加包的大小,因此定期检查项目中的更改如何影响包大小是一个好主意。</p> <p>对于 Vite,你可以使用 <code>vite-bundle-visualizer</code> 来找出哪些依赖项增加了你的包大小:<a href="https://github.com/KusStar/vite-bundle-visualizer" target="_blank"><code>github.com/KusStar/vite-bundle-visualizer</code></a>.</p> <p>对于 Next.js,你可以使用官方的 <code>@next/bundle-analyzer</code> 插件:<a href="https://nextjs.org/docs/app/building-your-application/optimizing/bundle-analyzer" target="_blank"><code>nextjs.org/docs/app/building-your-application/optimizing/bundle-analyzer</code></a>.</p> <h1 id="摘要-16">摘要</h1> <p>在这本书中,我们以成为全栈开发者的动机开始。然后,我们搭建了我们的开发环境,并学习了使我们的生活更轻松的工具。接下来,我们了解了 Node.js 和 MongoDB,作为后端开发者迈出了第一步。然后,我们使用 Express 和 Mongoose 为博客应用实现了后端,并使用 Jest 为其编写了单元测试。之后,我们使用 React 和 TanStack Query 将前端与后端集成,从而创建了我们的第一个全栈 Web 应用。接下来,我们学习了如何使用 Docker 部署我们的应用,以及如何设置 CI/CD。然后,我们使用 JWT 为我们的应用添加了身份验证。我们学习了如何使用 SSR 提高我们应用的加载时间,并在过程中开发了自己的(简单的)SSR 解决方案。然后,我们学习了搜索引擎的工作原理,以及如何通过促进 SEO 和为社交媒体嵌入提供元数据,确保客户可以找到我们的 Web 应用。接下来,我们使用 Playwright 实现了端到端测试,确保我们的应用始终按预期工作。然后,我们学习了如何使用 MongoDB 和 Victory 聚合和可视化统计数据。</p> <p>之后,我们偏离了 REST API,使用 GraphQL API 开发了一个后端,学习了 GraphQL 是什么以及它的好处。然后,我们开发了一个前端来消费这个 GraphQL API。接下来,我们暂时放下了博客应用,使用 Socket.IO 构建了一个基于事件的聊天应用。在这个过程中,我们学习了如何创建后端和前端,以及如何在基于事件的范式下添加持久性。在本书的最后几章中,我们学习了 Next.js,一个全栈 Web 开发框架。我们介绍了应用路由,这是一种新的应用程序结构方式,以及 RSCs,它允许我们将后端和前端合并得更紧密,减少了创建 API 的样板代码需求,并允许我们直接从 RSCs 内部的数据层访问代码。我们还学习了 Next.js 中的高级概念和优化,如缓存、SEO 和优化的字体和图像加载。最后,我们学习了如何使用 Vercel 部署 Next.js 应用,Vercel 是由 Next.js 的制作者提供的云平台,我们还创建了一个自定义的部署设置,使用 Docker,这样我们就可以在任何其他云服务提供商(或我们自己的服务器)上部署我们的应用。</p> <p>这已经是一条漫长的道路。然而,正如我们在本章中看到的,还有更多的话题需要我们深入研究,Web 开发生态系统也在快速发展。新技术不断涌现,尤其是在 RSCs 和 Server Actions 方面,在撰写本文时,这些技术仍然新颖且正在兴起。我预计这个领域将推出更多功能,所以请密切关注 React 世界中的突破性公告!</p> <p>“求知若饥,虚心若愚。永远不要放弃对新想法、新体验和新冒险的渴望。” —— 史蒂夫·乔布斯</p>

posted @ 2025-09-08 13:02  绝不原创的飞龙  阅读(101)  评论(0)    收藏  举报