React-生产环境应用架构-全-

React 生产环境应用架构(全)

原文:zh.annas-archive.org/md5/ceb525cb4b1ca0f3a18a581d1eef2dfa

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在生产中使用 React 构建大规模应用程序可能会因为选择众多和缺乏连贯的资源而令人感到不知所措。这本实践指南旨在分享实践和示例,以帮助解决构建企业级 React 应用程序的这些挑战。

本书提供了一个具体且实用的示例,贯穿全书,以展示书中的概念。你将学习如何从头开始构建现代前端应用程序,使其适用于生产。从了解 React 生态系统概述开始,你将识别可用于解决复杂开发挑战的工具。你将学习如何构建模拟 API、组件和页面,形成一个完整的前端应用程序。本书还将分享测试、保护和以结构化方式打包应用程序的实践。最后,你将学习如何考虑可扩展性将应用程序部署到生产环境。

到本书结束时,你将能够通过遵循行业实践和专家建议,高效地构建生产就绪的应用程序。

本书面向的对象

本书面向已经对 JavaScript、React 以及一般意义上的 Web 开发有良好理解的初级 Web 开发者,他们希望有效地构建大规模的 React 应用程序。除了 JavaScript 和 React 的经验外,一些 TypeScript 的经验将是有益的。

本书涵盖的内容

第一章理解 React 应用程序的架构,教你从架构的角度思考应用程序。它首先介绍了良好架构的重要性及其益处。然后,它讨论了 React 应用程序中的一些良好和不良实践。最后,我们将介绍一个真实 React 应用程序的规划,这个应用程序将在整本书中构建。

第二章设置和项目结构概述,涵盖了我们将要构建的应用程序的所有工具和设置。它将介绍 Next.js、TypeScript、ESLint、Prettier、Husky 和 Lint Staged 等工具。最后,它将介绍基于功能的项目结构,这有助于改善代码库的组织。

第三章构建和记录组件,介绍了 Chakra UI,这是一个优秀的组件库,我们将用它作为构建 UI 的基石。我们将介绍如何设置它,然后我们将构建可以在整个应用程序中重复使用的组件,以使应用程序的 UI 更加一致。最后,我们将学习如何使用 Storybook 记录这些组件。

第四章构建和配置页面,更深入地介绍了 Next.js。首先,我们将介绍基础知识,例如 Next.js 路由和它所支持的渲染策略。然后,我们将学习如何处理共享布局。最后,我们将通过构建我们应用程序的页面来应用这些技术。

第五章模拟 API,深入探讨了用于开发和测试的 API 模拟。它首先解释了为什么这很有用。然后,它介绍了 MSW 库,它允许以优雅的方式模拟 API 端点。最后,我们将通过实现我们应用程序的端点来应用我们所学的知识。

第六章将 API 集成到应用程序中,教我们如何与后端 API 进行通信。我们将学习如何配置 API 客户端和 React Query,并使用它们来构建我们应用程序的 API 层。然后,我们将通过实现我们应用程序的 API 调用来应用我们所学的知识。

第七章实现用户身份验证和全局通知,首先教你如何实现应用程序的身份验证。然后,它演示了如何通过实现我们应用程序的通知系统来处理全局状态。

第八章测试,教你如何接近测试 React 应用程序。它涵盖了使用 Jest 的单元测试,使用 Jest 和 React Testing Library 的集成测试,以及使用 Cypress 的端到端测试。

第八章配置 CI/CD 以进行测试和部署,涵盖了 GitHub Actions 管道的基础知识。然后,我们将学习如何配置管道以进行代码检查和测试。最后,我们将配置它以部署到 Vercel。

第十章超越,涉及一些未涉及的话题。由于应用程序处于 MVP 阶段,有改进的空间,这一章涵盖了其中的一些改进。我们还将了解一些技术改进,这将有助于应用程序进一步扩展。

为了充分利用这本书

具有 JavaScript 和 React 的前期经验以及网络开发的基本知识将使你更容易跟随书中的内容。同时,拥有 TypeScript 和 Next.js 的经验是可取的,但即使没有这些经验,也应该能够跟随,因为我们在书中会涵盖基础知识。

本书涵盖的软件/硬件 操作系统要求
React 18 macOS、Windows 或 Linux
Next.js 12
TypeScript 4.8

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

关于设置和要求的更多详细信息,最好查看书中 GitHub 仓库中的 README 文件。

下载示例代码文件

您可以从 GitHub 上的github.com/PacktPublishing/React-Application-Architecture-for-Production下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。

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

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/DjfrW

使用的约定

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

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“让我们创建.github/workflows/main.yml文件和初始代码。”

代码块设置如下:

name: CI/CD
on:
  - push
jobs:
# add jobs here

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

jobs:
  # previous jobs
  e2e:
    name: E2E Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: mv .env.example .env
      - uses: cypress-io/github-action@v4
        with:
          build: npm run build
          start: npm run start

任何命令行输入或输出都应如下编写:

git clone https://github.com/PacktPublishing/React-Application-Architecture-for-Production.git

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“当用户点击应用按钮时,电子邮件客户端会以正确设置的主题打开。”

提示或重要注意事项

看起来是这样的。

联系我们

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

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

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

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

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

分享您的想法

一旦您阅读了《React Application Architecture for Production》,我们非常乐意听到您的想法!请选择www.amazon.in/review/create-review/error?asin=1801070539为这本书并分享您的反馈。

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

下载这本书的免费 PDF 副本

感谢您购买这本书!

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

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

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

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

优惠不仅限于此,您还可以获得独家折扣、新闻通讯以及每天收件箱中的优质免费内容

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

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

图片

https://packt.link/free-ebook/9781801070539

  1. 提交您的购买证明

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

第一章:理解 React 应用程序的架构

React 是由 Meta(Facebook)创建和维护的开源 JavaScript 库,用于构建用户界面。

现在可能最流行的用于构建用户界面的库就是它。它之所以受欢迎,是因为它性能出色,API 简小,这使得它成为创建用户界面的简单而又非常强大的工具。

它是组件化的,这允许我们将大型应用程序拆分成更小的部分,并独立地工作在这些部分上。

React 也非常出色,因为它的核心 API 与平台解耦,这使得像 React Native 这样的项目可以在网络平台之外存在。

React 最大的优点也是缺点之一在于它的非常灵活。这允许其社区构建出优秀的解决方案。然而,默认定义一个良好的应用程序架构可能具有挑战性。

对于任何应用程序的成功来说,做出正确的架构决策至关重要,尤其是在它需要变化或规模、用户数量和参与人数增长时。

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

  • 良好应用程序架构的好处

  • 探索 React 应用程序的架构挑战

  • 在构建 React 应用程序时理解架构决策

  • 规划我们的应用程序

到本章结束时,我们将学会在开始 React 应用程序开发时从架构的角度思考更多。

良好应用程序架构的好处

每个应用程序都使用某种架构,即使没有考虑过。它可能被随机选择,可能并不适合其需求和需求,但无论如何,每个应用程序确实都有架构。

因此,在项目开始时就关注适当的架构对于每个项目都是至关重要的。让我们定义几个原因:

  • 为项目打下良好的基础

  • 更容易的项目管理

  • 提高开发速度和生产力

  • 成本效益

  • 更好的产品质量

值得注意的是,所有应用程序都容易受到需求变化的影响,因此并不总是能够提前预测一切。然而,我们应该从一开始就关注架构。我们将在接下来的章节中详细讨论这些原因。

为项目打下良好的基础

每座建筑都应该建立在稳固的基础上,以保持对不同条件如年龄、天气条件、地震和其他原因的抵抗力。

同样,这种情况也适用于应用程序。在项目生命周期中,多种因素会导致各种变化,例如需求、组织、技术、市场、财务等。建立在稳固的基础上将使它能够抵御所有这些变化。

更容易的项目管理

合理组织不同的组件将使组织和委派任务变得更加容易,尤其是在涉及更大团队的情况下。

良好的组件解耦将允许团队和团队成员之间更好地分配工作,并实现更快的迭代,而不会因为彼此而受阻。

它还允许更好地估计完成一个功能所需的时间。

提高开发速度和生产力

定义良好的架构可以让开发者专注于他们正在构建的产品,而无需过度思考技术实现,因为大多数技术决策应该已经做出。

此外,它将为新开发者提供一个更平滑的入职流程,他们可以在熟悉整体架构后迅速变得高效。

经济效益

之前各节中提到的所有原因都表明,良好的架构带来的改进将降低成本。

在大多数情况下,每个项目的最大成本是人力和他们的工作以及时间。因此,通过让他们更有效率,我们可以减少一些不良架构可能带来的冗余成本。

它还将允许更好地进行软件产品的财务分析和定价模型规划。这将使预测平台需要的功能所需的所有成本变得更加容易。

更好的产品质量

让所有团队成员都变得高效,这样他们就有时间专注于重要的事情,比如业务需求和用户需求,而不是花大部分时间修复错误和减少技术债务。

更好的产品质量也会让我们的用户更加满意,这应该是最终目标。

为了存在,每款软件都需要满足其需求。在下一节中,我们将看到这些软件需求是什么。

探索 React 应用程序的架构挑战

在本节中,我们将重点关注 React,并探讨在构建 React 应用程序时需要考虑哪些因素,以及大多数 React 开发者构建应用程序时面临的主要挑战。

构建 React 应用程序时有哪些挑战?

React 是构建用户界面的优秀工具。然而,在构建应用程序时,我们应该考虑一些具有挑战性的事情。它非常灵活,这既是好事也是坏事。好事在于我们可以定义应用程序不同部分的结构,而不会受到库的干扰。

由于 React 非常灵活,它已经聚集了一个全球范围内的开发者社区,构建了不同的开源解决方案。在开发过程中,我们可能会遇到任何问题的完整解决方案。这使得 React 生态系统非常丰富。

然而,这种灵活性和生态系统丰富性是有代价的。

让我们看看由roadmap.sh制作的以下 React 生态系统概述图:

图 1.1 – 由 roadmap.sh 提供的 React 开发者路线图

图 1.1 – 由 roadmap.sh 提供的 React 开发者路线图

图 1.1所示,在用 React 构建应用程序时有很多要考虑的因素。我们也要记住,这个图表可能只显示了冰山一角。许多不同的包和解决方案都可以用来构建相同的应用程序。

在开始使用新的 React 应用程序时,最常见的一些问题如下:

  • 我们正在使用什么项目结构?

  • 我们正在使用什么渲染策略?

  • 我们正在使用什么状态管理解决方案?

  • 我们正在使用什么样式解决方案?

  • 我们正在使用什么数据获取方法?

  • 我们将如何处理用户身份验证?

  • 我们将要使用哪些测试策略?

这些挑战不仅限于 React——它们与构建前端应用总体相关,无论使用哪些工具。但鉴于本书专注于 React,我们将从那个角度来处理它们。

我们正在使用什么项目结构?

由于 React 非常灵活并且 API 非常小,它对我们的项目结构没有特定的看法。以下是 React 的维护者之一 Dan Abramov 关于这一点所说的话:

移动文件直到感觉正确

这是一个非常正确的观点。它将主要取决于应用程序的本质。例如,我们不会以相同的方式组织社交网络应用程序和文本编辑应用程序,因为它们有不同的需求和要解决的问题。

我们正在使用什么渲染策略?

这取决于我们应用程序的本质。

如果我们正在构建一个内部仪表板应用程序,单页应用程序就足够了。

另一方面,如果我们构建的是一个面向客户的、应该公开且 SEO 友好的应用程序,那么我们应该考虑服务器端渲染或静态生成,具体取决于页面上的数据更新频率。

我们正在使用什么状态管理解决方案?

React 通过其 hooks 和 Context API 内置了状态管理机制,但对于更复杂的应用程序,我们通常求助于外部解决方案,如ReduxMobXZustandRecoil等。

选择正确的状态管理解决方案很大程度上取决于应用程序的需求和需求。如果我们正在构建待办事项应用程序或电子商务应用程序,我们不会使用相同的工具。

这主要取决于整个应用程序中需要共享的状态量以及这些状态更新的频率。

我们的应用程序会有很多频繁的更新吗?如果是这样,我们可能会考虑基于原子的解决方案,如RecoilJotai

如果我们的应用程序需要许多不同的组件共享相同的状态,那么Redux结合Redux Toolkit是一个好选择。

另一方面,如果我们没有很多全局状态并且不经常更新它,那么ZustandReact Context API,结合 hooks,是不错的选择。

最后,一切都取决于应用的需求和我们试图解决的问题的本质。

我们将使用什么样式解决方案?

这主要取决于个人喜好。有些人喜欢纯 CSS,有些人喜欢像Tailwind这样的实用优先 CSS 库,而有些开发者则无法离开CSS in JS

做出这个决定也应该取决于我们的应用是否会被频繁地重新渲染。如果是这样,我们可能会考虑构建时解决方案,如纯 CSS、SCSS、Tailwind 等。否则,我们可以使用运行时样式解决方案,如Styled ComponentsEmotion等。

我们还应该记住,我们是否想使用预构建的组件库,或者如果我们想从头开始构建一切。

我们将如何处理用户认证?

这取决于 API 实现。我们是否使用基于令牌的认证?我们的 API 服务器是否支持基于 cookie 的认证?使用带有httpOnly cookie 的基于 cookie 的认证被认为更安全,可以防止跨站脚本攻击(XSS)。

这些大多数事情都应该与后端团队一起定义。

我们将使用什么测试策略?

这取决于团队结构,所以如果我们有 QA 工程师可用,我们将能够让他们进行端到端测试。

这也取决于我们可以投入多少时间进行测试和其他方面。记住,我们应该始终考虑至少进行一些级别的测试,对于应用最关键的部分,进行端到端测试。

构建 React 应用时理解架构决策

不论应用的具体需求如何,在构建过程中,我们都可以做出一些普遍的优劣决策。

恶劣的架构决策

让我们看看一些可能拖慢我们进度的恶劣架构决策。

平坦的项目结构

想象一下有很多组件,它们都生活在同一个文件夹中。最简单的事情就是将所有 React 组件放在 components 文件夹中,如果我们的组件数量不超过 20 个,这是可以的。之后,由于它们都混合在一起,就很难找到组件应该属于的位置。

大型、紧密耦合的组件

拥有大型和耦合的组件有几个缺点。它们在隔离测试中很难,很难重用,并且在某些情况下可能存在性能问题,因为组件需要完全重新渲染,而不是我们只重新渲染需要重新渲染的小部分。

不必要的全局状态

拥有全局状态是可以的,并且通常是必需的。但是,将太多东西放在全局状态中可能是个坏主意。它可能会影响性能,也可能影响可维护性,因为它使得理解状态的范围变得困难。

使用错误的工具解决问题

React 生态系统中的选择数量使得选择错误的工具来解决问题变得更容易——例如,在全局存储中缓存服务器响应。这可能是有可能的,我们过去一直在这样做,但这并不意味着我们应该继续这样做,因为存在解决这个问题的工具,如 React Query、SWR、Apollo Client 等。

将整个应用程序放在单个文件中的单个组件里

这是不应该发生的事情,但仍然值得提及。没有任何东西阻止我们在单个文件中创建一个完整的应用程序。它可能长达数千行——即一个能够完成所有工作的单个组件。但正如拥有大型组件一样,应该避免这样做。

未清理用户输入

网络上有许多黑客试图窃取我们的用户数据。因此,我们应该尽一切可能防止这类事情发生。通过清理用户输入,我们可以防止黑客在我们的应用程序中执行恶意代码并窃取用户数据。例如,我们应该通过移除可能存在风险的任何输入部分,防止我们的用户输入任何可能在我们的应用程序中执行的内容。

使用未优化的基础设施来托管我们的应用程序

使用未优化的基础设施来托管我们的应用程序,当从世界各地的不同部分访问时,会使我们的应用程序变慢。

现在我们已经讨论了一些不良的架构决策,让我们看看如何改进它们。

良好的架构决策

让我们看看我们可以做出的一些良好决策,以使我们的应用程序变得更好。

基于领域和功能的更好结构化的项目结构

将应用程序结构拆分为不同的功能或领域特定模块,每个模块负责其自身的角色,将允许更好地分离应用程序不同部分的关注点,更好地模块化应用程序的不同部分,更好的灵活性和可扩展性。

更好的状态管理

我们应该从定义尽可能接近组件中使用状态的部分的状态开始,只有在必要时才提升它,而不是将所有内容放入全局状态。

更小的组件

拥有更小的组件将使它们更容易测试、更容易跟踪更改,并且更容易在大团队中协作。

关注点分离

让每个组件尽可能少做。这使得组件易于理解、测试、修改,甚至重用。

静态代码分析

依赖于静态代码分析工具,如ESLintPrettierTypeScript,将提高我们的代码质量,而无需我们过多思考。我们只需要配置这些工具,它们就会告诉我们代码中有什么问题。这些工具还在代码库的格式、代码实践和文档方面引入了一致性。

在 CDN 上部署应用程序

由于用户遍布全球,我们的应用程序应该在全球范围内功能齐全且易于访问。通过在 CDN 上部署应用程序,全球用户都可以以最优化方式访问应用程序。

规划我们的应用程序

现在,让我们将刚刚学到的原则应用到实际场景中,我们将规划我们将要构建的应用程序。

我们在构建什么?

我们将构建一个应用程序,允许组织管理他们的工作板。组织管理员可以为他们的组织创建职位发布,候选人可以申请这些职位。

我们将构建一个具有最少功能集的最小可行产品(MVP)版本,但它应该可以扩展以添加更多功能。在本书的最后,我们将介绍最终应用程序可能具有的功能,但为了保持简单,我们将专注于 MVP 版本。

正确的应用规划始于收集应用程序的需求。

应用需求

应用程序有两种类型的应用需求:

  • 功能性需求

  • 非功能性需求

功能性需求

功能性需求应定义应用程序应该做什么。它们是我们用户将使用的所有功能和功能描述。

我们的应用程序可以分为两部分:

  • 公开部分

  • 组织管理员仪表板

公开部分

  • 包含我们应用程序一些基本信息的着陆页。

  • 公共组织视图,访客可以找到有关给定组织的详细信息。除了基本组织信息外,还应包括组织的职位列表。

  • 公共职位视图,访客可以查看有关给定职位的一些基本信息。除了这些信息外,还应包括申请职位的操作。

组织管理员仪表板

  • 用于仪表板的身份验证系统,应允许组织管理员登录仪表板。对于我们的最小可行产品(MVP),我们只需实现使用现有测试用户的登录功能。

  • 职位列表视图,管理员可以查看组织的所有职位。

  • 创建一个包含创建新工作表单的工作视图。

  • 包含有关职位所有信息的职位详情视图。

非功能性需求

非功能性需求应定义应用程序从技术角度应该如何工作:

  • 性能:应用程序必须在 5 秒内交互。这意味着用户应该在请求加载应用程序后 5 秒内与页面交互。

  • 可用性:应用程序必须用户友好且直观。这包括为小屏幕实现响应式设计。我们希望用户体验流畅且直接。

  • SEO:应用程序的公开页面应该是 SEO 友好的。

数据模型概述

为了更好地理解我们的应用程序在底层的工作方式,了解其数据模型是有帮助的,因此我们将在本节中深入探讨。

在下面的图中,我们可以看到从数据库的角度看我们的数据模型是什么样的:

图 1.2 – 数据模型概述

图 1.2 – 数据模型概述

图 1.2所示,应用程序中有三种主要模型:

  • 用户

  • 组织

  • 工作

定义应用程序需求和数据模型应该能让我们很好地理解我们正在构建的内容。现在,让我们来探讨我们应用程序的技术决策。

探索技术决策

让我们看看我们需要为我们的应用程序做出哪些技术决策。

项目结构

我们将使用基于功能的工程项目结构,这有助于良好的功能隔离和功能之间的良好沟通。

这意味着我们将为每个较大的功能创建一个功能文件夹,这将使应用程序结构更具可扩展性。

当功能数量增加时,它将扩展得非常好,因为我们只需要担心一个特定的功能,而不是整个应用程序,代码散布在各个地方。

我们将在接下来的章节中看到项目结构定义的实际应用。

渲染策略

当提到渲染策略时,我们指的是我们应用程序页面创建的方式。

让我们来看看不同的渲染策略类型:

  • 服务器端渲染:在互联网的早期,这是生成具有动态内容的页面的最常见方式。页面内容是即时创建的,在服务器上插入页面,然后返回给客户端。这种方法的好处是页面更容易被搜索引擎爬取,这对 SEO 很重要,并且与单页应用程序相比,用户可能会获得更快的页面初始加载速度。这种方法的不利之处在于可能需要更多的服务器资源。在我们的场景中,我们将使用这种方法来更新频繁且需要同时进行 SEO 优化的页面,例如公共组织页面和公共工作页面。

  • 客户端渲染:客户端 JavaScript 库和框架(如 React、Angular、Vue 等)的存在,使我们能够在客户端完全创建复杂的客户端应用程序。这种方法的优点是,一旦应用程序在浏览器中加载,页面之间的转换看起来非常快。另一方面,为了加载应用程序,我们需要下载大量的 JavaScript 来使用应用程序。这可以通过代码拆分和懒加载来改进。使用搜索引擎爬取页面内容也更加困难,这可能会影响 SEO 评分。我们可以使用这种方法来保护页面,也就是我们应用程序仪表板中的每一个页面。

  • 静态生成:这是最直接的方法。在这里,我们可以在构建应用程序的同时生成我们的页面,并以静态方式提供服务。这非常快,我们可以使用这种方法为那些永不更新但需要 SEO 优化的页面,例如我们应用程序的着陆页。

由于我们的应用程序需要多种渲染策略,我们将使用 Next.js,它非常出色地支持每种策略。

状态管理

状态管理可能是 React 生态系统中最常讨论的话题之一。它非常碎片化,这意味着有如此多的库处理状态,以至于让开发者难以做出选择。

为了使状态管理对我们来说更容易,我们需要理解存在多种状态类型:

  • 本地状态:这是最简单的状态类型。它是在单个组件中使用的状态,且不需要在其他任何地方使用。我们将使用内置的 React hooks 来处理这一点。

  • 全局状态:这是在应用程序的多个组件间共享的状态。它用于避免属性钻取。我们将使用一个名为Zustand的轻量级库来处理这一点。

  • 服务器状态:这种状态用于存储来自 API 的数据响应。像加载状态、请求去重、轮询等其他功能从头开始实现非常具有挑战性。因此,我们将使用React Query来优雅地处理这些功能,这样我们就有更少的代码要写。

  • 表单状态:这应该处理表单输入、验证和其他方面。我们将使用React Hook Form库来处理我们应用程序中的表单。

  • URL 状态:这种类型的状态经常被忽视,但非常强大。URL 和查询参数也可以被视为状态的一部分。当我们想要深度链接视图的某个部分时,这特别有用。在 URL 中捕获状态使其非常容易分享。

样式

样式也是 React 生态系统中的一个重要话题。有许多优秀的库用于样式化 React 组件。

为了样式化我们的应用程序,我们将使用Chakra UI组件库,它底层使用 Emotion,并附带各种看起来很好且无障碍友好的组件,这些组件非常灵活且易于修改。

选择 Chakra UI 的原因是它拥有极佳的开发者体验。它非常可定制,并且其组件默认就是无障碍友好的。

认证

我们应用程序的认证将基于 cookie,这意味着在成功的认证请求中,一个 cookie 将被附加到头部,这将处理服务器上的用户认证。我们选择基于 cookie 的认证是因为它更安全。

测试

测试是我们确保应用程序按预期工作的重要方法。

我们不希望我们的产品带有错误。此外,手动测试需要更多的时间和精力来发现新错误,因此我们希望为我们的应用程序拥有自动化测试。

存在多种类型的测试:

  • 单元测试:单元测试仅在隔离状态下测试应用程序的最小单元。我们将使用 Jest 对应用程序的共享组件进行单元测试。

  • 集成测试:集成测试同时测试多个单元。它们对于测试应用程序不同部分之间的通信非常有用。我们将使用 React Testing Library 测试我们的页面。

  • 端到端测试:端到端测试允许我们从头到尾测试应用程序最重要的部分,这意味着我们可以测试整个流程。通常,最重要的端到端测试应该测试最关键的功能。对于这种测试,我们将使用 Cypress

这是对我们的应用程序应该如何工作的概述。现在,我们应该能够开始在接下来的章节中用代码实现它。

摘要

React 是一个用于构建用户界面的非常流行的库,它将大多数架构选择留给了开发者,这可能具有挑战性。

在本章中,我们了解到设置良好架构的一些好处包括良好的项目基础、更易于项目管理、提高生产力、成本效益和更好的产品质量。

我们还了解到了需要考虑的挑战,例如项目结构、渲染策略、状态管理、样式、身份验证、测试以及其他。

然后,我们介绍了我们将要构建的应用程序的规划阶段,这是一个通过收集需求来管理职位板和职位申请的应用程序。我们通过定义应用程序的数据模型和选择合适的工具来克服架构挑战来实现这一点。

这为我们提供了一个良好的基础,可以在现实场景中实施我们的架构,正如我们将在以下章节中看到的那样。

在下一章中,我们将介绍我们将用于构建应用程序的整个设置。

第二章:设置和项目结构概述

在上一章中,我们探讨了构建 React 应用程序时遇到的挑战以及一些可以帮助我们处理这些挑战的出色解决方案。我们还规划了我们的应用程序应该如何工作以及我们应该使用哪些工具。

在本章中,我们将探讨项目结构和为我们的项目提供良好基线的设置工具。

我们将涵盖以下主题:

  • Next.js 应用程序概述

  • TypeScript 设置概述及使用

  • ESLint 设置概述

  • Prettier 设置概述

  • 预提交检查设置概述

  • 项目结构概述

到本章结束时,我们将对用于项目设置和基于功能的项目的工具有一个很好的理解,这将使我们的代码组织更加易于管理。

技术要求

在我们开始之前,我们需要设置我们的项目。为了能够开发我们的项目,我们需要在计算机上安装以下内容:

  • Node.js 版本 16 或更高版本和 npm 版本 8 或更高版本。

安装 Node.js 和 npm 有多种方式。这里有一篇很好的文章,详细介绍了更多细节:www.nodejsdesignpatterns.com/blog/5-ways-to-install-node-js

  • VSCode(可选)是目前最流行的 JavaScript/TypeScript 编辑器/IDE,因此我们将使用它。它是开源的,与 TypeScript 集成良好,并且我们可以通过扩展来扩展其功能。可以从 code.visualstudio.com/ 下载。

本章的代码文件可以在以下位置找到:github.com/PacktPublishing/React-Application-Architecture-for-Production

可以使用以下命令在本地克隆仓库:

git clone https://github.com/PacktPublishing/React-Application-Architecture-for-Production.git

克隆完仓库后,我们需要安装应用程序的依赖项:

npm install

我们还需要提供环境变量:

cp .env.example .env

依赖项安装完成后,我们需要选择与本章匹配的代码库的正确阶段。我们可以通过执行以下命令来完成:

npm run stage:switch

此命令将为我们显示每个章节的阶段列表:

? What stage do you want to switch to? (Use arrow
 keys)
❯ chapter-02
  chapter-03
  chapter-03-start
  chapter-04
  chapter-04-start
  chapter-05
  chapter-05-start
(Move up and down to reveal more choices)

这是第二章,因此我们可以选择 chapter-02 选项。

选择好章节后,所有必要的文件将显示出来。为了跟随本章,我们不需要对代码进行任何更改。我们只需将其作为参考,以帮助更好地了解代码库。

更多关于设置细节的信息,请查看 README.md 文件。

Next.js 应用程序概述

Next.js 是一个基于 React 和 Node.js 的 Web 框架,允许我们构建 Web 应用程序。因为它可以在服务器上运行,所以它可以作为一个全栈框架使用。

为什么选择 Next.js?

使用 Next.js 有多个好处。我们想使用它,原因如下:

  • 非常容易上手:在 React 的早期阶段,开始一个项目非常具有挑战性。为了在屏幕上显示一个简单的页面,我们必须处理许多工具,如 Webpack、Babel 等。我们今天仍在使用这些工具,但幸运的是,大多数工具配置都通过界面隐藏起来,如果需要,可以扩展配置。

除了设置项目时的挑战外,随着时间的推移维护所有这些依赖项也非常具有挑战性。Next.js 将所有这些复杂性隐藏起来,让开发者能够快速开始新的项目。

  • 允许多种渲染策略:能够使用多种渲染策略可能是我们想要使用 Next.js 的主要原因,尽管它还带来了其他许多好处。首先,它允许我们在页面级别定义页面渲染的行为,这意味着我们可以定义我们想要如何单独渲染每个页面。它还支持多种渲染策略,例如以下:

    • 客户端渲染

    • 服务器端渲染

    • 静态站点生成

    • 渐进式静态再生

我们将根据应用程序的需求使用不同的策略。

  • 性能优化:Next.js 是考虑到网络性能而构建的。它实现了以下性能优化技术:

    • 代码拆分

    • 懒加载

    • 预取

    • 图片优化

这就是为什么我们想要为我们的应用程序使用 Next.js 的原因。现在,让我们看看 Next.js 应用程序的结构是什么样的。

Next.js 应用程序结构

开始使用 Next.js 的最简单方法是使用create-next-app CLI 生成新应用程序。

由于我们已经在代码示例中作为部分生成了应用程序,我们不需要使用 CLI,但如果我们从头开始生成应用程序,我们将执行以下命令:

npx create-next-app@latest jobs-app --typescript

通过执行此命令,我们将生成一个带有 TypeScript 配置的新 Next.js 应用程序。

有几件事情是 Next.js 特有的。让我们看看以下简单 Next.js 应用程序的文件和文件夹结构:

- .next
- public
- src
  - pages
    - _app.tsx
    - index.tsx
- next.config.js
- package.json

让我们逐个分析每个文件和文件夹:

  • .next:包含通过运行 Next.js 的build命令生成的生产就绪文件。

  • public:包含应用程序的所有静态资源。

  • src/pages:这是 Next.js 中的一个特殊文件夹,其中定义的所有页面都可在相应的路由中访问。这是由于基于文件系统的路由系统实现的。pages文件夹也可以位于项目的根目录中,但将所有内容保持在src文件夹中会更好。

  • src/pages/_app.tsx_app.tsx文件是一个特殊的文件,它导出一个 React 组件,该组件在渲染时包裹每个页面。通过使用这个特殊组件包裹页面,我们可以为我们的应用程序添加自定义行为,例如向所有页面添加任何全局配置、提供者、样式、布局等。

  • src/pages/index.tsx: 这是我们声明应用程序页面的方式。这显示了根页面的定义。我们将在接下来的章节中深入探讨 Next.js 特定的路由。

  • next.config.js: 这是我们以简单方式扩展默认功能,如 Webpack 配置和其他事物的位置。

  • package.json: 每个 Next.js 应用程序都包含以下 npm 脚本:

    • dev: 在 localhost:3000 上启动开发服务器

    • build: 为生产构建应用程序

    • start: 在 localhost:3000 上启动生产构建

我们将在接下来的章节中更详细地介绍这些主题,但到目前为止,这应该足以让我们开始使用 Next.js。

TypeScript 设置概述和使用

JavaScript 是一种动态类型的编程语言,这意味着它在构建时不会捕获任何类型错误。这就是 TypeScript 发挥作用的地方。

TypeScript 是一种作为 JavaScript 超集的编程语言,它允许我们用静态类型语言的一些行为来编写 JavaScript。这很有用,因为我们可以在它们进入生产之前捕获许多潜在的错误。

为什么选择 TypeScript?

TypeScript 特别适用于由大型团队构建的大型应用程序。用 TypeScript 编写的代码比用纯 JavaScript 编写的代码文档更完善。通过查看类型定义,我们可以了解一段代码应该如何工作。

另一个原因是 TypeScript 使得重构变得更加容易,因为大多数问题可以在运行应用程序之前被发现。

TypeScript 还帮助我们利用编辑器的 IntelliSense,它显示智能代码补全、悬停信息和签名信息,这提高了我们的生产力。

TypeScript 设置

我们的项目已经配置了 TypeScript。TypeScript 配置定义在项目根目录下的 tsconfig.json 文件中。它允许我们根据我们的需求配置其严格程度:

{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "Node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["next-env.d.ts", "src"],
  "exclude": ["node_modules"]
}

我们不会深入到每个配置属性,因为大多数属性已经被自动生成。然而,还有一件事也被提供了:

   "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }

这将告诉 TypeScript 编译器,通过 @/* 导入的任何内容都将引用 src 文件夹。

以前,我们必须进行混乱的导入,如下所示:

import { Component } from '../../../components/component'

现在,我们可以这样导入组件:

import { Component } from '@/components/component'

无论我们有多少嵌套级别,我们都可以始终使用绝对路径进行导入,并且如果我们决定将消费者文件移动到其他地方,我们不需要更改我们的导入语句。

基本 TypeScript 用法

让我们了解一些 TypeScript 基础知识,以便我们在这本书中能够舒适地使用它。

原始类型

let numberVar: number;
numberVar = 1 // OK
numberVar = "1" // Error
let stringVar: string;
stringVar = "Hi"; // OK
stringVar = false; // Error
let stringVar: string;
stringVar = "Hi"; // OK
stringVar = false; // Error

正如我们所见,我们只能使用相应的类型赋值。将值赋给除 any 类型(我们将在下一刻介绍)之外的任何其他类型将导致 TypeScript 错误。

Any

any 类型是 TypeScript 中最宽松的类型,使用它将禁用任何类型检查。当我们想要绕过通常会发生的错误时,我们可以使用它。然而,我们应将其作为最后的手段,并首先尝试使用其他类型:

let anyVar: any;
anyVar = 1; // OK
anyVar = "Hello" // OK
anyVar = true; // OK
numberVar = anyVar; // OK

如我们所见,具有 any 类型的变量可以接受并分配为任何其他类型的值,这使得它非常灵活。

Unknown

有时,我们可能无法提前知道我们将会有哪些类型。这可能会发生在一些动态数据上,我们不知道它的类型。在这里,我们可以使用 unknown 类型:

let unknownVar: unknown;
unknownVar = 1; // OK
unknownVar = "123" // OK
let unknownVar2: unknown;
unknownVar = unknownVar2; // OK
anyVar = unknownVar2; // OK
numberVar = unknownVar2; // Error
stringVar = unknownVar2; // Error
booleanVar = unknownVar2; // Error

如我们所见,我们可以将任何类型的值分配给具有 unknown 类型的变量。然而,我们只能将具有 unknown 类型的值分配给具有 anyunknown 类型的变量。

数组

在 TypeScript 中定义数组类型有两种方式:

type numbers = number[]
type strings = Array<string>

对象

对象形状可以通过两种方式定义:

type Person = {
  name: string;
  age: number;
}
interface Person {
  name: string;
  age: number;
}

第一个被称为类型别名,而第二个被称为接口。

类型别名和接口之间有一些区别,但我们现在不会深入探讨。对于我们定义的任何对象形状类型,我们都可以使用类型别名。

联合

我们刚才提到的基本类型很棒,但有时我们希望允许一个变量可以是许多类型之一。让我们看看以下示例:

type Content = string | number;
let content: Content;
content = 1 // OK
content = "Hi"; // OK
content = false // Error

如我们所见,content 变量现在可以是 stringnumber

我们还可以在联合中添加字面量类型,如下面的示例所示:

type Color = "red" | "green" | "blue";
let color: Color;
color = "red" // OK
color = "yellow" // Error

在这里,我们正在将颜色定义为字符串,但我们想添加更多约束,以便我们只能接受这三种颜色之一。如果我们尝试添加其他任何东西,TypeScript 将通过错误警告我们。

交集

交集类型允许我们将两个不同对象的属性组合成一个单一的类型。考虑以下示例:

type Foo = {
  x: string;
  y: number;
}
type Bar = {
  z: boolean;
}
type FooBar = Foo & Bar;

FooBar 类型现在将包含 xyz 属性。

泛型

泛型是一种通过参数化创建可重用类型的机制。它们可以帮助我们减少代码重复。考虑以下类型:

type Foo = {
  x: number;
}

让我们看看如果我们需要相同的结构但 x 是字符串会发生什么:

type Foo = {
  x: string;
}

在这里,我们可以看到存在一些代码重复。我们可以通过使其泛型化来简化它,使其接受类型 T。这将作为 x 属性的类型分配:

type Foo<T> = {
  x: T;
}
let x: Foo<number>;
let y: Foo<string>;

现在,我们有一种通过向泛型传递不同类型来重用结构的好方法。

我们也可以在函数中使用泛型:

function logger<T>(value: T) {
  console.log(value)
}
logger<number>(1) // OK
logger<string>(1); // Error

要尝试这些代码片段并查看不同类型的行为,请访问 www.typescriptlang.org/play,复制代码片段,并尝试不同的类型以查看它们的工作方式。

TypeScript 和 React

每个使用 JSX 的 TypeScript 文件都必须有 .tsx 扩展名。

打印 React 组件非常简单:

type InfoProps = {
  name: string;
  age: number
};
const Info = (props: InfoProps) => {
  return <div>{props.name}-{props.age}</div>;
};

这些例子相当简单。在我们开始构建应用程序时,将在接下来的章节中看到更多实用的例子。要了解更多关于 TypeScript 的信息,建议查看 TypeScript 手册www.typescriptlang.org/docs,它详细介绍了所有这些主题。

ESLint 设置概述

代码检查是一个过程,其中代码检查器分析源代码并检测代码库中任何潜在的问题。

我们将使用ESLint,这是 JavaScript 中最受欢迎的代码检查工具。它可以配置不同的插件和规则,以适应我们应用程序的需求。

ESLint 配置定义在项目根目录下的.eslintrc.js文件中。我们可以添加不同的规则,通过不同的插件扩展它们,并覆盖应用到哪些文件上,以便它们满足我们应用程序的需求。

有时,我们不想检查每个文件夹和文件,因此我们可以通过在.eslintignore文件中定义它们来告诉 ESLint 忽略文件夹和文件。

ESLint 与编辑器和 IDE 有很好的集成,这样我们可以在编码时看到文件中的任何潜在问题。

要运行我们的代码检查器,我们在package.json中定义了代码检查脚本:

"lint": "eslint --ext .ts,.tsx ./src",

通过运行npm run lint,我们将检查src目录中的每个.ts.tsx文件,并且代码检查器将通知我们任何潜在的问题。

Prettier 设置概述

.prettierrc文件。它还会在代码出现问题时给我们提供良好的反馈。如果它没有自动格式化,那么代码中可能存在问题,需要修复。

Prettier 默认提供了一套配置。我们可以通过创建.prettierrc文件并修改配置来覆盖它。

就像 ESLint 一样,有时,有些文件我们不想自动格式化。我们可以通过将它们添加到.prettierignore文件中来告诉 Prettier 忽略文件和文件夹。

要运行 Prettier,我们在package.json中定义了一些脚本:

"prettier": "prettier \"**/*.+(json|ts|tsx)\"",
"format:check": "npm run prettier -- --check",
"format:fix": "npm run prettier -- --write",

如我们所见,我们可以运行npm run format:check来仅检查格式而不尝试修复它。如果我们想修复它,则可以运行npm run format:fix,这将修改需要修复的文件。

预提交检查设置概述

拥有像 TypeScript、ESLint 和 Prettier 这样的静态代码分析工具是很好的;我们已经配置了它们,并且每次我们进行一些更改时都可以运行单个脚本,以确保一切处于最佳状态。

然而,也有一些缺点。开发者可能会忘记在提交到仓库之前运行所有检查,这仍然会将问题和不一致的代码带到生产环境中。

幸运的是,有一个解决方案可以解决这个问题:每次我们尝试向仓库提交时,我们希望以自动化的方式运行所有检查。

这是我们要实现的工作流程:

图 2.1 – 预提交代码检查图

图 2.1 – 预提交代码检查图

如我们所见,每次我们尝试向仓库提交时,git pre-commit 钩子都会运行并执行检查脚本。如果所有检查都通过,更改将被提交到仓库;否则,我们必须修复问题并再次尝试。

为了启用这个流程,我们将使用 huskylint-staged

  • husky 是一个允许我们运行 git 钩子的工具。我们希望运行预提交钩子在提交更改之前进行检查。

  • lint-staged 是一个工具,它允许我们只在 Git 的暂存区域中的文件上运行这些检查。这提高了代码检查的速度,因为在整个代码库上执行可能会太慢。

我们已经安装并配置了这些工具,但如果我们没有,可以使用以下命令进行安装:

npm install –-save-dev husky lint-staged

然后,我们需要启用 Git 钩子:

npx husky install

然后,我们需要创建预提交钩子:

npx husky add .husky/pre-commit "npx lint-staged"

Husky 预提交钩子将运行 lint-staged。然后,我们需要在 lint-staged.config.js 文件中定义 lint-staged 应该运行的命令:

module.exports = {
  '*.{ts,tsx}': [
    'npm run lint',
    "bash -c 'npm run types:check'",
    'npm run format:check',
  ],
};

如果我们尝试提交包含任何违规的代码,它将失败并阻止我们提交更改。

现在我们已经覆盖了大部分设置,让我们看看我们项目的结构。

项目结构概述

如我们之前提到的,React 在项目结构方面非常灵活。

拥有良好项目结构的某些好处如下:

  • 关注点的分离

  • 更容易的重构

  • 更好的代码库推理

  • 更容易让大型团队同时处理代码库

让我们看看基于功能的工程项目结构是什么样的。

注意

我们将只关注 src 文件夹,因为从现在起,大部分代码库都存放在那里。

这里是我们的 src 文件夹的结构:

- components // (1)
- config // (2)
- features // (3)
- layouts // (4)
- lib // (5)
- pages // (6)
- providers // (7)
- stores // (8)
- testing // (9)
- types // (10)
- utils // (11)

让我们逐个分析每个文件夹:

  1. components:包含在整个应用程序中使用的所有共享组件。

  2. config:包含应用程序配置文件。

  3. features:包含所有基于功能的模块。我们将在下一节中更详细地分析这个模块。

  4. layouts:包含页面使用的不同布局。

  5. lib:包含我们在应用程序中使用的不同库的配置。

  6. pages:包含我们的应用程序页面。这是 Next.js 在基于文件系统的路由中查找页面的地方。

  7. providers:包含所有应用程序提供者。例如,如果我们的应用程序使用许多不同的提供者进行样式、状态等,我们可以在这里将它们组合起来,并导出一个单一的应用程序提供者,我们可以用这个提供者包裹 _app.tsx,使所有提供者对所有页面都可用。

  8. stores:包含在应用程序中使用的所有全局状态存储。

  9. testing:包含与测试相关的模拟、辅助工具、实用程序和配置。

  10. types:包含跨应用程序使用的基 TypeScript 类型定义。

  11. utils:包含所有共享的实用函数。

根据文件类型将文件分组到文件夹中并没有什么问题。然而,一旦应用程序开始增长,就变得更加难以推理和维护代码库,因为存在太多单一类型的文件。

功能

为了以最简单和最可维护的方式扩展应用程序,我们希望将大部分应用程序代码保存在features文件夹中,该文件夹应包含基于功能的不同事物。每个feature文件夹应包含特定功能的领域特定代码。这将使我们能够将功能的功能范围限定在功能内,而不是将其声明与共享事物混合。这比具有许多文件的扁平文件夹结构更容易维护。

让我们看看我们的一个功能文件夹,它具有以下结构:

- api // (1)
- components // (2)
- types // (3)
- index.ts // (4)
  1. api:包含与特定功能相关的 API 请求声明和 API 钩子。这使得我们的 API 层和 UI 层分离且可重用。

  2. components:包含所有特定于特定功能的部分组件。

  3. types:这包含特定功能的 TypeScript 类型定义。

  4. index.ts:这是每个功能的入口点。它作为功能的公共 API,应该只导出其他应用程序部分应该公开的东西。

注意

功能可能还有其他文件夹,例如hooksutils等,具体取决于功能的需要。唯一必需的文件是index.ts文件,它作为功能的公共 API。

让我们尝试用以下图表来可视化项目结构:

图 2.2 – 项目结构

图 2.2 – 项目结构

如我们所见,我们的大部分应用程序代码将存在于功能中。

我们还可以配置强制开发者通过index.ts文件导入功能代码,如下所示:

import {JobsList} from '@/features/jobs'

我们不应该这样做:

import {JobsList} from '@/features/jobs/components/jobs-
  list'

这将使我们更好地了解哪些依赖项在哪里使用以及它们来自何处。此外,如果功能被重构,它不会影响应用程序中该组件被使用的外部部分。

我们可以通过在.eslintrc.js文件中设置以下 ESLint 规则来约束我们的代码:

rules: {
    'no-restricted-imports': [
      'error',
      {
        patterns: ['@/features/*/*'],
      },
    ],
    'import/no-cycle': 'error',
      … rest of the eslint rules
}

no-restricted-imports规则将通过错误报告前一个模式中的任何违规行为来对其他功能的导入添加约束。

只有当它们从该功能的index.ts文件中导出时,才能消费来自功能的东西。这将迫使我们明确地将功能中的某些内容公开。

如果我们决定以这种方式使用功能,我们还应该包括import/no-cycle规则,以防止 A 功能从 B 功能导入东西,反之亦然的情况。如果发生这种情况,这意味着应用程序设计中有问题,需要重构。

在本节中,我们学习了我们的应用程序结构将如何呈现。然后,我们专注于按功能拆分应用程序,这样如果我们决定添加更多功能,我们的代码库将能够很好地扩展。

摘要

在本章中,我们学习了 Next.js 应用程序设置的基础知识,该设置已配置为与 TypeScript 一起使用。然后,我们学习了绝对导入,这将使移动文件变得更容易。我们还概述了 ESLint 和 Prettier,并将它们作为静态代码分析工具,以便通过使用 lint-staged 和 Husky 在将更改提交到我们的存储库之前运行检查。

最后,我们学习了我们的项目结构将如何呈现。我们了解到最佳方式是将代码按功能分组。我们还定义了一个 ESLint 规则,以强制以特定方式从功能中导入代码,并防止循环依赖,从而使代码库整洁且易于推理。

在下一章中,我们将创建作为我们应用程序用户界面基准的共享组件。

第三章:构建和记录组件

在 React 中,一切都是组件。这种范式允许我们将用户界面分割成更小的部分,从而使开发应用程序更容易。它还使组件可重用,因为我们可以在多个地方重用相同的组件。

在本章中,我们将构建一些组件,我们将使用这些组件作为应用程序用户界面的基础。这将使应用程序的 UI 更加一致,更容易理解和维护。我们还将学习如何使用 Storybook(一个优秀的工具,可以作为常见应用程序组件的目录)来记录组件。

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

  • Chakra UI

  • 构建组件

  • Storybook

  • 组件文档

到本章结束时,我们将学习如何创建和记录可重用的组件,我们可以使用这些组件来构建应用程序。

技术要求

在我们开始之前,我们需要设置项目。为了能够开发项目,您需要在您的计算机上安装以下内容:

  • Node.js版本 16 或更高版本以及npm版本 8 或更高版本。

安装 Node.js 和 npm 有多种方法。这里有一篇很好的文章,详细介绍了更多内容:

www.nodejsdesignpatterns.com/blog/5-ways-to-install-node-js

  • VSCode(可选)是目前最流行的 JavaScript/TypeScript 编辑器/IDE,因此我们将使用它。它是开源的,与 TypeScript 有很好的集成,并且您可以通过扩展来扩展其功能。您可以从这里下载:code.visualstudio.com/.

本章的代码文件可以在以下位置找到:github.com/PacktPublishing/React-Application-Architecture-for-Production.

可以使用以下命令在本地克隆存储库:

git clone https://github.com/PacktPublishing/React-Application-Architecture-for-Production.git

一旦克隆了存储库,我们需要安装应用程序的依赖项:

npm install

我们还需要提供环境变量:

cp .env.example .env

在安装了依赖项之后,我们需要选择与本章匹配的代码库的正确阶段。我们可以通过执行以下命令来完成:

npm run stage:switch

此命令将为我们显示每个章节的阶段列表:

? What stage do you want to switch to? (Use arrow
 keys)
❯ chapter-02
  chapter-03
  chapter-03-start
  chapter-04
  chapter-04-start
  chapter-05
  chapter-05-start
(Move up and down to reveal more choices)

这是第三章,所以如果您想跟随,可以选择chapter-03-start,或者选择chapter-03来查看章节的最终结果。

一旦选择了章节,所有必要的文件将显示出来以跟随章节。

要跟随本章,您不需要对代码进行任何更改。您可以使用它作为参考,以帮助更好地了解代码库。

关于设置细节的更多信息,请查看README.md文件。

Chakra UI

每当我们为应用程序构建 UI 时,我们必须决定使用什么来为我们的组件进行样式设计。此外,我们还必须考虑我们是否希望从头开始构建所有组件或使用带有预制组件的组件库。

使用组件库的优势在于,它为我们提供了生产力提升,因为我们不需要实现已经实现过的组件,例如按钮、对话框和标签页。此外,一些库默认提供了出色的可访问性,因此我们不必像从头开始构建那样过多地考虑它。这些库可能存在一些成本,例如难以定制或对最终包大小有重大影响。另一方面,它们为我们节省了大量开发时间。

对于我们的应用程序,我们将使用 Chakra UI,这是一个基于 emotionstyled-system 组合构建的组件库,它将允许我们以一致的方式在 JavaScript 中编写 CSS。

Chakra UI 设置

我们已经安装了 Chakra UI 库,现在我们需要对其进行配置。

要使用 Chakra UI,首先,我们需要配置其主题提供者以启用其组件的样式。由于我们所有的提供者和包装器都在 src/providers/app.tsx 中定义,我们可以在那里添加 ChakraProvider

import {
  ChakraProvider,
  GlobalStyle,
} from '@chakra-ui/react';
import { ReactNode } from 'react';
import { theme } from '@/config/theme';
type AppProviderProps = {
  children: ReactNode;
};
export const AppProvider = ({
  children,
}: AppProviderProps) => {
  return (
    <ChakraProvider theme={theme}>
      <GlobalStyle />
      {children}
    </ChakraProvider>
  );
};

在这里,我们正在将整个应用程序包裹在提供者中,以应用主题和样式到所有 Chakra UI 组件。我们还渲染了 GlobalStyles 组件,它将接受来自我们的主题的任何全局样式并将其应用到应用程序中。

Chakra UI 设置和组件非常可定制,可以在自定义主题中配置,我们可以将其传递给提供者。它将覆盖默认的主题配置。让我们在 src/config/theme.ts 中配置主题,添加以下内容:

import { extendTheme } from '@chakra-ui/react';
const colors = {
  primary: '#1a365d',
  primaryAccent: '#ffffff',
};
const styles = {
  global: {
    'html, body': {
      height: '100%',
      bg: 'gray.50',
    },
    '#__next': {
      height: '100%',
      bg: 'gray.50',
    },
  },
};
export const theme = extendTheme({ colors, styles });

我们正在定义一些将通过 GlobalStyles 组件注入的全局样式,我们已经在 AppProvider 中添加了它。我们还定义了希望在组件中可用的主题颜色。然后,我们通过使用 extendTheme 工具将这些配置与默认主题值结合,该工具将合并所有配置并给我们完整的主题对象。

将主题配置集中化是有用的,因为如果应用程序的品牌发生变化,它很容易使用和更改。例如,我们可以轻松地在单个位置更改主颜色值,并将其应用到整个应用程序,而无需进行任何额外的更改。

构建组件

现在 Chakra UI 的设置已经就绪,我们可以构建组件。在本章的起始文件中,我们已经导出了一些默认组件。目前,我们可以在 src/pages/index.tsx 中定义的着陆页上渲染它们,如下所示:

import { Button } from '@/components/button';
import { InputField } from '@/components/form';
import { Link } from '@/components/link';
const LandingPage = () => {
  return (
    <>
      <Button />
      <br />
      <InputField />
      <br />
      <Link />
    </>
  );
};
export default LandingPage;

要启动应用程序开发服务器,我们需要运行以下命令:

npm run dev

这将使新创建的页面在http://localhost:3000上可用。开发服务器将监听我们做出的任何更改,并使用最新的更改自动刷新页面。

首页将显示组件。如果我们打开http://localhost:3000,我们应该看到以下内容:

图 3.1 – 首页上初始组件的预览

图 3.1 – 首页上初始组件的预览

目前组件并没有做太多,所以我们需要专注于它们的实现。

按钮

让我们从实现Button组件开始,这是每个应用程序中最常见的组件之一。该组件已经在src/components/button/button.tsx中创建,但我们需要对其进行修改。

让我们先导入其依赖项:

import { Button as ChakraButton } from '@chakra-ui/react';
import { MouseEventHandler, ReactNode } from 'react';

现在,我们可以创建variants对象,它将包含我们按钮的所有样式属性,并将相应地应用于默认的 Chakra UI Button组件:

const variants = {
  solid: {
    variant: 'solid',
    bg: 'primary',
    color: 'primaryAccent',
    _hover: {
      opacity: '0.9',
    },
  },
  outline: {
    variant: 'outline',
    bg: 'white',
    color: 'primary',
  },
};

然后,我们可以为Button组件键入属性:

export type ButtonProps = {
  children: ReactNode;
  type?: 'button' | 'submit' | 'reset';
  variant?: keyof typeof variants;
  isLoading?: boolean;
  isDisabled?: boolean;
  onClick?: MouseEventHandler<HTMLButtonElement>;
  icon?: JSX.Element;
};

键入组件的属性是一种很好的方式来描述其 API,这对于记录其使用方式非常有用。

现在,我们可以创建Button组件,它只是 Chakra UI 提供的默认Button组件的包装器:

export const Button = ({
  variant = 'solid',
  type = 'button',
  children,
  icon,
  ...props
}: ButtonProps) => {
  return (
    <ChakraButton
      {...props}
      {...variants[variant]}
      type={type}
      leftIcon={icon}
    >
      {children}
    </ChakraButton>
  );
};

然后,我们可以按照以下方式更新src/pages/index.tsx中的Button组件的使用:

<Button variant="solid" type="button">
  Click Me
</Button>

输入字段

输入字段组件是我们构建表单时想要使用的输入组件。让我们更改src/components/form/input-field.tsx

首先,我们需要导入所有依赖项:

import {
  FormControl,
  FormHelperText,
  FormLabel,
  forwardRef,
  Input,
  Textarea,
} from '@chakra-ui/react';
import {
  FieldError,
  UseFormRegister,
} from 'react-hook-form';

然后,我们为组件的属性定义类型:

export type InputFieldProps = {
  type?: 'text' | 'email' | 'password' | 'textarea';
  label?: string;
  error?: FieldError;
} & Partial<
  ReturnType<UseFormRegister<Record<string, unknown>>>
>;

最后,我们实现组件本身:

export const InputField = forwardRef(
  (props: InputFieldProps, ref) => {
    const {
      type = 'text',
      label,
      error,
      ...inputProps
    } = props;
    return (
      <FormControl>
        {label && <FormLabel>{label}</FormLabel>}
        {type === 'textarea' ? (
          <Textarea
            bg="white"
            rows={8}
            {...inputProps}
            ref={ref}
          />
        ) : (
          <Input
            bg="white"
            type={type}
            {...inputProps}
            ref={ref}
          />
        )}
        {error && (
          <FormHelperText color="red">
            {error.message}
          </FormHelperText>
        )}
      </FormControl>
    );
  }
);

如您所见,我们正在构建一个输入字段组件,我们可以使用react-hook-form库来创建表单,我们将在接下来的章节中学习如何做到这一点。注意我们是如何使用forwardRef包装组件的。这将允许我们在必要时传递对组件的引用。

让我们更新其在src/pages/index.tsx中的使用:

<InputField label="Name" />

链接

对于链接,我们将使用 Next.js 提供的Link组件。然而,我们希望集中配置和样式,并在所有地方使用它。让我们修改src/components/link/link.tsx

首先,让我们导入所有依赖项:

import { Button } from '@chakra-ui/react';
import NextLink from 'next/link';
import { ReactNode } from 'react';

与我们对Button组件所做的方式类似,我们希望允许链接接受一些变体,这将向组件应用额外的样式属性:

const variants = {
  link: {
    variant: 'link',
    color: 'primary',
  },
  solid: {
    variant: 'solid',
    bg: 'primary',
    color: 'primaryAccent',
    _hover: {
      opacity: '0.9',
    },
  },
  outline: {
    variant: 'outline',
    color: 'primary',
    bg: 'white',
  },
};

然后,我们定义组件属性的类型:

export type LinkProps = {
  href: string;
  children: ReactNode;
  variant?: keyof typeof variants;
  icon?: JSX.Element;
  shallow?: boolean;
};

这里是Link组件的实现。注意我们是如何使用 Next.js 中的Link组件来包装 Chakra UI 中的Button组件的:

export const Link = ({
  href,
  children,
  variant = 'link',
  icon,
  shallow = false,
}: LinkProps) => {
  return (
    <NextLink shallow={shallow} href={href} passHref>
      <Button
        leftIcon={icon}
        as="a"
        {...variants[variant]}
      >
        {children}
      </Button>
    </NextLink>
  );
};

为什么我们使用 Button 组件而不是 Chakra UI 的 Link?我们本可以使用 Link,但我们希望大多数链接看起来和按钮一样,所以原因只是风格偏好。注意我们是如何将 as="a" 传递给 Button 的。这将使元素成为一个锚点,这在可访问性方面是正确的,并且该组件将在 DOM 中作为链接元素渲染。

让我们在 src/pages/index.tsx 中更新其使用情况:

<Link href="/">Home</Link>

注意,我们无法提前预测和构建所有共享组件。有时在我们开发过程中,我们会意识到某些东西需要被抽象化。预测组件的所有边缘情况也很具有挑战性,因此过早地抽象化可能会在长期内使事情变得复杂。

目前,我们已经抽象化了我们将肯定使用的最通用的组件,保持它们原样。

记住,每个组件的实现细节并不重要。如果你不理解它们所做的一切以及它们是如何工作的,那没关系。关键是要点是我们希望抽象出最常见的组件,以便在需要时可以重用它们。

由于大多数组件库都非常通用,提供了许多选项以满足每个人的需求,因此创建我们围绕其默认组件的包装器是一个好主意,以减少默认 API 表面和适应应用程序的需求。这将减少具有太多配置选项和我们将永远不会使用的属性组件的冗余。此外,它将带来一致性,因为开发者被限制在更少的选择范围内。

让我们看看我们的索引页面,其中渲染了组件:

图 3.2 – 登录页面组件预览

图 3.2 – 登录页面组件预览

太好了!现在你可以尝试使用不同的属性,看看组件的表现如何。

我们的组件工作正常,并准备好在应用程序中使用。然而,有几个问题:

  • 我们正在占用索引路由。当我们想用它来执行一些有意义的事情,比如登录页面时,会发生什么?我们将无法使用该页面来预览我们的组件。当然,我们可以创建并使用另一个永远不会被使用的页面,但这也不是一个好的选择。

  • 我们不想一起显示所有组件,因为这会很混乱,最好是在隔离状态下尝试它们。

  • 我们想尝试组件的属性,但当前的方法无法做到,因为我们必须修改代码。

让我们在下一节中看看如何解决这些问题,并在不更改应用程序代码的情况下隔离开发和尝试组件。

Storybook

Storybook 是一个允许我们在隔离状态下开发和测试 UI 组件的工具。我们可以将其视为制作所有组件目录的工具。它非常适合文档化组件。使用 Storybook 的几个好处包括以下内容:

  • Storybook 允许在无需复制应用确切状态的情况下独立开发组件,使开发者能够专注于他们正在构建的事物

  • Storybook 作为 UI 组件的目录,允许所有利益相关者尝试组件而无需在应用程序中使用它们

Storybook 的配置可以通过以下命令完成:

npx storybook init

此命令将安装所有必需的依赖项,并设置位于项目根目录 .storybook 文件夹中的配置。

Storybook 配置

我们已经安装了 Storybook,现在让我们看看配置,它包含两个文件。

第一个文件包含主要配置,它控制 Storybook 服务器的行为以及如何处理我们的故事。它位于 .storybook/main.js

const path = require('path');
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
module.exports = {
  stories: ['../src/**/*.stories.tsx'],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
    '@chakra-ui/storybook-addon',
  ],
  features: {
    emotionAlias: false,
  },
  framework: '@storybook/react',
  core: {
    builder: '@storybook/builder-webpack5',
  },
  webpackFinal: async (config) => {
    config.resolve.plugins = config.resolve.plugins || [];
    config.resolve.plugins.push(
      new TsconfigPathsPlugin({
        configFile: path.resolve(
          __dirname,
          '../tsconfig.json'
        ),
      })
    );
    return config;
  },
};

主要配置包含以下属性:

  • stories:一个表示我们的故事位置的 glob 数组。

  • addons:用于增强 Storybook 默认行为的插件列表。

  • features:启用 Storybook 的附加功能。

  • framework:框架特定的配置。

  • core:内部功能配置。

  • webpackFinal:扩展默认 webpack 配置的配置。我们通过告诉 Storybook 使用 tsconfig.json 文件中的路径来启用绝对导入。

第二个配置文件控制故事在 UI 中的渲染方式。此配置位于 .storybook/preview.js

import { theme } from '../src/config/theme';
export const parameters = {
  actions: { argTypesRegex: '^on[A-Z].*' },
  controls: {
    matchers: {
      color: /(background|color)$/i,
      date: /Date$/,
    },
  },
  controls: { expanded: true },
  chakra: {
    theme,
  },
};

注意我们是如何在 parameters 中的 chakra 属性中传递主题的。这将使 Chakra 主题能够应用于 Storybook 中的组件。

我们可以选择导出装饰器,这将包装所有故事。如果组件依赖于我们希望在所有故事中可用的某些提供者,这很有用。

Storybook 脚本

我们的故事书设置有两个 npm 脚本:

  • 在开发中运行 Storybook

要启动开发服务器,我们可以执行以下命令:

npm run storybook

此命令将在 http://localhost:6006/ 打开 Storybook。

  • 为生产构建 Storybook

我们还可以生成并部署故事,以便在不运行开发服务器的情况下可见。要构建故事,我们可以执行以下命令:

npm run storybook:build

生成的文件可以在 storybook-static 文件夹中找到,并且可以部署到任何地方。

现在我们已经熟悉了设置,是时候为组件编写故事了。

组件文档化

如果我们从上一节回忆起来,.storybook/main.js 中的配置具有以下 stories 属性:

stories: ['../src/**/*.stories.tsx']

这意味着 src 文件夹中以 .stories.tsx 结尾的任何文件都应由 Storybook 选择并作为故事处理。换句话说,我们将将与组件并列放置故事,因此每个组件的结构将类似于以下内容:

components
  my-component
    my-component.stories.tsx
    my-component.tsx
    index.ts

我们将根据 组件故事格式CSF),一个编写组件示例的开放标准,创建我们的故事。

但首先,什么是故事?根据 CSF 标准,一个故事应该代表一个组件的单个真相来源。我们可以将故事视为一个用户故事,其中组件以相应的状态呈现。

CSF 需要以下内容:

  • 默认导出应该定义关于组件的元数据,包括组件本身、组件的名称、装饰器和参数

  • 命名导出应定义所有故事

现在,让我们为组件创建故事。

按钮故事

要为 Button 组件创建故事,我们需要创建一个 src/components/button/button.stories.tsx 文件。

然后,我们可以开始添加所需的导入:

import { PlusSquareIcon } from '@chakra-ui/icons';
import { Meta, Story } from '@storybook/react';
import { Button, ButtonProps } from './button';

然后,我们创建元配置对象:

const meta: Meta = {
  title: 'Components/Button',
  component: Button,
};
export default meta;

注意,我们将其作为默认导出。根据 CSF,这是 Storybook 所要求的。

由于我们可以有多个故事,我们必须创建一个故事模板:

const Template: Story<ButtonProps> = (props) => (
  <Button {...props} />
);

然后,我们可以导出第一个故事:

export const Default = Template.bind({});
Default.args = {
  children: 'Click Me',
};

我们可以将所需的任何属性传递给附加到故事的 args 对象,这将反映在 Storybook 的故事中。

我们可以为另一个故事做同样的事情,其中我们想要有一个带有图标的 Button 版本:

export const WithIcon = Template.bind({});
WithIcon.args = {
  children: 'Click Me',
  icon: <PlusSquareIcon />,
};

要查看故事,请执行以下命令:

npm run storybook

现在,让我们访问 http://localhost:6006/

图 3.3 – 按钮组件故事

图 3.3 – 按钮组件故事

然后,我们单独预览了 Button 组件。注意底部的控制面板。这为我们提供了一个友好的界面来操作组件的属性,而无需接触代码。

这难道不是比我们在首页上渲染组件时更好吗?我们可以将故事部署到任何地方,并允许非技术人员在没有编码知识的情况下实验组件。

练习

为了巩固你对 Storybook 的理解,让我们尝试一些练习。继续创建以下组件的故事:

  • 输入字段

    • 默认故事

    • 带有错误故事

  • 链接

    • 默认故事

    • 带图标的故事

摘要

在本章中,我们的重点是构建我们将重用在我们的应用程序中的基础组件。

我们首先配置了 Chakra UI 提供者和主题。然后,为了测试目的,我们在首页上显示组件。它们没有做什么,所以我们实现了它们。定义共享组件的目的在于我们可以将它们在任何地方重用,这从长远来看使开发更容易。组件在这里所做的事情并不重要。重要的是要考虑将创建共享组件作为应用程序的基础。

我们随后需要在某处预览组件,由于在页面上这样做不是一个非常优雅的解决方案,我们选择了 Storybook。我们介绍了其配置,然后为 Button 组件定义了几个故事。这些故事是用 组件故事格式CSF) 编写的,这是一种编写组件示例的标准。

作为本章结束时的练习,还有更多的故事需要实现,这应该能够巩固到目前为止的所有学习成果。

在下一章中,当我们开始创建我们的页面时,我们将使用这些组件。

第四章:构建和配置页面

在前面的章节中,我们已经配置了应用程序的基础,包括应用程序的设置和共享 UI 组件,这些组件将作为我们 UI 的基础。

在本章中,我们可以通过创建应用程序页面来继续前进。我们将学习 Next.js 中的路由是如何工作的,以及我们可以使用哪些渲染方法来充分利用 Next.js。然后,我们将学习如何配置每个页面的布局,使我们的应用程序看起来和感觉像一个单页应用程序。

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

  • Next.js 路由

  • Next.js 渲染策略

  • Next.js SEO

  • 布局

  • 构建页面

到本章结束时,我们将学习如何在 Next.js 中创建页面,并更好地了解根据应用程序的需求选择不同的渲染策略。

技术要求

在我们开始之前,我们需要设置项目。为了能够开发项目,你需要在你的计算机上安装以下内容:

  • Node.js 版本 16 或以上和 npm 版本 8 或以上。

安装 Node.js 和 npm 有多种方法。这里有一篇很好的文章,详细介绍了更多细节:

www.nodejsdesignpatterns.com/blog/5-ways-to-install-node-js

  • VSCode(可选)目前是 JavaScript/TypeScript 最受欢迎的编辑器/IDE,因此我们将使用它。它是开源的,与 TypeScript 集成良好,并且你可以通过扩展来扩展其功能。可以从 code.visualstudio.com/ 下载。

本章的代码文件可以在以下位置找到:github.com/PacktPublishing/React-Application-Architecture-for-Production

可以使用以下命令在本地克隆存储库:

git clone https://github.com/PacktPublishing/React-Application-Architecture-for-Production.git

一旦克隆了存储库,我们需要安装应用程序的依赖项:

npm install

我们还需要提供环境变量:

cp .env.example .env

一旦安装了依赖项,我们需要选择与本章匹配的代码库的正确阶段。我们可以通过执行以下命令来完成:

npm run stage:switch

此命令将为我们显示每个章节的阶段列表:

? What stage do you want to switch to? (Use arrow
 keys)
❯ chapter-02
  chapter-03
  chapter-03-start
  chapter-04
  chapter-04-start
  chapter-05
  chapter-05-start
(Move up and down to reveal more choices)

这是第四章,所以如果你想跟随,可以选择 chapter-04-start,或者选择 chapter-04 来查看章节的最终结果。一旦选择了章节,所有必要的文件都会出现,以便跟随章节内容。

要跟随本章内容,你不需要对代码进行任何更改。你可以将其作为参考,以帮助更好地了解代码库。

关于设置细节的更多信息,请查看 README.md 文件。

Next.js 路由

Next.js 有一个基于文件系统的路由器,其中每个页面文件代表一个页面。页面是存在于 pages 文件夹中的特殊文件,它们具有以下结构:

const Page = () => {
     return <div>Welcome to the page!</div>
}
export default Page;

如您所见,只需将page组件作为默认导出即可;这是定义页面所需的最小要求。我们将在稍后看到还可以从页面导出什么。

由于路由是基于文件系统的,路由由页面文件的命名方式确定。例如,指向根路由的页面应在src/pages/index.tsx文件中定义。如果我们想定义关于页面,我们可以在src/pages/about.tsx中定义它。

对于任何具有动态数据的复杂应用程序,仅创建预定义页面是不够的。例如,假设我们有一个社交网络应用程序,我们可以访问用户个人资料。个人资料应该通过用户的 ID 加载。由于为每个用户创建页面文件会过于重复,我们需要使页面动态化,如下所示:

// pages/users/[userId].tsx
import { useRouter } from 'next/router';
const UserProfile = () => {
     const router = useRouter();
     const userId = router.query.userId;
     return <div>User: {userId}</div>;
}
export default UserProfile

要获取 ID 并动态加载数据,我们可以在pages/users/[userId].tsx中定义一个通用的用户个人资料页面,其中userId将动态注入到页面中。例如,访问/users/123将显示用户个人资料页面,并通过路由器的query属性将123的值作为userId传递。

Next.js 渲染策略

Next.js 支持四种不同的渲染策略:

  • 客户端渲染:在这里,我们可以在服务器上加载初始内容,然后从客户端获取附加数据。

  • 服务器端渲染:在这里,我们可以在服务器上获取数据,将其注入到页面中,并带有提供的数据将页面返回给客户端。

  • 静态站点生成:在这里,静态数据被注入到页面中,并以标记的形式返回给客户端。

  • 增量静态再生:服务器端渲染和静态站点生成之间的折中方案。我们可以静态生成x个页面,然后如果请求尚未渲染和缓存的页面,Next.js 可以在服务器上渲染它并为其未来的请求缓存它。

对于我们的应用程序,我们将主要关注前两种方法,让我们看看以下示例中它们是如何工作的。

客户端渲染

考虑到用户个人资料页面示例,我们可以通过以下方式执行客户端渲染:

// pages/users/[userId].tsx
import { useRouter } from 'next/router';
import { useUser } from './api';
const UserProfile = () => {
     const router = useRouter();
     const userId = router.query.userId;
     const { user, isLoading } = useUser(userId);
     if(!user && isLoading) return <div>Loading...</div>;
     if(!user) return <div>User not found!</div>;
     return <div>User: {user.name}</div>;
}

如我们所见,我们正在使用userId来获取用户数据。在这个例子中,我们在客户端执行此操作,这意味着服务器最初将渲染以下标记:

<div>Loading...</div>

只有在客户端获取数据后,用户数据才会显示:

<div>User: {user.name}</div>

这在关注 SEO 和初始页面加载性能的情况下是可行的。这里我们必须等待初始页面加载,然后获取用户数据。这种方法对于不应公开的数据,如管理仪表板,是完全有效的。

然而,对于公共页面,让服务器返回实际的标记给客户端是一个好主意,这样可以使搜索引擎更容易爬取和索引我们的页面。我们可以通过服务器端渲染页面来实现这一点。

服务器端渲染

让我们回顾一下用户个人资料页面的示例,这次是在服务器上渲染:

// pages/users/[userId].tsx
import { useRouter } from 'next/router';
import { getUser } from './api';
const UserProfile = ({ user }) => {
     const router = userRouter();
     const userId = router.query;
     const { user } = useUser(userId);
     if(!user) return <div>User not found!</div>;
     return <div>User: {user.name}</div>;
}
export const getServerSideProps = async ({ params }) => {
     const userId = params.userId;
     const user = await getUser(userId);
     return {
          props: {
               user
          }
     }
}

正如我们所见,除了页面组件外,page文件还导出了getServerSideProps函数,该函数在服务器上执行。它的返回值可以包含props,这些props被传递到组件的属性中。

服务器将渲染以下标记:

<div>User: {user.name}</div>

包含用户数据的完整标记将在初始渲染时可用。

让我们记住,没有一种完美的渲染策略适用于所有用例;因此,我们必须权衡利弊,并根据我们的需求选择使用哪种策略。Next.js 的伟大之处在于它允许我们根据每个页面使用不同的渲染策略,这样我们就可以将它们结合起来,以最佳方式满足应用程序的需求。

Next.js SEO

为了提高我们页面的 SEO,我们应该添加一些元标签和页面的标题,并将它们注入到页面中。这可以通过 Next.js 提供的Head组件来完成。

对于应用程序,我们希望有一个专门的组件,我们可以添加页面的标题。让我们打开src/components/seo/seo.tsx文件并添加以下内容:

import Head from 'next/head';
export type SeoProps = {
  title: string;
};
export const Seo = ({ title }: SeoProps) => {
  return (
    <Head>
      <title>{title}</title>
    </Head>
  );
};

Head组件将把其内容注入到页面的head中。目前,标题就足够了,但如果需要,它可以扩展以添加不同的元标签。

让我们在src/pages/index.tsx的着陆页中添加Seo组件。

首先,让我们导入组件:

import { Seo } from '@/components/seo';

然后,我们可以在组件的顶部添加它:

const LandingPage = () => {
  return (
    <>
      <Seo title="Jobs App" />
      <Center>
      {/* rest of the component */}
      </Center>
    </>
  );
};
export default LandingPage

布局

当开发具有多个视图或页面的应用程序时,我们需要考虑布局的可重用性。

考虑以下示例:

图 4.1 – 布局示例

图 4.1 – 布局示例

我们可以看到,在两个页面中,导航栏和页脚是相同的,而主要内容位于中间,因此使其可重用是一个好主意。

向页面添加layout组件有两种方式:

  • 将每个页面的返回 JSX 包裹在布局组件中

  • 将布局附加到页面组件上,并使用它来包裹整个组件

将每个页面的 JSX 包裹在布局组件中

假设我们有一个可以包裹每个页面内容的布局组件:

const Layout = ({ children }) => {
     return (
          <div>
               <Header />
               {children}
               <Footer />
          </div>
     )
}

我们可以将Layout组件添加到页面中,如下所示:

const Page1 = () => {
     const user = useUser();
     if (!user) {
          return (
               <Layout>
                    <div>Unauthenticated!</div>
               </Layout
          )
     }
     return (
          <Layout>
               <h1>Page 1</h1>
          </Layout
     )
}

在 Next.js 应用程序中处理布局的这种方式对于一些简单情况来说是可行的。然而,它也有一些缺点,如下列所示:

  • 如果Layout组件跟踪一些内部状态,当页面更改时它将丢失这些状态

  • 页面将丢失其滚动位置

  • 我们想在最终返回之前返回的任何内容,也需要用Layout包裹

对于我们的应用程序,我们将使用一种更好的方式来处理每个页面的布局,通过将其附加到页面组件上。让我们在以下部分中看看它是如何工作的。

将布局附加到页面组件上,并使用它来包裹整个组件

为了使这起作用,我们首先需要更新src/pages/_app.tsx文件:

import { NextPage } from 'next';
import type { AppProps } from 'next/app';
import { ReactElement, ReactNode } from 'react';
import { AppProvider } from '@/providers/app';
type NextPageWithLayout = NextPage & {
  getLayout?: (page: ReactElement) => ReactNode;
};
type AppPropsWithLayout = AppProps & {
  Component: NextPageWithLayout;
};
const App = ({
  Component,
  pageProps,
}: AppPropsWithLayout) => {
  const getLayout =
    Component.getLayout ?? ((page) => page);
  const pageContent = getLayout(
    <Component {...pageProps} />
  );
  return <AppProvider>{pageContent}</AppProvider>;
};
export default App;

页面组件期望附加getLayout静态属性,它将在_app.tsx中渲染整个组件时被用来包装。多亏了 React 的协调,当在具有相同布局的页面之间导航时,所有布局组件的状态都将保持。

我们已经构建了布局组件,只需将它们添加到我们的页面中。

现在我们已经准备好了所有东西,让我们构建我们的页面。

构建页面

现在我们已经熟悉了 Next.js 页面的工作方式,并准备好了Seo组件和布局设置,让我们实现应用程序的页面。我们将实现以下页面:

  • 公共组织详情页面

  • 公共工作详情页面

  • 仪表板中的工作页面

  • 仪表板中的工作详情页面

  • 创建工作页面

  • 404 页面

公共组织详情页面

公共组织详情页面是任何用户都可以看到特定组织所有详情及其工作列表的页面。由于这是一个公开页面,我们希望它在服务器上渲染以获得更好的 SEO。

要创建页面,让我们创建src/pages/organizations/[organizationId]/index.tsx文件,其中organizationId指的是组织的动态 ID,它将被用来检索指定的组织。

然后,让我们导入所有依赖项:

import { Heading, Stack } from '@chakra-ui/react';
import {
  GetServerSidePropsContext,
  InferGetServerSidePropsType,
} from 'next';
import { ReactElement } from 'react';
import { NotFound } from '@/components/not-found';
import { Seo } from '@/components/seo';
import { JobsList, Job } from '@/features/jobs';
import { OrganizationInfo } from '@/features/
  organizations';
import { PublicLayout } from '@/layouts/public-layout';
import {
  getJobs,
  getOrganization,
} from '@/testing/test-data';

现在,让我们实现页面组件:

type PublicOrganizationPageProps =
  InferGetServerSidePropsType<typeof getServerSideProps>;
const PublicOrganizationPage = ({
  organization,
  jobs,
}: PublicOrganizationPageProps) => {
  if (!organization) return <NotFound />;
  return (
    <>
      <Seo title={organization.name} />
      <Stack
        spacing="4"
        w="full"
        maxW="container.lg"
        mx="auto"
        mt="12"
        p="4"
      >
        <OrganizationInfo organization={organization} />
        <Heading size="md" my="6">
          Open Jobs
        </Heading>
        <JobsList
          jobs={jobs}
          organizationId={organization.id}
          type="public"
        />
      </Stack>
    </>
  );
};

页面组件接受organizationjobs作为 props。props 由 Next.js 自动传递给页面。传递给页面组件的 props 由getServerSideProps函数的返回值决定,该函数在服务器上执行并启用服务器端渲染。我们将在稍后看到它的实现,但现在,让我们连接布局:

PublicOrganizationPage.getLayout = function getLayout(
  page: ReactElement
) {
  return <PublicLayout>{page}</PublicLayout>;
};

这是我们将根据我们刚刚配置的设置来使用布局的方式。getLayout函数将包装页面组件,并将应用布局。如果需要,我们还可以嵌套多个布局,因此这种方法非常灵活。

现在,让我们导出我们的页面,它必须以default导出:

export default PublicOrganizationPage;

然后,让我们实现getServerSideProps函数:

export const getServerSideProps = async ({
  params,
}: GetServerSidePropsContext) => {
  const organizationId = params?.organizationId as string;
  const [organization, jobs] = await Promise.all([
    getOrganization(organizationId).catch(() => null),
    getJobs(organizationId).catch(() => [] as Job[]),
  ]);
  return {
    props: {
      organization,
      jobs,
    },
  };
};

我们正在从params中提取组织的 ID,并使用它来获取组织和其工作,然后我们将其作为 props 返回,这些 props 将被传递给页面组件。getServerSideProps函数必须作为命名导出导出。

需要注意的另一件事是,目前我们正在使用加载测试数据的实用函数来加载数据,因为我们还没有准备好 API。在接下来的章节中,我们将看到如何创建实际的 API 集成,但到目前为止,这将使我们能够构建我们页面的大部分 UI。

让我们现在打开http://localhost:3000/organizations/amYXmIyT9mD9GyO6CCr

图 4.2 – 公共组织详情页面

图 4.2 – 公共组织详情页面

我们的组织详细信息页面就在这里!组织可以使用此链接来分享他们组织的详细信息以及他们的职位发布列表。

页面是在服务器上渲染的,这意味着页面的内容将立即对用户可用。

为了验证这一点,请在浏览器中禁用 JavaScript 并刷新页面。

你会注意到没有任何区别。即使禁用了 JavaScript,所有内容都是可用的,因为所有标记都是在服务器上生成的并返回给客户端的。

公共职位详细信息页面

公共职位详细信息页面是显示特定职位所有详细信息并允许用户申请的页面。它应该对所有用户都可用,因此我们希望使其对搜索引擎友好。因此,我们希望像组织页面一样在服务器上渲染其内容。

让我们首先创建 src/pages/organizations/[organizationId]/jobs/[jobId].tsx 文件,其中 jobId 指的是职位的 ID。

然后,让我们导入所有必需的依赖项:

import { Stack, Button } from '@chakra-ui/react';
import {
  GetServerSidePropsContext,
  InferGetServerSidePropsType,
} from 'next';
import { ReactElement } from 'react';
import { NotFound } from '@/components/not-found';
import { Seo } from '@/components/seo';
import { PublicJobInfo } from '@/features/jobs';
import { PublicLayout } from '@/layouts/public-layout';
import {
  getJob,
  getOrganization,
} from '@/testing/test-data';

然后,让我们定义我们的职位页面组件:

type PublicJobPageProps = InferGetServerSidePropsType<
  typeof getServerSideProps
>;
export const PublicJobPage = ({
  job,
  organization,
}: PublicJobPageProps) => {
  const isInvalid =
    !job ||
    !organization ||
    organization.id !== job.organizationId;
  if (isInvalid) {
    return <NotFound />;
  }
  return (
    <>
      <Seo title={`${job.position} | ${job.location}`} />
      <Stack w="full">
        <PublicJobInfo job={job} />
        <Button
          bg="primary"
          color="primaryAccent"
          _hover={{
            opacity: '0.9',
          }}
          as="a"
          href={`mailto:${organization?.email}?subject=
            Application for ${job.position} position`}
          target="_blank"
        >
          Apply
        </Button>
      </Stack>
    </>
  );
};

正如我们在组织页面中所做的那样,我们通过 getServerSideProps 加载职位和组织,并在服务器上渲染内容。

接下来,我们可以附加页面布局并将其导出:

PublicJobPage.getLayout = function getLayout(
  page: ReactElement
) {
  return <PublicLayout>{page}</PublicLayout>;
};
export default PublicJobPage;

最后,让我们创建 getServerSideProps 函数并将其导出:

export const getServerSideProps = async ({
  params,
}: GetServerSidePropsContext) => {
  const organizationId = params?.organizationId as string;
  const jobId = params?.jobId as string;
  const [organization, job] = await Promise.all([
    getOrganization(organizationId).catch(() => null),
    getJob(jobId).catch(() => null),
  ]);
  return {
    props: {
      job,
      organization,
    },
  };
};

我们正在获取职位和组织数据,并将这些数据作为属性传递给页面。内容在服务器上渲染,因此它将立即对客户端可用,就像在组织详细信息页面上一样。

为了验证一切是否正常工作,让我们打开 http://localhost:3000/organizations/amYXmIyT9mD9GyO6CCr/jobs/2LJ_sgmy_880G9WivH5Hf

图 4.3 – 公共职位详细信息页面

图 4.3 – 公共职位详细信息页面

很好,内容立即在客户端可用,那么为什么不在服务器上渲染一切呢?

服务器端渲染有几个缺点:

  • 需要更多的服务器计算能力,这可能会影响服务器成本

  • 长时间的 getServerSideProps 执行时间可能会阻塞整个应用程序

正因如此,我们只想在有意义的地方使用它,例如应该对搜索引擎友好的公共页面,以及它们的内容可能更频繁变化的地方。

对于仪表板页面,我们将在服务器上渲染初始加载状态,然后在客户端加载和渲染数据。

仪表板中的职位页面

让我们创建 src/pages/dashboard/jobs/index.tsx 文件。

然后,我们可以导入所有必需的依赖项:

import { PlusSquareIcon } from '@chakra-ui/icons';
import { Heading, HStack } from '@chakra-ui/react';
import { ReactElement } from 'react';
import { Link } from '@/components/link';
import { Loading } from '@/components/loading';
import { Seo } from '@/components/seo';
import { JobsList } from '@/features/jobs';
import { DashboardLayout } from '@/layouts/dashboard-layout';
import { useJobs, useUser } from '@/testing/test-data';

接下来,我们可以定义并导出页面组件:

const DashboardJobsPage = () => {
  const user = useUser();
  const jobs = useJobs(user.data?.organizationId ?? '');
  if (jobs.isLoading) return <Loading />;
  if (!user.data) return null;
  return (
    <>
      <Seo title="Jobs" />
      <HStack
        mb="8"
        align="center"
        justify="space-between"
      >
        <Heading>Jobs</Heading>
        <Link
          icon={<PlusSquareIcon />}
          variant="solid"
          href="/dashboard/jobs/create"
        >
          Create Job
        </Link>
      </HStack>
      <JobsList
        jobs={jobs.data || []}
        isLoading={jobs.isLoading}
        organizationId={user.data.organizationId}
        type="dashboard"
      />
    </>
  );
};
DashboardJobsPage.getLayout = function getLayout(
  page: ReactElement
) {
  return <DashboardLayout>{page}</DashboardLayout>;
};
export default DashboardJobsPage;

注意到所有数据获取都是在组件中发生的,因为我们是在客户端进行的。

为了验证一切是否按预期工作,让我们打开 http://localhost:3000/dashboard/jobs

图 4.4 – 仪表板职位页面

图 4.4 – 仪表板职位页面

就这样!这个页面允许组织管理员对其组织的职位有一个概览。

仪表板中的工作详情页面

仪表板工作详情页面将在仪表板中显示给定工作的所有详细信息。

要开始,让我们创建src/pages/dashboard/jobs/[jobId].tsx,其中jobId指的是工作的动态 ID。

然后,我们可以导入所有依赖项:

import { useRouter } from 'next/router';
import { ReactElement } from 'react';
import { Loading } from '@/components/loading';
import { NotFound } from '@/components/not-found';
import { Seo } from '@/components/seo';
import { DashboardJobInfo } from '@/features/jobs';
import { DashboardLayout } from '@/layouts/
  dashboard-layout';
import { useJob } from '@/testing/test-data';

然后,让我们定义并导出我们的页面组件:

const DashboardJobPage = () => {
  const router = useRouter();
  const jobId = router.query.jobId as string;
  const job = useJob(jobId);
  if (job.isLoading) {
    return <Loading />;
  }
  if (!job.data) {
    return <NotFound />;
  }
  return (
    <>
      <Seo
        title={`${job.data.position} | ${job.data.
          location}`}
      />
      <DashboardJobInfo job={job.data} />
    </>
  );
};
DashboardJobPage.getLayout = function getLayout(
  page: ReactElement
) {
  return <DashboardLayout>{page}</DashboardLayout>;
};
export default DashboardJobPage;

为了验证一切按预期工作,让我们打开http://localhost:3000/dashboard/jobs/wS6UeppUQoiXGTzAI6XrM

图 4.5 – 仪表板工作详情页面

图 4.5 – 仪表板工作详情页面

这就是我们的仪表板工作详情页面。我们可以在这里看到给定工作的基本详情。

创建工作页面

创建工作页面是我们将渲染创建工作表单的页面。

要开始,让我们创建src/pages/dashboard/jobs/create.tsx

然后,让我们导入所需的依赖项:

import { Heading } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import { ReactElement } from 'react';
import { Seo } from '@/components/seo';
import { CreateJobForm } from '@/features/jobs';
import { DashboardLayout } from '@/layouts/
  dashboard-layout';

然后,我们可以创建并导出页面组件:

const DashboardCreateJobPage = () => {
  const router = useRouter();
  const onSuccess = () => {
    router.push(`/dashboard/jobs`);
  };
  return (
    <>
      <Seo title="Create Job" />
      <Heading mb="8">Create Job</Heading>
      <CreateJobForm onSuccess={onSuccess} />
    </>
  );
};
DashboardCreateJobPage.getLayout = function getLayout(
  page: ReactElement
) {
  return <DashboardLayout>{page}</DashboardLayout>;
};
export default DashboardCreateJobPage;

为了验证一切按预期工作,让我们打开http://localhost:3000/dashboard/jobs/create

图 4.6 – 仪表板创建工作页面

图 4.6 – 仪表板创建工作页面

看到这里!对于本章,我们刚刚创建了页面,将在接下来的章节中处理数据提交。

404 页面

如果您在我们实现之前尝试访问一个页面,您可能已经注意到一个空白页面。为了使用户知道他们访问了一个不存在的页面,我们应该创建一个自定义的 404 页面。

让我们先创建src/pages/404.tsx并添加以下内容:

import { Center } from '@chakra-ui/react';
import { Link } from '@/components/link';
import { NotFound } from '@/components/not-found';
const NotFoundPage = () => {
  return (
    <>
      <NotFound />
      <Center>
        <Link href="/">Home</Link>
      </Center>
    </>
  );
};
export default NotFoundPage;

pages文件夹中的404.tsx文件是一个特殊页面,当用户访问未知页面时将显示。

为了验证一切按预期工作,让我们访问http://localhost:3000/non-existing-page

图 4.7 – 404 页面

图 4.7 – 404 页面

看到这里!我们有一个漂亮的界面可以返回到应用程序,如果我们最终进入了一个缺失的页面。

摘要

在本章中,我们的重点是构建我们应用程序的页面。

我们首先查看 Next.js 中的路由是如何工作的。然后,我们介绍了我们将要使用的渲染策略。之后,我们构建了 SEO 组件,该组件将内容注入到页面的头部。

然后,我们为我们的页面配置了布局系统。在本章的结尾,我们为我们的应用程序构建了页面。为了构建页面的内容,我们使用了预定义的测试数据。我们使用测试数据在页面上渲染内容,但我们仍然需要执行真实的 API 调用。

在下一章中,我们将学习如何模拟 API 端点,我们可以在开发期间使用它们来执行 HTTP 请求并获取数据,就像我们正在消费真实的 API 一样。

第五章:模拟 API

在上一章中,我们构建了使用测试数据的应用程序页面。页面的 UI 是完整的,但页面尚未启用。我们正在使用测试数据,而不向 API 发送请求。

在本章中,我们将学习模拟是什么以及为什么它有用。我们将学习如何使用 msw 库模拟 API 端点,这是一个允许我们创建模拟 API 端点的强大工具,这些端点的行为类似于现实世界的 API 端点。

我们还将学习如何使用 @mswjs/data 库对应用程序实体的数据进行建模。

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

  • 为什么模拟是有用的?

  • MSW 简介

  • 配置数据模型

  • 配置 API 端点的请求处理器

到本章结束时,我们将学习如何生成具有完整功能的模拟 API,其中已设置数据模型,这将使我们的代码库在开发期间对外部 API 的依赖性降低。

技术要求

在我们开始之前,我们需要设置我们的项目。为了能够开发我们的项目,我们将在计算机上需要以下内容安装:

  • Node.js 版本 16 或以上和 npm 版本 8 或以上

安装 Node.js 和 npm 有多种方法。以下是一篇深入探讨的精彩文章:www.nodejsdesignpatterns.com/blog/5-ways-to-install-node-js.

  • Visual Studio CodeVS Code)(可选)是目前最流行的 JavaScript/TypeScript 编辑器/IDE,因此我们将使用它。它是开源的,与 TypeScript 集成良好,并且我们可以通过扩展来扩展其功能。可以从这里下载:code.visualstudio.com/.

本章的代码文件可以在以下位置找到:github.com/PacktPublishing/React-Application-Architecture-for-Production

可以使用以下命令在本地克隆存储库:

git clone https://github.com/PacktPublishing/React-Application-Architecture-for-Production.git

一旦克隆了存储库,我们需要安装应用程序的依赖项:

npm install

我们可以使用以下命令提供环境变量:

cp .env.example .env

一旦安装了依赖项,我们需要选择与本章匹配的代码库的正确阶段。我们可以通过执行以下命令来完成:

npm run stage:switch

此命令将为我们提供每个章节的阶段列表:

? What stage do you want to switch to? (Use arrow
 keys)
❯ chapter-02
  chapter-03
  chapter-03-start
  chapter-04
  chapter-04-start
  chapter-05
  chapter-05-start
(Move up and down to reveal more choices)

这是第五章,所以如果我们想跟随,可以选择 chapter-05-start,或者选择 chapter-05 来查看章节的最终结果。

一旦选择了章节,所有用于跟随本章所需的文件将显示出来。

更多关于设置细节的信息,请查看 README.md 文件。

为什么模拟是有用的?

模拟是模拟系统部分的过程,这意味着它们不是生产就绪的,而是有用的开发测试的假版本。

你可能会问自己,为什么我们要费心设置模拟 API 呢? 拥有模拟 API 有几个好处:

  • 开发期间外部服务的独立性:一个 Web 应用程序通常由许多不同的部分组成,如前端、后端、外部第三方 API 等。在开发前端时,我们希望尽可能自主,不被系统中的某些非功能部分所阻碍。如果我们的应用程序 API 损坏或不完整,我们仍然应该能够继续开发应用程序的前端部分。

  • 快速原型设计:模拟端点允许我们更快地原型化应用程序,因为它们不需要任何额外的设置,例如后端服务器、数据库等。这对于构建概念验证POCs)和最小可行产品MVP)应用程序非常有用。

  • 离线开发:通过模拟 API 端点,我们可以在没有互联网连接的情况下开发我们的应用程序。

  • 测试:我们不想在测试前端时触及我们的真实服务。这就是模拟 API 变得有用的地方。我们可以像针对真实 API 构建一样构建和测试整个功能,然后在生产时切换到真实的一个。

为了测试我们的 API 端点,我们将使用Mock Service WorkerMSW)库,这是一个非常棒的工具,它允许我们以非常优雅的方式模拟端点。

MSW 简介

MSW 是一个允许我们创建模拟 API 的工具。它作为一个服务工作者,拦截任何已定义模拟版本的 API 请求。我们可以像调用真实 API 一样,在我们的浏览器“网络”标签页中检查请求和响应。

为了获得其工作的高级概述,让我们看看他们网站上提供的图解:

图 5.1 – MSW 工作流程图

图 5.1 – MSW 工作流程图

MSW 的一个优点是,我们的应用程序将表现得就像它正在使用真实的 API 一样,而且通过关闭模拟端点和不拦截请求,切换到使用真实 API 是非常简单的。

另一件很棒的事情是,由于拦截发生在网络级别,我们仍然能够在浏览器开发者工具的“网络”标签页中检查我们的请求。

配置概述

我们已经将 MSW 包安装为开发依赖项。msw 模拟 API 可以被配置为在浏览器和服务器上同时工作。

浏览器

模拟 API 的浏览器版本可以在应用程序开发期间运行模拟端点。

初始化

需要做的第一件事是创建一个服务工作者。这可以通过执行以下命令来完成:

npx msw init public/ --save

上述命令将在public/mockServiceWorker.js创建一个服务工作者,它将在浏览器中拦截我们的请求并相应地修改响应。

为浏览器配置工作器

我们现在可以配置我们的工作器使用我们将在不久后定义的端点。让我们打开src/testing/mocks/browser.ts文件并添加以下内容:

import { setupWorker } from 'msw';
import { handlers } from './handlers';
export const worker = setupWorker(...handlers);

上述代码片段将配置 MSW 与提供的处理程序在浏览器中一起工作。

服务器

服务器版本主要用于运行自动化测试,因为我们的测试运行器在 Node 环境中工作,而不是在浏览器中。服务器版本对于在服务器上执行的 API 调用也很有用,这对于我们的应用程序在服务器端渲染时是必需的。

为服务器配置 MSW

让我们打开src/testing/mocks/server.ts文件并添加以下内容:

import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);

上述代码片段将处理程序应用到我们的模拟的服务器版本。

在应用程序中运行 MSW

现在我们已经配置了 MSW,我们需要让它在我们的应用程序中运行。为此,让我们打开src/testing/mocks/initialize.ts文件并修改initializeMocks函数如下:

import { IS_SERVER } from '@/config/constants';
const initializeMocks = () => {
  if (IS_SERVER) {
    const { server } = require('./server');
    server.listen();
  } else {
    const { worker } = require('./browser');
    worker.start();
  }
};
initializeMocks();

initializeMocks函数负责根据其被调用的环境调用适当的 MSW 设置。如果它在服务器上执行,它将运行服务器版本。否则,它将启动浏览器版本。

现在,我们需要集成我们的模拟。

让我们创建一个src/lib/msw.tsx文件并添加以下内容:

import { MSWDevTools } from 'msw-devtools';
import { ReactNode } from 'react';
import { IS_DEVELOPMENT } from '@/config/constants';
import { db, handlers } from '@/testing/mocks';
export type MSWWrapperProps = {
  children: ReactNode;
};
require('@/testing/mocks/initialize');
export const MSWWrapper = ({
  children,
}: MSWWrapperProps) => {
  return (
    <>
      {IS_DEVELOPMENT && (
        <MSWDevTools db={db} handlers={handlers} />
      )}
      {children}
    </>
  );
};

在这里,我们定义了MSWWrapper,这是一个将包裹我们的应用程序并初始化 MSW 和 MSW 开发工具到包裹应用程序中的组件。

现在我们可以通过打开src/pages/_app.tsx将其集成到我们的应用程序中。

我们想要添加新的导入:

import dynamic from 'next/dynamic';
import { API_MOCKING } from '@/config/constants';
import { MSWWrapperProps } from '@/lib/msw';

然后,我们想要动态加载MSWWrapper

const MSWWrapper = dynamic<MSWWrapperProps>(() =>
  import('@/lib/msw').then(({ MSWWrapper }) => MSWWrapper)
);

最后,让我们修改App组件的return语句如下:

return (
    <AppProvider>
      {API_MOCKING ? (
        <MSWWrapper>{pageContent}</MSWWrapper>
      ) : (
        pageContent
      )}
    </AppProvider>
  );

如您所见,我们只有在模拟启用时才会加载MSWWrapper组件并包裹页面内容。我们这样做是为了排除应用程序生产版本中的 MSW 相关代码,该版本使用真实 API,并且不需要冗余的 MSW 相关代码。

为了验证 MSW 是否正在运行,让我们打开控制台。我们应该看到如下内容:

图 5.2 – MSW 在我们的应用程序中运行

图 5.2 – MSW 在我们的应用程序中运行

现在我们已经成功安装并集成了 MSW 到我们的应用程序中,让我们实现我们的第一个模拟端点。

编写我们的第一个处理程序

要定义模拟端点,我们需要创建请求处理程序。将请求处理程序想象成函数,它们通过模拟其响应来确定是否应该拦截和修改请求。

让我们在src/testing/mocks/handlers/index.ts文件中创建我们的第一个处理程序,添加以下内容:

import { rest } from 'msw';
import { API_URL } from '@/config/constants';
export const handlers = [
  rest.get(`${API_URL}/healthcheck`, (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json({ healthy: true })
    );
  }),
];

我们正在使用msw提供的rest辅助工具来定义我们的 REST 端点。我们使用的是get方法,它接受路径和一个回调,该回调将修改响应。

处理程序回调将返回一个状态码为200的响应,并将响应数据设置为{ healthy: true }

为了验证我们的处理程序是否正常工作,让我们在右下角打开开发者工具,然后选择健康检查端点:

图 5.3 – 健康检查处理程序测试选择

图 5.3 – 健康检查处理程序测试选择

发送请求应该会给我们一个响应,如下所示:

图 5.4 – 健康检查处理程序测试结果

图 5.4 – 健康检查处理程序测试结果

Devtools小部件将为我们提供测试处理程序的能力,而无需立即在应用程序中创建 UI。

现在我们已经在应用程序中正确运行了 MSW,是时候为我们的应用程序创建数据模型了。

配置数据模型

为了对应用程序进行数据建模,我们将使用 MSW 的数据库,它非常实用且易于使用,可以以类似后端对象关系映射器(ORM)的方式操作数据。

要使我们的请求处理程序功能化,我们只需直接编写响应即可,但那样有什么乐趣呢?使用 MSW 及其数据库,我们可以构建一个模拟的后端,它包含业务逻辑,并且如果我们决定实现它,它将完全功能化。

要配置我们的数据模型,让我们打开src/testing/mocks/db.ts文件并添加以下内容:

import { factory, primaryKey } from '@mswjs/data';
import { uid } from '@/utils/uid';
const models = {
  user: {
    id: primaryKey(uid),
    createdAt: Date.now,
    email: String,
    password: String,
    organizationId: String,
  },
  organization: {
    id: primaryKey(uid),
    createdAt: Date.now,
    adminId: String,
    name: String,
    email: String,
    phone: String,
    info: String,
  },
  job: {
    id: primaryKey(uid),
    createdAt: Date.now,
    organizationId: String,
    position: String,
    info: String,
    location: String,
    department: String,
  },
};
export const db = factory(models);

我们从@mswjs/data包中导入factoryprimaryKey函数。primaryKey函数允许我们在模拟数据库中定义主键,而factory函数创建一个内存数据库,我们可以用它来进行测试。

然后,我们可以访问每个模型的一组不同方法,这些方法允许我们更轻松地操作我们的数据,如下所示:

db.job.findFirst
db.job.findMany
db.job.create
db.job.update
db.job.delete

如果我们能在数据库中预先填充一些数据,那就太好了,这样我们总是有东西可以在我们的应用程序中展示。为此,我们应该对数据库进行预种。

让我们打开src/testing/mocks/seed-db.ts文件并添加以下内容:

import { db } from './db';
import { testData } from '../test-data';
export const seedDb = () => {
  const userCount = db.user.count();
  if (userCount > 0) return;
  testData.users.forEach((user) => db.user.create(user));
  testData.organizations.forEach((organization) =>
    db.organization.create(organization)
  );
  testData.jobs.forEach((job) => db.job.create(job));
};

seedDb函数将用测试数据填充数据库。

在我们的模拟端点初始化后执行它。打开src/testing/mocks/initialize.ts并调用函数,如下所示:

import { IS_SERVER } from '@/config/constants';
import { seedDb } from './seed-db';
const initializeMocks = () => {
  if (IS_SERVER) {
    const { server } = require('./server');
    server.listen();
  } else {
    const { worker } = require('./browser');
    worker.start();
  }
  seedDb();
};
initializeMocks();

要检查我们数据库中的数据,我们可以在Devtools中打开数据标签页:

图 5.5 – 检查预种数据

图 5.5 – 检查预种数据

太棒了!现在,我们的数据库已经预先填充了一些测试数据。我们现在可以创建请求处理程序,它们将与数据库交互并消耗数据。

配置 API 端点的请求处理程序

在本节中,我们将定义我们应用程序的处理程序。如前所述,MSW 中的处理程序是一个函数,如果定义了它,将拦截任何匹配的请求,而不是将请求发送到网络,而是修改它们并返回模拟的响应。

API 工具

在开始之前,让我们快速查看src/testing/mocks/utils.ts文件,它包含我们将用于处理 API 处理程序业务逻辑的一些实用工具:

  • authenticate 接受用户凭证,如果它们有效,它将从数据库返回用户以及认证令牌。

  • getUser 返回一个测试用户对象。

  • requireAuth 如果 cookie 中的令牌可用,则返回当前用户。如果令牌不存在,它可以选择抛出一个错误。

在开始之前,让我们将所有处理器包含在配置中。打开 src/testing/mocks/handlers/index.ts 文件并将其更改为以下内容:

import { rest } from 'msw';
import { API_URL } from '@/config/constants';
import { authHandlers } from './auth';
import { jobsHandlers } from './jobs';
import { organizationsHandlers } from './organizations';
export const handlers = [
  ...authHandlers,
  ...jobsHandlers,
  ...organizationsHandlers,
  rest.get(`${API_URL}/healthcheck`, (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json({ healthy: true })
    );
  }),
];

我们将定义的所有处理器都包含在每个处理器的文件中,并使它们对 MSW 可用。

现在,我们可以开始为我们应用程序编写请求处理器。

认证处理器

对于 auth 功能,我们需要以下端点:

  • POST /auth/login

  • POST /auth/logout

  • GET /auth/me

auth 的端点将在 src/test/mocks/handlers/auth.ts 文件中定义。

让我们先导入依赖项:

import { rest } from 'msw';
import { API_URL } from '@/config/constants';
import {
  authenticate,
  requireAuth,
  AUTH_COOKIE,
} from '../utils';

然后,让我们创建一个用于登录的请求处理器:

const loginHandler = rest.post(
  `${API_URL}/auth/login`,
  async (req, res, ctx) => {
    const credentials = await req.json();
    const { user, jwt } = authenticate(credentials);
    return res(
      ctx.delay(300),
      ctx.cookie(AUTH_COOKIE, jwt, {
        path: '/',
        httpOnly: true,
      }),
      ctx.json({ user })
    );
  }
);

我们正在提取凭证并使用它们来获取用户信息和令牌。然后,我们将令牌附加到 cookie 中,并在 300 毫秒的延迟后以真实 API 的方式返回用户。

我们使用 httpOnly cookie,因为它更安全,因为它不可从客户端访问。

然后,让我们创建一个注销处理器:

const logoutHandler = rest.post(
  `${API_URL}/auth/logout`,
  async (req, res, ctx) => {
    return res(
      ctx.delay(300),
      ctx.cookie(AUTH_COOKIE, '', {
        path: '/',
        httpOnly: true,
      }),
      ctx.json({ success: true })
    );
  }
);

该处理器将仅清空 cookie 并返回响应。任何后续请求到受保护的处理器都将抛出错误。

最后,我们有一个用于获取当前认证用户的端点:

const meHandler = rest.get(
  `${API_URL}/auth/me`,
  async (req, res, ctx) => {
    const user = requireAuth({ req, shouldThrow: false });
    return res(ctx.delay(300), ctx.json(user));
  }
);

该端点将提取令牌中的用户并将其作为响应返回。最后,我们应该导出处理器,以便它们可以被 MSW 消费:

export const authHandlers = [
  loginHandler,
  logoutHandler,
  meHandler,
];

工作处理器

对于 jobs 功能,我们需要以下端点:

  • GET /jobs

  • GET /jobs/:jobId

  • POST /jobs

jobs 的端点将在 src/test/mocks/handlers/jobs.ts 文件中定义。

让我们先导入依赖项:

import { rest } from 'msw';
import { API_URL } from '@/config/constants';
import { db } from '../db';
import { requireAuth } from '../utils';

然后,让我们实现一个用于获取工作的处理器:

const getJobsHandler = rest.get(
  `${API_URL}/jobs`,
  async (req, res, ctx) => {
    const organizationId = req.url.searchParams.get(
      'organizationId'
    ) as string;
    const jobs = db.job.findMany({
      where: {
        organizationId: {
          equals: organizationId,
        },
      },
    });
    return res(
      ctx.delay(300),
      ctx.status(200),
      ctx.json(jobs)
    );
  }
);

我们从查询参数中获取组织 ID,并使用它来获取给定组织的作业,然后将其作为响应返回。

我们还想要创建一个工作详情端点。我们可以通过创建以下处理器来实现:

const getJobHandler = rest.get(
  `${API_URL}/jobs/:jobId`,
  async (req, res, ctx) => {
    const jobId = req.params.jobId as string;
    const job = db.job.findFirst({
      where: {
        id: {
          equals: jobId,
        },
      },
    });
    if (!job) {
      return res(
        ctx.delay(300),
        ctx.status(404),
        ctx.json({ message: 'Not found!' })
      );
    }
    return res(
      ctx.delay(300),
      ctx.status(200),
      ctx.json(job)
    );
  }
);

我们从 URL 参数中获取工作 ID,并使用它从数据库检索给定的工作。如果没有找到工作,我们返回一个 404 错误。否则,我们在响应中返回工作。

我们的应用程序还需要一个用于创建工作的端点。我们可以创建一个处理器,如下所示:

const createJobHandler = rest.post(
  `${API_URL}/jobs`,
  async (req, res, ctx) => {
    const user = requireAuth({ req });
    const jobData = await req.json();
    const job = db.job.create({
      ...jobData,
      organizationId: user?.organizationId,
    });
    return res(
      ctx.delay(300),
      ctx.status(200),
      ctx.json(job)
    );
  }
);

我们首先检查用户是否已认证,因为我们不希望允许未认证用户创建(操作)。然后,我们从请求中获取工作数据,并使用这些数据创建一个新的工作,然后将其作为响应返回。

最后,我们想要导出处理器,以便它们对 MSW 可用:

export const jobsHandlers = [
  getJobsHandler,
  getJobHandler,
  createJobHandler,
];

组织处理器

对于 organizations 功能,我们需要 GET /``organizations/:organizationId 端点。

所有针对此功能的处理程序都将定义在 src/test/mocks/handlers/organizations.ts 文件中。

让我们先导入所有必需的依赖项:

import { rest } from 'msw';
import { API_URL } from '@/config/constants';
import { db } from '../db';

然后,我们可以通过添加以下内容来实现获取组织详情的端点:

const getOrganizationHandler = rest.get(
  `${API_URL}/organizations/:organizationId`,
  (req, res, ctx) => {
    const organizationId = req.params
      .organizationId as string;
    const organization = db.organization.findFirst({
      where: {
        id: {
          equals: organizationId,
        },
      },
    });
    if (!organization) {
      return res(
        ctx.status(404),
        ctx.json({ message: 'Not found!' })
      );
    }
    return res(
      ctx.delay(300),
      ctx.status(200),
      ctx.json(organization)
    );
  }
);

我们从 URL 参数中获取组织 ID,并使用它来检索指定的组织。如果它在数据库中不存在,处理程序将返回一个 404 错误;否则,它将返回找到的组织。

最后,我们必须导出处理程序:

export const organizationsHandlers = [
  getOrganizationHandler,
];

为了验证我们已定义了所有处理程序,我们可以再次访问 Devtools

图 5.6 – 模拟端点

图 5.6 – 模拟端点

太好了!现在,我们已经拥有了所有必需的处理程序,使我们的应用程序能够像消费真实 API 一样工作。玩转这些处理程序以确保一切按预期工作。在下一章中,我们将将这些端点集成到应用程序中。

摘要

在本章中,我们学习了如何模拟 API。我们介绍了 MSW 库,这是一个以优雅方式模拟 API 的优秀工具。它可以在浏览器和服务器上工作。它在原型设计和开发过程中的测试中非常有用。

在下一章中,我们将集成应用程序的 API 层,该层将消费我们刚刚创建的端点。

第六章:将 API 集成到应用程序中

在上一章中,我们介绍了设置模拟 API,这是我们将在应用程序中消费的 API。

在本章中,我们将学习如何通过应用程序消费 API。

当我们说 API 时,我们指的是 API 后端服务器。我们将学习如何从客户端和服务器获取数据。对于 HTTP 客户端,我们将使用Axios,而对于处理获取的数据,我们将使用React Query库,它允许我们在 React 应用程序中处理 API 请求和响应。

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

  • 配置 API 客户端

  • 配置 React Query

  • 为功能创建 API 层

  • 在应用程序中使用 API 层

到本章结束时,我们将知道如何以干净和有序的方式使我们的应用程序与 API 进行通信。

技术要求

在我们开始之前,我们需要设置我们的项目。为了能够开发我们的项目,我们需要在计算机上安装以下内容:

  • Node.js版本 16 或以上和npm版本 8 或以上

安装 Node.js 和 npm 有多种方式。这里有一篇很好的文章,详细介绍了更多细节:www.nodejsdesignpatterns.com/blog/5-ways-to-install-node-js

  • VSCode(可选)目前是 JavaScript/TypeScript 最受欢迎的编辑器/IDE,因此我们将使用它。它是开源的,与 TypeScript 有很好的集成,并且我们可以通过扩展来扩展其功能。可以从code.visualstudio.com/下载。

本章的代码文件可以在以下位置找到:github.com/PacktPublishing/React-Application-Architecture-for-Production

可以使用以下命令在本地克隆仓库:

git clone https://github.com/PacktPublishing/React-Application-Architecture-for-Production.git

一旦克隆了仓库,我们需要安装应用程序的依赖项:

npm install

我们可以使用以下命令提供环境变量:

cp .env.example .env

依赖项安装完成后,我们需要选择与本章匹配的代码库的正确阶段。我们可以通过执行以下命令来完成:

npm run stage:switch

此命令将为我们提供每个章节的阶段列表:

? What stage do you want to switch to? (Use arrow
 keys)
❯ chapter-02
  chapter-03
  chapter-03-start
  chapter-04
  chapter-04-start
  chapter-05
  chapter-05-start
(Move up and down to reveal more choices)

这是第六章,所以如果我们想跟随着学习,可以选择chapter-06-start,或者选择chapter-06来查看本章的最终结果。

一旦选择了章节,所有跟随本章所需的文件都将出现。

更多关于设置细节的信息,请查看README.md文件。

配置 API 客户端

对于我们应用程序的 API 客户端,我们将使用 Axios,这是一个用于处理 HTTP 请求的非常流行的库。它在浏览器和服务器上都得到支持,并提供创建实例、拦截请求和响应、取消请求等功能。

让我们先创建一个 Axios 实例,这将包括我们希望在每次请求中完成的常见操作。

创建 src/lib/api-client.ts 文件并添加以下内容:

import Axios from 'axios';
import { API_URL } from '@/config/constants';
export const apiClient = Axios.create({
  baseURL: API_URL,
  headers: {
    'Content-Type': 'application/json',
  },
});
apiClient.interceptors.response.use(
  (response) => {
    return response.data;
  },
  (error) => {
    const message =
      error.response?.data?.message || error.message;
    console.error(message);
    return Promise.reject(error);
  }
);

在这里,我们创建了一个 Axios 实例,其中我们定义了一个公共基本 URL 和我们希望在每次请求中包含的标头。

然后,我们在想要提取数据属性并返回给客户端的地方附加了一个响应拦截器。我们还定义了错误拦截器,其中我们想要将错误记录到控制台。

然而,拥有一个配置好的 Axios 实例并不足以优雅地处理 React 组件中的请求。我们仍然需要处理调用 API、等待数据到达以及将其存储在状态中的操作。这就是 React Query 发挥作用的地方。

配置 React Query

React Query 是一个处理异步数据和使其在 React 组件中可用的优秀库。

为什么选择 React Query?

React Query 是处理异步远程状态的一个很好的选择,主要原因是它为我们处理了很多事情。

想象以下组件,它从 API 加载数据并显示:

const loadData = () => Promise.resolve('data');
const DataComponent = () => {
  const [data, setData] = useState();
  const [error, setError] = useState();
  const [isLoading, setIsLoading] = useState();
  useEffect(() => {
    setIsLoading(true);
    loadData()
      .then((data) => {
        setData(data);
      })
      .catch((error) => {
        setError(error);
      })
      .finally(() => {
        setIsLoading(false);
      });
  }, []);
  if (isLoading) return <div>Loading</div>;
  if (error) return <div>{error}</div>;
  return <div>{data}</div>;
};

如果我们只从 API 获取一次数据,这没问题,但在大多数情况下,我们需要从许多不同的端点获取数据。我们可以看到这里有一些样板代码:

  • 需要定义相同的 dataerrorisLoading 状态片段

  • 必须相应地更新不同的状态片段

  • 当我们离开组件时,数据就会被丢弃

这就是 React Query 发挥作用的地方。我们可以将我们的组件更新为以下内容:

import { useQuery } from '@tanstack/react-query';
const loadData = () => Promise.resolve('data');
const DataComponent = () => {
  const {data, error, isLoading} = useQuery({
    queryFn: loadData,
    queryKey: ['data']
  })
  if (isLoading) return <div>Loading</div>;
  if (error) return <div>{error}</div>;
  return <div>{data}</div>;
};

注意状态处理是如何从消费者中抽象出来的。我们不需要担心存储数据,或处理加载和错误状态;一切由 React Query 处理。React Query 的另一个好处是其缓存机制。对于每个查询,我们需要提供一个相应的查询键,该键将用于在缓存中存储数据。

这也有助于请求的去重。如果我们从多个地方调用相同的查询,它会确保 API 请求只发生一次。

配置 React Query

现在,回到我们的应用程序。我们已经有 react-query 安装了。我们只需要为我们的应用程序配置它。配置需要一个查询客户端,我们可以在 src/lib/react-query.ts 中创建它并添加以下内容:

import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: false,
      refetchOnWindowFocus: false,
      useErrorBoundary: true,
    },
  },
});

React Query 在创建查询客户端时提供了一个默认配置,我们可以在创建过程中覆盖它。完整的选项列表可以在文档中找到。

现在我们已经创建了我们的查询客户端,我们必须将其包含在提供者中。让我们前往 src/providers/app.tsx 并将内容替换为以下内容:

import {
  ChakraProvider,
  GlobalStyle,
} from '@chakra-ui/react';
import { QueryClientProvider } from '@tanstack/
  react-query';
import { ReactQueryDevtools } from '@tanstack/
  react-query-devtools';
import { ReactNode } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { theme } from '@/config/theme';
import { queryClient } from '@/lib/react-query';
type AppProviderProps = {
  children: ReactNode;
};
export const AppProvider = ({
  children,
}: AppProviderProps) => {
  return (
    <ChakraProvider theme={theme}>
      <ErrorBoundary
        fallback={<div>Something went wrong!</div>}
        onError={console.error}
      >
        <GlobalStyle />
        <QueryClientProvider client={queryClient}>
          <ReactQueryDevtools initialIsOpen={false} />
          {children}
        </QueryClientProvider>
      </ErrorBoundary>
    </ChakraProvider>
  );
};

在这里,我们正在导入并添加 QueryClientProvider,这将使查询客户端及其配置可用于查询和突变。注意我们如何将查询客户端实例作为 client 属性传递。

我们还添加了ReactQueryDevtools,这是一个允许我们检查所有查询的小部件。它仅在开发中使用,这对于调试非常有用。

现在我们已经设置了react-query,我们可以开始实现功能的 API 层。

定义功能的 API 层

API 层将在每个功能的api文件夹中定义。一个 API 请求可以是查询或突变。查询描述了仅获取数据的请求。突变描述了一个在服务器上修改数据的 API 调用。

对于每个 API 请求,我们都会有一个包含并导出 API 请求定义函数和用于在 React 中消费请求的钩子的文件。对于请求定义函数,我们将使用我们刚刚用 Axios 创建的 API 客户端,对于钩子,我们将使用 React Query 的钩子。

我们将在接下来的章节中学习如何实际实现它。

工作

对于jobs功能,我们有三个 API 调用:

  • GET /jobs

  • GET /jobs/:jobId

  • POST /jobs

获取工作

让我们从获取工作的 API 调用开始。为了在我们的应用程序中定义它,让我们创建src/features/jobs/api/get-jobs.ts文件并添加以下内容:

import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { Job } from '../types';
type GetJobsOptions = {
  params: {
    organizationId: string | undefined;
  };
};
export const getJobs = ({
  params,
}: GetJobsOptions): Promise<Job[]> => {
  return apiClient.get('/jobs', {
    params,
  });
};
export const useJobs = ({ params }: GetJobsOptions) => {
  const { data, isFetching, isFetched } = useQuery({
    queryKey: ['jobs', params],
    queryFn: () => getJobs({ params }),
    enabled: !!params.organizationId,
    initialData: [],
  });
  return {
    data,
    isLoading: isFetching && !isFetched,
  };
};

如我们所见,这里发生了一些事情:

  1. 我们正在定义请求选项的类型。在那里,我们可以传递organizationId来指定我们想要获取工作的组织。

  2. 我们正在定义getJobs函数,这是获取工作的请求定义。

  3. 我们通过使用react-queryuseQuery钩子来定义useJobs钩子。useQuery钩子返回许多不同的属性,但我们只想暴露应用程序所需的内容。注意,通过使用enabled属性,我们正在告诉useQuery只有在organizationId提供时才运行。这意味着查询将在获取数据之前等待organizationId存在。

由于我们将在功能外部使用它,让我们在src/features/jobs/index.ts中使其可用:

export * from './api/get-jobs';

获取工作详情

获取工作请求应该是直接的。让我们创建src/features/jobs/api/get-job.ts文件并添加以下内容:

import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { Job } from '../types';
type GetJobOptions = {
  jobId: string;
};
export const getJob = ({
  jobId,
}: GetJobOptions): Promise<Job> => {
  return apiClient.get(`/jobs/${jobId}`);
};
export const useJob = ({ jobId }: GetJobOptions) => {
  const { data, isLoading } = useQuery({
    queryKey: ['jobs', jobId],
    queryFn: () => getJob({ jobId }),
  });
  return { data, isLoading };
};

如我们所见,我们正在定义和导出getJob函数和useJob查询,我们将在稍后使用它们。

我们希望在功能外部使用这个 API 请求,因此我们必须通过从src/features/jobs/index.ts重新导出它来使其可用:

export * from './api/get-job';

创建工作

正如我们已经提到的,每当我们在服务器上更改某些内容时,都应该将其视为突变。有了这个,让我们创建src/features/jobs/api/create-job.ts文件并添加以下内容:

import { useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { queryClient } from '@/lib/react-query';
import { Job, CreateJobData } from '../types';
type CreateJobOptions = {
  data: CreateJobData;
};
export const createJob = ({
  data,
}: CreateJobOptions): Promise<Job> => {
  return apiClient.post(`/jobs`, data);
};
type UseCreateJobOptions = {
  onSuccess?: (job: Job) => void;
};
export const useCreateJob = ({
  onSuccess,
}: UseCreateJobOptions = {}) => {
  const { mutate: submit, isLoading } = useMutation({
    mutationFn: createJob,
    onSuccess: (job) => {
      queryClient.invalidateQueries(['jobs']);
      onSuccess?.(job);
    },
  });
  return { submit, isLoading };
};

这里发生了一些事情:

  1. 我们定义了 API 请求的CreateJobOptions类型。它将需要一个包含创建新工作所需所有字段的数据对象。

  2. 我们定义了createJob函数,它向服务器发送请求。

  3. 我们定义了UseCreateJobOptions,它接受一个可选的回调函数,在请求成功时调用。这在我们想要显示通知、重定向用户或执行与 API 请求无直接关系的事情时可能很有用。

  4. 我们正在定义useCreateJob钩子,它使用react-query中的useMutation。如类型定义中所述,它接受一个可选的onSuccess回调,如果突变成功则被调用。

  5. 要创建突变,我们将createJob函数作为mutationFn提供。

  6. 我们定义useMutationonSuccess,在新工作创建后,我们使所有工作查询无效。使查询无效意味着我们想要在缓存中将它们设置为无效。如果我们再次需要它们,我们必须从 API 中获取它们。

  7. 我们正在减少useCreateJob钩子的 API 表面,只暴露那些应用程序使用的功能,所以我们只暴露submitisLoading。如果我们注意到我们需要更多东西,我们总是可以在未来暴露更多东西。

由于它只用于jobs功能内部,我们不需要从index.ts文件中导出这个请求。

组织

对于organizations功能,我们有一个 API 调用:

  • GET /organizations/:organizationId

获取组织详情

让我们创建src/features/organizations/api/get-organization.ts并添加以下内容:

import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { Organization } from '../types';
type GetOrganizationOptions = {
  organizationId: string;
};
export const getOrganization = ({
  organizationId,
}: GetOrganizationOptions): Promise<Organization> => {
  return apiClient.get(
    `/organizations/${organizationId}`
  );
};
export const useOrganization = ({
  organizationId,
}: GetOrganizationOptions) => {
  const { data, isLoading } = useQuery({
    queryKey: ['organizations', organizationId],
    queryFn: () => getOrganization({ organizationId }),
  });
  return { data, isLoading };
};

在这里,我们定义了一个查询,它将根据我们传递的organizationId属性获取组织。

由于这个查询也将被organizations功能外部使用,让我们也从src/features/organizations/index.ts中重新导出:

export * from './api/get-organization';

现在我们已经定义了所有的 API 请求,我们可以在我们的应用程序中开始使用它们了。

在应用程序中消费 API

为了能够在没有 API 功能的情况下构建 UI,我们在我们的页面上使用了测试数据。现在,我们想要用我们刚刚为与 API 通信而制作的真实查询和突变来替换它。

公共组织

我们现在需要替换一些东西。

让我们打开src/pages/organizations/[organizationId]/index.tsx并删除以下内容:

import {
  getJobs,
  getOrganization,
} from '@/testing/test-data';

现在,我们必须从 API 加载数据。我们可以通过从相应的features中导入getJobsgetOrganization来实现这一点。让我们添加以下内容:

import { JobsList, Job, getJobs } from '@/features/jobs';
import {
  getOrganization,
  OrganizationInfo,
} from '@/features/organizations';

新的 API 函数略有不同,所以我们需要替换以下代码:

const [organization, jobs] = await Promise.all([
  getOrganization(organizationId).catch(() => null),
  getJobs(organizationId).catch(() => [] as Job[]),
]);

我们必须用以下内容替换它:

const [organization, jobs] = await Promise.all([
  getOrganization({ organizationId }).catch(() => null),
  getJobs({
    params: {
      organizationId: organizationId,
    },
  }).catch(() => [] as Job[]),
]);

公共工作

对于公共工作页面,应该重复相同的过程。

让我们打开src/pages/organizations/[organizationId]/jobs/[jobId].tsx并删除以下内容:

import {
  getJob,
  getOrganization,
} from '@/testing/test-data';

现在,让我们从相应的功能中导入getJobgetOrganization

import { getJob, PublicJobInfo } from '@/features/jobs';
import { getOrganization } from '@/features/organizations';

然后,在getServerSideProps内部,我们需要更新以下内容:

const [organization, job] = await Promise.all([
  getOrganization({ organizationId }).catch(() => null),
  getJob({ jobId }).catch(() => null),
]);

仪表板工作

对于仪表板工作,我们唯一需要做的事情是更新导入,这样我们就不再从测试数据中加载工作,而是从 API 中加载。

让我们通过更新src/pages/dashboard/jobs/index.tsx中的以下行来从jobs功能导入useJobs而不是测试数据:

import { JobsList, useJobs } from '@/features/jobs';
import { useUser } from '@/testing/test-data';

目前我们仍然会保留来自 test-datauseUser;我们将在下一章替换它。

由于新创建的 useJobs 钩子与 test-data 中的钩子略有不同,我们需要更新其使用方式,如下所示:

const jobs = useJobs({
  params: {
    organizationId: user.data?.organizationId ?? '',
  },
});

仪表板工作

仪表板中的工作详情页面也非常简单。

src/pages/dashboard/jobs/[jobId].tsx 文件中,让我们移除从 test-data 导入的 useJob

import { useJob } from '@/testing/test-data';

现在,让我们从 jobs 功能中导入它:

import {
  DashboardJobInfo,
  useJob,
} from '@/features/jobs';

在这里,我们需要更新 useJob 的使用方式:

const job = useJob({ jobId });

创建工作

对于工作创建,我们需要更新表单,当提交时,将创建一个新的工作。

目前,表单不可用,因此我们需要添加一些内容。

打开 src/features/jobs/components/create-job-form/create-job-form.tsx 文件,并将内容替换为以下内容:

import { Box, Stack } from '@chakra-ui/react';
import { useForm } from 'react-hook-form';
import { Button } from '@/components/button';
import { InputField } from '@/components/form';
import { useCreateJob } from '../../api/create-job';
import { CreateJobData } from '../../types';
export type CreateJobFormProps = {
  onSuccess: () => void;
};
export const CreateJobForm = ({
  onSuccess,
}: CreateJobFormProps) => {
  const createJob = useCreateJob({ onSuccess });
  const { register, handleSubmit, formState } =
    useForm<CreateJobData>();
  const onSubmit = (data: CreateJobData) => {
    createJob.submit({ data });
  };
  return (
    <Box w="full">
      <Stack
        as="form"
        onSubmit={handleSubmit(onSubmit)}
        w="full"
        spacing="8"
      >
        <InputField
          label="Position"
          {...register('position', {
            required: 'Required',
          })}
          error={formState.errors['position']}
        />
        <InputField
          label="Department"
          {...register('department', {
            required: 'Required',
          })}
          error={formState.errors['department']}
        />
        <InputField
          label="Location"
          {...register('location', {
            required: 'Required',
          })}
          error={formState.errors['location']}
        />
        <InputField
          type="textarea"
          label="Info"
          {...register('info', {
            required: 'Required',
          })}
          error={formState.errors['info']}
        />
        <Button
          isDisabled={createJob.isLoading}
          isLoading={createJob.isLoading}
          type="submit"
        >
          Create
        </Button>
      </Stack>
    </Box>
  );
};

在这个组件中,有几个值得注意的点:

  1. 我们正在使用 useForm 钩子来处理表单的状态。

  2. 我们正在导入并使用之前定义的 useCreateJob API 钩子来提交请求。

  3. 当突变成功时,会调用 onSuccess 回调。

注意

创建工作表单要求用户进行身份验证。由于我们尚未实现身份验证系统,您可以使用 MSW 开发工具使用测试用户进行身份验证以尝试表单提交。

摘要

在本章中,我们学习了如何使应用程序与其 API 进行通信。首先,我们定义了一个 API 客户端,它允许我们统一 API 请求。然后,我们介绍了 React Query,这是一个用于处理异步状态的库。使用它减少了样板代码并显著简化了代码库。

最后,我们声明了 API 请求,然后将其集成到应用程序中。

在下一章中,我们将学习如何为我们的应用程序创建一个身份验证系统,只有经过身份验证的用户才能访问仪表板。

第七章:实现用户认证和全局通知

在前面的章节中,我们配置了页面,创建了模拟 API,并从我们的应用程序中进行了 API 调用。然而,当涉及到管理仪表板中用户的认证时,应用程序仍然依赖于测试数据。

在本章中,我们将构建应用程序的认证系统,允许用户在管理仪表板中认证并访问受保护资源。我们还将创建一个吐司通知系统,以便在发生我们希望通知用户的行为时向用户提供反馈。

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

  • 实现认证系统

  • 实现通知

到本章结束时,我们将学会如何在我们的应用程序中认证用户,以及如何使用 Zustand 处理全局应用程序状态。

技术要求

在我们开始之前,我们需要设置项目。为了能够开发项目,你需要在你的计算机上安装以下内容:

  • Node.js版本 16 或以上以及npm版本 8 或以上。

安装 Node.js 和 npm 有多种方法。这里有一篇很好的文章,详细介绍了更多细节:www.nodejsdesignpatterns.com/blog/5-ways-to-install-node-js

  • VSCode(可选)是目前最流行的 JavaScript/TypeScript 编辑器/IDE,因此我们将使用它。它是开源的,与 TypeScript 有很好的集成,并且你可以通过扩展来扩展其功能。可以从这里下载:code.visualstudio.com/

本章的代码文件可以在此处找到:github.com/PacktPublishing/React-Application-Architecture-for-Production

可以使用以下命令在本地克隆存储库:

git clone https://github.com/PacktPublishing/React-Application-Architecture-for-Production.git

一旦克隆了存储库,我们需要安装应用程序的依赖项:

npm install

我们可以使用以下命令提供环境变量:

cp .env.example .env

一旦安装了依赖项,我们需要选择与本章匹配的正确代码库阶段。我们可以通过执行以下命令来完成:

npm run stage:switch

此命令将为我们提供每个章节的阶段列表:

? What stage do you want to switch to? (Use arrow
 keys)
❯ chapter-02
  chapter-03
  chapter-03-start
  chapter-04
  chapter-04-start
  chapter-05
  chapter-05-start
(Move up and down to reveal more choices)

这是第七章,所以如果你想跟随,可以选择chapter-07-start,或者选择chapter-07来查看本章的最终结果。

一旦选择了章节,所有必要的文件将出现,以便跟随本章内容。

如需了解更多关于设置细节的信息,请查看README.md文件。

实现认证系统

认证是识别平台上的用户的过程。在我们的应用程序中,我们需要在用户访问管理仪表板时识别用户。

在实现系统之前,我们应该仔细研究它的工作方式。

认证系统概述

我们将使用基于令牌的认证系统来认证用户。这意味着 API 将期望用户在请求中发送他们的认证令牌以访问受保护资源。

让我们看一下以下图表和后续步骤:

图 7.1 – 认证系统概述

图 7.1 – 认证系统概述

以下是对先前图表的解释:

  1. 用户通过向/auth/login端点创建请求来使用凭据提交登录表单。

  2. 如果用户存在且凭据有效,将返回包含用户数据的响应。除了响应数据外,我们还在附加一个httpOnly cookie,从现在起将用于认证请求。

  3. 每当用户进行认证时,我们将从响应中存储用户对象到 react-query 的缓存中,并使其对应用程序可用。

  4. 由于认证是基于httpOnly cookie 的 cookie,我们不需要在前端处理认证令牌。任何后续请求都将自动包含令牌。

  5. 在页面刷新时持久化用户数据将通过调用/auth/me端点来处理,该端点将获取用户数据并将其存储在相同的 react-query 缓存中。

为了实现这个系统,我们需要以下内容:

  • 认证功能(登录、登出和访问认证用户)

  • 保护需要用户认证的资源

构建认证功能

为了构建认证功能,我们已经有实现了端点。我们在第五章,“模拟 API”中创建了它们。现在我们需要在我们的应用程序中消费它们。

登录

为了允许用户登录到仪表板,我们将要求他们输入他们的电子邮件和密码并提交表单。

为了实现登录功能,我们需要向服务器上的登录端点发起 API 调用。让我们创建src/features/auth/api/login.ts文件并添加以下内容:

import { useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { queryClient } from '@/lib/react-query';
import { AuthUser, LoginData } from '../types';
export const login = (
  data: LoginData
): Promise<{
  user: AuthUser;
}> => {
  return apiClient.post('/auth/login', data);
};
type UseLoginOptions = {
  onSuccess?: (user: AuthUser) => void;
};
export const useLogin = ({
  onSuccess,
}: UseLoginOptions = {}) => {
  const { mutate: submit, isLoading } = useMutation({
    mutationFn: login,
    onSuccess: ({ user }) => {
      queryClient.setQueryData(['auth-user'], user);
      onSuccess?.(user);
    },
  });
  return { submit, isLoading };
};

我们正在定义 API 请求和 API 突变钩子,允许我们从我们的应用程序中调用 API。

然后,我们可以更新登录表单以进行 API 调用。让我们修改src/features/auth/components/login-form/login-form.tsx

首先,让我们导入useLogin钩子:

import { useLogin } from '../../api/login';

然后,在LoginForm组件体内部,我们希望在提交处理程序中初始化登录突变并提交它:

export const LoginForm = ({
  onSuccess,
}: LoginFormProps) => {
  const login = useLogin({ onSuccess });
  const { register, handleSubmit, formState } =
    useForm<LoginData>();
  const onSubmit = (data: LoginData) => {
    login.submit(data);
  };
     // rest of the component body
}

我们还应该指出操作正在提交,通过禁用提交按钮:

<Button
  isLoading={login.isLoading}
  isDisabled={login.isLoading}
  type="submit"
>
  Log in
</Button>

当表单提交时,它将调用登录端点,如果凭据有效,将认证用户。

登出

为了实现登出功能,我们需要调用登出端点,这将清除认证 cookie。让我们创建src/features/auth/api/logout.ts文件并添加以下内容:

import { useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { queryClient } from '@/lib/react-query';
export const logout = () => {
  return apiClient.post('/auth/logout');
};
type UseLogoutOptions = {
  onSuccess?: () => void;
};
export const useLogout = ({
  onSuccess,
}: UseLogoutOptions = {}) => {
  const { mutate: submit, isLoading } = useMutation({
    mutationFn: logout,
    onSuccess: () => {
      queryClient.clear();
      onSuccess?.();
    },
  });
  return { submit, isLoading };
};

我们正在定义登出 API 请求和登出突变。

然后,我们可以通过从src/features/auth/index.ts文件中重新导出它来从认证功能中公开它:

export * from './api/logout';

我们希望在用户点击src/layouts/dashboard-layout.tsx文件并导入额外依赖项时使用它:

import { useRouter } from 'next/router';
import { useLogout } from '@/features/auth';

然后,在Navbar组件中,让我们使用useLogout钩子:

const Navbar = () => {
  const router = useRouter();
  const logout = useLogout({
    onSuccess: () => router.push('/auth/login'),
  });
  // the rest of the component
};

注意,当注销操作成功时,我们如何将用户重定向到登录页面。

让我们最终将操作连接到注销按钮:

<Button
  isDisabled={logout.isLoading}
  isLoading={logout.isLoading}
  variant="outline"
  onClick={() => logout.submit()}
>
  Log Out
</Button>

现在,当用户点击注销按钮时,将调用注销端点,然后用户将被带到登录页面。

获取经过认证的用户

要开始,让我们创建src/features/auth/api/get-auth-user.ts文件并添加以下内容:

import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { AuthUser } from '../types';
export const getAuthUser = (): Promise<AuthUser> => {
  return apiClient.get('/auth/me');
};
export const useUser = () => {
  const { data, isLoading } = useQuery({
    queryKey: ['auth-user'],
    queryFn: () => getAuthUser(),
  });
  return { data, isLoading };
};

此端点将返回当前登录用户的信息。

然后,我们希望从src/features/auth/index.ts文件中导出它:

export * from './api/get-auth-user';

回到src/layouts/dashboard-layout.tsx文件,我们需要那里的用户数据。

让我们用以下内容替换测试数据中的useUser钩子:

import { useLogout, useUser } from '@/features/auth';

另一个需要用户数据的地方是仪表板工作页面。让我们打开src/pages/dashboard/jobs/index.tsx并导入useUser钩子:

import { useUser } from '@/features/auth';

保护需要用户认证的资源

如果未经认证的用户尝试查看受保护资源,会发生什么?我们希望确保任何此类尝试都将用户重定向到登录页面。为此,我们希望创建一个组件,该组件将包装受保护资源,并且只有在用户经过认证的情况下才允许用户查看受保护内容。

Protected组件将从/auth/me端点获取用户,如果用户存在,它将允许内容显示。否则,它将重定向用户到登录页面。

该组件已在src/features/auth/components/protected/protected.tsx文件中定义,但现在并没有做什么。让我们修改该文件如下:

import { Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import { ReactNode, useEffect } from 'react';
import { Loading } from '@/components/loading';
import { useUser } from '../../api/get-auth-user';
export type ProtectedProps = {
  children: ReactNode;
};
export const Protected = ({
  children,
}: ProtectedProps) => {
  const { replace, asPath } = useRouter();
  const user = useUser();
  useEffect(() => {
    if (!user.data && !user.isLoading) {
      replace(
        `/auth/login?redirect=${asPath}`,
        undefined,
        { shallow: true }
      );
    }
  }, [user, asPath, replace]);
  if (user.isLoading) {
    return (
      <Flex direction="column" justify="center" h="full">
        <Loading />
      </Flex>
    );
  }
  if (!user.data && !user.isLoading) return null;
  return <>{children}</>;
};

该组件接受子内容作为 props,这意味着它将包裹嵌套内容并决定是否应该渲染。

我们从相同的useUser钩子中访问用户。最初,在数据正在获取时,组件渲染Loading组件。一旦数据被获取,我们在useEffect中检查用户是否存在,如果不存在,我们将重定向到登录页面。否则,我们可以像往常一样渲染子组件。

Protected组件旨在在仪表板中使用。由于我们已经有了一个可重用的仪表板布局,我们不需要在每一页上包裹Protected,我们可以在仪表板布局中只做一次。

让我们打开src/layouts/dashboard-layout.tsx并导入Protected组件:

import { Protected } from '@/features/auth';

然后,在DashboardLayout组件的 JSX 中,让我们将一切包裹在Protected中,如下所示:

export const DashboardLayout = ({
  children,
}: DashboardLayoutProps) => {
  const user = useUser();
  return (
    <Protected>
      <Box as="section" h="100vh" overflowY="auto">
        <Navbar />
        <Container as="main" maxW="container.lg" py="12">
          {children}
        </Container>
        <Box py="8" textAlign="center">
          <Link
            href={`/organizations/${user.data?.
              organizationId}`}
          >
            View Public Organization Page
          </Link>
        </Box>
      </Box>
    </Protected>
  );
};

如果您尝试访问http://localhost:3000/dashboard/jobs页面,您将被重定向到登录页面。

尝试使用现有的凭据(电子邮件:user1@test.com;密码:password)进行登录。如果一切顺利,您可以使用属于给定用户组织的数据访问仪表板。

实现通知

每当应用程序中发生某些事情,例如表单提交成功或 API 请求失败时,我们希望通知我们的用户。

我们需要创建一个全局存储库,用于跟踪所有通知。我们希望它是全局的,因为我们希望从应用程序的任何地方显示这些通知。

对于处理全局状态,我们将使用 Zustand,这是一个轻量级且非常简单的状态管理库。

创建存储库

让我们打开src/stores/notifications/notifications.ts文件并导入我们将要使用的依赖项:

import { createStore, useStore } from 'zustand';
import { uid } from '@/utils/uid';

然后,让我们声明存储库的通知类型:

export type NotificationType =
  | 'info'
  | 'warning'
  | 'success'
  | 'error';
export type Notification = {
  id: string;
  type: NotificationType;
  title: string;
  duration?: number;
  message?: string;
};
export type NotificationsStore = {
  notifications: Notification[];
  showNotification: (
    notification: Omit<Notification, 'id'>
  ) => void;
  dismissNotification: (id: string) => void;
};

存储库将跟踪活动通知的数组。要显示通知,我们需要调用showNotification方法,要关闭它,我们将调用dismissNotification

让我们创建存储库:

export const notificationsStore =
  createStore<NotificationsStore>((set, get) => ({
    notifications: [],
    showNotification: (notification) => {
      const id = uid();
      set((state) => ({
        notifications: [
          ...state.notifications,
          { id, ...notification },
        ],
      }));
      if (notification.duration) {
        setTimeout(() => {
          get().dismissNotification(id);
        }, notification.duration);
      }
    },
    dismissNotification: (id) => {
      set((state) => ({
        notifications: state.notifications.filter(
          (notification) => notification.id !== id
        ),
      }));
    },
  }));

为了创建存储库,我们使用来自zustand/vanillacreateStore来使其更便携和可测试。该函数为我们提供了setget辅助函数,分别允许我们修改和访问存储库。

由于我们使用纯方法创建了存储库,我们需要使其与 React 兼容。我们通过以下方式使用 Zustand 提供的useStore钩子来实现这一点:

export const useNotifications = () =>
  useStore(notificationsStore);

那就是通知存储库。如您所见,它非常简单,几乎没有样板代码。

任何时候我们需要在 React 组件或钩子内部访问存储库,我们都可以使用useNotifications钩子。或者,如果我们想从 React 之外的纯 JavaScript 函数中访问存储库,我们可以直接使用notificationStore

创建用户界面

现在我们有了通知存储库,我们需要构建一个 UI 来在活动时显示这些通知。

让我们打开src/components/notifications/notifications.tsx文件并导入所需的依赖项:

import {
  Flex,
  Box,
  CloseButton,
  Stack,
  Text,
} from '@chakra-ui/react';
import {
  Notification,
  NotificationType,
  useNotifications,
} from '@/stores/notifications';

然后,让我们创建Notifications组件,它将显示通知:

export const Notifications = () => {
  const { notifications, dismissNotification } =
    useNotifications();
  if (notifications.length < 1) return null;
  return (
    <Box
      as="section"
      p="4"
      position="fixed"
      top="12"
      right="0"
      zIndex="1"
    >
      <Flex gap="4" direction="column-reverse">
        {notifications.map((notification) => (
          <NotificationToast
            key={notification.id}
            notification={notification}
            onDismiss={dismissNotification}
          />
        ))}
      </Flex>
    </Box>
  );
};

我们通过useNotifications钩子访问通知,它为我们提供了对存储库的访问。

如您所见,我们正在映射活动通知。我们为每个活动通知渲染NotificationToast组件,并将通知对象和关闭处理程序作为属性传递。让我们通过描述变体和属性类型来实现它:

const notificationVariants: Record<
  NotificationType,
  { color: string }
> = {
  info: {
    color: 'primary',
  },
  success: {
    color: 'green',
  },
  warning: {
    color: 'orange',
  },
  error: {
    color: 'red',
  },
};
type NotificationToastProps = {
  notification: Omit<Notification, 'duration'>;
  onDismiss: (id: string) => void;
};

然后,实现NotificationToast组件:

const NotificationToast = ({
  notification,
  onDismiss,
}: NotificationToastProps) => {
  const { id, type, title, message } = notification;
  return (
    <Box
      w={{ base: 'full', sm: 'md' }}
      boxShadow="md"
      bg="white"
      borderRadius="lg"
      {...notificationVariants[type]}
    >
      <Stack
        direction="row"
        p="4"
        spacing="3"
        justifyContent="space-between"
      >
        <Stack spacing="2.5">
          <Stack spacing="1">
            <Text fontSize="sm" fontWeight="medium">
              {title}
            </Text>
            {notification.message && (
              <Text fontSize="sm" color="muted">
                {message}
              </Text>
            )}
          </Stack>
        </Stack>
        <CloseButton
          onClick={() => onDismiss(id)}
          transform="translateY(-6px)"
        />
      </Stack>
    </Box>
  );
};

现在我们已经有了通知存储库和创建的 UI,是时候将它们集成到应用程序中了。

集成和使用通知

要将通知集成到应用程序中,让我们打开src/providers/app.tsx文件并导入Notifications组件:

import { Notifications } from '@/components/notifications';

然后,让我们在AppProvider中渲染组件:

export const AppProvider = ({
  children,
}: AppProviderProps) => {
  return (
    <ChakraProvider theme={theme}>
      <GlobalStyle />
      <Notifications />
      {/* rest of the code */}
    </ChakraProvider>
  );
};

完美!现在我们准备好开始显示一些通知了。

如前所述,我们可以在 React 世界和其外部使用该存储。

我们需要在创建作业的页面 React 组件中使用它。每当成功创建一个作业时,我们希望让用户知道。

让我们打开src/pages/dashboard/jobs/create.tsx文件并导入useNotifications钩子:

import { useNotifications } from '@/stores/notifications';

然后,让我们在DashboardCreateJobPage组件体内部初始化钩子:

const { showNotification } = useNotifications();

然后,我们可以在onSuccess处理程序中调用showNotification

const onSuccess = () => {
  showNotification({
    type: 'success',
    title: 'Success',
    duration: 5000,
    message: 'Job Created!',
  });
  router.push(`/dashboard/jobs`);
};

我们展示了一个新的成功通知,它将在 5 秒后消失。

要查看其操作效果,让我们打开localhost:3000/dashboard/jobs/create并提交表单。如果提交成功,我们应该看到如下内容:

图 7.2 – 通知在操作中

图 7.2 – 通知在操作中

完美!每当创建一个作业时,用户都会收到通知。

我们可以利用通知的另一个地方是在 API 错误处理中。每当发生 API 错误时,我们希望让用户知道出了些问题。

我们可以在 API 客户端级别处理它。由于 Axios 支持拦截器,并且我们已经配置了它们,我们只需要修改响应错误拦截器。

让我们打开src/lib/api-client.ts并导入存储:

import { notificationsStore } from '@/stores/notifications';

然后,在响应错误拦截器中,让我们定位以下内容:

console.error(message);

我们将用以下内容替换它:

notificationsStore.getState().showNotification({
  type: 'error',
  title: 'Error',
  duration: 5000,
  message,
});

要访问 vanilla Zustand 存储上的值和方法,我们需要调用getState方法。

每当 API 发生错误时,都会向用户显示错误通知。

值得注意的是,Chakra UI 自带一个开箱即用的 toast 通知系统,使用起来非常简单,非常适合我们的需求,但我们还是自己构建了一个,以便学习如何以优雅且简单的方式管理全局应用程序状态。

摘要

在本章中,我们学习了如何处理身份验证和管理应用程序的全局状态。

我们从对身份验证系统及其工作原理的概述开始。然后,我们实现了登录、注销和获取认证用户信息等身份验证功能。我们还构建了Protected组件,该组件根据用户的认证状态控制用户是否可以查看页面。

然后,我们构建了一个 toast 通知系统,用户可以从应用程序的任何地方触发和显示通知。构建它的主要目的是介绍 Zustand,这是一个非常简单且易于使用的全局状态管理库,用于处理全局应用程序状态。

在下一章中,我们将学习如何使用单元测试、集成测试和端到端测试来测试应用程序。

第八章:测试

我们终于完成了应用程序的开发。在我们将其发布到生产之前,我们想确保一切按预期工作。

在本章中,我们将学习如何使用不同的测试方法来测试我们的应用程序。这将给我们信心重构应用程序,构建新功能,修改现有功能,而不用担心破坏当前应用程序的行为。

我们将涵盖以下主题:

  • 单元测试

  • 集成测试

  • 端到端测试

到本章结束时,我们将知道如何使用不同的方法和工具来测试我们的应用程序。

技术要求

在我们开始之前,我们需要设置我们的项目。为了能够开发我们的项目,我们需要在计算机上安装以下内容:

  • Node.js 版本 16 或更高版本以及 npm 版本 8 或更高版本

安装 Node.js 和 npm 有多种方法。这里有一篇很好的文章,详细介绍了更多细节:www.nodejsdesignpatterns.com/blog/5-ways-to-install-node-js

  • VSCode(可选)目前是 JavaScript/TypeScript 最受欢迎的编辑器/IDE,因此我们将使用它。它是开源的,与 TypeScript 集成良好,我们可以通过扩展来扩展其功能。可以从 code.visualstudio.com/ 下载。

本章的代码文件可以在以下位置找到:github.com/PacktPublishing/React-Application-Architecture-for-Production

可以使用以下命令在本地克隆存储库:

git clone https://github.com/PacktPublishing/React-Application-Architecture-for-Production.git

一旦克隆了存储库,我们需要安装应用程序的依赖项:

npm install

我们可以使用以下命令提供环境变量:

cp .env.example .env

一旦安装了依赖项,我们需要选择与本章匹配的正确代码库阶段。我们可以通过执行以下命令来完成:

npm run stage:switch

此命令将为我们提供每个章节的阶段列表:

? What stage do you want to switch to? (Use arrow
 keys)
❯ chapter-02
  chapter-03
  chapter-03-start
  chapter-04
  chapter-04-start
  chapter-05
  chapter-05-start
(Move up and down to reveal more choices)

这是第八章,因此如果我们想跟随,可以选择 chapter-08-start,或者选择 chapter-08 来查看本章的最终结果。

一旦选择了章节,所有跟随该章节所需的文件将显示出来。

关于设置细节的更多信息,请查看 README.md 文件。

单元测试

单元测试是一种测试方法,其中应用程序单元在隔离状态下进行测试,不依赖于其他部分。

对于单元测试,我们将使用 Jest,这是测试 JavaScript 应用程序最流行的框架。

在我们的应用程序中,我们将对通知存储进行单元测试。

让我们打开 src/stores/notifications/__tests__/notifications.test.ts 文件并添加以下内容:

import {
  notificationsStore,
  Notification,
} from '../notifications';
const notification = {
  id: '123',
  title: 'Hello World',
  type: 'info',
  message: 'This is a notification',
} as Notification;
describe('notifications store', () => {
  it('should show and dismiss notifications', () => {
    // 1
    expect(
      notificationsStore.getState().notifications.length
    ).toBe(0);
    // 2
    notificationsStore
      .getState()
      .showNotification(notification);
    expect(
      notificationsStore.getState().notifications
    ).toContainEqual(notification);
    // 3
    notificationsStore
      .getState()
      .dismissNotification(notification.id);
    expect(
      notificationsStore.getState().notifications
    ).not.toContainEqual(notification);
  });
});

通知测试工作如下:

  1. 我们断言 notifications 数组最初是空的。

  2. 然后,我们触发showNotification动作,并测试新创建的通知是否存在于notifications数组中。

  3. 最后,我们调用dismissNotification函数来取消通知,并确保通知已从notifications数组中移除。

要运行单元测试,我们可以执行以下命令:

npm run test

单元测试的另一个用例将是各种实用函数和可重用组件,包括可以单独测试的逻辑。然而,在我们的案例中,我们将主要使用集成测试来测试我们的组件,这将在下一节中看到。

集成测试

集成测试是一种测试方法,其中测试应用程序的多个部分。集成测试通常比单元测试更有帮助,并且大多数应用程序测试应该是集成测试。

集成测试更有价值,因为它们可以增加我们对应用程序的信心,因为我们正在测试不同部分的功能、它们之间的关系以及它们如何进行通信。

对于集成测试,我们将使用 Jest 和 React Testing Library。这是一种测试应用程序功能的好方法,就像用户使用它一样。

src/testing/test-utils.ts中,我们可以定义一些我们可以在测试中使用的实用工具。我们还应该从这里重新导出 React Testing Library 提供的所有实用工具,这样我们就可以在测试中需要时轻松访问它们。目前,除了 React Testing Library 提供的所有函数外,我们还导出以下实用工具:

  • appRender是一个函数,它调用 React Testing Library 中的render函数,并将AppProvider作为wrapper。我们需要这样做,因为在我们进行集成测试时,我们的组件依赖于在AppProvider中定义的多个依赖项,例如 React Query 上下文、通知等。提供AppProvider作为wrapper将在我们测试期间渲染组件时使其可用。

  • checkTableValues是一个函数,它遍历表格中的所有单元格,并将每个值与提供的数据中的相应值进行比较,确保所有信息都显示在表格中。

  • waitForLoadingToFinish是一个函数,它在我们可以继续进行测试之前等待所有加载旋转器消失。这在我们必须等待某些数据被获取后才能断言值时很有用。

另一个值得提及的文件是src/testing/setup-tests.ts,在那里我们可以配置不同的初始化和清理动作。在我们的案例中,它帮助我们初始化和重置测试之间的模拟 API。

我们可以根据页面拆分我们的集成测试,并测试每个页面上的所有部分。想法是在以下部分对我们的应用程序进行集成测试:

  • 仪表板工作页面

  • 仪表板工作页面

  • 创建工作页面

  • 登录页面

  • 公共工作页面

  • 公共组织页面

仪表板工作页面

仪表板作业页面的功能基于当前登录用户。在这里,我们正在获取用户组织的所有作业并在作业表中显示它们。

让我们从打开src/__tests__/dashboard-jobs-page.test.tsx文件并添加以下内容开始:

import DashboardJobsPage from '@/pages/dashboard/jobs';
import { getUser } from '@/testing/mocks/utils';
import { testData } from '@/testing/test-data';
import {
  appRender,
  checkTableValues,
  screen,
  waitForLoadingToFinish,
} from '@/testing/test-utils';
// 1
jest.mock('@/features/auth', () => ({
  useUser: () => ({ data: getUser() }),
}));
describe('Dashboard Jobs Page', () => {
  it('should render the jobs list', async () => {
    // 2
    await appRender(<DashboardJobsPage />);
    // 3
    expect(screen.getByText(/jobs/i)).toBeInTheDocument();
    // 4
    await waitForLoadingToFinish();
    // 5
    checkTableValues({
      container: screen.getByTestId('jobs-list'),
      data: testData.jobs,
      columns: ['position', 'department', 'location'],
    });
  });
});

测试工作如下:

  1. 由于加载作业依赖于当前登录用户,我们需要模拟useUser钩子以返回正确的用户对象。

  2. 然后,我们渲染页面。

  3. 然后,我们确保作业页面的标题显示在页面上。

  4. 要获取已加载的作业,我们需要等待它们加载完成。

  5. 最后,我们断言表格中的作业值。

仪表板作业页面

仪表板作业页面的功能是我们希望加载作业数据并在页面上显示它。

让我们从打开src/__tests__/dashboard-job-page.test.tsx文件并添加以下内容开始:

import DashboardJobPage from '@/pages/dashboard/jobs/
  [jobId]';
import { testData } from '@/testing/test-data';
import {
  appRender,
  screen,
  waitForLoadingToFinish,
} from '@/testing/test-utils';
const job = testData.jobs[0];
const router = {
  query: {
    jobId: job.id,
  },
};
// 1
jest.mock('next/router', () => ({
  useRouter: () => router,
}));
describe('Dashboard Job Page', () => {
  it('should render all the job details', async () => {
    // 2
    await appRender(<DashboardJobPage />);
    await waitForLoadingToFinish();
    const jobPosition = screen.getByRole('heading', {
      name: job.position,
    });
    const info = screen.getByText(job.info);
    // 3
    expect(jobPosition).toBeInTheDocument();
    expect(info).toBeInTheDocument();
  });
});

测试工作如下:

  1. 由于我们是根据jobId URL 参数加载作业数据,我们需要模拟useRouter钩子以返回正确的作业 ID。

  2. 然后,我们渲染页面并等待数据加载,通过等待页面上所有加载器消失来实现。

  3. 最后,我们检查作业数据是否显示在页面上。

作业创建页面

作业创建页面包含一个表单,当提交时,它调用 API 端点在后端创建一个新的作业。当请求成功时,我们将用户重定向到仪表板作业页面并显示关于成功创建作业的通知。

让我们从打开src/__tests__/dashboard-create-job-page.test.tsx文件并添加以下内容开始:

import DashboardCreateJobPage from '@/pages/dashboard/jobs/
  create';
import {
  appRender,
  screen,
  userEvent,
  waitFor,
} from '@/testing/test-utils';
const router = {
  push: jest.fn(),
};
// 1
jest.mock('next/router', () => ({
  useRouter: () => router,
}));
const jobData = {
  position: 'Software Engineer',
  location: 'London',
  department: 'Engineering',
  info: 'Lorem Ipsum',
};
describe('Dashboard Create Job Page', () => {
  it('should create a new job', async () => {
    // 2
    appRender(<DashboardCreateJobPage />);
    const positionInput = screen.getByRole('textbox', {
      name: /position/i,
    });
    const locationInput = screen.getByRole('textbox', {
      name: /location/i,
    });
    const departmentInput = screen.getByRole('textbox', {
      name: /department/i,
    });
    const infoInput = screen.getByRole('textbox', {
      name: /info/i,
    });
    const submitButton = screen.getByRole('button', {
      name: /create/i,
    });
    // 3
    userEvent.type(positionInput, jobData.position);
    userEvent.type(locationInput, jobData.location);
    userEvent.type(departmentInput, jobData.department);
    userEvent.type(infoInput, jobData.info);
    // 4
    userEvent.click(submitButton);
    // 5
    await waitFor(() =>
      expect(
        screen.getByText(/job created!/i)
      ).toBeInTheDocument()
    );
  });
});

测试工作如下:

  1. 首先,我们需要模拟useRouter钩子以包含push方法,因为提交后它用于导航到作业页面。

  2. 然后,我们渲染页面组件。

  3. 之后,我们将所有输入值插入到它们中。

  4. 然后,我们通过模拟提交按钮上的点击事件来提交表单。

  5. 提交后,我们需要等待文档中显示作业已创建的通知。

公共组织页面

对于组织页面,由于我们是在服务器上渲染它,我们需要在服务器上获取数据并在页面上显示。

让我们从打开src/__tests__/public-organization-page.test.tsx文件并定义测试套件的骨架开始,如下所示:

import PublicOrganizationPage, {
  getServerSideProps,
} from '@/pages/organizations/[organizationId]';
import { testData } from '@/testing/test-data';
import {
  appRender,
  checkTableValues,
  screen,
} from '@/testing/test-utils';
const organization = testData.organizations[0];
const jobs = testData.jobs;
describe('Public Organization Page', () => {
  it('should use getServerSideProps that fetches and
    returns the proper data', async () => {
  });
  it('should render the organization details', async () => {
  });
  it('should render the not found message if the
    organization is not found', async () => {
  });
});

现在,我们将关注测试套件中的每个测试。

首先,我们想要测试getServerSideProps函数获取正确的数据并将其作为 props 返回,这些 props 将在页面上提供:

it('should use getServerSideProps that fetches and returns
  the proper data', async () => {
  const { props } = await getServerSideProps({
    params: {
      organizationId: organization.id,
    },
  } as any);
  expect(props.organization).toEqual(organization);
  expect(props.jobs).toEqual(jobs);
});

在这里,我们正在调用getServerSideProps函数并断言返回的值包含相应的数据。

在第二个测试中,我们想要验证提供给PublicOrganizationPage组件的 props 是否正确渲染:

it('should render the organization details', async () => {
  appRender(
    <PublicOrganizationPage
      organization={organization}
      jobs={jobs}
    />
  );
  expect(
    screen.getByRole('heading', {
      name: organization.name,
    })
  ).toBeInTheDocument();
  expect(
    screen.getByRole('heading', {
      name: organization.email,
    })
  ).toBeInTheDocument();
  expect(
    screen.getByRole('heading', {
      name: organization.phone,
    })
  ).toBeInTheDocument();
  checkTableValues({
    container: screen.getByTestId('jobs-list'),
    data: jobs,
    columns: ['position', 'department', 'location'],
  });
});

在这个测试中,我们正在渲染页面组件并验证所有值是否显示在页面上。

在测试套件的第三个测试中,我们想要断言如果组织不存在,我们想要显示未找到消息:

it('should render the not found message if the organization is not found', async () => {
  appRender(
    <PublicOrganizationPage
      organization={null}
      jobs={[]}
    />
  );
  const notFoundMessage = screen.getByRole('heading', {
    name: /not found/i,
  });
  expect(notFoundMessage).toBeInTheDocument();
});

在这里,我们正在渲染PublicOrganizationPage组件,并使用null的组织值,然后验证未找到消息是否在文档中。

公共职位页面

对于公共职位页面,由于我们在服务器上渲染它,我们需要在服务器上获取数据并在页面上显示它。

让我们从打开src/__tests__/public-job-page.test.tsx文件并定义测试的框架开始:

import PublicJobPage, {
  getServerSideProps,
} from '@/pages/organizations/[organizationId]/jobs/[jobId]';
import { testData } from '@/testing/test-data';
import { appRender, screen } from '@/testing/test-utils';
const job = testData.jobs[0];
const organization = testData.organizations[0];
describe('Public Job Page', () => {
  it('should use getServerSideProps that fetches and
    returns the proper data', async () => {
  });
  it('should render the job details', async () => {
  });
  it('should render the not found message if the data does
    not exist', async () => {
  });
});

现在,我们可以专注于测试套件中的每个测试。

首先,我们需要测试getServerSideProps函数,它将获取数据并通过 props 将数据返回到页面:

it('should use getServerSideProps that fetches and returns
  the proper data', async () => {
  const { props } = await getServerSideProps({
    params: {
      jobId: job.id,
      organizationId: organization.id,
    },
  } as any);
  expect(props.job).toEqual(job);
  expect(props.organization).toEqual(organization);
});

在这里,我们调用getServerSideProps并断言返回值是否与预期数据匹配。

现在,我们可以测试PublicJobPage,我们想要确保提供的数据显示在页面上:

it('should render the job details', async () => {
  appRender(
    <PublicJobPage
      organization={organization}
      job={job}
    />
  );
  const jobPosition = screen.getByRole('heading', {
    name: job.position,
  });
  const info = screen.getByText(job.info);
  expect(jobPosition).toBeInTheDocument();
  expect(info).toBeInTheDocument();
});

在这里,我们渲染页面组件并验证提供的职位数据是否显示在页面上。

最后,我们要断言getServerSideProps提供的数据不存在的情况:

it('should render the not found message if the data does not exist', async () => {
  const { rerender } = appRender(
    <PublicJobPage organization={null} job={null} />
  );
  const notFoundMessage = screen.getByRole('heading', {
    name: /not found/i,
  });
  expect(notFoundMessage).toBeInTheDocument();
  rerender(
    <PublicJobPage
      organization={organization}
      job={null}
    />
  );
  expect(notFoundMessage).toBeInTheDocument();
  rerender(
    <PublicJobPage organization={null} job={job} />
  );
  expect(notFoundMessage).toBeInTheDocument();
  rerender(
    <PublicJobPage
      organization={organization}
      job={{ ...job, organizationId: '123' }}
    />
  );
  expect(notFoundMessage).toBeInTheDocument();
});

由于存在多个数据可能被视为无效的情况,我们使用了rerender函数,它可以使用不同的 props 集重新渲染组件。我们断言如果数据未找到,则未找到消息将在页面上显示。

登录页面

登录页面渲染登录表单,当成功提交时,将用户导航到仪表板。

让我们从打开src/__tests__/login-page.test.tsx文件并添加以下内容开始:

import LoginPage from '@/pages/auth/login';
import {
  appRender,
  screen,
  userEvent,
  waitFor,
} from '@/testing/test-utils';
// 1
const router = {
  replace: jest.fn(),
  query: {},
};
jest.mock('next/router', () => ({
  useRouter: () => router,
}));
describe('Login Page', () => {
  it('should login the user into the dashboard', async () => {
    // 2
    await appRender(<LoginPage />);
    const emailInput = screen.getByRole('textbox', {
      name: /email/i,
    });
    const passwordInput =
      screen.getByLabelText(/password/i);
    const submitButton = screen.getByRole('button', {
      name: /log in/i,
    });
    const credentials = {
      email: 'user1@test.com',
      password: 'password',
    };
    // 3
    userEvent.type(emailInput, credentials.email);
    userEvent.type(passwordInput, credentials.password);
    userEvent.click(submitButton);
    // 4
    await waitFor(() =>
      expect(router.replace).toHaveBeenCalledWith(
        '/dashboard/jobs'
      )
    );
  });
});

测试工作如下:

  1. 我们需要模拟useRouter钩子,因为它被用来在成功提交时将用户导航到仪表板。

  2. 接下来,我们渲染页面。

  3. 然后,我们将凭据输入到表单中并提交。

  4. 最后,我们期望在路由器上的replace方法被调用,并带有/dashboard/jobs值,如果登录提交成功,则应将用户导航到仪表板。

要运行集成测试,我们可以执行以下命令:

npm run test

如果我们想观察测试中的变化,我们可以执行以下命令:

npm run test:watch

端到端测试

端到端测试是一种将应用程序作为一个完整实体进行测试的测试方法。通常,这些测试包括以自动化方式运行整个应用程序,包括前端和后端,并验证整个系统是否正常工作。

在端到端测试中,我们通常想要测试成功路径,以确认一切按预期工作。

为了测试我们的应用程序端到端,我们将使用 Cypress,这是一个非常流行的测试框架,它通过在无头浏览器中执行测试来工作。这意味着测试将在真实的浏览器环境中运行。除了 Cypress 之外,由于我们已经熟悉了 React Testing Library,我们将使用 Cypress 的 Testing Library 插件来与页面交互。

对于我们的应用程序,我们想要测试两个应用程序的流程:

  • 仪表板流程

  • 公共流程

仪表板流程

仪表板流程是组织管理员想要测试用户认证以及访问和交互仪表板不同部分的流程。

让我们从打开cypress/e2e/dashboard.cy.ts文件并添加我们的测试骨架开始:

import { testData } from '../../src/testing/test-data';
const user = testData.users[0];
const job = testData.jobs[0];
describe('dashboard', () => {
  it('should authenticate into the dashboard', () => {
  });
  it('should navigate to and visit the job details page', () => {
  });
  it('should create a new job', () => {
  });
  it('should log out from the dashboard', () => {
  });
});

现在,让我们来实现测试。

首先,我们想要认证进入仪表板:

it('should authenticate into the dashboard', () => {
  cy.clearCookies();
  cy.clearLocalStorage();
  cy.visit('http://localhost:3000/dashboard/jobs');
  cy.wait(500);
  cy.url().should(
    'equal',
    'http://localhost:3000/auth/login?redirect=/dashboard/
      jobs'
  );
  cy.findByRole('textbox', {
    name: /email/i,
  }).type(user.email);
  cy.findByLabelText(/password/i).type(
    user.password.toLowerCase()
  );
  cy.findByRole('button', {
    name: /log in/i,
  }).click();
  cy.findByRole('heading', {
    name: /jobs/i,
  }).should('exist');
});

在这里,我们想要清除 cookies 和localStorage。然后,我们必须尝试导航到仪表板;然而,应用程序将重定向我们到登录页面。我们必须在登录表单中输入凭据并提交。之后,我们将被重定向到仪表板职位页面,在那里我们可以看到职位标题。

现在我们处于仪表板职位页面,我们可以通过访问职位详情页面来进一步操作:

it('should navigate to and visit the job details page', () => {
  cy.findByRole('row', {
    name: new RegExp(
      `${job.position} ${job.department} ${job.location}
        View`,
      'i'
    ),
  }).within(() => {
    cy.findByRole('link', {
      name: /view/i,
    }).click();
  });
  cy.findByRole('heading', {
    name: job.position,
  }).should('exist');
  cy.findByText(new RegExp(job.info, 'i')).should(
    'exist'
  );
});

在这里,我们点击了其中一个职位的查看链接,并导航到职位详情页面,以验证所选的职位数据是否显示在页面上。

现在,让我们测试职位创建过程:

it('should create a new job', () => {
  cy.go('back');
  cy.findByRole('link', {
    name: /create job/i,
  }).click();
  const jobData = {
    position: 'Software Engineer',
    location: 'London',
    department: 'Engineering',
    info: 'Lorem Ipsum',
  };
  cy.findByRole('textbox', {
    name: /position/i,
  }).type(jobData.position);
  cy.findByRole('textbox', {
    name: /department/i,
  }).type(jobData.department);
  cy.findByRole('textbox', {
    name: /location/i,
  }).type(jobData.location);
  cy.findByRole('textbox', {
    name: /info/i,
  }).type(jobData.info);
  cy.findByRole('button', {
    name: /create/i,
  }).click();
  cy.findByText(/job created!/i).should('exist');
});

由于我们处于职位详情页面,我们需要导航回仪表板职位页面,在那里我们可以点击创建职位链接。这将带我们到创建职位页面。在这里,我们填写表格并提交。当提交成功时,应该会显示职位已创建的通知。

现在我们已经测试了仪表板的所有功能,我们可以从仪表板注销:

it('should log out from the dashboard', () => {
  cy.findByRole('button', {
    name: /log out/i,
  }).click();
  cy.wait(500);
  cy.url().should(
    'equal',
    'http://localhost:3000/auth/login'
  );
});

点击注销按钮将用户注销并重定向到登录页面。

公共流程

应用程序的公共流程对访问它的每个人都是可用的。

让我们从打开cypress/e2e/public.cy.ts文件并添加测试的骨架开始:

import { testData } from '../../src/testing/test-data';
const organization = testData.organizations[0];
const job = testData.jobs[0];
describe('public application flow', () => {
  it('should display the organization public page', () => {
  });
  it('should navigate to and display the public job details
    page', () => {
  });
});

现在,让我们开始实现测试。

首先,我们想要访问组织页面:

it('should display the organization public page', () => {
  cy.visit(
    `http://localhost:3000/organizations/${organization.id}`
  );
  cy.findByRole('heading', {
    name: organization.name,
  }).should('exist');
  cy.findByRole('heading', {
    name: organization.email,
  }).should('exist');
  cy.findByRole('heading', {
    name: organization.phone,
  }).should('exist');
  cy.findByText(
    new RegExp(organization.info, 'i')
  ).should('exist');
});

在这里,我们正在访问组织详情页面,并检查显示的数据是否与组织匹配。

现在我们处于组织详情页面,我们可以查看组织的职位:

it('should navigate to and display the public job details
  page', () => {
  cy.findByTestId('jobs-list').should('exist');
  cy.findByRole('row', {
    name: new RegExp(
      `${job.position} ${job.department} ${job.location}
        View`,
      'i'
    ),
  }).within(() => {
    cy.findByRole('link', {
      name: /view/i,
    }).click();
  });
  cy.url().should(
    'equal',
    `http://localhost:3000/organizations/$
      {organization.id}/jobs/${job.id}`
  );
  cy.findByRole('heading', {
    name: job.position,
  }).should('exist');
  cy.findByText(new RegExp(job.info, 'i')).should(
    'exist'
  );
});

在这里,我们点击了职位的查看链接,然后导航到职位详情页面,在这里我们断言职位数据。

要运行端到端测试,我们需要首先通过运行以下命令来构建应用程序:

npm run build

然后,我们可以通过打开浏览器来开始测试:

npm run e2e

或者,我们可以以无头模式运行测试,因为它对资源的需求较少,这对于 CI 来说非常好:

npm run e2e:headless

摘要

在本章中,我们学习了如何测试我们的应用程序,使其准备好投入生产。

我们首先通过为我们的通知存储实现单元测试来学习单元测试。

由于集成测试非常有价值,因为它们提供了更多的信心,表明某些东西正在正常工作,我们使用了这些测试来测试页面。

最后,我们为公共和仪表板流程创建了端到端测试,其中我们测试了每个流程的整个功能。

在下一章中,我们将学习如何准备和发布我们的应用程序到生产环境。我们将使用这些测试并将它们集成到我们的 CI/CD 管道中,如果任何测试失败,我们将不允许应用程序发布到生产环境。这将使我们的用户更加满意,因为出现错误最终进入生产环境的可能性更小。

第九章:配置测试和部署的 CI/CD

我们的应用程序终于准备就绪,可以投入生产并迎接第一批用户。我们已经构建了其功能并实现了所有必需的检查,例如代码检查、测试等,这将让我们有信心应用程序代码正在正确运行。

然而,目前,所有这些检查都必须在我们的本地机器上执行。每次我们想要将新功能推送到生产环境时,都需要运行所有脚本然后手动重新部署应用程序,这是一个非常繁琐的过程。

在本章中,我们将学习什么是 CI/CD。然后,我们将学习什么是 GitHub Actions 以及 GitHub Actions 流水线的主要部分。接着,我们将学习如何创建一个 CI/CD 流水线,该流水线将自动化应用程序的验证和部署到 Vercel。

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

  • 什么是 CI/CD?

  • 使用 GitHub Actions

  • 配置测试流水线

  • 配置部署到 Vercel 的流水线

到本章结束时,我们将知道如何使用 GitHub Actions 配置 CI/CD 流水线并将应用程序部署到 Vercel。

技术要求

在我们开始之前,我们需要设置我们的项目。为了能够开发我们的项目,我们将在计算机上需要以下内容安装:

  • Node.js 版本 16 或更高,以及 npm 版本 8 或更高。

  • 安装 Node.js 和 npm 有多种方式。这里有一篇很好的文章详细介绍了更多细节:www.nodejsdesignpatterns.com/blog/5-ways-to-install-node-js

  • VSCode(可选)是目前最流行的 JavaScript/TypeScript 编辑器/IDE,因此我们将使用它。它是开源的,与 TypeScript 有很好的集成,并且我们可以通过扩展来扩展其功能。可以从code.visualstudio.com/下载。

本章的代码文件可以在以下位置找到:github.com/PacktPublishing/React-Application-Architecture-for-Production

可以使用以下命令在本地克隆存储库:

git clone https://github.com/PacktPublishing/React-Application-Architecture-for-Production.git

一旦克隆了存储库,我们需要安装应用程序的依赖项:

npm install

我们可以使用以下命令提供环境变量:

cp .env.example .env

一旦安装了依赖项,我们需要选择与本章匹配的正确代码库阶段。我们可以通过执行以下命令来完成:

npm run stage:switch

此命令将提示我们每个章节的阶段列表:

? What stage do you want to switch to? (Use arrow
 keys)
❯ chapter-02
  chapter-03
  chapter-03-start
  chapter-04
  chapter-04-start
  chapter-05
  chapter-05-start
(Move up and down to reveal more choices)

这是第九章,所以如果我们想跟随,可以选择chapter-09-start,或者选择chapter-09来查看本章的最终结果。

一旦选择了章节,所有必要的文件都会显示出来以供跟随本章内容。

更多关于设置细节的信息,请查看README.md文件。

什么是 CI/CD?

持续集成/持续部署CI/CD)是一种以自动化方式向用户交付应用程序更改的方法。CI/CD 通常应包括以下部分:

  • 持续集成是自动验证代码是否已构建、测试并合并到存储库的过程

  • 持续交付意味着将更改交付到存储库

  • 持续部署意味着将更改发布到生产服务器,在那里更改对用户可用

现在,让我们考虑如何为我们的应用程序实现 CI/CD。我们已经有了所有部分——我们只需要将它们组合在一起。这个过程将像这样工作:

  • 运行应用程序的所有代码检查(单元和集成测试、代码风格检查、类型检查、格式检查等)

  • 构建应用程序并运行端到端测试

  • 如果两个过程都成功完成,我们可以部署我们的应用程序

下面是如何可视化这个过程:

图 9.1 – 管道概述

图 9.1 – 管道概述

此过程将确保我们的应用程序始终处于最佳状态,并且更改可以频繁且容易地发布到生产环境中。这对于在大型团队中工作特别有用,因为每天都会向应用程序引入许多更改。

要运行 CI/CD 管道,我们需要适当的基础设施。由于我们将存储库保存在 GitHub 上,我们可以使用 GitHub Actions 来处理 CI/CD。

使用 GitHub Actions

GitHub Actions是一个 CI/CD 工具,允许我们自动化、构建、测试和部署管道。我们可以在存储库中的特定事件上创建运行工作流程。

要了解它是如何工作的,让我们在以下部分中查看其一些组件。

工作流程

一个.github/workflows文件夹。当指定的事件被触发时,可以运行工作流程。我们还可以直接从 GitHub 手动重新运行工作流程。一个存储库可以有我们想要的任何数量的工作流程。

事件

当一个事件被触发时,将导致工作流程运行。GitHub 活动可以触发事件,例如向存储库推送或创建拉取请求。除此之外,它们还可以按计划或通过 HTTP POST 请求启动。

作业

一个作业定义了一系列将在工作流程中执行的步骤。一个步骤可以是执行的动作或脚本。

一个工作流程可以有多个可以并行运行的作业,或者它们可以在开始之前等待依赖作业完成。

动作

动作是在 GitHub Actions 上运行以执行重复性任务的应用程序。我们可以使用在github.com/marketplace?type=actions上可用的已构建动作,或者我们可以创建自己的。我们将在我们的管道中使用几个预制的动作。

运行器

运行器是一个在触发时运行工作流程的服务器。它可以在 GitHub 上托管,也可以自行托管。

现在我们已经熟悉了 GitHub Actions 的基础知识,我们可以开始创建我们应用程序的工作流程。

让我们创建 .github/workflows/main.yml 文件和初始代码:

name: CI/CD
on:
  - push
jobs:
# add jobs here

在前面的代码中,我们提供了工作流程的名称。如果我们省略它,名称将被分配给工作流程文件的名称。在这里,我们定义了 push 事件,这将导致代码更改推送到仓库时工作流程运行。

我们将在以下部分定义作业。

对于我们定义的每个作业,我们将提供以下内容:

name: Name of the job
runs-on: ubuntu-latest

这些属性将适用于所有作业:

  • name 设置正在运行的作业名称

  • runs-on 设置运行器,它将运行作业

现在我们已经了解了 GitHub Actions 是什么以及管道的主要部分,我们可以开始为我们的应用程序构建管道。

配置测试管道

我们的测试管道将包括两个作业,它们应该执行以下操作:

  • 运行所有代码检查,如代码风格检查、类型检查、单元测试和集成测试等

  • 构建应用程序并运行端到端测试

代码检查作业

代码检查作业应该像以下图示中显示的那样工作:

图 9.2 – 代码检查作业概述

图 9.2 – 代码检查作业概述

如我们所见,作业应该是直接的:

  1. 首先,我们需要向应用程序提供环境变量。

  2. 然后,我们需要安装依赖项。

  3. 接下来,我们必须运行单元测试和集成测试。

  4. 然后,我们必须运行代码风格检查。

  5. 然后,我们必须检查代码格式。

  6. 最后,我们必须运行类型检查。

jobs 中,让我们添加运行这些任务的作业:

jobs:
  code-checks:
    name: Code Checks
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 16
      - run: mv .env.example .env
      - run: npm install
      - run: npm run test
      - run: npm run lint
      - run: npm run format:check
      - run: npm run types:check

关于作业,有几件事情值得提及:

  • 我们使用市场中的 actions/checkout@v3 动作允许作业访问仓库

  • 我们使用 actions/setup-node 动作来配置要运行哪个节点版本

  • 我们执行脚本以验证一切是否按预期工作

端到端测试作业

我们与测试相关的第二个作业是端到端作业,我们希望在上一章中定义的应用程序构建和端到端测试。

它应该像以下图示中显示的那样工作:

图 9.3 – E2E 测试作业

图 9.3 – E2E 测试作业

如我们所见,作业将按以下方式工作:

  1. 首先,我们需要添加环境变量。

  2. 然后,需要安装应用程序的依赖项。

  3. 然后,我们需要创建应用程序的生产构建版本。

  4. 最后,生产代码得到端到端测试。

为了实现这个作业,让我们添加以下代码:

jobs:
  # previous jobs
  e2e:
    name: E2E Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: mv .env.example .env
      - uses: cypress-io/github-action@v4
        with:
          build: npm run build
          start: npm run start

关于作业,有几件事情值得提及:

  • 我们使用 actions/checkout@v3 动作来检出仓库。

  • 我们使用 cypress-io/github-action@v4 动作,它将抽象化端到端测试。它将安装所有依赖项,构建应用程序,然后启动并运行所有 Cypress 测试。

现在我们已经配置了运行代码检查(如代码检查、格式化、类型检查和测试)的管道,我们可以开始部署应用程序的工作。

配置部署到 Vercel 的管道

当我们的测试作业完成后,我们希望将应用程序部署到 Vercel。要从 GitHub Actions 开始部署到 Vercel,我们需要做一些事情:

  • 拥有 Vercel 账户

  • 禁用 Vercel 的 GitHub 集成

  • 将项目链接到 Vercel

  • 向 GitHub Actions 提供环境变量

  • 创建部署应用程序的作业

拥有 Vercel 账户

Vercel 很容易开始使用。访问 vercel.com/signup 并创建账户,如果您还没有的话。

禁用 Vercel 的 GitHub 集成

Vercel 是一个与 GitHub 集成出色的平台。这意味着每次我们向存储库推送更改时,应用程序的新版本将自动部署到 Vercel。然而,在我们的情况下,我们希望在部署步骤之前验证我们的应用程序是否按预期工作,以便我们可以从 CI/CD 管道执行此任务。

要做到这一点,我们需要在 Vercel 中禁用 GitHub 集成。这可以通过创建包含以下内容的vercel.json文件来完成:

{
  "version": 2,
  "github": {
    "enabled": false
  }
}

将项目链接到 Vercel

由于我们已禁用 GitHub 集成,我们需要在 Vercel 中将项目链接到我们的存储库。这可以通过使用 Vercel CLI 来完成。

让我们执行以下命令:

npx vercel

CLI 将会询问我们一系列问题,如下所示:

? Set up and deploy "~/web/project-name"? [Y/n] y
? Which scope do you want to deploy to? org-name
? Link to existing project? [y/N] n
? What's your project's name? project-name
? In which directory is your code located? ./

一旦 CLI 进程完成,.vercel 文件夹将被生成。这是一个不应该由存储库跟踪的文件夹。在 .vercel/project.json 文件中,我们将找到我们的项目凭据,如下所示:

{"orgId":"example_org_id","projectId":"example_project_id"}

我们将在几分钟后需要将这些值提供给 GitHub Actions。

向 GitHub Actions 提供环境变量

对于我们的管道,我们需要几个环境变量:

  • VERCEL_ORG_ID,我们可以从.vercel/project.json文件中获取

  • VERCEL_PROJECT_ID,我们也可以从.vercel/project.json文件中获取

  • VERCEL_TOKEN,我们可以从vercel.com/account/tokens 获取

一旦我们有了这些值,我们就可以将它们添加到我们项目的 GitHub Actions 中:

图 9.4 – 向 GitHub Actions 添加环境变量

图 9.4 – 向 GitHub Actions 添加环境变量

创建部署应用程序的作业

现在一切都已经设置好了,我们可以开始工作,这个工作将完成所有的工作。我们可以在以下图中看到它应该如何工作:

图 9.5 – 部署作业概述

图 9.5 – 部署作业概述

如我们所见,它将经过几个步骤:

  1. 检查存储库所有者,因为我们不希望从存储库分叉触发工作流程时进行部署。

  2. 将部署状态设置为 开始

  3. 部署到 Vercel。

  4. 将部署状态设置为完成

让我们在定义了其他作业的工作流程文件中添加deploy作业:

jobs:
  # previous jobs
  deploy:
    name: Deploy To Vercel
    runs-on: ubuntu-latest
    needs: [code-checks, e2e]
    if: github.repository_owner == 'my-username'
    permissions:
      contents: read
      deployments: write
    steps:
      - name: start deployment
        uses: bobheadxi/deployments@v1
        id: deployment
        with:
          step: start
          token: ${{ secrets.GITHUB_TOKEN }}
          env: ${{ fromJSON('["Production", "Preview"]')
            [github.ref != 'refs/heads/master'] }}
      - uses: actions/checkout@v3
      - run: mv .env.example .env
      - uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-args: ${{ fromJSON('["--prod", ""]')
            [github.ref != 'refs/heads/master'] }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID}}
          vercel-project-id: ${{ secrets.
            VERCEL_PROJECT_ID}}
          scope: ${{ secrets.VERCEL_ORG_ID}}
          working-directory: ./
      - name: update deployment status
        uses: bobheadxi/deployments@v1
        if: always()
        with:
          step: finish
          token: ${{ secrets.GITHUB_TOKEN }}
          status: ${{ job.status }}
          env: ${{ steps.deployment.outputs.env }}
          deployment_id: ${{ steps.deployment.outputs.
            deployment_id }}

关于作业有几件事情值得提及:

  • 我们通过添加needs: [code-checks, e2e]将此作业设置为依赖于前两个作业。这意味着此作业将在那些作业成功完成后才开始。如果其中一些作业失败,此作业将永远不会运行。

  • 使用if: github.repository_owner == 'my-username',我们检查仓库所有者是否是项目的所有者。这个检查应该可以防止仓库分叉部署应用程序。

  • 在部署任务前后,我们使用bobheadxi/deployments@v1动作来更新 GitHub 中的部署状态。

  • 我们使用amondnet/vercel-action@v25动作部署到 Vercel。根据哪个分支被更新,它将被部署到预览环境或生产环境。

我们的工作流程应该看起来像这样:

图 9.6 – 工作流程

图 9.6 – 工作流程

我们可以在仓库页面右下角跟踪每个环境的部署状态:

图 9.7 – 部署状态

图 9.7 – 部署状态

太棒了!我们的应用程序现在已投入生产并可供用户使用。配置管道可能需要初始时更多的努力,但从长远来看,它可以节省大量时间,因为我们不必担心所有这些步骤。它们都已经自动化了。

摘要

在本章中,我们了解到 CI/CD 管道是一个允许自动化代码更改和交付的过程。我们还介绍了 GitHub Actions 以及允许我们创建 CI/CD 管道以自动化测试和部署我们应用程序的各个部分。

之后,我们为工作流程定义了三个作业。通过这些作业,我们自动化了运行所有必需的检查、测试和部署的过程。最后,我们学习了如何从 CI/CD 管道部署到 Vercel 并将应用程序交付给用户。

这标志着我们应用程序 MVP 版本的完成。在下一章中,我们将介绍一些我们可以对应用程序进行的潜在功能和改进。

第十章:超越

我们的应用程序最终投入生产。可能在我们说话的时候,它已经有了用户。然而,就像每一件软件一样,我们的应用程序可能永远不会完全完成。总有改进的空间,而且由于我们构建的应用程序只是一个 MVP,有很多潜在的改进值得提及。

在本章中,我们将涵盖从功能和技术角度的一些最重要的改进。这些主题可能会给我们一些关于扩展和改进现有应用程序的想法。

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

  • 功能改进

  • 技术改进

  • 附录

到本章结束时,我们将介绍一些可以添加到现有应用程序中的功能,使其更加完整。我们还将提及一些本书未涉及但值得自己探索的主题。

功能改进

由于我们的应用程序目前处于 MVP 阶段,从用户的角度来看,有许多潜在的改进可以使应用程序更加易用。

工作功能改进

工作功能是本应用最重要的功能。我们可以实施一些改进来使应用程序变得更好:

  • 更新工作

  • 以草稿状态添加工作

  • 删除工作

  • 使用 markdown/ WYSIWYG 编辑器添加/更新工作信息

更新工作

目前,我们的应用程序仅支持工作创建。当我们想要更改给定职位发布的信息时会发生什么?如果在创建后能够编辑工作数据,那将非常棒。

这里是我们如何做到这一点的说明:

  • PATCH /jobs/:jobId 创建 更新 端点处理程序,该处理程序将更新数据库中的数据

  • /dashboard/jobs/:jobId/update 创建 更新工作 页面,这是更新表单应该放置的地方

  • 创建 更新 表单,包含我们想要能够更新工作所需的所有字段

  • 在成功提交后,我们应该使工作查询无效,以便重新获取其数据

以草稿状态添加工作

目前,当我们为我们的组织创建一个工作,它将立即对公众可用。然而,如果我们能扩展其功能,以便我们可以选择何时将职位发布给公众,那将非常棒。

这可以通过以下方式完成:

  • 使用 status 属性扩展工作模型。

  • status 值设置为 draftpublished

  • 在提交工作创建表单时,新创建的工作将最初具有 draft 状态。

  • 然后,我们可以通过 更新 表单更新工作的状态,在那里我们发送期望的状态作为值。另一种我们可以做到的方法是公开一个单独的端点,该端点只会更新工作的状态。

删除工作

大多数时候,职位空缺会被关闭。在这种情况下,没有人想要一个不再相关的职位发布,因此允许组织管理员删除不再相关的职位可能是个好主意。

这可以通过两种方式实现:

  • 有一个删除端点,将处理从数据库中删除职位。点击按钮会发送请求,在请求成功的情况下,将用户重定向到职位列表。

  • 扩展status属性,现在它可能具有额外的archiveddeleted值。这种方法被称为软删除,因为我们并没有从数据库中删除条目,但从应用程序的角度来看,它看起来就像被删除了。存档职位发布可能有助于跟踪以前招聘的不同统计数据。

使用 Markdown/WYSIWYG 编辑器添加/更新职位信息

目前,职位信息是通过textarea输入字段填充的,这对于纯文本值来说很方便。然而,管理员添加尽可能多的信息的能力仅限于文本。

如果我们能够允许管理员添加诸如不同的标题、列表、链接等内容到职位信息中,那么职位发布将提供尽可能多的信息。

解决方案是将textarea输入字段替换为富文本编辑器,这将使我们能够添加不仅仅是文本的内容。只需确保在提交之前对输入进行清理,以使申请尽可能安全。

组织改进

目前,组织管理员无法更新组织信息。组织应该能够随时更改任何信息。

要实现这一点,我们可以做以下事情:

  • PATCH /organizations/:organizationId创建更新组织的端点

  • /dashboard/organization/update创建一个页面,我们可以在这里填写更新表单

添加职位申请

我们还可以改进的一点是添加职位申请的能力。

目前,没有直接在应用程序中申请职位的机制。当用户点击申请按钮时,电子邮件客户端会打开,并设置正确的主题。然后,用户会向组织的电子邮件地址发送电子邮件,这就是整个流程。

要将其提升到下一个层次,我们可以创建另一个名为Application的实体,当用户申请工作时将提交此实体。这种方法将允许管理员跟踪其组织的职位申请。

让我们重新思考一下,使用这个新功能,应用程序的数据模型将看起来是什么样子:

图 10.1 – 数据模型中的应用

图 10.1 – 数据模型中的应用

如我们所见,申请应包含有关候选人的基本信息、一条消息、面试官的报告等等。

一旦数据模型被更新,我们可以构建应用程序功能,这将处理所有相关事务。这包括以下内容:

  • 创建和浏览应用的端点。

  • 仪表板上的页面,管理员可以浏览所有应用。它们可以定义为 /dashboard/applications/dashboard/applications/:applicationId,分别对应列表和详情页面。

过滤和分页数据列表

在表格中显示数据列表是好的,但当条目数量开始显著增长时会发生什么?一次性加载所有条目并不是很优化,因为一开始可能并不需要所有条目。

为了优化数据列表,我们可以添加对过滤和分页数据的支持。这将帮助用户缩小搜索结果,以满足他们的需求。过滤和分页都应该在服务器上发生。

应该通过 URL 参数处理当前筛选和分页值。这将使应用程序能够轻松地深度链接搜索结果以供进一步使用。

添加用户注册

这一点相当直接。到目前为止,我们一直依赖于测试数据,其中有一个测试用户,我们用它来登录仪表板。然而,没有方法可以注册新用户。如果我们想使这个应用程序被多个组织使用,我们应该添加这个功能。这可以通过以下方式实现:

  • 在 POST /auth/register 上创建注册端点,它将从表单中获取所需数据并创建用户及其对应组织在数据库中的记录

  • /auth/register 创建注册页面,其中包含注册表单,提交后调用注册端点

技术改进

我们的应用状态良好,但在应用开始增长时,有几件事情应该牢记在心。让我们看看。

服务器端渲染和缓存

我们可以进一步优化如何在服务器上渲染公共页面,我们可以做出以下改进。

目前,我们正在每个请求上渲染页面,如果数据频繁更改,这是好的;否则,它可能会增加加载时间和服务器成本,因为服务器上的渲染是一个计算密集型操作。

幸运的是,Next.js 支持另一种名为 增量 静态重新生成 的渲染策略。

它的工作方式如下:

  1. 用户 1 请求一个页面。

  2. 服务器返回缓存的页面版本并将其返回。

  3. 在那次请求期间,Next.js 被触发以使用最新数据重新生成相同的页面。

  4. 用户 2 请求一个页面。

  5. 服务器返回页面的新版本。

以我们的公共工作详情页面为例,它的工作方式如下。

首先,我们需要使用 getStaticPaths 来生成所有工作的所有路径:

export const getStaticPaths = async () => {
  const jobs = await getJobs();
  const paths = jobs.map((job) => ({
     params: { jobId: job.id }
  }));
  return { paths, fallback: true };
}

这将为数据库中存在的所有作业生成路径列表。这里的关键是fallback属性,它将使 Next.js 不返回 404 页面,而是尝试生成一个新的页面。

我们还必须将getServerSideProps替换为getStaticProps,其外观可能如下所示:

export const getStaticProps = async ({
  params,
}: GetStaticPropsContext) => {
  const jobId = params?.jobId as string;
  const job = await getJob({ jobId });
  return {
    props: {
      job
    },
    revalidate: 60,
  };
};

注意我们如何可以将revalidate属性添加到return值中。这将强制页面在 60 秒后重新验证。

由于作业和组织的数据变化不是很频繁,这种渲染策略从长远来看听起来更优,尤其是在请求数量开始增加之后。

这在性能和数据新鲜度之间提供了一个良好的折衷方案。

React Query 的 SSR 解冻

目前,我们正在使用 React Query 来处理客户端的数据获取,但服务器端的数据获取则没有使用它。我们只是在页面上获取数据并传递和渲染它。如果我们没有很多层级的组件,这没问题,但还有更好的方法来做这件事。

React Query 支持两种在服务器上获取数据并将其传递到客户端的方式:

  • 在服务器上获取数据,然后将其作为initialData传递给查询

  • 在服务器上预取,解冻缓存,并在客户端重新解冻

第一种方案适用于较小型的应用,其中组件之间没有非常复杂的层次结构,因此没有必要将服务器数据向下传递多个层级到所需的查询。

第二种方案可能需要更多的初始设置,但最终会使代码库变得更加简单。

pages/_app.tsx文件中,我们应该将QueryClientProvider内部的任何内容都包裹在Hydrate中,如下所示:

import { Hydrate, QueryClient, QueryClientProvider }
  from '@tanstack/react-query'
export const App = ({ Component, pageProps }) => {
  const [queryClient] = React.useState(() => new
    QueryClient())
  return (
    <QueryClientProvider client={queryClient}>
      <Hydrate state={pageProps.dehydratedState}>
        <Component {...pageProps} />
      </Hydrate>
    </QueryClientProvider>
  )
}

这将使应用程序准备好处理任何解冻状态。但我们如何向页面提供解冻状态呢?

在特定页面上,我们可以修改getStaticPropsgetServerSideProps,如下所示:

export const getServerSideProps = async () => {
     const queryClient = new QueryClient()
  await queryClient.prefetchQuery(['jobs'], getJobs)
  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  }
}

然后,我们可以像在客户端获取它们一样消费作业:

const JobsPage = () => {
     const jobs = useJobs();
     // ...
}

这将使使用 React Query 处理所有服务器状态变得更加容易。

使用查询键工厂

当查询的数量开始增加时,整个应用中遍布许多查询可能会变得难以管理。跟踪所有查询的变体及其使用位置可能很困难。防止重复查询键可能也是一个问题。

正因如此,我们应该考虑使用查询键工厂而不是故意在各个地方添加查询键。

我们可以在src/lib/react-query.ts中定义所有潜在键:

首先,我们可以定义工厂的简化版本:

const getQueryKeys = (baseKey: string) => {
  return {
    all: [baseKey],
    many: (params: Record<string, unknown>) => [baseKey,
      params],
    one: (id: string) => [baseKey, id],
  };
};

然后,我们可以为查询创建键:

export const queryKeys = {
  auth: {
    authUser: ['auth-user'],
  },
  jobs: getQueryKeys('jobs'),
  organizations: {
    one: getQueryKeys('organizations').one,
  },
};

如您所见,并非所有功能都具有相同的键结构,但我们可以结合不同的工厂来创建所需的内容。

然后,如果我们想在查询中使用一个键,可以这样做:

const useJobs = () => {
     const { data, isLoading } = useQuery({
    queryKey: queryKeys.jobs.many(params),
    queryFn: () => getJobs({ params }),
    enabled: !!params.organizationId,
    initialData: [],
  });
  //...
}

这种方法的优点是我们对所有密钥有一个集中的概览,这减少了因误输入密钥或类似情况而犯错的概率。

这是一个简化查询密钥工厂的例子。如果您需要一个更健壮的解决方案,有一个非常好的库可以在www.npmjs.com/package/@lukemorales/query-key-factory找到。

代码脚手架

当查看我们的应用程序时,我们可能会注意到存在一定程度的样板代码。例如,创建组件需要这样一个文件夹:

- my-component
     - index.ts
     - my-component.tsx

我们必须记住从index.ts重新导出组件,使其可用。

对于 API 请求也可以这么说。我们需要创建请求函数,然后是消费它的钩子。这些事情可以通过帮助我们通过 CLI 更容易生成这些类型文件的工具来自动化。

拥有一些脚手架工具,如 Plop.js 和 Hygen.io,也给代码库带来了更好的一致性。

使用 Zod 验证表单输入和 API 响应

让我们简要地谈谈验证。通过验证,我们想确保数据处于预期的形式。对于我们的应用程序,我们可以验证表单输入和 API 响应。

对于验证,我们可以使用 Zod,这是一个以 TypeScript 为先的出色验证库。这意味着我们可以定义一个模式,从中我们可以推断出我们可以使用的类型。

表单输入验证

react-hooks-form库为 Zod 提供了很好的支持,我们可以利用它来做这件事。以当前的登录表单为例,我们可以修改它使其看起来像这样:

import { z } from 'zod';
import { yupResolver } from '@hookform/resolvers/yup';
const schema = z.object({
  email: z.string().min(1, 'Required'),
  password: z.string().min(1, 'Required'),
});
const LoginForm  = () => {
     const { register, handleSubmit } = useForm({
          resolver: yupResolver(schema);
     })
     // ...
     return (
          <Stack
      as="form"
      onSubmit={handleSubmit(onSubmit)}
      spacing="5"
      w="full"
    >
      <InputField
        label="Email"
        type="email"
        {...register('email')}
        error={formState.errors['email']}
      />
      <InputField
        label="Password"
        type="password"
        {...register('password')}
        error={formState.errors['password']}
      />
      <Button
        isLoading={login.isLoading}
        isDisabled={login.isLoading}
        type="submit"
      >
        Log in
      </Button>
    </Stack>
     )
}

这里,我们正在创建一个对象模式,并借助yupResolver将其提供给useForm

这将确保表单只有在所有字段都有有效值的情况下才会提交。

API 请求验证

我们确实有 TypeScript 类型,但它们不能保护我们免受运行时错误的影响。这就是为什么在某些情况下我们应该考虑验证 API 响应。让我们看看以下例子:

import { z } from 'zod';
const JobSchema = z.object({
     position: z.string(),
     info: z.string(),
     location: z.string()
});

由于 Zod 是一个以 TypeScript 为先的库,我们可以用它来推断给定对象的形状类型:

type Job = z.infer<typeof JobSchema>

这可能有助于减少重复的类型定义。最后,我们可以按照以下方式验证我们的请求:

const getJob = async () => {
     const jobResponse = await apiClient.get('/jobs/123');
     const job = JobSchema.parse(jobResponse);
     return job;
}

如果任何作业属性与模式不匹配,Zod 将抛出一个运行时错误,然后我们可以妥善处理。

Next.js 13

Next.js 13 即将到来!它最近发布,带来了一些重大变化,包括以下内容:

  • 带有应用文件夹的新路由系统

  • 服务器组件

  • 新的数据获取方法

值得注意的是,它与旧版本向后兼容,因此它允许增量升级。可能需要一些时间来完善所有内容,但值得关注,并在某个时候升级到新方法。

附录

有几个主题与我们所构建的应用程序没有直接关系,但它们值得提及。

GraphQL

在当今,拥有 GraphQL API 是非常普遍的,尤其是在微服务架构中。我们在我们的应用程序中使用了 REST API,但如果它是 GraphQL API,我们将如何构建我们的 API 层?

好吧,实现将非常相似。我们可以选择使用不同的库,例如 Apollo,但我们将坚持使用 React Query。

看以下请求:

import { request, gql } from "graphql-request";
import { useQuery } from '@tanstack/react-query';
const jobsQuery = gql`
     query {
          jobs {
               data {
                    position
                    department
                    location
               }
          }
     }
`;
const getJobs = () => {
     return request('/api/graphql', jobsQuery);
};
const useJobs = () => {
     const { data, isLoading } = useQuery({
          queryKey: ['jobs'],
          queryFn: getJobs
     })
     // ...
};

如您所见,首先,我们定义了 GraphQL 查询,然后我们使用它来定义请求函数。最后,我们使用请求函数来创建useJobs钩子。

单仓库

单仓库是一个包含多个项目且这些项目之间有明确关系的 Git 仓库。这意味着一个好的单仓库设置应该提供以下功能:

  • 项目间易于代码共享

  • 项目约束和可见性

  • 计算缓存

  • 项目清晰的边界

值得探索单仓库,因为它们被用于一些最大的软件项目中,并使这些大型项目更容易管理。

一些最受欢迎的单仓库工具有以下:

  • Lerna

  • Nx

  • Turborepo

  • Yarn 工作空间

微前端架构

微前端架构是一个非常有趣的概念。这意味着我们可以将应用程序的组件作为独立的应用程序构建和部署,它们看起来和感觉就像它们是同一应用程序的一部分。

使用这种架构的一些好处如下:

  • 当在一个拥有许多不同团队的平台工作时很有用。

  • 不限制应用程序使用特定的技术。每个微前端应用程序都可以有不同的堆栈,并且它们可以真正地很好地协同工作。

然而,也有一些缺点:

  • 尽管使用不同的技术构建微前端架构是可能的,但应该予以劝阻。最好选择一个框架,并制定应用程序构建的标准。

  • 微前端架构需要更复杂的工具,对于大多数用例来说可能并不值得。

一些值得探索的工具如下:

  • 模块联邦

  • 单 SPA

摘要

在这一章中,我们探讨了在完成这本书之后值得探索的其他主题。比如功能改进和技术改进可以将您的应用程序提升到下一个层次。希望您能将在这里学到的知识应用到类似的真实世界场景中。

posted @ 2025-09-07 09:21  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报