React18-设计模式和最佳实践-全-
React18 设计模式和最佳实践(全)
原文:
zh.annas-archive.org/md5/513d5bc62ca582f3c794eb80a8a48f5b译者:飞龙
前言
React是一个革命性的开源 JavaScript 库,通过构建由称为组件的小型、隔离的块组成的复杂用户界面,为 Web 应用程序注入活力。本书作为一份路线图,引导您领略 React 的奇妙之处,通过引入一个高效的工作流程来提高您的生产力,同时不牺牲质量。
我们的旅程从深入 React 的核心开始,全面了解其内部机制和架构。在这个坚实的基础之上,我们将引导您编写干净且易于维护的代码,将复杂的概念分解成易于消化和管理的小块。
在我们继续旅程的过程中,我们将揭示构建组件的艺术,这些组件不仅仅是单一实体,而是可以在整个应用程序中重复使用的部件。我们将阐明构建应用程序结构的方法,使它们更加有序和易于管理。随着我们为您提供有效实现这一目标的策略和技术,创建功能表单的看似艰巨的任务将变得轻而易举。
随着我们进一步攀登,我们将沉浸在 React 组件的样式化中。您将学习如何通过美学吸引力使您的应用程序生动起来,同时确保它们保持快速和响应。此外,您还将发现提高应用程序性能的秘密,对您的组件进行微调以实现速度和效率。
在我们旅程的最后阶段,我们将深入研究有效的测试方法,提高您应用程序的质量和可靠性。您还将获得对贡献 React 及其繁荣生态系统的洞察,加入那些不断推动其发展的开发者行列。
到本书结束时,试错过程、开发障碍和猜测将成为过去。您将掌握 React 的力量,拥有构建和部署真实世界 React Web 应用程序所需的知识和技能,自信且优雅。
这本书面向谁
这本书是为那些希望提高他们对 React 的理解并将其应用于实际应用程序开发的网络开发者而写的。假设您具有中级 React 和 JavaScript 经验。
本书涵盖的内容
在第一章,React 的初学者之旅中,我们通过学习编写声明式代码和区分我们的组件与 React 的元素来开始理解 React 的旅程。我们还讨论了为什么我们在 React 中将逻辑和模板结合起来,这个决定最初颇具争议,但最终是有益的。在 JavaScript 快速发展的世界中,我们建议采取小而可控的步骤以避免疲劳。我们通过介绍新的create-vite工具来结束,为您在 React 中进行动手编码体验做好准备。
在第二章,介绍 TypeScript中,我们将学习 TypeScript 的基础知识,包括创建简单的类型、接口、使用枚举、命名空间和模板字符串。我们还将了解如何设置我们的第一个 TypeScript 配置文件(tsconfig.json),并将其分为两部分 - 一个公共部分和一个特定部分,这在与 MonoRepos 一起工作时特别有用。在本章之后,你将准备好深入使用 JSX/TSX 代码,并在下一章探索使你的代码更好的方法。准备好使用 TypeScript 使你的 React 应用易于使用和维护。
在第三章,清理你的代码中,我们将了解 JSX,包括它的编写方式和它能做什么。我们还将设置 Prettier 和 ESLint 以保持我们的代码整洁并防止错误。此外,我们将学习函数式编程,它使我们的代码更容易管理和测试。在整理好我们的代码后,我们将准备好更深入地学习 React,并在下一章学习如何制作我们可以重复使用的组件。通过养成良好的习惯,我们可以构建简单易管理、可扩展和可检查的 React 应用。
在第四章,探索流行的组合模式中,我们将学习如何使用“props”使我们的可重用组件更好地协同工作。使用 props 有助于保持我们的组件独立和定义明确。我们将探讨两种常见的组件组织方式:容器模式和展示模式,它们将组件的规则和外观分开。我们还将了解高阶组件(HOCs)用于处理上下文,而不会使我们的组件过于依赖,以及函数作为子组件的模式,用于动态创建组件。
在第五章,为浏览器编写代码中,我们将了解 React 如何在 Web 浏览器中用于创建表单、处理事件和动画 SVG。我们将学习useRef钩子,它是一种轻松访问 DOM 节点的方法。
使用 React 简单直接的方法,管理复杂的 Web 应用变得更容易。此外,如果需要,React 允许我们直接访问 DOM 节点,这使得我们可以轻松地将 React 与其他库一起使用。
在第六章,让你的组件看起来很漂亮中,我们将深入了解 React 中的样式。我们首先将探讨 CSS 在大项目中工作的问题,以 Meta 的经验为例。我们将学习如何在我们的 React 组件内部直接编写样式,这使我们的代码整洁且易于阅读。但我们也将了解这种方法的优势,并探索另一种样式方法,称为 CSS 模块,它允许我们在单独的文件中编写 CSS,但将样式限制在单个组件中。最后,我们将了解styled-components,这是一个流行的 React 样式库。到本章结束时,你将拥有许多使你的 React 应用看起来很棒的工具。
在第七章,应避免的反模式中,我们将讨论四种使用组件的方法,这些方法可能会减慢或破坏我们的 Web 应用。对于每个问题,我们将使用示例来展示出了什么问题以及如何修复它。我们将了解为什么使用属性设置状态可能会在状态和属性之间引起问题。我们还将看到如何使用错误的“key”属性破坏 React 更新组件的方式。最后,我们将了解为什么将非标准属性扩展到 DOM 元素是一个坏主意。理解这些问题将帮助我们更有效地使用 React 并避免常见错误。
在第八章,React Hooks中,我们将愉快地学习关于新 React Hooks 的知识。我们将了解它们是如何工作的,如何使用它们来获取数据,以及如何将类组件转换为 Hooks 组件。我们还将了解副作用以及memo、useMemo和useCallback之间的区别。最后,我们将看到useReducer Hook 是如何工作的,以及它与react-redux的不同之处。所有这些都将帮助我们使我们的 React 组件更快、更好。
在第九章,React Router中,我们将学习关于 React Router 的知识,这是我们与 React 一起使用来在单页应用中切换页面的工具。React 本身不这样做,所以我们使用 React Router。我们将了解如何使用它来使我们的应用响应不同的 URL 并管理导航。到本章结束时,你将了解 React Router 是如何工作的,以及如何在你的项目中使用它。我们将学习react-router、react-router-dom和react-router-native包之间的区别,如何设置 React Router,如何添加<Routes>组件,以及如何向路由添加参数。
在第十章,React 18 新特性中,我们将探索新的和改进的 React 18。它拥有众多特性,使得构建酷炫、交互式的 APP 变得更加容易。
通过自动状态更新分组、并发渲染、用于获取数据的 Suspense、更好的错误处理和新的组件类型,你可以创建引人入胜且快速的 APP。如果你使用 React,考虑升级到 React 18 是个不错的选择。我们还将探讨 Node 18 和 19 的一些重大新特性,这些特性可以使我们的 Web 项目更加出色。
在第十一章,数据管理中,我们将学习关于 React Context API 以及如何使用 React Suspense 与 SWR 的知识。我们将了解 Context API 的基础知识,包括创建和使用上下文以及useContext钩子如何使这一切变得更加简单。我们还将探讨 React Suspense 以及它是如何帮助我们更好地处理加载状态以提供更流畅的用户体验的。我们还将了解 SWR,它使得使用 React Suspense 获取和缓存数据变得更加容易。最后,我们将学习如何使用新的 Redux Toolkit。所有这些工具都将帮助我们构建更快、更用户友好的 React 应用。
在第十二章,服务器端渲染,我们将通过 React 完成对服务器端渲染(SSR)的旅程。现在你将知道如何创建一个使用 SSR 的应用程序,以及为什么它对搜索引擎优化(SEO)、社交分享和提升性能等方面很有用。我们将学习如何在服务器上加载数据并将其放入 HTML 模板中,以便在浏览器启动时客户端应用程序准备好。最后,我们将看到像 Next.js 这样的工具如何通过减少额外代码和隐藏一些复杂部分来简化在 React 中设置 SSR。
在第十三章,通过真实项目理解 GraphQL,我们将学习 GraphQL,这是一个帮助我们更高效地与 API 和数据工作的酷炫工具。与常规的 REST API 不同,GraphQL 允许我们请求我们需要的精确内容,而不需要更多。我们将用它来为真实项目制作一个简单的登录和用户注册系统。我们将学习如何安装 PostgreSQL,使用.env文件设置环境变量,设置 Apollo Server,执行 GraphQL 查询和突变,与解析器一起工作,创建 Sequelize 模型,使用 JWT,在 GraphQL Playground 中玩耍,并进行身份验证。到那时,你将知道如何在你的项目中使用 GraphQL。
在第十四章,MonoRepo 架构,我们将讨论一个称为“MonoRepo”的概念。通常,当我们构建应用程序时,我们有一个应用程序,一个 git 仓库和一个构建输出。但许多组织使用单个仓库来存储所有应用程序、组件和库,以简化开发。这就是我们所说的单仓库。这就像把所有的代码放在一个大篮子里,而不是许多小篮子里。这使得保持一切更新变得更容易,并且可以节省时间。我们还将讨论 MonoRepo 如何使代码重构、提高团队合作和加快更新包依赖项的过程变得更容易,而无需每次更新时发布新版本。
在第十五章,提高应用程序的性能,我们将探讨使你的应用程序运行得更顺畅、更快的技巧,以提供更好的用户体验。我们将深入了解 React 如何更新应用程序的显示,以及如何使用键来提高此过程的效率。我们将发现结构良好、任务导向的组件在提升应用程序性能方面的重要性。我们将讨论不可变性的概念及其在帮助React.memo和shallowCompare有效工作中的重要性。接近尾声时,我们将介绍各种工具和库,这些工具和库可以进一步加快你的应用程序。本章旨在为你提供宝贵的知识,以增强你应用程序的速度和性能。
在第十六章,测试和调试中,我们将学习有关测试的所有内容。你会发现为什么测试很重要,并探索检查我们的 React 组件是否按预期工作的不同工具和技术。我们将使用 React Testing Library 和 Jest 等库来编写和运行测试,甚至了解如何测试应用中的复杂部分,如高阶组件或字段众多的表单。此外,我们还将学习如何使用 React DevTools 和 Redux DevTools 等工具来帮助我们开发更好的应用。到本章结束时,你将牢固掌握如何通过有效的测试保持应用的良好运行状态。
在第十七章,部署到生产环境中,我们将把您构建的 React 应用分享给全世界!我们将使用一个名为 DigitalOcean 的云服务来完成这项工作。您将学习如何使用 Node.js 和 nginx 在服务器上运行您的应用,我们将使用 DigitalOcean 的 Ubuntu 服务器来完成这项工作。我们将向您介绍如何设置 DigitalOcean Droplet、配置它并将其链接到您的域名。我们还将向您介绍 CircleCI,这是一个帮助您自动确保应用始终为用户准备就绪的工具,无论您做了多少更改。到本章结束时,您的应用将可以在互联网上供所有人查看!
要充分利用本书
要掌握 React,你需要具备 JavaScript 和 Node.js 的基础知识。本书主要面向网页开发者,在撰写本书时,对读者的以下假设:
-
读者知道如何安装 Node.js 的最新版本。
-
读者是一位中级开发者,能够理解 JavaScript ES6 语法。
-
读者对 CLI 工具和 Node.js 语法有一些经验。
下载示例代码文件
本书代码包托管在 GitHub 上,网址为 github.com/PacktPublishing/React-18-Design-Patterns-and-Best-Practices-Fourth-Edition/。
我们还从丰富的图书和视频目录中提供了其他代码包,可在 github.com/PacktPublishing/ 上找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/o1WtB。
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。例如:“在您创建此 util 之后,您需要在 packages/utils/src/index.ts 创建 index.ts 文件。”
代码块设置为以下格式:
{
"name": "api",
"version": "1.0.0",
"main": "index.js",
"author": "",
"license": "ISC"
}
任何命令行输入或输出都按以下方式编写:
cd packages/api
npm init -y
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。例如:“我们需要创建的第一个包,以便能够编译其他包,称为devtools。”
警告或重要说明看起来像这样。
小贴士和技巧看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈:请通过feedback@packtpub.com发送电子邮件,并在邮件主题中提及书籍标题。如果您对此书的任何方面有疑问,请通过questions@packtpub.com发送电子邮件给我们。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在此书中发现错误,我们将不胜感激,如果您能向我们报告,请访问www.packtpub.com/submit-errata,点击提交勘误,并填写表格。
盗版:如果您在互联网上发现我们作品的任何形式的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了React 18 设计模式和最佳实践,第四版,我们很乐意听到您的想法!请点击此处直接转到此书的 Amazon 评论页面并分享您的反馈。
您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
你喜欢在路上阅读,但无法携带你的印刷书籍到处走?你的电子书购买是否与你的选择设备不兼容?
不要担心,现在每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠远不止这些,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。
按照以下简单步骤获取好处:
- 扫描下面的二维码或访问以下链接

packt.link/free-ebook/978-1-80323-310-9
-
提交您的购买证明
-
就这些!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件。
第一章:React 入门第一步
亲爱的读者们!
本书假设您已经了解 React 是什么以及它能为您解决什么问题。您可能已经使用 React 编写了一个小型/中型应用程序,并希望提高您的技能并解答所有疑问。您应该知道 React 由 Meta 的开发者和 JavaScript 社区内的数百名贡献者维护。React 是创建 UIs 最受欢迎的库之一,它因其与文档对象模型(DOM)智能交互而闻名,速度快。它包含 JSX,这是一种在 JavaScript 中编写标记的新语法,这要求您改变对关注点分离的看法。它有许多酷炫的功能,例如服务器端渲染,这使您能够编写通用应用程序。
在本章中,我们将探讨一些基本概念,这些概念对于有效地使用 React 至关重要,但对于初学者来说也足够简单,可以自行理解:
-
命令式编程和声明式编程之间的区别
-
React 组件及其实例,以及 React 如何使用元素来控制 UI 流程
-
React 如何改变我们构建 Web 应用程序的方式,强制执行不同的关注点分离新概念,以及其不受欢迎的设计选择背后的原因
-
为什么人们会感到 JavaScript 疲劳,以及您如何避免在接近 React 生态系统时开发者最常犯的错误
技术要求
要跟随本书,您需要有一些使用终端运行 Unix 命令的经验。此外,您需要安装Node.js。您有两个选择:第一个是从官方网站直接下载 Node.js(nodejs.org),第二个选项(推荐)是从github.com/nvm-sh/nvm安装Node 版本管理器(NVM)。
如果您决定使用 NVM,您可以安装任何版本的 Node.js,并通过nvm install命令切换版本:
-
node是最新版本的别名:nvm install node -
您还可以安装 Node.js 的全局版本(
nvm将本地安装最新版本的 Node.js 到用户的计算机上):nvm install 19 nvm install 18 nvm install 17 nvm install 16 nvm install 15 -
或者,您可以安装一个非常具体的版本:
nvm install 12.14.3 -
安装了不同版本后,您可以通过使用
nvm use命令在它们之间切换:nvm use node # for latest version nvm use 16 # for the latest version of node 16.X.X nvm use 12.14.3 # Specific version -
最后,您可以通过运行以下命令指定默认的 Node.js 版本:
nvm alias default node nvm alias default 16 nvm alias default 12.14.3
简而言之,以下是完成本章所需的条件列表:
-
Node.js (19+):
nodejs.org -
VS Code:
code.visualstudio.com -
TypeScript:
www.npmjs.com/package/typescript
你可以在书的 GitHub 仓库中找到代码:github.com/PacktPublishing/React-18-Design-Patterns-and-Best-Practices-Fourth-Edition。
区分声明式和命令式编程
当阅读 React 文档或关于 React 的博客文章时,你无疑会遇到声明式这个术语。React 之所以如此强大,其中一个原因就是它强制执行声明式编程范式。
因此,要精通 React,理解声明式编程的含义以及命令式和声明式编程之间的主要区别是至关重要的。最简单的方法是将命令式编程视为描述事物如何工作的方式,将声明式编程视为描述你想要实现的方式。
在命令式世界中,进入酒吧喝啤酒是一个现实生活中的例子,你通常会向酒吧服务员下达以下指示:
-
找一个杯子并从架子上取下来。
-
把杯子放在水龙头下。
-
把手拉到杯满为止。
-
把杯子递给我。
在声明式世界中,你只需说,“请给我一杯啤酒,好吗?”
声明式方法假设酒吧服务员已经知道如何服务啤酒,这是声明式编程工作方式的一个重要方面。
让我们来看一个 JavaScript 的例子。在这里,我们将编写一个简单的函数,给定一个小写字符串数组,返回一个包含相同字符串的大写数组:
toUpperCase(['foo', 'bar']) // ['FOO', 'BAR']
解决这个问题的命令式函数可以这样实现:
const toUpperCase = input => {
const output = []
for (let i = 0; i < input.length; i++) {
output.push(input[i].toUpperCase())
}
return output
}
首先,创建一个空数组来存放结果。然后,函数遍历输入数组的所有元素,将大写值推入空数组。最后,返回输出数组。
声明式解决方案如下:
const toUpperCase = input => input.map(value => value.toUpperCase())
输入数组的项被传递给一个map函数,该函数返回一个包含大写值的新数组。有一些值得注意的显著差异:前一个例子不太优雅,需要更多的努力才能理解。后者更简洁,更容易阅读,这在大型代码库中,维护性至关重要。
另一个值得提到的方面是,在声明式示例中,不需要使用变量或在执行过程中更新它们的值。声明式编程倾向于避免创建和修改状态。
作为最后的例子,让我们看看 React 声明式意味着什么。我们将尝试解决的问题是在 Web 开发中常见的任务:创建一个切换按钮。
想象一个简单的 UI 组件,比如切换按钮。当你点击它时,如果它之前是灰色(关闭),它会变成绿色(开启);如果它之前是绿色(开启),它会变成灰色(关闭)。
做这件事的命令式方法如下:
const toggleButton = document.querySelector('#toggle')
toogleButton.addEventListener('click', () => {
if (toggleButton.classList.contains('on')) {
toggleButton.classList.remove('on')
toggleButton.classList.add('off')
} else {
toggleButton.classList.remove('off')
toggleButton.classList.add('on')
}
})
它是命令式的,因为需要所有这些指令来更改类。相比之下,使用 React 的声明式方法如下:
// To turn on the Toggle
<Toggle on />
// To turn off the toggle
<Toggle />
在声明式编程中,开发者只需描述他们想要实现的内容,无需列出所有使它工作的步骤。React 提供声明式方法使得它易于使用,因此生成的代码简单,这通常会导致更少的错误和更高的可维护性。
在下一节中,你将学习 React 元素的工作原理,并了解更多关于如何在 React 组件上传递 props 的上下文。
React 元素的工作原理
在这本书中,我们假设你已经熟悉组件及其实例,但如果你想要有效地使用 React,你应该了解另一个对象——元素。元素是轻量级的不可变描述,用于表示应该渲染的内容,而组件则是更复杂的具有状态的对象,负责生成元素。
每当你调用createClass、extend Component或声明一个无状态函数时,你都在创建一个组件。React 在运行时管理你组件的所有实例,在给定的时间点内存中可以存在同一组件的多个实例。
如前所述,React 遵循声明式范式,无需告诉它如何与 DOM 交互;你只需声明你希望在屏幕上看到的内容,React 就会为你完成工作。使这个过程更具表达性和可读性的一个工具是 JSX,它允许你直接在 JavaScript 代码中编写类似 HTML 的语法。JSX 不是必需的,但在 React 社区中广泛使用。
为了控制 UI 流程,React 使用一种称为元素的特殊类型的对象。这些元素是通过React.createElement()函数创建的,或者更常见的是,通过 JSX 语法。元素只包含严格需要表示界面的信息。
下面是一个使用 JSX 创建的元素的示例:
<Title color="red">
<h1>Hello, H1!</h1>
</Title>
此 JSX 代码被转换成如下 JavaScript 对象:
{
type: Title,
props: {
color: 'red',
children: {
type: 'h1',
props: {
children: 'Hello, H1!'
}
}
}
}
元素的类型至关重要,因为它告诉 React 如何处理它。如果类型是字符串,则元素表示一个 DOM 节点;如果是函数,则元素表示一个组件。
你可以嵌套 DOM 元素和组件来创建渲染树,表示应用程序用户界面的结构。通过以分层的方式组织你的元素和组件,你可以创建复杂和动态的 UI。
React 使用一种称为虚拟 DOM 的技术,它是实际 DOM 的内存表示。它比较当前树和新树,以最小化实际 DOM 更新的数量。这个过程称为协调,并由 React DOM 和 React Native 用于为其各自的平台创建 UI。
当一个元素的类型是函数时,React 会调用该函数,并将元素的属性传递给它以获取底层元素。它会递归地重复此过程,直到构建出可以在屏幕上渲染的 DOM 节点树。
总结来说,元素在 React 的声明式范式中的作用至关重要,它允许你创建复杂用户界面,而无需手动管理 DOM 元素的创建和销毁。
通过理解元素和组件如何协同工作,以及 React 如何使用虚拟 DOM 和协调高效地更新 UI,你将能够构建动态且高效的 Web 应用程序。
重新学习一切
当第一次使用 React 时,以开放的心态去接近它是至关重要的。这是因为 React 代表了一种新的设计 Web 和移动应用程序的方式,打破了许多传统的最佳实践。
在过去的二十年里,我们已经了解到关注点分离是至关重要的,这通常涉及到将逻辑与模板分离。我们的目标是把 JavaScript 和 HTML 写在不同的文件中,为此已经创建了各种模板解决方案来帮助开发者实现这一目标。
然而,这种方法的缺点是它往往造成了一种分离的错觉。实际上,JavaScript 和 HTML 无论在哪里都是紧密耦合的。为了说明这一点,让我们考虑一个示例模板:
{{#items}}
{{#first}}
<li><strong>{{name}}</strong></li>
{{/first}}
{{#link}}
<li><a href="{{url}}">{{name}}</a></li>
{{/link}}
{{/items}}
前面的代码片段来自 Mustache 网站,这是最受欢迎的模板系统之一。
第一行告诉 Mustache 遍历一个项目集合。在循环内部,有一些条件逻辑来检查 #first 和 #link 属性是否存在,并根据它们的值渲染不同的 HTML 片段。变量被括在花括号中。
如果你的应用程序只需要显示一些变量,模板库可能是一个不错的解决方案,但当涉及到开始处理复杂的数据结构时,情况就改变了。模板系统和它们的 领域特定语言 (DSL) 提供了一组功能,并试图提供与真实编程语言相同的功能,而不达到相同的完整性水平。正如示例所示,模板高度依赖于从逻辑层接收的模型来显示信息。
另一方面,JavaScript 与模板渲染的 DOM 元素交互,以更新 UI,即使它们是从不同的文件加载的。同样的问题也适用于样式 – 它们在不同的文件中定义,但在模板中被引用,CSS 选择器遵循标记的结构,因此几乎不可能在不破坏另一个的情况下更改其中一个,这就是 耦合 的定义。这就是为什么经典的关注点分离最终变成了更多技术的分离,这当然不是一件坏事,但它并没有解决任何真正的问题。
React 试图通过将模板放在它们应该的位置——逻辑旁边——向前迈出一大步。它这样做的原因是 React 建议你通过组合称为组件的小块来组织你的应用程序。框架不应该告诉你如何分离关注点,因为每个应用程序都有自己的,只有开发者应该决定如何限制它们应用程序的边界。
基于组件的方法彻底改变了我们编写 Web 应用程序的方式,这就是为什么经典的概念——关注点分离——正逐渐被一个更加现代的结构所取代。React 强加的范式并不新鲜,它也不是由其创造者发明的,但 React 为使这个概念主流化做出了贡献,最重要的是,它以更容易被不同水平的专业开发者理解的方式普及了这个概念。
渲染 React 组件看起来像这样:
return (
<button style={{ color: 'red' }} onClick={handleClick}>
Click me!
</button>
)
我们都同意,一开始这看起来有点奇怪,但这仅仅是因为我们还不习惯那种语法。一旦我们学会了它,并意识到它的强大;我们就理解了它的潜力。使用 JavaScript 进行逻辑和模板化不仅帮助我们更好地分离关注点,而且还赋予我们更多的能力和表现力,这是我们构建复杂 UI 所需要的东西。
因此,即使混合 JavaScript 和 HTML 的想法一开始听起来很奇怪,但给 React 五分钟时间是至关重要的。开始使用新技术最好的方式是在一个小型项目上尝试,看看效果如何。一般来说,正确的做法总是准备好放弃一切,并改变你的思维方式,如果长期利益值得的话。
另有一个概念相当有争议,难以接受,那就是 React 背后的工程师试图推广给社区的:将样式逻辑也移入组件内部。最终目标是封装创建我们组件所使用的每一种技术,并根据它们的领域和功能来分离关注点。以下是一个从 React 文档中摘取的样式对象的例子:
const divStyle = {
color: 'white',
backgroundImage: `url(${imgUrl})`,
WebkitTransition: 'all', // note the capital 'W' here
msTransition: 'all' // 'ms' is the only lowercase vendor prefix
}
ReactDOM.render(<div style={divStyle}>Hello World!</div>, mountNode)
这套解决方案,其中开发者使用 JavaScript 编写他们的样式,被称为#CSSinJS,我们将在第六章让你的组件看起来更美观中详细讨论。
在下一节中,我们将看到如何避免由运行 React 应用程序所需的大量配置引起的 JavaScript 疲劳(主要是 webpack)。
理解 JavaScript 疲劳
有一种普遍的观点认为 React 由一套庞大的技术和工具组成,如果你想使用它,你就被迫处理包管理器、转译器、模块打包器和无穷无尽的库。这种想法已经非常普遍,并且被广泛传播,以至于它已经被明确定义并命名为JavaScript 疲劳。
关于 React 的误解
理解 JavaScript 疲劳的原因并不难。React 生态系统中的所有存储库和库都是使用闪亮的新技术、JavaScript 的最新版本和最先进的技术和范式构建的。此外,GitHub 上有大量的 React 模板代码,每个都有数十个依赖项,为任何问题提供解决方案。
然而,重要的是要理解 React 是一个非常小的库,它可以在任何页面(甚至 JSFiddle 内)中使用,就像人们以前使用 jQuery 或 Backbone 一样,只需在关闭 body 元素之前在页面上包含脚本即可。
无疲劳地开始使用 React
React 被拆分为两个包:
-
react:实现了库的核心功能
-
react-dom:包含所有与浏览器相关的功能
原因在于核心包用于支持不同的目标,例如 React DOM 在浏览器上和在移动设备上的 React Native。在单个 HTML 页面内运行 React 应用程序不需要任何包管理器或复杂的操作。
这里是开始使用 React 需要包含在 HTML 中的 URL:
-
unpkg.com/react@18.2.0/umd/react.production.min.js -
unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js
对于简单的 UI,我们只需使用 createElement (自 React 17 起称为 _jsx)即可,只有在开始构建更复杂的东西时,我们才需要包含一个转译器来启用 JSX 并将其转换为 JavaScript。随着应用的成长,我们可能需要路由器、API 端点和外部依赖项。
JavaScript 生态系统的优势
尽管 JavaScript 生态系统发展迅速且不断变化,但它提供了几个优势。社区在推动创新和快速演变中发挥着重要作用。一旦宣布或起草了规范,社区中就会有人将其实现为转译器插件或 polyfill,让其他人可以在浏览器供应商达成一致并开始支持它之前进行实验。
这使得 JavaScript 和浏览器与其他语言或平台相比成为一个独特的环境。缺点是变化很快,但这只是找到一个正确平衡的问题,即在押注新技术和保持安全之间找到平衡。
再见 Create-React-App,欢迎 Vite!
最近,React 团队决定从他们的官方文档中移除 create-react-app,这表明它不再是设置新 React 项目的默认方法。相反,React 现在推荐使用像 Next.js、Remix 或 Gatsby 这样的框架,以获得更全面的解决方案。然而,如果你需要更简单的替代方案,可以选择 Vite 或 Parcel 这样的构建工具。
Vite 作为解决方案
Vite 是由 Vue.js 的创造者 Evan You 创建的一个构建工具和开发服务器。它利用现代浏览器中本地的 ES 模块功能,以实现快速开发和高效的生成构建。
要使用 Vite 与 React,首先,使用以下命令全局安装 Vite:
npm install -g create-vite
然后,使用 React TypeScript 模板创建一个新的 Vite 项目:
create-vite my-react-app --template react-ts
最后,进入新创建的项目文件夹并启动开发服务器:
cd my-react-app
npm install
npm run dev
您应该看到项目默认在端口 5173 上运行。

图 1.1:Vite 默认应用
如果您想将端口改为 3000,您可以像这样修改 vite.config.ts 文件:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3000
}
})
使用 Vite,您可以用最少的依赖来搭建和运行一个 React 应用程序,同时仍然可以访问构建完整的 React 应用程序所需的所有高级技术功能。
摘要
在本章中,我们学习了对于理解本书的其余部分非常重要的基本概念,这些概念对于日常使用 React 至关重要。我们现在知道如何编写声明式代码,并且对创建的组件与 React 用于在屏幕上显示其实例的元素之间的区别有了清晰的理解。
我们了解了将逻辑和模板放在一起选择的原因,以及为什么这个不受欢迎的决定对 React 来说是一个巨大的胜利。我们探讨了在 JavaScript 生态系统中为什么会普遍感到疲劳的原因,但我们也已经看到了如何通过遵循迭代方法来避免这些问题。
最后,我们了解了新的 create-vite CLI 是什么,我们现在可以开始编写一些真正的代码了。
在下一章中,您将学习 TypeScript 以及如何在项目中使用它。
加入我们的 Discord 社区
加入我们的 Discord 空间,与作者和其他读者进行讨论:
packt.link/React18DesignPatterns4e

第二章:介绍 TypeScript
本章假设你已经有 JavaScript 的经验,并且对通过学习 TypeScript 来提高代码质量感兴趣。TypeScript 是一个类型化的 JavaScript 超集,它可以编译成 JavaScript。换句话说,TypeScript 实质上是带有一些额外功能的 JavaScript。
由微软 C# 的创造者 Anders Hejlsberg 设计,TypeScript 是一种开源语言,它增强了 JavaScript 的功能。通过引入静态类型和其他高级功能,TypeScript 帮助开发者编写更可靠和可维护的代码。
在本章中,我们将探讨 TypeScript 的特性和如何将现有的 JavaScript 代码转换为 TypeScript。到本章结束时,你将深入理解 TypeScript 的优势以及如何利用它们来创建更健壮和可扩展的应用程序。
在本章中,我们将涵盖以下主题:
-
TypeScript 的特性
-
将 JavaScript 代码转换为 TypeScript
-
类型
-
接口
-
扩展接口和类型
-
实现接口和类型
-
合并接口
-
枚举
-
命名空间
-
模板字面量类型
-
TypeScript 配置文件
技术要求
为了完成本章的内容,你需要以下工具:
-
Node.js 19+
-
Visual Studio Code
TypeScript 的特性
TypeScript 是一种由微软开发和维护的流行开源编程语言,正迅速在全球开发者中流行起来。它被引入作为一种 JavaScript 的超集,旨在简化更大规模的应用程序的开发,同时提高代码质量和可维护性。TypeScript 利用静态类型并编译成干净的、简单的 JavaScript 代码,确保与现有的 JavaScript 环境兼容。
这门强大的语言带来了一系列强大的特性,使其与众不同,成为许多程序员的优选。值得注意的是,TypeScript 将强类型注入到 JavaScript 中,提供了更好的错误检查并减少了运行时错误。此外,它完全支持面向对象编程,具有类、接口和继承等高级功能。
由于任何有效的 JavaScript 代码也是 TypeScript,因此从 JavaScript 过渡到 TypeScript 可以逐步进行,开发者可以逐步将类型引入到他们的代码库中。这使得 TypeScript 成为小型和大型项目都适用的灵活、可扩展的解决方案。
在本节中,我们将总结 TypeScript 的基本特性,这些特性是你应该利用的:
-
TypeScript 是 JavaScript: TypeScript 是 JavaScript 的超集,这意味着你写的任何 JavaScript 代码都将与 TypeScript 兼容。如果你已经知道如何使用 JavaScript,那么你已经拥有了使用 TypeScript 所需的所有知识。你只需要学习如何给你的代码添加类型。所有的 TypeScript 代码最终都会被转换成 JavaScript。
-
JavaScript 是 TypeScript:这仅仅意味着你可以将任何有效的
.js文件重命名为.ts扩展名,并且它将工作。 -
错误检查:TypeScript 编译代码并检查错误,这有助于在运行代码之前识别问题。
-
强类型:默认情况下,JavaScript 不是强类型。使用 TypeScript,你可以为所有变量和函数添加类型,甚至可以指定返回值类型。
-
支持面向对象编程:TypeScript 支持类、接口、继承等高级概念。这有助于更好地组织代码并提高其可维护性。
在讨论了 TypeScript 的关键特性之后,让我们深入探讨将 JavaScript 代码转换为 TypeScript 的实际演示。
将 JavaScript 代码转换为 TypeScript
在本节中,我们将看到如何将一些 JavaScript 代码转换为 TypeScript。
假设我们必须检查一个单词是否是回文。这个算法的 JavaScript 代码如下:
function isPalindrome(word) {
const lowerCaseWord = word.toLowerCase()
const reversedWord = lowerCaseWord.split('').reverse().join('')
return lowerCaseWord === reversedWord
}
你可以将这个文件命名为palindrome.ts。
如你所见,我们接收一个string变量(word),并返回一个boolean值。那么,这如何翻译成 TypeScript?
function isPalindrome(word: string): boolean {
const lowerCaseWord = word.toLowerCase()
const reversedWord = lowerCaseWord.split('').reverse().join('')
return lowerCaseWord === reversedWord
}
你可能正在想,“太好了,我已经将string类型指定为word,并将函数返回值指定为boolean类型,但现在怎么办?”
如果你尝试用与string不同的值运行函数,你会得到一个 TypeScript 错误:
console.log(isPalindrome('Level')) // true
console.log(isPalindrome('Anna')) // true
console.log(isPalindrome('Carlos')) // false
console.log(isPalindrome(101)) // TS Error
console.log(isPalindrome(true)) // TS Error
console.log(isPalindrome(false)) // TS Error
所以,如果你尝试将一个数字传递给函数,你会得到以下错误:

图 2.1:类型 number 不能赋值给类型为 string 的参数
这就是为什么 TypeScript 非常有用,因为它会强制你更严格、更明确地对待你的代码。
类型
在最后一个例子中,我们看到了如何为我们的函数参数和返回值指定一些原始类型,但你可能想知道如何更详细地描述一个对象或数组。类型可以帮助我们更好地描述我们的对象或数组。例如,假设你想描述一个User类型以将信息保存到数据库中:
type User = {
username: string
email: string
name: string
age: number
website: string
active: boolean
}
const user: User = {
username: 'czantany',
email: 'carlos@milkzoft.com',
name: 'Carlos Santana',
age: 33,
website: 'http://www.js.education',
active: true
}
// Let's suppose you will insert this data using Sequelize...
models.User.create({ ...user }}
如果我们忘记添加一个节点或在其中放入一个无效值,我们会得到以下错误:

图 2.2:类型 User 中缺少年龄,但需要
如果你需要可选节点,你可以在节点的年龄旁边始终放置一个?,如下面的代码块所示:
type User = {
username: string
email: string
name: string
age?: number
website: string
active: boolean
}
你可以随意命名type,但遵循一个良好的实践是添加前缀T。例如,User类型将变为TUser。这样,你可以快速识别它是type,而不会混淆地认为它是一个类或 React 组件。
接口
接口与类型非常相似,有时开发者不知道它们之间的区别。接口可以用来描述对象的形状或函数签名,就像类型一样,但语法不同:
interface User {
username: string
email: string
name: string
age?: number
website: string
active: boolean
}
您可以随意命名接口,但遵循一个良好的实践是添加前缀 I。例如,User 接口将变为 IUser。这样,您可以快速识别它是一个接口,并且不会混淆地认为它是一个类或 React 组件。
接口也可以扩展、实现和合并。
扩展接口和类型
接口或类型也可以扩展,但同样,语法将有所不同,如下面的代码块所示:
// Extending an interface
interface IWork {
company: string
position: string
}
interface IPerson extends IWork {
name: string
age: number
}
// Extending a type
type TWork = {
company: string
position: string
}
type TPerson = TWork & {
name: string
age: number
}
// Extending an interface into a type
interface IWork {
company: string
position: string
}
type TPerson = IWork & {
name: string
age: number
}
如您所见,通过使用 & 字符,您可以扩展一个类型,而使用 extends 关键字扩展接口。
理解接口和类型的扩展为我们深入了解它们的实现铺平了道路。让我们过渡到展示 TypeScript 中的类如何实现这些接口和类型,同时考虑到处理联合类型时的固有约束。
实现接口和类型
一个类可以以完全相同的方式实现接口或类型别名。但它不能实现(或扩展)一个命名 联合类型 的 类型别名。例如:
// Implementing an interface
interface IWork {
company: string
position: string
}
class Person implements IWork {
name: 'Carlos'
age: 35
}
// Implementing a type
type TWork = {
company: string
position: string
}
class Person2 implements TWork {
name: 'Cristina'
age: 34
}
// You can't implement a union type
type TWork2 = { company: string; position: string } | { name: string; age: number }
class Person3 implements TWork2 {
company: 'Google'
position: 'Senior Software Engineer'
}
如果您编写前面的代码,您的编辑器将出现以下错误:

图 2.3:一个类只能实现具有静态已知成员的对象类型或对象类型的交集
如您所见,您无法实现联合类型。
合并接口
与类型不同,接口可以定义多次,并且将被视为单个接口(所有声明将合并),如下面的代码块所示:
interface IUser {
username: string
email: string
name: string
age?: number
website: string
active: boolean
}
interface IUser {
country: string
}
const user: IUser = {
username: 'czantany',
email: 'carlos@milkzoft.com',
name: 'Carlos Santana',
country: 'Mexico',
age: 35,
website: 'http://www.js.education',
active: true
}
这在需要在不同场景下通过重新定义相同的接口来扩展你的接口时非常有用。
枚举
枚举是 TypeScript 具有的少数几个不是 JavaScript 类型 级扩展的特性之一。枚举允许开发者定义一组 命名常量。使用枚举可以使文档化意图或创建一组不同情况变得更容易。
枚举可以存储数字或字符串值,通常用于提供预定义值。我个人喜欢在主题系统中使用它们来定义一组颜色,如下所示:

图 2.4:用于颜色调板的枚举
接下来,让我们探索 TypeScript 的另一个有用特性,即命名空间。
命名空间
您可能在其他编程语言中听说过 命名空间,例如 Java 或 C++。在 JavaScript 中,命名空间只是全局作用域中的命名对象。它们充当一个区域,在该区域中,变量、函数、接口或类在局部作用域中被组织并分组在一起,以避免全局作用域中组件之间的命名冲突。
虽然模块也用于代码组织,但对于简单用例,命名空间实现起来更为直接。然而,模块提供了命名空间不提供的额外好处,例如代码隔离、捆绑支持、重新导出组件以及重命名组件。
在我的个人项目中,我发现当使用 styled-components 时,命名空间对于分组样式很有用,例如:
import styled from 'styled-components'
export namespace CSS {
export const InputWrapper = styled.div`
padding: 10px;
margin: 0;
background: white;
width: 250px;
`
export const InputBase = styled.input`
width: 100%;
background: transparent;
border: none;
font-size: 14px;
`
}
然后当我需要使用它时,我会这样使用它:
import React, { ComponentPropsWithoutRef, FC } from 'react'
import { CSS } from './Input.styled'
export interface Props extends ComponentPropsWithoutRef<'input'> {
error?: boolean
}
const Input: FC<Props> = ({
type = 'text',
error = false,
value = '',
disabled = false,
...restProps
}) => (
<CSS.InputWrapper style={error ? { border: '1px solid red' } : {}}>
<CSS.InputBase type={type} value={value} disabled={disabled} {...restProps} />
</CSS.InputWrapper>
)
这非常有用,因为我不需要担心导出多个样式组件。我只需导出 CSS 命名空间,就可以使用该命名空间内部定义的所有样式组件。
模板字符串
在 TypeScript 中,模板字符串基于字符串字面量类型,可以使用联合扩展成多个字符串。这些类型对于定义一个主题名称很有用,例如:
type Theme = 'light' | 'dark'
Theme 是一个联合类型,只能分配两种字符串字面量类型之一:'light' 或 'dark'。这提供了类型安全并防止了由于传递无效值作为主题名称而导致的运行时错误。
使用这种方法,您可以定义一个变量、参数或参数的可能值集合,并确保在编译时只使用有效的值。这使得您的代码更加可靠且易于维护。
TypeScript 配置文件
目录中存在 tsconfig.json 文件表示该目录是 TypeScript 项目的根目录。tsconfig.json 文件指定了项目所需的根文件和编译器选项。
您可以在官方 TypeScript 网站上检查所有编译器选项:www.typescriptlang.org/tsconfig。
这是我通常在我的项目中使用的 tsconfig.json 文件。我总是将它们分为两个文件:tsconfig.common.json 文件将包含所有共享的编译器选项,而 tsconfig.json 文件将扩展 tsconfig.common.json 文件并添加一些特定于该项目的选项。这在您与 MonoRepos 一起工作时非常有用。
我的 tsconfig.common.json 文件看起来像这样:
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"alwaysStrict": true,
"declaration": true,
"declarationMap": true,
"downlevelIteration": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"jsx": "react-jsx",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "commonjs",
"moduleResolution": "node",
"noEmit": false,
"noFallthroughCasesInSwitch": false,
"noImplicitAny": true,
"noImplicitReturns": true,
"outDir": "dist",
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"suppressImplicitAnyIndexErrors": false,
"target": "ESNext"
},
"exclude": ["node_modules", "dist", "coverage", ".vscode", "**/__tests__/*"]
}
我的 tsconfig.json 文件看起来像这样:
{
"extends": "./tsconfig.common.json",
"compilerOptions": {
"baseUrl": "./packages",
"paths": {
"@web-creator/*": ["*/src"]
}
}
}
在 第十四章 中,我将解释如何创建 MonoRepos 架构。
摘要
在本章中,我们介绍了 TypeScript 的基础知识,包括创建基本类型和接口、扩展它们,以及使用枚举、命名空间和模板字符串。我们还探讨了设置第一个 TypeScript 配置文件(tsconfig.json)并将其分为两部分——一部分用于共享,另一部分用于扩展 tsconfig.common.json。这种方法在处理 MonoRepos 时尤其有用。
在下一章中,我们将深入探讨使用 JSX/TSX 代码,并探索可以应用于改进代码风格的多种配置。你将学习如何利用 TypeScript 的力量来创建高效且易于维护的 React 应用程序。
第三章:清理您的代码
本章假设您已经具备 JSX(JavaScript XML)的相关经验,并且有兴趣提高您使用它的技能以有效利用它。为了在使用 JSX/TSX 时不出现任何问题或意外行为,了解其底层工作原理及其作为构建 UI 的有用工具的原因至关重要。
我们的目标是编写干净的 JSX/TSX 代码,维护它,并对其内部工作原理有良好的理解,包括它如何转换为 JavaScript 以及它提供的功能。
通过理解 JSX/TSX 的复杂性,您可以充分利用其全部潜力来构建高效且可扩展的 UI。我们将探讨各种技巧和技术,帮助您编写更好的代码并避免常见的错误。到本章结束时,您将牢固掌握 JSX/TSX 的工作原理以及如何在您的 React 应用程序中有效地使用它。
在本章中,我们将涵盖以下主题:
-
JSX 是什么,为什么我们应该使用它?
-
Babel 是什么,以及我们如何使用它来编写现代 JavaScript 代码?
-
JSX 的主要特性和 HTML 与 JSX 之间的区别。
-
编写优雅且可维护的 JSX 的最佳实践。
-
如何通过 linting(特别是 ESLint)使我们的 JavaScript 代码在应用程序和团队之间保持一致性。
-
函数式编程的基础以及遵循函数式范式将如何帮助我们编写更好的 React 组件。
技术要求
要完成本章,您需要以下内容:
-
Node.js 19+
-
Visual Studio Code
使用 JSX
在 第一章 中,我们看到了 React 如何改变关注点分离的概念,将边界移动到组件内部。我们还学习了 React 如何使用组件返回的元素在屏幕上显示 UI。
现在我们来看看我们如何在组件内部声明元素。
React 提供了两种定义元素的方法。第一种是通过使用 JavaScript 函数,第二种是通过使用 JSX,这是一种可选的类似 XML 的语法。以下是新官方 React.js 文档的截图(react.dev):

图 3.1:React.js 的新官方文档网站
首先,JSX 是人们未能接近 React 的主要原因之一,因为第一次看到主页上的示例,看到 JavaScript 与 HTML 混合在一起,对我们大多数人来说可能看起来很奇怪。
一旦我们习惯了它,我们就会意识到它非常方便,正是因为它与 HTML 类似,对已经在网络上创建过 UI 的人来说看起来非常熟悉。开闭标签使得表示嵌套元素树变得更加容易,这在使用纯 JavaScript 时将是难以阅读和难以维护的。
让我们更详细地了解以下子节中的 JSX。
Babel
Babel 是一个流行的 JavaScript 编译器,在 React 社区中得到广泛使用。它允许开发者使用最新的语言特性编写代码,如 JSX 和 ES6,这些特性可能尚未在所有浏览器中得到支持。通过将代码转换成更广泛支持的 ES5,Babel 确保你的应用程序在不同浏览器上平稳运行。
要使用 Babel,你首先需要安装必要的包。在旧版本(Babel 6.x)中,你会安装 babel-cli 包,它包含了 babel-node 和 babel-core。然而,在较新版本中,这些包已经被分离成单独的模块:@babel/core、@babel/cli、@babel/node 等等。
要安装 Babel,请按照以下步骤操作:
-
在全局范围内安装所需的包(尽管通常更喜欢本地安装):
npm install -g @babel/core @babel/node -
要使用 Babel 编译 JavaScript 文件,请运行:
babel source.js -o output.js -
Babel 可以高度配置,你可以使用预设来自定义它。要安装最常用的预设,请运行:
npm install -g @babel/preset-env @babel/preset-react -
在你的项目根目录中创建一个
.babelrc配置文件,并添加以下内容以告诉 Babel 使用已安装的预设:{ "presets": [ "@babel/preset-env", "@babel/preset-react" ] }
现在,你可以在源文件中编写 ES6 和 JSX,Babel 会将它们转换成浏览器兼容的 ES5 JavaScript 代码。
创建第一个元素
现在我们支持 JSX 的环境,让我们探索一个基本示例:创建一个 div 元素。使用 _jsx 函数,我们可以编写:
_jsx('div', {})
然而,使用 JSX,我们可以简单地编写:
<div />
这看起来与常规 HTML 类似,但关键的区别在于我们正在 .js 文件中编写标记。请注意,JSX 只是一种语法糖,在浏览器中执行之前会被转换成 JavaScript。
当我们运行 Babel 时,我们的 <div /> 元素被转换成 _jsx('div', {})。在构建模板时请记住这一点。
从 React 17 开始,React.createElement('div') 已被弃用,库现在内部使用 react/jsx-runtime 来渲染 JSX。这意味着你不再需要导入 React 对象来编写 JSX 代码。相反,你可以像上一个示例中那样直接编写 JSX。
DOM 元素和 React 组件
JSX 允许我们创建 HTML 元素和 React 组件,唯一的区别在于它们是否以大写字母开头。例如,要渲染 HTML 按钮,我们使用 <button />,而要渲染 Button 组件,我们使用 <Button />。
第一个按钮被转换成以下形式:
_jsx('button', {})
第二个被转换成以下形式:
_jsx(Button, {})
关键区别在于第一次调用时,我们传递 DOM 元素的类型作为字符串,而在第二次调用中,我们传递组件本身。因此,组件必须在作用域中存在才能正常工作。
JSX 还支持自闭合标签,这对于保持代码简洁和避免不必要的标签重复很有用。
属性
当你的 DOM 元素或 React 组件有 props 时,JSX 非常方便。使用 XML 在元素上设置属性相当简单:
<img src="img/logo.png" alt="Cabañas San Pancho" />
在 JavaScript 中,这将是以下内容:
_jsx("img", {
src: "https://www.ranchosanpancho.com/images/logo.png",
alt: "Cabañas San Pancho"
})
这要难读得多,即使只有几个属性,没有一点推理也难以阅读。
子元素
JSX 允许你定义子元素来描述元素的树形结构并组合复杂的 UI。一个基本的例子是在其中包含文本的链接,如下所示:
<a href="https://ranchosanpancho.com">Click me!</a>
这将被转换成以下内容:
_jsx(
"a",
{ href: "https://ranchosanpancho.com" },
"Click me!"
)
我们的链接可以包含在一个 div 元素中,以满足某些布局要求,实现这一点的 JSX 片段如下所示:
<div>
<a href="https://ranchosanpancho.com">Click me!</a>
</div>
JavaScript 的等效代码如下:
_jsx(
"div",
null,
_jsx(
"a",
{ href: "https://ranchosanpancho.com" },
"Click me!"
)
)
现在应该很清楚 JSX 的 XML-like 语法是如何使一切更加可读和可维护的,但了解与我们的 JSX 并行的 JavaScript 如何控制元素的创建始终很重要。好处是,我们不仅限于将元素作为元素的子元素,我们还可以使用 JavaScript 表达式,例如函数或变量。
要做到这一点,我们必须将表达式包裹在花括号内:
<div>
Hello, {variable}.
I'm a {() => console.log('Function')}.
</div>
同样适用于非字符串属性,如下所示:
<a href={someFunction()}>Click me!</a>
如你所见,任何变量或函数都应该被包裹在花括号内。
与 HTML 的区别
到目前为止,我们已经探讨了 JSX 和 HTML 之间的相似之处。现在让我们看看它们之间的一些细微差别以及它们存在的原因。
属性
我们必须始终记住 JSX 不是一个标准语言,并且它会被转换成 JavaScript。因此,一些属性不能使用。
例如,我们不得不使用 className 而不是 class,以及使用 htmlFor 而不是 for,如下所示:
<label className="awesome-label" htmlFor="name" />
原因是 class 和 for 是 JavaScript 的保留字。
样式
一个相当显著的区别是 style 属性的工作方式。我们将在 第六章,让你的组件看起来更美观 中更详细地探讨如何使用它,但现在我们将关注它的工作方式。
style 属性不接受 CSS 字符串,就像 HTML 那样,而是期望一个 JavaScript 对象,其中样式名称是 camelCased:
<div style={{ backgroundColor: 'red' }} />
如你所见,你可以向 style prop 传递一个对象,这意味着如果你愿意,你的样式甚至可以放在一个单独的变量中:
const styles = {
backgroundColor: 'red'
}
<div style={styles} />
这是更好地控制你的内联样式的方法。
根
值得注意的是,与 HTML 相比的一个重要区别是,由于 JSX 元素被转换成 JavaScript 函数,而在 JavaScript 中你不能返回两个函数,所以当你有多个同一级别的元素时,你被迫将它们包裹在一个父元素中。
让我们看看一个简单的例子:
<div />
<div />
这会给我们以下错误:
Adjacent JSX elements must be wrapped in an enclosing tag.
另一方面,以下是可以工作的:
<div>
<div />
<div />
</div>
以前,React 强制你返回一个被 <div> 元素或其他标签包裹的元素;自 React 16.2.0 以来,可以直接返回一个数组,如下所示:
return [
<li key="1">First item</li>,
<li key="2">Second item</li>,
<li key="3">Third item</li>
]
或者,你甚至可以直接返回一个字符串,如下面的代码块所示:
return 'Hello World!'
此外,React 现在有一个名为Fragment的新特性,它也可以作为一个特殊的元素包装器。它可以指定为React.Fragment:
import { Fragment } from 'react'
return (
<Fragment>
<h1>An h1 heading</h1>
Some text here.
<h2>An h2 heading</h2>
More text here.
Even more text here.
</Fragment>
)
或者你可以使用空标签(<></>):
return (
<>
<ComponentA />
<ComponentB />
<ComponentC />
</>
)
Fragment不会在 DOM 上渲染任何可见的内容;它只是一个用于包裹你的 React 元素或组件的辅助标签。
空格
在开始时可能会有一点小麻烦,再次强调,这涉及到我们应该始终记住 JSX 不是 HTML,即使它有类似 XML 的语法。JSX 处理文本和元素之间的空格的方式与 HTML 不同,这种方式可能不符合直观。
考虑以下代码片段:
<div>
<span>My</span>
name is
<span>Carlos</span>
</div>
在一个解析 HTML 的浏览器中,这段代码会显示My name is Carlos,这正是我们预期的结果。
在 JSX 中,相同的代码会被渲染为MynameisCarlos,这是因为三个嵌套的行被转换成了div元素的单独子元素,没有考虑到空格。一个常见的解决方案是在元素之间显式地放置一个空格,如下所示:
<div>
<span>My</span>
{' '}
name is
{' '}
<span>Carlos</span>
</div>
如你可能已经注意到的,我们正在使用一个包裹在 JavaScript 表达式中的空字符串来强制编译器在元素之间应用空格。
布尔属性
在真正开始之前,还有一些关于你在 JSX 中定义布尔属性的方式值得提及。
如果你设置了一个没有值的属性,JSX 会假设它的值是true,这与 HTML 中的disabled属性的行为相同,例如。
这意味着如果我们想将属性设置为false,我们必须明确地声明为false:
<button disabled />
_jsx("button", { disabled: true })
下面是布尔属性的另一个例子:
<button disabled={false} />
_jsx("button", { disabled: false })
在开始时这可能会让人困惑,因为我们可能会认为省略属性意味着false,但事实并非如此。在使用 React 时,我们应该始终明确以避免混淆。
展开属性
一个重要的特性是展开属性操作符(...),它来自 ECMAScript“提案”中的 rest/spread 属性,并且在我们想要将 JavaScript 对象的全部属性传递给元素时非常方便。
一种常见的做法可以减少错误,那就是不要通过引用传递整个 JavaScript 对象给子组件,而是使用它们的原始值,这些值可以轻松验证,使组件更加健壮和防错。
让我们看看它是如何工作的:
const attrs = {
id: 'myId',
className: 'myClass'
}
return <div {...attrs} />
之前的代码会被转换成以下形式:
var attrs = {
id: 'myId',
className: 'myClass'
}
return _jsx('div', attrs)
模板字面量
模板字面量是允许嵌入表达式、多行字符串和字符串插值的字符串字面量。它们由反引号(``)字符包围,而不是单引号或双引号。
模板字面量最有用的特性之一是能够使用美元符号和花括号(${expression})包含占位符。这允许我们轻松地将变量或复杂的表达式插入到字符串模板中。以下是一个例子:
const name = 'Carlos'
const age = 35
const message = `Hello, my name is ${name} and I am ${age} years old.`
console.log(message)
这将输出以下内容:
Hello, my name is Carlos and I am 35 years old.
除了字符串插值外,模板字符串还支持多行字符串,这使得在不使用加号(+)运算符连接多个字符串的情况下编写和阅读复杂的字符串变得更加容易。
常见模式
既然我们已经了解了 JSX 的工作原理并能熟练掌握它,我们就准备好按照一些有用的约定和技术来正确地使用它了。
多行
让我们从一个非常简单的例子开始。正如之前所述,我们应该优先选择 JSX 而不是 React 的 _jsx 函数的主要原因之一是因为其类似 XML 的语法,以及平衡的开启和关闭标签非常适合表示节点树。
因此,我们应该尝试以正确的方式使用它并最大限度地发挥其作用。以下是一个例子;每当有嵌套元素时,我们总是应该使用多行:
<div>
<Header />
<div>
<Main content={...} />
</div>
</div>
这比以下情况更可取:
<div><Header /><div><Main content={...} /></div></div>
例外情况是如果子元素不是文本或变量等元素。在这种情况下,保持在同一行上并避免向标记中添加噪音是有意义的,如下所示:
<div>
<Alert>{message}</Alert>
<Button>Close</Button>
</div>
总是记得在多行编写元素时将它们包裹在括号内。JSX 总是被替换为函数,而写在新的行上的函数可能会因为自动分号插入而导致意外结果。例如,假设你从渲染方法中返回 JSX,这是你在 React 中创建 UI 的方式。
以下示例工作正常,因为 div 元素与 return 在同一行上:
return <div />
然而,以下是不正确的:
return
<div />
原因是,这样你会有以下情况:
return
_jsx("div", null)
这就是为什么你必须将语句包裹在括号中的原因,如下所示:
return (
<div />
)
多属性
在编写 JSX 时,一个常见的问题是一个元素具有多个属性。一个解决方案是将所有属性都写在同一行上,但这会导致代码行非常长,而我们不希望代码中出现这种情况(参见下一节了解如何强制执行编码风格指南)。
一个常见的解决方案是将每个属性都写在新的行上,缩进一级,然后将关闭括号与开启标签对齐:
<button
foo="bar"
veryLongPropertyName="baz"
onSomething={this.handleSomething}
/>
条件语句
当我们开始处理 条件语句 时,事情会变得更有趣,例如,如果我们只想在满足某些条件时渲染某些组件。我们可以在条件中使用 JavaScript 是一个很大的优点,但在 JSX 中有许多表达条件的方式,理解每种方式的优点和问题对于编写既可读又可维护的代码非常重要。
假设我们只想在用户当前登录到我们的应用程序时显示注销按钮。
一个简单的示例片段如下:
let button
if (isLoggedIn) {
button = <LogoutButton />
}
return <div>{button}</div>
这确实可行,但可读性并不高,尤其是当有多个组件和多个条件时。
在 JSX 中,我们可以使用内联条件:
<div>
{isLoggedIn && <LoginButton />}
</div>
这之所以有效,是因为如果条件是false,则不会渲染任何内容,但如果条件是true,则LoginButton的createElement函数会被调用,并将元素返回以组成结果树。
如果条件有备选方案(经典的if...else语句),并且我们想要,例如,当用户登录时显示注销按钮,否则显示登录按钮,我们可以如下使用 JavaScript 的if...else语句:
let button
if (isLoggedIn) {
button = <LogoutButton />
} else {
button = <LoginButton />
}
return <div>{button}</div>
或者,更好的是,我们可以使用一个使代码更紧凑的三元条件:
<div>
{isLoggedIn ? <LogoutButton /> : <LoginButton />}
</div>
你可以在流行的仓库中找到使用的三元条件,例如 Redux 现实世界的例子(github.com/reactjs/redux/blob/master/examples/real-world/src/components/List.js#L28),其中三元条件用于在组件正在获取数据时显示Loading标签,或者根据isFetching变量的值在按钮内部显示Load More:
<button [...]>
{isFetching ? 'Loading...' : 'Load More'}
</button>
现在我们来看当事情变得更加复杂时最好的解决方案,例如,我们必须检查多个变量以确定是否渲染组件:
<div>
{dataIsReady && (isAdmin || userHasPermissions) &&
<SecretData />
}
</div>
在这种情况下,很明显使用内联条件是一个好的解决方案,但可读性受到了严重影响。相反,我们可以在组件内部创建一个辅助函数并在 JSX 中使用它来验证条件:
const MyComponent = ({ dataIsReady, isAdmin, userHasPermissions }) => {
const canShowSecretData = () => {
return dataIsReady && (isAdmin || userHasPermissions)
}
return (
<div>
{canShowSecretData() && <SecretData />}
</div>
)
}
如你所见,这个更改使代码更易于阅读,条件更明确。如果你在 6 个月后查看这段代码,仅通过阅读函数名你仍然可以找到它很清晰。
这同样适用于计算属性。假设你有两个单独的属性用于货币和价值。你可以在render内部创建一个价格字符串,而不是这样做,你可以创建一个函数:
const MyComponent = ({ currency, value }) => {
const getPrice = () => {
return `${currency}${value}`
}
return <div>{getPrice()}</div>
}
这更好,因为它被隔离了,如果你想要测试它是否包含逻辑,你可以轻松地进行测试。
回到条件语句,我们可以创建一个自定义组件并命名为RenderIf来条件性地渲染我们的组件:
import React, { FC, ReactElement } from 'react'
interface Props {
children: ReactElement | string
isTrue?: Boolean
isFalse?: Boolean
}
const RenderIf: FC<Props> = ({ children, isTrue, isFalse }) => {
if (isTrue === true) {
return <>{children}</>
}
if (isFalse === false) {
return <>{children}</>
}
return null
}
export default RenderIf
我们可以很容易地在我们的项目中使用它,如下所示:
import RenderIf from './RenderIf'
const MyComponent = ({ dataIsReady, isAdmin, userHasPermissions }) => {
return (
<div>
<RenderIf isTrue={dataIsReady && (isAdmin || userHasPermissions)}>
<SecretData />
</RenderIf>
</div>
)
}
循环
在 UI 开发中,显示项目列表是一个非常常见的操作。当涉及到显示列表时,使用 JavaScript 作为模板语言是一个非常不错的选择。
如果我们在 JSX 模板中编写一个返回数组的函数,数组中的每个元素都会被编译成一个元素。
正如我们之前看到的,我们可以在大括号内使用任何 JavaScript 表达式,并且给定一个对象数组,生成元素数组最常见的方式是使用map。
让我们深入一个现实世界的例子。假设你有一个用户列表,每个用户都有一个附加的name属性。
要创建一个无序列表来显示用户,你可以这样做:
<ul>
{users.map(user => <li>{user.name}</li>)}
</ul>
这个片段既简单又强大,因为 HTML 和 JavaScript 的力量在这里汇聚。
子渲染
值得强调的是,我们总是希望保持我们的组件非常小,我们的渲染方法非常干净和简单。
然而,这并不是一个容易实现的目标,尤其是在您迭代创建应用程序时,在第一次迭代中,您不确定如何将组件拆分成更小的部分。那么,当渲染方法变得太大难以维护时,我们应该怎么做?一个解决方案是以一种让我们能够保持所有逻辑在同一个组件中的方式将其拆分成更小的函数。
让我们来看一个例子:
const renderUserMenu = () => {
// JSX for user menu
}
const renderAdminMenu = () => {
// JSX for admin menu
}
return (
<div>
<h1>Welcome back!</h1>
{userExists && renderUserMenu()}
{userIsAdmin && renderAdminMenu()}
</div>
)
这并不总是被认为是最佳实践,因为这看起来更明显地应该将组件拆分成更小的部分。然而,有时保持渲染方法更干净是有帮助的。例如,在 Redux 的实际应用示例中,使用子渲染方法来渲染加载更多按钮。
现在我们已经是 JSX 的高级用户了,是时候继续前进,看看如何在我们的代码中遵循样式指南,以使其保持一致。
代码样式
在本节中,您将学习如何通过验证代码风格来实施EditorConfig和ESLint,从而提高您的代码质量。在您的团队中拥有标准的代码风格并避免使用不同的代码风格非常重要。
EditorConfig
EditorConfig帮助开发者在不同 IDE 之间保持一致的编码风格。
EditorConfig 被许多编辑器支持。您可以在官方网站上检查您的编辑器是否受支持,www.editorconfig.org。
您需要在您的root目录中创建一个名为.editorconfig的文件——我使用的配置如下:
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.html]
indent_size = 4
[*.css]
indent_size = 4
[*.md]
trim_trailing_whitespace = false
您可以使用[*]影响所有文件,以及使用[.extension]影响特定文件。
Prettier
Prettier是一个有偏见的代码格式化工具,支持多种语言,可以与大多数编辑器集成。这个插件非常有用,因为您可以在保存时格式化代码,您不需要在代码审查中讨论代码风格,这将为您节省大量时间和精力。
如果您使用 Visual Studio Code,您必须首先安装 Prettier 扩展:

图 3.2:Prettier – 代码格式化工具
然后,如果您想在保存文件时配置格式化选项,您需要前往设置,搜索在保存时格式化,并确认该选项:

图 3.3:配置保存文件时的格式化选项
这将影响您所有的项目,因为它是一个全局设置。如果您只想在特定项目中应用此选项,您必须在您的项目内部创建一个.vscode文件夹和一个settings.json文件,其中包含以下代码:
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
}
然后,您可以在您的.prettierrc文件中配置您想要的选项——这是我通常使用的配置:
{
"arrowParens": "avoid",
"bracketSpacing": true,
"jsxSingleQuote": false,
"printWidth": 100,
"quoteProps": "as-needed",
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none",
"useTabs": false
}
这将帮助您或您的团队标准化代码风格。
ESLint
编写高质量的代码始终是我们的目标,但错误仍然可能发生,花费数小时追踪由简单的打字错误引起的错误可能会非常令人沮丧。幸运的是,有一些工具可以帮助我们在键入时就捕获这些错误,从而避免简单的语法错误。
如果你来自像 C#这样的静态类型语言,你可能已经习惯了在 IDE 中得到警告。在 JavaScript 世界中,用于代码检查的流行工具是 ESLint。ESLint 是一个于 2013 年发布的开源项目,它高度可配置和可扩展。
在快速发展的 JavaScript 生态系统中,库和技术经常变化,因此拥有一个可以轻松扩展插件和规则的工具至关重要,这些规则可以根据需要启用或禁用。此外,由于像 Babel 这样的转换器和尚未成为标准 JavaScript 版本一部分的实验性语言功能,我们需要能够告诉我们的代码检查器我们在源文件中遵循哪些规则。代码检查器不仅帮助我们更快地捕获错误,而且还强制执行常见的编码风格指南,这对于大型团队尤为重要,因为一致性是关键。
在接下来的章节中,我们将更详细地探讨 ESLint 以及它是如何帮助我们编写更好、更一致的代码的。
安装
首先,我们必须安装 ESLint 和一些插件,如下所示:
npm install -g eslint eslint-config-airbnb eslint-config-prettier eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-prettier eslint-plugin-react
一旦安装了可执行文件,我们就可以使用以下命令运行它:
eslint source.ts
输出将告诉我们文件中是否有错误。
当我们第一次安装和运行它时,我们没有看到任何错误,因为它完全可配置,并且它没有自带任何默认规则。
配置
让我们开始配置 ESLint。它可以通过位于项目根目录的.eslintrc文件进行配置。为了添加一些规则,让我们创建一个.eslintrc文件,配置为 TypeScript,并添加一条基本规则:
{
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "prettier"],
"extends": [
"airbnb",
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended"
],
"settings": {
"import/extensions": [".js", ".jsx", ".ts", ".tsx"],
"import/parsers": {
"@typescript-eslint/parser": [".ts", ".tsx"]
},
"import/resolver": {
"node": {
"extensions": [".js", ".jsx", ".ts", ".tsx"]
}
}
},
"rules": {
"semi": [2, "never"]
}
}
这个配置文件需要一些解释:“semi”是规则的名称,而[2, "never"]是值。第一次看到它时可能不太直观。
ESLint 规则有三个级别,用于确定问题的严重性:
-
off(或0):规则被禁用。 -
warn(或1):规则是一个警告。 -
error(或2):规则会抛出一个错误。
我们使用值2是因为我们希望 ESLint 在代码不遵循规则时每次都抛出错误。第二个参数告诉 ESLint 我们希望分号永远不被使用(相反是总是)。ESLint 及其插件都有很好的文档,对于任何单个规则,你都可以找到规则的描述和一些示例,说明它在何时通过以及在何时失败。
现在创建一个index.ts文件,内容如下:
const foo = 'bar';
如果我们运行eslint index.js,我们会得到以下结果:
Extra semicolon (semi)
这很好;我们设置了代码检查器,它正在帮助我们遵循第一条规则。
这里有一些我更喜欢关闭或更改的规则:
"rules": {
"semi": [2, "never"],
"@typescript-eslint/class-name-casing": "off",
"@typescript-eslint/interface-name-prefix": "off",
"@typescript-eslint/member-delimiter-style": "off",
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/ban-ts-ignore": "off",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"no-restricted-syntax": "off",
"no-use-before-define": "off",
"import/extensions": "off",
"import/prefer-default-export": "off",
"max-len": [
"error",
{
"code": 100,
"tabWidth": 2
}
],
"no-param-reassign": "off",
"no-underscore-dangle": "off",
"react/jsx-filename-extension": [
1,
{
"extensions": [".tsx"]
}
],
"import/no-unresolved": "off",
"consistent-return": "off",
"jsx-a11y/anchor-is-valid": "off",
"sx-a11y/click-events-have-key-events": "off",
"jsx-a11y/no-noninteractive-element-interactions": "off",
"jsx-a11y/click-events-have-key-events": "off",
"jsx-a11y/no-static-element-interactions": "off",
"react/jsx-props-no-spreading": "off",
"jsx-a11y/label-has-associated-control": "off",
"react/jsx-one-expression-per-line": "off",
"no-prototype-builtins": "off",
"no-nested-ternary": "off",
"prettier/prettier": [
"error",
{
"endOfLine": "auto"
}
]
}
Git 钩子
为了避免在我们的仓库中有未检查的代码,我们可以通过 Git 钩子在我们流程的某个点上添加 ESLint。例如,我们可以使用husky在名为pre-commit的 Git 钩子中运行我们的代码检查器,并且运行名为pre-push的钩子来运行我们的单元测试也是很有用的。
要安装husky,你需要运行以下命令:
npm install --save-dev husky
然后,在我们的package.json文件中,我们可以添加这个节点来配置我们想要在 Git 钩子中运行的任务。
编辑package.json > prepare脚本并运行一次:
npm pkg set scripts.prepare="husky install"
npm run prepare
添加一个钩子:
npx husky add .husky/pre-commit "npm run lint"
git add .husky/pre-commit
提交一个版本:
git commit -m "Keep calm and commit"
# `npm run lint` will run every time you commit
ESLint 命令有一个特殊的选项(标志)叫做--fix – 使用这个选项,ESLint 将尝试自动修复我们所有的代码检查错误(不是所有的)。注意这个选项,因为有时它可能会稍微影响我们的代码风格。另一个有用的标志是--ext,用于指定我们想要验证的文件扩展名 – 在这种情况下,只验证.tsx和.ts文件。
在下一节中,你将了解函数式编程(FP)是如何工作的,以及诸如首类对象、纯净性、不可变性、柯里化和组合等主题。
函数式编程
除了遵循最佳实践和使用代码检查器来捕获错误和强制一致性之外,另一种清理我们代码的方法是采用FP风格。
正如我们在第一章,React 入门中讨论的那样,React 的声明式编程方法使我们的代码更易读。FP 也是一种声明式范式,其中避免了副作用,并且数据被视为不可变,这使得代码更容易维护和推理。
尽管我们不会在本节深入探讨 FP,但我们会介绍一些在 React 中常用且你应该了解的概念。
FP 原则,如不可变性、纯净函数和高阶函数,可以帮助我们编写更易于维护和测试的代码。通过将我们的数据视为不可变,我们可以避免副作用,并使我们的应用程序流程更容易推理。总是对相同的输入返回相同输出的纯净函数帮助我们避免意外的副作用,并使我们的代码更容易测试。接受函数作为参数并/或返回函数作为输出的高阶函数可以帮助我们创建更模块化和可重用的代码。
通过采用 FP 风格,我们可以编写更多声明式代码和更少的命令式代码,使我们的组件更容易阅读和推理。
首类函数
JavaScript 有首类函数,因为它们被当作任何其他变量一样对待,这意味着你可以将一个函数作为参数传递给其他函数,或者它可以被另一个函数返回并赋值给一个变量。
这允许我们引入高阶函数(HoFs)的概念。HoFs 是接受一个函数作为参数(可选地还有其他参数),并返回一个函数的函数。返回的函数通常具有一些特殊的行为。
让我们来看一个例子:
const add = (x, y) => x + y
const log = fn => (...args) => {
return fn(...args)
}
const logAdd = log(add)
这里,一个函数正在添加两个数字,它增强了一个记录所有参数并执行原始函数的函数。
这个概念非常重要,因为,在 React 世界中,一个常见的模式是使用高阶组件将我们的组件视为函数,并用共同的行为来增强它们。我们将在第四章,探索流行的组合模式中看到 HOC 和其他模式。
纯度
函数式编程的一个重要方面是编写纯函数。你将在 React 生态系统中经常遇到这个概念,特别是如果你查看像 Redux 这样的库。
函数要成为纯函数意味着什么?
一个函数是纯的,当且仅当没有副作用,这意味着函数不会改变任何不是函数本身局部的东西。
例如,一个改变应用程序状态的函数,或修改在更高作用域中定义的变量,或者一个接触外部实体(如文档对象模型(DOM))的函数,被认为是非纯函数。非纯函数更难调试,而且大多数时候不可能多次应用并期望得到相同的结果。
例如,以下函数是纯函数:
const add = (x, y) => x + y
它可以运行多次,总是得到相同的结果,因为没有任何东西被存储在任何地方,也没有任何东西被修改。
以下函数不是纯函数:
let x = 0
const add = y => (x = x + y)
运行add(1)两次,我们得到两个不同的结果。第一次我们得到1,但第二次我们得到2,即使我们用相同的参数调用相同的函数。我们得到这种行为的原因是全局状态在每次执行后都会被修改。
不可变性
我们已经看到了如何编写不改变状态的纯函数,但如果我们需要改变变量的值怎么办?在函数式编程(FP)中,一个函数不是改变变量的值,而是创建一个新的变量并返回它,这个变量具有新的值。
这种处理数据的方式被称为不可变性。
一个不可变值是一个不能被改变的值。
让我们来看一个例子:
const add3 = arr => arr.push(3)
const myArr = [1, 2]
add3(myArr); // [1, 2, 3]
add3(myArr); // [1, 2, 3, 3]
前面的函数不遵循不可变性,因为它改变了给定数组的价值。再次强调,如果我们两次调用相同的函数,我们会得到不同的结果。
我们可以使用concat将前面的函数改为不可变,它返回一个新数组而不修改给定的数组:
const add3 = arr => arr.concat(3)
const myArr = [1, 2]
const result1 = add3(myArr) // [1, 2, 3]
const result2 = add3(myArr) // [1, 2, 3]
我们运行函数两次后,myArr仍然保持其原始值。
柯里化
函数式编程中的一个常见技术是柯里化。柯里化是将接受多个参数的函数转换为一次接受一个参数并返回另一个函数的过程。让我们通过一个例子来澄清这个概念。
让我们从之前见过的add函数开始,将其转换为一个柯里化函数。
假设我们有以下代码:
const add = (x, y) => x + y
我们可以定义函数如下:
const add = x => y => x + y
我们以以下方式使用它:
const add1 = add(1)
add1(2); // 3
add1(3); // 4
这是一种相当方便的编写函数的方法,因为第一个值在应用第一个参数后存储,我们可以多次重用第二个函数。
组合
最后,一个在函数式编程中非常重要且可以应用于 React 的概念是组合。函数(和组件)可以组合在一起,产生具有更高级功能和属性的新函数。
考虑以下函数:
const add = (x, y) => x + y
const square = x => x * x
这些函数可以组合在一起,创建一个新的函数,该函数先加两个数字,然后将结果加倍:
const addAndSquare = (x, y) => square(add(x, y))
按照这种范式,我们最终得到的是小型、简单、可测试的纯函数,它们可以组合在一起。
摘要
在本章中,我们介绍了 JSX 的基础知识,包括其语法和功能。我们还学习了如何配置 Prettier 和 ESLint 以保持代码库的一致性,并在代码早期阶段捕获错误。此外,我们还探讨了函数式编程的一些基本概念,这些概念可以帮助我们编写更易于维护和测试的代码。
现在我们已经将代码整理得干净、有序,我们准备深入探索 React,并在下一章学习如何编写真正可重用的组件。通过遵循最佳实践和养成良好的编码习惯,我们可以创建易于维护、扩展和测试的 React 应用程序。
加入我们的 Discord 社区
加入我们的社区 Discord 空间,与作者和其他读者进行讨论:
packt.link/React18DesignPatterns4e

第四章:探索流行的组合模式
在本章中,我们将学习如何使组件之间有效地进行通信,这是使用小型、可测试和可维护的组件构建复杂 React 应用程序的关键部分。通过掌握 React 中流行的组合模式和工具,您将能够控制应用程序的每一个部分,并构建可扩展的软件。
让我们深入探讨如何利用这些模式和工具构建更好的 React 应用程序。我们将涵盖以下主题:
-
组件如何使用 props 和 children 进行通信
-
容器和表现模式以及它们如何使我们的代码更易于维护
-
高阶组件(HOCs)是什么以及如何利用它们以更好的方式构建我们的应用程序
-
子组件模式的功能及其好处
技术要求
要完成本章,您需要以下内容:
-
Node.js 19+
-
Visual Studio Code
您可以在本书的 GitHub 仓库中找到本章的代码,网址为github.com/PacktPublishing/React-18-Design-Patterns-and-Best-Practices-Fourth-Edition/tree/main/Chapter04。
通信组件
使用 React 构建应用程序的关键好处之一是编写 React 组件。通过创建具有清晰界面的小型、可重用组件,您可以轻松地将它们组合在一起,以创建既强大又易于维护的复杂应用程序。
具有清晰界面的小型组件可以组合在一起,以创建既强大又易于维护的复杂应用程序。
组合 React 组件很简单;您只需在 render 中包含它们:
const Profile = ({ user }) => (
<>
<Picture profileImageUrl={user.profileImageUrl} />
<UserName name={user.name} screenName={user.screenName} />
</>
)
例如,您可以通过简单地组合一个Picture组件来显示用户头像,以及一个UserName组件来显示用户名和屏幕名,来创建一个Profile组件。
以这种方式,您可以非常快速地生成新的用户界面部分,只需编写几行代码。每当您组合组件时,就像前面的例子一样,您可以使用 props 在它们之间共享数据。Props 是父组件将数据传递到树中每个需要它(或其部分)的组件的方式。
Profile is not the direct parent of Picture (the div tag is), but Profile owns Picture because it passes down the props to it.
在下一节中,您将了解children属性及其正确使用方法。
使用 children 属性
有一个特殊的属性可以从所有者传递到其内部定义的组件——children。
在 React 文档中,它被描述为不透明,因为它是一个不告诉你其包含值的属性。通常,在父组件的渲染中定义的子组件会接收到作为组件自身属性传递的 props,或者在 JSX 中的_jsx函数的第二个参数中传递。组件也可以在它们内部定义嵌套组件,并且它们可以使用children属性访问这些子组件。
考虑我们有一个具有文本属性的Button组件,该属性表示按钮的文本:
const Button = ({ text }) => <button className="btn">{text}</button>
该组件可以使用以下方式:
<Button text="Click me!" />
这将渲染以下代码:
<button class="btn">Click me!</button>
现在,假设我们想在应用程序的多个部分使用具有相同类名的相同按钮,并且我们还想能够显示比简单的字符串更多的内容。我们的 UI 由带文本的按钮、带文本和图标的按钮以及带文本和标签的按钮组成。
在大多数情况下,一个好的解决方案是向Button添加多个参数或创建不同版本的Button,每个版本都有其单一的专业化,例如IconButton。
然而,我们应该意识到Button可能只是一个包装器,我们能够在其中渲染任何元素并使用children属性:
const Button = ({ children }) => <button className="btn">{children}</button>
通过传递children属性,我们不仅限于简单的单个文本属性,我们还可以将任何元素传递给Button,并且它将替换children属性进行渲染。
在这种情况下,我们包裹在Button组件内的任何元素都将被渲染为具有btn类名的按钮元素的子元素。
例如,如果我们想在按钮内渲染一个图像,并在span标签内包裹一些文本,我们可以这样做:
<Button>
<img src="img/..." alt="..." />
<span>Click me!</span>
</Button>
前面的代码片段在浏览器中的渲染方式如下:
<button class="btn">
<img src="img/..." alt="..." />
<span>Click me!</span>
</button>
这是一种相当方便的方式,允许组件接受任何子元素并将这些元素包裹在预定义的父元素内。
现在,我们可以在Button组件内传递图像、标签,甚至其他 React 组件,并且它们将被渲染为其子元素。正如您在前面的示例中看到的,我们将children属性定义为数组,这意味着我们可以传递任意数量的元素作为组件的子元素。
我们可以传递单个子元素,如下面的代码所示:
<Button>
<span>Click me!</span>
</Button>
现在,让我们在下一节中探索容器和展示模式。
探索容器和展示模式
在上一章中,我们看到了如何逐步将耦合组件转换为可重用组件。现在我们将看到如何将类似的模式应用到我们的组件中,使它们更清晰、更易于维护。
React 组件通常包含逻辑和展示的混合。通过逻辑,我们指的是与 UI 无关的任何内容,例如 API 调用、数据处理和事件处理程序。展示是渲染的部分,我们在其中创建要在 UI 上显示的元素。
在 React 中,有一些简单而强大的模式,称为container和presentational,我们可以在创建组件时应用这些模式,帮助我们分离这两个关注点。
在逻辑和表示之间创建明确的边界不仅使组件更可重用,还提供了许多其他好处,你将在本节中了解到。再次强调,学习新概念的最佳方法之一是通过查看实际示例,所以让我们深入一些代码。
假设我们有一个组件,它使用地理位置 API 来获取用户的当前位置,并在浏览器页面上显示纬度和经度。
首先,我们在components文件夹中创建一个Geolocation.tsx文件,并使用函数组件定义Geolocation组件:
import { useState, useEffect } from 'react'
const Geolocation = () => {}
export default Geolocation
然后我们定义我们的状态:
const [latitude, setLatitude] = useState<number | null>(null)
const [longitude, setLongitude] = useState<number | null>(null)
现在,我们可以使用useEffect Hook 来触发对 API 的请求:
useEffect(() => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(handleSuccess)
}
}, [navigator])
当浏览器返回数据时,我们使用以下函数将结果存储在状态中(在useEffect Hook 之前放置此函数):
const handleSuccess = ({
coords: { latitude, longitude }
}: { coords: { latitude: number; longitude: number }}) => {
setLatitude(latitude)
setLongitude(longitude)
}
最后,我们显示纬度和经度值:
return (
<div>
<h1>Geolocation:</h1>
<div>Latitude: {latitude}</div>
<div>Longitude: {longitude}</div>
</div>
)
需要注意的是,在第一次渲染时,纬度和经度是空的,因为我们是在组件挂载时请求浏览器的坐标。在现实世界的组件中,你可能想在数据返回之前显示一个加载指示器。为此,你可以使用我们在第三章,清理你的代码中看到的一种条件技术。
现在,这个组件没有任何问题,并且按预期工作。如果能够将其与请求和加载位置的部分分开,以便更快地迭代它,那岂不是很好?
我们将使用容器和表示模式来隔离表示部分。在这个模式中,每个组件都被分成两个更小的组件,每个组件都有其明确的职责。容器了解组件的所有逻辑,是调用 API 的地方。它还处理数据处理和事件处理。
表示组件是定义 UI 的地方,它从容器接收以 props 形式的数据。由于表示组件通常没有逻辑,我们可以将其创建为一个无状态的函数组件。
没有规则规定表示组件不能有状态(例如,它可以在内部保持 UI 状态)。在这种情况下,我们需要一个组件来显示纬度和经度,所以我们将使用一个简单的函数。
首先,我们应该将我们的Geolocation组件重命名为GeolocationContainer:
const GeolocationContainer = () => {...}
我们还将更改文件名,从Geolocation.tsx更改为GeolocationContainer.tsx。
这个规则并不是强制性的,但在 React 社区中广泛使用的一种最佳实践是在Container组件名称的末尾附加Container,并将原始名称给表示组件。
我们还必须更改render的实现,并从中删除所有 UI 部分,如下所示:
return <Geolocation latitude={latitude} longitude={longitude} />
如前文片段所示,我们不是在容器的返回值内部创建 HTML 元素,而是使用当前的表现性组件(我们将在下一节创建),并将状态传递给它。这些状态是纬度和经度,默认为 null,当浏览器触发回调时,它们包含用户的实际位置。
让我们创建一个新文件,命名为Geolocation.tsx,在其中定义如下功能组件:
import { FC } from 'react'
type Props = {
latitude: number
longitude: number
}
const Geolocation: FC<Props> = ({ latitude, longitude }) => (
<div>
<h1>Geolocation:</h1>
<div>Latitude: {latitude}</div>
<div>Longitude: {longitude}</div>
</div>
)
export default Geolocation
功能组件是定义 UI 的一种极其优雅的方式。它们是纯函数,给定一个状态,就返回它的元素。在这种情况下,我们的函数从所有者那里接收纬度和经度,并返回用于显示的标记结构。
第一次在浏览器中运行组件时,浏览器将要求您授权以允许它知道您的位置。

图 4.1:浏览器将要求您的权限以访问您的位置
在您允许浏览器知道您的位置后,您将看到如下内容:

图 4.2:显示纬度和经度
遵循容器和表现性模式,我们创建了一个“哑”或表现性组件,它是可重用的,并且可以轻松地集成到我们的组件中。这使得我们可以方便地传递模拟坐标进行测试或演示。如果应用程序的其他地方需要类似的数据结构,它消除了从头开始构建新组件的需要。相反,我们可以在新容器中封装这个现有的组件。例如,这个容器可以设计为从单独的端点检索纬度和经度信息。
同时,我们团队中的其他开发者可以通过添加一些错误处理逻辑来改进使用地理位置的容器,而不会影响其展示。他们甚至可以构建一个临时的表现性组件,仅用于显示和调试数据,然后在准备就绪时替换为真实的表现性组件。
能够在同一个组件上并行工作对团队来说是一个巨大的胜利,尤其是对于那些构建界面是一个迭代过程的公司。
这种模式简单但非常强大,当应用于大型应用程序时,它可以在开发速度和项目的可维护性方面产生差异。另一方面,如果没有真正的原因就应用这种模式,可能会带来相反的问题,使代码库变得不那么有用,因为它涉及到创建更多文件和组件。
因此,当我们决定一个组件必须按照容器和展示模式进行重构时,我们应该仔细思考。一般来说,正确的路径是从一个单一组件开始,只有在逻辑和展示变得过于耦合时才将其拆分。
在我们的例子中,我们从一个单一组件开始,并意识到我们可以将 API 调用与标记分离。决定将什么放入容器以及什么进入展示并不总是直接的;以下要点应有助于你做出这个决定:
以下是一些容器组件的特点:
-
它们更关注行为。
-
它们渲染它们的展示组件。
-
它们执行 API 调用并操作数据。
-
它们定义事件处理器。
以下是一些展示组件的特点:
-
它们更关注视觉表示。
-
它们渲染 HTML 标记(或其他组件)。
-
它们以 props 的形式从父组件接收数据。
-
它们通常被写成无状态的函数组件。
如你所见,这些模式形成了一个非常强大的工具,将帮助你更快地开发你的网络应用程序。让我们看看下一节中 HOCs 是什么。
理解 HOCs
在第三章 清理你的代码 的 函数式编程 部分,我们介绍了 高阶函数(HOFs)的概念。HOFs 是接受另一个函数作为参数、增强其行为并返回一个新函数的函数。将 HOFs 的想法应用于组件会导致 高阶组件(HOCs)的产生。
一个 HOC 看起来是这样的:
const HoC = Component => EnhancedComponent
HOCs 是接受一个组件作为输入并返回一个增强组件作为输出的函数。让我们从一个简单的例子开始,了解增强组件是什么样的。
假设你需要将相同的 className 属性附加到每个组件上。你可以手动将 className 属性添加到每个渲染方法中,或者你可以编写一个像这样的 HOC:
const withClassName = Component => props => (
<Component {...props} className="my-class" />
)
在 React 社区中,使用 with 前缀为 HOCs(高阶组件)是常见的。
上述代码一开始可能让人感到困惑,所以让我们来分解一下。我们声明了一个 withClassName 函数,它接受一个 Component 并返回另一个函数。返回的函数是一个函数组件,它接收一些属性并渲染原始组件。收集到的属性被展开,并传递一个值为 "my-class" 的 className 属性给函数组件。
HOCs 通常会传播它们接收的属性,因为它们旨在保持透明并仅添加新行为。
虽然这个例子很简单且并不特别有用,但它应该能让你更好地理解 HOCs 是什么以及它们看起来像什么。现在,让我们看看我们如何在组件中使用 withClassName HOC。
首先,创建一个接收 className 并将其应用于 div 标签的无状态函数组件:
const MyComponent = ({ className }) => <div className={className} />
不是直接使用组件,而是将其传递给一个 HOC,如下所示:
const MyComponentWithClassName = withClassName(MyComponent)
将我们的组件包裹在withClassName函数中确保它们接收className属性。
现在,让我们创建一个更令人兴奋的 HOC 来检测innerWidth。首先,创建一个接收Component的函数:
import { useEffect, useState } from 'react'
const withInnerWidth = Component => props => <Component {...props} />
使用with模式为增强组件提供信息的 HOCs 通常会有前缀。
接下来,定义innerWidth状态和handleResize函数:
const withInnerWidth = Component => props => {
const [innerWidth, setInnerWidth] = useState(window.innerWidth)
const handleResize = () => {
setInnerWidth(window.innerWidth)
}
return <Component {...props} />
}
然后,添加效果:
useEffect(() => {
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [])
最后,按照如下方式渲染原始组件:
return <Component {...props} innerWidth={innerWidth} />
在这里,我们像之前一样展开属性,但同时也传递了innerWidth状态。
我们将innerWidth值存储为状态,以在不污染组件状态的情况下实现原始行为。相反,我们使用属性。使用属性是提高可重用性的绝佳方式。
现在,使用 HOC 和获取innerWidth值是直接的。新的 React Hooks 可以通过创建自定义 Hooks 轻松地替换 HOC。创建一个期望innerWidth作为属性的函数组件:
const MyComponent = ({ innerWidth }) => {
console.log('window.innerWidth', innerWidth)
// ...
}
如下增强它:
const MyComponentWithInnerWidth = withInnerWidth(MyComponent)
通过使用 HOCs,我们避免了任何状态的污染,并且不需要组件实现任何函数。这意味着组件和 HOC 不是耦合的,并且可以在整个应用程序中重用。
使用属性而不是状态,我们可以创建一个“哑”组件,可以在我们的风格指南中使用,忽略复杂的逻辑,只需传递属性。
在这个特定的情况下,我们可以为支持的每个不同的innerWidth大小创建一个组件。考虑以下示例:
<MyComponent innerWidth={320} />
或者如下:
<MyComponent innerWidth={960} />
如您所见,通过使用高阶组件(HOCs),我们可以传递一个组件,然后返回一个具有额外功能的新组件。一些常见的 HOCs 包括来自 Redux 的connect和来自 Relay 的createFragmentContainer。
理解函数作为子组件
FunctionAsChild模式在 React 社区中得到了共识。它在像react-motion这样的流行库中广泛使用,我们将在第五章为浏览器编写代码中探讨。
主要概念是,我们不是将子组件作为组件传递,而是定义一个可以接收来自父组件参数的函数。让我们看看它是什么样子:
const FunctionAsChild = ({ children }) => children()
如您所见,FunctionAsChild是一个具有子属性定义为函数的组件。它不是用作 JSX 表达式,而是被调用。
之前提到的组件可以这样使用:
<FunctionAsChild>
{() => <div>Hello, World!</div>}
</FunctionAsChild>
这个例子相当简单:子函数在父组件的渲染方法中执行,返回包裹在div标签中的Hello, World!文本,该文本在屏幕上显示。
现在,让我们探索一个更有意义的例子,其中父组件将一些参数传递给子函数。
创建一个Name组件,它期望一个函数作为子组件,并将'World'字符串传递给它:
const Name = ({ children }) => children('World')
之前提到的组件可以这样使用:
<Name>
{name => <div>Hello, {name}!</div>}
</Name>
Hello, World! again, but this time the name has been passed by the parent. It should now be clear how this pattern works. Let’s look at the advantages of this approach:
-
主要优势是能够封装组件,动态传递变量,而不是使用静态属性,这是高阶组件中常见的做法。一个很好的例子是
Fetch组件,它被设计用来从特定的 API 端点检索数据,并将其随后返回给其子函数:<Fetch url="..."> {data => <List data={data} />} </Fetch> -
其次,使用这种方法组合组件不会强迫子组件使用预定义的 prop 名称。由于函数接收变量,使用该组件的开发者可以决定它们的名称。这种灵活性使得“函数作为子组件”解决方案更加灵活。
-
最后,包装器非常可重用,因为它不对接收到的子组件做出任何假设——它只期望一个函数。因此,同一个
FunctionAsChild组件可以在应用程序的不同部分使用,以服务于各种子组件。
通过采用“函数作为子组件”模式,你可以在 React 应用程序中创建更灵活、多功能和可重用的组件。
摘要
在本章中,我们学习了如何有效地使用 props 来组合和通信我们的可重用组件。通过使用 props,我们可以创建定义良好的接口,并使我们的组件彼此解耦。
我们还探索了两种流行的组合模式:容器模式和展示模式,这些模式帮助我们分离逻辑和展示,以便为更专业和专注的组件提供支持。此外,我们还发现了高阶组件(HOCs)作为处理上下文的一种方式,它允许我们不必将组件紧密耦合到上下文中,以及用于动态组合组件的“函数作为子组件”模式。
在下一章中,我们将深入探讨受控组件与不受控组件、refs、处理事件和 React 中的动画。
第五章:为浏览器编写代码
当我们与 React 和浏览器一起工作时,我们可以执行一些特定的操作。例如,我们可以要求我们的用户通过表单输入一些信息。在本章中,我们将探讨如何应用不同的技术来处理表单。我们可以实现非受控组件,让字段保持其内部状态,或者我们可以使用受控组件,其中我们可以完全控制字段的状态。
在本章中,我们还将探讨 React 中的事件是如何工作的,以及库如何实现一些高级技术,以在不同浏览器之间提供一致的接口。我们将探讨 React 团队实施的一些有趣的解决方案,以使事件系统非常高效。
事件发生后,我们将跳转到refs来查看我们如何在 React 组件中访问底层DOM节点。这代表了一个强大的功能,但应该谨慎使用,因为它打破了使 React 易于工作的某些约定。
在 refs 之后,我们将探讨如何使用 React 插件轻松实现动画。最后,我们将学习如何在 React 中与可缩放矢量图形(SVG)一起工作有多容易,以及我们如何为我们的应用程序创建动态可配置的图标。
本章我们将讨论以下主题:
-
使用不同的技术用 React 创建表单
-
监听 DOM 事件并实现自定义处理程序
-
使用 refs 在 DOM 节点上执行命令式操作的方法
-
创建在不同浏览器上都能工作的简单动画
-
React 生成 SVG 的方式
技术要求
要完成本章,你需要以下内容:
-
Node.js 19+
-
Visual Studio Code
你可以在本书的 GitHub 仓库中找到本章的代码:github.com/PacktPublishing/React-18-Design-Patterns-and-Best-Practices-Fourth-Edition/tree/main/Chapter05。
理解和实现表单
在本节中,我们将学习如何使用 React 实现表单。一旦我们开始用 React 构建真实的应用程序,我们就需要与用户交互。如果我们想在浏览器中从我们的用户那里获取信息,表单是最常见的解决方案。由于库的工作方式和其声明性特性,使用 React 处理输入字段和其他表单元素并不简单,但一旦我们理解了其逻辑,它就会变得清晰。在下一节中,我们将学习如何使用非受控和受控组件。
非受控组件
非受控组件类似于常规 HTML 表单输入,对于这些输入,你将无法自行管理其值,而是 DOM 将负责处理该值,你可以通过使用 React ref 来获取这个值。让我们从一个基本示例开始——显示一个带有输入字段和提交按钮的表单。
代码相当简单:
import { FC, useState, ChangeEvent, MouseEvent } from 'react'
const Uncontrolled: FC = () => {
const [value, setValue] = useState<string>('')
return (
<form>
<input type="text" />
<button>Submit</button>
</form>
)
}
export default Uncontrolled
如果我们在浏览器中运行前面的代码片段,我们会看到我们预期的结果——一个可以写入内容的输入框和一个可点击的按钮。这是一个未受控组件的例子,我们并没有设置输入框的值,而是让组件管理其自身的内部状态。
很可能,我们想在点击 Submit 按钮时对元素的值做些处理。例如,我们可能想将数据发送到 API 端点。
我们可以通过添加一个 onChange 监听器轻松做到这一点(我们将在本章后面更多地讨论事件监听器)。让我们看看添加监听器意味着什么。
我们需要创建 handleChange 函数:
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value)
}
事件监听器接收一个事件对象,其中目标表示生成事件的字段,我们对其值感兴趣。我们首先只是记录它,因为重要的是要从小步骤开始,但很快我们会将值存储到状态中。
最后,我们渲染表单:
return (
<form>
<input type="text" onChange={handleChange} />
<button>Submit</button>
</form>
)
如果我们在浏览器中渲染组件并将单词 React 输入到表单字段中,我们将在控制台看到如下内容:
R
Re
Rea
Reac
React
handleChange 监听器会在输入框的值每次改变时被触发。因此,我们的函数会在每个输入字符时被调用一次。下一步是存储用户输入的值,并在用户点击 Submit 按钮时使其可用。
我们只需更改处理程序的实施方式,将其存储在状态中而不是记录它,如下所示:
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value)
}
当表单提交时得到通知与监听输入字段的变化事件非常相似;它们都是浏览器在发生某些事情时调用的事件。
让我们定义 handleSubmit 函数,其中我们只是记录值。在现实世界的场景中,你可以将数据发送到 API 端点或传递给另一个组件:
const handleSubmit = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
console.log(value) // Here we are logging the value state
}
这个处理程序相当简单;我们只是记录当前存储在状态中的值。我们还想克服表单提交时浏览器默认行为,执行自定义操作。这似乎是合理的,并且对于单个字段来说效果很好。现在的问题是,如果我们有多个字段怎么办?假设我们有数十个不同的字段?
让我们从基本示例开始,手动创建每个字段和处理程序,并看看我们如何通过应用不同的优化级别来改进它。
让我们创建一个新的表单,包含姓氏和名字字段。我们可以重用 Uncontrolled 组件并添加一些新的状态:
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
我们在状态中初始化两个字段,并为每个字段定义一个事件处理程序。正如你可能已经注意到的,当有很多字段时,这并不很好地扩展,但在转向更灵活的解决方案之前,清楚地理解问题是重要的。
现在,我们实现新的处理程序:
const handleChangeFirstName = ({ target: { value } }) => {
setFirstName(value)
}
const handleChangeLastName = ({ target: { value } }) => {
setLastName(value)
}
我们还必须稍微更改提交处理程序,以便在点击时显示姓名的首尾:
const handleSubmit = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
console.log(`${firstName} ${lastName}`) // Logging the firstName and lastName states
}
最后,我们渲染表单:
return (
<form onSubmit={handleSubmit}>
<input type="text" onChange={handleChangeFirstName} />
<input type="text" onChange={handleChangeLastName} />
<button>Submit</button>
</form>
)
我们已经准备就绪:如果我们将在浏览器中运行前面的组件,我们将看到两个字段,如果我们将在第一个字段中输入Carlos,在第二个字段中输入Santana,当表单提交时,我们将在浏览器控制台中看到完整的姓名显示。
再次强调,这种方法效果良好,我们可以通过这种方式做一些有趣的事情,但它无法处理复杂场景,除非我们编写大量的模板代码。
让我们看看如何对其进行一点优化。我们的目标是使用单个更改处理程序,这样我们就可以添加任意数量的字段,而无需创建新的监听器。
让我们回到组件,并更改我们的状态:
const [values, setValues] = useState({ firstName: '', lastName: '' })
我们可能仍然想要初始化值,在本节的后面部分,我们将探讨如何为表单提供预填充值。
现在,有趣的部分在于我们可以如何修改onChange处理程序实现,使其在不同的字段中工作:
const handleChange = ({ target: { name, value } }) => {
setValues({
...values,
[name]: value
})
}
如我们之前所见,我们接收的事件的target属性代表了触发事件的输入字段,因此我们可以使用字段的名称及其值作为变量。
然后,我们必须为每个字段设置名称:
return (
<form onSubmit={handleSubmit}>
<input
type="text"
name="firstName"
onChange={handleChange}
/>
<input
type="text"
name="lastName"
onChange={handleChange}
/>
<button>Submit</button>
</form>
)
就这样!我们现在可以添加任意多的字段,而无需创建额外的处理程序。
受控组件
受控组件是 React 组件,它通过使用组件状态来控制表单中输入元素的值。
在这里,我们将探讨如何使用一些值预先填充表单字段,这些值可能来自服务器或作为父组件的 props。为了完全理解这个概念,我们将从一个非常简单的无状态函数组件开始,并逐步改进它。
第一个示例显示了输入字段内的预定义值:
const Controlled = () => (
<form>
<input type="text" value="Hello React" />
<button>Submit</button>
</form>
)
如果我们在浏览器中运行此组件,我们会发现它按预期显示了默认值,但它不允许我们更改值或在其内部输入其他内容。
原因在于,在 React 中,我们声明了希望在屏幕上显示的内容,设置固定值属性总会导致渲染该值,无论采取其他什么操作。这在实际应用中可能不是我们希望的行为。
如果我们打开控制台,我们会得到以下错误消息。React 本身告诉我们我们做错了什么:
You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field.
现在,如果我们只想让输入字段有一个默认值,并且我们想要能够通过输入来更改它,我们可以使用defaultValue属性:
import { useState } from 'react'
const Controlled = () => {
return (
<form>
<input type="text" defaultValue="Hello React" />
<button>Submit</button>
</form>
)
}
export default Controlled
这样,当字段被渲染时,它将显示Hello React,然后用户可以在其中输入任何内容并更改其值。现在让我们添加一些状态:
const [values, setValues] = useState({ firstName: 'Carlos', lastName: 'Santana' })
处理程序与之前相同:
const handleChange = ({ target: { name, value } }) => {
setValues({
[name]: value
})
}
const handleSubmit = (e) => {
e.preventDefault()
console.log(`${values.firstName} ${values.lastName}`)
}
实际上,我们将使用输入字段的值属性来设置它们的初始值以及更新后的值:
return (
<form onSubmit={handleSubmit}>
<input
type="text"
name="firstName"
value={values.firstName}
onChange={handleChange}
/>
<input
type="text"
name="lastName"
value={values.lastName}
onChange={handleChange}
/>
<button>Submit</button>
</form>
)
当表单第一次渲染时,React 使用状态中的初始值作为输入字段的值。当用户在字段中输入某些内容时,handleChange函数被调用,并将字段的新值存储在状态中。
当状态发生变化时,React 会重新渲染组件并再次使用它来反映输入字段的当前值。我们现在完全控制字段的值,我们称这种模式为受控组件。
在下一节中,我们将处理事件,这是 React 处理来自表单的数据的基本部分。
处理事件
事件在不同的浏览器中工作方式略有不同。React 试图抽象事件的工作方式,并为开发者提供一个一致的接口来处理。这是 React 的一个伟大特性,因为我们可以忘记我们正在针对的浏览器,并编写事件处理程序和函数,它们是供应商无关的。
为了提供这个功能,React 引入了合成事件的概念。合成事件是一个对象,它包装了浏览器提供的原始事件对象,并且无论在哪里创建,都具有相同的属性。
要将事件监听器附加到节点并在事件触发时获取事件对象,我们可以使用一个简单的约定,它回忆了事件附加到 DOM 节点的方式。实际上,我们可以使用单词 on 加上camelCased事件名称(例如,onKeyDown)来定义在事件发生时被触发的回调。一个流行的约定是将事件处理程序函数命名为事件名称,并使用handle作为前缀(例如,handleKeyDown)。
我们在之前的示例中已经看到了这种模式的实际应用,当时我们正在监听表单字段的onChange事件。让我们再次回顾一个基本的事件监听器示例,看看我们如何以更优雅的方式在同一个组件内部组织多个事件。我们将实现一个简单的按钮,并且像往常一样,首先创建一个组件:
const Button = () => {
}
export default Button
然后我们定义事件处理程序:
const handleClick = (syntheticEvent) => {
console.log(syntheticEvent instanceof MouseEvent)
console.log(syntheticEvent.nativeEvent instanceof MouseEvent)
}
正如您在这里所看到的,我们正在做一件非常简单的事情:我们只是检查从 React 接收的事件对象的类型以及附加到其上的原生事件类型。我们期望第一个返回 false,第二个返回 true。
您永远不需要访问原始的本地事件,但了解您可以在需要时这样做是好的。最后,我们使用onClick属性定义按钮,并将事件监听器附加到它:
return (
<button onClick={handleClick}>Click me!</button>
)
现在,假设我们想要将第二个处理程序附加到按钮,以便监听双击事件。一个解决方案是创建一个新的单独的处理程序,并使用onDoubleClick属性将其附加到按钮,如下所示:
<button
onClick={handleClick}
onDoubleClick={handleDoubleClick}
>
Click me!
</button>
请记住,我们总是力求编写更少的样板代码并避免代码重复。因此,一个常见的做法是为每个组件编写一个单个事件处理程序,它可以根据事件类型触发不同的操作。
这种技术由 Michael Chan 在一系列模式中描述:
reactpatterns.com/#event-switch
让我们实现通用事件处理器:
const handleEvent = (event) => {
switch (event.type) {
case 'click':
console.log('clicked')
break
case 'dblclick':
console.log('double clicked')
break
default:
console.log('unhandled', event.type)
}
}
通用事件处理器接收事件对象并根据事件类型切换以触发正确的动作。如果我们想在每个事件上调用一个函数(例如,分析)或者某些事件共享相同的逻辑,这特别有用。
最后,我们将新的事件监听器附加到onClick和onDoubleClick属性:
return (
<button
onClick={handleEvent}
onDoubleClick={handleEvent}
>
Click me!
</button>
)
从这一点开始,每当我们需要为同一组件创建一个新的事件处理器时,我们只需向 switch 添加一个新的情况,而不是创建一个新的方法和绑定它。
关于 React 中事件的一些有趣的事情是,合成事件会被重用,并且存在一个全局处理器。第一个概念意味着我们不能存储一个合成事件并在之后重用它,因为事件在动作之后立即变为 null。这种技术在性能方面非常好,但如果出于某种原因我们想在组件的状态中存储事件,可能会遇到问题。为了解决这个问题,React 为我们提供了一个在合成事件上的持久化方法,我们可以调用它来使事件持久化,以便我们可以存储和稍后检索它。
第二个非常有趣的具体实现细节又是关于性能的,这涉及到 React 将事件处理器附加到 DOM 的方式。
每当我们使用 on 属性时,我们都在向 React 描述我们想要实现的行为,但库不会将实际的事件处理器附加到底层的 DOM 节点。
它所做的相反,是将单个事件处理器附加到根元素,该处理器监听所有事件,这要归功于事件冒泡。当浏览器触发我们感兴趣的事件时,React 代表它调用特定组件上的处理器。这种技术称为事件委托,用于内存和速度优化。
在下一节中,我们将探索 React refs 并了解如何利用它们。
探索 refs
人们喜欢 React 的一个原因是因为它是声明式的。声明式意味着你只需描述在任何时刻你想在屏幕上显示的内容,React 会负责与浏览器的通信。这个特性使得 React 在推理上非常容易,同时也很强大。
然而,可能有一些情况下你需要访问底层的 DOM 节点来执行一些命令式操作。这应该被避免,因为在大多数情况下,有更符合 React 的解决方案来实现相同的结果,但了解我们有这个选项并且知道它是如何工作的,以便我们可以做出正确的决定。
假设我们想要创建一个简单的表单,其中包含一个输入元素和一个按钮,并且我们希望它以这样的方式运行:当按钮被点击时,输入字段会获得焦点。我们想要做的是在浏览器窗口中调用输入节点(即输入的实际 DOM 实例)的 focus 方法。
让我们创建一个名为Focus的组件;你需要导入useRef并创建一个inputRef常量:
import { useRef } from 'react'
const Focus = () => {
const inputRef = useRef(null)
}
export default Focus
然后,我们实现handleClick方法:
const handleClick = () => {
inputRef.current.focus()
}
如你所见,我们正在引用inputRef的当前属性,并在其上调用 focus 方法。
要了解其来源,你只需检查render的实现:
return (
<>
<input
type="text"
ref={inputRef}
/>
<button onClick={handleClick}>Set Focus</button>
</>
)
接下来是逻辑的核心。我们在表单内部创建一个带有输入元素的表单,并在其ref属性上定义一个函数。
当组件挂载时,我们定义的回调会被调用,其中的 element 参数代表输入的 DOM 实例。重要的是要知道,当组件卸载时,相同的回调会被带有 null 参数调用,以释放内存。
在回调中我们所做的是存储元素的引用,以便将来使用(例如,当handleClick方法被触发时)。然后,我们有带有其事件处理器的按钮。在浏览器中运行前面的代码将显示带有字段和按钮的表单,点击按钮将使输入字段获得焦点,正如预期的那样。
正如我们之前提到的,通常情况下,我们应该尽量避免使用 refs,因为它们会使代码变得更加命令式,并且它们变得难以阅读和维护。
理解forwardRef
React.forwardRef是一个有用的特性,允许你从父组件向下传递一个 ref(简称“引用”)到子组件。本文将提供一个关于React.forwardRef的基本介绍,并提供一个简单的示例来帮助你理解其实际用法。
React 中的 refs 是一种机制,用于访问和与组件渲染的 DOM 元素交互。它们提供了一种修改 DOM 或直接访问 DOM 属性的方法。
React.forwardRef是一个高阶组件,允许你将 ref 传递给子组件。当你需要从父组件访问子组件的 DOM 元素或实例时,这非常有用。
要创建一个可以接受转发 ref 的组件,你将使用React.forwardRef函数,它接受一个作为参数的渲染函数。这个渲染函数接收两个参数:组件的props和转发的 ref。
import React from 'react'
const TextInputWithRef = React.forwardRef((props, ref) => {
return <input ref={ref} type="text" {...props} />
})
export default TextInputWithRef
要使用forwardRef组件,你需要使用useRef()钩子创建一个 ref,并将其分配给forwardRef组件。
import React, { useRef } from 'react'
import TextInputWithRef from './TextInputWithRef'
function App() {
const inputRef = useRef()
const handleClick = () => {
inputRef.current.focus()
}
return (
<div>
<TextInputWithRef ref={inputRef} />
<button onClick={handleClick}>Focus on input</button>
</div>
)
}
export default App
在这个例子中,我们创建了一个TextInputWithRef组件,它接受一个传递的 ref。在App组件中,我们使用useRef()钩子创建一个 ref,然后将其传递给TextInputWithRef组件。当点击“聚焦到输入框”按钮时,会调用handleClick函数,该函数聚焦到输入元素。
React.forwardRef是一个强大的功能,允许您将 ref 从父组件传递到子组件,从而提供对子组件行为的更大控制。
通过理解refs和forwardRef的基本知识,并检查一个简单的示例,你可以在你的 React 应用程序中有效地利用这个功能。
在探讨了利用React.forwardRef以实现对组件的更高级控制之后,我们现在可以将注意力转向另一个增强 React 应用程序用户体验的关键方面:实现动画。
实现动画
当我们思考 UI 和浏览器时,我们肯定也会想到动画。动画化的 UI 对用户来说更愉快,它们是向用户展示已经发生或即将发生的事情的重要工具。
本节的目的不是提供一个创建动画和美观 UI 的详尽指南;这里的目的是提供一些关于我们可以实施以动画化我们的 React 组件的常见解决方案的基本信息。
对于像 React 这样的 UI 库,为开发者提供一个简单的方式来创建和管理动画至关重要。React 附带了一个名为react-transition-group的附加组件,它是一个帮助我们以声明式方式构建动画的组件。再次强调,能够以声明式方式执行操作非常强大,它使得代码更容易推理和与团队共享。
要开始构建动画组件,我们首先需要安装附加组件:
npm install --save react-transition-group @types/react-transition-group
完成这些后,我们可以导入组件:
import { TransitionGroup} from 'react-transition-group'
然后,我们只需将动画应用到我们想要应用的组件上:
const Transition = () => (
<TransitionGroup
transitionName="fade"
transitionAppear
transitionAppearTimeout={500}
>
<h1>Hello React</h1>
</TransitionGroup>
)
如您所见,有一些属性需要解释。首先,我们声明了transitionName属性。ReactTransitionGroup将具有该属性名称的类应用到子元素上,这样我们就可以使用 CSS 过渡来创建动画。
使用单个类,我们无法轻松地创建合适的动画,这就是为什么过渡组根据动画的状态应用多个类。在这种情况下,使用transitionAppear属性,我们告诉组件当子元素出现在屏幕上时,我们想要对它们进行动画处理。
因此,库所做的是在组件渲染后立即将其fade-appear类(其中fade是transitionName属性的值)应用到组件上。在下一次 tick 时,应用fade-appear-active类,这样我们就可以使用 CSS 从初始状态到新状态触发动画。
我们还必须设置transitionAppearTimeout属性来告诉 React 动画的长度,这样它就不会在动画完成之前从 DOM 中删除元素。
使元素淡入的 CSS 如下。
首先,我们在初始状态中定义元素的透明度:
.fade-appear {
opacity: 0.01;
}
然后,我们使用第二个类定义我们的过渡,它一旦应用到元素上就开始:
.fade-appear.fade-appear-active {
opacity: 1;
transition: opacity .5s ease-in;
}
我们正在使用缓动函数在 500ms 内将透明度从 0.01 过渡到 1。这很简单,但我们可以创建更复杂的动画,我们还可以动画化组件的不同状态。例如,当新的元素作为过渡组的子元素被添加时,会应用*-enter和*-enter-active类。类似的事情也适用于删除元素。
在深入研究动画的动态世界并了解它们如何极大地增强我们的 React 组件之后,现在让我们将注意力转向网络设计的另一个迷人方面:可伸缩矢量图形(SVG)的探索。
探索 SVG
最后,我们可以在浏览器中应用的一种最有趣的技巧是使用 SVG 来绘制图标和图表。
SVG 很棒,因为它是一种描述矢量的声明性方式,它与 React 的目的完美契合。我们过去使用图标字体来创建图标,但它们有众所周知的问题,首先是它们不可访问。使用 CSS 定位图标字体也很困难,并且它们并不总是在所有浏览器中看起来都很漂亮。这就是我们应该在我们的 Web 应用程序中优先考虑 SVG 的原因。
从 React 的角度来看,无论我们从渲染方法中输出 div 还是 SVG 元素,这都没有任何区别,这也是它如此强大的原因。我们也倾向于选择 SVG,因为我们可以使用 CSS 和 JavaScript 轻松地在运行时修改它们,这使得它们成为 React 函数式方法的绝佳候选者。
因此,如果我们把我们的组件视为其属性的函数,我们就可以很容易地想象出如何创建自包含的 SVG 图标,我们可以通过向它们传递不同的属性来操作它们。在 React web 应用中创建 SVG 的一个常见方法是将我们的矢量包装在一个 React 组件中,并使用属性来定义它们的动态值。
让我们看看一个简单的例子,我们画一个蓝色圆圈,从而创建一个包装 SVG 元素的 React 组件:
const Circle = ({ x, y, radius, fill }) => (
<svg>
<circle cx={x} cy={y} r={radius} fill={fill} />
</svg>
)
如您所见,我们可以轻松使用无状态的函数式组件来包装 SVG 标记,并且它接受 SVG 相同的属性。
以下是一个示例用法:
<Circle x={20} y={20} radius={20} fill="blue" />
我们显然可以使用 React 的全部功能并设置一些默认参数,这样,如果圆图标没有属性被渲染,我们仍然会显示一些内容。
例如,我们可以定义默认颜色:
const Circle = ({ x, y, radius, fill = 'red' }) => (...)
当我们构建 UI 时,这非常强大,尤其是在一个团队中,我们共享我们的图标集,并希望在它中设置一些默认值,但我们也希望让其他团队决定他们的设置,而无需重新创建相同的 SVG 形状。
然而,在某些情况下,我们更喜欢更加严格,并固定一些值以保持一致性。使用 React,这是一个超级简单的任务。
例如,我们可以将基本圆圈组件包裹在RedCircle中,如下所示:
const RedCircle = ({ x, y, radius }) => (
<Circle x={x} y={y} radius={radius} fill="red" />
)
在这里,颜色默认设置,并且不能更改,而其他属性则透明地传递给原始圆。
以下截图显示了由 React 使用 SVG 生成的两个圆圈,蓝色和红色:

图 5.1:两个圆圈,蓝色和红色 SVG
我们可以应用这种技术并创建圆的不同变体,例如SmallCircle和RightCircle,以及构建我们的 UI 所需的一切。
摘要
在本章中,我们探讨了 React 在针对浏览器时的不同功能,从创建表单和处理事件到动画 SVG。我们还学习了新的useRef钩子,它提供了一种简单的方式来访问 DOM 节点。React 的声明式方法简化了复杂 Web 应用程序的管理。此外,React 提供了一种访问 DOM 节点的方式,如果需要,可以进行命令式操作,这使得将 React 与现有库集成变得更加容易。
在下一章中,我们将深入研究 CSS 和内联样式,并探讨在 JavaScript 中编写 CSS 的概念。
第六章:让你的组件看起来美观
我们对 React 最佳实践和设计模式的探索已经到了想要让我们的组件看起来美观的阶段。为了做到这一点,我们将探讨为什么常规 CSS 可能不是为组件设置样式的最佳方法,并检查各种替代解决方案。
从内联样式开始,然后是 CSS 模块和styled-components,本章将引导你进入 CSS 在 JavaScript 中的神奇世界。
在本章中,我们将涵盖以下主题:
-
规范 CSS 在规模上的常见问题
-
在 React 中使用内联样式意味着什么以及它们的缺点
-
如何使用 Webpack 和 CSS 模块从头开始设置项目
-
CSS 模块的特点以及为什么它们代表避免全局 CSS 的绝佳解决方案
-
styled-components,一个提供现代方法来设置 React 组件样式的库
技术要求
要完成本章,你需要以下内容:
-
Node.js 19+
-
Visual Studio Code
你可以在本书的 GitHub 仓库中找到本章的代码:github.com/PacktPublishing/React-18-Design-Patterns-and-Best-Practices-Fourth-Edition/tree/main/Chapter06。
CSS in JavaScript
2014 年 11 月,Christopher Chedeau,也被称为vjeux,在 NationJS 大会上发表了一次演讲(blog.vjeux.com/2014/javascript/react-css-in-js-nationjs.xhtml),这引发了一场关于 React 组件样式的革命。作为 React 的贡献者和 Meta 的员工,Christopher 概述了 Facebook 在规模上遇到的许多 CSS 问题。了解这些问题很重要,因为它们在 Web 开发中很常见,并将帮助我们介绍内联样式和局部作用域的类名等概念。
以下是一个 CSS 问题的列表,这些问题基本上是 CSS 在规模上的问题:
-
全局命名空间
-
依赖项
-
死代码消除
-
压缩
-
共享常量
-
非确定性解析
-
隔离
CSS 的第一个众所周知的问题是所有选择器都是全局的。无论我们如何通过使用命名空间或块、元素、修饰符(BEM)方法等来组织样式,我们总是在污染全局命名空间,这是我们大家都知道是错误的。这不仅从原则上讲是错误的,而且还会导致大型代码库中许多错误,并且从长远来看,它使得可维护性变得非常困难。与大型团队合作时,了解特定的类或元素是否已经被样式化是非平凡的,而且大多数时候,我们倾向于添加更多类而不是重用现有的类。
CSS 的第二个问题与依赖关系的定义有关。实际上,很难清楚地说明特定的组件依赖于特定的 CSS,并且必须加载 CSS 才能应用样式。由于样式是全局的,任何文件中的任何样式都可以应用到任何元素上,失去控制很容易。
前端开发者倾向于使用预处理器将 CSS 分割成子模块,但最终,浏览器会生成一个大的、全局的 CSS 包。
由于 CSS 代码库往往很快就会变得很大,我们失去了对它们的控制,第三个问题与死代码消除有关。很难快速识别哪些样式属于哪个组件,这使得删除代码变得极其困难。实际上,由于 CSS 的级联性质,删除选择器或规则可能会在浏览器中产生意外的结果。
与 CSS 一起工作的另一个痛点涉及选择器和类名的压缩,这在 CSS 和 JavaScript 应用程序中都是如此。这看起来可能是一个简单的任务,但实际上并非如此,尤其是在类名在客户端动态应用或连接时;这是第四个问题。
无法压缩和优化类名会对性能产生不利影响,并且可以极大地影响 CSS 的大小。另一个在常规 CSS 中非平凡的常见操作是在样式和客户端应用程序之间共享常量。例如,我们经常需要知道标题的高度,以便重新计算依赖于它的其他元素的位置。
通常,我们使用 JavaScript API 在客户端读取值,但最佳解决方案是共享常量并避免在运行时进行昂贵的计算。这代表了 vjeux 和 Facebook 的其他开发者试图解决的第五个问题。
第六个问题与 CSS 的非确定性解析有关。实际上,在 CSS 中,顺序很重要,如果 CSS 是按需加载的,则顺序无法保证,这会导致错误样式被应用到元素上。
假设,例如,我们想要优化请求 CSS 的方式,只有在用户导航到特定页面时才加载与该页面相关的 CSS。如果与最后一个页面相关的 CSS 中有一些规则也适用于不同页面的元素,那么它最后被加载的事实可能会影响应用程序其余部分的样式。例如,如果用户返回到上一个页面,他们可能会看到一个与第一次访问时略有不同的 UI 页面。
控制所有各种样式、规则和导航路径的组合非常困难,但再次强调,能够在需要时加载 CSS 可以对 Web 应用的性能产生关键影响。
最后但同样重要的是,根据克里斯托弗·舍代乌的说法,CSS 的第七个问题是与隔离相关的。在 CSS 中,几乎不可能在文件或组件之间实现适当的隔离。选择器是全局的,并且很容易被覆盖。仅通过知道应用于元素的类名来预测元素的最终样式是困难的,因为样式不是隔离的,应用程序其他部分的规则可能会影响无关的元素。这可以通过使用内联样式来解决。
在下一节中,我们将探讨使用 React 中的内联样式意味着什么,以及它的优点和缺点。
理解和实现内联样式
官方的 React 文档建议开发者使用 内联样式 来样式化他们的 React 组件。这似乎很奇怪,因为我们都知道在过去的几年里,关注点的分离很重要,我们不应该混合标记和 CSS。
React 通过将关注点的分离从技术的分离转变为组件的分离,试图改变关注点分离的概念。当标记、样式和逻辑紧密耦合,并且一个不能在没有另一个的情况下工作时,将它们分离到不同的文件中只是一个幻象。即使这有助于保持项目结构更清晰,但它并不提供任何真正的益处。
在 React 中,我们通过组合组件来创建应用程序,其中组件是我们结构的基本单元。我们应该能够将组件移动到应用程序的任何地方,并且它们应该在逻辑和 UI 方面提供相同的结果,无论它们在哪里渲染。
这就是为什么在 React 中将样式与我们的组件一起放置,并在元素上使用内联样式应用它们是有意义的其中一个原因。
首先,让我们看看如何使用 React 中节点的 style 属性来对我们的组件应用样式的一个例子。我们将创建一个带有文本 点击我! 的按钮,并给它应用颜色和背景颜色:
const style = {
color: 'palevioletred',
backgroundColor: 'papayawhip'
}
const Button = () => <button style={style}>Click me!</button>
如您所见,在 React 中使用内联样式来样式化元素非常简单。我们只需要创建一个对象,其中属性是 CSS 规则,值是我们会在常规 CSS 文件中使用的值。
唯一的不同之处在于,连字符 CSS 规则必须是 camelCased 才符合 JavaScript 规范,并且值是字符串,因此它们必须用引号括起来。
关于供应商前缀有一些例外。例如,如果我们想在 webkit 上定义一个过渡效果,我们应该使用 WebkitTransition 属性,其中 webkit 前缀以大写字母开头。这个规则适用于所有供应商前缀,除了 ms,它是小写的。
其他用例是数字——它们可以不带引号或单位来编写,默认情况下,它们被视为像素。
以下规则应用了 100 像素的高度:
const style = {
height: 100
}
通过使用内联样式,我们还可以做一些用常规 CSS 难以实现的事情。例如,我们可以在客户端运行时重新计算一些 CSS 值,这是一个非常强大的概念,你将在下面的示例中看到。
假设你想创建一个表单字段,其字体大小会根据其值而改变。所以,如果字段的值是24,字体大小将是 24 像素。使用正常的 CSS,这种行为几乎不可能实现,除非投入巨大的努力和重复的代码。
让我们来看看使用内联样式有多简单,首先创建一个FontSize组件,然后声明一个值状态:
import { useState, ChangeEvent } from 'react'
const FontSize = () => {
const [value, setValue] = useState<number>(16)
}
export default FontSize
我们实现了一个简单的更改处理程序,其中我们使用事件的target属性来检索字段的当前值:
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setValue(Number(e.target.value))
}
最后,我们渲染数字类型的输入文件,它是一个受控组件,因为我们通过使用状态来保持其值的更新。它还有一个事件处理程序,每当字段值改变时都会触发。
最后但同样重要的是,我们使用字段的style属性来设置其字体大小值。正如你所见,我们正在使用 CSS 规则的camelCased版本来遵循 React 约定:
return (
<input
type="number"
value={value}
onChange={handleChange}
style={{ fontSize: value }}
/>
)
渲染前面的组件,我们可以看到一个输入字段,其字体大小会根据其值而改变。其工作原理是,当值改变时,我们将字段的新值存储在状态中。修改状态会强制组件重新渲染,我们使用新的状态值来设置字段的显示值及其字体大小;这既简单又强大。
计算机科学中的每个解决方案都有其缺点,并且总是代表一种权衡。在内联样式中,不幸的是,问题很多。
例如,使用内联样式,无法使用伪选择器(例如,:hover)和伪元素,这对于创建具有交互和动画的 UI 来说是一个相当大的限制。
有一些解决方案,例如,你总是可以创建真实元素而不是伪元素,但对于伪类,必须使用 JavaScript 来模拟 CSS 行为,这并不是最优的。
同样适用于媒体查询,无法使用内联样式定义,这使得创建响应式 Web 应用更加困难。由于样式是使用 JavaScript 对象声明的,因此也无法使用style回退:
display: -webkit-flex;
display: flex;
JavaScript 对象不能有两个具有相同名称的属性。应该避免使用样式回退,但如果有需要,始终具备使用它们的能力是好的。
CSS 的另一个特性是内联样式无法模拟,那就是动画。这里的解决方案是全局定义动画,并在元素的 style 属性中使用它们。使用内联样式时,每当我们需要用常规 CSS 覆盖样式时,我们总是被迫使用!important关键字,这不符合最佳实践,因为它会阻止其他样式应用于该元素。
与内联样式一起工作最困难的事情是调试。我们倾向于使用类名在浏览器 DevTools 中查找元素以进行调试和检查应用了哪些样式。使用内联样式时,所有项目的样式都列在它们的style属性中,这使得检查和调试结果变得非常困难。
例如,本节中我们创建的按钮是以以下方式渲染的:
<button style="color:palevioletred;background-color:papayawhip;">Click me!</button>
单独来看,它似乎并不难读,但如果你想象有成百上千个元素和风格,你就会意识到问题变得非常复杂。
此外,如果你正在调试一个每个项目都有相同style属性的列表,并且如果你在浏览器中实时修改一个以检查结果,你会看到你只应用了样式到它,而没有应用到所有其他兄弟元素上,即使它们共享相同的样式。
最后但同样重要的是,如果我们将应用渲染在服务器端(我们将在第十二章,服务器端渲染)中讨论这个话题),使用内联样式时页面的大小会更大。
使用内联样式,我们将 CSS 的全部内容放入了标记中,这给发送给客户端的文件增加了额外的字节数,使得网络应用看起来运行得更慢。压缩算法可以解决这个问题,因为它们可以轻松压缩相似的模式,在某些情况下,加载关键路径 CSS 是一个不错的解决方案;但总的来说,我们应该尽量避免这样做。
结果表明,内联样式带来的问题比它们试图解决的问题还要多。因此,社区创建了不同的工具来解决内联样式的问题,同时保持样式在组件内部或局部,以获得两全其美的效果。
在 Christopher Chedeau 的演讲之后,许多开发者开始讨论内联样式,并提出了许多解决方案和实验,以寻找在 JavaScript 中编写 CSS 的新方法。最初,有二三种解决方案,而如今已有超过 40 种。
在下一节中,我们将学习如何使用 CSS 模块。
使用 CSS 模块
如果你认为内联样式不适合你的项目或团队,但你仍然希望将样式尽可能靠近你的组件,有一个解决方案适合你,称为CSS 模块。CSS 模块是 CSS 文件,其中所有类名和动画名默认都是本地作用域的。让我们看看我们如何在项目中使用它们;但首先,我们需要配置webpack。
Webpack 5
在深入研究 CSS 模块并了解它们是如何工作之前,了解它们的创建过程以及支持它们的工具非常重要。
在第三章,清理你的代码中,我们探讨了如何使用 Babel 及其预设来编写 ES6 代码并进行转换。一旦应用程序增长,你可能还想将你的代码库拆分成模块。
你可以使用 Webpack 或 Browserify 将应用程序分割成小模块,你可以在需要时导入它们,同时仍然为浏览器创建一个大包。这些工具被称为模块打包器,它们所做的是将你的应用程序的所有依赖项加载到一个单独的包中,这个包可以在浏览器中执行,而浏览器(目前)还没有模块的概念。
在 React 的世界里,Webpack 特别受欢迎,因为它提供了许多有趣和有用的功能,其中第一个就是加载器的概念。使用 Webpack,你可以潜在地加载除 JavaScript 之外的所有依赖项,如果存在相应的加载器的话。例如,你可以加载 JSON 文件,以及图片和其他资产到包中。
2015 年 5 月,CSS 模块的创造者之一 Mark Dalgleish 发现,你还可以在 Webpack 包中导入 CSS,并将这一概念推进了一步。他认为,由于 CSS 可以本地导入到组件中,所有导入的类名也可以本地作用域化,这真是太好了,因为这将会隔离样式。
在追踪了 CSS 模块本地作用域概念演变的一位先驱者 Mark Dalgleish 的思路,并理解了它是如何革命性地改变 Webpack 包中的样式隔离之后,让我们过渡到一个更实际的领域。下一节将指导我们设置一个利用这些原则的项目。
设置项目
在本节中,我们将探讨如何设置一个非常简单的 Webpack 应用程序,使用 Babel 将 JavaScript 和 CSS 模块转换为加载本地作用域的 CSS 到包中。我们还将了解 CSS 模块的所有功能,并查看它们可以解决的问题。首先,我们需要移动到一个空文件夹并运行以下命令:
npm init
这将创建一个包含一些默认设置的package.json文件。
现在,是时候安装依赖项了,首先是 Webpack,其次是webpack-dev-server,我们将使用它来本地运行应用程序并动态创建包:
npm install --save-dev webpack webpack-dev-server webpack-cli
一旦 Webpack 安装完成,就是安装 Babel 及其加载器的时候了。由于我们使用 Webpack 来创建包,因此我们将在 Webpack 本身中使用 Babel 加载器来转译我们的 ES6 代码:
npm install --save-dev @babel/core @babel/preset-env @babel/preset-react ts-loader
最后,我们需要安装style-loader和 CSS 加载器,这两个加载器是我们启用 CSS 模块所必需的:
npm install --save-dev style-loader css-loader
为了使事情更简单,我们还需要做一件事,那就是安装html-webpack-plugin,这是一个插件,可以创建一个 HTML 页面来即时托管我们的 JavaScript 应用程序,只需查看 Webpack 配置,而无需我们创建常规文件。此外,我们还需要安装fork-ts-checker-webpack-plugin包以使 TypeScript 与 Webpack 协同工作:
npm install --save-dev html-webpack-plugin fork-ts-checker-webpack-plugin typescript
最后但同样重要的是,我们安装react和react-dom以在简单示例中使用它们:
npm install react react-dom
现在所有依赖项都已安装,是时候配置一切使其工作。
首先,你需要在你的根路径下创建一个.babelrc文件:
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}
首先要做的事情是在package.json中添加一个 npm 脚本以运行webpack-dev-server,这将服务于开发中的应用程序:
"scripts": {
"dev": "webpack serve --mode development --port 3000"
}
在 Webpack 5 中,你需要使用这种方式调用 webpack 而不是webpack-dev-server,但你仍然需要安装此包。
Webpack 需要一个配置文件来了解我们应用程序中使用的不同类型的依赖项,为此,我们必须创建一个名为webpack.config.ts的文件,该文件导出一个对象:
module.exports = {}
我们导出的对象代表 Webpack 用于创建包的配置对象,它可以根据项目的规模和功能具有不同的属性。
我们希望保持示例非常简单,所以我们将添加三个属性。第一个是entry,它告诉 Webpack 我们的应用程序的主文件在哪里:
entry: './src/index.tsx'
第二个是module,这是我们告诉 Webpack 如何加载外部依赖项的地方。它有一个名为rules的属性,其中我们为每种文件类型设置一个特定的加载器:
module: {
rules: [
{
test: /\.(tsx|ts)$/,
exclude: /node_modules/,
use: {
loader: 'ts-loader',
options: {
transpileOnly: true
}
}
},
{
test: /\.css/,
use: [
'style-loader',
'css-loader?modules=true'
]
}
]
}
我们说匹配.ts或.tsx正则表达式的文件将使用ts-loader加载,以便它们被转译并加载到包中。
你可能也注意到了,我们在.babelrc文件中添加了我们的预设。正如我们在第三章清理你的代码中看到的,预设是一组配置选项,它指导 Babel 如何处理不同类型的语法(例如,TSX)。
rules数组中的第二个条目告诉 Webpack 在导入 CSS 文件时应该做什么,它使用带有modules标志的css-loader来激活CSS 模块。转换的结果随后传递给style-loader,它将样式注入到页面的头部。
最后,我们启用 HTML 插件为我们生成页面,自动添加我们之前指定的entry路径的脚本标签:
const HtmlWebpackPlugin = require('html-webpack-plugin')
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin')
plugins: [
new ForkTsCheckerWebpackPlugin(),
new HtmlWebpackPlugin({
title: 'Your project name',
template: './src/index.xhtml',
filename: './index.xhtml'
})
]
完整的webpack.config.ts应该如下所示:
const HtmlWebpackPlugin = require('html-webpack-plugin')
const path = require('path')
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin')
const isProduction = process.env.NODE_ENV === 'production'
module.exports = {
devtool: !isProduction ? 'source-map' : false, // We generate source maps
// only for development
entry: './src/index.tsx',
output: { // The path where we want to output our bundles
path: path.resolve(__dirname, 'dist'),
filename: '[name].[hash:8].js',
sourceMapFilename: '[name].[hash:8].map',
chunkFilename: '[id].[hash:8].js',
publicPath: '/'
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.json', '.css'] // Here we add the
// extensions we want to support
},
target: 'web',
mode: isProduction ? 'production' : 'development', // production mode
// minifies the code
module: {
rules: [
{
test: /\.(tsx|ts)$/,
exclude: /node_modules/,
use: {
loader: 'ts-loader',
options: {
transpileOnly: true
}
}
},
{
test: /\.css/,
use: [
'style-loader',
'css-loader?modules=true'
]
}
]
},
plugins: [
new ForkTsCheckerWebpackPlugin(),
new HtmlWebpackPlugin({
title: 'Your project name',
template: './src/index.xhtml',
filename: './index.xhtml'
})
],
optimization: { // This is to split our bundles into vendor and main
splitChunks: {
cacheGroups: {
default: false,
commons: {
test: /node_modules/,
name: 'vendor',
chunks: 'all'
}
}
}
}
}
然后,为了配置 TypeScript,你需要这个 tsconfig.json 文件:
{
"compilerOptions": {
"allowJs": true,
"allowSyntheticDefaultImports": true,
"baseUrl": "src",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"jsx": "react-jsx",
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "node",
"noEmit": true,
"noFallthroughCasesInSwitch": true,
"noImplicitAny": false,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"target": "esnext"
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]
}
为了使用 TypeScript 导入 CSS 文件,你需要在 src/declarations.d.ts 创建一个声明文件:
declare module '*.css' {
const content: Record<string, string>
export default content
}
然后,你需要创建 src/index.tsx 的主文件:
import { createRoot } from 'react-dom/client'
const App = () => {
return <div>Hello World</div>
}
createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
最后,你需要创建初始的 HTML 文件 src/index.xhtml:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"
/>
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<div id="root"></div>
</body>
</html>
我们完成了,如果我们终端中运行 npm run dev 命令并将浏览器指向 http://localhost:8080,我们应该能够看到以下标记正在被提供:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Your project name</title>
<script defer src="img/vendor.12472959.js"></script>
<script defer src="img/main.12472959.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
完美!我们的 React 应用程序正在运行!现在让我们看看我们如何给我们的项目添加一些 CSS。
局部作用域的 CSS
现在是时候创建我们的应用程序了,它将包含一个与之前示例中使用的相同类型的简单按钮。我们将使用它来展示 CSS 模块的所有功能。
让我们更新 src/index.tsx 文件,这是我们指定的 Webpack 配置的入口:
import { createRoot } from 'react-dom/client'
然后,我们可以创建一个简单的按钮。像往常一样,我们将从一个未加样式的按钮开始,并将样式逐步添加:
const Button = () => <button>Click me!</button>
最后,我们可以将按钮渲染到 DOM 中:
createRoot(document.getElementById('root') as HTMLElement).render(<Button />)
现在,假设我们想要给按钮应用一些样式——背景颜色、大小等等。我们创建一个普通的 CSS 文件,命名为 index.css,并将以下类放入其中:
.button {
background-color: #ff0000;
width: 320px;
padding: 20px;
border-radius: 5px;
border: none;
outline: none;
}
现在,我们说过,使用 CSS 模块,我们可以将 CSS 文件导入到 JavaScript 中;让我们看看它是如何工作的。
在我们定义 button 组件的 index.ts 文件内部,我们可以添加以下行:
import styles from './index.css'
这个 import 语句的结果是一个 styles 对象,其中所有属性都是 index.css 中定义的类。
如果我们运行 console.log(styles),我们可以在 DevTools 中看到以下对象:
{
button: "_2wpxM3yizfwbWee6k0UlD4"
}
因此,我们有一个对象,其中属性是类名,值是(表面上)随机的字符串。我们稍后会看到它们不是随机的,但让我们先看看我们可以用这个对象做什么。
我们可以使用这个对象来设置按钮的类名属性,如下所示:
const Button = () => (
<button className={styles.button}>Click me!</button>
);
如果我们回到浏览器,现在我们可以看到我们在 index.css 中定义的样式已经应用到了按钮上。这不是魔法,因为如果我们检查 DevTools,应用到的元素类与我们在代码中导入的 style 对象中附加的相同字符串:
<button class="_2wpxM3yizfwbWee6k0UlD4">Click me!</button>
如果我们查看页面的头部部分,现在我们可以看到相同的类名也已注入到页面中:
<style type="text/css">
._2wpxM3yizfwbWee6k0UlD4 {
background-color: #ff0000;
width: 320px;
padding: 20px;
border-radius: 5px;
border: none;
outline: none;
}
</style>
这就是 CSS 和样式加载器的工作方式。CSS 加载器允许你将 CSS 文件导入到你的 JavaScript 模块中,当 module 标志被激活时,所有类名都局部作用在导入它们的模块中。
如我们之前提到的,我们导入的字符串不是随机的,但它使用文件哈希和一些其他参数以在代码库中唯一的方式生成。
最后,style-loader 会将 CSS 模块转换的结果注入到页面的头部部分。这非常强大,因为我们拥有了 CSS 的全部功能和表现力,同时结合了局部作用域类名和显式依赖的优势。
如本章开头所述,CSS 是全局的,这使得它在大型应用中很难维护。使用 CSS 模块,类名是局部作用域的,它们不会与其他部分的类名冲突,从而确保结果的可预测性。
此外,在组件内部显式导入 CSS 依赖项可以帮助我们清楚地看到哪些组件需要哪些 CSS。这对于消除死代码也非常有用,因为当我们出于任何原因删除一个组件时,我们可以确切地知道它使用了哪些 CSS。
CSS 模块是常规的 CSS,因此我们可以使用伪类、媒体查询和动画。
例如,我们可以添加如下 CSS 规则:
.button:hover {
color: #fff;
}
.button:active {
position: relative;
top: 2px;
}
@media (max-width: 480px) {
.button {
width: 160px;
}
}
这将被转换成以下代码并注入到文档中:
._2wpxM3yizfwbWee6k0UlD4:hover {
color: #fff;
}
._2wpxM3yizfwbWee6k0UlD4:active {
position: relative;
top: 2px;
}
@media (max-width: 480px) {
._2wpxM3yizfwbWee6k0UlD4 {
width: 160px;
}
}
类名被创建,并在按钮被使用的地方被替换,这使得它既可靠又局部,正如预期的那样。
正如你可能已经注意到的,这些类名很好,但它们使得调试变得相当困难,因为我们无法轻易地知道哪个类生成了哈希。在开发模式下,我们可以添加一个特殊的配置参数,通过它可以选择用于生成作用域类名的模式。
例如,我们可以更改加载器的值如下:
{
test: /\.css/,
use: [
{
loader: 'style-loader'
},
{
loader: 'css-loader',
options: {
modules: {
localIdentName: '[local]--[hash:base64:5]'
}
}
}
]
}
在这里,localIdentName 是参数,而 [local] 和 [hash:base64:5] 是原始类名值和五个字符哈希的占位符。其他可用的占位符包括 [path],它代表 CSS 文件的路径,以及 [name],它是源 CSS 文件的名字。
激活之前的配置选项,我们在浏览器中看到的结果如下:
<button class="button--2wpxM">Click me!</button>
这使得代码更易于阅读和调试。
在生产环境中,我们不需要这样的类名,我们更关注性能,因此我们可能希望有更短的类名和哈希。
使用 Webpack,这非常直接,因为我们可以在应用生命周期的不同阶段使用多个配置文件。此外,在生产环境中,我们可能希望提取 CSS 文件而不是将其从包中注入到浏览器中,这样我们可以有一个更轻量的包,并在内容分发网络(CDN)上缓存 CSS 以提高性能。
要做到这一点,你需要安装另一个 Webpack 插件,名为 mini-css-extract-plugin,它可以写入实际的 CSS 文件,并将从 CSS 模块生成的所有作用域类名放入其中。
CSS 模块有几个值得注意的特性。
第一项是 global 关键字。在任意类名前加上 :global,实际上意味着要求 CSS 模块不要将当前选择器本地化。
例如,假设我们按以下方式更改我们的 CSS:
:global .button {
...
}
输出将如下所示:
.button {
...
}
如果你想要应用无法本地化的样式,比如第三方小部件,这会很好。
我最喜欢的 CSS 模块功能是 组合。通过组合,我们可以从同一文件或外部依赖中提取类,并将所有样式应用到元素上。
例如,将设置按钮背景为红色的规则从按钮的规则中提取到一个单独的块中,如下所示:
.background-red {
background-color: #ff0000;
}
我们可以按以下方式在我们的按钮内部组合它:
.button {
composes: background-red;
width: 320px;
padding: 20px;
border-radius: 5px;
border: none;
outline: none;
}
结果是,按钮的所有规则和 composes 声明的所有规则都应用到该元素上。
这是一个非常强大的功能,它以令人着迷的方式工作。你可能预期所有组合的类都会在它们被引用的类中重复,就像 SASS @extend 一样,但这并不是事实。简单来说,所有组合的类名都是按顺序应用到 DOM 中的组件上的。
在我们的特定情况下,我们将有以下内容:
<button class="_2wpxM3yizfwbWee6k0UlD4 Sf8w9cFdQXdRV_i9dgcOq">Click me!</button>
这里,注入到页面中的 CSS 如下所示:
.Sf8w9cFdQXdRV_i9dgcOq {
background-color: #ff0000;
}
._2wpxM3yizfwbWee6k0UlD4 {
width: 320px;
padding: 20px;
border-radius: 5px;
border: none;
outline: none;
}
如你所见,我们的 CSS 类名具有唯一名称,这有助于隔离我们的样式。现在,让我们看看原子 CSS 模块。
原子 CSS 模块
应该很清楚组合是如何工作的,以及为什么它是 CSS 模块的一个非常强大的功能。在我开始写这本书的时候,我在迪士尼公司工作,我们试图更进一步,将 composes 的力量与 Atomic CSS(也称为 功能性 CSS)的灵活性结合起来。
原子 CSS 是一种使用 CSS 的方法,其中每个类只有一个规则。
例如,我们可以创建一个类来设置 margin-bottom 为 0:
.mb0 {
margin-bottom: 0;
}
我们可以使用另一个类来设置 font-weight 为 600:
.fw6 {
font-weight: 600;
}
然后,我们可以将这些原子类应用到元素上:
<h2 class="mb0 fw6">Hello React</h2>
这种技术具有争议性,同时也很高效。很难开始使用它,因为你最终会在你的标记中拥有太多的类,这使得预测最终结果变得困难。如果你这么想,它和内联样式非常相似,因为你为每条规则应用一个类,除了你使用一个较短的类名作为代理之外。
反对原子 CSS 的最大论点通常是你将样式逻辑从 CSS 移动到标记,这是错误的。类是在 CSS 文件中定义的,但它们是在视图中组合的,每次你不得不修改元素的样式时,你最终都会编辑标记。
另一方面,我们尝试使用原子 CSS 一段时间,我们发现它使原型设计变得非常快。
事实上,当所有基本规则都生成后,将这些类应用到元素上并创建新样式是一个非常快速的过程,这是很好的。其次,使用 Atomic CSS,我们可以控制 CSS 文件的大小,因为一旦我们创建了带有其样式的新的组件,我们就在使用现有的类,而不需要创建新的类,这对性能来说是非常好的。
因此,我们尝试使用 CSS 模块来解决 Atomic CSS 的问题,并将其技术命名为Atomic CSS modules。
从本质上讲,你开始创建你的基础 CSS 类(例如,mb0),然后,而不是在标记中逐个应用类名,你使用 CSS 模块将它们组合成占位符类。
让我们来看一个例子:
.title {
composes: mb0 fw6;
}
这里有一个另一个例子:
<h2 className={styles.title}>Hello React</h2>
这很棒,因为你仍然将样式逻辑保留在 CSS 中,CSS 模块的composes为你完成了工作,通过在标记中应用所有单个类。
上述代码的结果如下:
<h2 class="title--3JCJR mb0--21SyP fw6--1JRhZ">Hello React</h2>
在这里,title、mb0和fw6都被自动应用到元素上。它们也被局部作用域化,因此我们拥有 CSS 模块的所有优势。
实现 styled-components
有一个库非常有前景,因为它考虑到了其他库在组件样式方面遇到的所有问题。在 JavaScript 中编写 CSS 已经遵循了不同的路径,并且尝试了许多解决方案,因此现在是时候有一个库能够吸收所有这些经验,并在其基础上构建一些东西了。
这个库由 JavaScript 社区中的两位流行开发者构想和维护:Glenn Maddern和Max Stoiber。它代表了一种非常现代的解决问题的方法,它使用了 ES2015 的边缘特性以及一些应用于 React 的高级技术,以提供完整的样式解决方案。
让我们看看如何创建我们在上一节中看到的相同按钮,并检查我们感兴趣的 CSS 特性(例如,伪类和媒体查询)是否与styled-components一起工作。
首先,我们必须通过运行以下命令来安装库:
npm install styled-components
一旦库安装完成,我们就要在我们的组件文件中导入它:
import styled from 'styled-components'
在那个阶段,我们可以使用styled函数通过styled.elementName创建任何元素,其中elementName可以是 div、button 或其他任何有效的 DOM 元素。
第二件事是要定义我们创建的元素的样式,为此,我们使用一个名为tagged template literals的 ES6 特性,这是一种在不预先插值的情况下将模板字符串传递给函数的方法。
这意味着函数接收包含所有 JavaScript 表达式的实际模板,这使得库能够利用 JavaScript 的全部力量来应用样式到元素上。
让我们从一个具有基本样式的简单按钮开始创建:
const Button = styled.button`
backgroundColor: #ff0000;
width: 320px;
padding: 20px;
borderRadius: 5px;
border: none;
outline: none;
`;
这种“有点奇怪”的语法返回一个正确的 React 组件,称为 Button,它渲染一个 button 元素,并将模板中定义的所有样式应用于它。应用样式的做法是通过创建一个唯一的类名,将其添加到元素中,然后在文档的头部注入相应的样式。
下面是将被渲染的组件:
<button class="kYvFOg">Click me!</button>
添加到页面上的样式如下:
.kYvFOg {
background-color: #ff0000;
width: 320px;
padding: 20px;
border-radius: 5px;
border: none;
outline: none;
}
styled-components 的好处是它几乎支持所有 CSS 功能,这使得它成为在现实世界中使用的良好候选者。
例如,它支持使用类似 SASS 的语法使用伪类:
const Button = styled.button`
background-color: #ff0000;
width: 320px;
padding: 20px;
border-radius: 5px;
border: none;
outline: none;
&:hover {
color: #fff;
}
&:active {
position: relative;
top: 2px;
}
`
它还支持媒体查询:
const Button = styled.button`
background-color: #ff0000;
width: 320px;
padding: 20px;
border-radius: 5px;
border: none;
outline: none;
&:hover {
color: #fff;
}
&:active {
position: relative;
top: 2px;
}
@media (max-width: 480px) {
width: 160px;
}
`;
该库可以为你的项目带来许多其他功能。
例如,一旦创建了按钮,你就可以轻松地覆盖其样式,并使用不同的属性多次使用它。在模板内部,也可以使用组件接收到的 props 并相应地更改样式。
另一个很棒的功能是主题化。通过将你的组件包裹在 ThemeProvider 组件中,你可以将 theme 属性注入到三个组件的子组件中,这使得创建部分样式在组件之间共享,而其他一些属性依赖于当前所选主题的 UI 变得极其简单。
毫无疑问,styled-components 库在将你的样式提升到下一个层次时是一个变革者。一开始,它可能看起来有点奇怪,因为它涉及到使用组件实现样式,但一旦你习惯了它,我保证它将成为你最喜欢的样式包。
摘要
在本章中,我们探讨了旨在帮助读者导航 React 中样式复杂性的重要主题。我们讨论了扩展 CSS 的挑战,并以 Meta 的经验为例,突出大型组织面临的现实世界困难。这强调了我们所分享知识的相关性和适用性。
为了使 React 中的样式更加直观和高效,我们研究了内联样式的工作原理以及将样式与组件本地化放置的好处。这种方法促进了代码的有序和可读性,这对于希望掌握 React 的开发者来说至关重要。
认识到内联样式的局限性,我们介绍了 CSS 模块作为替代方案。我们提供了一个逐步指南来设置项目,让读者通过动手实验来学习。
将 CSS 文件导入组件也被强调为一种重要的实践。这有助于明确依赖关系,并通过将类名本地化来防止问题,确保代码的可扩展性和无冲突。
最后,我们向读者介绍了 styled-components,这是一个与本书目标一致的库,旨在提供创新的方式来样式化组件并优化 React 中的开发实践。
到目前为止,我们已经探讨了在 React 中管理 CSS 样式的各种方法,每种方法都展示了我们核心论点的不同方面。在下一章中,我们将通过深入研究服务器端渲染的实际实现和好处来进一步加深你对 React 的理解——这是一种提高应用程序性能和用户体验的技术。
加入我们的 Discord 社区
加入我们的 Discord 空间,与作者和其他读者进行讨论:
packt.link/React18DesignPatterns4e

第七章:需要避免的反模式
在这本书中,你已经学会了如何在编写 React 应用程序时应用最佳实践。在前几章中,我们回顾了基本概念以建立坚实的基础,然后在接下来的章节中,我们深入探讨了更高级的技术。
现在,你应该能够构建可重用的组件,使组件之间相互通信,并优化应用程序树以获得最佳性能。然而,开发者会犯错误,本章全部关于我们在使用 React 时应避免的常见反模式。
通过查看常见错误,可以帮助你避免它们,并有助于你理解 React 的工作原理以及如何以 React 的方式构建应用程序。对于每个问题,我们将看到一个示例,展示如何重现和解决它。
在本章中,我们将涵盖以下主题:
-
使用属性初始化状态
-
使用索引作为键
-
在 DOM 元素上展开属性
技术要求
要完成本章,你需要以下内容:
-
Node.js 19+
-
Visual Studio Code
你可以在本书的 GitHub 仓库中找到本章的代码:github.com/PacktPublishing/React-18-Design-Patterns-and-Best-Practices-Fourth-Edition/tree/main/Chapter07。
使用属性初始化状态
在本节中,我们将看到使用从父组件接收的属性初始化状态通常是一个反模式。我使用“通常”这个词,因为我们将会看到,一旦我们清楚这种方法的弊端,我们仍然可能决定使用它。
学习某样东西的最好方法之一是查看代码,所以我们将从一个带有+按钮的简单组件开始,用于增加计数器。
让我们创建一个名为Counter的函数组件,如下代码片段所示:
import { FC, useState } from 'react'
type Props = {
count: number
}
const Counter: FC<Props> = (props) => {}
export default Counter
现在,让我们设置我们的count状态:
const [state, setState] = useState<number>(props.count)
点击处理器的实现很简单 - 我们只是将1添加到当前的count值,并将结果值存储回状态:
const handleClick = () => {
setState({ count: state.count + 1 })
}
最后,我们渲染并描述输出,它由count状态的当前值和增加它的按钮组成:
return (
<div>
{state.count}
<button onClick={handleClick}>+</button>
</div>
)
现在,让我们渲染这个组件,将1作为count属性传递:
<Counter count={1} />
它按预期工作 - 每次点击+按钮都会增加当前值。那么问题是什么?
主要有两个错误,如下所述:
-
我们有一个重复的真实来源。
-
如果传递给组件的
count属性发生变化,状态不会更新。
如果我们使用 React DevTools 检查Counter元素,我们会注意到Props和State持有相似的价值:
<Counter>
Props
count: 1
State
count: 1
这使得在组件内部和向用户显示时,不清楚哪个是当前和可信的值。
更糟糕的是,点击+一次就会使值发散。以下代码展示了这种发散的例子:
<Counter>
Props
count: 1
State
count: 2
到目前为止,我们可以假设第二个值代表当前的计数,但这并不明确,可能会导致意外的行为或错误的值。
第二个问题集中在 React 如何创建和实例化类。组件的useState函数在组件创建时只被调用一次。
在我们的Counter组件中,我们读取count属性的值并将其存储在状态中。如果在应用程序的生命周期中该属性的值发生变化(比如说变成 10),Counter组件将永远不会使用新的值,因为它已经被初始化。这使组件处于不一致的状态,这并不理想且难以调试。
如果我们真的想使用属性的值来初始化组件,并且我们确信该值在将来不会改变呢?
在这种情况下,最好是使其明确,并给属性一个表明你意图的名称,例如initialCount。例如,让我们假设我们以以下方式更改Counter组件的属性声明:
type Props = {
initialCount: number
}
const Counter: FC<Props> = (props) => {
const [count, setState] = useState<Count>({ count: props.initialCount })
...
}
这种用法清楚地表明,父组件只能初始化计数器,任何随后的initialCount属性的值都将被忽略:
<Counter initialCount={1} />
在下一节中,我们将深入探讨键的概念。
使用索引作为键
在第十五章“提高应用程序的性能”中,我们讨论了性能和协调器,我们看到了如何通过使用key属性来帮助 React 找出更新 DOM 的最短路径。
key属性唯一标识 DOM 中的元素,React 使用它来检查元素是否是新元素,或者当组件属性或状态改变时是否必须更新。
使用键总是一个好主意,如果你不这样做,React 会在控制台(在开发模式下)发出警告。然而,这不仅仅是使用键的问题;有时,我们决定用作键的值可能会产生影响。实际上,使用错误的键在某些情况下可能会给我们带来意外的行为。在本节中,我们将看到这些情况之一。
让我们再次创建一个List组件,如下所示:
import { FC, useState } from 'react'
const List: FC = () => {
}
export default List
然后我们定义我们的状态:
const [items, setItems] = useState(['foo', 'bar'])
点击处理器的实现与之前略有不同,因为在这种情况下,我们需要在列表顶部插入一个新项目:
const handleClick = () => {
const newItems = items.slice()
newItems.unshift('baz')
setItems(newItems)
}
最后,在渲染中,我们显示列表和添加到列表顶部的baz项目的+按钮:
return (
<div>
<ul>
{items.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
<button onClick={handleClick}>+</button>
</div>
)
如果你将组件运行在浏览器中,你将看不到任何问题;点击+按钮会在列表顶部插入一个新项目。但让我们做一个实验。
让我们以以下方式更改渲染,在每个项目旁边添加一个输入字段。我们之所以使用输入字段,是因为我们可以编辑其内容,这使得找出问题更容易:
return (
<div>
<ul>
{items.map((item, index) => (
<li key={index}>
{item}
<input type="text" />
</li>
))}
</ul>
<button onClick={handleClick}>+</button>
</div>
)
如果我们在浏览器中再次运行此组件,复制输入字段中项目的值,然后点击 +,我们将得到意外的行为。
如以下截图所示,项目向下移动,而输入元素保持在相同的位置,这样它们的值就不再与项目的值匹配:

图 7.1:使用索引作为键
运行组件,点击 +,并检查控制台应该会给我们所有需要的答案。
我们可以看到,React 不是在顶部插入新元素,而是交换两个现有元素的文本,并将最后一个项目插入底部,仿佛它是新的。这样做的原因是我们正在使用 map 函数的索引作为键。
实际上,索引始终从 0 开始,即使我们将新项目推到列表的顶部,React 也会认为我们更改了现有两个项目的值,并在索引 2 处添加了一个新元素。这种行为与完全不使用 key 属性时的行为相同。
这是一个非常常见的模式,因为我们可能会认为提供任何键总是最好的解决方案,但事实并非如此。键必须是唯一的和稳定的,以标识一个、且仅标识一个项目。
要解决这个问题,例如,如果我们预期列表中不会重复该项的值,我们可以使用该项的值,或者创建一个唯一的标识符,例如:
{items.map((item, index) => (
<li key={`${item}-${index}`}>
{item}
<input type="text" />
</li>
))}
现在我们已经了解了在 React 中唯一和稳定的键的重要性,并探讨了解决这个常见问题的实际解决方案,让我们将注意力转向 React 开发中的另一种普遍做法。接下来的部分将专注于在 DOM 元素上分配属性,这是一种最近被 Dan Abramov 标记为反模式的技术。
在 DOM 元素上分配属性
最近,Dan Abramov 将这种常见的做法描述为反模式;在您的 React 应用程序中执行此操作时,它也会在控制台中触发警告。
这是一种在社区中广泛使用的技巧,我亲自在多个实际项目中看到过。我们通常将属性分配给元素,以避免手动编写每一个,如下所示:
<Component {...props} />
这方法非常有效,并且通过 Babel 转译成以下代码:
_jsx(Component, props)
然而,当我们向 DOM 元素分配属性时,我们面临添加未知 HTML 属性的风险,这是不良实践。
问题不仅与 Spread 操作符有关;逐个传递非标准属性也会导致相同的问题和警告。由于 Spread 操作符隐藏了我们正在分配的单个属性,因此更难弄清楚我们传递给元素的内容。
要在控制台中看到警告,我们可以执行的基本操作是渲染以下组件:
const Spread = () => <div foo="bar" />
我们收到的消息看起来如下,因为foo属性对于div元素不是有效的:
Unknown prop `foo` on <div> tag. Remove this prop from the element
在这种情况下,正如我们所说的,很容易找出我们传递了哪个属性并移除它,但如果我们使用Spread运算符,就像以下示例中那样,我们就无法控制从父组件传递哪些属性:
const Spread = props => <div {...props} />;
如果我们以以下方式使用组件,就没有问题:
<Spread className="foo" />
然而,如果我们做如下操作,情况就不同了。React 会抱怨因为我们正在将非标准属性应用于 DOM 元素:
<Spread foo="bar" className="baz" />
我们可以使用创建一个名为domProps的属性来解决这个问题的解决方案,我们可以安全地将其传播到组件中,因为我们明确表示它包含有效的 DOM 属性。
例如,我们可以这样改变Spread组件:
const Spread = props => <div {...props.domProps} />
然后,我们可以这样使用它:
<Spread foo="bar" domProps={{ className: 'baz' }} />
就像我们多次在 React 中看到的那样,明确总是好的实践。
摘要
了解所有最佳实践总是好事,但有时,了解反模式可以帮助我们避免走错路。最重要的是,了解为什么某些技术被认为是坏习惯有助于我们理解 React 是如何工作的,以及我们如何有效地使用它。
在本章中,我们介绍了四种可能损害我们 Web 应用程序性能和行为的使用组件的不同方式。
对于每一个,我们使用示例来重现问题,并提供了需要应用以修复问题的更改。
我们学习了为什么使用属性初始化状态可能导致状态和属性之间不一致。我们还看到了使用错误的key属性如何对协调算法产生不良影响。最后,我们学习了为什么将非标准属性传播到 DOM 元素被认为是反模式。
在下一章中,我们将探讨新的 React Hooks。
第八章:React Hooks
React Hooks 已经彻底改变了我们编写 React 应用程序的方式,允许我们使用函数组件而不是类组件,使编码更快、更高效。自从它们在 React 16.8 中引入以来,Hooks 已经成为 React 开发的一个基本组成部分,并且大大提高了我们应用程序的性能。使用 Hooks,我们可以以更简洁、更易读的方式管理状态、处理副作用和重用代码。在下一章中,我们将探讨不同类型的 Hooks 以及如何使用它们来增强我们的 React 应用程序。
在本章中,我们将涵盖以下主题:
-
新的 React Hooks 及其使用方法
-
Hooks 的规则
-
如何将类组件迁移到 React Hooks
-
使用 Hooks 和 effects 理解组件生命周期
-
如何使用 Hooks 获取数据
-
如何使用
memo、useMemo和useCallback来缓存组件、值和函数 -
如何实现
useReducer
技术要求
要完成本章,你需要以下内容:
-
Node.js 19+
-
Visual Studio Code
你可以在本书的 GitHub 仓库中找到本章的代码:github.com/PacktPublishing/React-18-Design-Patterns-and-Best-Practices-Fourth-Edition/tree/main/Chapter08。
介绍 React Hooks
React Hooks 是 React 16.8 的新增功能。它们允许你使用状态和其他 React 功能,而无需编写 React 类组件。React Hooks 也是向后兼容的,这意味着它们不包含任何破坏性变更,也不会取代你对 React 概念的了解。在本章的整个过程中,我们将为经验丰富的 React 用户概述 Hooks,同时我们还将学习一些最常用的 React Hooks,例如 useState、useEffect、useMemo、useCallback 和 memo。
没有破坏性变更
在 React 开发的背景下,一个常见的误解是 React Hooks 的引入使得类组件变得过时。然而,这并不正确,因为 React 没有计划从 React 中移除类。Hooks API 并没有取代你对 React 概念的理解,而是提供了一个更流畅的方式来处理这些概念,例如 props、states、context、refs 和生命周期,这些你已经是熟悉的。
使用 State Hook
在旧的 React 代码中,我们使用 this.setState 来使用组件状态。现在我们将使用 useState Hook 来完成这项工作。
首先,你需要从 React 中提取 useState Hook:
import { useState } from 'react'
自 React 17 以来,不再需要 React 对象来渲染 JSX 代码。
然后,你需要通过定义状态和该特定状态的设置器来声明你想要使用的状态:
const Counter = () => {
const [counter, setCounter] = useState<number>(0)
}
如你所见,我们使用 setCounter 设置器声明了计数器状态,并指定我们只接受数字,最后,我们将初始值设置为零。
为了测试我们的状态,我们需要创建一个方法,该方法将由onClick事件触发:
type Operation = 'add' | 'substract'
const Counter = () => {
const [counter, setCounter] = useState<number>(0)
const handleCounter = (operation: Operation) => {
if (operation === 'add') {
return setCounter(counter + 1)
}
setCounter(counter - 1)
}
}
最后,我们可以渲染counter状态和一些按钮来增加或减少计数器状态:
return (
<p>
Counter: {counter} <br />
<button onClick={() => handleCounter('add')}>+ Add</button>
<button onClick={() => handleCounter('subtract')}>- Subtract</button>
</p>
)
如果你点击+ 添加按钮一次,你应该看到1个计数器:

图 8.1:计数器 1
如果你点击- 减少两次按钮,那么你应该看到-1个计数器:

图 8.2:计数器-1
如你所见,useState Hook 是 React 的一个变革者,使得在函数组件中处理状态变得非常容易。
在欣赏了useState Hook 如何彻底改变了 React 中函数组件的状态管理之后,我们现在准备深入了解 Hooks 的细微差别。下一节将讨论控制 React 应用程序中 Hooks 使用的必要Hooks 规则。
Hooks 规则
React Hooks 基本上是 JavaScript 函数,但你需要遵循两条规则才能使用它们。React 提供了一个 linter 插件来自动执行这些规则,你可以通过运行以下命令来安装:
npm install --save-dev eslint-plugin-react-hooks
让我们看看这两条规则。
规则 1:只从顶层调用 Hooks
为了确保 React Hooks 的正常工作,重要的是避免在循环、条件或嵌套函数中调用它们。相反,建议始终在 React 函数的最高级别使用 Hooks。这种做法确保每次组件渲染时 Hooks 的调用顺序相同,从而使 React 能够正确地保留多个useState和useEffect调用之间的 Hooks 状态。遵循此规则将帮助你用 React Hooks 编写更高效、更易于维护的代码。
规则 2:只从 React 函数中调用 Hooks
为了确保组件中所有有状态逻辑在源代码中清晰可见,避免从常规 JavaScript 函数中调用 Hooks。相反,在 React 函数组件或自定义 Hooks(我们将在下一节中学习)中使用 Hooks。通过遵循这种做法,你可以确保所有有状态逻辑都集中且易于理解。
在下一节中,我们将学习如何将类组件迁移到使用新的 React Hooks。
将类组件迁移到 React Hooks
让我们将当前使用类组件并使用一些生命周期方法的代码进行转换。在这个例子中,我们从 GitHub 仓库获取问题并列出它们。
对于这个例子,你需要安装axios来执行获取操作:
npm install axios
这是类组件版本:
import axios from 'axios'
import { Component } from 'react'
type Issue = {
number: number
title: string
state: string
}
type Props = {}
type State = { issues: Issue[] }
class Issues extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = {
issues: []
}
}
componentDidMount() {
axios.get('https://api.github.com/repos/ContentPI/ContentPI/issues')
.then((response: any) => {
this.setState({
issues: response.data
})
})
}
render() {
const { issues = [] } = this.state
return (
<>
<h1>ContentPI Issues</h1>
{issues.map((issue: Issue) => (
<p key={issue.title}>
<strong>#{issue.number}</strong>{' '}
<a
href={`https://github.com/ContentPI/ContentPI/issues/${issue.number}`}
target="_blank"
>
{issue.title}
</a>{' '}
{issue.state}
</p>
))}
</>
)
}
}
export default Issues
如果你渲染这个组件,你应该看到类似以下的内容:

图 8.3:ContentPI 问题
现在,让我们使用 React Hooks 将我们的代码转换成一个函数组件。我们需要做的第一件事是导入一些 React 函数和类型:
import { FC, useState, useEffect } from 'react'
import axios from 'axios'
现在我们可以移除之前创建的Props和State类型,只留下Issue类型:
type Issue = {
number: number
title: string
state: string
}
然后,你可以将类定义更改为使用函数组件:
const Issues: FC = () => {...}
FC 类型用于在 React 中定义 函数组件。如果你需要向组件传递一些属性,你可以这样传递:
type Props = {
propX: string
propY: number
propZ: boolean
}
const Issues: FC<Props> = () => {...}
接下来,我们需要用 useState Hook 来替换我们的构造函数和状态定义:
// The useState hook replace the this.setState method
const [issues, setIssues] = useState<Issue[]>([])
我们之前使用过名为 componentDidMount 的生命周期方法,它在组件挂载时执行,并且只运行一次。新的 React Hook,称为 useEffect,现在将使用不同的语法处理所有生命周期方法,但让我们现在看看我们如何在新的函数组件中实现与 componentDidMount 相同的 effect:
// When we use the useEffect hook with an empty array [] on the
// dependencies (second parameter)
// this represents the componentDidMount method (will be executed when the
// component is mounted).
useEffect(() => {
axios
.get('https://api.github.com/repos/ContentPI/ContentPI/issues')
.then((response: any) => {
// Here we update directly our issue state
setIssues(response.data)
})
}, [])
最后,我们只需渲染我们的 JSX 代码:
return (
<>
<h1>ContentPI Issues</h1>
{issues.map((issue: Issue) => (
<p key={issue.title}>
<strong>#{issue.number}</strong> {' '}
<a
href={`https://github.com/ContentPI/ContentPI/issues/${issue.number}`}
target="_blank">{issue.title}
</a> {' '}
{issue.state}
</p>
))}
</>
)
如你所见,新的 Hooks 有助于我们极大地简化代码,并且更有意义。此外,我们还减少了 10 行代码(类组件代码有 53 行,函数组件有 43 行)。
现在我们已经看到了新的 Hooks 在简化我们的代码和减少冗余方面的变革力量,让我们将注意力转向 React 中的另一个基础概念。在下一节中,我们将深入了解在类组件中使用的组件生命周期方法和创新 React effects 之间的区别。
理解 React 的 effects
在本节中,我们将学习我们在类组件中使用的组件生命周期方法和新的 React effects 之间的区别。即使你在其他地方读到它们是相同的,只是语法不同,这也是不正确的。
理解 useEffect
当你使用 useEffect 时,你需要 从 effects 的角度思考。如果你想使用 useEffect 来执行与 componentDidMount 相当的方法,你可以这样做:
useEffect(() => {
// Here you perform your side effect
}, [])
第一个参数是你想要执行的效果的回调函数,第二个参数是依赖项数组。如果你传递一个空数组 ([]) 到依赖项中,状态和属性将保留它们的原始初始值。
然而,重要的是要提到,尽管这是与 componentDidMount 最接近的等价物,但它并不具有相同的行为。与 componentDidMount 和 componentDidUpdate 不同,我们传递给 useEffect 的函数在布局和绘制之后、在延迟事件期间触发。这通常适用于许多常见的副作用,例如设置订阅和事件处理器,因为大多数类型的工作不应该阻止浏览器更新屏幕。
然而,并非所有 effects 都可以延迟。例如,如果你需要修改 文档对象模型 (DOM),你将看到一个闪烁。这就是为什么你必须在下一次绘制之前同步触发事件的原因。React 提供了一个名为 useLayoutEffect 的 Hook,它的工作方式与 useEffect 完全相同。
有条件地触发 effects
如果你需要有条件地触发一个影响(副作用),那么你应该将一个依赖项添加到依赖项数组中;否则,你将多次执行影响,这可能会导致无限循环。如果你传递一个依赖项数组,useEffect Hook 将仅在其中一个依赖项发生变化时运行:
useEffect(() => {
// When you pass an array of dependencies the useEffect hook will only
// run if one of the dependencies changes.
}, [dependencyA, dependencyB])
如果你理解了 React 类生命周期方法的工作原理,基本上,useEffect 的行为与 componentDidMount、componentDidUpdate 和 componentWillUnmount 结合在一起。
影响(副作用)非常重要,但让我们也探索一些其他重要的新 Hooks,包括 useCallback、useMemo 和 memo。
理解 useCallback、useMemo 和 memo
为了理解 useCallback、useMemo 和 memo 之间的区别,我们将通过待办事项列表的示例来进行。你可以通过使用 create-react-app 和 Typescript 作为模板来创建一个基本的应用程序:
npx create-react-app todo --template typescript
在那之后,你可以移除所有额外的文件(App.css、App.test.ts、index.css、logo.svg、reportWebVitals.ts 和 setupTests.ts)。你只需要保留 App.tsx 文件,它将包含以下代码:
import { FC, useState, useEffect, useMemo, useCallback, ChangeEvent } from 'react'
import List, { Todo } from './List'
const initialTodos: Todo[] = [
{ id: 1, task: 'Go shopping' },
{ id: 2, task: 'Pay the electricity bill'}
]
const App: FC = () => {
const [todoList, setTodoList] = useState<Todo[]>(initialTodos)
const [task, setTask] = useState<string>('')
useEffect(() => {
console.log('Rendering <App />')
})
const handleCreate = () => {
const newTodo = {
id: Date.now(),
task
}
// Pushing the new todo to the list
setTodoList([...todoList, newTodo])
// Resetting input value
setTask('')
}
return (
<>
<input
type="text"
value={task}
onChange={(e: ChangeEvent<HTMLInputElement>) => setTask(e.target.value)}
/>
<button onClick={handleCreate}>Create</button>
<List todoList={todoList} />
</>
)
}
export default App
基本上,我们定义了一些初始任务并创建了 todoList 状态,我们将将其传递给 List 组件。然后,你需要创建 List.tsx 文件,并使用以下代码:
import { FC, useEffect } from 'react'
import Task from './Task'
export type Todo = {
id: number
task: string
}
interface Props {
todoList: Todo[]
}
const List: FC<Props> = ({ todoList }) => {
useEffect(() => {
// This effect is executed every new render
console.log('Rendering <List />')
})
return (
<ul>
{todoList.map((todo: Todo) => (
<Task key={todo.id} id={todo.id} task={todo.task} />
))}
</ul>
)
}
export default List
如你所见,我们通过使用 Task 组件渲染 todoList 数组中的每个任务,并将任务作为属性传递。我还添加了一个 useEffect Hook 来查看我们执行了多少次渲染。
最后,我们使用以下代码创建我们的 Task.tsx 文件:
import { FC, useEffect } from 'react'
interface Props {
id: number
task: string
}
const Task: FC<Props> = ({ task }) => {
useEffect(() => {
console.log('Rendering <Task />', task)
})
return (
<li>{task}</li>
)
}
export default Task
这是我们应该如何看待待办事项列表的方式:
![图片 B18414_08_04.png]
图 8.4:待办事项列表
如你所见,当我们渲染我们的待办事项列表时,默认情况下,我们正在执行两个 Task 组件的渲染,一个用于 List,另一个用于 App 组件。
现在,如果我们尝试在输入中写入一个新的任务,我们可以看到,对于每个我们写的字母,我们都会再次看到所有的那些渲染:

图 8.5:在待办事项列表中搜索
如你所见,仅仅通过写入 Go,我们就有了两批新的渲染,因此我们可以确定这个组件的性能不佳,这就是 memo 可以帮助我们提高性能的地方。在接下来的章节中,我们将学习如何实现 memo、useMemo 和 useCallback 来记忆化组件、值和函数。
使用 memo 记忆化组件
memo 高阶组件(HOC)与 React 类中的 PureComponent 类似,因为它执行了属性的浅比较(意味着表面检查),所以如果我们尝试始终使用相同的属性渲染组件,组件将只渲染一次并且会进行记忆。唯一重新渲染组件的方法是当属性值发生变化时。
为了在输入时将我们的组件固定,避免多次渲染,我们需要将我们的组件包裹在memo 高阶组件中。
我们首先将修复的组件是List组件,你只需要在import memo和export default中包裹组件:
import { FC, useEffect, memo } from 'react'
...
export default memo(List)
然后,你需要对Task组件做同样的处理:
import { FC, useEffect, memo } from 'react'
...
export default memo(Task)
现在,当我们再次在输入中尝试编写Go时,让我们看看这次我们会得到多少次渲染:

图 8.6:评估我们的待办事项列表执行了多少次渲染
现在,我们第一次只得到第一批渲染,然后,当我们编写Go时,我们只得到App组件的两次更多渲染,这是完全可以接受的,因为我们要改变的任务状态(输入值)实际上是App组件的一部分。
此外,我们还可以看到当我们点击创建按钮创建新任务时,我们执行了多少次渲染:

图 8.7:提高性能
如果你看到,前 16 次渲染是Go to the doctor字符串的单词计数,然后,当你点击创建按钮时,你应该看到Task组件的一次渲染,List组件的一次渲染,以及App组件的一次渲染。正如你所见,我们大大提高了性能,我们只是执行了确切的渲染需求。
到目前为止,你可能认为正确的事情是始终将memo添加到我们的组件中,或者你可能认为,为什么 React 不默认为我们做这件事?
原因是性能,这意味着除非绝对必要,否则不建议将 memo 添加到所有我们的组件中;否则,浅比较和记忆化的过程将比不使用它时的性能更差。
在决定是否使用memo时,我有一个规则,这个规则很简单:尽量不要使用它。通常,当我们处理小型组件或基本逻辑时,我们不需要这个功能,除非你正在处理来自某些 API 的大量数据,你的组件需要执行大量渲染(通常是巨大的列表),或者当你注意到你的应用运行缓慢时。只有在那种情况下,我才会推荐使用memo。
使用useMemo缓存值
假设我们现在想在我们的待办事项列表中实现一个搜索功能。我们首先需要做的是在App组件中添加一个新的状态term:
const [term, setTerm] = useState('')
然后,我们需要创建一个名为handleSearch的函数:
const handleSearch = () => {
setTerm(task)
}
在返回之前,我们将创建filterTodoList,它将根据任务过滤待办事项,我们将在那里添加一个控制台来查看它被渲染了多少次:
const filteredTodoList = todoList.filter((todo: Todo) => {
console.log('Filtering...')
return todo.task.toLowerCase().includes(term.toLowerCase())
})
最后,我们需要在已经存在的创建按钮旁边添加一个新按钮:
<button onClick={handleSearch}>Search</button>
到目前为止,我建议你删除或注释掉List和Task组件中的console.log,这样我们就可以专注于过滤的性能:

图 8.8:审查过滤性能
当你再次运行应用程序时,你会看到过滤正在执行两次,然后是App组件,这里看起来一切正常,但问题是什么?
再次在输入中尝试写Go to the doctor,让我们看看你得到多少次Rendering…和Filtering…的计数:

图 8.9:过滤性能不佳
如你所见,对于你输入的每个字母,你将得到两次过滤调用和一次App渲染。你不需要是天才就能看出这是性能不佳;更不用说如果你正在处理一个大的数据数组,这会更糟,那么我们如何解决这个问题呢?
在这种情况下,useMemo Hook 是我们的英雄,基本上,我们需要将我们的过滤移动到useMemo内部,但首先,让我们看看语法:
const filteredTodoList = useMemo(() => SomeProcessHere, [])
useMemo Hook 将记忆化函数的结果(值)并有一些依赖项需要监听。让我们看看我们如何实现它:
const filteredTodoList = useMemo(() => todoList.filter((todo: Todo) => {
console.log('Filtering...')
return todo.task.toLowerCase().includes(term.toLowerCase())
}), [])
现在,如果你再次在输入中写些东西,你会看到过滤不会像之前那样一直执行:

图 8.10:提高过滤性能
这很好,但还有一个小问题。如果你尝试点击搜索按钮,它不会过滤,这是因为我们遗漏了依赖项。
实际上,如果你查看控制台警告,你会看到这个警告:

图 8.11:react-hooks/exhaustive-deps
你需要将term和todoList依赖项添加到数组中:
const filteredTodoList = useMemo(() => todoList.filter((todo: Todo) => {
console.log('Filtering...')
return todo.task.toLowerCase().includes(term.toLowerCase())
}), [term, todoList])
如果你输入Go并点击搜索按钮,它现在应该可以工作:

图 8.12:修复警告后的情况
在这里,我们必须使用与memo相同的规则;除非绝对必要,否则不要使用它。
使用useCallback记忆化函数定义
现在我们将添加一个删除任务功能来学习useCallback是如何工作的。我们首先需要做的是在我们的App组件中创建一个新的函数,称为handleDelete:
const handleDelete = (taskId: number) => {
const newTodoList = todoList.filter((todo: Todo) => todo.id !== taskId)
setTodoList(newTodoList)
}
然后你需要将这个函数作为 prop 传递给List组件:
<List todoList={filteredTodoList} handleDelete={handleDelete} />
然后,在我们的List组件中,你需要将 prop 添加到Props接口:
interface Props {
todoList: Todo[]
handleDelete: any
}
接下来,你需要从 props 中提取它,并将其传递到Task组件:
const List: FC<Props> = ({ todoList, handleDelete }) => {
useEffect(() => {
// This effect is executed every new render
console.log('Rendering <List />')
})
return (
<ul>
{todoList.map((todo: Todo) => (
<Task
key={todo.id}
id={todo.id}
task={todo.task}
handleDelete={handleDelete}
/>
))}
</ul>
)
}
在Task组件中,你需要创建一个按钮,该按钮将执行handleDelete onClick:
interface Props {
id: number
task: string
handleDelete: any
}
const Task: FC<Props> = ({ id, task, handleDelete }) => {
useEffect(() => {
console.log('Rendering <Task />', task)
})
return (
<li>{task} <button onClick={() => handleDelete(id)}>X</button></li>
)
}
到目前为止,我建议你从List和Task组件中删除或注释掉console.log,这样我们就可以专注于过滤的性能。现在你应该能看到任务旁边的X按钮:

图 8.13:让我们删除一个任务
如果您点击 Go shopping 的 X,您应该能够删除它:

图 8.14:删除任务
到目前为止,一切都很顺利,对吧?但同样,我们在这个实现中又遇到了一点小问题。如果您现在尝试在输入框中输入一些内容,比如 Go to the doctor,让我们看看会发生什么:

图 8.15:性能不佳
如您所见,我们再次对所有组件进行了 71 次渲染。此时,您可能正在想,如果我们已经实现了记忆 HOC 来记忆组件,会发生什么?但现在的问题是,我们的 handleDelete 函数被传递到两个组件中,从 App 到 List 和 Task,问题在于这个函数每次重新渲染时都会被重新生成,在这种情况下,每次我们写入某些内容时。那么我们如何解决这个问题呢?
在这种情况下,useCallback 钩子是英雄,其语法与 useMemo 非常相似,但主要区别在于,useCallback 不是像 useMemo 那样记忆函数的结果值,而是记忆函数定义:
const handleDelete = useCallback(() => SomeFunctionDefinition, [])
我们的 handleDelete 函数应该是这样的:
const handleDelete = useCallback((taskId: number) => {
const newTodoList = todoList.filter((todo: Todo) => todo.id !== taskId)
setTodoList(newTodoList)
}, [todoList])
现在,如果我们再次写下 Go to the doctor,它应该可以正常工作:

图 8.16:提高性能
现在,我们不再有 71 次渲染,而是只有 23 次,这是正常的,我们也能够删除任务:

图 8.17:删除任务
如您所见,useCallback 钩子帮助我们显著提高性能。在下一节中,您将学习如何在 useEffect 钩子中记忆作为参数传递的函数。
在效果中记忆作为参数传递的函数
有一种特殊情况,我们需要使用 useCallback 钩子,那就是当我们在一个 useEffect 钩子中传递一个函数作为参数时,例如在我们的 App 组件中。让我们创建一个新的 useEffect 块:
const printTodoList = () => {
console.log('Changing todoList')
}
useEffect(() => {
printTodoList()
}, [todoList])
在这种情况下,我们正在监听 todoList 状态的变化。如果您运行此代码并创建或删除一个任务,它将正常工作(记得先删除所有其他控制台输出):

图 8.18:更改待办事项列表
一切都正常,但让我们将 todoList 添加到控制台:
const printTodoList = () => {
console.log('Changing todoList', todoList)
}
如果您正在使用 Visual Studio Code,您将得到以下警告:

图 8.19:react-hooks/exhaustive-deps
实际上,它是在要求我们添加 printTodoList 函数到依赖项中:
useEffect(() => {
printTodoList()
}, [todoList, printTodoList])
但现在,在我们这样做之后,我们得到了另一个警告:

图 8.20:useCallback 警告
我们得到这个警告的原因是我们现在正在操作状态(控制台输出状态),这就是为什么我们需要在这个函数中添加一个 useCallback 钩子来解决这个问题:
const printTodoList = useCallback(() => {
console.log('Changing todoList', todoList)
}, [todoList])
现在,当我们删除一个任务时,我们可以看到 todoList 正确更新了:

图 8.21:更改待办事项列表数据
到目前为止,这对你来说可能信息量过大,所以让我们快速回顾一下:
-
memo:-
记忆一个组件
-
当属性改变时重新记忆
-
避免重新渲染
-
-
useMemo:-
记忆一个计算值
-
对于计算属性
-
对于重型过程
-
-
useCallback:-
记忆一个函数定义以避免在每次渲染时重新定义它
-
当一个函数作为效果参数传递时使用它
-
当一个函数作为属性传递给记忆组件时使用它
-
最后,别忘了黄金法则:除非绝对必要,否则不要使用它们。
在下一节,我们将学习如何使用新的 useReducer 钩子。
理解 useReducer 钩子
你可能有一些使用 Redux (react-redux) 与类组件的经验,如果是这样,那么你会理解 useReducer 的工作原理。概念基本上是相同的:动作、还原器、分发、存储和状态。尽管在一般情况下它们看起来非常相似,但它们有一些区别。主要区别是 react-redux 提供了中间件和包装器,如 thunk、sagas 以及更多,而 useReducer 只提供了你可以用来分发普通对象的 dispatch 方法。此外,useReducer 默认没有存储;相反,你可以使用 useContext 创建一个,但这只是重新发明轮子。
让我们创建一个基本的应用程序来理解 useReducer 的工作原理。你可以从创建一个新的 React 应用程序开始:
npx create-react-app reducer --template typescript
然后,像往常一样,你可以删除你的 src 文件夹中的所有文件,除了 App.tsx 和 index.tsx 以外,以启动一个全新的应用程序。
我们将创建一个基本的 Notes 应用程序,我们可以使用 useReducer 来 list、delete、create 或 update 我们笔记。你需要做的第一件事是将我们稍后创建的 Notes 组件导入到你的 App 组件中:
import Notes from './Notes'
function App() {
return (
<Notes />
)
}
export default App
现在,在我们的 Notes 组件中,你首先需要导入 useReducer 和 useState:
Import { useReducer, useState, ChangeEvent } from 'react'
然后,我们需要定义一些 TypeScript 类型,这些类型是我们需要用于我们的 Note 对象、Redux 动作和 动作类型:
type Note = {
id: number
note: string
}
type Action = {
type: string
payload?: any
}
type ActionTypes = {
ADD: 'ADD'
UPDATE: 'UPDATE'
DELETE: 'DELETE'
}
const actionType: ActionTypes = {
ADD: 'ADD',
DELETE: 'DELETE',
UPDATE: 'UPDATE'
}
然后,我们需要创建一些初始的 initialNotes(也称为 initialState)来模拟一些笔记:
const initialNotes: Note[] = [
{
id: 1,
note: 'Note 1'
},
{
id: 2,
note: 'Note 2'
}
]
如果你记得如何使用 reducers,那么这将会非常类似于我们使用 switch 语句处理 reducers 的方式,以便执行基本的操作,如 ADD、DELETE 和 UPDATE:
const reducer = (state: Note[], action: Action) => {
switch (action.type) {
case actionType.ADD:
return [...state, action.payload]
case actionType.DELETE:
return state.filter(note => note.id !== action.payload)
case actionType.UPDATE:
const updatedNote = action.payload
return state.map((n: Note) => n.id === updatedNote.id ? updatedNote : n)
default:
return state
}
}
最后,组件非常直接。基本上,你从useReducer Hook(类似于useState)获取笔记和分发方法,你需要传递reducer函数和initialNotes(initialState):
const Notes = () => {
const [notes, dispatch] = useReducer(reducer, initialNotes)
const [note, setNote] = useState<string>('')
...
}
然后,我们有一个handleSubmit函数,当我们输入一些内容时创建一个新的笔记。然后,我们按下Enter:
const handleSubmit = (e: ChangeEvent<HTMLInputElement>) => {
e.preventDefault()
const newNote = {
id: Date.now(),
note
}
dispatch({ type: actionType.ADD, payload: newNote })
}
最后,我们使用map渲染我们的Notes列表,我们还创建了两个按钮,一个用于delete,一个用于update,然后输入应该被包裹在一个<form>标签中:
return (
<div>
<h2>Notes</h2>
<ul>
{notes.map((n: Note) => (
<li key={n.id}>
{n.note} {' '}
<button onClick={() => dispatch({ type: actionType.DELETE, payload: n.id })}>
X
</button>
<button
onClick={() => dispatch({ type: actionType.UPDATE, payload: {...n, note} })}
>
Update
</button>
</li>
))}
</ul>
<form onSubmit={handleSubmit}>
<input
placeholder="New note"
value={note}
onChange={e => setNote(e.target.value)}
/>
</form>
</div>
)
export default Notes
如果你运行应用程序,你应该看到以下输出:

图 8.22:React DevTools
如您在React DevTools中看到的,Reducer对象包含了我们定义为我们初始状态的两个笔记。
现在,如果你在输入框中写下一些内容并按下Enter,你应该能够创建一个新的笔记:

图 8.23:创建新笔记
然后,如果你想删除一个笔记,你只需点击X按钮。让我们删除笔记 2:

图 8.24:删除笔记
最后,你可以在输入框中写下任何你想要的内容,如果你点击更新按钮,你将改变笔记的值:

图 8.25:更新笔记
好吧,对吧?如您所见,useReducer Hook 在分发方法、动作和 reducer 方面与redux几乎相同,但主要区别在于这仅限于组件及其子组件的上下文,所以如果你需要一个可以从整个应用程序访问的全局存储,那么你应该使用react-redux。
摘要
希望你喜欢阅读这一章,它充满了关于新 React Hooks 的非常棒的信息。到目前为止,你已经学习了新 React Hooks 的工作原理;如何使用 Hooks 获取数据;如何将类组件迁移到React Hooks;效果的工作原理,memo、useMemo和useCallback之间的区别;最后,你学习了useReducer Hook 的工作原理以及与react-redux相比的主要区别。这将帮助你提高 React 组件的性能。
在下一章中,我们将学习 React Router v6 以及如何在我们的项目中实现它。
第九章:React Router
React 是一个提供大量用于创建网络应用程序的有用构建块的库,但它并不包含你可能需要的所有功能 开箱即用。React 不提供的一个关键特性是路由,即处理 URL 并在单页应用程序中导航到不同的页面或视图的能力。为此,我们转向第三方库,而 React 最受欢迎的库是 React Router。
在本章中,我们将探索 React Router 并学习如何使用它来创建动态路由并在我们的 React 应用程序中处理导航。到本章结束时,你将很好地理解 React Router 的工作原理以及如何在你的项目中有效地使用它。
在本章中,我们将涵盖以下主题:
-
理解
react-router、react-router-dom和react-router-native包之间的区别 -
如何安装和配置 React Router
-
添加
<Routes>组件 -
为路由添加参数
-
React Router v6.4 和 React Router 加载器
技术要求
要完成本章,你需要以下内容:
-
Node.js 19+
-
Visual Studio Code
你可以在本书的 GitHub 仓库中找到本章的代码:github.com/PacktPublishing/React-18-Design-Patterns-and-Best-Practices-Fourth-Edition/tree/main/Chapter09。
安装和配置 React Router
使用 create-react-app 创建新的 React 应用程序后,你需要做的第一件事是安装 React Router v6.x,使用以下命令:
npm install react-router-dom @types/react-router-dom
你可能不明白为什么我们要安装 react-router-dom 而不是 react-router。React Router 包含了 react-router-dom 和 react-router-native 的所有常见组件。这意味着如果你使用 React 进行网页开发,你应该使用 react-router-dom,如果你使用 React Native,则需要使用 react-router-native。
react-router-dom 包最初是为了包含版本 4 而创建的,而 react-router 使用的是版本 3。react-router-dom v6 包在 react-router 上有一些改进。它们列在这里:
-
简化路由配置:React Router v6 引入了一种更直接的路线配置,消除了对
Switch和精确属性的需求。路线现在根据它们的定义顺序隐式优先排序。 -
嵌套路由:React Router v6 改进了对嵌套路由的支持。
Outlet组件用于渲染子路由,允许更直观和可维护的路线结构。 -
简化导航:在 v6 中,
useNavigate钩子取代了useHistory钩子,提供了一种更直接和声明性的导航方法。 -
路由相对链接和导航:随着 v6 中
useLinkProps和Link组件的引入,现在创建与当前路由相关的链接变得更加容易。这减少了硬编码完整路径的需求,并简化了路由管理。 -
简化路由守卫:React Router v6 引入了一种更简洁的方法来使用
useRoutes钩子和element属性进行路由守卫。它允许更容易和更易于维护的路由保护模式。
自从 React Router v6.4 以来,创建路由的方法略有变化,但它仍然支持“旧方法”。在我们的最后一节中,我们将将相同的示例转换为新方法。
创建我们的部分
让我们创建一些部分来测试一些基本路由。我们需要创建四个无状态组件(About、Contact、Home 和 Error404)并将它们命名为 index.tsx 在它们的目录中。
你可以在 src/components/Home.tsx 组件中添加以下内容:
const Home = () => (
<div className="Home">
<h1>Home</h1>
</div>
)
export default Home
src/components/About.tsx 组件可以创建如下:
const About = () => (
<div className="About">
<h1>About</h1>
</div>
)
export default About
以下创建 src/components/Contact.tsx 组件:
const Contact = () => (
<div className="Contact">
<h1>Contact</h1>
</div>
)
export default Contact
最后,src/components/Error404.tsx 组件的创建方式如下:
const Error404 = () => (
<div className="Error404">
<h1>Error404</h1>
</div>
)
export default Error404
在我们创建了所有功能组件之后,我们需要修改我们的 index.tsx 文件以导入我们将在下一步创建的路由文件:
// Dependencies
import { createRoot } from 'react-dom/client'
import { BrowserRouter as Router } from 'react-router-dom'
// Routes
import AppRoutes from './routes'
createRoot(document.getElementById('root') as HTMLElement).render(
<Router>
<AppRoutes />
</Router>
)
现在,我们需要创建 src/routes.tsx 文件,其中我们将渲染用户访问根路径(/)时的 Home 组件:
// Dependencies
import { Routes, Route } from 'react-router-dom'
// Components
import App from './App'
import Home from './components/Home'
const AppRoutes = () => (
<App>
<Routes>
<Route path="/" element={<Home />} />
</Routes>
</App>
)
export default AppRoutes
之后,我们需要修改我们的 App.tsx 文件以将路由组件作为子组件渲染:
import { FC, ReactNode } from 'react'
import './App.css'
type Props = {
children: ReactNode
}
const App: FC<Props> = ({ children }) => (
<div className="App">
{children}
</div>
)
export default App
如果你运行应用程序,你将在根目录(/)中看到 Home 组件:
![图片 B18414_09_01.png]
图 9.1:主页
现在,让我们在用户尝试访问任何其他路由时添加 Error404:
// Dependencies
import { Routes, Route } from 'react-router-dom'
// Components
import App from './App'
import Home from './components/Home'
import Error404 from './components/Error404'
const AppRoutes = () => (
<App>
<Routes>
<Route path="/" element={<Home />} />
<Route path="*" element={<Error404 />} />
</Routes>
</App>
)
export default AppRoutes
现在,如果你访问 /somefakeurl,你将能够看到 Error404 组件:
![图片 B18414_09_02.png]
图 9.2:错误 404 页面
现在,我们可以添加我们的其他组件(About 和 Contact):
// Dependencies
import { Routes, Route } from 'react-router-dom'
// Components
import App from './App'
import About from './components/About'
import Contact from './components/Contact'
import Home from './components/Home'
import Error404 from './components/Error404'
const AppRoutes = () => (
<App>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
<Route path="*" element={<Error404 />} />
</Routes>
</App>
)
export default AppRoutes
现在,你可以访问 /about:

图 9.3:关于页面
或者,你现在可以访问 /contact:
![图片 B18414_09_04.png]
图 9.4:联系页面
现在你已经实现了你的第一个路由,让我们在下一节中向路由添加一些参数。
向路由添加参数
到目前为止,你已经学会了如何使用 React Router 进行基本路由(单级路由)。接下来,我将向你展示如何向路由添加一些参数并将它们传递到你的组件中。
对于这个例子,我们将创建一个 Contacts 组件,在访问 /contacts 路由时显示联系人列表,但当用户访问 /contacts/:contactId 时,我们将显示联系信息(姓名、电话和电子邮件)。
我们需要做的第一件事是创建我们的 Contacts 组件。让我们使用以下骨架:const Contacts = () => (
<div className="Contacts">
<h1>Contacts</h1>
</div>
)
export default Contacts
让我们使用以下 CSS 样式:
.Contacts ul {
list-style: none;
margin: 0;
margin-bottom: 20px;
padding: 0;
}
.Contacts ul li {
padding: 10px;
}
.Contacts a {
color: #555;
text-decoration: none;
}
.Contacts a:hover {
color: #ccc;
text-decoration: none;
}
一旦您创建了Contacts组件,您需要将其导入到您的路由文件中:
import { Routes, Route } from 'react-router-dom'
import App from './App'
import About from './components/About'
import Contact from './components/Contact'
import Home from './components/Home'
import Error404 from './components/Error404'
import Contacts from './components/Contacts'
const AppRoutes = () => (
<App>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
<Route path="/contacts" element={<Contacts />} />
<Route path="*" element={<Error404 />} />
</Routes>
</App>
)
export default AppRoutes
现在,如果您访问/contacts URL,您将能够看到Contacts组件:

图 9.5:联系人页面
现在,由于Contacts组件已连接到 React Router,让我们将联系人作为列表渲染:
import { FC, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import './Contacts.css'
type Contact = {
id: number
name: string
email: string
phone: string
}
const data: Contact[] = [
{
id: 1,
name: 'Carlos Santana',
email: 'carlos.santana@dev.education',
phone: '415-307-3112'
},
{
id: 2,
name: 'John Smith',
email: 'john.smith@dev.education',
phone: '223-344-5122'
},
{
id: 3,
name: 'Alexis Nelson',
email: 'alexis.nelson@dev.education',
phone: '664-291-4477'
}
]
const Contacts: FC = () => {
const { contactId = 0 } = useParams()
// For now we are going to add our contacts to our
// local state, but normally this should come
// from some service.
const [contacts, setContacts] = useState<Contact[]>(data)
const renderContacts = () => (
<ul>
{contacts.map((contact: Contact, key) => (
<li key={contact.id}>
<Link to={`/contacts/${contact.id}`}>{contact.name}</Link>
</li>
))}
</ul>
)
return (
<div className="Contacts">
<h1>Contacts</h1>
{renderContacts()}
</div>
)
}
export default Contacts
如您所见,我们正在使用<Link>组件,它将生成一个指向/contacts/contact.id的<a>标签,这是因为我们将向我们的路由文件添加一个新的嵌套路由以匹配联系人的 ID:
const AppRoutes = () => (
<App>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
<Route path="/contacts" element={<Contacts />}>
<Route path=":contactId" element={<Contacts />} />
</Route>
<Route path="*" element={<Error404 />} />
</Routes>
</App>
)
React Router v6 有一个名为useParams的特殊钩子,它将为您提供访问contactId参数的权限:
import { FC, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import './Contacts.css'
const data = [
{
id: 1,
name: 'Carlos Santana',
email: 'carlos.santana@dev.education',
phone: '415-307-3112'
},
{
id: 2,
name: 'John Smith',
email: 'john.smith@dev.education',
phone: '223-344-5122'
},
{
id: 3,
name: 'Alexis Nelson',
email: 'alexis.nelson@dev.education',
phone: '664-291-4477'
}
]
type Contact = {
id: number
name: string
email: string
phone: string
}
const Contacts: FC<any> = () => {
const { contactId = 0 } = useParams()
console.log('contactId', contactId)
现在,我们将把联系人添加到我们的本地状态中,但通常这应该来自某个服务:
const [contacts, setContacts] = useState<Contact[]>(data)
默认情况下,我们的selectedNote是false:
let selectedContact: any = false
if (contactId > 0) {
如果contactId大于0,则我们从contacts数组中过滤它:
selectedContact = contacts.filter((contact) => contact.id === Number(contactId))[0]
}
const renderSingleContact = ({ name, email, phone }: Contact) => (
<>
<h2>{name}</h2>
<p>{email}</p>
<p>{phone}</p>
</>
)
const renderContacts = () => (
<ul>
{contacts.map((contact: Contact, key) => (
<li key={key}>
<Link to={`/contacts/${contact.id}`}>{contact.name}</Link>
</li>
))}
</ul>
)
return (
<div className="Contacts">
<h1>Contacts</h1>
{/* We render our selectedContact or all the contacts */}
{selectedContact ? renderSingleContact(selectedContact) : renderContacts()}
</div>
)
}
export default Contacts
如您所见,我们正在使用useParams接收contactId参数。
如果您再次运行应用程序,您应该会看到您的联系人如下所示:

图 9.6:显示联系人
如果您点击约翰·史密斯(其contactId为 2),您将看到联系信息:

图 9.7:显示特定联系人
在此之后,您可以在App组件中添加一个navbar以访问所有路由:
import { Link } from 'react-router-dom'
import './App.css'
const App = ({ children }) => (
<div className="App">
<ul className="menu">
<li><Link to="/">Home</Link></li>
<li><Link to="/about">About</Link></li>
<li><Link to="/contacts">Contacts</Link></li>
<li><Link to="/contact">Contact</Link></li>
</ul>
{children}
</div>
)
export default App
现在,让我们修改我们的App样式:
.App {
text-align: center;
}
.App ul.menu {
margin: 50px;
padding: 0;
list-style: none;
}
.App ul.menu li {
display: inline-block;
padding: 0 10px;
}
.App ul.menu li a {
color: #333;
text-decoration: none;
}
.App ul.menu li a:hover {
color: #ccc;
}
最后,您将看到类似这样的内容:

图 9.8:显示菜单
到本节结束时,您将知道如何将带参数的路由添加到您的应用程序中。这真是太棒了,对吧?
React Router v6.4
如本章开头所述,React Router v6.4引入了一种新的路由实现方式。
让我们重写我们的最后一个示例来探索差异。第一个差异是,我们不再使用之前使用的AppRoutes,而是现在将路由直接添加到我们的App.tsx文件中。让我们首先修改我们的main.tsx并删除AppRoutes:
import { createRoot } from 'react-dom/client'
import App from './App'
createRoot(document.getElementById('root') as HTMLElement).render(
<App />
)
现在,在我们的App.tsx文件中,我们需要从react-router-dom导入一些新函数,并加载每个 URL 将渲染的组件:
import { FC } from 'react'
import {
createBrowserRouter,
createRoutesFromElements,
Route,
Link,
Outlet,
RouterProvider
} from 'react-router-dom'
import About from './components/About'
import Home from './components/Home'
import Pokemons, { dataLoader } from './components/Pokemons'
import Error404 from './components/Error404'
import './App.css'
之后,我们需要通过使用createBrowserRouter和createRoutesFromElements函数来指定我们的路由:
const App: FC<any> = () => {
const router = createBrowserRouter(
createRoutesFromElements(
<Route path="/" element={<Root />}>
<Route index element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="*" element={<Error404 />} />
</Route>
)
)
}
如您所见,我们正在渲染<Root />组件,您可能想知道这个组件在哪里。<Root />组件的作用是容纳我们的导航菜单。此外,使用新的<Outlet />组件,我们可以指定我们想要渲染路由内容的位置。为了实现这一点,您需要在定义App组件(在顶部)之前创建<Root />组件:
const Root = () => (
<>
<ul className="menu">
<li><Link to="/">Home</Link></li>
<li><Link to="/about">About</Link></li>
<li><Link to="/pokemons">Pokemons</Link></li>
</ul>
<div>
<Outlet />
</div>
</>
)
第一条路由是我们的Home,这就是为什么我们需要使用索引属性。接下来,我们有about路由,我们指定路径为/about。最后,我们添加了一个星号,这将匹配我们还没有的其他页面,渲染一个404 错误页面。
在创建了Root组件并指定了路由之后,我们需要渲染RouterProvider并将创建的 router 作为参数传递:
return (
<div className="App">
<RouterProvider router={router} />
</div>
)
如果您一切操作正确,您应该能够看到主页和关于页面:

图 9.9:主页
如果您点击关于,您应该看到页面如下显示:

图 9.10:关于页面
在了解了 React Router v6.4 的变化之后,我们现在将探讨如何使用Pokemons页面作为我们的工作示例来实现新的加载器功能。
React Router 加载器
React Router 6.4 的主要变化之一是增加了加载器。这些加载器提供了一种更好的方式来获取数据,消除了在组件内部使用useEffect和fetch的常见模式。
如您在菜单中可能已经注意到的,我包括了Pokemons页面,但尚未指定路由。这样做的原因是我想用这个页面作为示例来演示如何使用新的 React Router 加载器。
首先,让我们通过使用Home组件作为模板来创建我们的Pokemons组件:
const Pokemons = () => (
<div className="Pokemons">
<h1>Pokemons</h1>
</div>
)
export default Pokemons
现在我们已经有了基本组件,我们需要创建一个异步的dataLoader函数。这个函数将负责获取数据:
export const dataLoader = async () => {
const response = await fetch('https://pokeapi.co/api/v2/pokemon?limit=151')
const data = await response.json()
return data.results
}
export default Pokemons
如您所见,我们在导出Pokemons组件作为默认值之前放置了dataLoader。一旦您创建了dataLoader,您需要在App.tsx文件中导入它并指定Pokemons的路由。请记住将dataLoader传递给加载器属性:
import Pokemons, { dataLoader } from './components/Pokemons'
...
const router = createBrowserRouter(
createRoutesFromElements(
<Route path="/" element={<Root />}>
<Route index element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/pokemons" element={<Pokemons />} loader={dataLoader} />
<Route path="*" element={<Error404 />} />
</Route>
)
)
在将dataLoader连接到路由后,我们现在可以渲染Pokemons的数据。为了检索数据,我们将使用新的useLoaderData钩子。此外,我们将使用useNavigation钩子来监控路由的状态,使我们能够确定数据是否仍在加载。以下是为Pokemons组件编写的完整代码:
import { useLoaderData, useNavigation } from 'react-router-dom'
const Pokemons = () => {
const pokemons: any = useLoaderData()
const navigation = useNavigation()
if (navigation.state === 'loading') {
return <h1>Loading...</h1>
}
const imgUrl = 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/'
return (
<div className="Home">
<h1>Pokemons</h1>
{pokemons.map((pokemon: any, index: number) => (
<div key={pokemon.name}>
<h2>{index + 1} {pokemon.name}</h2>
<img
src={`${imgUrl}/${pokemon.url.split('/').slice(-2, -1)}.png`}
alt={pokemon.name}
/>
<p>
<a href={pokemon.url} target="_blank" rel="noreferrer">
{pokemon.url}
</a>
</p>
</div>
))}
</div>
)
}
export const dataLoader = async () => {
const response = await fetch('https://pokeapi.co/api/v2/pokemon?limit=151')
const data = await response.json()
return data.results
}
export default Pokemons
让我们来测试我们的Pokemons页面。我们应该看到前 150 个宝可梦:

图 9.11:宝可梦页面
通过新功能如加载器,React Router v6.4 简化了 React 应用程序中的路由和数据获取。我们使用 dataLoader 函数创建了一个 宝可梦 页面,该函数从 API 异步获取数据。通过将此功能集成到我们的路由配置中并使用 React Router 的 useLoaderData 和 useNavigation 钩子,我们提供了一个更友好的用户界面。这些改进使得 React Router v6.4 现在更加健壮、高效和直观,使开发者能够以更少的努力创建更复杂、数据驱动的应用程序。
摘要
干得好!通过导航 React Router,你已经掌握了安装、配置和管理路由以及将参数纳入嵌套路由的基本技能。利用这些功能,你将能够使用 React Router 创建更动态和健壮的 Web 应用程序。此外,你还了解了 React Router v6.4 的前沿特性,特别是其创新地使用加载器。
我们即将开始本系列的下一章节,我们将探讨 React 18 中引入的令人兴奋的新功能。通过持续学习和应用,你将精通 React。
加入我们的 Discord 社区
加入我们的 Discord 空间,与作者和其他读者进行讨论:
packt.link/React18DesignPatterns4e

第十章:React 18 新特性
React 18,作为流行的 JavaScript 库的最新版本,引入了许多新特性和改进,旨在提高性能并增强开发者体验。作为不断发展的 React 生态系统的一部分,了解这些进步至关重要。在本章中,我们将简要概述 React 18 中最显著的新增功能,随后简要介绍 Node.js 19 的最新功能。
React 18 的新特性包括:
-
状态更新的自动批处理:React 18 自动将多个状态更新批处理为单个更新,从而提高了性能和更平滑的动画。这种自动批处理消除了手动批处理的需求。
-
并发渲染:此功能允许 React 优先渲染某些组件,从而实现更快的加载时间、更平滑的动画和更好的用户体验。
-
数据获取的 Suspense:Suspense 允许开发者挂起组件的渲染,直到所需数据加载完成,从而提供无缝的用户体验和改进的错误处理。
-
改进的错误处理:React 18 通过提供更多关于错误的信息,如错误发生的组件和代码位置,简化了错误处理,从而简化了调试过程。
-
新的组件类型:React 18 引入了两种新的组件类型—— portals 和具有副作用组件。Portals 允许在父组件之外渲染组件,而具有副作用组件允许在不使用单独函数的情况下执行副作用。
-
不支持 Internet Explorer 11:为了利用现代网络标准并提高性能,React 18 不再支持 Internet Explorer 11。开发者必须确保他们的用户使用现代、受支持的浏览器,如 Google Chrome、Mozilla Firefox、Apple Safari 或 Microsoft Edge。
我们将涵盖以下主要主题:
-
并发模式
-
自动批处理
-
服务器端的 Suspense
-
新的 API
-
新的 Hook
-
严格模式
-
Node.js 最新特性
并发模式
React 并发模式是 React 18 的一组新特性,通过允许 React 同时处理多个任务,从而实现更快的响应式用户界面。
在传统的 React 中,渲染过程是同步的,这意味着 React 在单次遍历中更新用户界面。这有时会导致性能问题,尤其是在渲染大型、复杂的应用程序或处理实时更新时。
并发模式允许 React 将渲染过程拆分为更小的独立工作单元,这些单元可以独立并行执行。这意味着 React 可以优先处理某些任务,例如更新用户界面,同时仍然允许其他任务在后台运行,例如处理用户输入或获取数据。
这里是 React 并发模式的几个关键特性:
-
时间分片:时间分片是一种技术,允许 React 将大量工作分解成小块,并优先处理最重要的任务。这可以帮助减少应用程序的感知延迟,使其感觉更响应。
-
Suspense:Suspense 是 React 中的一种新特性,允许开发者挂起组件的渲染,直到必要的数据被加载。这可以帮助提高应用程序的感知性能并提供更好的用户体验。
-
并发渲染:并发渲染是 React 中的一种新渲染模式,允许 React 更频繁地更新用户界面,从而实现更平滑的动画和过渡效果。
总体而言,React 并发模式是一组强大的新特性,可以帮助开发者创建更快、更响应式的用户界面。虽然这可能需要对现有代码进行一些调整,但采用并发模式可以帮助改善应用程序的用户体验,并在快速发展的数字领域中保持竞争力。以下是一个演示在 React 18 中使用时间分片和并发渲染的例子:
import React, { useState } from 'react'
function Counter() {
const [count, setCount] = useState(0)
function handleClick() {
setCount(count + 1)
}
return (
<button onClick={handleClick}>
{count}
</button>
)
}
function App() {
return (
<React.Suspense fallback={<div>Loading...</div>}>
<Counter />
</React.Suspense>
)
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />)
自动批处理
自动批处理是 React 18 中的一个新特性,通过自动将多个更新组合成一个渲染遍历来提高更新的性能。在传统的 React 中,用户界面的更新通常是同步处理的,这意味着每次更新都会触发一个新的渲染遍历。
这可能效率低下,尤其是在多个更新连续发生时。自动批处理通过将多个更新组合在一起并在单个渲染遍历中处理它们来解决这个问题。
下面是一个说明自动批处理如何工作的例子:
function MyComponent() {
const [count, setCount] = useState(0)
function handleClick() {
setCount(count + 1)
setCount(count + 1)
setCount(count + 1)
}
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>Increment</button>
</div>
)
}
在这个例子中,我们有一个名为 MyComponent 的组件,它使用 useState 钩子来管理一个 count 状态变量。当用户点击 Increment 按钮,我们会连续三次快速调用 setCount 函数,每次将计数增加 1。
在传统的 React 中,每次调用 setCount 都会触发一个新的渲染遍历,导致用户界面有三个单独的更新。然而,在 React 18 的自动批处理中,这些更新会自动组合在一起并在单个渲染遍历中处理。这可以实现显著的性能提升,尤其是在处理用户输入或实时更新时。
总体来说,自动批处理是 React 18 中的一个强大新特性,可以帮助提高应用程序的性能和响应速度。通过自动将多个更新组合在一起,React 可以优化渲染过程并减少不必要的渲染遍历,从而实现更快、更高效的界面更新。
过渡效果
React 18 引入了一个名为 transitions 的新特性,允许开发者在其应用程序中创建平滑、声明式的动画和过渡效果。
过渡建立在 React 的声明式编程模型现有功能之上,提供了一种简单直观的方式来动画化元素和组件。
下面是一个简单的例子,说明过渡是如何工作的:
import { useState } from 'react'
import { Transition } from 'react-transition-group'
function MyComponent() {
const [show, setShow] = useState(false)
function handleClick() {
setShow(!show)
}
return (
<div>
<button onClick={handleClick}>
{show ? 'Hide' : 'Show'}
</button>
<Transition in={show} timeout={300}>
{(state) => (
<div
style={{
transition: 'opacity 300ms ease-out',
opacity: state === 'entered' ? 1 : 0,
}}
>
{show && <p>Hello, world!</p>}
</div>
)}
</Transition>
</div>
)
}
在这个例子中,我们使用react-transition-group库中的Transition组件来动画化p元素的显示和消失。Transition组件接受一个in属性,该属性确定元素是否应该显示或隐藏,以及一个timeout属性,该属性指定过渡的持续时间(以毫秒为单位)。
在Transition组件内部,我们定义了一个函数,该函数接受一个状态参数并返回过渡元素的 内容。状态参数是一个字符串,表示过渡的当前状态,可以是entering、entered、exiting或exited之一。
在我们的例子中,我们使用状态参数来根据过渡的当前状态设置div元素的透明度。当状态是entered时,我们将透明度设置为1以使元素完全可见。当状态是exiting或exited时,我们将透明度设置为0以使元素平滑地淡出。
通过使用Transition组件和状态参数,我们可以创建一个平滑、声明式的动画,该动画能够响应应用程序状态的变化。这可以是一种强大的方式来创建引人入胜且动态的用户界面,使其感觉生动且响应迅速。
总体而言,过渡是 React 18 中的一个强大新特性,它允许开发者轻松地创建声明式动画和过渡。通过利用 React 的声明式编程模型,开发者可以用几行代码创建复杂的动画和过渡,使创建引人入胜且动态的用户界面变得比以往任何时候都更容易。
服务器上的 Suspense
React 18 通过 Suspense 引入了一些改进,这些改进允许开发者创建更高效和可扩展的服务器渲染应用程序。
在 React 18 之前,Suspense 主要用于客户端渲染来管理异步数据加载和代码拆分。然而,随着 React 18 的推出,Suspense 也可以在服务器上使用以优化服务器渲染组件的渲染。
下面是 Suspense 在服务器上工作的高级概述:
-
在服务器渲染组件的初始渲染过程中,任何 Suspense 边界都会被注册,并且它们的回退内容会被渲染而不是主内容。
-
当需要数据加载或代码拆分时,服务器可以返回一个包含 Suspense 边界回退内容的“占位符”HTML 响应。
-
一旦异步数据或代码加载完成,客户端可以使用实际内容来填充 Suspense 边界,用最终内容替换回退内容。
这种方法允许服务器避免渲染可能因数据加载或代码拆分而阻塞的组件树的开销。相反,服务器可以返回一个包含回退内容的简单 HTML 响应,客户端可以快速轻松地渲染它。这可以显著提高服务器端渲染应用程序的性能和可伸缩性。
下面是一个示例,说明如何在服务器上使用 Suspense:
import { Suspense } from 'react'
import { fetchUserData } from './api'
function MyComponent() {
const userData = fetchUserData();
return (
<div>
<p>Name: {userData.name}</p>
<Suspense fallback={<p>Loading...</p>}>
<UserProfile userId={userData.id} />
</Suspense>
</div>
)
}
在这个例子中,我们有一个 MyComponent 组件,它从 API 获取用户数据并将其与需要额外数据加载的 UserProfile 组件一起渲染。通过在 UserProfile 组件周围包裹 Suspense 边界,我们可以确保在额外数据加载完成之前显示回退内容。
在服务器端渲染时,服务器可以返回一个简单的 HTML 响应,包含 Suspense 边界的回退内容,允许客户端快速轻松地渲染回退内容。一旦数据加载完成,客户端可以使用实际内容来填充 Suspense 边界,用最终内容替换回退内容。
总体而言,React 18 中 Suspense 对 SSR 的改进有助于提高服务器端渲染应用程序的性能和可伸缩性,使用户能够创建快速响应的 Web 体验。
新 API
React 18 引入了一系列新的 API,这些 API 专注于增强用户界面,提高应用程序性能,并为开发者提供更好的体验。值得注意的是,重要的新增包括 createRoot、hydrateRoot 和 renderToPipeableStream。
createRoot
React 18 引入了一个名为 createRoot 的新 API,它提供了一种更简单、更明确的渲染 React 组件到 DOM 中的方法。
传统上,当将 React 应用程序渲染到 DOM 中时,你会使用 ReactDOM.render 方法来指定根元素和要渲染到其中的 React 组件。例如:
import React from 'react'
import ReactDOM from 'react-dom'
const App = () => {
return <div>Hello, world!</div>
}
ReactDOM.render(<App />, document.getElementById('root'))
使用 createRoot,你可以创建一个根元素,可以用来 渲染多个组件,而不是为每个组件指定根元素。下面是一个示例:
const App = () => {
return <div>Hello, world!</div>
}
const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(<App />)
在这个例子中,我们首先使用 createRoot 创建一个根元素,将我们想要渲染 React 应用程序的 DOM 元素传递给它。然后,我们在 root 元素上使用 render 方法来指定要渲染的 React 组件。
createRoot API 也支持并发模式,这允许 React 通过将大型更新拆分为更小的块来更高效、更响应式地更新 UI。要使用 createRoot 的并发模式,你可以传递一个 mode 选项:
const root = ReactDOM.createRoot(document.getElementById('root'), { mode: 'concurrent' })
root.render(<App />)
在这个例子中,我们传递了 mode 选项,其值为 'concurrent',表示我们希望在渲染 React 组件时使用并发模式。
总体而言,createRoot API 提供了一种更简单、更灵活的方式来将 React 组件渲染到 DOM 中,并支持 React 18 中引入的新功能,如并发模式和改进的带有 Suspense 的服务器端渲染。
hydrateRoot
hydrateRoot 是 React 18 中引入的另一个新 API,它与 createRoot 一起工作。
在传统的 React 渲染模型中,服务器会渲染一个静态的 HTML 文档并发送到客户端,客户端随后会创建一个新的 React 根并渲染应用在客户端。然而,随着 SSR 的出现,React 可以在服务器上渲染初始 HTML 并发送到客户端,客户端随后可以将 HTML “水合”成完全功能的 React 应用。
hydrateRoot 用于将服务器发送的初始 HTML 水合到 React 组件树的过程。它允许 React 重新使用服务器渲染的标记,从而使初始页面加载更快,客户端的工作量更少。
这里是一个如何使用 hydrateRoot 水合客户端初始 HTML 的示例:
import React from 'react'
import { createRoot, hydrateRoot } from 'react-dom'
const App = () => {
return <div>Hello, world!</div>
}
const root = createRoot(document.getElementById('root'))
if (root.isMounted()) {
hydrateRoot(document.getElementById('root'), <App />)
} else {
root.render(<App />)
}
在这个示例中,我们首先使用 createRoot 创建一个根元素,就像在之前的示例中做的那样。然后我们通过调用 root.isMounted() 检查根是否已经挂载。如果是,我们使用 hydrateRoot 来水合页面上现有的 HTML。如果不是,我们使用 root.render 以通常的方式渲染 React 组件。
注意,你需要确保服务器和客户端渲染相同的 HTML 结构,否则,水合可能会失败,你可能会在服务器渲染的标记和已水合的 React 组件树之间出现不匹配。为了避免这种情况,你可以使用 Suspense 组件来处理服务器和客户端的异步渲染和数据获取,并确保 HTML 结构保持一致。
renderToPipeableStream
renderToPipeableStream 是 React 18 中引入的另一个新 API,它允许你将 React 组件树渲染到 Node.js 流中。这在需要通过网络或文件发送渲染内容的服务器端渲染场景中非常有用。
这里是一个如何使用 renderToPipeableStream 将 React 组件渲染到流中的示例:
import React from 'react'
import { renderToPipeableStream } from 'react-dom/server'
import { createServer } from 'http'
const App = () => {
return <div>Hello, world!</div>
}
const server = createServer((req, res) => {
const stream = renderToPipeableStream(<App />)
stream.pipe(res)
})
server.listen(3000)
在这个示例中,我们首先创建一个简单的 React 组件,称为 App。然后我们使用 createServer 方法创建一个 Node.js HTTP 服务器。当对服务器发出请求时,我们使用 renderToPipeableStream 将 App 组件渲染到 Node.js 流中。然后我们使用 pipe 方法将流管道连接到响应对象。
注意,renderToPipeableStream 返回一个 Node.js 流,你可以将其管道连接到其他流或将内容写入文件。这允许你轻松生成服务器端渲染的内容,并通过网络发送或将其保存到磁盘,而无需在内存中缓冲整个 HTML。
还要注意,renderToPipeableStream 是异步的,因此它返回一个解析为流的 Promise。这意味着你可以使用 await 等待渲染完成后再发送响应。
总体来说,renderToPipeableStream 是 Node.js 环境中 SSR 的一个有用 API,可以帮助提高你服务器端渲染应用程序的性能和可扩展性。
新钩子
在 React 18 中,引入了一系列创新的钩子,这些钩子提供了管理 ID、过渡和优化性能的增强技术。这些钩子包括 useId、useTransition、useDeferredValue 和 useInsertionEffect。
useId
useId 是 React 18 中的一个新内置钩子,可用于生成唯一 ID。这在需要为 React 组件中的元素生成唯一标识符的场景中非常有用,例如在构建表单时。
下面是一个如何使用 useId 生成唯一 ID 的示例:
import { useId } from 'react'
const MyComponent = () => {
const id = useId()
return <div id={id}>Hello, world!</div>
}
在这个示例中,我们使用 useId 钩子生成一个唯一 ID,然后将其用作 <div> 元素的 id 属性。
useId 生成一个在每次渲染中保证不同的唯一 ID。它接受一个可选参数,可以用来指定生成 ID 的前缀,这对于以一致的方式命名元素非常有用。
下面是一个如何使用 prefix 参数指定生成 ID 的前缀的示例:
import { useId } from 'react'
const MyComponent = () => {
const id = useId('my-prefix')
return <div id={id}>Hello, world!</div>
}
在这个示例中,我们使用带有 'my-prefix' 前缀的 useId 钩子,它生成以字符串 'my-prefix' 开头的 ID。这可以用于以与你的应用程序命名约定一致的方式命名元素。
总体来说,useId 是 React 18 的一个有用补充,可以简化为 React 组件中的元素生成唯一标识符的过程。
虽然 React 18 中的 useId 钩子提供了独特的优势,但了解某些注意事项对于避免潜在问题至关重要。首先,不建议使用 useId 为列表中的元素生成键。首选的方法是直接从你的数据中派生键。其次,useId 钩子在服务器渲染期间需要服务器端和客户端组件树之间完全匹配。服务器和客户端渲染树之间的任何差异都可能导致 ID 不一致。
useTransition
useTransition 是 React 18 中的一个新内置钩子,允许你为你的应用程序添加平滑的过渡效果。它是新并发模式功能的一部分,旨在与 Suspense 一起使用,以创建数据获取的加载状态和回退。
下面是一个如何使用 useTransition 在数据获取时添加加载指示器的示例:
import React, { useState, useTransition } from 'react'
const MyComponent = () => {
const [data, setData] = useState(null)
const [startTransition, isPending] = useTransition({ timeoutMs: 3000 })
const handleClick = () => {
startTransition(() => {
const newData = fetchData()
setData(newData)
})
}
return (
<div>
{isPending && <LoadingSpinner />}
<button onClick={handleClick}>Fetch Data</button>
{data && <DataDisplay data={data} />}
</div>
)
}
在这个例子中,我们使用 useState 来存储获取的数据,并使用 useTransition 来处理数据获取时的加载状态。当点击 Fetch Data 按钮时,调用 startTransition 函数并传递一个回调函数,该回调函数获取数据并更新状态。从 useTransition 返回的 isPending 值用于条件性地渲染一个加载指示器。
useTransition 接收一个可选的配置对象,其中包含一个 timeoutMs 属性,该属性指定了在显示加载指示器之前,在挂起状态中可以花费的最大时间。如果数据在超时之前被获取,则不会显示加载指示器。
总体来说,useTransition 是 React 18 中一个强大的新特性,可以帮助你创建更平滑、更响应的应用程序,提供更好的用户体验。
useDeferredValue
useDeferredValue 是 React 18 中一个新的内置钩子,允许你将值的更新延迟到下一帧。当处理性能密集型操作,如动画时,这可能很有用。
下面是一个如何使用 useDeferredValue 来动画化组件的例子:
import { useState, useDeferredValue } from 'react'
function MyComponent() {
const [x, setX] = useState(0)
const deferredX = useDeferredValue(x, { timeoutMs: 100 })
function handleClick() {
setX(x => x + 100)
}
return (
<div style={{ transform: `translateX(${deferredX}px)` }} onClick={handleClick}>
Click me!
</div>
)
}
在这个例子中,我们使用 useState 来存储组件的当前位置,并使用 useDeferredValue 来延迟对位置的更新直到下一帧。当组件被点击时,使用 setX 更新位置。延迟的值用于使用 CSS 变换渲染组件,并添加过渡效果。
useDeferredValue 接收两个参数:要延迟的值和一个可选的配置对象。配置对象可以用来指定一个 timeoutMs 属性,该属性决定了延迟更新的最大时间。默认情况下,更新将延迟到下一帧。
注意,useDeferredValue 只能与 useTransition 钩子一起使用,它提供了必要的定时信息,以便将更新延迟到下一帧。
useInsertionEffect
useInsertionEffect 是现有 useEffect 钩子的一种变体,允许你在 DOM 节点被插入页面后执行操作。这对于与第三方库集成或执行需要 DOM 节点存在的操作非常有用。
下面是一个如何使用 useInsertionEffect 的例子:
import { useInsertionEffect } from 'react'
function MyComponent() {
useInsertionEffect(() => {
const canvas = document.createElement('canvas')
canvas.width = 300
canvas.height = 200
canvas.style.backgroundColor = 'red'
document.body.appendChild(canvas)
return () => {
document.body.removeChild(canvas)
}
}, [])
return (
<div>
<h1>Hello, world!</h1>
<p>This is my React component.</p>
</div>
)
}
在这个例子中,我们使用 useInsertionEffect 来创建一个新的 canvas 元素,并在组件挂载时将其添加到 DOM 中。钩子返回的清理函数在组件卸载时移除 canvas 元素。
注意,useInsertionEffect 的第二个参数是一个空数组。这是因为我们只想在组件挂载后执行一次插入操作。如果我们在这个数组中包含了任何依赖项,那么插入操作会在这些依赖项每次变化时执行。
严格模式
React 18 引入了一个名为Strict Mode的新功能,允许你为你的 React 应用程序选择更严格的检查和警告集。Strict Mode 的目标是在开发早期捕捉潜在的问题,并鼓励最佳实践,使你的代码更高效且更容易调试。
下面是一个如何使用 Strict Mode 的例子:
import React from 'react'
function MyComponent() {
return (
<React.StrictMode>
<div>
<h1>Hello, world!</h1>
<p>This is my React component.</p>
</div>
</React.StrictMode>
)
}
在这个例子中,我们用React.StrictMode组件包裹我们的组件树。这可以在开发期间启用一些额外的检查和警告,例如检测不安全的生命周期方法、识别潜在的副作用,以及突出潜在的性能问题。
Strict Mode 不会影响生产环境中应用程序的行为,并且仅在开发期间使用。一旦你确信你的代码没有 Strict Mode 突出显示的任何问题,你就可以从你的代码中移除React.StrictMode组件。
值得注意的是,虽然 Strict Mode 可以在开发早期捕捉潜在的问题很有用,但它不能替代彻底的测试和调试。在部署到生产之前,始终彻底测试你的代码,并使用像 React 内置调试工具这样的工具来识别和修复出现的任何问题。
Node.js 最新特性
在 Node 的最新版本(18 和 19)中,有一些相关的新特性;让我们看看这些版本中有什么新内容。
实验性 Fetch API
Node.js 18(也包括在版本 19 中)包括一个默认可用的实验性全局Fetch API。该 API 的实现灵感来源于node-fetch,它最初基于undici-fetch,并来自undici。API 的开发者旨在使其尽可能接近规范,但一些功能需要浏览器环境,因此被省略。
下面是一个调用宝可梦 API 的例子:
const getPokemons = async () => {
const response = await fetch('https://pokeapi.co/api/v2/pokemon')
if (response.ok) {
const pokemons = await response.json()
console.log(pokemons)
} else {
console.error(`${response.status} ${response.statusText}`)
}
}
getPokemons()
这个 Node.js 18(也包括在版本 19 中)的添加使得以下全局变量可用:fetch、FormData、Headers、Request和Response。用户可以通过指定--no-experimental-fetch命令行标志来禁用此 API。
实验性测试运行器模块
重要的是要注意,测试运行器模块仍然处于实验阶段。为了编写单元测试并生成Test Anything Protocol(TAP)格式的报告,我们可以导入node:test模块。在本节中,我们将提供一些示例来说明它是如何工作的。这种测试方法与广泛使用的 JavaScript 测试框架 Jest 有一些相似之处。
node:test模块简化了编写生成TAP格式报告的 JavaScript 测试的过程。要访问它,只需使用以下代码:
import test from 'node:test'
import assert from 'node:assert'
为了提供一个例子,这里是一个带有两个子测试的父测试演示:
import test from 'node:test'
import assert from 'node:assert'
test('Math tests', async (t) => {
await t.test('Multiply test', (t) => {
const n = 2 * 2
assert.equal(n, 4)
})
await t.test('Sum test', (t) => {
const n = 5 + 3
assert.equal(n, 8)
})
})
如果一切正常,你应该会看到类似以下的内容:

图 10.1:实验性测试运行器模块
实验性 node watch
Node --watch被引入作为nodemon的直接竞争对手,并且是一个用于监视任何内容的流行工具,尽管它主要用于 Node.js 项目。然而,通过下面提供的代码片段,你现在可以更轻松地使用它:
node --watch <file or directory to observe>
此代码将自动检测对指定文件或目录所做的任何更改,并根据需要重新启动服务器或脚本。此功能在 Node.js 19.0.0 和 18.11.0+版本中可用。
Node 18 现在是长期支持(LTS)
在 Node.js 19 发布之后,Node.js 18 于 2022 年 10 月 25 日成为LTS版本,代号为Hydrogen。这一过渡标志着 Node.js 18.x 的活跃开发阶段的结束。
当前 Node.js 18.x 版本已升级到Active LTS状态,并将保持这一状态直到 2023 年 10 月。之后,它将进入维护阶段,并继续接收必要的安全修复和更新,直到 2025 年 4 月底。
摘要
在 React 18 中,引入了一系列新的功能和改进,这些功能简化了高质量和交互式应用程序的开发。这包括自动批处理状态更新、并发渲染、包含用于数据获取的 Suspense、改进的错误处理以及新增组件类型。因此,开发者现在能够创建更响应和吸引人的用户界面。对于 React 开发者来说,考虑升级到 React 18 具有重大价值。此外,我们还探讨了 Node.js 18 和 19 的关键特性,这些特性对于增强我们的 Web 项目至关重要。
在下一章中,我们将学习如何通过使用 React Context API、React Suspense 和stale-while-revalidate(SWR)来正确处理数据。
第十一章:管理数据
在本章中,我们将探讨两个有益的工具:React Context API和React Suspense。Context API 简化了在整个应用程序中共享数据的过程,无需通过多层传递。另一方面,React Suspense 允许应用程序的特定部分在显示之前等待某些操作,从而实现更平滑的加载体验。
通过共同利用这些工具,我们可以增强数据管理并提高应用程序的整体性能。加入我们,深入了解 React 中数据的有效处理。
在本章中,我们将涵盖以下主题:
-
React Context API
-
如何使用
useContext消费上下文 -
如何使用 SWR(Stale-While-Revalidate)与 React Suspense
-
如何使用 Redux Toolkit
技术要求
为了完成本章,你需要以下内容:
-
Node.js 19+
-
Visual Studio Code
你可以在本书的 GitHub 仓库中找到本章的代码:github.com/PacktPublishing/React-18-Design-Patterns-and-Best-Practices-Fourth-Edition/tree/main/Chapter11。
介绍 React Context API
React Context API自从首次作为实验性功能引入以来已经走了很长的路。自 16.3.0 版本以来,它已被正式添加到 React 中,并成为许多开发者的游戏改变者。事实上,现在许多人正在使用新的 Context API 而不是 Redux。Context API 允许你在不向每个子组件传递 prop 的情况下在组件之间共享数据。
为了说明如何使用新的 Context API,让我们回顾一下第八章的例子,React Hooks,其中我们使用 React Hooks 获取 GitHub 问题,但这次我们使用 Context API。
创建我们的第一个上下文
你需要做的第一件事是创建问题上下文。为此,你可以在你的src文件夹中创建一个名为contexts的文件夹,在那里你将添加Issue.tsx文件。
然后,你需要从 React 和axios导入一些函数:
import { FC, createContext, useState, useEffect, ReactElement, useCallback } from 'react'
import axios from 'axios'
到目前为止,很明显你应该安装axios。如果你还没有安装,只需做以下操作:
npm install axios
npm install --save-dev @types/axios
接下来,我们需要声明我们的接口:
export type Issue = {
number: number
title: string
url: string
state: string
}
interface Issue_Context {
issues: Issue[]
url: string
}
interface Props {
url: string
}
在此之后,我们需要做的第一件事是使用createContext函数创建我们的上下文,并定义我们想要导出的值:
export const IssueContext = createContext<Issue_Context>({ issues: [], url: '' })
一旦我们有了IssueContext,我们需要创建一个组件,在那里我们可以接收 props,设置一些状态,并使用useEffect执行 fetch,然后我们在IssueContext.Provider中渲染,指定我们想要导出的上下文(值):
const IssueProvider: FC<Props> = ({ children, url }) => {
// State
const [issues, setIssues] = useState<Issue[]>([])
const fetchIssues = useCallback(async () => {
const response = await axios(url)
if (response) {
setIssues(response.data)
}
}, [url])
// Effects
useEffect(() => {
fetchIssues()
}, [fetchIssues])
const context = {
issues,
url
}
return <IssueContext.Provider value={context}>{children}</IssueContext.Provider>
}
export default IssueProvider
如你所知,每次你想在useEffect Hook 中使用一个函数时,你都需要用useCallback Hook 将你的函数包装起来。如果你想要使用 async/await,一个好的做法是将其放在一个单独的函数中,而不是直接在useEffect中。
一旦我们在 issues 状态中执行获取并获取数据,我们将添加我们想要导出为上下文的所有值,然后当渲染 IssueContext.Provider 时,我们将通过 value 属性传递上下文,最后,我们将渲染组件的子元素。
使用提供者包裹我们的组件
消费上下文的方式分为两部分。第一部分是你将你的应用程序包裹在你的上下文提供者中,因此这段代码可以添加到 App.tsx 中(通常,所有提供者都在父组件中定义)。
注意这里我们正在导入 IssueProvider 组件:
// Providers
import IssueProvider from '../contexts/Issue'
// Components
import Issues from './Issues'
const App = () => {
return (
<IssueProvider url="https://api.github.com/repos/ContentPI/ContentPI/issues">
<Issues />
</IssueProvider>
)
}
export default App;
如你所见,我们正在用 IssueProvider 包裹 Issues 组件。这意味着在 Issues 组件内部,我们可以消费我们的上下文并获取问题值。
许多人发现这个概念很令人困惑。如果你忘记将你的组件包裹在提供者中,你将无法在它们内部访问上下文。挑战之处在于你可能不会收到错误消息;相反,你将遇到未定义的数据,这使得很难定位问题。
现在我们已经理解了正确包裹组件以提供者的重要性,让我们探索如何在 Issues 组件中使用 useContext 钩子精确地消费我们的上下文。
使用 useContext 消费上下文
如果你已经在 App.tsx 中放置了 IssueProvider,现在你可以在你的 Issues 组件中使用 useContext 钩子来消费你的上下文。
注意这里我们正在导入 IssueContext 上下文(在 { } 之间):
// Dependencies
import { FC, useContext } from 'react'
// Contexts
import { IssueContext, Issue } from '../contexts/Issue'
const Issues: FC = () => {
// Here you consume your Context, and you can grab the issues value.
const { issues, url } = useContext(IssueContext)
return (
<>
<h1>ContentPI Issues from Context</h1>
{issues.map((issue: Issue) => (
<p key={`issue-${issue.number}`}>
<strong>#{issue.number}</strong> {' '}
<a href={`${url}/${issue.number}`}>{issue.title}</a> {' '}
{issue.state}
</p>
))}
</>
)
}
export default Issues
如果你一切操作正确,你应该能够看到问题列表:

图 11.1:来自上下文的 ContentPI 问题
当你想要将应用程序与数据分离并在其中执行所有获取操作时,Context API 非常有用。当然,Context API 有多种用途,也可以用于主题或传递函数;这完全取决于你的应用程序。
在下一节中,我们将学习如何使用 SWR 库实现 React Suspense。
使用 SWR 引入 React Suspense
React Suspense 在 React 16.6 版本中被引入。Suspense 允许你在满足某个条件之前暂停组件的渲染。你可以渲染一个加载组件或任何你想要的作为 Suspense 的后备内容。
目前,这个用法只有两种:
-
代码拆分:当你拆分应用程序并且用户想要访问它时正在等待下载应用程序的一个块。
-
数据获取:当你正在获取数据。
在这两种情况下,你可以渲染一个后备内容,这通常可以是一个加载指示器,一些加载文本,或者更好的是,一个占位符骨架。
介绍 SWR
Stale-While-Revalidate (SWR)是一个用于数据获取的 React 钩子;它是一个 HTTP 缓存失效策略。SWR 是一种策略,首先从缓存(过时)返回数据,然后发送获取请求(重新验证),最后返回最新的数据,并由 Vercel 公司开发,该公司创建了 Next.js。
构建 Pokedex!
我找不到比构建一个 宝可梦图鉴 更好的例子来解释 React Suspense 和 SWR。我们将使用一个公开的宝可梦 API (pokeapi.co):必须全部捕捉!
你需要做的第一件事是安装一些包:
npm install swr react-loading-skeleton styled-components
对于这个示例,你需要在 src/components/Pokemon 创建 Pokemon 目录。我们需要做的第一件事是为了使用 SWR 创建一个 fetcher 文件,我们将在这里执行我们的请求。
此文件应在 src/components/Pokemon/fetcher.ts 创建:
const fetcher = (url: string) => {
return fetch(url).then((response) => {
if (response.ok) {
return response.json()
}
return {
error: true
}
})
}
export default fetcher
如果你注意到,我们在响应不成功时返回一个带有 error 的对象。这是因为有时我们可以从 API 获取 404 错误,这可能导致应用程序崩溃。
一旦你创建了你的 fetcher,让我们修改 App.tsx 以配置 SWRConfig 并启用 Suspense:
import { SWRConfig } from 'swr'
import PokeContainer from './Pokemon/PokeContainer'
import fetcher from './Pokemon/fetcher'
import { StyledPokedex, StyledTitle } from './Pokemon/Pokemon.styled'
const App = () => {
return (
<>
<StyledTitle>Pokedex</StyledTitle>
<SWRConfig value={{ fetcher, suspense: true }}>
<StyledPokedex>
<PokeContainer />
</StyledPokedex>
</SWRConfig>
</>
)
}
export default App
如你所见,我们需要将 PokeContainer 组件包裹在 SWRConfig 中,以便能够获取数据。PokeContainer 组件将是我们的父组件,我们将在这里添加第一个 Suspense。此文件位于 src/components/Pokemon/PokeContainer.tsx:
import { FC, Suspense } from 'react'
import Pokedex from './Pokedex'
const PokeContainer: FC = () => {
return (
<Suspense fallback={<h2>Loading Pokedex...</h2>}>
<Pokedex />
</Suspense>
)
}
export default PokeContainer
如你所见,我们为第一个 Suspense 定义了一个回退,它只是 Loading Pokedex... 文本。你可以在那里渲染任何你想要的内容,React 组件或纯文本。然后,我们在 Suspense 内部放置了 Pokedex 组件。
现在我们来看看我们的 Pokedex 组件,我们将使用 useSWR 钩子来获取第一次数据:
import { FC, Suspense } from 'react'
import useSWR from 'swr'
import LoadingSkeleton from './LoadingSkeleton'
import Pokemon from './Pokemon'
import { StyledGrid } from './Pokemon.styled'
const Pokedex: FC = () => {
const { data: { results } } = useSWR('https://pokeapi.co/api/v2/pokemon?limit=150')
return (
<>
{results.map((pokemon: { name: string }) => (
<Suspense fallback={<StyledGrid><LoadingSkeleton /></StyledGrid>}>
<Pokemon key={pokemon.name} pokemonName={pokemon.name} />
</Suspense>
))}
</>
)
}
export default Pokedex
如你所见,我们正在获取前 150 个宝可梦,因为我是个老派的人,这些是第一代。现在,我不知道有多少宝可梦存在。此外,如果你注意到,我们正在获取数据中的 results 变量(这是 API 的实际响应)。然后我们将结果映射以渲染每个宝可梦,但我们为每个宝可梦添加了一个带有 <LoadingSkeleton /> 回退的 Suspense 组件(<StyledGrid /> 有一些 CSS 样式使其看起来更美观),最后,我们将 pokemonName 传递给 <Pokemon> 组件,这是因为第一次获取只带来了宝可梦的名字,但我们需要进行另一次获取以带来实际的宝可梦数据(名称、类型、力量等)。
然后,最后,我们的宝可梦组件将通过宝可梦名称执行特定的获取,并将数据渲染出来:
import { FC } from 'react'
import useSWR from 'swr'
import { StyledCard, StyledTypes, StyledType, StyledHeader } from './Pokemon.styled'
type Props = {
pokemonName: string
}
const Pokemon: FC<Props> = ({ pokemonName }) => {
const { data, error } = useSWR(`https://pokeapi.co/api/v2/pokemon/${pokemonName}`)
// Do you remember the error we set on the fetcher?
if (error || data.error) {
return <div />
}
if (!data) {
return <div>Loading...</div>
}
const { id, name, sprites, types } = data
const pokemonTypes = types.map((pokemonType: any) => pokemonType.type.name)
return (
<StyledCard pokemonType={pokemonTypes[0]}>
<StyledHeader>
<h2>{name}</h2>
<div>#{id}</div>
</StyledHeader>
<img alt={name} src={sprites.front_default} />
<StyledTypes>
{pokemonTypes.map((pokemonType: string) => (
<StyledType key={pokemonType}>{pokemonType}</StyledType>
))}
</StyledTypes>
</StyledCard>
)
}
export default Pokemon
基本上,在这个组件中,我们将所有宝可梦数据(ID、名称、精灵和类型)组合在一起,并渲染信息。正如你所看到的,我正在使用样式组件,它们非常棒,所以如果你想了解我用于 Pokedex 的样式,这里就是 Pokemon.styled.ts 文件:
import styled from 'styled-components'
// Type colors
const type: any = {
bug: '#2ADAB1',
dark: '#636363',
dragon: '#E9B057',
electric: '#ffeb5b',
fairy: '#ffdbdb',
fighting: '#90a4b5',
fire: '#F7786B',
flying: '#E8DCB3',
ghost: '#755097',
grass: '#2ADAB1',
ground: '#dbd3a2',
ice: '#C8DDEA',
normal: '#ccc',
poison: '#cc89ff',
psychic: '#705548',
rock: '#b7b7b7',
steel: '#999',
water: '#58ABF6'
}
export const StyledPokedex = styled.div`
display: flex;
flex-wrap: wrap;
flex-flow: row wrap;
margin: 0 auto;
width: 90%;
&::after {
content: '';
flex: auto;
}
`
type Props = {
pokemonType: string
}
export const StyledCard = styled.div<Props>`
position: relative;
${({ pokemonType }) => `
background: ${type[pokemonType]} url(./pokeball.png) no-repeat;
background-size: 65%;
background-position: center;
`}
color: #000;
font-size: 13px;
border-radius: 20px;
margin: 5px;
width: 200px;
img {
margin-left: auto;
margin-right: auto;
display: block;
}
`
export const StyledTypes = styled.div`
display: flex;
margin-left: 6px;
margin-bottom: 8px;
`
export const StyledType = styled.span`
display: inline-block;
background-color: black;
border-radius: 20px;
font-weight: bold;
padding: 6px;
color: white;
margin-right: 3px;
opacity: 0.4;
text-transform: capitalize;
`
export const StyledHeader = styled.div`
display: flex;
justify-content: space-between;
width: 90%;
h2 {
margin-left: 10px;
margin-top: 5px;
color: white;
text-transform: capitalize;
}
div {
color: white;
font-size: 20px;
font-weight: bold;
margin-top: 5px;
}
`
export const StyledTitle = styled.h1`
text-align: center;
`
export const StyledGrid = styled.div`
display: flex;
flex-wrap: wrap;
flex-flow: row wrap;
div {
margin-right: 5px;
margin-bottom: 5px;
}
`
最后,我们的LoadingSkeleton组件应该像这样:
import { FC } from 'react'
import Skeleton from 'react-loading-skeleton'
const LoadingSkeleton: FC = () => (
<div>
<Skeleton height={200} width={200} />
</div>
)
export default LoadingSkeleton
这个库非常棒。它让你可以创建骨架占位符来等待数据。当然,你可以构建你想要的任何数量的表单。你可能已经在 LinkedIn 或 YouTube 等网站上看到过这种效果。
测试 React Suspense
一旦所有的代码片段都开始工作,你可以使用一个技巧来查看所有的 Suspense 回退。通常,如果你有高速连接,很难看到它,但你可以通过在 Chrome 检查器的网络标签页中选择慢速 3G来降低你的连接速度,看看一切是如何渲染的:

图 11.2:慢速 3G 连接
一旦你设置了慢速 3G预设并运行你的项目,你将看到的第一个回退是正在加载宝可梦...:

图 11.3:加载宝可梦
然后,你会看到宝可梦的回退,每个正在加载的宝可梦都会渲染SkeletonLoading:

图 11.4:SkeletonLoading
通常这些加载器有动画,但在这本书中当然不会看到!然后你将开始看到数据是如何渲染的,一些图片开始出现:

图 11.5:加载宝可梦图鉴
如果你等待所有数据正确下载,你现在应该能看到包含所有宝可梦的宝可梦图鉴:

图 11.6:显示整个宝可梦图鉴
真的很不错,对吧?但还有其他要说的;正如我之前提到的,SWR 会首先从缓存中获取数据,然后会持续验证数据,以查看是否有新的更新。这意味着每次数据发生变化时,SWR 都会执行另一个获取操作来重新验证旧数据是否仍然有效或需要用新数据替换。
即使你从宝可梦标签页移到另一个标签页然后再返回,你也能看到这个效果。你会发现你的网络终端,第一次,看起来是这样的:

图 11.7:请求
正如你所见,我们执行了 151 个初始请求(1 个用于宝可梦列表和 150 个其他请求,每个宝可梦 1 个),但如果你切换标签页然后返回,你会看到 SWR 再次进行获取:

图 11.8:151 个请求
现在你可以看到它正在执行 302 个请求(另外 151 个)。当你需要每秒或每分钟获取实时数据时,这非常有用。
目前,React Suspense 缺乏一个定义的使用模式,这意味着有各种方法可以利用它,而且还没有建立最佳实践。我发现 SWR 是处理 React Suspense 最容易、最易懂的方法。我相信这是一个功能强大的库,即使不需要 Suspense 也可以有效地使用。
在探索了 React Suspense 的灵活性之后,让我们将注意力转向 React 生态系统中的另一个强大工具:Redux Toolkit。这个工具正在改变 Redux 的方法,在接下来的章节中,我们将深入探讨其基本特性和通过实际代码示例展示其应用。
Redux Toolkit:Redux 的现代方法
Redux Toolkit 是官方的、有偏见的、包含所有电池的工具集,用于高效的 Redux 开发。它是为了帮助开发者用更少的样板代码编写更好、更高效的 Redux 代码而创建的。在本节中,我们将探讨 Redux Toolkit 的关键特性,并伴随代码示例来展示如何在您的应用程序中使用它。
关键特性
Redux Toolkit 包含几个关键特性,简化了 Redux 开发过程:
-
configureStore:一个设置具有合理默认值的 Redux 存储的函数。
-
createSlice:一个基于提供的配置自动生成动作创建器和还原器的函数。
-
createAction:一个创建具有特定类型和有效负载的动作创建器的实用函数。
-
createReducer:一个简化还原器创建的实用函数,使用 Immer,允许直接状态操作。
入门
首先,安装 Redux Toolkit 和其依赖项:
npm install @reduxjs/toolkit react-redux typescript @types/react @types/react-redux @types/react-dom
创建存储
要创建存储,我们将使用 Redux Toolkit 提供的 configureStore 函数(store.ts):
import { configureStore } from '@reduxjs/toolkit'
import rootReducer from './rootReducer'
const store = configureStore({
reducer: rootReducer
})
export type RootState = ReturnType<typeof rootReducer>
export default store
创建一个切片
一个切片代表 Redux 存储中与特定功能或领域相对应的部分。要创建一个切片,请使用 createSlice 函数(createSlice.ts):
import { createSlice } from '@reduxjs/toolkit'
const counterSlice = createSlice({
name: 'counter',
initialState: 0,
reducers: {
increment: (state) => state + 1,
decrement: (state) => state – 1
}
})
export const { increment, decrement } = counterSlice.actions
export default counterSlice.reducer
合并还原器
如果您有多个切片,您可以使用 Redux Toolkit 中的 combineReducers 函数来创建一个根还原器(rootReducer.ts):
import { combineReducers } from '@reduxjs/toolkit'
import counterReducer from './counterSlice'
const rootReducer = combineReducers({
counter: counterReducer
})
export default rootReducer
将组件连接到存储
要将 React 组件连接到 Redux 存储,请使用来自 react-redux 包的 useSelector 和 useDispatch 钩子(Counter.ts):
import { useSelector, useDispatch } from 'react-redux'
import { increment, decrement } from './counterSlice'
import { RootState } from './store'
function Counter() {
const count = useSelector((state: RootState) => state.counter)
const dispatch = useDispatch()
return (
<div>
<button onClick={() => dispatch(decrement())}>-</button>
<span>{count}</span>
<button onClick={() => dispatch(increment())}>+</button>
</div>
)
}
export default Counter
将存储与 React 应用程序集成
最后,使用来自 react-redux 的 Provider 组件包裹您的 React 应用程序,并将您的存储作为属性传递:
import React from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import store from './store'
import Counter from './Counter'
createRoot(document.getElementById('root') as HTMLElement).render(
<Provider store={store}>
<Counter />
</Provider>
)
在本节中,我们探讨了 Redux Toolkit 的关键特性,包括 configureStore、createSlice、createAction 和 createReducer。通过利用这些特性,开发者可以用更少的样板代码编写更高效、更易于维护的 Redux 代码。提供的代码示例演示了如何使用 Redux Toolkit 创建一个简单的计数器应用程序,说明了设置存储、创建切片和还原器以及将组件连接到存储所需的步骤。通过利用 Redux Toolkit,您可以简化 Redux 开发过程并构建更健壮的应用程序。
概述
我真心希望你喜欢阅读这一章,其中包含了大量关于 React Context API 以及如何使用 SWR 实现 React Suspense 的信息。我们涵盖了 Context API 的基础知识,包括如何创建和消费上下文,以及如何使用 useContext 钩子进行更简单的消费。
此外,我们还探讨了 React Suspense 以及它如何通过允许我们更有效地处理加载状态来提升用户体验。我们还学习了 SWR 以及它是如何通过 React Suspense 简化数据获取和缓存的。最后,我们学习了如何实现新的 Redux Toolkit。通过利用这些强大的工具,你可以构建更高效、更友好的 React 应用程序。
在下一章中,我们将学习如何在 React 中使用 Server-Side Rendering 和 Next.js。
第十二章:服务器端渲染
在构建 React 应用程序的下一步是学习服务器端渲染(SSR)是如何工作的以及它能够给我们带来什么好处。通过实现 SSR,我们可以创建对搜索引擎优化(SEO)更有利的通用应用,并使前端和后端之间的知识共享成为可能。它们还可以提高网络应用的感知速度,这通常会导致转化率的提高。然而,将 SSR 应用于 React 应用程序是有代价的,我们应该仔细考虑我们是否真的需要它。
在本章中,您将了解如何设置服务器端渲染应用,并在相关章节的末尾,您将能够构建一个通用应用并理解该技术的优缺点。
在本章中,我们将涵盖以下主题:
-
理解通用应用是什么
-
找出我们为什么可能想要启用 SSR 的原因
-
使用 React 创建一个简单的静态服务器端渲染应用
-
在服务器端渲染中添加数据获取,并理解脱水和再水合等概念
-
使用 Zeit 的 Next.js 轻松创建一个在服务器和客户端上运行的反应式应用
技术要求
为了完成本章,您需要以下内容:
-
Node.js 19+
-
Visual Studio Code
您可以在本书的 GitHub 仓库中找到本章的代码:github.com/PacktPublishing/React-18-Design-Patterns-and-Best-Practices-Fouth-Edition/tree/main/Chapter12。
理解通用应用
通用应用是一种可以在服务器端和客户端以相同代码运行的应用。在本节中,我们将探讨为什么我们应该考虑使我们的应用通用,以及我们将学习 React 组件如何轻松地在服务器端渲染。
当我们谈论 JavaScript 网络应用时,我们通常想到的是存在于浏览器中的客户端代码。它们通常的工作方式是服务器返回一个带有 script 标签的空 HTML 页面,用于加载应用。当应用准备就绪时,它会在浏览器内部操作 DOM 来显示 UI 并与用户交互。这种情况在过去几年中一直如此,并且对于大量应用来说,这仍然是一种可行的方式。
在这本书中,我们已经看到使用 React 组件创建应用是多么容易,以及它们在浏览器中的工作方式。我们还没有看到的是 React 如何在服务器上渲染相同的组件,这为我们提供了一个强大的功能,称为 SSR。
在深入细节之前,让我们尝试理解创建既在服务器端又在客户端渲染的应用程序意味着什么。多年来,我们通常为服务器和客户端使用完全不同的应用程序:例如,使用 Django 应用程序在服务器上渲染视图,以及在客户端使用一些 JavaScript 框架,如 Backbone 或 jQuery。这些单独的应用程序通常需要由两个不同技能集的开发团队维护。如果你需要在服务器端渲染的页面和客户端应用程序之间共享数据,你可以在一个脚本标签中注入一些变量。使用两种不同的语言和平台,无法在应用程序的不同部分之间共享常见信息,如模型或视图。
自从 Node.js 在 2009 年发布以来,JavaScript 由于像 Express 这样的网络应用程序框架,在服务器端也获得了大量的关注和流行。
在两边使用相同的语言不仅使开发者更容易重用他们的知识,而且还使服务器和客户端之间共享代码的不同方式成为可能。
尤其是使用 React,同构 Web 应用程序的概念在 JavaScript 社区中变得非常流行。编写一个 同构应用程序 意味着构建一个在服务器和客户端看起来都一样的应用程序。使用相同的语言编写这两个应用程序意味着逻辑的大部分可以共享,这开辟了许多可能性。这使得代码库更容易推理,并避免了不必要的重复。
React 将这一概念推进了一步,为我们提供了一个简单的 API,可以在服务器上渲染我们的组件,并且透明地应用所有使页面交互所需的逻辑(例如,事件处理器)在浏览器上。
术语 isomorphic 在这个场景中不适用,因为在这种情况下,React 的应用程序是相同的,这就是为什么 React Router 的一个创造者 Michael Jackson 提出了一个更有意义的名称:通用。
在深入探讨实现通用服务器端渲染的具体原因之前,让我们暂停一下,确保我们有一个坚实的理解,即何时以及为什么这个功能可能对我们应用程序来说是必要的。
实现 SSR 的原因
SSR 是一个很棒的功能,但我们不应该仅仅为了使用它而盲目地跳进去。我们应该有一个真实、明确的原因来开始使用它。
没有明确目的的 SSR 可能会引入不必要的复杂性和问题到你的应用程序中。SSR 的复杂性可能会使诸如状态管理、数据获取和路由等方面变得复杂。此外,SSR 会增加服务器的负载,因为它负责为每个请求渲染 HTML。如果不仔细优化,这可能会导致响应时间变慢和更高的服务器成本。
此外,SSR(服务器端渲染)给应用程序带来的额外复杂性可能会减缓开发过程,使调试复杂化,并需要维护特定的工具和配置。此外,如果你的应用程序没有大量的公共内容,SSR 通常推动其采用的 SEO 好处可能并不显著。
从本质上讲,虽然 SSR 可以提供好处,但关键是要清楚地了解其权衡。在决定采用 SSR 之前,仔细评估应用程序的需求,并权衡其优势与潜在的不利因素。
实施 SEO
我们可能想要在服务器端渲染应用程序的主要原因之一是 SEO。如果我们向主要搜索引擎的爬虫提供空的 HTML 骨架,它们将无法从中提取任何有意义的信息。如今,Google 似乎能够运行 JavaScript,但有一些限制,SEO 通常是我们的业务的关键方面。
多年来,我们通常编写两个应用程序:一个用于爬虫的 SSR 应用程序,另一个用于用户在客户端使用。我们之所以这样做,是因为 SSR 应用程序无法提供用户期望的交互性水平,而客户端应用程序则不会被搜索引擎索引。
维护和支持两个应用程序是困难的,这使得代码库变得不那么灵活,也不太容易改变。幸运的是,随着 React 的出现,我们可以在服务器端渲染我们的组件,并以一种易于爬虫理解和索引内容的方式向爬虫提供我们应用程序的内容。
这对于 SEO 和社交分享服务来说都是非常好的。例如,Facebook 或 Twitter 等平台为我们提供了一种定义当我们的页面被分享时显示的内容片段的方法。
例如,使用 Open Graph,我们可以告诉 Facebook,对于特定的页面,我们希望显示特定的图像,并使用特定的标题作为帖子的标题。仅使用客户端应用程序几乎不可能做到这一点,因为从页面中提取信息的引擎使用的是服务器返回的标记。
如果我们的服务器为所有 URL 返回空的 HTML 结构,结果是当页面在社交网络上分享时,我们的 Web 应用程序的片段也是空的,这影响了它们的传播性。
共享的代码库
在应用程序的客户端和服务器端使用 JavaScript 提供了许多好处。首先,它通过在所有组件中使用相同的语言来简化问题。这简化了维护一个良好运行的系统的过程,并促进了公司内部同事之间的知识共享。
此外,在网站的前端和后端之间共享代码消除了重复工作的需要。因此,这种方法通常减少了错误和问题的发生。
此外,维护单个代码库比处理两个独立的代码库更容易管理。此外,在服务器端集成 JavaScript 可以增强前端和后端开发者之间的协作。通过利用相同的语言,他们可以有效地重用代码并迅速做出决策,从而提高工作流程和生产力。
更好的性能
最后但同样重要的是,我们都喜欢客户端应用程序,因为它们快速且响应灵敏,但存在一个问题——用户在可以对应用程序采取任何行动之前,必须先加载和运行捆绑包。
这可能在使用现代笔记本电脑或具有快速互联网连接的台式计算机上不是问题。然而,如果我们使用 3G 连接的移动设备加载一个巨大的 JavaScript 捆绑包,用户在与应用程序交互之前必须等待一段时间。这不仅对用户体验总体上不利,还影响转化率。大型电子商务网站已经证明,页面加载时增加的几毫秒可能会对收入产生巨大影响。
例如,如果我们通过服务器提供我们的应用程序,一个空的 HTML 页面和一个脚本标签,并且我们向用户显示一个旋转器,直到他们可以点击任何东西,那么网站速度的感知将受到显著影响。
如果我们在服务器端渲染我们的网站,并且用户在点击页面后立即看到一些内容,那么他们更有可能留下来,即使他们必须等待相同的时间才能真正做任何事情,因为客户端捆绑包必须加载,无论是否使用 SSR。
这种感知的性能是我们可以通过 SSR(服务器端渲染)大幅提升的,因为我们可以在服务器上输出我们的组件,并立即向用户返回一些信息。
不要低估 SSR 的复杂性
尽管 React 提供了一个简单的 API 来在服务器上渲染组件,但创建一个通用应用程序是有成本的。因此,在考虑启用它之前,我们应该仔细考虑上述原因,并检查我们的团队是否准备好支持和维护一个通用应用程序。
SSR 可能会产生额外的成本,延长开发时间并增加复杂性。它还增加了服务器负载,可能需要更昂贵的硬件基础设施。在运营方面,SSR 需要一个维护良好的服务器和完整的设置,从而导致运营成本增加。此外,由于复杂性增加,测试可能变得更加耗时。
在这些成本和 SSR(服务器端渲染)的潜在好处之间取得平衡至关重要,例如提高 SEO(搜索引擎优化)和加快初始页面加载速度。
随着我们进入即将到来的章节,我们将发现,在创建服务器端渲染应用的过程中,渲染组件并不是唯一需要完成的任务。我们必须建立并维护一个服务器,包括其路由和逻辑,管理服务器数据流,并执行各种其他必要任务,以维持一个完全功能化的通用应用。考虑缓存内容以提高页面服务效率,并处理其他必要的责任。
因此,我的建议是首先专注于构建你 Web 应用程序的客户端版本。一旦它在服务器上完全功能化并且表现良好,你就可以考虑将 SSR 纳入其中,以提升用户体验。只有在真正需要时才启用 SSR 至关重要。例如,如果你的网站在搜索引擎中的可见性(SEO)是一个优先事项,那么你应该开始考虑实施 SSR。
如果你意识到你的应用程序加载时间很长,并且你已经做了所有的优化(有关此主题的更多信息,请参阅第十六章,提高应用程序的性能),你可以考虑使用 SSR 为用户提供更好的体验并提高感知速度。现在我们已经学习了 SSR 是什么以及通用应用程序的好处,让我们在下一节中跳入一些基本的 SSR 示例。
创建一个基本的 SSR 示例
现在,我们将创建一个非常简单的服务器端应用程序,以查看构建基本通用设置所需的步骤。故意保持这种最小化和简单的设置,因为这里的目的是展示 SSR 是如何工作的,而不是提供一个全面的解决方案或模板,尽管你可以将示例应用程序作为实际应用的起点。
本节假设读者对 Node.js 有基本的了解,并且熟悉与 JavaScript 构建工具相关的概念,例如webpack及其加载器。
应用程序将包含两个部分:
-
在服务器端,我们将使用 Express 创建一个基本的 Web 服务器,并使用服务器端渲染的 React 应用程序提供 HTML 页面。
-
在客户端,我们将像往常一样使用
react-dom来渲染应用程序。
从零开始配置我们的项目使用 webpack
在运行之前,应用程序的两侧都将使用 Babel 进行转译,并使用 webpack 进行打包,这将使我们能够在 Node.js 和浏览器上使用 ES6 的全部功能和模块。
让我们从创建一个新的项目文件夹(你可以称之为ssr-project)并运行以下命令来创建一个新的包开始:
npm init
一旦创建了package.json,就是安装依赖的时候了。我们可以从 webpack 开始:
npm install webpack
完成这些后,就是安装ts-loader和我们需要使用 React 和 TSX 编写 ES6 应用程序所需的预设的时候了:
npm install --save-dev @babel/core @babel/preset-env @babel/preset-react ts-loader typescript
为了创建服务器包,我们需要安装一个依赖项。Webpack 允许我们定义一组 externals,这些是我们不想包含在包中的依赖项。当为服务器生成构建时,最好不包含所有使用的 Node.js 包;相反,我们只想打包我们的服务器代码。从服务器包中排除依赖项提供了几个优点,包括减少包大小、加快编译速度以及与 Node.js 环境的兼容性。通过利用 Node.js 的原生模块系统,服务器代码可以直接访问已安装的包,而无需打包。像 webpack-node-externals 这样的工具有助于在 webpack 配置中将这些依赖项定义为 externals,从而实现优化的服务器包和简化的构建过程。让我们继续安装这个工具:
npm install --save-dev webpack-node-externals
太好了。现在是时候在 package.json 的 npm 脚本部分创建一个条目,这样我们就可以轻松地从终端运行 build 命令了:
"scripts": {
"build": "webpack"
}
接下来,你需要在你的根路径下创建一个 .babelrc 文件:
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}
我们现在需要创建一个配置文件,称为 webpack.config.js,以告诉 webpack 我们希望如何打包我们的文件。
让我们先导入我们将用来设置我们的 node externals 的库。我们还将为 ts-loader 定义配置,我们将使用它来为客户端和服务器:
const nodeExternals = require('webpack-node-externals')
const path = require('path')
const rules = [{
test: /\.(tsx|ts)$/,
use: 'ts-loader',
exclude: /node_modules/
}]
在 第六章,让你的组件看起来很漂亮 中,我们探讨了如何从配置文件中导出一个配置对象。Webpack 有一个很酷的功能,允许我们导出一个配置数组,这样我们就可以在同一个地方定义客户端和服务器配置,并且一次使用两者。
下面的代码块中显示的客户端配置应该非常熟悉:
const client = {
entry: './src/client.tsx',
output: {
path: path.resolve(__dirname, './dist/public'),
filename: 'bundle.js',
publicPath: '/'
},
module: {
rules
}
}
我们正在告诉 webpack 客户端应用程序的源代码位于 src 文件夹中,并且我们希望输出包生成在 dist 文件夹中。
我们还使用之前创建的带有 ts-loader 的对象设置了模块加载器。服务器配置略有不同;我们需要定义一个不同的入口,并添加一些新节点,例如 target、externals 和 resolve:
const server = {
entry: './src/server.ts',
output: {
path: path.resolve(__dirname, './dist'),
filename: 'server.js',
publicPath: '/'
},
module: {
rules
},
target: 'node',
externals: [nodeExternals()],
resolve: {
extensions: [".ts", ".tsx", ".js", ".json"]
}
}
如你所见,entry、output 和 module 都是相同的,除了文件名不同。
新参数是目标,我们指定节点告诉 webpack 忽略 Node.js 的所有内置系统包,例如 fs,以及 externals,我们使用之前导入的库告诉 webpack 忽略依赖项。
最后但同样重要的是,我们必须将配置作为一个数组导出:
module.exports = [client, server]
配置已完成。我们现在可以编写一些代码了,我们将从我们更熟悉的 React 应用程序开始。
创建应用程序
让我们创建一个 src 文件夹,并在其中创建一个 app.ts 文件。
app.ts 文件应该包含以下内容:
const App = () => <div>Hello React</div>
export default App
这里没有复杂的内容;我们导入 React,创建一个App组件,它渲染Hello React消息,并将其导出。
现在让我们创建client.tsx,它负责在 DOM 中渲染App组件:
import { render } from 'react-dom'
import App from './app'
render(<App />, document.getElementById('root'))
再次,这应该听起来很熟悉,因为我们导入了 React、ReactDOM 以及我们之前创建的App组件,并使用 ReactDOM 在具有应用程序 ID 的 DOM 元素中渲染它。
现在让我们转到服务器。
首件事是创建一个template.ts文件,它导出一个函数,我们将使用它来返回服务器将返回给浏览器的页面标记:
export default body => `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
</head>
<body>
<div id="root">${body}</div>
<script src="img/bundle.js"></script>
</body>
</html>
这应该相当直接。该函数接受body,我们稍后将看到它包含 React 应用程序,并返回页面的骨架。
值得注意的是,即使应用程序在服务器端渲染,我们也会在客户端加载捆绑包。SSR 只是 React 渲染我们的应用程序所做工作的一半。我们仍然希望我们的应用程序是一个客户端应用程序,具有我们可以在浏览器中使用的所有功能,例如事件处理器等。
然后,你需要安装express、react和react-dom:
npm install express react react-dom @types/express @types/react @types/react-dom
现在是时候创建server.tsx了,它有更多的依赖项,值得详细探索:
import React from 'react'
import express, { Request, Response } from 'express'
import { renderToString } from 'react-dom/server'
import path from 'path'
import App from './App'
import template from './template'
我们首先导入的是express,这是一个库,它允许我们轻松创建带有一些路由的 Web 服务器,并且还能够提供静态文件。
其次,我们导入 React 和 ReactDOM 来渲染App,我们也导入它。注意 ReactDOM 导入语句中的/server路径。最后我们导入的是我们之前定义的模板。
现在我们创建一个 Express 应用程序:
const app = express()
我们告诉应用程序我们的静态资源存储在哪里:
app.use(express.static(path.resolve(__dirname, './dist/public')))
如您可能已经注意到的,路径与我们用于 webpack 客户端配置中的客户端包输出目标相同。
然后,接下来是使用 React 的 SSR 逻辑:
app.get('/', (req: Request, res: Response) => {
const body = renderToString(<App />)
const html = template(body)
res.send(html)
})
我们告诉 Express 我们想要监听/路由,当客户端击中它时,我们使用 ReactDOM 库将App渲染为一个字符串。这就是 React SSR 的神奇和简单之处。
renderToString所做的就是返回由我们的App组件生成的 DOM 元素的字符串表示,这与如果使用 ReactDOM 的render方法在 DOM 中渲染的相同树。
body变量的值可能如下所示:
<div data-reactroot="" data-reactid="1" data-react-checksum="982061917">Hello React</div>
如您所见,它代表我们在App的render方法中定义的内容,除了 React 在客户端使用的一些数据属性,用于将客户端应用程序附加到服务器端渲染的字符串。
现在我们有了我们应用程序的 SSR 表示,我们可以使用template函数将其应用于 HTML 模板,并在 Express 响应中将其发送回浏览器。
最后但同样重要的是,我们必须启动 Express 应用程序:
app.listen(3000, () => {
console.log('Listening on port 3000')
})
我们现在准备就绪;只剩下几个操作。第一个是定义 npm 的启动脚本并将其设置为运行节点服务器:
"scripts": {
"build": "webpack",
"start": "node ./dist/server"
}
脚本已经准备好了,所以我们可以首先使用以下命令构建应用程序:
npm run build
当创建捆绑包时,我们可以运行以下命令:
npm start
将浏览器指向http://localhost:3000并查看结果。
这里有两个需要注意的重要事项。首先,当我们使用浏览器的查看页面源代码功能时,我们可以看到应用程序被渲染并从服务器返回的源代码,如果我们没有启用 SSR,我们就看不到这些代码。
第二,如果我们打开 DevTools 并且安装了 React 扩展,我们可以看到App组件已经在客户端启动了。
以下截图显示了页面的源代码:

图 12.1:源代码页面
太棒了!现在你已经使用 SSR 创建了你的第一个 React 应用程序,让我们在下一节学习如何获取数据。
实现数据获取
上一节中的示例应该清楚地解释如何在 React 中设置一个通用应用程序。这相当直接,主要关注的是完成任务。然而,在实际应用中,我们可能更希望加载一些数据,而不是像示例中的App这样的静态 React 组件。
例如,假设我们想在服务器上加载 Dan Abramov 的gists,并从我们刚刚创建的 Express 应用程序返回项目列表。
在第十二章,管理数据的数据获取示例中,我们探讨了如何使用useEffect来触发数据加载。在服务器上这不会起作用,因为组件不会在 DOM 上挂载,生命周期 Hook 也永远不会被触发。
使用之前执行过的 Hooks 也不会起作用,因为数据获取操作是异步的,而renderToString不是。因此,我们必须找到一种方法在之前加载数据,并将其作为 props 传递给组件。
让我们看看如何将上一节中的应用程序稍作修改,使其在 SSR 阶段加载gists。
首件事是将App.tsx修改为接受一个gists列表作为props,并在render方法中遍历它以显示它们的描述:
import { FC } from 'react'
type Gist = {
id: string
description: string
}
type Props = {
gists: Gist[]
}
const App: FC<Props> = ({ gists }) => (
<ul>
{gists.map(gist => (
<li key={gist.id}>{gist.description}</li>
))}
</ul>
)
export default App
应用我们在上一章中学到的概念,我们定义一个无状态的函数组件,它接收gists作为props并遍历元素以渲染项目列表。现在,我们必须更改服务器以检索gists并将它们传递给组件。
要在服务器端使用fetch API,我们必须安装一个名为isomorphic-fetch的库,该库实现了 fetch 标准。它可以在 Node.js 和浏览器中使用:
npm install isomorphic-fetch @types/isomorphic-fetch
我们首先将库导入到server.tsx中:
import fetch from 'isomorphic-fetch'
我们想要进行的 API 调用如下所示:
fetch('https://api.github.com/users/gaearon/gists')
.then(response => response.json())
.then(gists => {})
在这里,gists 可用于在最后一个 then 函数内部使用。在我们的情况下,我们希望将它们传递给 App。
因此,我们可以将 / 路由修改如下:
app.get('/', (req, res) => {
fetch('https://api.github.com/users/gaearon/gists')
.then(response => response.json())
.then(gists => {
const body = renderToString(<App gists={gists} />)
const html = template(body)
res.send(html)
})
})
在这里,我们首先获取 gists,然后以字符串的形式渲染 App,并传递属性。
一旦 App 被渲染并且我们有了它的标记,我们就使用上一节中使用的模板,并将其返回给浏览器。
在控制台中运行以下命令,并将浏览器指向 http://localhost:3000。你应该能够看到一个 server-side render 的 gists 列表:
npm run build && npm start
为了确保列表是从 Express 应用程序渲染的,你可以导航到 view-source:http://localhost:3000,你将看到标记和 gists 的描述。
这很好,看起来也很简单,但如果我们检查 DevTools 控制台,我们可以看到 Cannot read property 'map' of undefined error。我们看到错误的原因是在客户端,我们再次渲染 App,但没有将 gists 传递给它。
这在开始时可能听起来有些反直觉,因为我们可能认为 React 足够智能,可以在客户端使用服务器端字符串中渲染的 gists。但这并不是实际情况,因此我们必须找到一种方法,使 gists 也可以在客户端使用。
你可能会考虑在客户端再次执行 fetch。这也可以工作,但不是最优的,因为你最终会触发两个 HTTP 请求,一个在 Express 服务器上,一个在浏览器中。如果我们仔细想想,我们已经在服务器上执行了调用,并且我们已经拥有了所有需要的数据。在服务器和客户端之间共享数据的一个典型解决方案是在 HTML 标记中解冻数据,并在浏览器中重新解冻它。
这看起来像是一个复杂的概念,但实际上并不复杂。我们现在将看看如何轻松实现。我们必须做的第一件事是在客户端获取 gists 之后,将它们注入到模板中。
要做到这一点,我们必须稍微修改一下模板,如下所示:
export default (body, gists) => `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
</head>
<body>
<div id="root">${body}</div>
<script>window.gists = ${JSON.stringify(gists)}</script>
<script src="img/bundle.js"></script>
</body>
</html>
`
template 函数现在接受两个参数——应用的 body 和 gists 集合。第一个参数被插入到 app 元素中,而第二个参数用于定义一个全局的 gists 变量,并将其附加到 window 对象上,这样我们就可以在客户端使用它。
在 Express 路由(server.ts)内部,我们只需更改生成模板时传递 body 的那一行,如下所示:
const html = template(body, gists)
最后但同样重要的是,我们必须在 client.tsx 文件中,使用附加到窗口上的 gists,这相当简单:
ReactDOM.hydrate(
<App gists={window.gists} />,
document.getElementById('app')
)
hydrate 方法是在 React 16 中引入的,它在客户端与 render 方法的工作方式相似,无论 HTML 是否有服务器端渲染的标记。如果没有使用 SSR 的标记,那么 hydrate 方法将触发一个警告,你可以通过使用新的 suppressHydrationWarning 属性来忽略这个警告。
我们直接读取 gists,并将它们传递给在客户端渲染的 App 组件。
现在,再次运行以下命令:
npm run build && npm start
如果我们将浏览器窗口指向http://localhost:3000,错误就会消失,如果我们使用 React DevTools 检查App组件,我们可以看到客户端的App组件是如何接收 gists 集合的。
既然我们已经创建了第一个 SSR 应用程序,那么现在让我们看看如何通过在下一节中使用一个名为 Next.js 的 SSR 框架来更容易地做到这一点。
使用 Next.js 创建 React 应用程序
你已经了解了使用 React 的 SSR 基础知识,并且可以使用我们创建的项目作为真实应用程序的起点。然而,你可能认为有太多的样板代码,并且需要了解太多不同的工具才能运行一个简单的通用应用程序。这是一种常见的感受,称为JavaScript 疲劳,正如本书引言中所描述的。
幸运的是,Meta 的开发者和 React 社区中的其他公司正在非常努力地改进开发体验(DX),使开发者的生活更加轻松。你可能已经使用过create-react-app来尝试前几章中的示例,你应该理解它如何使创建 React 应用程序变得非常简单,而不需要开发者学习许多技术和工具。
现在,create-react-app还不支持 SSR,但有一家公司叫做Vercel,它创建了一个名为Next.js的工具,这使得生成通用应用程序变得非常容易,无需担心配置文件。它还大大减少了样板代码。
重要的是要说明,使用抽象来快速构建应用程序总是非常好的。然而,在添加太多层之前了解内部工作原理是至关重要的,这就是为什么我们在学习 Next.js 之前先从手动过程开始。我们已经研究了 SSR 的工作原理以及如何从服务器将状态传递到客户端。现在,基础概念已经清晰,我们可以继续使用一个稍微隐藏一些复杂性的工具,这样我们就可以用更少的代码实现相同的结果。
我们将创建一个相同的应用程序,其中加载了 Dan Abramov 的所有 gists,你将看到代码是如何干净且简单的,这要归功于 Next.js。
首先,创建一个新的项目文件夹(你可以称它为next-project),并运行以下命令:
npm init
当这一切完成时,我们可以安装 Next.js 库和 React:
npm install next react react-dom typescript @types/react @types/node
现在项目已经创建,我们必须添加一个 npm 脚本来运行二进制文件:
"scripts": {
"dev": "next"
}
完美!现在是我们生成App组件的时候了。Next.js 基于约定,其中最重要的一个约定是你可以创建与浏览器 URL 匹配的页面。默认页面是index,因此我们可以创建一个名为pages的文件夹,并在其中放置一个index.js文件。
我们开始导入依赖项:
import fetch from 'isomorphic-fetch'
再次,我们导入isomorphic-fetch,因为我们想能够在服务器端使用fetch函数。
然后我们定义一个名为App的组件:
const App = () => {}
export default App
然后我们定义一个静态异步函数,称为 getInitialProps,这是我们告诉 Next.js 我们想要加载哪些数据,无论是在服务器端还是在客户端。该库将函数返回的对象作为组件内部的 props 提供。
将 static 和 async 关键字应用于类方法意味着该函数可以在类的实例外部访问,并且该函数在其主体内部产生 wait 指令的执行。
这些概念相当高级,它们不属于本章的范围,但如果您对它们感兴趣,您应该查看 ECMAScript 提案 (github.com/tc39/proposals)。
我们刚才描述的方法的实现如下:
App.getInitialProps = async () => {
const url = 'https://api.github.com/users/gaearon/gists'
const response = await fetch(url)
const gists = await response.json()
return {
gists
}
}
我们正在告诉函数触发 fetch 并等待响应,然后我们将响应转换为 JSON,它返回一个承诺。当承诺解决时,我们可以返回带有 gists 的 props 对象。
组件的渲染看起来与上一个非常相似:
return (
<ul>
{props.gists.map(gist => (
<li key={gist.id}>{gist.description}</li>
))}
</ul>
)
在运行项目之前,您需要配置 tsconfig.json:
{
"compilerOptions": {
"baseUrl": "src",
"esModuleInterop": true,
"module": "esnext",
"noImplicitAny": true,
"outDir": "dist",
"resolveJsonModule": true,
"sourceMap": false,
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"moduleResolution": "node",
"isolatedModules": true,
"jsx": "react-jsx"
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]
}
现在,打开控制台并运行以下命令:
npm run dev
我们将看到以下输出:
> Ready on http://localhost:3000
如果我们将浏览器指向该 URL,我们可以看到通用应用程序的实际运行情况。使用 Next.js 几行代码和零配置设置通用应用程序真是太容易了,这真的很令人印象深刻。
您可能还会注意到,如果您在编辑器中编辑应用程序,您将能够立即在浏览器中看到结果,而无需刷新页面。这是 Next.js 的另一个特性,它实现了热模块替换。这在开发模式下非常有用。
如果您喜欢这一章,请在 GitHub 上给它点个赞:github.com/zeit/next.js。
摘要
服务器端渲染的旅程已经结束。现在您可以使用 React 创建服务器端渲染的应用程序,并且应该清楚为什么这对您来说很有用。SEO 当然是主要原因之一,但社交分享和性能也是重要因素。您学习了如何在服务器上加载数据并在 HTML 模板中使其脱水,以便在浏览器启动时提供给客户端应用程序。
最后,您了解了如何使用 Next.js 等工具来减少样板代码并隐藏设置服务器端渲染的 React 应用程序通常带来的某些复杂性。
在下一章中,我们将讨论如何提高我们的 React 应用程序的性能。
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
packt.link/React18DesignPatterns4e

第十三章:通过真实项目理解 GraphQL
GraphQL是一种强大的查询语言,旨在与 API 无缝协作,使它们能够高效地与现有数据交互。与传统 REST API 不同,GraphQL 提供了 API 中数据的全面概述,使得请求所需的确切数据变得容易,而不需要更多。这不仅简化了 API 请求,而且在必要时也使得优化和改进 API 变得更加容易。此外,GraphQL 还配备了强大的开发者工具,以进一步增强您的开发体验。
在本章中,我们将深入探讨 GraphQL 的实际应用,通过为现实世界项目构建基本的登录和用户注册系统。通过探索 GraphQL 在此环境下的应用,您将全面了解该语言,并能够在自己的项目中有效地应用它。
本章将涵盖以下主题:
-
安装 PostgreSQL
-
使用
.env文件创建环境变量 -
配置 Apollo Server
-
定义 GraphQL 查询和突变
-
使用解析器进行工作
-
创建 Sequelize 模型
-
实现 JWT
-
使用 GraphQL Playground
-
执行身份验证
技术要求
要完成本章,您需要以下内容:
-
Node.js 19+
-
Visual Studio Code
-
PostgreSQL
-
Homebrew (
brew.sh) -
pgAdmin 4 (
www.pgadmin.org/download/)
您可以在本书的 GitHub 仓库中找到本章的代码:github.com/PacktPublishing/React-18-Design-Patterns-and-Best-Practices-Fourth-Edition/tree/main/Chapter13。
使用 PostgreSQL、Apollo Server、GraphQL、Sequelize 和 JSON Web Tokens(JWT)构建后端登录系统
在本节中,我们将使用 PostgreSQL、Apollo Server、GraphQL、Sequelize 和JSON Web Tokens(JWTs)构建后端登录系统。我们将利用 PostgreSQL 进行数据存储,Sequelize 执行数据库操作,Apollo Server 创建 GraphQL API,GraphQL 塑造我们的 API,JWTs 用于用户身份验证和授权。无论您是初学者还是有经验的开发者,本指南将全面了解如何将这些技术集成到一个强大且安全的后端登录系统中。让我们开始吧。
安装 PostgreSQL
对于这个示例,我们将使用 PostgreSQL 数据库,因此您需要安装 PostgreSQL 才能在您的机器上运行此项目。
PostgreSQL 是我们数据库的一个优秀选择。为什么?它在保持数据安全和井然有序方面表现出色,即使在意外问题发生时也是如此。它能够处理各种类型的数据,这证明非常方便。此外,PostgreSQL 是可扩展的,使其能够超越基础功能。它运行高效,可以同时管理大量用户。
此外,它拥有强大的安全特性,确保我们的数据得到保护。作为一个开源平台,它不仅免费,还能从大量积极致力于其改进的社区中受益。如果你有其他数据库的先前经验,PostgreSQL 很容易理解,因为它遵循相同的标准。此外,它能够处理大量数据,并能够同时容纳众多用户。这正是它成为我们登录系统等项目的可靠选择的原因。
如果你有一台 macOS 机器,通过 Homebrew 安装 PostgreSQL 是最简单的方法。你只需要运行以下命令:
brew install postgres
安装完成后,你需要运行以下命令:
ln -sfv /usr/local/opt/postgresql/*.plist ~/Library/LaunchAgents
此命令创建了一个从 PostgreSQL plist文件(macOS 使用的配置文件)到你的~/Library/LaunchAgents目录的符号链接(一种快捷方式)。ln -sfv命令使用的选项如下:“s”代表符号(创建符号链接),“f”代表强制(删除现有的目标文件),以及“v”代表详细(显示正在发生的事情)。
然后,你可以创建两个新的别名来启动和停止你的 PostgreSQL 服务器:
alias pg_start="launchctl load ~/Library/LaunchAgents"
alias pg_stop="launchctl unload ~/Library/LaunchAgents"
现在,你应该可以使用pg_start启动你的 PostgreSQL 服务器,或者使用pg_stop停止它。之后,你需要创建你的第一个数据库,如下所示:
createdb `whoami`
现在,你可以使用psql命令连接到 PostgreSQL。如果你收到一个错误,指出角色"postgresql"不存在,你可以通过运行以下命令来修复它:
createuser -s postgres
如果你一切操作正确,你应该会看到类似以下的内容:

图 13.1:psql
如果你使用 Windows,你可以从www.postgresql.org/download/windows/下载 PostgreSQL,而对于使用 Linux(Ubuntu)的用户,你可以从www.postgresql.org/download/linux/ubuntu/下载。
PostgreSQL 数据库管理的最佳工具
PostgreSQL 数据库管理的最佳工具是pgAdmin 4 (www.pgadmin.org/download/)。我喜欢这个工具,因为它可以用来创建新的服务器、用户和数据库,也可以用来执行 SQL 查询和处理数据。记住,为了在这个例子中使用它,你需要创建一个数据库。
有时,当你启动 PostgreSQL 服务器时,可能会遇到错误,可能会说类似 FATAL 锁文件“postmaster.pid”已存在 的话。如果你遇到这个错误,你可以通过运行以下命令轻松修复它:
rm /usr/local/var/postgres/postmaster.pi
使用这个方法,你将能够启动你的 PostgreSQL 服务器。
现在我们已经完成了 PostgreSQL 的设置,并且有了 pgAdmin 工具来简化数据库管理,我们可以将注意力转移到下一个任务,即构建我们的后端项目。
创建我们的后端项目
首先,你需要在你的 GraphQL 项目中创建一个后端目录(graphql/backend)。然后,让我们回顾一下你需要安装的大量 NPM 软件包(重点关注最相关的):
npm init --yes
npm install @apollo/server@4.7.3 @contentpi/lib@1.0.10 @graphql-tools/load-files@7.0.0 @graphql-tools/merge@9.0.0 @graphql-tools/schema@10.0.0 body-parser@1.20.2 cors@2.8.5 dotenv@16.1.4 express@4.18.2 graphql-middleware@6.1.34 graphql-tag@2.12.6 jsonwebtoken@9.0.0 pg@8.11.0 pg-hstore@2.3.4 pm2@5.3.0 sequelize@6.32.0 ts-node@10.9.1
npm install --save-dev prettier@2.8.8 ts-node-dev@2.0.0 typescript@5.1.3 eslint@8.42.0 @types/jsonwebtoken@9.0.2 @types/cors@2.8.13
请注意,我上一本书的一些读者遇到了某些代码的问题,这些代码没有按预期工作。这是由于自写作以来软件包版本的更新导致的。
为了确保这本书中的代码能够正确运行,我已经指定了我使用的特定版本的软件包。需要注意的是,这些软件包的新版本可能包含破坏性更改,可能会影响代码的功能,因此建议你使用指定的版本以避免任何问题。
你应该在 package.json 文件中拥有的脚本如下:
"scripts": {
"dev": "ts-node-dev src/index.ts",
"build": "rm -rf dist && tsc -p . --traceResolution",
"lint": "eslint . --ext .js,.tsx,.ts",
"lint:fix": "eslint . --fix --ext .js,.tsx,.ts",
"test": "jest src"
}
在下一节中,我们将配置我们的环境变量。
配置我们的 .env 文件
.env 文件(也称为 dotenv)是一个配置文件,用于指定应用程序的环境变量。通常情况下,你的应用程序在开发、预发布或生产环境中不会改变,但它们通常需要不同的配置。最常更改的变量是基本 URL、API URL,甚至 API 密钥。
在我们深入研究实际的登录代码之前,我们需要创建一个名为 .env 的文件(通常,这个文件被 .gitignore 忽略),这将允许我们使用私有数据,例如数据库连接和安全机密。仓库中已经存在一个名为 .env.example 的文件;你只需要将其重命名并将你的连接数据放入其中。.env 文件 将看起来像这样:
DB_DIALECT=postgres
DB_PORT=5432
DB_HOST=localhost
DB_DATABASE=<your-database>
DB_USERNAME=<your-username>
DB_PASSWORD=<your-password>
创建一个基本的配置文件
对于这个项目,我们需要创建一个配置文件来存储一些安全数据,它应该创建在 /backend/config/config.json。
在这里,我们将定义一些基本配置,例如我们的服务器端口和一些安全信息:
{
"server": {
"port": 4000
},
"security": {
"secretKey": "C0nt3ntP1",
"expiresIn": "7d"
}
}
然后,你需要在配置目录中创建一个 index.ts 文件。这将引入我们在 .env 文件中定义的所有数据库连接信息,然后导出三个配置变量,分别称为 \(db**、**\)security 和 $server:
import dotenv from 'dotenv'
import config from './config.json'
dotenv.config()
type Db = {
dialect: string
host: string
port: string
database: string
username: string
password: string
}
type Security = {
secretKey: string
expiresIn: string
}
type Server = {
port: number
}
const db: Db = {
dialect: process.env.DB_DIALECT || '',
port: process.env.DB_PORT || '',
host: process.env.DB_HOST || '',
database: process.env.DB_DATABASE || '',
username: process.env.DB_USERNAME || '',
password: process.env.DB_PASSWORD || ''
}
const { security, server } = config
export const $db: Db = db
export const $security: Security = security
export const $server: Server = server
如果你的 .env 文件不在 root 目录中或不存在,所有你的变量都将被 未定义。
一旦您配置了文件并验证了项目的安全细节,接下来增强我们项目的步骤就涉及到利用和设置 Apollo Server。这个无价工具促进了服务器和客户端之间数据交换的管理,简化了通信过程。
配置 Apollo Server
Apollo Server 是一个高度流行的开源库,用于处理 GraphQL,无论是作为服务器还是客户端。凭借广泛的文档和简单的实现,它已成为许多开发者的首选。其直观的界面和灵活的架构使其易于定制和适应您的特定需求,同时其强大的功能和可靠的表现确保了与现有代码库的无缝集成。无论您是经验丰富的开发者还是 GraphQL 新手,Apollo Server 都是一个强大的工具,可以帮助您将项目提升到新的水平。
以下图表解释了 Apollo Server 在客户端和服务器中的工作方式:

图 13.2:Apollo Server
Apollo Server 促进了您的应用程序或网站与相关数据库之间的有效通信。通过利用 GraphQL,它允许您的应用程序的前端部分在单个操作中从后端请求特定的数据,从而实现更快、更流畅的数据交换。本质上,它充当了用户界面和数据库之间的有效中介。
对于我们的设置,我们将使用 Express 来建立我们的 Apollo Server,并使用 Sequelize 对象关系映射器(ORM)来处理我们的 PostgreSQL 数据库。由于 Express 与 Apollo 的无缝集成及其灵活性,它成为配置 Apollo Server 的流行选择,这为开发者提供了更大的自由度。Express.js 是一个轻量级且性能优化的框架,适用于从小型到大型和可扩展的各种规模的应用程序。此外,它的成熟度和广泛的社区支持使其成为一个可靠的选择。它的简单性,特别是对于那些已经熟悉 JavaScript 和 Node.js 的人来说,使得 Apollo Server 的设置快速而高效。因此,我们将首先导入必要的组件。
所需的文件可以在 /backend/src/index.ts 找到:
import { makeExecutableSchema } from '@graphql-tools/schema'
import { ApolloServer } from '@apollo/server'
import { expressMiddleware } from '@apollo/server/express4'
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer'
import cors from 'cors'
import http from 'http'
import express from 'express'
import { applyMiddleware } from 'graphql-middleware'
import { json } from 'body-parser'
import { $server } from '../config'
import resolvers from './graphql/resolvers'
import typeDefs from './graphql/types'
import models from './models'
首先,我们需要设置我们的 Express.js 应用程序 和 cors:
const app = express()
const corsOptions = {
origin: '*',
credentials: true
}
app.use(cors(corsOptions))
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*')
res.header(
'Access-Control-Allow-Headers',
'Origin, X-Requested-With, Content-Type, Accept'
)
next()
})
然后,我们需要使用 applyMiddleware 和 makeExecutableSchema 通过传递 typeDefs 和 resolvers 来创建我们的模式:
// Schema
const schema = applyMiddleware(
makeExecutableSchema({
typeDefs,
resolvers
})
)
之后,我们需要创建一个 Apollo Server 的实例,其中我们需要传递模式和插件:
// Apollo Server
const apolloServer = new ApolloServer({
schema,
plugins: [ApolloServerPluginDrainHttpServer({ httpServer })]
})
最后,我们需要同步 Sequelize。在这里,我们传递一些可选变量(alter 和 force)。如果 force 为真并且你更改了 Sequelize 模型,这将删除你的表,包括它们的值,并强制你再次创建表,而如果 force 为 false 且 alter 为 true,那么你将只更新表字段,而不会影响你的值。因此,你需要小心这个选项,因为你不小心可能会丢失所有数据。然后,在同步之后,我们必须运行我们的 Apollo Server,它监听端口 4000($server.port):
const main = async () => {
const alter = true
const force = false
await apolloServer.start()
await models.sequelize.sync({ alter, force })
app.use(
'/graphql',
cors<cors.CorsRequest>(),
json(),
expressMiddleware(apolloServer, {
context: async () => ({ models })
})
)
await new Promise<void>((resolve) => httpServer.listen({
port: $server.port
}, resolve))
console.log(`🚀 Server ready at http://localhost:${$server.port}/graphql`)
}
main()
这个过程将帮助我们同步数据库与模型,确保对模型所做的任何修改都将自动更新相应的表。
定义我们的 GraphQL 类型、查询和变异
现在你已经创建了 Apollo Server 实例,下一步是创建你的 GraphQL 类型。在设置像 Apollo 这样的 GraphQL 服务器时,创建 GraphQL 类型至关重要。这些类型确保从你的 API 返回的数据是可靠的,并符合预期的结构。它们作为可用数据和其预期格式的有用参考。通过使用类型,你的应用程序可以精确地请求所需的数据,从而实现更快的执行和减少数据消耗。此外,类型有助于维护数据一致性,从而实现强大、易于理解且高效的 API。
标量类型
你需要做的第一件事是在 /backend/src/graphql/types/Scalar.ts 中定义你的标量类型:
import gql from 'graphql-tag'
export default gql`
scalar UUID
scalar Datetime
scalar JSON
现在,让我们创建我们的 User 类型(backend/src/graphql/types/User.ts):
import gql from 'graphql-tag'
export default gql`
type User {
id: UUID!
username: String!
email: String!
password: String!
role: String!
active: Boolean!
createdAt: Datetime!
updatedAt: Datetime!
}
`
如你所见,我们使用了一些标量类型,如 UUID 和 Datetime,来定义 User 类型中的某些字段。在这种情况下,当你定义 GraphQL 中的类型时,你需要使用 type 关键字,后跟类型名称的首字母大写。然后,你可以在大括号 {} 内定义你的字段。
GraphQL 中有一些原始数据类型,例如 String、Boolean、Float 和 Int。你可以定义自定义标量类型,就像我们定义 UUID、Datetime 和 JSON 一样,你也可以定义自定义类型,例如 User 类型,并指定我们是否想要该类型的数组,例如 [User]。
类型后面的 ! 字符表示字段不可为空。
查询
GraphQL 查询用于从数据存储中读取或获取值。现在你知道如何定义自定义类型,让我们定义我们的 Query 类型。在这里,我们将定义 getUsers 和 getUser。第一个将检索用户列表,而第二个将提供特定用户的详细信息:
type Query {
getUser(at: String!): User!
getUsers: [User!]
}
在这种情况下,我们的 getUsers 查询将返回用户数组([User!]),而我们的 getUser 查询,它需要 at(访问令牌)属性,将返回单个 User!。记住,对于你添加的任何查询,你都需要在后面的 resolvers 中定义它(我们将在下一节中这样做)。
变异
突变用于写入或发布值:也就是说,修改数据存储中的数据,如果你想要与 REST 进行一些比较,例如执行任何 POST、PUT、PATCH 或 DELETE 操作,你可以返回一个值。Mutation 类型与 Query 类型的工作方式完全相同,你需要定义你的突变并指定你将接收什么参数以及返回什么数据:
type Mutation {
createUser(input: CreateUserInput): User!
login(input: LoginInput): Token!
}
如你所见,我们定义了两个突变。第一个是 createUser,用于在我们的数据存储中注册或创建新用户,而第二个是执行 登录。正如你可能已经注意到的,它们都接收带有不同值的输入参数(CreateUserInput 和 LoginInput),称为 输入类型,这些类型用作查询或突变的参数。最后,它们将分别返回 User! 和 Token! 类型。让我们学习如何定义这些输入:
type Token {
token: String!
}
input CreateUserInput {
username: String!
password: String!
email: String!
active: Boolean!
role: String!
}
input LoginInput {
emailOrUsername: String!
password: String!
}
输入通常与突变一起使用,但你也可以将它们与查询一起使用。
合并类型定义
现在我们已经定义了所有我们的类型、查询和突变,我们需要合并所有我们的 GraphQL 文件来创建我们的 GraphQL 模式,这基本上是一个包含所有我们的 GraphQL 定义的大文件。
为了做到这一点,你需要创建一个名为 /backend/src/graphql/types/index.ts 的文件,其中包含以下代码:
import { mergeTypeDefs } from '@graphql-tools/merge'
import Scalar from './Scalar'
import User from './User'
export default mergeTypeDefs([Scalar, User])
在成功将你的类型定义合并到一个综合的 GraphQL 模式后,下一个关键步骤是创建解析器。解析器是负责从你的 GraphQL 模式中定义的字段获取和生成数据的函数。
创建我们的解析器
解析器是一个负责为你的 GraphQL 模式中的字段生成数据的函数。它通常可以以任何你想要的方式生成数据,因为它可以从数据库中获取数据或使用第三方 API。
要创建我们的用户解析器,你需要创建一个名为 /backend/src/graphql/resolvers/user.ts 的文件。让我们创建我们的解析器应该看起来像的框架。在这里,我们需要指定在 GraphQL 模式中的 Query 和 Mutation 下定义的函数。所以,你的解析器应该看起来像这样:
export default {
Query: {
getUsers: () => {},
getUser: () => {}
},
Mutation: {
createUser: () => {},
login: () => {}
}
}
如你所见,我们返回一个包含两个主要节点 Query 和 Mutation 的对象,并将我们在 GraphQL 模式中定义的查询和突变映射(User.ts 文件)。当然,我们需要做一些更改以接收一些参数并返回一些数据,但我首先想向你展示解析器文件的基本框架。
你需要做的第一件事是在文件中添加一些导入:
import { doLogin, getUserBy } from '../../lib/auth'
import { getUserData } from '../../lib/jwt'
import { ICreateUserInput, IloginInput, Imodels, Itoken, Iuser } from '../../types'
我们将在下一节创建 getUsers 和 getUser 函数。
创建 getUsers 查询
我们的第一种方法将是 getUsers 查询。让我们看看我们需要如何定义它:
getUsers: (
_: any,
args: any,
ctx: { models: Imodels }
): Iuser[] => ctx.models.User.findAll(),
在任何查询或突变方法中,我们总是接收四个参数:父节点(定义为 _),参数(定义为 args),上下文(定义为 ctx),和信息(这是可选的)。
如果您想稍微简化一下代码,可以像这样解构上下文:
getUsers: (
_: any,
args: any,
{ models }: { models: Imodels }
): Iuser[] => ctx.models.User.findAll(),
在我们的下一个解析器函数中,我们也将解构我们的参数。提醒一下,上下文是在我们的 Apollo Server 设置中传递的(我们之前这样做过):
// Apollo Server
const apolloServer = new ApolloServer({
schema,
context: async () => ({
models
})
})
当我们需要在解析器中全局共享某些内容时,上下文非常重要。
创建 getUser 查询
这个函数需要是async的,因为我们需要执行一些异步操作,例如,如果用户已经有一个有效的会话,通过at(访问令牌)获取已连接的用户。然后,我们可以通过查看我们的数据库来验证这确实是一个真实用户。这有助于阻止人们修改 cookies 或尝试进行某种形式的注入。如果我们找不到已连接的用户,则返回一个包含空数据的用户对象:
getUser: async (
_: any,
{ at }: { at: string },
{ models }: { models: IModels }
): Promise<any> => {
// Get current connected user
const connectedUser = await getUserData(at)
if (connectedUser) {
// Validating if the user is still valid
const user = await getUserBy({
id: connectedUser.id,
email: connectedUser.email,
active: connectedUser.active
},
[connectedUser.role],
models
)
if (user) {
return connectedUser
}
}
return {
id: '',
username: '',
password: '',
email: '',
role: '',
active: false
}
}
创建突变
我们的突变非常简单:我们只需要执行一些函数,并通过展开输入值(这来自我们的 GraphQL 模式)传递所有参数。让我们看看我们的Mutation节点应该是什么样子:
Mutation: {
createUser: (
_: any,
{ input }: { input: ICreateUserInput },
{ models }: { models: IModels }
): IUser => models.User.create({ ...input }),
login: (
_: any,
{ input }: { input: ILoginInput },
{ models }: { models: IModels }
): Promise<IToken> => doLogin(input.email, input.password, models)
}
您需要将电子邮件、密码和模型传递给doLogin函数。
合并我们的解析器
就像我们对types定义所做的那样,我们需要使用@graphql-tools包合并所有我们的解析器。您需要在/backend/src/graphql/resolvers/index.ts创建以下文件:
import { mergeResolvers } from '@graphql-tools/merge'
import user from './user'
const resolvers = mergeResolvers([user])
export default resolvers
这将把所有您的解析器组合成一个解析器数组。
一旦您的解析器合并,将所有您的数据获取函数整合到一个连贯的结构中,就到了下一个阶段:创建 Sequelize 模型。Sequelize 是一个强大的工具,它简化了您的应用程序与各种数据库之间的交互,将复杂的 SQL 命令转换为用户友好的 JavaScript。
使用 Sequelize ORM
Sequelize 是 Node.js 的一个流行的 ORM 库。它使开发者能够通过将底层的 SQL 命令抽象为高级、易于使用的 JavaScript 对象和方法,与 MySQL、PostgreSQL、SQLite 和 Microsoft SQL Server 等数据库进行交互。
使用 Sequelize,开发者可以执行创建、更新、删除和查询记录等数据库操作,而无需编写原始 SQL 查询。Sequelize 还帮助定义数据模型、管理表之间的关系以及处理数据库迁移。
Sequelize ORM 的一些关键特性包括:
-
模型定义:Sequelize 允许您定义具有其属性、数据类型和约束的模型,这些映射到底层数据库中的表。
-
关联:您可以轻松定义模型之间的关系,例如一对一、一对多和多对多,这些映射到数据库中的外键约束。
-
查询:Sequelize 提供了一个强大的查询系统,允许您在不编写原始 SQL 的情况下获取、过滤、排序和分页数据。
-
事务:它支持执行多个数据库操作的事务。
-
迁移: Sequelize 提供了一个迁移系统来管理随时间变化的模式更改,并确保您的数据库模式与应用程序代码保持同步。
在 Sequelize 中创建用户模型
在我们深入研究身份验证函数之前,我们需要在 Sequelize 中创建我们的 User 模型。为此,我们需要在 /backend/src/models/User.ts 创建一个文件。我们的模型将包含以下字段:
-
id -
username -
password -
email -
role -
active
让我们看看代码:
import { encrypt } from '@contentpi/lib'
import { IDataTypes, IUser } from '../types'
export default (sequelize: any, DataTypes: IDataTypes): IUser => {
const User = sequelize.define('User', {
id: {
primaryKey: true,
allowNull: false,
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4()
},
username: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: {
isAlphanumeric: {
args: true,
msg: 'The user just accepts alphanumeric characters'
},
len: {
args: [4, 20],
msg: 'The username must be from 4 to 20 characters'
}
}
},
password: {
type: Datatypes.STRING,
allowNull: false
},
email: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: {
isEmail: {
args: true,
msg: 'Invalid email'
}
}
},
role: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'user'
},
active: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
}
},
{
hooks: {
beforeCreate: (user: IUser): void => {
user.password = encrypt(user.password)
}
}
}
)
return User
}
如您所见,我们正在定义一个名为 beforeCreate 的 Sequelize 钩子,它在我们保存数据之前帮助我们将用户密码加密(使用 sha1)。最后,我们返回 User 模型。
将 Sequelize 连接到 PostgreSQL 数据库
现在我们已经创建了用户模型,我们需要将 Sequelize 连接到我们的 PostgreSQL 数据库并将所有模型组合在一起。
您需要将以下代码添加到 /backend/src/models/index.ts 文件中:
import { Sequelize } from 'sequelize'
import { $db } from '../../config'
import { IModels } from '../types'
// Db Connection
const { dialect, port, host, database, username, password } = $db
// Connecting to the database
const uri = `${dialect}://${username}:${password}@${host}:${port}/${database}`
const sequelize = new Sequelize(uri)
// Models
const models: IModels = {
User: require('./User').default(sequelize, Sequelize),
sequelize
}
export default models
身份验证函数
一步一步地,我们将所有拼图碎片拼在一起。现在,让我们看看我们将使用的身份验证函数,以验证用户是否已连接以及获取用户数据。为此,我们需要使用 JWT。
JWT 是在 RFC 7519 中概述的开放标准(tools.ietf.org/html/rfc7519)。它作为在各方之间作为 JSON 对象传输信息的宝贵工具。JWT 的一个主要优点是其数字签名,这使得它们可以轻松验证和信任。令牌使用 HMAC 算法和秘密或公钥对(使用 RSA 或 ECDSA)进行签名,确保其安全且防篡改。这使得 JWT 成为各种应用程序中身份验证和授权的可靠选择。
创建 JWT 函数
让我们创建一些函数来帮助验证 JWT 并获取用户数据。为此,我们需要在 /backend/src/lib/jwt.ts 创建文件:
import { encrypt, getBase64, setBase64 } from '@contentpi/lib'
import jwt from 'jsonwebtoken'
import { $security } from '../../config'
import { IUser } from '../types'
const { secretKey } = $security
export function jwtVerify(accessToken: string, cb: any): void {
// Verifiying our JWT token using the accessToken and the secretKey
jwt.verify(accessToken, secretKey, (error: any, accessTokenData: any = {}) => {
const { data: user } = accessTokenData
// If we get an error or the user is not found we return false
if (error || !user) {
return cb(false)
}
// The user data is on base64 and getBase64 will retreive the
// information as JSON object
const userData = getBase64(user)
return cb(userData)
})
}
export async function getUserData(accessToken: string): Promise<any> {
// We resolve the jwtVerify promise to get the user data
const UserPromise = new Promise((resolve) => jwtVerify(accessToken, (user: any) => resolve(user)))
// This will get the user data or false (if the user is not connected)
const user = await UserPromise
return user
}
export const createToken = async (user: IUser): Promise<string[]> => {
// Extracting the user data
const { id, username, password, email, role, active } = user
// Encrypting our password by combining the secretKey and the password
// and converting it to base64
const token = setBase64(`${encrypt($security.secretKey)}${password}`)
// The "token" is an alias for password in this case
const userData = {
id,
username,
email,
role,
active,
token
}
// We sign our JWT token and we save the data as Base64
const _createToken = jwt.sign({ data: setBase64(userData) }, $security.secretKey, {
expiresIn: $security.expiresIn
})
return Promise.all([_createToken])
}
如您所见,jwt.sign 用于创建新的 JWT,而 jwt.verify 用于验证我们的 JWT。
创建身份验证函数
现在我们已经创建了 JWT 函数,我们需要创建一些函数来帮助我们登录到 /backend/src/lib/auth.ts:
import { encrypt, isPasswordMatch } from '@contentpi/lib'
import { IToken, IModels, IUser } from '../types'
import { createToken } from './jwt'
export const getUserBy = async (where: any, models: IModels): Promise<IUser> => {
我们通过 WHERE 条件查找用户:
const user = await models.User.findOne({
where,
raw: true
})
return user
}
export const doLogin = async (
email: string,
password: string,
models: IModels
): Promise<IToken> => {
通过电子邮件查找用户:
const user = await getUserBy({ email }, models)
如果用户不存在,我们返回 Invalid Login:
if (!user) {
throw new Error('Invalid Login')
}
我们验证我们的加密密码是否与 user.password 的值相同:
const passwordMatch = isPasswordMatch(encrypt(password), user.password)
我们验证用户是否处于活动状态:
const isActive = user.active
如果密码不匹配,我们返回 Invalid Login:
if (!passwordMatch) {
throw new Error('Invalid Login')
}
如果账户未处于活动状态,我们返回一个错误:
if (!isActive) {
throw new Error('Your account is not activated yet')
}
如果用户存在,密码正确且账户处于活动状态,则我们创建 JWT:
const [token] = await createToken(user)
// Finally we return the token to Graphql
return {
token
}
}
在这里,我们通过电子邮件验证用户是否存在,密码是否正确,以及账户是否处于活动状态,以便创建 JWT。
定义类型和接口
最后,我们需要为所有我们的 Sequelize 模型和 GraphQL 输入定义我们的类型和接口。为此,您需要在 /backend/src/types/types.ts 中创建一个文件:
export type User = {
username: string
password: string
email: string
role: string
active: boolean
}
export type Sequelize = {
_defaults?: any
name?: string
options?: any
associate?: any
}
现在,让我们在 /backend/src/types/interfaces.ts 中创建我们的接口:
import { Sequelize, User } from './types'
export interface IDataTypes {
UUID: string
UUIDV4(): string
STRING: string
BOOLEAN: boolean
TEXT: string
INTEGER: number
DATE: string
FLOAT: number
}
export interface IUser extends User, Sequelize {
id: string
token?: string
createdAt?: Date
updatedAt?: Date
}
export interface ICreateUserInput extends User {}
export interface ILoginInput {
email: string
password: string
}
export interface IToken {
token: string
}
export interface IModels {
User: any
sequelize: any
}
最后,我们需要导出 /backend/src/types/index.ts 中的两个文件:
export * from './interfaces'
export * from './types'
当您需要添加更多模型时,请记住始终将这些类型和接口添加到这些文件中。
最后,您需要在 root 目录中创建您的 tsconfig.json 文件:
{
"compilerOptions": {
"baseUrl": "./src",
"esModuleInterop": true,
"module": "commonjs",
"noImplicitAny": true,
"outDir": "dist",
"resolveJsonModule": true,
"sourceMap": true,
"target": "ESNext",
"typeRoots": ["./src/@types", "./node_modules/@types"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
在下一节中,我们将运行我们的项目并创建我们的表。
第一次运行我们的项目
接下来,我们将第一次启动我们的项目。如果我们一切都做得正确,我们将看到我们的 Users 表正在设置,我们的 Apollo 服务器将开始运行。
在这部分,我们将介绍如何启动我们的项目。之后,我们将探索如何使用我们的 GraphQL API。我们将学习关于测试查询的知识,这些查询允许我们检索数据,以及突变,它使我们能够修改数据。我们还将讨论验证,这是确保我们数据正确性的检查。最后,我们将深入了解用户登录的过程。让我们开始吧!
如果您正确地遵循了前面的部分并运行了 npm run dev 命令,您应该能够看到 Users 表已创建,并且 Apollo 服务器正在端口 4000 上运行:

图 13.3:第一次运行我们的项目
现在,假设您想修改您的用户模型并将 "username" 字段更改为 "username2"。让我们看看会发生什么:
[INFO] 23:45:16 Restarting: /Users/czantany/projects/React-Design-Patterns-and-Best-Practices-Third-Edition/Chapter05/graphql/backend/src/models/User.ts has been modified
Executing (default): CREATE TABLE IF NOT EXISTS "Users" ("id" UUID NOT NULL , "username2" VARCHAR(255) NOT NULL UNIQUE, "password" VARCHAR(255) NOT NULL, "email" VARCHAR(255) NOT NULL UNIQUE, "privilege" VARCHAR(255) NOT NULL DEFAULT 'user', "active" BOOLEAN NOT NULL DEFAULT false, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, PRIMARY KEY ("id"));
Executing (default): ALTER TABLE "public"."Users" ADD COLUMN "username2" VARCHAR(255) NOT NULL UNIQUE;
Executing (default): ALTER TABLE "Users" ALTER COLUMN "password" SET NOT NULL;ALTER TABLE "Users" ALTER COLUMN "password" DROP DEFAULT;ALTER TABLE "Users" ALTER COLUMN "password" TYPE VARCHAR(255);
Executing (default): ALTER TABLE "Users" ALTER COLUMN "email" SET NOT NULL;ALTER TABLE "Users" ALTER COLUMN "email" DROP DEFAULT;ALTER TABLE "Users" ADD UNIQUE ("email");ALTER TABLE "Users" ALTER COLUMN "email" TYPE VARCHAR(255) ;
Executing (default): ALTER TABLE "Users" ALTER COLUMN "privilege" SET NOT NULL;ALTER TABLE "Users" ALTER COLUMN "privilege" SET DEFAULT 'user';ALTER TABLE "Users" ALTER COLUMN "privilege" TYPE VARCHAR(255);
Executing (default): ALTER TABLE "Users" ALTER COLUMN "active" SET NOT NULL;ALTER TABLE "Users" ALTER COLUMN "active" SET DEFAULT false;ALTER TABLE "Users" ALTER COLUMN "active" TYPE BOOLEAN;
Executing (default): ALTER TABLE "Users" ALTER COLUMN "createdAt" SET NOT NULL;ALTER TABLE "Users" ALTER COLUMN "createdAt" DROP DEFAULT;ALTER TABLE "Users" ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE;
Running on http://localhost:4000/graphql
这将执行以下 SQL 查询:
Executing (default): ALTER TABLE "public"."Users" ADD COLUMN "username2" VARCHAR(255) NOT NULL UNIQUE;
Executing (default): ALTER TABLE "public"."Users" DROP COLUMN "username";
现在,假设您将 index.ts 文件中的 force 常量更改为 true。以下将发生:

图 13.4:DROP TABLE IF EXISTS
如您所见,如果 force 为 true,它将执行 DROP TABLE IF EXISTS "Users" CASCADE;。这将完全删除您的表和值,然后从头开始重新创建您的表。这就是为什么您在使用 force 选项时需要小心。
在这一点上,如果您打开 localhost:4000/graphql,您应该能够看到您的新 GraphQL 探索器:

图 13.5:GraphQL 探索器
点击查询您的服务器按钮,然后我们就可以开始测试我们的查询和突变了。
测试 GraphQL 查询和突变
太好了!在这个时候,您已经非常接近执行您的第一个 GraphQL 查询和突变了。我们将执行的第一个查询将是 getUsers。以下运行查询的正确语法:
query {
getUsers {
id
username
email
role
}
}
当你没有属性要传递给查询时,你只需要在 query {...} 块下指定查询的名称,然后在你执行查询后指定你想要检索的字段。在这种情况下,我们想要检索 id、username、email 和 role 字段。
如果你运行这个查询,你可能会得到一个空的数据数组。这是因为我们还没有注册任何用户:

图 13.6:getUsers 查询
这意味着我们需要执行我们的 createUser 变异 来注册我们的第一个用户。我喜欢 GraphQL Explorer 的一个地方是,你可以在左侧的 Schema 图标中找到所有的模式文档。如果你点击 Schema 图标,你会看到所有你的查询和变异列出来。
让我们点击那里并选择我们的 createUser 变异 来查看需要调用什么以及可能返回什么数据:

图 13.7:模式
如你所见,createUser 变异需要一个 input 参数,它是 CreateUserInput。让我们点击那个输入:

图 13.8:CreateUserInput
太棒了!现在,我们知道我们需要传递 username、password、email、role 和 active 字段来创建一个新用户。让我们这么做吧!
创建一个新标签页,这样你就不会丢失第一个查询的代码,然后编写变异:
mutation($input: CreateUserInput) {
createUser(input: $input) {
id
username
email
role
active
}
}
如你所见,你的变异需要在 mutation {...} 块下编写,你必须将 input 参数作为一个对象在 Variables 部分传递。最后,一旦变异正确执行,你必须指定你想要检索的字段。如果一切正常,你应该看到类似这样的内容:

图 13.9:CreateUser 变异
如果你好奇并想看看你运行 Apollo Server 的终端,你会看到为这个用户执行的 SQL 查询:
INSERT INTO "Users" ("id","username","password","email","role","active","createdAt","updatedAt") VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
VALUES 变量由 Apollo Server 处理,所以你不会在那里看到实际的值,但你可以找出正在数据库中执行的操作。
现在,回到你的第一个查询(getUsers)并再次运行它!

图 13.10:getUsers 查询
很好:这是你在 GraphQL 中正确执行的第一个查询和变异。如果你想在你数据库中看到这些数据,你可以使用 OmniDB 或 PgAdmin4 来查看你的 PostgreSQL 数据库中的 Users 表:

图 13.11:数据库查询
如你所见,我们的第一条记录有自己的 id 字段(UUID)以及一个加密的密码字段(记得我们在用户模型中的 beforeCreate 钩子吗?)。默认情况下,Sequelize 将创建 createdAt 和 updatedAt 字段。
测试模型验证和查询用户
如你所回忆的,关于我们的用户模型,你想要确保我们做的所有验证都正常工作,比如用户是否唯一,或者他们的电子邮件是否有效且唯一。你只需要再次执行完全相同的突变:

图 13.12:用户名必须唯一
如你所见,我们会得到一个“用户名必须唯一”的错误信息,因为我们已经注册了“admin”用户名。现在,让我们尝试将用户名更改为“admin2”,但保持电子邮件不变(admin@js.education):

图 13.13:电子邮件必须唯一
我们还会得到一个“电子邮件必须唯一”的错误。现在,尝试将电子邮件更改为无效的地址,例如 admin@myfakedomain:

图 13.14:无效的电子邮件
现在,我们得到了一个“Invalid email”错误信息。这真是太神奇了,不是吗?现在,让我们停止玩验证,并添加一个新有效用户(用户名:admin2 和 电子邮件:admin2@js.education)。一旦你创建了第二个用户,再次运行我们的 getUsers 查询。然而,这次,将 active 字段添加到我们想要返回的字段列表中:

图 13.15:getUsers 查询
现在,我们有两个注册用户,并且这两个都是非活动账户("active" = false)。
我喜欢 GraphQL 的一个地方是,当你编写查询或突变,并且你不记得某个字段时,GraphQL 总会显示该查询或突变可用的字段列表。例如,如果你只写密码的字母 p,你会看到类似这样的内容:

图 13.16:自动完成
现在,我们准备尝试登录!
执行登录
我想恭喜你来到这本书的这一部分:我知道我们已经覆盖了很多内容,但我们几乎要完成了!现在,我们将尝试使用 GraphQL 登录(这有多疯狂?)。
首先,我们需要编写我们的登录突变:
mutation($input: LoginInput) {
login(input: $input) {
token
}
}
然后,我们需要使用“fake@email.com”作为我们的电子邮件地址和“123456”作为我们的密码来登录我们的用户。这些信息在我们的数据库中不存在:

图 13.17:使用不存在的登录详情后的无效登录
因为邮箱在我们的数据库中不存在,所以会返回一个无效登录错误信息。现在,让我们添加正确的邮箱,但使用一个假的密码:

图 13.18:输入正确的邮箱但使用假密码后的无效登录
如您所见,我们收到了完全相同的错误(无效登录)。这是因为我们不希望提供太多关于登录错误的详细信息,因为有人可能正在尝试入侵您的系统。如果我们说像“密码无效”或“您的邮箱不在我们的系统中”这样的话,我们就会给攻击者提供可能对他们有用的额外信息。
现在,让我们尝试使用正确的用户名和密码(admin@js.education和123456)进行连接,看看会发生什么:

图 13.19:您的账户尚未激活
现在,我们收到一个错误信息,显示“您的账户尚未激活”。这是正常的,因为我们的用户尚未激活。通常情况下,当用户在一个系统中注册时,你需要发送一个链接到他们的邮箱,以便他们可以激活他们的账户。我们目前没有这个功能,但假设我们已经发送了那封邮件,并且用户已经激活了他们的账户。我们可以通过使用 OnmiDB 或 PgAdmin4 手动更改数据库中的值来模拟这种情况。
我们可以通过执行一个UPDATE SQL 查询来完成这个操作:

图 13.20:UPDATE SQL 查询
现在,让我们再次尝试登录!

图 13.21:登录令牌
好的:我们进去了,宝贝!这是你现在的情况:

图 13.22:匿名
现在我们已经登录并获取了我们的 JWT,让我们复制那个长字符串,并在我们的getUser查询中使用它,看看我们是否能获取用户的数据:

图 13.23:访问令牌
如果一切顺利,你应该能获取到用户的数据:

图 13.24:getUser 数据
如果你更改或删除字符串中的任何字母(意味着令牌无效),你应该会得到空的用户数据:

图 13.25:空的 getUser 数据
现在我们后端的登录系统运行得非常完美,是时候在前端应用程序中实现这个功能了。我们将在下一节中这样做。
使用 Apollo Client 构建前端登录系统
在上一节中,我们学习了如何使用 Apollo Server 创建我们的 GraphQL 查询和突变来构建登录系统的后端。你可能正在想,“太好了,后端已经工作,但我如何在前端使用它?” 你是对的:我总是喜欢用完整的示例来解释事物,而不仅仅是展示基本的东西,即使这会花费更长的时间。那么,让我们开始吧!
你可以在这个部分的代码示例在 github.com/PacktPublishing/React-18-Design-Patterns-and-Best-Practices-Fourth-Edition/tree/main/Chapter13/graphql/frontend 找到。
配置 Webpack 5
我们将不使用 vite 项目,而是从头开始配置一个使用 Webpack 5 和 Node.js 的 React 项目。
我们需要做的第一件事是创建前端目录并安装所有包。为此,我们将执行以下命令:
npm init --yes
npm install @apollo/client@3.7.0 @contentpi/lib@1.0.10 cookie-parser@1.4.6 cors@2.8.5 dotenv-webpack@8.0.1 express@4.18.2 jsonwebtoken@8.5.1 pm2@5.2.2 react@18.2.0 react-dom@18.2.0 react-cookie@4.1.1 react-router-dom@6.4.2 run-script-webpack-plugin@0.1.1 styled-components@5.3.6 typescript-plugin-styled-components@2.0.0 webpack-node-externals@3.0.0
npm install --save-dev @babel/core@7.19.3 @babel/preset-env@7.19.4 @babel/preset-react@7.18.6 @types/node@18.11.3 buffer@6.0.3 cross-env@7.0.3 crypto-browserify@3.12.0 dotenv@16.0.3 html-webpack-plugin@5.5.0 npm-run-all@4.1.5 prettier@2.7.1 stream-browserify@3.0.0 ts-loader@9.4.1 ts-node@10.9.1 ts-node-dev@2.0.0 typescript@4.8.4 webpack@5.74.0 webpack-cli@4.10.0 webpack-dev-server@4.11.1 webpackbar@5.0.2
buffer、crypto-browserify 和 stream-browserify 是在 Webpack 4 及之前版本中默认包含的 polyfills。然而,在最新版本(Webpack 5)中,这些 polyfills 已不再包含,因此你将遇到以下错误:

图 13.26:Webpack < 5 默认包含 Node.js 核心模块的 polyfills
你需要在你的 package.json 中包含以下脚本:
"scripts": {
"build": "npm-run-all clean build:production:*",
"build:production:client": "webpack --env mode=production --env presets=client",
"build:production:server": "webpack --env mode=production --env presets=server",
"clean": "rm -rf dist",
"dev": "cross-env DEBUG=server:* npm-run-all clean serve:dev",
"analyze": "cross-env ANALYZE=true cross-env DEBUG=server:* npm-run-all clean serve:*",
"start": "pm2 start apps.json",
"stop": "pm2 stop apps.json",
"restart": "pm2 restart apps.json",
"serve:dev": "cross-env NODE_ENV=development ts-node ./src/server/devServer.ts",
"webpack": "cross-env NODE_ENV=production webpack",
"lint": "eslint . --ext .js,.tsx,.ts",
"lint:fix": "eslint . --fix --ext .js,.tsx,.ts",
"test": "jest src",
"test:coverage": "jest src --coverage"
}
我喜欢将我的 Webpack 配置拆分为单独的文件,以便更容易地识别客户端、服务器、开发环境和生产环境的配置。首先,让我们在 /frontend/webpack/presets 下创建我们的 presets 目录,然后创建我们的 webpack.client.ts 以指定客户端配置:
import HtmlWebpackPlugin from 'html-webpack-plugin'
import { Configuration } from 'webpack'
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'
import WebpackBar from 'webpackbar'
const isAnalyze = Boolean(process.env.ANALYZE) // This is to analyze the bundles sizes
const webpackClientConfig: (args: { mode: string }) => Configuration = ({ mode }) => {
const isProductionMode = mode === 'production'
const title = 'My Website Title'
const webpackConfig: Configuration = {
entry: {
main: './src/client/index.tsx' // Entry for the client app
},
output: {
publicPath: 'http://localhost:3001/' // This is for webpack-dev-server
},
plugins: [
new HtmlWebpackPlugin({
title,
template: './src/client/index.xhtml',
filename: './index.xhtml'
}),
new WebpackBar({
name: 'client',
color: '#2EA1F8'
})
]
}
if (isProductionMode) {
webpackConfig.output = {
filename: '[name].js',
chunkFilename: '[name].js',
publicPath: '/'
}
}
if (isAnalyze) {
webpackConfig.plugins = [
...(webpackConfig.plugins || []),
new BundleAnalyzerPlugin({
analyzerPort: 9001
})
]
}
return webpackConfig
}
export default webpackClientConfig
这就是我们的客户端预设;现在让我们在 /frontend/webpack/presets/webpack.server.ts 下创建服务器预设:
import { resolve } from 'path'
import { RunScriptWebpackPlugin } from 'run-script-webpack-plugin'
import { Configuration, IgnorePlugin, optimize } from 'webpack'
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'
import nodeExternals from 'webpack-node-externals'
import WebpackBar from 'webpackbar'
const isAnalyze = Boolean(process.env.ANALYZE)
const webpackServerConfig: (args: { mode: string }) => Configuration = ({ mode }) => {
const isDevelopment = mode === 'development'
const webpackConfig: Configuration = {
target: 'node', // Target node is only for server
entry: './src/server/index.ts', // Entry for the server app
output: {
libraryTarget: 'commonjs2',
filename: 'server.js',
path: resolve('dist')
},
externals: [nodeExternals()], // Ignoring all node_modules
plugins: [
new optimize.LimitChunkCountPlugin({
maxChunks: 1
}),
new IgnorePlugin({
resourceRegExp: /\.((sc|c)ss|jpe?g|png|gif|svg)$/i
}),
new WebpackBar({
name: 'server',
color: '#2EA1F8',
profile: true,
basic: false
})
]
}
if (isDevelopment) {
webpackConfig.watch = true
if (webpackConfig.entry instanceof Array) {
webpackConfig.entry.unshift('webpack/hot/poll?300') // This is for HMR
}
if (webpackConfig.plugins instanceof Array) {
webpackConfig.plugins.push(
new RunScriptWebpackPlugin({
name: 'server.js',
nodeArgs: ['--inspect']
})
)
}
webpackConfig.externals = [
nodeExternals({
allowlist: ['webpack/hot/poll?300']
})
]
}
if (isAnalyze) {
webpackConfig.plugins = [
...(webpackConfig.plugins || []),
new BundleAnalyzerPlugin({
analyzerPort: 9002
})
]
}
return webpackConfig
}
export default webpackServerConfig
在创建预设之后,你需要创建一个 loadPresets.ts 文件来处理这些预设。此文件必须位于 /frontend/webpack/loadPresets.ts:
import { Configuration } from 'webpack'
import { merge } from 'webpack-merge'
import { ConfigArgs } from './webpack.types'
const loadPresets: (mode: ConfigArgs) => Promise<Configuration> = async (env) => {
const presets: string[] = ([] as string[]).concat(...[env.presets])
const webpackConfigs = await Promise.all(
presets.map(async (presetName: string) => {
try {
// Dynamically loading the presets
const {default: webpackConfig} = await import(`./presets/webpack.${presetName}`)
return Promise.resolve(webpackConfig(env))
} catch (err) {
return Promise.resolve({})
}
})
)
return merge({}, ...webpackConfigs)
}
export default loadPresets
除了客户端和服务器预设之外,我们还需要创建一些其他配置文件:一个用于开发,另一个用于生产,以及一个将包含两者之间共同配置的文件。首先,让我们在 /frontend/webpack/webpack.common.ts 创建公共配置:
import Dotenv from 'dotenv-webpack'
import { resolve } from 'path'
import createStyledComponentsTransformer from 'typescript-plugin-styled-components'
import { Configuration } from 'webpack'
const styledComponentsTransformer = createStyledComponentsTransformer()
const webpackCommonConfig: () => Configuration = () => {
const webpackConfig: Configuration = {
output: {
path: resolve('dist') // Output by default will be dist directory
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
alias: {
'~': resolve(__dirname, '../src') // Alias for src
},
fallback: {
crypto: require.resolve('crypto-browserify'),
buffer: require.resolve('buffer/'),
stream: require.resolve('stream-browserify')
}
},
optimization: { // This is to split the bundle in main.js (app) and vendor.js (node_modules)
splitChunks: {
cacheGroups: {
default: false,
commons: {
test: /node_modules/,
name: 'vendor',
chunks: 'all'
}
}
}
},
module: {
rules: [
{
test: /\.(woff|woff2)$/, // For loading fonts
use: {
loader: 'url-loader'
}
},
{
test: /\.(ts|tsx)$/, // For loading TypeScript files
exclude: /node_modules/,
use: [
{
loader: 'ts-loader',
options: {
transpileOnly: true,
getCustomTransformers: () => ({
before: [styledComponentsTransformer]
})
}
}
]
}
]
},
plugins: [new Dotenv()] // This will load our .env variables into Webpack
}
return webpackConfig
}
export default webpackCommonConfig
然后,我们需要在 /frontend/webpack/webpack.development.ts 创建开发配置:
import { Configuration, HotModuleReplacementPlugin, NoEmitOnErrorsPlugin } from 'webpack'
const webpackDevConfig: () => Configuration = () => {
const webpackConfig: Configuration = {
mode: 'development',
devtool: 'source-map',
output: {
filename: '[name].js'
},
plugins: [new HotModuleReplacementPlugin(), new NoEmitOnErrorsPlugin()]
}
return webpackConfig
}
export default webpackDevConfig
正如你在开发中看到的,我们包括 HotModuleReplacementPlugin 以实现 HMR,每次我们进行更改时都会重新加载网站。之后,你需要创建生产配置文件在 /frontend/webpack/webpack.production.ts:
import { Configuration } from 'webpack'
const webpackProdConfig: (args: { presets: string[] }) => Configuration = () => {
const webpackConfig: Configuration = {
mode: 'production' // By default this mode minifies all code
}
return webpackConfig
}
export default webpackProdConfig
最后,我们必须在 /frontend/webpack/webpack.types.ts 创建我们的 Webpack 类型文件。这些是我们将用于 Webpack 的 TypeScript 类型:
export type WebpackMode = 'production' | 'development'
export type ConfigArgs = {
mode: WebpackMode
presets: string[]
}
在这一点上,您需要创建index.xhtml文件,该文件应位于/frontend/src/client/index.xhtml。这将是我们的初始 HTML 文件,由HtmlWebpackPlugin处理:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<div id="root"></div>
</body>
</html>
在下一节中,我们将配置我们的 TypeScript。
配置我们的 TypeScript
TypeScript 是 JavaScript 的一种特殊版本,通常是编写 Web 应用的编程语言。TypeScript 有趣之处在于它能够更早地识别我们代码中的错误,这可能会为我们节省大量时间。当在大规模项目中工作时,此功能尤其有价值。因此,我们将为我们的项目使用 TypeScript。现在让我们深入了解设置过程。
我们的tsconfig.json文件应如下所示:
{
"compilerOptions": {
"sourceMap": true,
"target": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "commonjs",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"noImplicitAny": false,
"paths": {
"~/*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "**/*.test.tsx"]
}
现在,让我们学习如何配置 Express 服务器。
配置 Express 服务器
我们的应用程序需要一个 Express 服务器,以便我们可以执行验证。这些验证将帮助我们找出用户是否已连接(使用自定义中间件,我将在后面解释)并且还可以配置我们的 Express 会话。我们在网站上主要有四个路由:
-
/:我们的主页(由 React 处理)。 -
/dashboard:我们的仪表板,受保护。只有具有 god 或 admin 权限的已连接用户允许(首先由 Express 处理,然后由 React 处理)。 -
/login:我们的登录页面(由 React 处理)。 -
/logout:这将删除我们现有的会话(由 Express 处理)。
让我们看看我们的服务器代码。以下文件应位于/frontend/src/server.ts。这是为了创建我们的 Express 应用并运行我们的 React 应用:
import cookieParser from 'cookie-parser'
import cors from 'cors'
import express, { Application, Request, Response } from 'express'
import { resolve } from 'path'
import * as config from '../config'
import html from './html'
import { isConnected } from './lib/middlewares/user'
// Express application
const app: Application = express()
const distDir = resolve('dist')
const staticDir = resolve('src', 'static')
// Middlewares
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
app.use(cookieParser(config.security.secretKey))
app.use(cors({ credentials: true, origin: true }))
// Static directories
app.use(express.static(distDir))
app.use(express.static(staticDir))
// Routes
app.get('/login', isConnected(false), (req: Request, res: Response) => {
res.send(html({ title: 'My Website' }))
})
app.get(`/logout`, (req: Request, res: Response) => {
const redirect: any = req.query.redirectTo || '/'
res.clearCookie('at')
res.redirect(redirect)
})
app.get('*', (req: Request, res: Response) => {
res.send(html({ title: 'My Website' }))
})
export default app
如您所见,我们使用isConnected中间件保护我们的仪表板路由。在这里,我们验证我们只接受在登录路由中未连接的用户。
创建我们的前端配置
现在,我们需要创建我们的前端配置。因此,让我们在/frontend/src/config.ts创建配置。此文件将帮助我们管理我们的 GraphQL 端口和服务器,以及集成安全配置,如我们的密钥和过期选项:
// Types
type API = {
uri: string
}
type Security = {
secretKey: string
expiresIn: string
}
// Environment Configuration
export const isProduction: boolean = process.env.NODE_ENV === 'production'
export const isDevelopment: boolean = process.env.NODE_ENV !== 'production'
// Server Configuration
const devUrl = 'localhost'
const prodUrl = 'localhost' // change this to your production url
export const PORT: number = Number(process.env.PORT) || 3000
export const DEV_SERVER_PORT = 3001
export const GRAPHQL_PORT = 4000
export const GRAPHQL_SERVER = isDevelopment ? devUrl : prodUrl
// Paths Configuration
export const domain: string = devUrl
export const baseUrl: string = isProduction
? `https://${domain}:${PORT}`
: `http://${domain}:${PORT}` // Remove port in actual production
export const publicPath: string = isProduction
? ``
: `http://${domain}:${DEV_SERVER_PORT}/`
// API Configuration
export const api: API = {
uri: `http://${GRAPHQL_SERVER}:${GRAPHQL_PORT}/graphql`
}
// Security Configuration
export const security: Security = {
secretKey: process.env.SECURITY_SECRET_KEY || '',
expiresIn: '7d'
}
接下来,我们需要创建一个用户调用的中间件以及jwt函数来验证用户是否已连接并且具有正确的权限。
创建用户中间件
在 Web 开发中,中间件是一个可以访问请求对象(req)、响应对象(res)以及应用请求-响应周期中的下一个函数的功能。下一个函数是 Express 路由器中的一个函数,当被调用时,会执行当前中间件之后的中间件。这创建了一个函数链,每个函数都可以执行特定的任务或根据需要修改请求和响应对象。通过利用中间件,您可以简化代码并简化复杂的过程。
以下图表提供了中间件流程的视觉表示:

图 13.27:中间件流程的视觉表示
在我们的案例中,我们将创建 isConnected 中间件来验证用户是否已连接并且具有正确的权限。如果没有,我们将中断流程并将他们重定向到登录页面。如果用户有效,我们将执行下一个中间件,这将渲染我们的 React 应用程序。以下图表描述了此过程:

图 13.28:认证中间件
让我们将理论部分应用到我们的代码中。所需的文件应位于 /frontend/src/server/lib/middlewares/user.ts:
import { NextFunction, Request, Response } from 'express'
import { getUserData } from '../jwt'
export const isConnected = (isLogged = true, roles = ['user'], redirectTo = '/') =>
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const user = await getUserData(req.cookies.at)
if (!user && !isLogged) {
return next()
}
if (user && isLogged) {
if (roles.includes('god') && roles.role === 'god') {
return next()
}
if (roles.includes('admin') && user.role === 'admin') {
return next()
}
if (roles.includes('user') && user.role === 'user') {
return next()
}
res.redirect(redirectTo)
} else {
res.redirect(redirectTo)
}
}
基本上,通过这个中间件,我们可以控制是否要验证用户是否已连接(isLogged = true)。然后,我们可以验证特定的角色(roles = ['god', 'admin']),如果用户未连接或没有正确的角色,则将用户重定向(redirectTo = '/')。
如您所见,我们正在使用来自 jwt 的 getUserData 函数。我们将在下一节创建我们的 jwt 函数。
创建 JWT 函数
在我之前解释后端代码时,我提到了 JWT。在前端,我们需要这些函数来验证我们的令牌并获取用户数据。让我们在 /frontend/src/server/lib/jwt.ts 创建包含以下代码的文件:
import { getBase64 } from '@contentpi/lib'
import jwt from 'jsonwebtoken'
import * as config from '~/config'
const { security: { secretKey } } = config
export function jwtVerify(accessToken: string, cb: any) {
jwt.verify(accessToken, secretKey, (error: any, accessTokenData: any = {}) => {
const { data: user } = accessTokenData
if (error || !user) {
return cb(null)
}
const userData = getBase64(user)
return cb(userData)
})
}
export async function getUserData(accessToken: string): Promise<any> {
const UserPromise = new Promise(
(resolve) => jwtVerify(accessToken, (user: any) => resolve(user))
)
const user = await UserPromise
return user
}
如您所见,我们的 getUserData 函数将使用 accessToken 从 cookies 中检索用户数据,这是我们之前获取的。
为了安全原因和确保用户的身份得到验证,JWT 必须有效。服务器在用户发起请求时验证此令牌。如果令牌无效,服务器将不会满足用户的需求。此外,令牌有助于保护用户信息,因为它不能在没有服务器知识的情况下被更改。此外,这些令牌有有效期,需要用户再次登录。这防止了未经授权的个人使用被盗令牌来冒充用户。因此,确保 JWT 的有效性至关重要。
创建我们的 GraphQL 查询和突变
我们已经在我们的后端项目中创建了所需的查询和突变。然而,此时我们需要在我们的前端项目中创建一些文件来执行它们。目前,我们只需要定义我们的 getUserData 查询和登录突变,以便在前端执行登录。
让我们在 /frontend/src/client/graphql/user/getUser.query.ts 创建我们的 getUser 查询:
import { gql } from '@apollo/client'
export default gql`
query getUser($at: String!) {
getUser(at: $at) {
id
email
username
role
active
}
}
`
我们的登录突变应在 /frontend/src/graphql/user/login.mutation.ts:
import { gql } from '@apollo/client'
export default gql`
mutation login($email: String!, $password: String!) {
login(input: { email: $email, password: $password }) {
token
}
}
`
现在我们已经定义了查询和突变,让我们创建用户上下文,以便我们可以使用它们。
创建用户上下文以处理登录和已连接用户
在我们的用户上下文中,我们将有一个登录方法,它将执行我们的突变并验证电子邮件和密码是否正确。我们还将导出用户数据。
让我们在 /frontend/src/client/contexts/user.tsx 创建此上下文:
import { useMutation, useQuery } from '@apollo/client'
import { getGraphQlError, redirectTo } from '@contentpi/lib'
import { createContext, FC, ReactElement, useEffect, useState } from 'react'
import { useCookies } from 'react-cookie'
import GET_USER_QUERY from '../graphql/user/getUser.query'
import LOGIN_MUTATION from '../graphql/user/login.mutation'
// Interfaces
interface IUserContext {
login(input: any): any
connectedUser: any
}
interface IProps {
page?: string
children: ReactElement
}
// Creating context
export const UserContext = createContext<IUserContext>({
login: () => null,
connectedUser: null
})
const UserProvider: FC<IProps> = ({ page = '', children }) => {
const [cookies, setCookie] = useCookies()
const [connectedUser, setConnectedUser] = useState(null)
// Mutations
const [loginMutation] = useMutation(LOGIN_MUTATION)
// Queries
const { data: dataUser } = useQuery(GET_USER_QUERY, {
variables: {
at: cookies.at || ''
}
})
// Effects
useEffect(() => {
if (dataUser) {
if (!dataUser.getUser.id && page !== 'login') {
// If the user session is invalid and is on a different page than login
// we redirect them to login
redirectTo('/login?redirectTo=/dashboard')
} else {
// If we have the user data available we save it in our connectedUser state
setConnectedUser(dataUser.getUser)
}
}
}, [dataUser, page])
async function login(input: { email: string; password: string }): Promise<any> {
try {
// Executing our loginMutation passing the email and password
const { data: dataLogin } = await loginMutation({
variables: {
email: input.email,
password: input.password
}
})
if (dataLogin) {
// If the login was success, we save the token in our "at" cookie
setCookie('at', dataLogin.login.token, { path: '/' })
return dataLogin.login.token
}
} catch (err) {
// If there is an error we return it
return getGraphQlError(err)
}
}
// Exporting our context
const context = {
login,
connectedUser
}
return <UserContext.Provider value={context}>{children}</UserContext.Provider>
}
export default UserProvider
如您所见,我们处理了登录,并在我们的上下文中有了 connectedUser 数据。在这里,我们始终执行 GET_USER_QUERY 以验证用户是否已连接(验证数据库而不是仅通过 cookies)。
配置 Apollo 客户端
到目前为止,我们已经创建了大量的代码,但如果我们不配置 Apollo 客户端,那么这些代码将无法工作。要配置 Apollo 客户端,我们需要将其添加到我们的索引文件 /frontend/src/client/index.tsx 中:
import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client'
import { render } from 'react-dom'
import * as config from '../config'
import AppRoutes from './AppRoutes'
const client = new ApolloClient({
uri: config.api.uri,
cache: new InMemoryCache()
})
render(
<ApolloProvider client={client}>
<AppRoutes />
</ApolloProvider>,
document.querySelector('#root')
)
基本上,我们传递 config.api.uri,这是 GraphQL Playground 运行的地方 (localhost:4000/graphql),然后使用 ApolloProvider 组件包装我们的 AppRoutes 组件。
创建我们的应用路由
我们将使用 react-router-dom 创建我们的应用程序路由。让我们在 /frontend/src/client/AppRoutes.tsx 创建所需的代码:
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'
import DashboardPage from './pages/dashboard'
import Error404 from './pages/error404'
import HomePage from './pages/home'
import LoginPage from './pages/login'
const AppRoutes = () => (
<>
<Router>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/login" element={<LoginPage />} />
<Route element={<Error404 />} />
</Routes>
</Router>
</>
)
export default AppRoutes
如您所见,我们正在向我们的路由中添加一些页面,例如 HomePage、DashboardPage(受保护)和 LoginPage。如果用户尝试访问不同的 URL,那么我们将显示 Error404 组件。我们将在下一节中创建这些页面。
创建我们的页面
Home 页面应该位于 /frontend/src/client/pages/home.tsx:
const Page = () => (
<div className="home">
<h1>Home</h1>
<ul>
<li><a href="/dashboard">Go to Dashboard</a></li>
</ul>
</div>
)
export default Page
Dashboard 页面应该位于 /frontend/src/client/pages/dashboard.tsx:
import DashboardLayout from '../components/dashboard/DashboardLayout'
import UserProvider from '../contexts/user'
const Page = () => (
<UserProvider>
<DashboardLayout />
</UserProvider>
)
export default Page
Login 页面应该位于 /frontend/src/client/pages/login.tsx:
import { isBrowser } from '@contentpi/lib'
import { FC, ReactElement } from 'react'
import LoginLayout from '../components/users/LoginLayout'
import UserProvider from '../contexts/user'
interface IProps {
currentUrl?: string
}
const Page: FC<IProps> = ({
currentUrl = isBrowser() ? window.location.search.replace('?redirectTo=', '') : ''
}) => (
<UserProvider page="login">
<LoginLayout currentUrl={currentUrl} />
</UserProvider>
)
export default Page
最后,我们需要创建我们的 404 错误页面 (/frontend/src/client/pages/error404.tsx):
const Page = () => (
<div className="error404">
<h1>Error404</h1>
</div>
)
export default Page
我们几乎完成了。这个谜题的最后一块是创建 Login 和 Dashboard 组件。我们将在下一节中完成。
创建我们的登录组件
我为我们的登录和仪表板创建了一些基本组件。当然,它们的样式可以改进,但让我们看看它们是如何工作的,以及我们的登录系统将看起来如何。
您需要创建的第一个文件是位于 /frontend/src/client/components/users/LoginLayout.tsx 的 LoginLayout.tsx:
import { FC, useContext } from 'react'
import { UserContext } from '../../contexts/user'
import Login from './Login'
// Interfaces
interface IProps {
currentUrl: string
}
const Layout: FC<IProps> = ({ currentUrl }) => {
const { login } = useContext(UserContext)
return <Login login={login} currentUrl={currentUrl} />
}
export default Layout
布局文件在我们想要为组件添加特定布局时很有用。它还有助于从上下文中获取数据并将数据或函数作为属性传递。
我们的 Login 组件应该看起来像这样 (/frontend/src/client/components/users/Login.tsx):
import { redirectTo } from '@contentpi/lib'
import { ChangeEvent, FC, useState } from 'react'
import { IUser } from '../../types'
import { StyledLogin } from './Login.styled'
interface IProps {
login(input: any): any
currentUrl: string
}
const Login: FC<IProps> = ({ login, currentUrl }) => {
const [values, setValues] = useState({
email: '',
password: ''
})
const [errorMessage, setErrorMessage] = useState('')
const [invalidLogin, setInvalidLogin] = useState(false)
const onChange = (e: ChangeEvent<HTMLInputElement>): void => {
const { target: { name, value } } = e
if (name) {
setValues((prevValues: any) => ({
...prevValues,
[name]: value
}))
}
}
const handleSubmit = async (user: IUser): Promise<void> => {
// Here we execute the login mutation
const response = await login(user)
if (response.error) {
setInvalidLogin(true)
setErrorMessage(response.message)
} else {
redirectTo(currentUrl || '/')
}
}
return (
<>
<StyledLogin>
<div className="wrapper">
{invalidLogin && <div className="alert">{errorMessage}</div>}
<div className="form">
<p>
<input
autoComplete="off"
type="email"
className="email"
name="email"
placeholder="Email"
onChange={onChange}
value={values.email}
/>
</p>
<p>
<input
autoComplete="off"
type="password"
className="password"
name="password"
placeholder="Password"
onChange={onChange}
value={values.password}
/>
</p>
<div className="actions">
<button name="login" onClick={() => handleSubmit(values)}>Login</button>
</div>
</div>
</div>
</StyledLogin>
</>
)
}
export default Login
我们将接下来创建 dashboard 组件。
创建我们的仪表板组件
在创建我们的 dashboard 组件时,第一个应该是位于 /frontend/src/client/components/dashboard/DashboardLayout.tsx 的 DashboardLayout.tsx 文件:
import { FC, useContext } from 'react'
import { UserContext } from '../../contexts/user'
import Dashboard from './Dashboard'
const Layout: FC = () => {
const { connectedUser } = useContext(UserContext)
// We only render the Dashboard if the user is connected
if (connectedUser) {
return <Dashboard connectedUser={connectedUser} />
}
return <div />
}
export default Layout
这就是我们保护 dashboard 页面以仅允许已连接用户的方法。现在,让我们在 /frontend/src/components/dashboard/Dashboard.tsx 创建我们的 dashboard 组件:
interface IProps {
connectedUser: any
}
const Dashboard = ({ connectedUser }) => (
<div className="dashboard">
<h1>Welcome, {connectedUser.username}!</h1>
<ul>
<li><a href="/logout">Logout</a></li>
</ul>
</div>
)
export default Dashboard
有了这些,我们就完成了!我们将在下一节测试登录系统。
测试我们的登录系统
如果您正确地遵循了前面的章节,那么您应该能够成功运行登录系统。为此,我们需要打开三个终端:
-
在第一个中,您需要运行您的后端项目(npm run dev)。
-
在另一个方面,您需要在前端项目中运行 Node.js 服务器(npm run dev)。
第三个终端是当您第一次打开http://localhost:3000时,您应该能够看到Home页面:

图 13.29:主页
然后,如果您点击转到仪表板(localhost:3000/dashboard)链接,您将被重定向到localhost:3000/login?redirectTo=/dashboard,如下面的截图所示:

图 13.30:登录页面
这是我们的登录表单。如果您尝试使用虚假凭证登录,您应该会收到一个错误:

图 13.31:无效登录
如果您想查看 GraphQL 请求,您可以在头部选项卡上这样做:

图 13.32:GraphQL 请求
在这里,您可以查看您执行的查询和您发送的变量(电子邮件和密码)。您可以在预览选项卡上看到响应:

图 13.33:无效登录
如您所见,我们得到了一个"Invalid Login"错误消息,这就是为什么我们在login组件中渲染它。
现在,让我们尝试使用正确的账户(admin@js.education和123456)进行连接。如果您的登录正确,那么您应该被重定向到仪表板,在那里您将看到以下页面:

图 13.34:欢迎,admin!页面
此外,您还可以查看用于检索用户数据的查询(getUser):

图 13.35:获取用户数据
在这里,您将看到返回的负载:

图 13.36:getUserData 负载
我们从访问令牌(at)中获取用户信息。如果您刷新页面,您应该保持连接到页面。这是因为我们保存了一个包含我们的令牌的 cookie:

图 13.37:Cookies
现在,让我们尝试通过更改令牌中的任何字母来修改 cookie。例如,让我们将前两个字母(ey)更改为XX:

图 13.38:更新 cookie
在这里,您将收到用户的空数据。这将使会话无效,并再次将您重定向到登录页面:

图 13.39:空数据
现在,你已经学会了如何在后端实现 GraphQL 以及如何在前端消费查询和突变。
这个登录系统是我正在 YouTube 上开设的一门课程的组成部分,我在课程中教授观众如何从头开始开发一个无头 CMS,所以如果你渴望了解更多,你可以查看课程www.youtube.com/watch?v=4n1AfD6aV4M。
摘要
我希望你觉得这一章关于 GraphQL、JWT 创建、登录功能以及 Sequelize 模型创建的内容既信息丰富又引人入胜。它提供了大量宝贵的见解和实用技巧,你可以将这些应用到自己的项目中,帮助你简化开发过程,更高效地实现目标。通过掌握这些概念,你将更好地装备自己,构建强大、可扩展的应用程序,满足用户需求并推动你的成功。
感谢您的阅读,我期待在下一章中与您分享更多内容,届时你将学习如何创建单仓库和多站点项目。
第十四章:单仓库架构
当我们思考构建应用程序时,我们通常会谈论一个应用程序、一个git仓库和构建输出。然而,这种应用程序和仓库的配置并不总是反映开发者的实际体验。通常,组织会使用一个包含所有可能用于共同开发的程序、组件和库的单个仓库。这些被称为单仓库或单一仓库,并且它们正变得越来越受欢迎。
那么,是什么让单仓库对组织变得有趣?为什么要把所有代码放在一个地方?为什么不只有一个包含许多小型和独立仓库的单一git仓库?如果我们把所有代码都保存在一个项目中。
通过将所有代码保存在一个仓库中,你可以确保整个组织中的所有依赖项都保持最新。这可能是单仓库最大的好处。这样我们就可以停止浪费时间去更新几个不同项目的所有依赖项。
在本章中,我们将通过使用 TypeScript、webpack 和 NPM Workspaces 来创建包含多个包的单仓库进行讲解。
我们将探讨以下主题:
-
单仓库的优势及其解决的问题
-
如何创建单仓库
-
在单仓库中实现 TypeScript
-
创建一个
devtools包以使用 Webpack 编译其他包 -
创建一个
utils包 -
如何创建多站系统
技术要求
要完成本章,你需要以下内容:
-
Node.js 19+
-
Visual Studio Code
你可以在本书的 GitHub 仓库中找到本章的代码:github.com/PacktPublishing/React-18-Design-Patterns-and-Best-Practices-Fourth-Edition/tree/main/Chapter14。
单仓库的优势及其解决的问题
MonoRepo(单仓库)的一些优势包括:
-
共享变得简单:由于所有代码都在一个地方,利用相同的代码或工具跨多个项目变得更加容易,从而节省宝贵的时间和精力。
-
避免混淆:在 MonoRepo 中,每个项目都使用共享组件的同一版本,消除了不同版本之间兼容性问题的担忧。
-
一次性更改所有内容:在单仓库(MonoRepo)中,同时跨所有项目进行更改变得简单直接,这与在单独的仓库中管理单个项目的复杂性形成对比。
-
分组更改:在 MonoRepo 中同时修改多个项目确保所有相关组件保持同步,从而实现高效和连贯的更新。
-
人人皆可看到一切:由于所有代码都集中在一个仓库中,所有开发者都可以访问它,这有助于更好地理解整个系统,并促进有效的协作。
现在让我们来探讨一些 MonoRepo 解决的实际问题:
-
更新更快:使用 MonoRepo,你可以一次性更新所有项目。没有它,你将不得不单独更新每个项目,这可能会花费很多时间。
-
不再混淆:没有 MonoRepo,不同的项目可能会使用相同事物的不同版本,这可能会引起问题。在 MonoRepo 中,所有东西都使用相同的版本,因此不会有混淆。
-
更好的团队合作:当所有代码都在一个地方时,开发者可以轻松地看到和理解其他人做了什么。这有助于他们更好地合作。
-
新手更容易上手:对于新团队成员来说,当所有代码都在一个地方时,开始工作会更容易。他们可以快速理解整个系统,而不是需要在不同地方搜索。
重要的是要记住,MonoRepos 可能并不总是最佳选择。它们可能会引入自己的挑战,例如当它们变得过大时可能出现的性能问题和复杂性增加。是否采用 MonoRepo 取决于团队的具体需求和项目规模。
在下面的图像中,你可以看到MonoRepo的结构与多仓库的结构有何不同:

图 14.1:多仓库与单仓库对比
现在我们已经阐明了单仓库的概念,并探讨了为什么它越来越受到组织的青睐,我们将深入探讨使用 NPM Workspaces 实现单仓库的实践方法。
使用 NPM Workspaces 创建 MonoRepo
NPM Workspaces是在 NPM 7 中引入的,是一个通用术语,指的是 npm CLI 中提供支持管理本地文件系统中多个包的一组功能,这些包位于单个顶级根包内部。
为了创建一个单仓库,你需要首先创建一个根package.json文件,它应该包含以下代码:
{
"name": "web-creator",
"private": true,
"workspaces": [
"packages/*"
]
}
我们将命名我们的 MonoRepo 为web-creator。我们需要指定web-creator将是私有的(只有根目录),并且我们需要指定我们的包将驻留的工作区,即"packages/*";这里的*表示我们将包括packages文件夹下存在的任何目录。之后,你需要直接创建包。
让我们在新的packages文件夹内创建两个目录:"packages/api"和"packages/frontend"。现在转到你的api项目并运行npm init -y:
cd packages/api
npm init -y
一旦运行该命令,它将创建一个package.json文件,如下所示:
{
"name": "api",
"version": "1.0.0",
"main": "index.js",
"author": "",
"license": "ISC"
}
如你所见,该包的默认名称将是"api",但为了将此包连接到我们的主单一仓库,我们需要使用特殊格式调用它;在这种情况下,你需要将其重命名为"@<name_of_root_package>/api",在我们的例子中将是"@web-creator/api"。你的package.json应该像这样:
{
"name": "@web-creator/api",
"version": "1.0.0",
"main": "index.js",
"author": "",
"license": "ISC"
}
现在,你需要在你的api目录(稍后我们将将其更改为 TypeScript)中创建一个文件(packages/api/index.js),包含以下代码:
module.export = () => console.log("I'm the API package")
在此之后,你需要前往你的frontend包(packages/frontend)并运行相同的npm init -y命令:
cd packages/frontend
npm init -y
此外,你还需要重命名那个包@web-creator/frontend:
{
"name": "@web-creator/frontend",
"version": "1.0.0",
"main": "index.js",
"author": "Carlos Santana",
"license": "ISC"
}
单一仓库现在可以共享包了。假设你现在想在你的frontend包中消费你的api包。为此,你需要将api包指定为依赖项,并将该api包中的相同版本放入其中;在这种情况下,版本将是 1.0.0。你需要非常小心,除非你真的需要,否则不要更改此版本,如果你更改了它,那么你还需要在dependencies节点上更新它。
这将是你的packages/frontend中的package.json:
{
"name": "@web-creator/frontend",
"version": "1.0.0",
"main": "index.js",
"author": "Carlos Santana",
"license": "ISC",
"dependencies": {
"@web-creator/api": "1.0.0" // this version needs to match with the API package.json
}
}
在你指定了api包作为依赖项之后,你需要在前端项目中运行npm install。你将注意到一个非常有趣的事情,即使你在frontend包(packages/frontend)内部运行npm install命令,你的node_modules文件夹也会在根级别创建,看起来像这样:

图 14.2:Monorepo 结构
如果一切按预期工作,你可以在你的frontend包中消费你的api包。为此,你需要在packages/frontend/index.js中创建一个index.js文件,包含以下代码:
const api = require('@web-creator/api')
api()
现在,你可以使用node执行你的索引文件,并看到来自api包的控制台消息:

图 14.3:运行前端
单一仓库最大的优点之一是,如果你更新了你的 API index.js,变化将立即反映出来,无需你编译任何内容或发布任何包到 NPM 注册表。这对开发者在编码大型项目时非常有帮助。现在让我们将我们的消息更改为I'm the API package UPDATED在packages/api/index.js中,并再次使用node运行index.js:

图 14.4:更新 API
恭喜你,你已经成功创建了你的第一个 MonoRepo!在下一节中,我们将把我们的 MonoRepo 转换为使用 TypeScript。
在我们的 MonoRepo 中实现 TypeScript
在接下来的章节中,我将概述创建多站点的项目的步骤。由于涉及的代码量很大,我无法将所有内容都包含在这本书中。然而,我邀请您查看在 github.com/PacktPublishing/React-18-Design-Patterns-and-Best-Practices-Fourth-Edition/tree/main/Chapter14/web-creator 可用的存储库中的完整代码。
为了将 TypeScript 添加到你的项目中,你需要将 typescript 包安装到根级别:
npm install -D typescript
之后,你还需要在根级别创建 tsconfig.json 文件,代码如下:
{
"extends": "./tsconfig.common.json",
"compilerOptions": {
"baseUrl": "./packages",
"paths": {
"@web-creator/*": ["*/src"]
}
}
}
如你所见,我们将 tsconfig.json 文件扩展到 tsconfig.common.json,这是因为我们不希望重复每个我们想要转换为 TypeScript 的包。我们想要指定的唯一的 compilerOptions 是 packages 目录上的 baseUrl,在路径中我们将指定我们的 MonoRepo 的名称,以便能够在代码中进行导入。这是你需要创建的 tsconfig.common.json 文件:
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"alwaysStrict": true,
"declaration": true,
"declarationMap": true,
"downlevelIteration": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"jsx": "react-jsx",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "commonjs",
"moduleResolution": "node",
"noEmit": false,
"noFallthroughCasesInSwitch": false,
"noImplicitAny": true,
"noImplicitReturns": true,
"outDir": "dist",
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"suppressImplicitAnyIndexErrors": false,
"target": "ESNext"
},
"exclude": ["node_modules", "dist", "coverage", ".vscode", "**/__tests__/*"]
}
我们项目的架构将看起来像这样:

图 14.5:Web 创建器架构
现在我们已经探讨了项目的架构,让我们深入了解第一个将管理我们的 Webpack 配置的包。这个包将被称为 devtools。
创建一个用于使用 Webpack 编译包的 devtools 包
我们需要创建的第一个包,以便能够编译其他包,称为 devtools,应该创建在 packages/devtools 中。让我们看看它在 package.json 文件中应该是什么样子:
{
"name": "@web-creator/devtools",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "npm-run-all clean compile",
"clean": "rm -rf ./dist",
"compile": "tsc",
"lint": "npm run --prefix ../../ lint",
"lint:fix": "npm run --prefix ../../ lint:fix"
},
"author": "Carlos Santana",
"license": "MIT",
"devDependencies": {
"@types/cli-color": "².0.2",
"@types/ip": "¹.1.0",
"@types/webpack-bundle-analyzer": "⁴.6.0",
"@types/webpack-node-externals": "².5.3"
},
"dependencies": {
"@svgr/webpack": "⁶.5.1",
"@types/file-loader": "⁵.0.1",
"cli-color": "².0.3",
"css-loader": "⁶.7.3",
"dotenv": "¹⁶.0.3",
"file-loader": "⁶.2.0",
"html-webpack-plugin": "⁵.5.0",
"path-browserify": "¹.0.1",
"run-script-webpack-plugin": "⁰.1.1",
"style-loader": "³.3.1",
"ts-loader": "⁹.4.2",
"typescript-plugin-styled-components": "².0.0",
"webpack": "⁵.75.0",
"webpack-bundle-analyzer": "⁴.7.0",
"webpack-dev-server": "⁴.11.1",
"webpack-node-externals": "³.0.0",
"webpackbar": "⁵.0.2"
}
}
在创建 package.json 之后,你需要为 devtools 创建 tsconfig.json 文件。每个包都将有自己的 tsconfig.json 文件。基本上,我们将从根目录扩展我们的 tsconfig.common.json,并指定 outDir 并包含 src 文件夹内的文件:
{
"extends": "../../tsconfig.common.json",
"compilerOptions": {
"outDir": "./dist"
},
"include": ["src/**/*"]
}
创建一个彩色的日志
我们需要创建一个日志函数,这将帮助我们突出显示我们将要实现的 Webpack 配置,为此我们将使用 cli-color 包,该包为日志添加颜色。你需要创建位于 packages/devtools/src/cli/log.ts 下的文件:
import cliColor from 'cli-color'
type Args = {
text?: string
tag?: string
json?: any
type?: 'info' | 'error' | 'warning'
}
export const log = (args: Args | any) => {
const blockColor: any = {
info: cliColor.bgCyan.whiteBright.bold,
error: cliColor.bgRed.whiteBright.bold,
warning: cliColor.bgYellow.blackBright.bold
}
const textColor: any = {
info: cliColor.blue,
error: cliColor.red,
warning: cliColor.yellow
}
if (typeof args === 'string') {
console.info(textColor.info(args))
}
const { tag, json, type } = args
if (tag && json) {
console.info(blockColortype}`))
console.info(textColortype))
console.info(blockColortype} >>>`))
}
}
Webpack 通用配置
现在我们已经准备好了日志函数,我们将继续创建 Webpack 配置。我们将把 Webpack 配置分成三个文件:webpack.common.ts、webpack.development.ts 和 webpack.production.ts。通用配置将与开发和生产分别合并。然而,在创建我们的通用配置之前,我们需要创建我们的 Webpack 类型,并且你需要将此文件添加到 packages/devtools/src/webpack/webpack.types.ts:
export type WebpackMode = 'production' | 'development'
export type ConfigType = 'web' | 'package'
export type Package = 'api' | 'design-system' | 'frontend' | 'utils'
export type ConfigArgs = {
mode: WebpackMode
type: ConfigType
sandbox?: 'true' | 'false'
packageName: Package
}
export type ModeArgs = {
configType: ConfigType
packageName: Package
mode?: WebpackMode
sandbox?: boolean
devServer?: boolean
isAnalyze?: boolean
port?: number
analyzerPort?: number
color?: string
htmlOptions?: {
title: string
template: string
}
}
现在让我们创建我们的 webpack.common.ts 文件,从我们需要导入的包开始:
import HtmlWebPackPlugin from 'html-webpack-plugin'
import path from 'path'
import createStyledComponentsTransformer from 'typescript-plugin-styled-components'
import { Configuration } from 'webpack'
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'
import nodeExternals from 'webpack-node-externals'
import { ModeArgs } from './webpack.types'
接下来,我们需要创建getWebpackCommonConfig函数,它将接收来自终端的参数来通过 NPM 脚本来编译每个包:
const getWebpackCommonConfig = (args: ModeArgs): Configuration => {
const {
configType, // it can be "web" or "package"
isAnalyze,
port = 3000,
mode,
analyzerPort = 9001,
packageName,
htmlOptions,
sandbox,
devServer
} = args
// Here goes the next block of codes
}
export default getWebpackCommonConfig
在这一章的 GitHub 仓库中,您将看到的代码块需要添加到前一个代码中的注释位置:// 这里是下一个代码块。
首先,让我们检查我们是否正在运行沙盒(这将是我们的设计系统包)。如果是,我们将使用端口8080,如果不是,我们将使用port + 1(默认为3001):
const devServerPort = sandbox && devServer ? 8080 : port + 1
我们需要创建的第一个配置选项是entry,它将定义我们将用于编译项目的索引文件,基于我们在脚本中指定的packageName。我们可以通过运行以下代码来创建entry:
// Client Entry
const entry = configType !== 'web'
? path.resolve(__dirname, `../../../${packageName}/src/index.ts`)
: path.resolve(__dirname, `../../../${packageName}/src/index.tsx`)
当我们的configType是"package"(或不同于'web')时,我们将指定index.ts作为入口,而对于网络包,我们将使用index.tsx。
我们需要创建的第二个配置选项将是resolve节点,它将包括我们想要支持的扩展和每个包的别名(~)。在 Webpack 5 中,我们必须关闭一些默认未启用的回退包:
// Resolve
const resolve = {
extensions: ['*', '.ts', '.tsx', '.js', '.jsx'],
alias: {
'~': path.resolve(__dirname, `../../../${packageName}/src`)
},
fallback: {
buffer: false,
crypto: false,
stream: false,
querystring: false,
os: false,
zlib: false,
http: false,
https: false,
url: false,
path: require.resolve('path-browserify')
}
}
第三个配置选项是output,它将指定我们将放置编译后的项目的地方(dist目录),这将是一个动态的文件名([name].js)。如果我们想要编译一个包,我们将添加必要的选项来能够导出该包(libraryTarget、library、umdNamedDefine和globalObject):
// Output
const output = {
path: path.resolve(__dirname, `../../../${packageName}/dist`),
filename: '[name].js',
...(sandbox && {
publicPath: '/',
chunkFilename: '[name].js'
}),
...(configType === 'package' && !sandbox && {
filename: 'index.js',
libraryTarget: 'umd',
library: 'lib',
umdNamedDefine: true,
globalObject: 'this'
})
}
第四个配置选项是plugins,它将根据某些条件应用,取决于我们是否想要分析我们的包大小(BundleAnalyzerPlugin)并添加带有HtmlWebPackPlugin的模板:
// Plugins
const plugins = []
if (isAnalyze) {
plugins.push(
new BundleAnalyzerPlugin({
analyzerPort
})
)
}
if (mode === 'development' && htmlOptions?.title && htmlOptions.template) {
plugins.push(
new HtmlWebPackPlugin({
title: htmlOptions.title,
template: path.resolve(__dirname, `../../../${packageName}/${htmlOptions.template}`),
filename: './index.xhtml'
})
)
}
第五个配置选项是rules,我们将根据我们想要处理的扩展文件来定义。我们还将使用 Webpack 加载器,如ts-loader来加载 TypeScript 文件或svg-url-loader和@svgr/webpack来加载 SVG 文件:
// Rules
const rules = []
rules.push({
test: /\.(tsx|ts)$/,
exclude: /node_modules/,
loader: 'ts-loader',
options: {
getCustomTransformers: () => ({
before: [
createStyledComponentsTransformer({
displayName: true,
ssr: true,
minify: true
})
]
})
}
})
if (packageName === 'design-system') {
const svgUrlLoaderInclude: Record<string, string[]> = {
'design-system': [
path.resolve(__dirname, '../../../design-system/src/components/Spinner/loaders'),
path.resolve(__dirname, '../../../design-system/src/components/Dialog/icons'),
path.resolve(__dirname, '../../../design-system/src/icons')
]
}
const svgrWebpackInclude: Record<string, string[]> = {
'design-system': [
path.resolve(__dirname, '../../../design-system/src/components/Icon/icons')
]
}
rules.push({
test: /\.svg$/,
oneOf: [
{
use: 'svg-url-loader',
include: configType === 'package' ? svgUrlLoaderInclude[packageName] ?? [] : []
},
{
use: '@svgr/webpack',
include: configType === 'package' ? svgrWebpackInclude[packageName] ?? [] : []
}
]
})
}
if (configType === 'package' && sandbox) {
rules.push({
test: /\.(jpe?g|png|gif|svg)$/i,
use: [{ loader: 'file-loader', options: {} }]
})
}
最后,我们将所有选项一起放入webpackConfig对象中:
const webpackConfig = {
entry,
...(configType === 'package' && sandbox && {
entry: path.resolve(__dirname, `../../../${packageName}/sandbox/index.tsx`)
}),
...(devServer && {
devServer: {
historyApiFallback: true,
static: output.path,
port: devServerPort
}
}),
...(!sandbox && {
externals: [nodeExternals()]
}),
output,
resolve,
plugins,
module: {
rules
},
...(configType !== 'web' && !sandbox && {
target: 'node'
})
}
return webpackConfig as Configuration
Webpack 开发配置
在创建我们的 Webpack 通用配置文件后,我们现在需要创建我们的webpack.development.ts文件,这个文件比通用文件小得多,并将扩展该配置(在webpack.config.ts上),指定 Webpack 的开发模式,添加源映射,并传递HMR插件:
import {
Configuration as WebpackConfiguration,
HotModuleReplacementPlugin,
NoEmitOnErrorsPlugin
} from 'webpack'
import { Configuration as WebpackDevServerConfiguration } from 'webpack-dev-server'
interface Configuration extends WebpackConfiguration {
devServer?: WebpackDevServerConfiguration
}
const getWebpackDevelopmentConfig = (): Configuration => {
const webpackConfig: Configuration = {
mode: 'development',
devtool: 'source-map',
plugins: [new HotModuleReplacementPlugin(), new NoEmitOnErrorsPlugin()]
}
return webpackConfig
}
export default getWebpackDevelopmentConfig
Webpack 生产配置
我们需要创建的最后一个文件是webpack.production.ts,当我们尝试编译使用共享库(如React、Apollo Server或JSON Web Tokens)的包时,将使用外部库,将mode设置为生产模式,并禁用源映射:
import { Configuration } from 'webpack'
import { ModeArgs } from './webpack.types'
const getWebpackProductionConfig = (args: ModeArgs): Configuration => {
const { configType } = args
// Externals
const externals = configType === 'package'
? {
react: {
commonjs: 'react',
commonjs2: 'react',
amd: 'React',
root: 'React'
},
'react-dom': {
commonjs: 'react-dom',
commonjs2: 'react-dom',
amd: 'ReactDOM',
root: 'ReactDOM'
},
'apollo-server-express': 'apollo-server-express',
jsonwebtoken: 'jsonwebtoken'
}
: {}
const webpackConfig = {
mode: 'production',
devtool: false,
externals
}
return webpackConfig as Configuration
}
export default getWebpackProductionConfig
这就是我们的devtools包的全部内容。现在我们只需要在packages/devtools/src/index.ts中创建index.ts文件来导出所有 Webpack 配置,并能够编译我们的devtools包:
// CLI
export * from './cli/log'
// Webpack
export { default as getWebpackCommonConfig } from './webpack/webpack.common'
export { default as getWebpackDevelopmentConfig } from './webpack/webpack.development'
export { default as getWebpackProductionConfig } from './webpack/webpack.production'
export * from './webpack/webpack.types'
由于这将是一个将编译其他包的基础包,首先我们需要构建它,为此,我们只需在 packages/devtools 内运行 build 命令即可将 TypeScript 转换为 JavaScript 文件。
npm run build
如果一切正确,你应该会看到类似这样的内容:

图 14.6:npm run build
最后,我们需要在根级别创建 webpack.config.ts 文件,在那里我们将使用我们的全新 devtools 包并使用 webpack-merge 合并配置(开发 + 公共或生产 + 公共):
import {
ConfigArgs,
getWebpackCommonConfig,
getWebpackDevelopmentConfig,
getWebpackProductionConfig,
log
} from '@web-creator/devtools'
import { Configuration } from 'webpack'
import { merge } from 'webpack-merge'
// Mode Config
const getModeConfig = {
development: getWebpackDevelopmentConfig,
production: getWebpackProductionConfig
}
// Mode Configuration (development/production)
const modeConfig: (args: ConfigArgs) => Configuration = ({mode, type, packageName}) => {
const getWebpackConfiguration = getModeConfig[mode]
return getWebpackConfiguration({
configType: type,
packageName,
sandbox: true,
devServer: true
})
}
// Merging all configurations
const webpackConfig: (args: ConfigArgs) => Promise<Configuration> = async ({
mode, type, sandbox, packageName
} = {
mode: 'production',
type: 'web',
sandbox: 'false',
packageName: 'design-system'
}) => {
const isSandbox = type === 'package' && sandbox === 'true'
const commonConfiguration = getWebpackCommonConfig({
configType: type,
packageName,
mode,
...(isSandbox && {
htmlOptions: { title: 'Sandbox', template: 'sandbox/index.xhtml' },
sandbox: isSandbox,
devServer: isSandbox
})
})
// Mode Configuration
const modeConfiguration = mode && type ? modeConfig({ mode, type, packageName }) : {}
// Merging all configurations
const webpackConfiguration = merge(commonConfiguration, modeConfiguration)
// Logging Webpack Configuration
log({ tag: 'Webpack Configuration', json: webpackConfiguration, type: 'warning' })
return webpackConfiguration
}
export default webpackConfig
创建 utils 包
在我们创建了 devtools 包之后,是时候添加一个基本的 utils 包来测试使用 devtools 的 Webpack 编译了。为此,你需要在 packages/utils 创建一个目录。对于书中的示例,我们只需添加一个 util 文件来测试我们的 devtools,但在实际的仓库中,你会找到更多已经添加到项目中的 util 文件。
如往常一样,让我们从 utils 包中创建 package.json 开始:
{
"name": "@web-creator/utils",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "cross-env NODE_ENV=production npm-run-all clean compile webpack:production",
"build:dev": "cross-env NODE_ENV=development npm-run-all clean compile webpack:development",
"clean": "rm -rf ./dist",
"compile": "tsc",
"lint": "npm run --prefix ../../ lint",
"lint:fix": "npm run --prefix ../../ lint:fix",
"prepublishOnly": "npm run lint && npm run build",
"webpack:development": "webpack --config=../../webpack.config.ts --env mode=development --env type=package --env packageName=utils",
"webpack:production": "webpack --config=../../webpack.config.ts --env mode=production --env type=package --env packageName=utils"
},
"author": "Carlos Santana",
"license": "MIT",
"dependencies": {
"currency-formatter": "¹.5.9",
"slug": "⁸.2.2",
"uuid": "⁹.0.0"
},
"devDependencies": {
"@types/currency-formatter": "¹.5.1",
"@types/slug": "⁵.0.3",
"@types/uuid": "⁹.0.0"
}
}
在这个 package.json 文件中,有一些重要的元素我想提一下:
-
第一项是包的名称,它是
@web-creator/utils。正如我之前提到的,这是在 MonoRepo 内部命名包的正确格式。 -
第二个节点是
version,它始终将是1.0.0(除非你想将这个包发布到 NPM 注册表;现在你不需要担心这个问题)。 -
main是指定我们的utils代码将存在的地方,它将始终在dist/index.js。 -
types节是为了能够加载我们的 TypeScript 类型;如果你不指定这个,那么在消费这个包时,你将无法看到添加到你的utils包中的类型。 -
最后,
files节是一个数组,它将包含包含编译后包的dist目录。
此外,脚本还有一些有趣的事情你应该知道。我们的 build 命令将使用 npm-run-all 运行多个脚本,这是一个帮助我们依次运行多个脚本的库。在这种情况下,我们总是首先执行 clean 脚本来删除我们的 dist 文件夹并从头开始。然后我们使用 TypeScript (tsc) 编译代码,然后执行 webpack:production。这将执行 webpack,指定根目录中存在的配置文件(在两个级别之后)。我们还使用 --env 标志来传递变量。
这些变量定义在我们的 webpack.config.ts 文件中。在这种情况下,我们传递 mode=production、type=package 和 packageName=utils。
如果您注意到,一些脚本包含npm run --prefix ../../,我非常确信您想知道这个命令中的--prefix标志究竟是什么。它本质上是一种告诉 NPM 我们想要从一个不同的package.json中运行脚本的方法。在这个特定的例子中,我们返回两级以运行存在于我们根package.json中的lint脚本。
现在,让我们创建我们的第一个util文件,它将被命名为is.ts,并且您必须将其保存到packages/utils/src/utils/is.ts,以下代码如下:
const is = {
Array(v: unknown) {
return v instanceof Array
},
Defined(v: unknown) {
return typeof v !== 'undefined' && v !== null
},
Email(email: string) {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return regex.test(email)
},
False(v: unknown) {
return (this.Defined(v) && v === false) || v === 'false'
},
Number(v: unknown) {
return typeof v === 'number'
},
Function(v: unknown) {
return typeof v === 'function'
},
Object(v: unknown) {
return this.Defined(v) && typeof v === 'object' && !this.Array(v)
},
String(v: unknown) {
return this.Defined(v) && typeof v === 'string'
},
Undefined(v: unknown) {
return typeof v === 'undefined' || v === null
},
JSON(str: string) {
if (!str || str === null) {
return false
}
try {
JSON.parse(str)
} catch (e) {
return false
}
return true
},
Password(password: string, min = 8) {
return Boolean(password && password.length >= min)
},
PasswordMatch(p1: string, p2: string) {
return this.Password(p1) && this.Password(p2) && p1 === p2
},
Browser() {
return typeof window !== 'undefined'
},
Device() {
if (!this.Browser()) {
return false
}
const ua = navigator.userAgent
if (/(tablet|ipad|playbook|silk)|(android(?!.*mobi))/i.test(ua)) {
return true
}
if (/Mobile|Android|iP(hone|od)|IEMobile|BlackBerry|Kindle|Silk-Accelerated|(hpw|web)OS|Opera M(obi|ini)/.test(ua)) {
return true
}
return false
},
EmptyObject(v: any) {
return v ? Object.keys(v).length === 0 : true
}
}
export default is
在您创建这个util之后,您需要在packages/utils/src/index.ts中创建一个index.ts文件,在那里您将导出所有的utils:
export { default as is } from './utils/is'
最后,您必须向根package.json添加一个脚本,以便能够编译您全新的utils包。以下是您的根package.json文件应该看起来像这样:
{
"name": "web-creator",
"private": true,
"workspaces": [
"packages/*"
],
"scripts": {
"lint": "eslint --ext .tsx,.ts ./packages/**/src",
"lint:fix": "eslint --ext .tsx,.ts ./packages/**/src",
"build": "npm-run-all build:*",
"build:devtools": "cd ./packages/devtools && npm run build",
"build:utils": "cd ./packages/utils && npm run build",
"build:authentication": "cd ./packages/authentication && npm run build",
"build:design-system": "cd ./packages/design-system && npm run build",
"build:api": "cd ./packages/api && npm run build",
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "⁵.49.0",
"@typescript-eslint/parser": "⁵.49.0",
"cross-env": "⁷.0.3",
"eslint": "⁸.33.0",
"eslint-config-airbnb": "¹⁹.0.4",
"eslint-config-airbnb-typescript": "¹⁷.0.0",
"eslint-config-prettier": "⁸.6.0",
"eslint-import-resolver-typescript": "³.5.3",
"eslint-plugin-import": "².27.5",
"eslint-plugin-jsx-a11y": "⁶.7.1",
"eslint-plugin-prettier": "⁴.2.1",
"eslint-plugin-react": "⁷.32.2",
"eslint-plugin-react-hooks": "⁴.6.0",
"npm-run-all": "⁴.1.5",
"prettier": "².8.3",
"ts-node": "¹⁰.9.1",
"typescript": "⁴.9.5",
"webpack-cli": "⁵.0.1"
},
"dependencies": {
"webpack": "⁵.75.0",
"webpack-merge": "⁵.8.0"
}
}
如您所见,我们需要为每个我们想要构建的包添加一个build:package_name(在这个例子中,build:utils)脚本,然后我们的构建脚本将使用npm-run-all build:*执行所有这些脚本。
现在您可以通过在utils目录中运行npm run build脚本来构建您的utils包;您应该看到类似以下的内容:

图 14.7:构建 utils
在此之后,您应该看到我们用于编译此包的 Webpack 配置日志:

图 14.8:Webpack 配置
然后,在最后,您将看到 Webpack 编译的文件:

图 14.9:Webpack 编译的文件
现在我们已经创建了我们的第一个包,它是用devtools编译的,并且我们了解了包的结构,现在是时候开始处理我们的 API 了。
创建 API 包
在这个包中,我们将实现一个多服务系统,这将帮助我们连接到多个数据库。让我们看看我们的package.json文件对于api包应该看起来像什么:
{
"name": "@web-creator/api",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"build": "cross-env NODE_ENV=production npm-run-all clean compile webpack:production",
"build:dev": "cross-env NODE_ENV=development npm-run-all clean compile webpack:development",
"clean": "rm -rf ./dist",
"compile": "tsc",
"dev": "ts-node-dev src/index.ts",
"lint": "npm run --prefix ../../ lint",
"lint:fix": "npm run --prefix ../../ lint:fix",
"webpack:development": "webpack --config=../../webpack.config.ts --env mode=production --env type=api --env packageName=api",
"webpack:production": "webpack --config=../../webpack.config.ts --env mode=development --env type=api --env packageName=api"
},
"author": "Carlos Santana",
"license": "MIT",
"dependencies": {
"@graphql-tools/merge": "8.3.18",
"@graphql-tools/schema": "9.0.16",
"@web-creator/authentication": "1.0.0",
"@web-creator/utils": "¹.0.0",
"@apollo/server": "⁴.7.3",
"cookie-parser": "¹.4.6",
"cors": "².8.5",
"dotenv": "¹⁶.0.3",
"express": "⁴.18.2",
"graphql": "16.6.0",
"graphql-middleware": "6.1.33",
"graphql-tag": "2.12.6",
"isomorphic-fetch": "³.0.0",
"jsonwebtoken": "⁹.0.0",
"pg": "⁸.9.0",
"pg-hstore": "².3.4",
"pg-native": "³.0.1",
"sequelize": "⁶.28.0",
"sequelize-typescript": "².1.5"
},
"devDependencies": {
"@types/body-parser": "¹.19.2",
"@types/express-jwt": "⁶.0.4",
"@types/jsonwebtoken": "⁹.0.1",
"@types/cors": "².8.13",
"@types/node": "¹⁸.11.18",
"@types/pg": "⁸.6.6",
"ts-node-dev": "2.0.0"
}
}
在这种情况下,我们将使用 Sequelize(一个 ORM)和 PostgreSQL 作为数据库,但请随意使用 MySQL 或其他 Sequelize 支持的数据库类型。
在接下来的章节中,我们将详细介绍每个步骤。我们将演示如何集成所有组件并成功运行您的 CRM 服务。如果它看起来很复杂,无需担心。我们将以稳定的速度进行,并在过程中提供解释。
创建用户共享模型
我们需要首先创建的是我们的共享模型,目前这将是唯一的User模型,以便能够为所有我们的网站创建一个共享的身份验证系统。
您必须在packages/models/User.ts中创建User模型文件,这将创建一个包含以下字段的表:id(UUID),username(STRING),password(STRING),Email(STRING),Role(STRING),和active(BOOLEAN):
import { security } from '@web-creator/utils'
import { DataType, Sequelize, User } from '../types'
export default (sequelize: Sequelize, dataType: DataType): User => {
const user = sequelize.define('User', {
id: {
primaryKey: true,
allowNull: false,
type: dataType.UUID,
defaultValue: dataType.UUIDV4()
},
username: {
type: dataType.STRING,
allowNull: false,
unique: true,
validate: {
isAlphanumeric: {
args: true,
msg: 'The user just accepts alphanumeric characters'
},
len: {
args: [4, 20],
msg: 'The username must be from 4 to 20 characters'
}
}
},
password: {
type: dataType.STRING,
allowNull: false
},
email: {
type: dataType.STRING,
allowNull: false,
unique: true,
validate: {
isEmail: {
args: true,
msg: 'Invalid email'
}
}
},
role: {
type: dataType.STRING,
allowNull: false
},
active: {
type: dataType.BOOLEAN,
allowNull: false,
defaultValue: false
}
},
{
hooks: {
beforeCreate: (u: User): void => {
u.password = security.encrypt(u.password)
}
}
}
)
return user
}
创建一个用户共享的 GraphQL 类型和解析器
除了User共享模型外,我们还需要创建一个共享的 GraphQL 类型和解析器,以便在所有我们的网站上使用 GraphQL 处理认证。
首先,我们需要创建另一个共享的 GraphQL 类型,称为error,它将帮助我们处理我们稍后创建的任何查询或突变中的错误。此文件位于packages/api/src/graphql/types/Error.ts中:
import gql from 'graphql-tag'
export default gql`
type ErrorResponse {
code: Int
message: String!
}
type Error {
error: ErrorResponse
}
`
我们需要创建的另一个共享类型是标量类型,它将定义标量类型如UUID、Datetime和JSON。此文件位于packages/api/src/graphql/types/Scalar.ts中:
import gql from 'graphql-tag'
export default gql`
scalar UUID
scalar Datetime
scalar JSON
`
最后,我们需要创建我们的User类型,它将包括一些查询,通过访问令牌(at)获取特定用户,获取所有用户,以及一些突变来创建新用户和登录。此文件应放置在packages/api/src/graphql/types/User.ts中:
import gql from 'graphql-tag'
export default gql`
"User type"
type User {
id: UUID!
username: String!
email: String!
role: String!
active: Boolean!
createdAt: Datetime!
updatedAt: Datetime!
}
"Token type"
type Token {
token: String!
}
"User Query"
type Query {
getUser(at: String!): User!
getUsers: [User!]
}
"User Mutation"
type Mutation {
createUser(input: ICreateUser): User!
login(input: ILogin): Token!
}
"CreateUser Input"
input ICreateUser {
username: String!
password: String!
email: String!
active: Boolean!
role: String!
}
"Login Input"
input ILogin {
emailOrUsername: String!
password: String!
}
`
在创建前面的类型之后,您需要创建用户解析器。为此,我们将使用authentication包(请检查github.com/PacktPublishing/React-18-Design-Patterns-and-Best-Practices-Fourth-Edition/tree/main/Chapter14/web-creator/packages/authentication中的代码)。您还记得我们在第十三章中创建的认证系统吗?这是相同的代码,但现在它将拥有自己的包。这个解析器应该在packages/api/src/graphql/resolvers/user.ts中创建:
import { authenticate, getUserBy, getUserData } from '@web-builder/authentication'
import { ICreateUser, ILogin, Model } from '../../types'
const getUsers = (_: any, _args: any, { models }: { models: Model }) => models.User.findAll()
const getUser = async (_: any, { at }: { at: string }, { models }: {models: Model}) => {
const connectedUser = await getUserData(at)
if (connectedUser) {
// Validating if the user is still valid
const user = await getUserBy(
{
id: connectedUser.id,
email: connectedUser.email,
active: connectedUser.active
},
[connectedUser.role],
models
)
if (user) {
return {
...connectedUser
}
}
}
return {
id: '',
username: '',
email: '',
role: '',
active: false
}
}
const createUser = (_:any, {input}: {input: ICreateUser}, {models}: {models: Model}) =>
models.User.create({ ...input })
const login = (_: any, { input }: { input: ILogin }, { models }: { models: Model }) =>
authenticate(input.emailOrUsername, input.password, models)
export default {
Query: {
getUser,
getUsers
},
Mutation: {
createUser,
login
}
}
创建自定义服务
现在是时候创建我们的自定义服务了;为此,我们将创建一个默认服务(只是为了有一个空的服务)和一个用于CRM项目的服务(它将被命名为crm)。
我们需要做的第一件事是创建我们的服务配置,为此我们将创建一些类型,这将帮助我们非常严格地限制我们的配置将接收的选项。此文件需要创建在packages/api/src/types/config.ts中:
import { ValueOf } from '@web-creator/utils'
// Here you need to add all the services you want to create
export const Service = {
CRM: 'crm'
} as const
export type Service = ValueOf<typeof Service>
export type Mode = 'production' | 'development'
export enum DeploymentType {
PRODUCTION = 'production',
STAGING = 'staging',
DEVELOPMENT = 'development'
}
export interface ServiceConfiguration {
domainName: string
port: number
database?: {
engine?: string
port?: number
host?: string
database?: string
username?: string
password?: string
}
}
export interface ServiceBuilderConfiguration extends ServiceConfiguration {
service: Service
}
我们默认的配置应该是这样的(packages/api/src/services/default/config.ts):
import { ServiceConfiguration } from '../../types/config'
export const config: ServiceConfiguration = {
domainName: 'localhost',
port: 4000,
database: {
engine: 'postgresql',
port: 5432,
host: 'localhost',
database: '',
username: '',
password: ''
}
}
然后,让我们创建我们的 CRM 配置(自定义服务)。这应该放置在packages/api/src/services/crm/config.ts中:
import { ServiceConfiguration } from '../../types/config'
export const config: ServiceConfiguration = {
domainName: 'ranchosanpancho.com',
port: 4000,
database: {
database: 'crm'
}
}
我很确定您想知道数据库节点(engine、port、host、username和password)的其他选项在哪里。这些将在稍后创建的主要配置文件中被覆盖,但那些值将从我们的.env文件中获取(您必须重命名.env.example文件)。因此,让我们在packages/api/.env中创建该文件:
DB_ENGINE=postgresql
DB_PORT=5432
DB_HOST=localhost
DB_USERNAME=<YourDBUserName>
DB_PASSWORD=<YourDBPassword>
构建我们的服务配置
现在我们已经准备好了自定义服务(CRM),让我们构建我们的配置。为此,您需要在packages/api/src/config.ts中创建配置文件:
// This package will load the environment variables from our .env file
import dotenv from 'dotenv'
// Here you can add your custom services configuration
import { config as crmConfig } from './services/crm/config'
import { config as blankServiceConfig } from './services/default/config'
import { Service, ServiceBuilderConfiguration, ServiceConfiguration } from './types/config'
// Loading Env vars
dotenv.config()
const getServiceConfig = (service: Service): ServiceConfiguration => {
switch (service) {
// Add your custom services here
case Service.CRM:
return crmConfig
default:
return blankServiceConfig
}
}
const buildConfig = (): ServiceBuilderConfiguration => {
const service = process.env.SERVICE as Service
if (!service) {
throw 'You must specify a service (E.g., SERVICE=crm npm run dev)'
}
const serviceConfig = getServiceConfig(service)
const config: ServiceBuilderConfiguration = {
...serviceConfig,
database: {
...serviceConfig.database,
engine: process.env.DB_ENGINE,
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD
},
service
}
return config
}
// Building the config
const Config = buildConfig()
export default Config
创建我们的自定义模型
一旦我们正确创建了配置,我们需要创建我们的 CRM 服务的自定义模型,这些模型是专门为该服务创建的,并且它们将不会与其他服务共享。在这种情况下,我们将添加一个,并称之为Guest。这个模型需要保存在packages/api/src/services/crm/models/Guest.ts:
import { DataType } from '../../../types'
export default (sequelize: any, dataType: DataType) => {
const Guest = sequelize.define('Guest', {
id: {
primaryKey: true,
allowNull: false,
type: dataType.UUID,
defaultValue: dataType.UUIDV4()
},
fullName: {
type: dataType.STRING,
allowNull: false
},
email: {
type: dataType.STRING,
allowNull: false,
unique: true
},
photo: {
type: dataType.STRING,
allowNull: true
},
phone: {
type: dataType.STRING,
allowNull: true
},
socialMedia: {
type: dataType.STRING,
allowNull: true
},
location: {
type: dataType.STRING,
allowNull: true
},
gender: {
type: dataType.STRING,
allowNull: true
},
birthday: {
type: dataType.STRING,
allowNull: true
}
})
return Guest
}
在我们创建Guest模型后,我们需要连接到我们的数据库并将我们的全局模型(User)和本地模型(Guest)连接起来,以便创建我们的服务表。这个文件需要创建在packages/api/src/services/crm/models/index.ts:
import { keys, ts } from '@web-creator/utils'
import pg from 'pg'
import { Sequelize } from 'sequelize'
import Config from '../../../config'
// Db Connection
const { engine, port, host, database, username, password } = Config.database ?? {}
const uri = `${engine}://${username}:${password}@${host}:${port}/${database}`
const sequelize = new Sequelize(uri, {
dialectModule: pg
})
// Models
const addModel = (path: string) => require(path).default(sequelize, Sequelize)
const models: any = {
User: addModel('../../../models/User'), // Global model
Guest: addModel('./Guest'), // Local model
sequelize // We must pass the sequelize object here
}
// Relationships
keys(models).forEach((modelName: string) => {
if (ts.hasKey(models, modelName)) {
if (models[modelName].associate) {
models[modelName].associate(models)
}
}
})
export default models
创建模型种子
种子是我们模型(表)的初始数据。大多数时候,我们希望清除所有模型值但保留一些默认值,但在这个例子中,我们将为我们的User模型和Guest模型添加一些默认数据:
import models from '../models'
async function createFirstUser(): Promise<any> {
const existingUsers = await models.User.findAll()
if (existingUsers.length === 0) {
const newUser: any = await models.User.create({
username: 'admin',
password: '12345678',
email: 'admin@ranchosanpancho.com',
role: 'god',
active: true
})
return newUser
}
return null
}
async function createGuests(): Promise<any> {
const existingGuests = await models.Guest.findAll()
if (existingGuests.length === 0) {
const newGuests: any = await models.Guest.bulkCreate([
{
fullName: 'Carlos Santana',
email: 'carlos@ranchosanpancho.com',
photo: 'carlos.jpg',
phone: '+1 555 555 5555',
socialMedia: 'https://www.facebook.com/carlos.santana',
location: 'Colima, Mexico',
gender: 'Male',
birthday: '11/21/1987'
},
{
fullName: 'Cristina Santana',
email: 'cristina@ranchosanpancho.com',
photo: 'cristina.jpg',
phone: '+1 444 444 4444',
socialMedia: 'https://www.facebook.com/cristina.santana',
location: 'Colima, Mexico',
gender: 'Female',
birthday: '1/20/1989'
}
])
return newGuests
}
return null
}
function setInitialSeeds(): void {
createFirstUser()
createGuests()
}
export default setInitialSeeds
创建我们的自定义 GraphQL 类型和解析器
对于我们的 CRM,我们将创建一个Guest类型和解析器,只是为了说明我们如何在创建的不同服务中使用 GraphQL;您需要创建的第一个文件是Guest类型,它必须保存在packages/api/src/services/crm/graphql/types/Guest.ts:
import gql from 'graphql-tag'
export default gql`
type Guest {
id: UUID!
fullName: String!
email: String!
photo: String!
socialMedia: String!
location: String!
phone: String!
gender: String!
birthday: String
createdAt: Datetime!
updatedAt: Datetime!
}
type GuestResponse {
guests: [Guest!]!
}
union GuestResult = GuestResponse | Error
type Query {
getGuests: GuestResult
}
`
如您所见,我们使用一些个人字段如fullName、email、photo等定义了我们的Guest类型。然后我们创建了一个GuestResponse类型,它代表一个客人数组([Guest!]!)。方括号表示这是一个数组,感叹号(!)表示它不能包含null值。之后,我们创建了一个union类型,这使得schema字段可以返回多个对象类型中的一个。在这种情况下,当有客人时,它可以返回GuestResponse类型,如果没有客人或遇到任何其他问题,则返回Error类型。如果发生其他情况,我们在解析器中定义这些类型的响应。
在您创建了这个类型文件(或更多)之后,是时候合并您所有的类型定义(TypeDefs)了。为此,我们将在types目录内创建一个index.ts文件,并导入我们的全局类型(Error、Scalar和User)。我们还将包括我们的本地类型(Guest),并使用@graphql-tools/merge提供的函数将其合并。这个文件放在packages/api/src/services/crm/types/index.ts:
import { mergeTypeDefs } from '@graphql-tools/merge'
// Global Types
import Error from '../../../../graphql/types/Error'
import Scalar from '../../../../graphql/types/Scalar'
import User from '../../../../graphql/types/User'
// Local Types
import Guest from './Guest'
export default mergeTypeDefs([Error, Scalar, User, Guest])
现在一旦您合并了您的类型,您需要创建Guest解析器。这个文件应该放在packages/api/src/services/crm/graphql/resolvers/guest.ts:
export default {
Query: {
getGuests: async (_: any, _args: any, { models }: { models: any }): Promise<any> => {
const guests = await models.Guest.findAll({
order: [['fullName', 'ASC']]
})
// If there are guests, return them with a GuestResponse type
if (guests.length > 0) {
return {
__typename: 'GuestResponse',
guests
}
}
// If there are no guests, return an Error type with a 404 code and message
return {
__typename: 'Error',
error: {
code: 404,
message: 'No guests found'
}
}
}
}
}
如您所见,当我们找到客人(或数据)时,我们返回它们并添加一个__typename属性(这是一个 GraphQL 属性),其值为GuestResponse。由于我们使用的是union,这个属性对于正确解析查询是必要的。在这里,我们定义我们将返回的内容,无论是GuestResponse类型还是Error类型。另一方面,如果我们没有找到任何客人,我们返回一个包含代码和消息的错误对象,并将__typename设置为'Error'。
现在,我们需要对解析器做同样的事情。我们需要合并我们的解析器,包括全局解析器和本地解析器。为此,在相同的resolvers目录中创建一个index.ts文件,并添加以下代码:
import { mergeResolvers } from '@graphql-tools/merge'
import user from '../../../../graphql/resolvers/user'
import guest from './guest'
const resolvers = mergeResolvers([user, guest])
export default resolvers
我们已经像对TypeDef所做的那样对解析器做了类似的事情。现在,我们需要导入全局用户解析器并将其与我们的客人解析器合并。
同步我们的模型并启动 Apollo Server
现在我们已经创建了自定义的configs、models、seeds、types和resolvers,是时候将所有这些放在一起,同步我们的模型,并启动我们的 Apollo Server。此文件应放置在packages/api/src/index.ts:
import { makeExecutableSchema } from '@graphql-tools/schema'
import { ts } from '@web-creator/utils'
import { ApolloServer } from '@apollo/server'
import { expressMiddleware } from '@apollo/server/express4'
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer'
import bodyParser from 'body-parser'
import http from 'http'
import cookieParser from 'cookie-parser'
import cors from 'cors'
import express, { NextFunction, Request, Response } from 'express'
import { applyMiddleware } from 'graphql-middleware'
import { json } from 'body-parser'
import { Service } from './types/config'
在导入所有需要的包之后,首先我们需要检查是否从终端收到了SERVICE变量;如果没有,我们将选择默认服务。我们还将检查我们的服务是否有效(存在于我们的Service类型中):
// Service
const service: any = process.env.SERVICE ?? 'default'
// Validating service
if (!ts.includes(Service, service)) {
throw 'Invalid service'
}
一旦我们确认我们的服务是有效的,然后我们将动态导入resolvers、types、models和seeds:
// We are importing the service files dynamically
const resolvers = require(`./services/${service}/graphql/resolvers`).default
const typeDefs = require(`./services/${service}/graphql/types`).default
const models = require(`./services/${service}/models`).default
const seeds = require(`./services/${service}/seeds`).default
然后我们创建我们的 Express 应用并配置cors、cookieParser和bodyParser:
const app = express()
const httpServer = http.createServer(app)
const corsOptions = {
origin: '*',
credentials: true
}
app.use(cors(corsOptions))
app.use(cookieParser())
app.use(bodyParser.json())
// CORS
app.use((req: Request, res: Response, next: NextFunction) => {
res.header('Access-Control-Allow-Origin', '*')
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept')
next()
})
我们需要使用makeExecutableSchema创建我们的 GraphQL 模式,并使用applyMiddleware:
// Schema
const schema = applyMiddleware(
makeExecutableSchema({
typeDefs,
resolvers
})
)
最后,我们创建我们的ApolloServer实例,传递模式和插件。
// Apollo Server
const apolloServer = new ApolloServer({
schema,
plugins:[ApolloServerPluginDrainHttpServer({ httpServer })]
})
现在,我们需要同步我们的模型。alter选项使我们能够监听模型的变化并修改它们:
如果您更改了某些内容,请务必小心使用force选项。如果它是true,它将截断所有您的表(这意味着所有数据都将被删除)。因此,只有在绝对必要时才使用它。
// Database Sync
const main = async () => {
const alter = true
const force = false
await apolloServer.start()
await models.sequelize.sync({ alter, force })
// Setting up initial seeds
console.log('Initializing Seeds...')
seeds()
app.use(
'/graphql',
cors<cors.CorsRequest>(),
json(),
expressMiddleware(apolloServer, {
context: async () => ({ models })
})
)
await new Promise<void>((resolve) => httpServer.listen({ port: 4000 }, resolve))
console.log(' Server ready at http://localhost:4000/graphql')
}
main()
测试我们的 CRM 服务
如果您一切操作正确,您可以在api包内部运行命令SERVICE=crm npm run dev,您应该会看到类似以下的内容:

图 14.10:SERVICE=crm npm run dev
如果您检查您的数据库,您将看到从您的模型创建的两个表(Guests和Users),您应该能够看到您添加的种子:


图 14.11:数据库查询
如您所见,createdAt和updatedAt字段是由 Sequelize 自动创建的。在此之后,您可以尝试访问localhost:4000/graphql以查看您的 Apollo Server 是否运行正常。

图 14.12:GraphQL 探索器
我们可以开始测试我们的服务查询,例如getGuests;让我们看看它返回什么:

图 14.13:getGuests 查询
此外,您还可以测试getUsers查询:

图 14.14:获取用户查询
最后,您还可以测试 登录 突变,以确保您的全局认证系统运行良好:

图 14.15:登录突变
创建前端包
在这个包中,我们将实现一个多站点系统,这将帮助我们拥有多个具有相同代码库的站点。
让我们看看这个包的 package.json 文件应该是什么样子:
{
"name": "@web-creator/frontend",
"version": "1.0.0",
"scripts": {
"dev": "cross-env NODE_ENV=development npm run next:dev",
"build": "next build",
"next": "ts-node src/server.ts",
"next:dev": "ts-node src/server.ts",
"lint": "npm run --prefix ../../ lint",
"lint:fix": "npm run --prefix ../../ lint:fix",
"typecheck": "tsc --noEmit"
},
"author": "Carlos Santana",
"license": "ISC",
"peerDependencies": {
"react": ">=17.0.2",
"react-dom": ">=17.0.2"
},
"devDependencies": {
"@babel/core": "⁷.20.12",
"@babel/node": "⁷.20.7",
"@types/cookie-parser": "¹.4.3",
"@types/isomorphic-fetch": "⁰.0.36",
"@types/styled-components": "⁵.1.26",
"babel-plugin-jsx-remove-data-test-id": "³.0.0",
"babel-plugin-styled-components": "².0.7"
},
"dependencies": {
"@apollo/client": "³.7.7",
"@web-creator/authentication": "1.0.0",
"@web-creator/devtools": "1.0.0",
"@web-creator/utils": "¹.0.0",
"babel-preset-next": "¹.4.0",
"cookie-parser": "¹.4.6",
"dotenv": "¹⁶.0.3",
"express": "⁴.18.2",
"isomorphic-fetch": "³.0.0",
"next": "¹³.1.6",
"react-cookie": "⁴.1.1",
"styled-components": "⁵.3.6",
"webpack": "⁵.75.0"
}
}
我们的 前端 包与其他包的工作方式略有不同,因为我们使用 Next.js,它负责自己的 Webpack 配置。我们不使用我们的 devtools 编译它,就像其他包一样,TypeScript 配置也有细微差别。
这是我们的 前端 包的 tsconfig.json 文件:
{
"extends": "../../tsconfig.common.json",
"compilerOptions": {
"outDir": "./dist",
"baseUrl": ".",
"isolatedModules": true,
"noEmit": false,
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"incremental": true,
"jsx": "preserve",
"paths": {
"~/*": ["./src/*"]
}
},
"include": ["src/**/*"]
}
如您所见,我们定义了 ~ 路径。在其他包中,这由 devtools 处理,但在我们的情况下,我们需要直接修改下一个 Webpack 配置。为此,您需要创建文件 next.config.js(是的,.js,而不是 .ts),代码应如下所示:
const path = require('path')
module.exports = {
reactStrictMode: true,
webpack: (config, { isServer }) => {
// Fixes npm packages that depend on 'fs' module
if (!isServer) {
config.resolve.fallback.fs = false
}
// Aliases
config.resolve.alias['~'] = path.resolve(__dirname, './src')
return config
}
}
我们还需要设置的一个配置是向我们的 .babelrc 文件添加 styled-components 插件。我们还将使用 next/babel 预设。此文件位于 packages/frontend/.babelrc:
{
"presets": ["next/babel"],
"plugins": [["styled-components", { "ssr": true, "preprocess": false }]],
"env": {
"production": {
"plugins": ["babel-plugin-jsx-remove-data-test-id"]
}
}
}
现在我们已经完成了这一章节的这一部分,我们将继续创建一个通用的 用户 模型。这个模型将作为一个模板,可以在所有我们的网站上为任何注册的用户使用。
接下来,我们的重点将转向开发一个 站点 系统,这可以想象为一个负责管理我们网站的主控制室。就像切换电视频道一样,我们还将构建一个 页面切换器,使用户能够无缝地在我们的网站上切换不同的页面。
随后,我们将构建一个通用的 登录 系统,确保所有我们的网站上都有一致的登录体验。为了增强定制性和功能性,我们将设置一个 站点 配置,它作为每个独立站点的规则书或设置面板,规定其行为和功能。
为了将这些组件统一起来,我们将它们捆绑在一个名为 server.ts 的单个文件中,这个文件将作为我们系统的指挥中心。
一旦设置完成,我们将通过运行程序并使用各种示例检查结果来测试我们的工作。如果出现任何问题,我们的系统将提供错误消息以指示并协助故障排除。
在接下来的章节中,您将见证所有这些步骤的实际操作,使您能够理解它们如何集成到我们的更大系统中。请放心,尽管现在可能听起来很复杂,但我们将将其分解,并引导您通过每个步骤,确保对过程有清晰的理解。
创建我们的站点系统
网站系统基本上与我们之前在 API 包中创建的服务系统相同,但在这个案例中,我们不是管理数据库,而是管理网站。因此,就像我们之前做的那样,我们首先需要做的是创建每个网站的配置。在这个场景中,我们还将有一个默认网站,称为'blank-page',以避免在没有提供网站时系统崩溃。
让我们在packages/frontend/src/sites/blank-page/config.ts中为这个网站创建配置文件,这是该文件的内容:
import { SiteConfiguration } from '../../types/config'
export const config: SiteConfiguration = {
siteTitle: 'Blank Page',
domainName: 'localhost',
api: {
uri: 'http://localhost:4000/graphql'
},
pages: ['index']
}
对于这个例子,我将使用两个个人网站,san-pancho和codejobs,但你可以自由地将任何网站添加到项目中。
作为网站的一部分,我们需要创建graphql文件,以消费我们的 API 查询和突变,以及这个网站的具体页面。我们现在将添加的唯一查询是我们之前在 API 包中创建的getGuests查询。这个文件应该在packages/frontend/src/sites/san-pancho/graphql/guest/getGuests.query.ts中。如果你在某个时候想创建一个突变,你可能想使用myMutation.mutation.ts格式作为文件名:
import { gql } from '@apollo/client'
export const getGuestsQuery = '
getGuests {
... on GuestResponse {
guests {
id
fullName
email
photo
socialMedia
location
gender
birthday
note
}
}
... on Error {
error {
code
message
}
}
}
'
export default gql'
query getGuests {
${getGuestsQuery}
}
'
创建我们的页面切换器
如果你以前使用过 Next.js,你会知道Next页面系统是如何工作的。本质上,你有一个pages目录,你添加到该目录的文件或目录将代表页面的路由。在我们的例子中,我们需要创建一些 Next 页面,这些页面将“切换”或从每个网站渲染自定义页面。我知道这听起来有点复杂,但让我们将其分解成几个部分。
首先要做的事情是在packages/frontend/src/pages/index.ts中创建我们的index.ts页面(这是一个 Next 页面):
import React, { FC } from 'react'
import Config from '~/config'
const SwitcherPage = require('~/sites/${Config.site}/switcher').default
const getRouterParams = require('~/sites/${Config.site}/server/routerParams').default
type Props = {
siteTitle: string
}
const Page: FC<Props> = ({ siteTitle }) => {
const routerParams = getRouterParams({})
return <SwitcherPage routerParams={routerParams} siteTitle={siteTitle} />
}
export default Page
我们必须创建的另一个 Next.js 页面是具有特殊名称的页面,需要创建在packages/frontend/src/pages/[page]/[...params].tsx中。[page]将是一个动态路径。[...params].tsx文件将接收 URL 中传递的任何附加参数。如果我们有超过两个嵌套路由,这些附加路由将作为数组添加到params变量中:
import { useRouter } from 'next/router'
import React, { FC } from 'react'
import Config from '~/config'
const SwitcherPage = require('~/sites/${Config.site}/switcher').default
const getRouterParams = require('~/sites/${Config.site}/server/routerParams').default
type Props = {
siteTitle: string
serverData: any
}
const Page: FC<Props> = ({ siteTitle, serverData }) => {
const router = useRouter()
const routerParams = getRouterParams(router.query)
return (
<SwitcherPage
routerParams={routerParams}
siteTitle={siteTitle}
props={{ serverData }}
/>
)
}
export default Page
在每个 Next.js 页面上,我们将导入存在于每个网站上的SwitcherPage组件。我们还会导入routerParams,它也将控制每个网站的路由,并且我们将通过 props 接收siteTitle。换句话说,我们只是渲染SwitcherPage组件并传递 props。
让我们看看我们的san-pancho网站的Switcher组件是如何看的(packages/frontend/src/sites/san-pancho/switcher.tsx):
import dynamic from 'next/dynamic'
import React from 'react'
import Switcher, { Props } from '~/components/Switcher'
const dynamicPages: Record<string, Record<string, any>> = {
index: {
index: dynamic(() => import('./pages/index'))
},
login: {
index: dynamic(() => import('./pages/login'))
},
dashboard: {
index: dynamic(() => import('./pages/dashboard/index'))
}
}
export default ({ routerParams, siteTitle, props }: Props) => (
<Switcher
routerParams={routerParams}
siteTitle={siteTitle}
props={props}
dynamicPages={dynamicPages}
/>
)
next/dynamic是React.lazy和Suspense的复合扩展。这些组件可以延迟水合,直到Suspense边界解决。在我们的情况下,我们从这个网站动态加载页面,具体是index.index、login.index和dashboard.index。你可能想知道为什么我们为每个都设置了嵌套的索引页面。这是因为我们可以有嵌套的页面;例如,index.index将指向localhost:3000/,login.index指向localhost:3000/login,而dashboard.index指向localhost:3000/dashboard。然而,如果你想在仪表板内部添加一个名为guests的页面,那么你需要添加dashboard.guests,它将指向localhost:3000/dashboard/guests。
sites目录中的每个switcher.ts文件都使用Switcher组件。因此,让我们创建它。此文件位于packages/frontend/src/components/Switcher.tsx:
import React, { FC } from 'react'
import ErrorPage from '~/components/ErrorPage'
type Route = {
page: string
section?: string
subSection?: string
urlParams?: string[]
queryParams?: Record<string, string>
}
export type Props = {
routerParams: Route
siteTitle: string
props?: Record<string, any>
dynamicPages: any
}
const Switcher: FC<Props> = ({ routerParams, props = {}, dynamicPages: sitePages }) => {
const {
page,
section = 'index',
subSection = '',
urlParams,
queryParams = {}
} = routerParams
const extraProps = {
queryParams,
router: {
section,
subsection
},
...urlParams
}
const pageName = page
let PageToRender // This will be a dynamic React Component
let sectionPages: any = {}
// We validate if our main page exists (index, login or dashboard)
if (sitePages[pageName]) {
// If exists we get our sectionsPages (index.index, login.index and dashboard.index)
sectionPages = sitePages[pageName]
// By default we will try to render the index of each page
PageToRender = sectionPages.index
// If we have subsection, we render it (dashboard.guests)
if (sectionPages[section][subSection]) {
PageToRender = sectionPages[section][subSection]
} else if (section !== 'index') {
// This is to render nested routes that only have index
PageToRender = sectionPages[section].index
}
} else {
// If we can't find any of the pages, then we render an ErrorPage
PageToRender = ErrorPage
}
return <PageToRender {...props} {...extraProps} />
}
export default Switcher
现在,让我们为我们的 San Pancho 网站创建索引页面。这个页面有一个简单的目的:显示网站的标题,确认我们目前位于 San Pancho 网站上。此文件应位于packages/frontend/src/sites/san-pancho/pages/index.tsx:
import React from 'react'
export default () => <h1>San Pancho Index Page</h1>
在此之后,我们可以为我们的仪表板创建索引页面(packages/frontend/src/sites/san-pancho/pages/dashboard/index.tsx):
import React from 'react'
export default () => (
<>
<h1>Dashboard for San Pancho</h1>
<a href="/logout">Logout</a>
</>
)
最后,我们需要为san-pancho创建我们的登录页面,它将共享所有网站的Login组件(packages/frontend/src/sites/san-pancho/pages/login.tsx):
import React from 'react'
import Login from '~/components/Login'
export default () => <Login />
创建我们的登录系统
我们所有的网站都将使用相同的登录页面,因为我们共享认证系统。让我们创建我们的Login组件,看看我们如何执行登录操作:
import { Button, Input, RenderIf } from '@web-creator/design-system'
import { getRedirectToUrl, redirectTo } from '@web-creator/utils'
import React, { FC, useContext, useState } from 'react'
import { FormContext } from '~/contexts/form'
import { UserContext } from '~/contexts/user'
import { CSS } from './Login.styled'
type Props = {
background?: string
}
const Login: FC<Props> = () => {
const redirectToUrl = getRedirectToUrl()
// States
const [values, setValues] = useState({
emailOrUsername: '',
password: ''
})
const [notification, setNotification] = useState({
id: Math.random(),
message: ''
})
const [invalidLogin, setInvalidLogin] = useState(false)
// Contexts
const { change } = useContext(FormContext)
const { login } = useContext(UserContext)
// Methods
const onChange = (e: any): any => change(e, setValues)
const handleSubmit = async (user: any): Promise<void> => {
const response = await login(user)
if (response.error) {
setInvalidLogin(true)
setNotification({
id: Math.random(),
message: response.message
})
} else {
redirectTo(redirectToUrl || '/', true)
}
}
return (
<>
<RenderIf isTrue={invalidLogin && notification.message !== ''}>
{notification.message}
</RenderIf>
<CSS.Login>
<CSS.LoginBox>
<header>
<img className="logo" src="img/isotype.png" alt="Logo" /> <br />
<h2>Sign In</h2>
</header>
<section>
<Input
autoComplete="off"
name="emailOrUsername"
placeholder="Email Or Username"
onChange={onChange}
value={values.emailOrUsername}
/>
<Input
name="password"
type="password"
placeholder="Password"
onChange={onChange}
value={values.password}
/>
<div className="actions">
<Button onClick={(): Promise<void> => handleSubmit(values)}>
Login
</Button>
<Button color="success">
Register
</Button>
</div>
</section>
</CSS.LoginBox>
</CSS.Login>
</>
)
}
export default Login
如您所见,在handleSubmit中执行的login函数来自我们的UserContext。当用户需要执行登录操作时,它将执行login突变,并执行getUser查询以验证登录用户是否有效。让我们创建这个User Context(上下文 API),它应位于packages/frontend/src/contexts/user.ts:
import { useMutation, useQuery } from '@apollo/client'
import { getGraphQlError, parseDebugData, redirectTo } from '@web-builder/utils'
import React, { createContext, FC, ReactElement, useEffect,useMemo,useState} from 'react'
import { useCookies } from 'react-cookie'
import Config from '~/config'
import GET_USER_QUERY from '~/graphql/user/getUser.query'
import LOGIN_MUTATION from '~/graphql/user/login.mutation'
// Interfaces
interface IUserContext {
login(input: any): any
user: any
}
type Props = {
children: ReactElement
}
// Creating context
export const UserContext = createContext<IUserContext>({
login: () => null,
user: null
})
const UserProvider: FC<Props> = ({ children }) => {
// States
const [cookies, setCookie] = useCookies()
const [user, setUser] = useState(null)
// Mutations
const [loginMutation] = useMutation(LOGIN_MUTATION)
// Queries
const { data: dataUser } = useQuery(GET_USER_QUERY, {
variables: {
at: cookies['at-${Config.site}'] || ''
}
})
// Effects
useEffect(() => {
if (dataUser) {
setUser(dataUser.getUser)
}
}, [dataUser])
async function login(input:{emailOrUsername: string;password: string }): Promise<any> {
try {
const { data: dataLogin } = await loginMutation({
variables: {
emailOrUsername: input.emailOrUsername,
password: input.password
}
})
if (dataLogin) {
setCookie('at-${Config.site}', dataLogin.login.token, {
path: '/',
maxAge: 45 * 60 * 1000
})
return dataLogin.login.token
}
} catch (err) {
return getGraphQlError(err)
}
return null
}
const context = useMemo(() => ({
login,
user
}), [user])
return <UserContext.Provider value={context}>{children}</UserContext.Provider>
}
export default UserProvider
现在,让我们创建我们的login突变,它将接收两个参数($emailOrUsername和$password)。此文件应位于packages/frontend/src/graphql/user/login.mutation.ts:
import { gql } from '@apollo/client'
export default gql`
mutation login($emailOrUsername: String!, $password: String!) {
login(input: { emailOrUsername: $emailOrUsername, password: $password }) {
token
}
}
`
之后,我们需要创建getUser查询,它将accessToken(at)作为参数,并验证连接的用户是否有效。此文件位于packages/frontend/src/graphql/user/getUser.query.ts:
import { gql } from '@apollo/client'
export default gql`
query getUser($at: String!) {
getUser(at: $at) {
id
email
username
role
active
}
}
`
还有两件事要做。第一件事是将我们的UserProvider作为应用程序的包装器添加;我们需要在pages目录中的特殊页面"_app.tsx"上执行此操作:
import { ApolloProvider } from '@apollo/client'
import React, { FC } from 'react'
import Config from '~/config'
import GlobalStyle from '~/components/GlobalStyles/GlobalStyles'
import { useApollo } from '~/contexts/apolloClient'
import FormProvider from '~/contexts/form'
import UserProvider from '~/contexts/user'
const App: FC<any> = ({ Component, pageProps }) => {
const apolloClient = useApollo((pageProps && pageProps.initialApolloState) || {})
return (
<>
<GlobalStyle />
<ApolloProvider client={apolloClient}>
<UserProvider>
<FormProvider>
<Component {...pageProps} />
</FormProvider>
</UserProvider>
</ApolloProvider>
</>
)
}
// @ts-ignore
App.getInitialProps = async () => ({
...Config
})
export default App
最后,我们还需要在pages目录内创建另一个特殊文件,名为"_document.tsx"。在这个文件中,我们将渲染来自styled-components的ServerStyleSheet,以便在服务器(Next.js)中使用styled-components:
import { cx } from '@web-creator/utils'
import Document, { Head, Html, Main, NextScript } from 'next/document'
import React from 'react'
import { ServerStyleSheet } from 'styled-components'
import Config from '~/config'
export default class MyDocument extends Document {
static async getInitialProps(ctx: any) {
const sheet = new ServerStyleSheet()
const originalRenderPage = ctx.renderPage
try {
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: (App: any) => (props: any) => {
const themeClassname = 'theme--light'
return sheet.collectStyles(
<body className={cx.join(themeClassname)}>
<App {...props} title={Config.siteTitle} />
</body>
)
}
})
const initialProps = await Document.getInitialProps(ctx)
return {
...initialProps,
styles: (
<>
{initialProps.styles}
{sheet.getStyleElement()}
</>
)
}
} finally {
sheet.seal()
}
}
render() {
return (
<Html>
<Head>
<link rel="icon" type="image/x-icon" href="/images/favicon.png" />
</Head>
<Main />
<NextScript />
</Html>
)
}
}
创建我们的站点配置
正如我们在我们的 API 项目中做的那样,我们需要为我们的站点创建一个配置。让我们首先创建我们的SiteConfiguration类型,该类型的文件将位于packages/frontend/src/types/config.ts:
import { ValueOf } from '@web-creator/utils'
// Here you add your sites
export const Site = {
SanPancho: 'san-pancho',
Codejobs: 'codejobs',
BlankPage: 'blank-page'
} as const
export type Site = ValueOf<typeof Site>
export type Mode = 'production' | 'development'
export enum DeploymentType {
PRODUCTION = 'production',
STAGING = 'staging',
DEVELOPMENT = 'development'
}
export interface SiteConfiguration {
siteTitle: string
domainName: string
hostname?: string
mode?: string
api?: {
uri: string
}
pages: string[]
custom?: any
}
export interface SiteBuilderConfiguration extends SiteConfiguration {
site: Site
homeUrl: string
}
我们将要进行的配置是为san-pancho站点,你应该将此文件添加到packages/frontend/src/sites/san-pancho/config.ts:
import path from 'path'
import { SiteConfiguration } from '../../types/config'
export const config: SiteConfiguration = {
siteTitle: 'Cabañas San Pancho',
domainName: 'ranchosanpancho.com',
pages: ['index', 'login']
}
在此之后,我们必须创建我们的主要config.ts文件,该文件应在packages/frontend/src/config.ts中:
import { is } from '@web-creator/utils'
// Importing sites configurations
import { config as blankPageConfig } from './sites/blank-page/config'
import { config as sanPanchoConfig } from './sites/san-pancho/config'
import { config as codejobsConfig } from './sites/codejobs/config'
import { Site, SiteBuilderConfiguration, SiteConfiguration } from './types/config'
const isProduction = process.env.NODE_ENV === 'production'
const isLocal = process.env.LOCAL === 'true'
const isLocalProduction = isProduction && isLocal
// Getting site configuration
const getSiteConfig = (site: Site): SiteConfiguration => {
switch (site) {
case Site.SanPancho:
return sanPanchoConfig
case Site.Codejobs:
return codejobsConfig
default:
return blankPageConfig
}
}
// Building configuration
const buildConfig = (): SiteBuilderConfiguration => {
// Server site
let site = process.env.SITE as Site
// On client side we grab the site from Next props
if (is.Browser()) {
const { props } = window.__NEXT_DATA__
if (props && props.site) {
site = props.site
}
} else if (!site) {
throw 'You must specify a site (E.g. SITE=san-pancho npm run dev)'
}
const siteConfig = getSiteConfig(site)
// Building configuration based on the environment and site configuration
const config: SiteBuilderConfiguration = {
...siteConfig,
api: {
uri: isProduction && !isLocalProduction
? `https://${siteConfig.domainName}/graphql`
: `http://localhost:4000/graphql`
},
site,
homeUrl: `https://${siteConfig.domainName}`,
hostname: isProduction && !isLocalProduction ? siteConfig.domainName : 'localhost',
mode: isProduction ? 'production' : 'development'
}
return config
}
const Config = buildConfig()
export default Config
将一切整合在一起
最后一部分是server.ts文件,它将处理 Next.js、我们的静态目录和路由。让我们将文件分解成几个部分,并详细查看每一个。此文件应在packages/frontend/src/server.ts中。
我们需要做的第一件事是导入一些依赖项和站点配置:
import cookieParser from 'cookie-parser'
import express, { Application, NextFunction, Request, Response } from 'express'
import nextJS from 'next'
import path from 'path'
import { ts } from '@web-creator/utils'
import Config from './config'
import { isConnected } from './lib/middlewares/user'
import { Site } from './types/config'
然后,我们需要检查终端中传递的SITE是否实际上是有效的:
// Site
const site: string = process.env.SITE ?? 'blank-page'
// Validating service
if (!ts.includes(Site, site)) {
throw 'Invalid site'
}
如果站点有效,那么我们准备我们的Next和Express应用程序:
// Setting up Next App
const { hostname } = Config
const port = 3000
const dev = process.env.NODE_ENV !== 'production'
const nextApp = nextJS({ dev, hostname, port })
const handle = nextApp.getRequestHandler()
// Running Next App
nextApp.prepare().then(() => {
// Express application
const app: Application = express()
我们还需要配置我们的cookieParser,以便能够使用 cookie 并设置我们站点的静态目录,这样我们就可以有一个共享的public文件夹,然后在每个站点内部有特定的static目录:
// Cookies
app.use(cookieParser())
// Sites static directories
app.use(express.static(path.join(__dirname, '../public')))
app.use(express.static(path.join(__dirname, './sites/${Config.site}/static')))
接下来,我们将处理我们的自定义路由,并为特定路由,如/dashboard,添加额外的保护。我们希望确保只有已连接的用户才能访问此路由。为此,我们将使用isConnected中间件来验证用户是否已连接。如果用户未连接,我们将将他们重定向到登录页面:
// Custom Routes
app.get('/logout', (req: Request, res: Response) => {
const redirect: any = req.query.redirectTo || '/'
// The "at (accessToken)" cookie will be per site, like: "at-san-pancho" or "at-codejobs".
res.clearCookie('at-${Config.site}')
res.redirect(redirect)
})
app.get(
'/dashboard',
isConnected(true, ['god', 'admin', 'editor'], '/login?redirectTo=/dashboard'),
(req: Request, res: Response, next: NextFunction) => next()
)
最后,所有其他流量都将由 Next.js 处理;然后我们监听端口3000:
// Traffic handling
app.all('*', (req: Request, res: Response) => handle(req, res))
// Listening...
app.listen(3000)
展示时间!
在所有这些配置之后,我们准备好运行我们的项目并查看它是否工作。我们需要以与我们在 API 中相同的方式运行它,但我们将使用SITE变量而不是SERVICE变量。我们还需要指定我们想要运行哪个站点(san-pancho或codejobs)。如果你尝试运行一个不存在的其他站点,你会得到一个错误。让我们尝试一下以测试站点的验证:

图 14.16:getGuests 查询
验证工作正常。现在,让我们使用SITE=san-pancho npm run dev命令运行我们的san-pancho站点:

图 14.17:San Pancho 首页
如果一切顺利,你应该能看到前面的内容。接下来,让我们使用SITE=codejobs npm run dev命令运行我们的codejobs站点:

图 14.18:Codejobs 索引页面
很好,所以我们的两个网站都按预期工作!
现在是时候测试每个网站的登录页面了。让我们从 San Pancho 开始:

图 14.19:San Pancho 登录页面
然后让我们测试 Codejobs 登录页面:

图 14.20:Codejobs 登录页面
到目前为止,一切看起来都很正常。现在让我们用我们的默认凭证测试登录,这些凭证是用户名:admin和密码:12345678:

图 14.21:San Pancho 仪表板
很好!所以现在我们已经连接到了 San Pancho 的仪表板。
我想在这里强调的是我们用于用户会话的 cookie 名称,它是at-san-pancho。然而,即使你已经在 San Pancho 完成了登录,如果你尝试访问 Codejobs 仪表板,你将需要再次登录,因为每个网站会话都是独立的:

图 14.22:网站 cookie
最后,让我们测试一下访问我们网站上不存在的 URL:

图 14.23:404 错误页面
你应该会看到一个404页面,它将在两个网站上共享。
摘要
恭喜你走到了这一步!毫无疑问,这一章内容复杂,但非常有趣。现在,你已经准备好了个人网站的骨架,可以开始工作了。
在本章的学习过程中,你掌握了一套全面的技能。你学习了如何创建User模型和 GraphQL 类型,理解错误处理,并设置自定义服务,如 CRM。你成功导航了构建站点系统的过程,通过页面切换器增强了用户体验,并建立了一个共享登录系统。此外,你获得了管理配置、使用模型的“种子”或默认数据以及将组件合并到命令文件(如server.ts)中的知识。因此,你现在精通同步模型、启动 Apollo Server、运行测试以及有效地解决可能出现的任何问题。本质上,你在管理多站网站系统、增强用户体验、理解 GraphQL 和故障排除方面建立了坚实的基础。
在下一章中,你将有机会进一步扩展你的技能,学习如何提高你的 React 应用程序的性能。
第十五章:提高应用程序的性能
网络应用程序的有效性能对于提供良好的用户体验和提高转化率至关重要。React 库实现了不同的技术来快速渲染我们的组件,并尽可能少地接触文档对象模型(DOM)。对 DOM 的应用通常代价高昂,因此最小化操作数量至关重要。
然而,有一些场景 React 无法优化过程,这就需要开发者实现特定的解决方案来使应用程序运行顺畅。
在本章中,我们将学习 React 的基本概念,并学习如何使用一些 API 来帮助库找到更新 DOM 的最佳路径,而不会降低用户体验。我们还将看到一些可能损害我们的应用程序并使其变慢的常见错误。
我们应该避免仅仅为了优化而优化我们的组件,并且只有在需要时才应用以下章节中将要看到的技巧。
在本章中,我们将涵盖以下主题:
-
如何实现协调以及我们如何可以使用键来帮助 React 做得更好
-
常见的优化技术和常见的性能相关错误
-
有用的工具和库,使我们的应用程序运行更快
-
使用不可变数据意味着什么以及如何实现
技术要求
要完成本章,你需要以下内容:
-
Node.js 19+
-
Visual Studio Code
你可以在本书的 GitHub 仓库中找到本章的代码:github.com/PacktPublishing/React-18-Design-Patterns-and-Best-Practices-Fourth-Edition/tree/main/Chapter15。
如何实现协调
大多数情况下,React 默认情况下就足够快,你不需要做任何事情来提高你应用程序的性能。React 利用不同的技术来优化屏幕上组件的渲染。
当 React 必须显示一个组件时,它会调用其render方法以及其子组件的递归render方法。组件的render方法返回一个 React 元素的树,React 使用这个树来决定必须执行哪些 DOM 操作以更新 UI。
每当组件状态发生变化时,React 会在节点上再次调用render方法,并将结果与之前的 React 元素树进行比较。库足够智能,可以找出应用预期更改所需的最小操作集。这个过程被称为协调,并且由 React 透明地管理。正因为如此,我们可以轻松地以声明性方式描述我们的组件在特定时间点应该如何看起来,并让库完成其余的工作。
React 试图在 DOM 上应用尽可能少的操作,因为接触 DOM 是一个昂贵的操作。
然而,比较两个元素树也不是免费的,React 做出了两个假设来减少其复杂性:
-
如果两个元素具有不同的类型,它们将渲染不同的树。
-
开发者可以使用键来标记在不同渲染调用中保持稳定的子元素。
从开发者的角度来看,第二点很有趣,因为它给我们提供了一个工具,可以帮助 React 更快地渲染我们的视图。
默认情况下,当返回到 DOM 节点的子元素时,React 会同时迭代两个子元素列表,并且每当有差异时,它就会创建一个突变。
让我们看看一些例子。在将以下两个树转换为添加到子元素末尾的元素时,将工作得很好:
<ul>
<li>Carlos</li>
<li>Javier</li>
</ul>
<ul>
<li>Carlos</li>
<li>Javier</li>
<li>Emmanuel</li>
</ul>
两个<li>Carlos</li>树通过 React 与两个<li>Javier</li>树匹配,然后它会插入<li>Emmanuel</li>树。
如果天真地实现,在开始处插入一个元素会产生较差的性能。如果我们看例子,当在这两个树之间转换时,它表现得非常糟糕:
<ul>
<li>Carlos</li>
<li>Javier</li>
</ul>
<ul>
<li>Emmanuel</li>
<li>Carlos</li>
<li>Javier</li>
</ul>
每个子元素都将被 React 突变,而不是它意识到它可以保持子树<li>Carlos</li>和<li>Javier</li>的完整性。这可能是一个问题。当然,这个问题可以解决,解决方法是使用 React 支持的key属性。让我们看看下一个。
使用键
子元素具有键,这些键被 React 用于匹配后续树和原始树之间的子元素。通过在我们的前一个示例中添加键,可以使树转换更高效:
<ul>
<li key="2018">Carlos</li>
<li key="2019">Javier</li>
</ul>
<ul>
<li key="2017">Emmanuel</li>
<li key="2018">Carlos</li>
<li key="2019">Javier</li>
</ul>
React 现在知道2017键是新的,而2018和2019键只是移动了。
找到一个键并不难。你将要显示的元素可能已经有一个唯一的 ID。因此,键可以直接来自你的数据:
<li key={element.id}>{element.title}</li>
你可以给你的模型添加一个新的 ID,或者键可以由内容的一些部分生成。键必须在它的兄弟元素中是唯一的;它不需要在全局范围内是唯一的。可以将数组中的项目索引作为键传递,但现在这被认为是一种坏做法。然而,如果项目从未被记录,这可以很好地工作。重新排序将严重影响性能。
如果你使用map函数渲染多个项目,并且没有指定key属性,你会得到这个消息:警告:数组或迭代器中的每个子元素都应该有一个唯一的 key 属性。
让我们在下一节学习一些优化技术。
优化技术
重要的是要注意,在这本书的所有示例中,我们使用的是要么是用create-react-app创建的,要么是从零开始创建的,但总是使用 React 的开发版本。
使用 React 的开发版本对于编码和调试非常有用,因为它提供了修复各种问题的所有必要信息。然而,所有的检查和警告都有代价,我们希望在生产中避免这些代价。
因此,我们应该对应用程序做的第一个优化是构建包,将NODE_ENV环境变量设置为production。这在使用 webpack 时很容易,只需使用以下方式中的DefinePlugin即可:
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify('production')
}
})
为了达到最佳性能,我们不仅希望创建带有production标志的包,还希望将我们的包拆分,一个用于我们的应用程序,另一个用于node_modules。
要做到这一点,您需要使用 webpack 中的新optimization节点:
optimization: {
splitChunks: {
cacheGroups: {
default: false,
commons: {
test: /node_modules/,
name: 'vendor',
chunks: 'all'
}
}
}
}
Webpack 有两种模式,开发和生产。默认情况下,生产模式是启用的,这意味着当您使用生产模式编译包时,代码将被压缩和压缩;您可以使用以下代码块指定它:
{
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
}
您的webpack.config.ts文件应如下所示:
module.exports = {
entry: './index.ts',
optimization: {
splitChunks: {
cacheGroups: {
default: false,
commons: {
test: /node_modules/,
name: 'vendor',
chunks: 'all'
}
}
}
},
plugins: [
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify('production')
}
})
],
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development'
}
使用此 webpack 配置,我们将获得非常优化的包;一个用于我们的供应商,另一个用于实际的应用程序。
工具和库
在下一节中,我们将介绍几种技术、工具和库,我们可以将这些应用到我们的代码库中以监控和改进性能。
不可变性
新的 React Hooks,如React.memo,对 props 使用浅比较方法,这意味着如果我们传递一个对象作为 props,并且我们更改其值之一,我们将不会得到预期的行为。
实际上,浅比较无法在属性上找到突变,并且组件永远不会重新渲染,除非对象本身发生变化。解决此问题的一种方法是通过使用不可变数据,这种数据一旦创建,就不能被更改。
例如,我们可以按以下模式设置状态:
const [state, setState] = useState({})
const obj = state.obj
obj.foo = 'bar'
setState({ obj })
即使对象的foo属性值已更改,对象的引用仍然是相同的,浅比较也不会识别出来。
我们可以做的另一件事是每次更改对象时创建一个新的实例,如下所示:
const obj = Object.assign({}, state.obj, { foo: 'bar' })
setState({ obj })
在这种情况下,我们得到一个新的对象,其foo属性设置为bar,浅比较将能够找到差异。使用 ES6 和 Babel,还有另一种以更优雅的方式表达相同概念的方法,即使用对象扩展运算符:
const obj = {
...state.obj,
foo: 'bar'
}
setState({ obj })
这种结构比之前的结构更简洁,并且会产生相同的结果,但截至写作时,它需要将代码转换为浏览器内执行。
React 提供了一些不可变性辅助工具,使处理不可变对象变得容易,还有一个名为immutable.js的流行库,它具有更强大的功能,但需要您学习新的 API。
Babel 插件
此外,还有一些有趣的Babel插件,我们可以安装并使用它们来提高我们 React 应用程序的性能。它们使应用程序运行更快,在构建时优化代码的部分。
第一个是我们可以选择使用的 React 常量元素转换器,它找到所有不依赖于 props 的静态元素,并将它们从 render(或函数组件)中提取出来,以避免不必要的 _jsx 调用。
使用 Babel 插件非常简单。我们首先使用 npm 安装它:
npm install --save-dev @babel/plugin-transform-react-constant-elements
您需要创建 .babelrc 文件,并添加一个 plugins 键,其值为一个数组,包含我们想要激活的插件列表:
{
"plugins": ["@babel/plugin-transform-react-constant-elements"]
}
第二个 Babel 插件,我们可以选择使用来提高性能的是 React 内联元素转换,它将所有的 JSX 声明(或 _jsx 调用)替换为更优化的版本,以使执行更快。
使用以下命令安装插件:
npm install --save-dev @babel/plugin-transform-react-inline-elements
接下来,您可以轻松地将插件添加到 .babelrc 文件中的插件数组中,如下所示:
{
"plugins": ["@babel/plugin-transform-react-inline-elements"]
}
这两个插件应该只在生产环境中使用,因为它们会使开发模式中的调试更加困难。到目前为止,我们已经学习了许多优化技术以及如何使用 webpack 配置一些插件。
摘要
我们的性能之旅已经结束,现在我们可以优化我们的应用程序,为用户提供更好的用户体验。
在本章中,我们学习了协调算法的工作原理以及 React 如何始终尝试采取最短路径来应用更改到 DOM。我们还可以通过使用键来帮助库优化其工作。一旦找到瓶颈,就可以应用本章中我们看到的技术之一来解决问题。
我们已经学习了如何通过重构和正确设计组件的结构来提供性能提升。我们的目标是拥有小型组件,它们以最佳方式完成单一任务。在章节末尾,我们讨论了不可变性,并看到了为什么不要修改数据对于使 React.memo 和 shallowCompare 正常工作很重要。最后,我们回顾了不同的工具和库,它们可以使您的应用程序更快。
在下一章中,我们将探讨使用 Jest、React 测试库和 React DevTools 进行测试和调试。
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
packt.link/React18DesignPatterns4e

第十六章:测试和调试
多亏了其组件,React 使得测试我们的应用变得容易。有许多不同的工具可供我们使用,我们可以用 React 创建测试。在本章中,我们将介绍最受欢迎的工具,以了解它们提供的优势。
Jest 是由 Meta 的 Christoph Nakazawa 和社区内的贡献者维护的一站式测试框架解决方案,旨在为您提供最佳的开发者体验。
到本章结束时,您将能够从头开始创建测试环境并为您的应用组件编写测试。
在本章中,我们将探讨以下主题:
-
为什么测试我们的应用很重要以及它们如何帮助开发者更快地工作
-
如何设置 Jest 环境以使用 Enzyme 测试组件
-
React Testing Library 是什么以及为什么它是测试 React 应用的必备工具
-
如何测试事件
-
如何实现 Vitest
-
React DevTools 和一些错误处理技术
技术要求
为了完成本章,您需要以下内容:
-
Node.js 19+
-
Visual Studio Code
您可以在本书的 GitHub 仓库中找到本章的代码:github.com/PacktPublishing/React-18-Design-Patterns-and-Best-Practices-Fourth-Edition/tree/main/Chapter16。
理解测试的优势
测试 Web UI 一直是一项困难的任务。从单元测试到端到端测试,接口依赖于浏览器、用户交互以及许多其他变量,这使得实施有效的测试策略变得困难。
如果您曾经尝试为 Web 编写端到端测试,您就会知道如何获得一致的结果是多么复杂,以及结果通常如何受到网络等不同因素的影响,导致出现假阴性。除此之外,用户界面经常更新以提高体验、最大化转化率或简单地添加新功能。
如果测试难以编写和维护,开发者就不太可能覆盖他们的应用。另一方面,测试很重要,因为它们使开发者对自己的代码更有信心,这体现在速度和质量上。如果一段代码经过良好的测试(并且测试编写得很好),开发者可以确信它能够正常工作并准备好发布。同样,多亏了测试,重构代码变得更加容易,因为测试保证了在重写过程中功能不会改变。
开发者往往专注于他们当前正在实施的功能,有时很难知道应用的其他部分是否受到了这些更改的影响。测试有助于避免回归,因为它们可以告诉我们新代码是否破坏了旧测试。对编写新功能的更大信心导致更快地发布。
测试应用程序的主要功能可以使代码库更加稳固,每当发现新的错误时,它都可以被重现、修复并通过测试来防止未来再次发生。
幸运的是,React(以及组件时代)使得测试用户界面变得简单高效。测试组件或组件树是一项不那么繁重的任务,因为应用程序的每个部分都有其职责和边界。如果组件以正确的方式构建,如果它们是纯的,并且旨在具有可组合性和可重用性,那么它们可以像简单的函数一样进行测试。
现代工具带给我们的另一个强大功能是使用 Node.js 和控制台运行测试的能力。为每个测试启动浏览器会使测试变慢且不可预测,降低开发者的体验;相反,使用控制台运行测试要快得多。
仅在控制台中测试组件有时在它们在真实浏览器中渲染时可能会出现意外的行为,但根据我的经验,这种情况很少见。当我们测试 React 组件时,我们想要确保它们能正常工作,并且给定不同的 props 集合,它们的输出始终是正确的。
我们还可能想要覆盖组件可能具有的所有各种状态。状态可能会通过点击按钮而改变,因此我们编写测试来检查所有事件处理器是否正在执行它们应该执行的操作。
当组件的所有功能都被覆盖,但我们还想做更多的时候,我们可以编写测试来验证组件在边缘情况下的行为。边缘情况是组件可以假设的状态,例如,当所有 props 都是 null 或存在错误时。一旦编写了测试,我们就可以相当有信心地认为组件的行为符合预期。
测试单个组件固然很好,但这并不能保证将多个单独测试过的组件组合在一起后仍然能正常工作。正如我们稍后将会看到的,使用 React,我们可以挂载一个组件树并测试它们之间的集成。
我们可以使用不同的技术来编写测试,其中最受欢迎的一种是测试驱动开发(TDD)。应用 TDD 意味着先编写测试,然后再编写代码以通过测试。
遵循这种模式有助于我们编写更好的代码,因为我们被迫在实现功能之前更多地思考设计,这通常会导致更高的代码质量。
既然我们已经涵盖了所有这些内容,那就让我们挽起袖子开始为我们的 React 组件编写测试。我们还将了解一种称为测试驱动开发(test-driven development)的酷炫编程方式,并使用一个名为 Jest 的便捷工具来简化我们的 JavaScript 测试。准备好了吗?让我们深入其中,开始使用真实代码进行工作!
使用 Jest 进行无痛苦 JavaScript 测试
学习如何正确测试 React 组件最重要的方式是通过编写代码,这正是我们将在本节中要做的。
React 文档说明 Facebook 使用 Jest 来测试其组件。然而,React 并不强制你使用特定的测试框架,你可以使用你喜欢的任何一个而没有任何问题。为了看到 Jest 的实际应用,我们将从头创建一个项目,安装所有依赖项,并编写一个带有一些测试的组件。这将很有趣!
第一件事是要进入一个新的文件夹并运行以下命令:
npm init
一旦创建了 package.json,我们就可以开始安装依赖项,第一个是 jest 包本身:
npm install --save-dev jest
为了告诉 npm 我们想使用 jest 命令来运行测试,我们必须将以下脚本添加到 package.json 中:
"scripts": {
"build": "webpack",
"start": "node ./dist/server",
"test": "jest",
"test:coverage": "jest --coverage"
}
为了使用 ES6 和 JSX 编写组件和测试,我们必须安装所有与 Babel 相关的包,以便 Jest 可以使用它们进行转译和理解代码。
第二组依赖项的安装方式如下:
npm install --save-dev @babel/core @babel/preset-env @babel/preset-react ts-jest
如你所知,我们现在必须创建一个 .babelrc 文件,它被 Babel 用于知道我们希望在项目中使用的预设和插件。
.babelrc 文件看起来如下:
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}
现在,是时候安装 React 和 ReactDOM,这是我们创建和渲染组件所需的:
npm install --save react react-dom
设置已完成,我们可以运行 Jest 对 ES6 代码进行测试,并在 DOM 中渲染我们的组件,但还有一件事要做。
我们需要安装 jest-environment-jsdom、@testing-library/jest-dom 和 @testing-library/react:
npm install @testing-library/jest-dom @testing-library/react jest-environment-jsdom
在你安装了这些包之后,你必须创建 jest.config.js 文件:
module.exports = {
preset: 'ts-jest',
setupFilesAfterEnv: ['<rootDir>/setUpTests.ts'],
testEnvironment: 'jsdom'
}
然后,让我们创建 setUpTests.ts 文件:
import '@testing-library/jest-dom/extend-expect'
现在,让我们想象我们有一个 Hello 组件 (src/components/Hello/index.tsx):
import React, { FC } from 'react'
type Props = {
name?: string
}
function Hello({ name }: Props) {
return <h1 className="Hello">Hello {name || 'World'}</h1>
}
Hello.defaultProps = {
name: ''
}
export default Hello
为了测试这个组件,我们需要创建一个具有相同名称但添加 .test(或 .spec)后缀的新文件。这将是我们的测试文件:
import React from 'react'
import { render, cleanup } from '@testing-library/react'
import Hello from './index'
describe('Hello Component', () => {
it('should render Hello World', () => {
const wrapper = render(<Hello />)
expect(wrapper.getByText('Hello World')).toBeInTheDocument()
})
it('should render the name prop', () => {
const wrapper = render(<Hello name="Carlos" />)
expect(wrapper.getByText('Hello Carlos')).toBeInTheDocument()
})
it('should has .Home classname', () => {
const wrapper = render(<Hello />)
expect(wrapper.container.firstChild).toHaveClass('Hello')
})
afterAll(cleanup)
})
然后,为了运行测试,你需要执行以下命令:
npm test
你应该看到以下结果:

图 16.1:npm 测试
PASS 标签表示所有测试都已成功通过;如果至少有一个测试失败,你将看到 FAIL 标签。让我们更改我们的一个测试使其失败:
it('should render the name prop', () => {
const wrapper = render(<Hello name="Carlos" />)
expect(wrapper.getByText('Hello World')).toBeInTheDocument()
})
这是结果:

图 16.2:失败的测试
如你所见,FAIL 标签用 X 指定。此外,预期的和接收到的值提供了有用的信息,你可以看到预期的值和接收到的值。
如果你想要查看所有单元测试的覆盖率百分比,你可以执行以下命令:
npm run test:coverage
结果如下:

图 16.3:通过测试
覆盖率还会生成结果的 HTML 版本;它创建一个名为 coverage 的目录,并在其中创建一个名为 Icov-report 的目录。如果你在浏览器中打开 index.xhtml 文件,你会看到以下 HTML 版本:

图 16.4:Icov-report
现在你已经完成了第一次测试,并且知道如何收集覆盖率数据,让我们看看如何在下一节测试事件。
测试事件
事件在任何网络应用中都非常常见,我们同样需要测试它们,所以让我们学习如何测试事件。为此,让我们创建一个新的 ShowInformation 组件:
import { useState, ChangeEvent } from 'react'
function ShowInformation() {
const [state, setState] = useState({ name: '', age: 0, show: false })
const handleOnChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
setState({
...state,
[name]: value
})
}
const handleShowInformation = () => {
setState({
...state,
show: true
})
}
if (state.show) {
return (
<div className="ShowInformation">
<h1>Personal Information</h1>
<div className="personalInformation">
<p><strong>Name:</strong> {state.name}</p>
<p><strong>Age:</strong> {state.age}</p>
</div>
</div>
)
}
return (
<div className="ShowInformation">
<h1>Personal Information</h1>
<p><strong>Name:</strong></p>
<p>
<input name="name" type="text" value={state.name} onChange={handleOnChange} />
</p>
<p>
<input name="age" type="number" value={state.age} onChange={handleOnChange} />
</p>
<p><button onClick={handleShowInformation}>Show Information</button></p>
</div>
)
}
export default ShowInformation
现在,让我们在 src/components/ShowInformation/index.test.tsx 创建测试文件:
import { render, cleanup, fireEvent } from '@testing-library/react'
import ShowInformation from './index'
describe('Show Information Component', () => {
let wrapper
beforeEach(() => {
wrapper = render(<ShowInformation />)
})
it ('should modify the name', () => {
const nameInput = wrapper.container.querySelector('input[name="name"]') as HTMLInputElement
const ageInput = wrapper.container.querySelector('input[name="age"]') as HTMLInputElement
fireEvent.change(nameInput, { target: { value: 'Carlos' } })
fireEvent.change(ageInput, { target: { value: 34 } })
expect(nameInput.value).toBe('Carlos')
expect(ageInput.value).toBe('34')
})
it ('should show the personal information when user clicks on the button', () => {
const button = wrapper.container.querySelector('button')
fireEvent.click(button)
const showInformation = wrapper.container.querySelector('.personalInformation')
expect(showInformation).toBeInTheDocument()
})
afterAll(cleanup)
})
如果你运行测试并且一切正常,你应该会看到以下内容:

图 16.5:通过测试
介绍 Vitest
Vitest 是一个基于 Vite 构建的单元测试框架,旨在追求速度和最小化配置。它可作为 Jest、Mocha 和 Chai 等各种测试工具的替代品。由于 Vitest 是建立在 Jest API 之上的,如果你已经知道如何使用 Jest,它的工作方式将非常相似。
在这个上下文中,我们将利用 Vite,这是一个旨在为现代网络项目提供快速和精简开发体验的构建工具。
首先,你需要使用以下命令全局安装 Vite:
npm install vite -g
安装完成后,你需要使用 npm 命令创建你的第一个项目:
npm create vite@latest
它会要求你输入项目名称。你可以使用 my-first-vite-project,然后选择你想要使用的框架(React),最后选择变体(TypeScript):

图 16.6:npm create vite@latest
接下来,你需要安装项目依赖并运行 npm run dev 命令。如果你这样做,你将在端口 5173 上看到类似以下内容:

图 16.7:Vite 应用
安装和配置 Vitest
一旦你的 Vite 应用运行起来,就是时候安装 Vitest 了。要做到这一点,你只需要在你的项目终端中运行这个命令:
npm install -D vitest @test-library/react
在安装了 Vitest 之后,你需要使用以下代码修改 vite.config.ts 文件:
/// <reference types="vitest" />
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom'
}
})
如你所见,我们将使用 jsdom 环境,因此你也需要安装它:
npm install -D jsdom
此外,Vitest 还提供了一个名为 Vitest UI 的插件,它使 Vitest 能够在浏览器中提供直观的用户界面来查看和交互测试。虽然这是一个可选插件,但我们将使用它。你可以通过执行以下命令来安装它:
npm install -D @vitest/ui
为了测试你的代码,你需要使用 vitest --ui 命令将测试脚本添加到你的 package.json 文件中:
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest --ui"
}
我们将使用与 Jest 相同的 Hello 组件,尽管会有一些差异。你需要将此组件保存到 src/components/Hello/index.tsx:
import React, { FC } from 'react'
type Props = {
name?: string
}
const Hello: FC<Props> = ({ name }) => <h1 className="Hello">Hello {name || "World"}</h1>
export default Hello
然后,你需要在同一组件目录下创建一个名为 index.test.tsx 的测试文件:
import { cleanup, render } from '@testing-library/react'
import { afterAll, describe, expect, it } from 'vitest'
import Hello from './index'
describe("Hello Component", () => {
it("should render Hello World", () => {
const wrapper = render(<Hello />)
expect(wrapper.getByText("Hello World")).toBeDefined()
})
it("should render the name prop", () => {
const wrapper = render(<Hello name="Carlos" />)
expect(wrapper.getByText("Hello Carlos")).toBeDefined()
})
it("should has .Home classname", () => {
const wrapper = render(<Hello />)
const firstChild = wrapper.container.firstChild as HTMLElement
expect(firstChild?.classList.contains("Hello")).toBe(true)
})
afterAll(cleanup)
})
如你所见,代码与 Jest 非常相似。然而,主要区别之一是我们现在正在导入我们将要使用的所有测试方法,例如 afterAll、describe、expect 和 it。
如果您运行test命令,您应该在您的终端中看到类似以下的内容:

图 16.8:npm 测试
如果您已经注意到,这是由我们之前安装的 Vitest UI 插件生成的链接。如果您点击该链接,您将看到以下内容:

图 16.9:Vitest UI
目前,我们只有一个测试文件,但如果您添加更多,您将在左侧侧边栏上看到它们。现在,让我们点击我们当前的Hello测试:

图 16.10 – 报告
您将能够看到正确通过的测试用例。然而,这个 UI 插件最有趣的优势之一是您甚至可以直接在浏览器中通过点击代码标签来修改测试代码:

图 16.11:代码
让我们修改我们的代码,故意让一些测试失败。您可以将第一个测试改为"Hello Foo"而不是"Hello World",并确保保存(Cmd + S):

图 16.12:失败的测试
如您所见,现在我们的第一次测试失败了,因为它无法找到"Hello Foo"文本。
启用全局变量
个人来说,我更喜欢在一个文件中导入所有必要的函数或变量。然而,我意识到在创建大量测试文件时,反复导入全局测试变量如describe、it、expect等可能会变得繁琐和麻烦。
幸运的是,Vitest 提供了一个配置选项来启用globals,从而消除了每次都需要导入它们的需要。要启用此功能,您需要使用以下代码修改您的vite.config.ts文件:
/// <reference types="vitest" />
/// <reference types="vite/client" />
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true
}
})
在进行前面提到的更改后,您还需要通过添加全局类型来更新您的tsconfig.json文件:
"compilerOptions": {
"types": ["vitest/globals"]
}
在遵循这些步骤之后,您现在将能够从您的测试文件中移除对globals的导入。如果您仍然遇到任何 TypeScript 错误,您可能需要重新启动您的 TypeScript 服务器或重新加载 VSCode 中的窗口。
在源代码中测试
Vitest 还提供了一种在源代码与实现一起运行测试的方法,类似于 Rust 的模块测试。
个人来说,我有一个老式的做法,我通常更喜欢为我的测试保留一个单独的测试文件。然而,在某些情况下,被测试的组件或函数非常小,创建一个新的测试文件可能看起来有些过度。
要启用此功能,您需要修改您的vite.config.ts文件并添加includeSource选项:
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
includeSource: ["src/**/*.{ts,tsx}"]
}
})
要解决 TypeScript 问题,您需要在您的tsconfig.json文件中添加vitest/importMeta类型进行另一个更改:
"compilerOptions": {
"types": ["vitest/globals", "vitest/importMeta"]
}
现在,让我们将我们的Hello组件测试文件移到同一个Hello组件内部。再次强调,这是可选的,只是为了演示这是可能的。最后,您可以选择使用哪种测试方法。
要实现这一点,我们需要在我们的 Hello 组件内部添加一个 if 语句来检查我们是否处于测试模式。我们可以用以下代码来完成这个任务:if (import.meta.vitest)。在这个块内部,我们将移动所有的测试用例,并且我们也将只在该块内部要求 React 测试库 方法。这样,我们的代码将类似于以下内容:
import React, { FC } from 'react'
type Props = {
name?: string;
}
const Hello: FC<Props> = ({ name }) => <h1 className="Hello">Hello {name || "World"}</h1>
export default Hello;
if (import.meta.vitest) {
const { cleanup, render } = require('@testing-library/react')
describe("Hello Component", () => {
it("should render Hello World", () => {
const wrapper = render(<Hello />)
expect(wrapper.getByText("Hello World")).toBeDefined()
})
it("should render the name prop", () => {
const wrapper = render(<Hello name="Carlos" />)
expect(wrapper.getByText("Hello Carlos")).toBeDefined()
})
it("should has .Home classname", () => {
const wrapper = render(<Hello />)
const firstChild = wrapper.container.firstChild as HTMLElement
expect(firstChild?.classList.contains("Hello")).toBe(true)
})
afterAll(cleanup)
})
}
现在,你可以删除你之前的文件(index.test.tsx)。如果你再次运行你的测试,它们应该会按预期工作。
不同之处在于现在你将能够看到整个代码(Component 和 Test 用例):

图 16.13:通过测试
这种方法可能会加快组件或函数的测试过程。然而,我个人仍然更喜欢在单独的测试文件中进行测试。尽管如此,你可以自由选择对你和你的项目最有效的方法。
在探索了源代码测试的概念之后,让我们继续了解如何有效地将 React DevTools 应用于我们的开发过程中,以优化我们应用程序的性能并确保其平稳运行。
使用 React DevTools
当在控制台测试不够时,并且我们想在浏览器内运行的应用程序中检查我们的应用程序时,我们可以使用 React DevTools。
你可以在以下网址将其作为 Chrome 扩展安装:chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgo fadopljbjfkapdkoienihi?hl=en。
安装会在 Chrome DevTools 中添加一个名为 React 的标签页,你可以检查组件的渲染树并检查它们在特定时间点接收了哪些属性以及它们的状态。
可以读取属性和状态,并且可以实时更改它们以触发 UI 的更新并立即看到结果。这是一个必备的工具,在最新版本中,它有一个可以通过勾选跟踪 React 更新复选框来启用的新功能。
当这个功能被启用时,我们可以使用我们的应用程序,并查看当我们执行特定操作时哪些组件被更新。更新的组件会用彩色矩形突出显示,这使得发现可能的优化变得容易。
使用 Redux DevTools
如果你正在你的应用程序中使用 Redux,你可能想使用 Redux DevTools 来调试你的 Redux 流程。你可以在以下网址安装它:chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd?hl=es
此外,你还需要安装 redux-devtools-extension 包:
npm install --save-dev redux-devtools-extension
一旦你安装了 React DevTools 和 Redux DevTools,你将需要配置它们。
如果你尝试直接使用 Redux DevTools,它将不会工作;这是因为我们需要将 composeWithDevTools 方法传递给 Redux 存储;这应该是 configureStore.ts 文件:
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension';
import rootReducer from '@reducers';
export default function configureStore({
initialState,
reducer
}) {
const middleware = [thunk];
return createStore(
rootReducer,
initialState,
composeWithDevTools(applyMiddleware(...middleware))
);
}
这是测试我们 Redux 应用程序的最佳工具。
摘要
在本章中,你全面了解了测试的好处,以及可用于测试 React 组件的各种框架和工具。你学习了如何使用 React Testing Library 来实现和测试组件和事件,以及如何使用 Jest 覆盖率来优化你的测试过程。此外,你还探索了 React DevTools 和 Redux DevTools 等工具,以进一步增强你的开发体验。在测试复杂组件时,如高阶组件或具有多个嵌套字段的表单,记住常见的解决方案是很重要的,以确保你的测试能够准确反映应用程序的功能。
在下一章中,你将学习如何将你的应用程序部署到生产环境。
第十七章:部署到生产环境
现在你已经完成了你的第一个 React 应用程序,是时候学习如何将其部署到世界上了。为此,我们将使用名为DigitalOcean的云服务。
在本章中,你将学习如何使用 Node.js 和nginx在 DigitalOcean 的 Ubuntu 服务器上部署你的 React 应用程序。简而言之,我们将涵盖以下主题:
-
创建 DigitalOcean Droplet 并配置它
-
配置 nginx、PM2 和域名
-
实施 CircleCI 进行持续集成
技术要求
为了完成本章,你需要以下内容:
-
Node.js 19+
-
Visual Studio Code
创建我们的第一个 DigitalOcean Droplet
我已经使用了 DigitalOcean 七年了,我可以这么说,它是我在尝试过的最好的云服务之一,不仅因为成本合理,而且因为它配置超级简单快捷,社区有大量的更新文档来修复大多数与服务器配置相关的问题。
到这一点,你需要投资一些钱来获取这项服务。我会向你展示最便宜的方法来做这件事,如果你将来想要增加 Droplet 的功率,你将能够增加容量而无需重新配置。
非常基础的 Droplet 的最低价格为每月 6.00 美元(每小时 0.009 美元)。
我们将使用 Ubuntu 20.04(但请随意使用最新版本,21.04);你需要了解一些基本的 Linux 命令才能配置你的 Droplet。如果你是使用 Linux 的初学者,不要担心——我会尽量用非常简单的方式展示每个步骤。
在 DigitalOcean 上注册
如果你没有 DigitalOcean 账户,你可以在cloud.digitalocean.com/registrations/new注册。
你可以用你的 Google 账户注册,或者手动注册。一旦你用 Google 注册,你将看到账单信息视图,如下所示:

图 17.1:账单信息
你可以用信用卡或通过 PayPal 支付。一旦你配置了你的支付信息,DigitalOcean 将要求你提供一些关于你的项目的信息,以便它可以更快地配置你的 Droplet。

图 17.2:第一个应用程序
让我们继续创建我们的第一个 Droplet。
创建我们的第一个 Droplet
我们将从头开始创建一个新的 Droplet。按照以下步骤操作:
- 选择新 Droplet选项,如下面的截图所示:

图 17.3:新 Droplet
- 选择Ubuntu 20.04 (LTS) x64,如下所示:

图 17.4:选择镜像
- 然后,选择基础计划,如图所示:

图 17.5:选择一个计划
- 然后,你可以从支付计划选项中选择$6/月:

图 17.6:CPU 选项
-
选择一个区域。在这种情况下,我们将选择旧金山区域:
![]()
图 17.7:选择区域
-
创建一个根密码,添加你的 Droplet 名称,然后点击创建 Droplet按钮,如下所示:
![图形用户界面、文本、应用程序、电子邮件描述自动生成]()
图 17.8:身份验证
-
创建你的 Droplet 大约需要 30 秒。一旦创建完成,你将能够看到它:

图 17.9:我的第一个 Droplet
-
现在,在你的终端中,你可以使用以下命令访问 Droplet:
ssh root@THE_DROPLET_IP -
第一次访问时,你会被要求输入指纹。你只需输入是,然后它会要求你输入密码(你在创建 Droplet 时定义的密码)。
这是一种专门设计用于防止中间人攻击的安全功能。服务器的“指纹”充当一个独特的数字签名,该签名仅属于服务器本身。当你观察到与预期匹配的指纹时,你可以通过输入yes并按Enter键继续。随后,服务器将提示你输入密码。请提供你在创建 Droplet 时定义的密码,并按Enter键。请注意,出于安全原因,在输入密码时屏幕上不会显示任何字符。认证成功后,你将登录到你的服务器,准备开始执行命令。

图 17.10:连接到 Droplet
现在我们已经准备好安装 Node.js,我们将在下一节中介绍。
安装 Node.js
现在你已经连接到你的 Droplet,让我们来配置它。首先,我们需要使用个人软件包存档安装 Node.js 的最新版本。本书撰写时 Node.js 的当前版本是 19.9.x。按照以下步骤安装 Node.js:
-
如果你阅读这个段落时 Node.js 有新版本,请更改
setup_19.x命令中的版本:cd ~ curl -sL https://deb.nodesource.com/setup_19.x -o nodesource_setup.sh -
一旦你获取到
nodesource_setup.sh文件,运行以下命令:sudo bash nodesource_setup.sh -
然后,通过运行以下命令安装 Node:
sudo apt install nodejs -y -
如果一切正常,请使用以下命令验证安装的 Node 和
npm版本:node -v v19.9.0 npm -v 9.6.3
如果你需要 Node.js 的新版本,你可以随时升级它。
配置 Git 和 GitHub
我创建了一个特殊的仓库,帮助你将第一个 React 应用程序部署到生产环境(github.com/FoggDev/production)。
在你的 Droplet 上,你需要克隆这个 Git 仓库(或者如果你已经准备好了要部署的 React 应用程序,克隆你自己的仓库)。生产仓库是公开的,但通常你会使用私有仓库;在这种情况下,你需要将你的 Droplet 的 SSH 密钥添加到你的 GitHub 账户中。
要创建这个密钥,请按照以下步骤操作:
-
运行
ssh-keygen命令,然后按三次 Enter 键,不输入任何密码短语:![文本 自动生成的描述]()
图 17.11:ssh-keygen
如果你让你的终端闲置超过五分钟,你的 Droplet 连接可能会被关闭,你需要再次连接。
-
一旦你创建了你的 Droplet SSH 密钥,你可以通过运行以下命令来查看它:
vi /root/.ssh/id_rsa.pub你会看到类似以下的内容:
![文本 自动生成的描述]()
图 17.12:ssh-rsa
-
复制你的 SSH 密钥,然后访问你的 GitHub 账户。转到 设置 | SSH 和 GPG 密钥(
github.com/settings/ssh/new)。然后,将你的密钥粘贴到文本区域,并为密钥添加标题:

图 17.13:向 GitHub 添加新的 SSH 密钥
- 一旦你点击 添加 SSH 密钥 按钮,你会看到你的 SSH 密钥,如下所示:

图 17.14:SSH
-
现在,你可以使用以下命令克隆我们的仓库(或你的):
git clone git@github.com:FoggDev/production.git -
当你第一次克隆时,你会收到一条消息,要求你允许 RSA 密钥指纹:

图 17.15:克隆仓库
- 你必须输入
Yes并按 Enter 键才能克隆它:

图 17.16:已知主机
-
然后,你必须转到生产目录并安装
npm包:cd production npm install -
如果你想要测试应用程序,只需运行
start脚本:npm start -
然后打开你的浏览器,转到你的 Droplet IP 并添加端口号。在我的例子中,它是
http://144.126.222.17:3000:

图 17.17:开发模式下的项目运行
-
这将在开发模式下运行项目。如果你想以生产模式运行它,请使用以下命令:
npm run start:production你应该会看到 生产进程管理器(PM2)正在运行,如下面的截图所示:
![文本 自动生成的描述]()
图 17.18:PM2
-
如果你运行它并在你的 Chrome DevTools 中的 网络 选项卡下查看,你会看到正在加载的包:

图 17.19:网络选项卡
现在,我们的 React 应用程序已经在生产环境中运行,但在下一节中,让我们看看我们还能用 DigitalOcean 做些什么。
关闭我们的 Droplet
要关闭 Droplet,请按照以下步骤操作:
- 如果你想要关闭你的 Droplet,你可以转到 电源 部分,或者你可以使用 开启/关闭 开关:

图 17.20:关闭 Droplet
- DigitalOcean 只在你开启 Droplet 时才会收费。如果你点击 开启 开关来关闭它,那么你会收到以下确认消息:

图 17.21:关闭 Droplet
这样,你可以控制你的 Droplet,并在不使用 Droplet 时避免不必要的付费。
配置 nginx、PM2 和域名
我们的 Droplet 已准备好用于生产,但正如你所见,我们仍在使用端口 3000。我们需要配置 nginx 并实现代理以将流量从端口 80 重定向到 3000;这意味着我们不再需要直接指定端口。
Node PM2 将帮助我们安全地运行 Node 服务器。通常,如果我们直接使用 node 或 babel-node 命令运行 Node,并且应用程序中发生错误,那么它将崩溃并停止工作。PM2 在发生错误时重新启动 Node 服务器。
首先,在你的 Droplet 中,你需要全局安装 PM2:
npm install -g pm2
PM2 将帮助我们以非常简单的方式运行我们的 React 应用程序。
安装和配置 nginx
要安装 nginx,你需要执行以下命令:
sudo apt-get update
sudo apt-get install nginx
在你安装 nginx 之后,你可以开始配置:
-
我们需要调整防火墙以允许端口
80的流量。要列出可用的应用程序配置,你需要运行以下命令:sudo ufw app list Available applications: Nginx Full Nginx HTTP Nginx HTTPS OpenSSH -
Nginx Full表示它将允许来自端口80(HTTP)和端口443(HTTPS)的流量。我们尚未配置任何带有 SSL 的域名,因此,目前我们应该限制流量仅通过端口80(HTTP)发送:sudo ufw allow 'Nginx HTTP' Rules updated Rules updated (v6)如果你尝试访问 Droplet IP,你应该看到 nginx 正在运行:
![图形用户界面、文本、应用程序、电子邮件 自动生成的描述]()
图 17.22:欢迎使用 nginx
-
你可以使用以下命令管理 nginx 进程:
Start server: sudo systemctl start nginx Stop server: sudo systemctl stop nginx Restart server: sudo systemctl restart nginx
Nginx 是一个非常出色的网站服务器,现在越来越受欢迎。
设置反向代理服务器
如我之前所述,我们需要设置一个反向代理服务器,将端口 80(HTTP)的流量发送到端口 3000(React 应用程序)。为此,你需要打开以下文件:
sudo vi /etc/nginx/sites-available/default
设置反向代理服务器的步骤如下:
-
在
location /块中,你需要用以下代码替换文件中的代码:location / { proxy_pass http://localhost:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; } -
保存文件后,你可以使用以下命令验证 nginx 配置中是否存在语法错误:
sudo nginx -t -
如果一切正常,你应该看到这个:

图 17.23:sudo ngnix-t
-
最后,你需要重新启动 nginx 服务器:
sudo systemctl restart nginx
现在,您应该能够访问不带端口的 React 应用程序,如下面的截图所示:

图 17.24:不带端口的 React 应用程序
我们几乎完成了!在下一节中,我们将向我们的 Droplet 添加一个域名。
将域名添加到我们的 Droplet
使用 IP 地址访问网站并不方便;我们总是需要使用域名来帮助用户更容易地找到我们的网站。如果您想使用与您的 Droplet 关联的域名,您需要更改域名的名称服务器,使其指向 DigitalOcean DNS。我通常使用 GoDaddy 来注册我的域名。
要使用 GoDaddy 完成此操作,请按照以下步骤进行:
- 前往
dcc.godaddy.com/manage/YOURDOMAIN.COM/dns,然后进入名称服务器部分:

图 17.25:名称服务器
- 点击更改按钮,选择自定义,然后指定 DigitalOcean DNS:

图 17.26:DigitalOcean 名称服务器
- 通常,DNS 更改需要 15 到 30 分钟才能生效;现在,在您更新了名称服务器后,前往您的Droplet仪表板,然后选择添加域名选项:

图 17.27:添加域名
- 然后,输入您的域名,选择您的 Droplet,并点击添加域名按钮:

图 17.28:网络
- 现在,您必须为CNAME创建一个新的记录。选择CNAME选项卡,在主机名中输入
www;在别名字段中,默认写入@;默认情况下,TTL 为43200。所有这些都是为了通过www前缀访问您的域名:

图 17.29:创建新记录
如果您一切操作正确,您应该能够访问您的域名并看到 React 应用程序正在运行。正如我之前所说,这个过程可能需要长达 30 分钟,但在某些情况下,可能需要长达 24 小时,具体取决于 DNS 传播速度。

图 17.30:运行在域名上的 React 应用程序
太棒了。现在您已经正式部署了您的第一个 React 应用程序到生产环境!
实施 CircleCI 进行持续集成
我已经使用 CircleCI 一段时间了,我可以告诉你,它是最好的 CI 解决方案之一:对个人用户免费,提供无限数量的仓库和用户;你每月有 1,000 构建分钟,一个容器和一个并发作业;如果你需要更多,你可以以每月 50 美元的初始价格升级计划。
你需要做的第一件事是使用你的 GitHub 账户(或者如果你更喜欢,使用 Bitbucket)在该网站上注册。
如果你选择使用 GitHub,你需要在你的账户中授权 CircleCI,如下面的截图所示:

图 17.31:授权 CircleCI
在下一节中,我们将把我们的 SSH 密钥添加到 CircleCI。
在 CircleCI 中添加 SSH 密钥
现在,你已经创建了账户,CircleCI 需要一种方式来登录你的 DigitalOcean Droplet 以运行部署脚本。按照以下步骤完成此任务:
-
在你的 Droplet 中使用以下命令创建一个新的 SSH 密钥:
ssh-keygen -t rsa # Then save the key as /root/.ssh/id_rsa_droplet with no password. # After go to .ssh directory cd /root/.ssh -
然后,让我们将密钥添加到我们的
authorized_keys:cat id_rsa_droplet.pub >> authorized_keys -
现在,你需要下载私钥。为了验证你可以使用新密钥登录,你需要按照以下方式将其复制到你的本地机器:
# In your local machine do: scp root@YOUR_DROPLET_IP:/root/.ssh/id_rsa_droplet ~/.ssh/ cd .ssh ssh-add id_rsa_droplet ssh -v root@YOUR_DROPLET_IP如果你一切操作正确,你应该能够无密码登录你的 Droplet,这意味着 CircleCI 也可以访问我们的 Droplet。
-
复制你的
id_rsa_droplet.pub密钥的内容,然后转到你的仓库设置(app.circleci.com/settings/project/github/YOUR_GITHUB_USER/YOUR_REPOSITORY):

图 17.32:项目设置
- 前往 SSH 密钥,如下所示:

图 17.33:SSH 密钥
- 你也可以访问 URL
app.circleci.com/settings/project/github/YOUR_GITHUB_USER/YOUR_REPOSITORY/ssh,然后点击底部的 添加 SSH 密钥 按钮:

图 17.34:添加 SSH 密钥
- 粘贴你的私钥,然后为 主机名 字段提供一个名称;我们将命名为
DigitalOcean。
现在,让我们在下一节中配置我们的 CircleCI 实例。
配置 CircleCI
现在,你已经为你的 Droplet 配置了 CircleCI 的访问权限,你需要向你的项目中添加一个 config 文件来指定部署过程中要执行的作业。
此过程在以下步骤中展示:
-
为了这个,你需要创建
.circleci目录并在config.yml文件中添加以下内容:version: 2.1 jobs: build: working_directory: ~/tmp docker: - image: cimg/node:14.16.1 steps: - checkout - run: npm install - run: npm run lint - run: npm test - run: ssh -o StrictHostKeyChecking=no $DROPLET_USER@$DROPLET_IP 'cd production; git checkout master; git pull; npm install; npm run start:production;' workflows: build-deploy: jobs: - build: filters: branches: only: master -
当你有一个
.yml文件时,你需要注意缩进;它与 Python 类似,如果你没有正确使用缩进,你会得到一个错误。让我们看看这个文件是如何结构的。 -
指定我们将使用的 CircleCI 版本。在这个例子中,你正在使用版本
2.1(撰写本书时的最新版本):version: 2.1 -
在
jobs中,我们将指定需要配置容器;我们将使用 Docker 创建它,并概述部署过程的步骤。 -
working_directory将是我们将用于安装 npm 包和运行部署脚本的临时目录。在这种情况下,我决定使用tmp目录,如下所示:jobs: build: working_directory: ~/tmp -
如我之前所说,我们将创建一个 Docker 容器,在这个例子中,我选择了一个包含
node: 18.12.1的现有镜像。如果你想了解所有可用的镜像,你可以访问circleci.com/docs/2.0/circleci-images:docker: - image: cimg/node:18.12.1 -
对于代码案例,首先使用
git checkout命令切换到master分支,然后在每个run语句中,你需要指定你想要运行的脚本:steps: - checkout - run: npm install - run: npm run lint - run: npm test - run: ssh -o StrictHostKeyChecking=no $DROPLET_USER@$DROPLET_IP 'cd production; git checkout master; git pull; npm install; npm run start:production;'
这里是对之前步骤的解释:
-
首先,你需要使用
npm install命令安装 npm 包,以便能够执行后续任务。 -
使用
npm run lint执行 ESLint 验证。如果失败,它将中断部署过程;否则,它将继续下一个运行。 -
使用
npm run test执行 Jest 验证;如果失败,它将中断部署过程。否则,它将继续下一个运行。 -
在最后一步,我们连接到我们的 DigitalOcean Droplet,通过传递
StrictHostKeyChecking=no标志来禁用严格的主机密钥检查。然后,我们使用$DROPLET_USER和$DROPLET_IPENV变量来连接到它(我们将在下一步创建这些变量),最后,我们将使用单引号指定我们将在 Droplet 内执行的命令。
这些命令如下列出:
-
cd production: 授予对生产环境(或你的 Git 仓库名称)的访问权限。
-
git checkout master: 这将检出主分支。
-
git pull: 从我们的仓库拉取最新更改。
-
npm run start:production: 这是最后一步,它以生产模式运行我们的项目。
最后,让我们向我们的 CircleCI 添加一些环境变量。
在 CircleCI 中创建环境变量
正如你之前看到的,我们正在使用 \(DROPLET_USER** 和 **\)DROPLET_IP 变量,但我们如何定义这些变量呢?请按照以下步骤操作:
- 你需要再次进入项目设置,并选择 环境变量 选项。然后,你需要创建
DROPLET_USER变量:

图 17.35:添加环境变量
- 然后,你需要使用你的 Droplet IP 创建 DROPLET_IP 变量:

图 17.36:DROPLET_IP
-
现在,你需要将配置文件推送到你的仓库,然后你就可以准备魔法时刻了。现在,由于 CircleCI 已经连接到你的仓库,每次你将更改推送到 master 分支时,它都会触发一个构建。
通常,前两个或三个构建可能会因为语法错误、配置中的缩进错误,或者可能是因为我们有代码检查错误或单元测试错误而失败。如果你有失败,你会看到类似以下的内容:
![文本描述自动生成,中等置信度]()
图 17.37:构建错误
-
如前一个屏幕截图所示,底部的第一个构建失败说构建错误,第二个在 WORKFLOW 下说
build-deploy,如图 17.38所示。这基本上意味着在第一次构建中,我在config.yml文件中有语法错误。 -
在你修复了
config.yml文件中的所有语法错误以及所有与代码检查或单元测试相关的问题后,你应该会看到一个成功构建,如下所示:

图 17.38:成功构建
- 如果你点击构建号,你可以看到 CircleCI 在发布你 Droplet 中的新更改之前执行的所有步骤:

图 17.39:CircleCI 执行的步骤
- 如你所见,步骤的顺序与我们指定的
config.yml文件中的顺序相同;你甚至可以通过点击它来查看每个步骤的输出:

图 17.40:代码检查和测试步骤
- 现在,假设你在你的代码检查验证或某些单元测试中遇到了错误。让我们看看在这种情况下会发生什么,如下所示:

图 17.41:代码检查错误
如你所见,一旦检测到错误,它将以代码1退出。这意味着它将终止部署并将其标记为失败。请注意,在npm run lint之后的步骤都没有被执行。
另一件很酷的事情是,如果你现在去你的 GitHub 仓库检查你的提交,你会看到所有成功构建的提交和所有失败构建的提交:

图 17.42:GitHub 成功构建
这真是太棒了:现在你的项目已经配置好了自动部署,并且它与你的 GitHub 仓库相连。
摘要
恭喜!我们已经完成了部署过程的旅程,你现在拥有了将你的 React 应用程序部署到世界(生产环境)所需的知识和技能。你还学会了如何实现 CircleCI 进行持续集成,简化你的开发流程,并确保你的应用程序保持高性能和可靠性。
通过利用本章中概述的策略和最佳实践,您可以自信地将您的应用程序推向全球受众,安心地知道它已经针对速度、可扩展性和弹性进行了优化。感谢您与我一同踏上这段旅程。我希望您喜欢阅读我的书籍。









浙公网安备 33010602011771号