React17-设计模式最佳实践-全-

React17 设计模式最佳实践(全)

原文:zh.annas-archive.org/md5/49B07B9C9144903CED8C336E472F830F

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

React 是一个开源的、适应性强的 JavaScript 库,用于从称为组件的小型、独立的部分构建复杂的用户界面。本书将帮助您有效地使用 React,使您的应用程序更加灵活、易于维护,并提高其性能,同时通过提高速度而不影响质量来提高工作流程的效率。

您将首先了解 React 的内部工作原理,然后逐渐转向编写可维护和清晰的代码。接下来的章节将向您展示如何构建可在整个应用程序中重复使用的组件,如何组织应用程序以及如何创建真正有效的表单。之后,您将通过探索如何为 React 组件添加样式并优化它们,使应用程序更快、更具响应性。最后,您将学习如何有效地编写测试,并学习如何为 React 及其生态系统做出贡献。

阅读本书结束时,您将能够避免试错和开发头疼的过程,而是拥有有效构建和部署真实 React web 应用程序所需的技能。

本书适合对象

本书适用于希望增进对 React 的理解并将其应用于实际应用程序开发的 Web 开发人员。假定具有中级水平的 React 和 JavaScript 经验。

本书内容包括

第一章开始使用 React,涵盖了一些对于后续内容至关重要且对于日常使用 React 至关重要的基本概念。我们将学习如何编写声明性代码,并清楚地了解我们创建的组件与 React 用于在屏幕上显示实例的元素之间的区别。然后,我们将了解将逻辑和模板放在一起的选择背后的原因,以及为什么这个不受欢迎的决定对 React 来说是一个巨大的胜利。我们将了解在 JavaScript 生态系统中感到疲劳是常见的原因,但我们也将看到如何通过迭代方法来避免这些问题。最后,我们将了解新的create-react-app CLI 是什么,有了它,我们就准备好开始编写一些真正的代码了。

第二章《清理您的代码》教会您大量关于 JSX 的工作原理以及如何在我们的组件中正确使用它。我们从语法的基础开始,建立坚实的知识基础,使我们能够掌握 JSX 及其特性。我们将看看 ESLint 及其插件如何帮助我们更快地发现问题,并强制执行代码库中的一致风格指南。最后,我们将学习函数式编程的基础知识,以理解在编写 React 应用程序时使用的重要概念。现在我们的代码已经整洁,我们准备深入研究 React,并学习如何编写真正可重用的组件。

第三章《React Hooks》教会您如何使用新的 React Hooks 以及如何构建自己的 Hooks。

第四章《探索流行的组合模式》解释了如何组合我们的可重用组件并使它们有效地进行通信。然后,我们将介绍 React 中一些最有趣的组合模式。我们还将看到 React 如何尝试通过混合解决组件之间共享功能的问题。然后,我们将学习如何处理上下文,而无需将我们的组件与其耦合在一起,这要归功于 HOCs。最后,我们将看到如何通过遵循“FunctionAsChild”模式来动态组合组件。

第五章《使用真实项目理解 GraphQL》解释了如何在一个真实项目中使用 GraphQL 查询和变异,您将学习如何使用 GraphQL、JWT 令牌和 Node.js 构建身份验证系统。

第六章《数据管理》介绍了一些常见的模式,以使子组件和父组件使用回调进行通信。然后,我们将学习如何使用一个共同的父组件来在不直接连接的组件之间共享数据。我们将从一个简单的组件开始,它将能够从 GitHub 加载数据,然后我们将使用 HOCs 使其可重用,然后继续学习如何使用react-refetch将数据获取模式应用到我们的组件中,避免重复造轮子。最后,我们将学习如何使用新的 Context API。

第七章,“为浏览器编写代码”,探讨了当我们使用 React 针对浏览器时可以做的不同事情,从表单创建到事件;从动画到 SVG。React 为我们提供了一种声明性的方式来管理我们在创建 Web 应用程序时需要处理的所有方面。React 以一种我们可以执行命令式操作的方式让我们访问实际的 DOM 节点,这在我们需要将 React 与现有的命令式库集成时非常有用。

第八章,“让您的组件看起来漂亮”,研究了为什么常规 CSS 可能不是样式化组件的最佳方法,以及各种替代解决方案。在本章中,我们将学习在 React 中使用内联样式,以及这种方法的缺点,可以通过使用 Radium 库来解决。最后,将介绍一个新的库styled-components,以及它提供的现代方法的概要。

第九章,“为了乐趣和利润进行服务器端渲染”,邀请您按照一定的步骤设置服务器端渲染的应用程序。到本章末,我们将能够构建一个通用应用程序,并了解其利弊。

第十章,“改善您的应用程序的性能”,快速查看了 React 性能的基本组件,以及我们如何使用一些 API 来帮助库找到更新 DOM 的最佳路径,而不会降低用户体验。我们还将学习如何使用一些工具来监视性能并找到瓶颈,这些工具可以导入到我们的代码库中。最后,我们将看到不可变性和PureComponent是构建快速 React 应用程序的完美工具。

第十一章,“测试和调试”,解释了为什么测试我们的应用程序很重要,以及我们可以使用哪些最流行的工具来使用 React 创建测试的概要。我们还将学习建立一个 Jest 环境,使用 Enzyme 测试组件,以及讨论 Enzyme 是什么以及为什么它对于测试 React 应用程序是必不可少的。通过涵盖所有这些主题,到本章末,我们将能够从头开始创建一个测试环境,并为我们应用程序的组件编写测试。

第十二章React Router,讨论了一些步骤,将帮助我们在应用程序中实现 React Router。随着我们完成每个部分,我们将添加动态路由,并了解 React Router 的工作原理。我们将学习如何安装和配置 React Router,以及向路由添加组件、exact 属性和参数。

第十三章应避免的反模式,讨论了在使用 React 时应避免的常见反模式。我们将研究为什么改变状态对性能有害。选择正确的键和帮助调和器也将在本章中讨论,以及为什么在 DOM 元素上扩展 props 是不好的,以及我们如何避免这样做。

第十四章部署到生产环境,涵盖了如何在 Google Cloud 上的 Ubuntu 服务器上使用 Node.js 和 nginx 部署我们的 React 应用程序,以及配置 nginx、PM2 和域。还将介绍如何实施 CircleCI 进行持续集成。

第十五章下一步,演示了我们如何通过提出问题和拉取请求来为 React 库做出贡献,并解释了为什么重要的是回馈社区并分享我们的代码。最后,我们将介绍在推送开源代码时需要牢记的最重要的方面,以及如何发布一个npm包以及如何使用语义版本控制。

为了充分利用本书

要精通 React,您需要对 JavaScript 和 Node.js 有基本的了解。本书主要针对 Web 开发人员,在撰写时,对读者做出了以下假设:

  • 读者知道如何安装最新版本的 Node.js。

  • 读者是一名中级开发人员,能够理解 JavaScript ES6 语法。

  • 读者对 CLI 工具和 Node.js 语法有一定的经验。

下载示例代码文件

您可以从 GitHub 上的github.com/PacktPublishing/React-17-Design-Patterns-and-Best-Practices-Third-Edition下载本书的示例代码文件。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

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

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:static.packt-cdn.com/downloads/9781800560444_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。以下是一个例子:“将下载的WebStorm-10*.dmg磁盘映像文件挂载为系统中的另一个磁盘。”

代码块设置如下:

html, body, #map {
 height: 100%; 
 margin: 0;
 padding: 0
}

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

const name = `Carlos`
const multilineHtml = `<p>
  This is a multiline string
 </p>`
console.log(`Hi, my name is ${name}`)

任何命令行输入或输出都以以下方式编写:

npm install -g @babel/preset-env @babel/preset-react 

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。以下是一个例子:“从管理面板中选择系统信息。”

警告或重要说明会以这种方式出现。提示和技巧会以这种方式出现。

第一部分:你好,React!

本节的目标是向您解释声明式编程的基本概念,React 元素以及如何使用 TypeScript。

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

  • 第一章,用 React 迈出第一步

  • 第二章,整理你的代码

第一章:用 React 迈出第一步

你好,读者们!

本书假定您已经知道 React 是什么以及它可以为您解决什么问题。您可能已经用 React 编写了一个小/中型应用程序,并且希望提高自己的技能并回答所有未解决的问题。您应该知道 React 由 Facebook 的开发人员和 JavaScript 社区内的数百名贡献者维护。React 是创建 UI 的最受欢迎的库之一,由于其与文档对象模型DOM)的智能工作方式而闻名。它带有 JSX,这是一种在 JavaScript 中编写标记的新语法,这需要您改变有关关注点分离的思维。它具有许多很酷的功能,例如服务器端渲染,这使您有能力编写通用应用程序。

在本章中,我们将介绍一些基本概念,这些概念对于有效使用 React 至关重要,但对于初学者来说也足够简单易懂:

  • 命令式编程和声明式编程之间的区别

  • React 组件及其实例,以及 React 如何使用元素来控制 UI 流程

  • React 如何改变了我们构建 Web 应用程序的方式,强制执行了一种不同的关注点分离的新概念,以及其不受欢迎设计选择背后的原因

  • 为什么人们感到 JavaScript 疲劳,以及在接近 React 生态系统时开发人员常犯的最常见错误,您可以做些什么来避免这些错误

  • TypeScript 如何改变了游戏

技术要求

为了遵循本书,您需要具有一些使用终端运行几个 Unix 命令的最小经验。此外,您需要安装 Node.js。您有两个选项。第一个是直接从官方网站nodejs.org下载 Node.js,第二个选项(推荐)是从github.com/nvm-sh/nvm安装Node Version ManagerNVM)。

如果您决定使用 NVM,您可以安装任何您想要的 Node.js 版本,并使用nvm install命令切换版本:

# "node" is an alias for the latest version:
nvm install node

# You can also install a global version of node (will install the latest from that version):
nvm install 10
nvm install 9
nvm install 8
nvm install 7
nvm install 6

# Or you can install a very specific version:
nvm install 6.14.3

安装了不同版本后,您可以使用nvm use命令切换它们:

nvm use node # for latest version
nvm use 10
nvm use 6.14.3

最后,您可以通过运行以下命令指定默认的node版本:

nvm alias default node
nvm alias default 10
nvm alias default 6.14.3

简而言之,以下是完成本章所需的要求列表:

您可以在本书的 GitHub 存储库中找到本章的代码:github.com/PacktPublishing/React-17-Design-Patterns-and-Best-Practices-Third-Edition

区分声明性和命令式编程

当阅读 React 文档或关于 React 的博文时,你肯定会遇到“声明性”这个术语。React 之所以如此强大的原因之一是它强制执行声明性编程范式。

因此,要精通 React,了解声明性编程的含义以及命令式和声明式编程之间的主要区别是至关重要的。最简单的方法是将命令式编程视为描述事物如何工作的方式,将声明式编程视为描述你想要实现的方式。

进入酒吧喝啤酒是命令式世界中的一个现实例子,通常你会给酒吧员以下指示:

  1. 找一个玻璃杯并从架子上拿下来。

  2. 把玻璃杯放在龙头下面。

  3. 拉下把手直到玻璃杯满了。

  4. 递给我玻璃杯。

在声明性世界中,你只需要说“我可以要一杯啤酒吗?”

声明性方法假设酒吧员已经知道如何倒啤酒,这是声明性编程工作方式的一个重要方面。

让我们来看一个 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 元素的工作原理,并且将更多地了解props如何在 React 组件中传递。

React 元素的工作原理

本书假设您熟悉组件及其实例,但如果您想有效地使用 React,还有另一个对象您应该了解——元素。

每当您调用createClass,扩展Component或声明一个无状态函数时,您都在创建一个组件。React 在运行时管理所有组件的实例,并且在给定时间点内可以存在同一组件的多个实例。

如前所述,React 遵循声明式范式,无需告诉它如何与 DOM 交互;您声明要在屏幕上看到什么,React 会为您完成这项工作。

正如你可能已经经历过的那样,大多数其他 UI 库的工作方式正好相反:它们将保持界面更新的责任留给开发人员,开发人员必须手动管理 DOM 元素的创建和销毁。

为了控制 UI 流程,React 使用一种特殊类型的对象,称为元素,它描述了在屏幕上显示什么。这些不可变的对象与组件及其实例相比要简单得多,并且只包含严格需要表示界面的信息。

以下是一个元素的示例:

  { 
    type: Title, 
    props: { 
      color: 'red', 
      children: 'Hello, Title!' 
    } 
  }

元素有type,这是最重要的属性,还有一些属性。还有一个特殊的属性,称为children,它是可选的,代表元素的直接后代。

type很重要,因为它告诉 React 如何处理元素本身。如果type是一个字符串,那么该元素代表一个 DOM 节点,而如果type是一个函数,那么该元素是一个组件。

DOM 元素和组件可以相互嵌套,以表示渲染树:

  { 
    type: Title, 
    props: { 
      color: 'red', 
      children: { 
        type: 'h1', 
        props: { 
          children: 'Hello, H1!' 
        } 
      } 
    } 
  }

当元素的类型是一个函数时,React 调用该函数,传递props以获取底层元素。它继续对结果进行相同的递归操作,直到获得一个 DOM 节点树,React 可以在屏幕上渲染。这个过程称为协调,它被 React DOM 和 React Native 用来创建各自平台的 UI。

React 是一个改变游戏规则的技术,所以一开始,React 的语法可能对你来说很奇怪,但一旦你理解了它的工作原理,你会喜欢它,为此,你需要忘掉你到目前为止所知道的一切。

忘掉一切

第一次使用 React 通常需要开放的思维,因为这是一种设计 Web 和移动应用程序的新方式。React 试图创新我们构建 UI 的方式,打破了大多数众所周知的最佳实践。

在过去的二十年里,我们学到了关注点的分离是重要的,并且我们曾经认为这是将逻辑与模板分离。我们的目标一直是将 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={this.handleClick}> 
    Click me! 
  </button> 
)

我们都同意,开始时似乎有点奇怪,但那只是因为我们不习惯那种语法。一旦我们学会了它,意识到它有多么强大,我们就能理解它的潜力。在逻辑和模板中使用 JavaScript 不仅有助于更好地分离我们的关注点,而且还赋予我们更多的权力和更多的表现力,这正是我们构建复杂 UI 所需要的。

这就是为什么即使在开始时混合 JavaScript 和 HTML 的想法听起来很奇怪,但至关重要的是给 React 5 分钟。开始使用新技术的最佳方法是在一个小的副项目上尝试并看看效果如何。总的来说,正确的方法始终是准备好忘掉一切,如果长期利益值得的话,改变你的思维方式。

还有一个概念是相当有争议的,也很难接受,那就是 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,我们将在第八章《让您的组件看起来美丽》中对此进行广泛讨论。

在接下来的部分中,我们将看到如何避免 JavaScript 疲劳,这是由运行 React 应用程序所需的大量配置(主要是 webpack)引起的。

理解 JavaScript 疲劳

有一种普遍的观点认为,React 由大量的技术和工具组成,如果你想使用它,就不得不处理包管理器、转译器、模块捆绑器和无限的不同库列表。这个想法是如此普遍并且在人们中间共享,以至于它已经被明确定义,并被命名为JavaScript 疲劳

理解这背后的原因并不难。React 生态系统中的所有存储库和库都是使用全新的技术、最新版本的 JavaScript 和最先进的技术和范例制作的。

此外,在 GitHub 上有大量的 React 样板,每个样板都有数十个依赖项,以解决任何问题。很容易认为启动使用 React 需要所有这些工具,但事实远非如此。尽管有这种常见的思维方式,React 是一个非常小的库,可以像以前使用 jQuery 或 Backbone 一样在任何页面(甚至在 JSFiddle 中)使用,只需在页面中包含脚本即可。

有两个脚本是因为 React 被分成了两个包:

  • react:实现了库的核心功能

  • react-dom:包含所有与浏览器相关的功能

这背后的原因是核心包用于支持不同的目标,比如浏览器中的 React DOM 和移动设备上的 React Native。在单个 HTML 页面中运行 React 应用程序不需要任何包管理器或复杂的操作。您只需下载分发包并自行托管(或使用unpkg.com/),就可以在几分钟内开始使用 React 及其功能。

以下是在 HTML 中包含的 URL,以开始使用 React:

如果我们只添加核心 React 库,我们无法使用 JSX,因为它不是浏览器支持的标准语言;但整个重点是从最少的功能集开始,并在需要时添加更多功能。对于简单的 UI,我们可以只使用createElement(在 React 17 中为_jsx),只有当我们开始构建更复杂的东西时,才能包含转译器以启用 JSX 并将其转换为 JavaScript。一旦应用程序稍微增长,我们可能需要一个路由器来处理不同的页面和视图,我们也可以包含它。

在某些时候,我们可能想要从一些 API 端点加载数据,如果应用程序不断增长,我们将达到需要一些外部依赖来抽象复杂操作的地步。只有在那个时刻,我们才应该引入一个包管理器。然后,到了分离我们的应用程序为单独模块并以正确方式组织我们的文件的时候。在那时,我们应该开始考虑使用模块捆绑器。

遵循这种简单的方法,就不会感到疲劳。从具有 100 个依赖项和数十个我们一无所知的npm包的样板开始是迷失的最佳方式。重要的是要注意,每个与编程相关的工作(特别是前端工程)都需要不断学习。网络以惊人的速度发展并根据用户和开发人员的需求进行变化,这是我们的环境自始至终的工作方式,也是使其非常令人兴奋的原因。

随着我们在网络上工作的经验增加,我们学会了不能掌握一切,我们应该找到保持自己更新的正确方法以避免疲劳。我们能够跟上所有新趋势,而不是为了新库而跳进去,除非我们有时间做一个副业项目。

令人惊讶的是,在 JavaScript 世界中,一旦规范被宣布或起草,社区中就会有人将其实现为转译器插件或填充物,让其他人可以在浏览器供应商同意并开始支持之前使用它。

这是使 JavaScript 和浏览器与任何其他语言或平台完全不同的东西。它的缺点是事物变化很快,但只是要找到押注新技术与保持安全之间的正确平衡。

无论如何,Facebook 的开发人员非常关心开发者体验DX),他们仔细倾听社区的意见。因此,即使使用 React 并不需要学习数百种不同的工具,他们意识到人们感到疲劳,于是发布了一个 CLI 工具,使创建和运行真正的 React 应用程序变得非常容易。

唯一的要求是使用node.js/npm环境,并全局安装 CLI 工具,如下所示:

npm install -g create-react-app

当可执行文件安装后,我们可以使用它来创建我们的应用程序,传递一个文件夹名称:

create-react-app hello-world --template typescript

最后,我们进入我们应用程序的文件夹cd hello-world,然后运行以下命令:

npm start

神奇的是,我们的应用程序只依赖一个依赖项,但具有构建完整 React 应用程序所需的所有功能。以下截图显示了使用create-react-app创建的应用程序的默认页面:

这基本上就是您的第一个 React 应用程序。

介绍 TypeScript

TypeScript是 JavaScript 的一个有类型的超集,它被编译成 JavaScript,这意味着TypeScript是带有一些额外功能的JavaScript。TypeScript 是由微软的 Anders Hejlsberg(C#的设计者)设计的,并且是开源的。

让我们看看 TypeScript 的特性以及如何将 JavaScript 转换为 TypeScript。

TypeScript 特性

本节将尝试总结您应该利用的最重要的特性:

  • TypeScript 就是 JavaScript:您编写的任何 JavaScript 代码都将与 TypeScript 一起工作,这意味着如果您已经知道如何基本使用 JavaScript,您基本上已经具备了使用 TypeScript 所需的一切;您只需要学习如何向代码添加类型。最终,所有 TypeScript 代码都会转换为 JavaScript。

  • JavaScript 就是 TypeScript:这意味着您可以将任何有效的.js文件重命名为.ts扩展名,它将可以工作。

  • 错误检查: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类型指定为函数返回值,但现在呢?

如果您尝试使用与字符串不同的某个值运行函数,您将收到 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

因此,如果您尝试将数字传递给函数,您将收到以下错误:

这就是为什么 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 }}

如果您忘记添加其中一个节点或在其中一个节点中放入无效值,您将收到以下错误:

如果您需要可选节点,您可以在节点名称旁边始终放置?,如以下代码块所示:

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关键字扩展接口。

实现

类可以以完全相同的方式实现接口或类型别名。但它不能实现(或扩展)命名为联合类型的类型别名,例如:

// Implementing an interface
interface IWork {
  company: string
  position: string
}

class Person implements IWork {
  name: 'Carlos'
  age: 33
}

// Implementing a type
type TWork = {
  company: string
  position: string
}

class Person2 implements TWork {
  name: 'Cristina'
  age: 32
}

// 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'
}

如果您编写该代码,您将在编辑器中收到以下错误:

如您所见,您无法实现联合类型。

声明合并

与类型不同,接口可以被多次定义,并且将被视为单个接口(所有声明将被合并),如下面的代码块所示:

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: 33,
  website: 'http://www.js.education',
  active: true
}

当您需要通过重新定义相同的接口在不同场景下扩展接口时,这非常有用。

总结

在本章中,我们学习了一些对于接下来的书非常重要的基本概念,这些概念对于每天使用 React 非常关键。我们现在知道如何编写声明式代码,并且清楚地理解了我们创建的组件与 React 用来在屏幕上显示它们的实例之间的区别。

我们了解了将逻辑和模板放在一起的选择背后的原因,以及为什么这个不受欢迎的决定对 React 来说是一个巨大的胜利。我们通过了解在 JavaScript 生态系统中感到疲劳是很常见的原因,但我们也看到了如何通过迭代方法来避免这些问题。

我们学会了如何使用 TypeScript 来创建一些基本类型和接口。最后,我们看到了新的 create-react-app CLI 是什么,现在我们准备开始编写一些真正的代码。

在下一章中,您将学习如何使用 JSX/TSX 代码,并应用非常有用的配置来改进您的代码风格。

第二章:清理您的代码

本章假设您已经有了 JSX 的经验,并且希望提高使用它的技能。要想毫无问题地使用 JSX/TSX,理解其内部工作原理以及构建 UI 的有用工具的原因是至关重要的。

我们的目标是编写干净的 JSX/TSX 代码,维护它,并了解它的来源,它是如何被转换为 JavaScript 的,以及它提供了哪些特性。

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

  • 什么是 JSX,为什么我们应该使用它?

  • Babel 是什么,我们如何使用它来编写现代 JavaScript 代码?

  • JSX 的主要特性以及 HTML 和 JSX 之间的区别

  • 以优雅和可维护的方式编写 JSX 的最佳实践

  • linting 以及特别是 ESLint 如何使我们的 JavaScript 代码在应用程序和团队之间保持一致。

  • 函数式编程的基础以及为什么遵循函数式范式会让我们编写更好的 React 组件

技术要求

要完成本章,您将需要以下内容:

  • Node.js 12+

  • Visual Studio Code

使用 JSX

在上一章中,我们看到了 React 如何改变关注点分离的概念,将边界移到组件内部。我们还学习了 React 如何使用组件返回的元素来在屏幕上显示 UI。

现在让我们看看如何在组件内部声明我们的元素。

React 提供了两种定义元素的方式。第一种是使用 JavaScript 函数,第二种是使用 JSX,一种可选的类似 XML 的语法。以下是官方 React.js 网站示例部分的截图(reactjs.org/#examples):

首先,JSX 是人们失败接触 React 的主要原因之一,因为第一次看到主页上的示例并且看到 JavaScript 与 HTML 混合在一起可能对我们大多数人来说都会感到奇怪。

一旦我们习惯了它,我们就会意识到它非常方便,因为它类似于 HTML,并且对于已经在 Web 上创建过 UI 的人来说非常熟悉。开放和闭合标签使得表示嵌套的元素树变得更容易,使用纯 JavaScript 将会变得难以阅读和难以维护。

让我们在以下子章节中更详细地了解 JSX。

Babel 7

要在我们的代码中使用 JSX(和一些 ES6 的特性),我们必须安装新的 Babel 7。Babel 是一个流行的 JavaScript 编译器,在 React 社区广泛使用。

首先,重要的是清楚地了解它可以为我们解决的问题,以及为什么我们需要在我们的流程中添加一步。原因是我们想要使用语言的特性,这些特性尚未添加到浏览器,我们的目标环境。这些高级特性使我们的代码对开发人员更清晰,但浏览器无法理解和执行它。

解决方案是在 JSX 和 ES6 中编写我们的脚本,当我们准备好发布时,我们将源代码编译成 ES5,这是今天主要浏览器中实现的标准规范。

Babel 可以将 ES6 代码编译成 ES5 JavaScript,还可以将 JSX 编译成 JavaScript 函数。这个过程被称为转译,因为它将源代码编译成新的源代码,而不是可执行文件。

在较旧的 Babel 6.x 版本中,您安装了babel-cli包,并获得了babel-nodebabel-core,现在一切都分开了:@babel/core@babel/cli@babel/node等等。

要安装 Babel,我们需要安装@babel/core@babel/node如下:

npm install -g @babel/core @babel/node

如果您不想全局安装它(开发人员通常倾向于避免这样做),您可以将 Babel 安装到项目中并通过npm脚本运行它,但在本章中,全局实例就可以了。

安装完成后,我们可以运行以下命令来编译任何 JavaScript 文件:

babel source.js -o output.js

Babel 之所以如此强大的原因之一是因为它是高度可配置的。Babel 只是一个将源文件转译为输出文件的工具,但要应用一些转换,我们需要对其进行配置。

幸运的是,有一些非常有用的预设配置,我们可以轻松安装和使用:

npm install -g @babel/preset-env @babel/preset-react

安装完成后,我们在root文件夹中创建一个名为.babelrc的配置文件,并将以下行放入其中,告诉 Babel 使用这些预设:

{
  "presets": [
    "@babel/preset-env",
    "@babel/preset-react"
  ]
}

从这一点开始,我们可以在我们的源文件中编写 ES6 和 JSX,并在浏览器中执行输出文件。

创建我们的第一个元素

现在我们的环境已经设置好支持 JSX,我们可以深入最基本的例子:生成一个div元素。这是您使用_jsx函数创建div元素的方式:

_jsx('div', {})

这是用于创建div元素的 JSX:

<div />

它看起来类似于常规 HTML。

最大的区别在于我们在.js文件中编写标记,但重要的是要注意 JSX 只是语法糖,在在浏览器中执行之前会被转译成 JavaScript。

实际上,当我们运行 Babel 时,我们的<div />元素被翻译成_jsx('div', {}),这是我们在编写模板时应该牢记的事情。

在 React 17 中,React.createElement('div')已被弃用,现在内部使用react/jsx-runtime来渲染 JSX,这意味着我们将得到类似_jsx('div', {})的东西。基本上,这意味着您不再需要导入 React 对象来编写 JSX 代码。

DOM 元素和 React 组件

使用 JSX,我们可以创建 HTML 元素和 React 组件;唯一的区别是它们是否以大写字母开头。

例如,要渲染一个 HTML 按钮,我们使用<button />,而要渲染Button组件,我们使用<Button />。第一个按钮被转译成如下:

_jsx('button', {})

第二个被转译成如下:

_jsx(Button, {})

这里的区别在于,在第一个调用中,我们将 DOM 元素的类型作为字符串传递,而在第二个调用中,我们传递的是组件本身,这意味着它应该存在于作用域中才能工作。

正如您可能已经注意到的,JSX 支持自闭合标签,这对保持代码简洁非常有用,并且不需要我们重复不必要的标签。

属性

当您的 DOM 元素或 React 组件具有 props 时,JSX 非常方便。使用 XML 很容易在元素上设置属性:

<img src="https://www.js.education/images/logo.png" alt="JS Education" />

在 JavaScript 中的等价物如下:

_jsx("img", { 
  src: "https://www.js.education/images/logo.png", 
  alt: "JS Education" 
})

这样的代码可读性差得多,即使只有几个属性,没有一点推理就很难阅读。

子元素

JSX 允许您定义子元素以描述元素树并组合复杂的 UI。一个基本的例子是带有文本的链接,如下所示:

<a href="https://js.education">Click me!</a>

这将被转译成如下:

_jsx( 
  "a", 
  { href: "https://www.js.education" }, 
  "Click me!" 
)

我们的链接可以被包含在div元素中以满足一些布局要求,实现这一目的的 JSX 片段如下:

<div> 
  <a href="https://www.js.education">Click me!</a> 
</div>

JavaScript 等价物如下:

_jsx( 
  "div", 
  null, 
  _jsx( 
    "a", 
    { href: "https://www.js.education" }, 
    "Click me!" 
  ) 
)

现在应该清楚了 JSX 的类似 XML的语法如何使一切更易读和易维护,但重要的是要知道我们的 JSX 的 JavaScript 并行对元素的创建有控制。好处是我们不仅限于将元素作为元素的子元素,而是可以使用 JavaScript 表达式,比如函数或变量。

为了做到这一点,我们必须用花括号括起表达式:

<div> 
  Hello, {variable}. 
  I'm a {() => console.log('Function')}. 
</div> 

同样适用于非字符串属性,如下所示:

<a href={this.createLink()}>Click me!</a>

如你所见,任何变量或函数都应该用花括号括起来。

与 HTML 的不同

到目前为止,我们已经看到了 JSX 和 HTML 之间的相似之处。现在让我们看看它们之间的小差异以及存在的原因。

属性

我们必须始终记住 JSX 不是一种标准语言,它被转译成 JavaScript。因此,某些属性无法使用。

例如,我们必须使用className代替class,并且必须使用htmlFor代替for,如下所示:

<label className="awesome-label" htmlFor="name" />

这是因为classfor在 JavaScript 中是保留字。

样式

一个相当重要的区别是style属性的工作方式。我们将在第八章,使您的组件看起来漂亮中更详细地讨论如何使用它,但现在我们将专注于它的工作方式。

style属性不接受 CSS 字符串,而是期望一个 JavaScript 对象,其中样式名称是驼峰式的:

<div style={{ backgroundColor: 'red' }} />

正如你所看到的,你可以将一个对象传递给style属性,这意味着你甚至可以将你的样式放在一个单独的变量中。

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 /> 
React.createElement("button", { disabled: true })

以下是另一个布尔属性的例子:

<button disabled={false} /> 
React.createElement("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 multilineHtml = `<p>
 This is a multiline string
 </p>`
console.log(`Hi, my name is ${name}`)

常见模式

现在我们知道了 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 总是被函数替换,而在新行上编写的函数可能会因为自动分号插入而给您带来意外的结果。例如,假设您从 render 方法中返回 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,则会调用LoginButtoncreateElement函数,并将元素返回以组成最终的树。

如果条件有一个备选项(经典的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),在这里,三元条件用于在组件获取数据时显示一个“加载中”标签,或者根据isFetching变量的值在按钮内显示“加载更多”:

<button [...]> 
  {isFetching ? 'Loading...' : 'Load More'} 
</button>

现在让我们看看当事情变得更加复杂时的最佳解决方案,例如,当我们需要检查多个变量以确定是否渲染一个组件时:

<div>
  {dataIsReady && (isAdmin || userHasPermissions) && 
    <SecretData />
  }
</div>

在这种情况下,使用内联条件是一个好的解决方案,但可读性受到了严重影响。相反,我们可以在组件内创建一个辅助函数,并在 JSX 中使用它来验证条件:

const canShowSecretData = () => { 
  const { dataIsReady, isAdmin, userHasPermissions } = props
  return dataIsReady && (isAdmin || userHasPermissions)
} 

return (
  <div> 
    {this.canShowSecretData() && <SecretData />} 
  </div> )

正如你所看到的,这种改变使得代码更易读,条件更加明确。如果你在 6 个月后看这段代码,仅仅通过函数名就能清楚地理解。

计算属性也是一样。假设你有两个单一属性用于货币和价值。你可以创建一个函数来创建价格字符串,而不是在 render 中创建它:

const getPrice = () => { 
  return `${props.currency}${props.value}`
}

return <div>{getPrice()}</div>

这样做更好,因为它是隔离的,如果包含逻辑,你可以很容易地测试它。

回到条件语句,其他解决方案需要使用外部依赖。一个很好的做法是尽可能避免外部依赖,以使我们的捆绑包更小,但在这种特殊情况下可能是值得的,因为提高我们模板的可读性是一个很大的胜利。

第一个解决方案是 render-if,我们可以通过以下方式安装它:

npm install --save render-if

然后我们可以在我们的项目中轻松使用它,如下所示:

const { dataIsReady, isAdmin, userHasPermissions } = props

const canShowSecretData = renderIf( 
  dataIsReady && (isAdmin || userHasPermissions) 
);

return (
  <div> 
    {canShowSecretData(<SecretData />)} 
  </div> 
);

在这里,我们将我们的条件包装在 renderIf 函数中。

返回的实用函数可以作为一个接收 JSX 标记的函数来使用,当条件为 true 时显示。

一个目标是永远不要在我们的组件中添加太多逻辑。其中一些组件将需要一点逻辑,但我们应该尽量保持它们尽可能简单,这样我们就可以很容易地发现和修复错误。

我们至少应该尽量保持 renderIf 方法尽可能干净,为了做到这一点,我们可以使用另一个实用程序库,称为 react-only-if,它让我们编写我们的组件,就好像条件总是为 true 一样,通过使用高阶组件HOC)设置条件函数。

我们将在 第四章 探索流行的组合模式 中广泛讨论 HOCs,但现在,你只需要知道它们是接收一个组件并通过添加一些属性或修改其行为来返回一个增强的组件的函数。

要使用该库,我们需要按照以下方式安装它:

npm install --save react-only-if

安装完成后,我们可以在我们的应用程序中以以下方式使用它:

import onlyIf from 'react-only-if'

const SecretDataOnlyIf = onlyIf(
  ({ dataIsReady, isAdmin, userHasPermissions }) => dataIsReady && 
  (isAdmin || userHasPermissions)
)(SecretData)

const MyComponent = () => (
  <div>
    <SecretDataOnlyIf 
      dataIsReady={...}
      isAdmin={...}
      userHasPermissions={...}
    />
 </div>
)

export default MyComponent

正如你在这里看到的,组件本身没有任何逻辑。

我们将条件作为 onlyIf 函数的第一个参数传递,当条件匹配时,组件被渲染。

用于验证条件的函数接收组件的 props、state 和 context。

这样,我们就避免了用条件语句污染我们的组件,这样更容易理解和推理。

循环

UI 开发中一个非常常见的操作是显示项目列表。在显示列表时,使用 JavaScript 作为模板语言是一个非常好的主意。

如果我们在 JSX 模板中编写一个返回数组的函数,数组的每个元素都会被编译成一个元素。

正如我们之前所看到的,我们可以在花括号中使用任何 JavaScript 表达式,给定一个对象数组,生成一个元素数组的最常见方法是使用map

让我们深入一个真实的例子。假设你有一个用户列表,每个用户都有一个附加的名字属性。

要创建一个无序列表来显示用户,你可以这样做:

<ul> 
  {users.map(user => <li>{user.name}</li>)} 
</ul>

这段代码非常简单,同时也非常强大,HTML 和 JavaScript 的力量在这里汇聚。

控制语句

条件和循环在 UI 模板中是非常常见的操作,你可能觉得使用 JavaScript 的三元运算符或map函数来执行它们是错误的。JSX 被构建成只抽象了元素的创建,将逻辑部分留给了真正的 JavaScript,这很好,除了有时候,代码变得不够清晰。

总的来说,我们的目标是从组件中移除所有的逻辑,特别是从渲染方法中移除,但有时我们必须根据应用程序的状态显示和隐藏元素,而且我们经常必须循环遍历集合和数组。

如果你觉得使用 JSX 进行这种操作会使你的代码更易读,那么有一个可用的 Babel 插件可以做到:jsx-control-statements

它遵循与 JSX 相同的哲学,不会向语言添加任何真正的功能;它只是一种被编译成 JavaScript 的语法糖。

让我们看看它是如何工作的。

首先,我们必须安装它:

npm install --save jsx-control-statements

安装完成后,我们必须将它添加到我们的.babelrc文件中的 Babel 插件列表中:

"plugins": ["jsx-control-statements"]

从现在开始,我们可以使用插件提供的语法,Babel 将把它与常见的 JSX 语法一起转译。

使用该插件编写的条件语句如下所示:

<If condition={this.canShowSecretData}> 
  <SecretData /> 
</If>

这被转译成了一个三元表达式,如下所示:

{canShowSecretData ? <SecretData /> : null}

If组件很棒,但是如果由于某种原因,你在渲染方法中有嵌套的条件,它很容易变得混乱和难以理解。这就是Choose组件派上用场的地方:

<Choose> 
  <When condition={...}> 
    <span>if</span> 
  </When> 
 <When condition={...}> 
    <span>else if</span> 
  </When> 
 <Otherwise> 
 <span>else</span> 
 </Otherwise> 
</Choose>

请注意,前面的代码被转译成了多个三元运算符。

最后,还有一个组件(永远记住我们不是在谈论真正的组件,而只是语法糖)来管理循环,也非常方便:

<ul> 
 <For each="user" of={this.props.users}> 
    <li>{user.name}</li> 
  </For> 
</ul>

前面的代码被转译成了一个map函数 - 没有什么魔术。

如果你习惯使用linters,你可能会想知道为什么 linter 没有对那段代码进行投诉。在转译之前,user变量并不存在,也没有被包裹在一个函数中。为了避免这些 linting 错误,还有另一个要安装的插件:eslint-plugin-jsx-control-statements

如果您不理解上一句话,不用担心;我们将在接下来的部分讨论 linting。

子渲染

值得强调的是,我们始终希望保持我们的组件非常小,我们的渲染方法非常干净和简单。

然而,这并不是一个容易的目标,特别是当您迭代地创建一个应用程序时,在第一次迭代中,您并不确定如何将组件拆分成更小的组件。那么,当render方法变得太大而无法维护时,我们应该做些什么呢?一个解决方案是将其拆分成更小的函数,以便让我们将所有逻辑保留在同一个组件中。

让我们看一个例子:

const renderUserMenu = () => { 
  // JSX for user menu 
} 

const renderAdminMenu = () => { 
  // JSX for admin menu 
} 

return ( 
  <div> 
 <h1>Welcome back!</h1> 
    {userExists && renderUserMenu()} 
    {userIsAdmin && renderAdminMenu()} 
  </div> 
)

这并不总是被认为是最佳实践,因为将组件拆分成更小的组件似乎更明显。然而,有时候这有助于保持渲染方法的清晰。例如,在 Redux 的真实示例中,使用子渲染方法来渲染load more按钮。

既然我们是 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 扩展:

然后,如果您想配置选项以在保存文件时进行格式化,您需要转到设置,搜索Format on Save,并检查该选项:

这将影响您所有的项目,因为这是一个全局设置。如果您只想在特定项目中应用此选项,您需要在项目内创建一个.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 中获得这种警告。几年前,Douglas Crockford 在 JavaScript 中使用 JSLint(最初于 2002 年发布)使 linting 变得流行;然后我们有了 JSHint,最后,现在在 React 世界中的事实标准是 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文件进行配置。要添加一些规则,让我们创建一个为 TypeScript 配置的.eslintrc文件并添加一个基本规则:

{
  "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 规则有三个级别,确定问题的严重程度:

  • 关闭(或 0):规则被禁用。

  • 警告(或 1):规则是一个警告。

  • 错误(或 2):规则会抛出错误。

我们使用值为 2 是因为我们希望 ESLint 在我们的代码不遵循规则时抛出错误。第二个参数告诉 ESLint 我们不希望使用分号(相反的是always)。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 钩子

为了避免在我们的存储库中有未经过 lint 处理的代码,我们可以在我们的过程的某个时候使用 Git 钩子添加 ESLint。例如,我们可以使用husky在名为pre-commit的 Git 钩子中运行我们的 linter,还可以在名为pre-push的钩子上运行我们的单元测试。

要安装husky,您需要运行以下命令:

npm install --save-dev husky

然后,在我们的package.json文件中,我们可以添加这个节点来配置我们想要在 Git 钩子中运行的任务:

{
  "scripts": {
    "lint": "eslint --ext .tsx,.ts src",
    "lint:fix": "eslint --ext .tsx,.ts --fix src",
    "test": "jest src"
  },
  "husky": {
    "hooks": {
      "pre-commit": "npm lint",
      "pre-push": "npm test"
    }
  }
}

ESlint 命令有一个特殊的选项(标志)叫做--fix - 使用这个选项,ESlint 将尝试自动修复所有我们的 linter 错误(不是所有)。请注意这个选项,因为有时它可能会影响我们的代码风格。另一个有用的标志是--ext,用于指定我们想要验证的文件的扩展名,在这种情况下只有.tsx.ts文件。

在下一节中,您将了解函数式编程FP)的工作原理以及一级对象、纯度、不可变性、柯里化和组合等主题。

函数式编程

除了在编写 JSX 时遵循最佳实践并使用 linter 来强制一致性并更早地发现错误之外,我们还可以做一件事来清理我们的代码:遵循 FP 风格。

第一章中所讨论的,React 采用了一种声明式的编程方法,使我们的代码更易读。FP 是一种声明式的范式,其中避免副作用,并且数据被视为不可变,以使代码更易于维护和理解。

不要将以下子部分视为 FP 的详尽指南;这只是一个介绍,让您了解 React 中常用的一些概念。

一级函数

JavaScript 具有一级函数,因为它们被视为任何其他变量,这意味着您可以将函数作为参数传递给其他函数,或者它可以被另一个函数返回并分配为变量的值。

这使我们能够介绍高阶函数HoFs)的概念。 HoFs 是接受函数作为参数的函数,并且可能还有一些其他参数,并返回一个函数。返回的函数通常具有一些特殊的行为。

让我们看一个例子:

const add = (x, y) => x + y

const log = fn => (...args) => { 
 return fn(...args)
}

const logAdd = log(add)

在这里,一个函数正在添加两个数字,增强一个记录所有参数然后执行原始函数的函数。

理解这个概念非常重要,因为在 React 世界中,一个常见的模式是使用 HOCs 将我们的组件视为函数,并用常见的行为增强它们。我们将在第四章探索流行的组合模式中看到 HOCs 和其他模式。

纯度

FP 的一个重要方面是编写纯函数。在 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仍然保持其原始值。

柯里化

FP 中的一个常见技术是柯里化。柯里化是将接受多个参数的函数转换为一次接受一个参数并返回另一个函数的过程。让我们看一个例子来澄清这个概念。

让我们从之前看到的 add 函数开始,并将其转换为柯里化函数。

假设我们有以下代码:

const add = (x, y) => x + y

我们可以改为以下方式定义函数:

const add = x => y => x + y

我们以以下方式使用它:

const add1 = add(1)
add1(2); // 3
add1(3); // 4

这是编写函数的一种非常方便的方式,因为在应用第一个参数后,第一个值被存储,我们可以多次重复使用第二个函数。

组合

最后,FP 中一个重要的概念可以应用到 React 中,那就是组合。函数(和组件)可以组合在一起,产生具有更高级功能和属性的新函数。

考虑以下函数:

const add = (x, y) => x + y
const square = x => x * x

这些函数可以组合在一起创建一个新的函数,该函数将两个数字相加,然后将结果加倍:

const addAndSquare = (x, y) => square(add(x, y))

遵循这个范式,我们最终得到了小型、简单、可测试的纯函数,可以组合在一起。

FP 和 UI

最后一步是学习如何使用 FP 来构建 UI,这正是我们使用 React 的目的。

我们可以将 UI 视为一个函数,将应用程序的状态应用如下:

UI = f(state)

我们期望这个函数是幂等的,这样它在应用程序的相同状态下返回相同的 UI。

使用 React,我们使用组件来创建我们的 UI,我们可以将其视为函数,正如我们将在接下来的章节中看到的。

组件可以组合在一起形成最终的 UI,这是 FP 的一个特性。

在使用 React 构建 UI 的方式和 FP 的原则中有很多相似之处,我们越了解,我们的代码就会越好。

总结

在本章中,我们学到了关于 JSX 的工作原理以及如何在组件中正确使用它的很多知识。我们从语法的基础开始,创建了一个坚实的知识基础,使我们能够掌握 JSX 及其特性。

在第二部分,我们看了如何配置 Prettier 以及 ESLint 及其插件如何帮助我们更快地发现问题,并强制执行一致的代码风格指南。

最后,我们通过 FP 的基础知识来理解在编写 React 应用程序时使用的重要概念。

现在我们的代码已经整洁,我们准备在下一章深入学习 React,并学习如何编写真正可重用的组件。

第二部分:React 工作原理

本节将解释如何使用新的 React Hooks,它们的规则,以及如何创建自己的 Hooks。还将涵盖如何将当前的 React 类组件应用迁移到新的 React Hooks。

我们将在本节中涵盖以下章节:

  • 第三章,React Hooks

  • 第四章,探索流行的组合模式

  • 第五章,通过真实项目了解 GraphQL

  • 第六章,数据管理

  • 第七章,为浏览器编写代码

第三章:React Hooks

React 发展非常迅速,自 React 16.8 以来,引入了新的 React Hooks,这是 React 开发的一个改变者,因为它们将提高编码速度并改善应用程序的性能。React 使我们能够仅使用功能组件编写 React 应用程序,这意味着不再需要使用类组件。

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

  • 新的 React Hooks 以及如何使用它们

  • Hooks 的规则

  • 如何将类组件迁移到 React Hooks

  • 使用 Hooks 和效果理解组件生命周期

  • 如何使用 Hooks 获取数据

  • 如何使用memouseMemouseCallback来记忆组件、值和函数

  • 如何实现useReducer

技术要求

要完成本章,您将需要以下内容:

  • Node.js 12+

  • Visual Studio Code

您可以在书的 GitHub 存储库中找到本章的代码github.com/PacktPublishing/React-17-Design-Patterns-and-Best-Practices-Third-Edition/tree/main/Chapter03

介绍 React Hooks

React Hooks 是 React 16.8 中的新添加。它们让您在不编写 React 类组件的情况下使用状态和其他 React 功能。React Hooks 也是向后兼容的,这意味着它不包含任何破坏性更改,也不会取代您对 React 概念的了解。在本章的过程中,我们将看到有关经验丰富的 React 用户的 Hooks 概述,并且我们还将学习一些最常见的 React Hooks,如useStateuseEffectuseMemouseCallbackmemo

没有破坏性更改

许多人认为,使用新的 React Hooks,类组件在 React 中已经过时,但这种说法是不正确的。没有计划从 React 中删除类。Hooks 不会取代您对 React 概念的了解。相反,Hooks 为 React 概念提供了更直接的 API,如 props、state、context、refs 和生命周期,这些您已经了解。

使用 State Hook

您可能知道如何在类中使用this.setState来使用组件状态。现在您可以使用新的 React useState Hook 来使用组件状态。

首先,您需要从 React 中提取useState Hook:

import { useState } from 'react'

自 React 17 以来,不再需要 React 对象来渲染 JSX 代码。

然后,您需要通过定义状态和特定状态的 setter 来声明要使用的状态:

const Counter = () => {
  const [counter, setCounter] = useState<number>(0)
}

正如您所看到的,我们使用setCounter setter 声明了计数器状态,并且我们指定只接受数字,最后,我们将初始值设置为零。

为了测试我们的状态,我们需要创建一个将由onClick事件触发的方法:

const Counter = () => {
  const [counter, setCounter] = useState<number>(0)

  const handleCounter = (operation) => {
    if (operation === 'add') {
      return setCounter(counter + 1)
    }

    return setCounter(counter - 1)
  }
}

最后,我们可以渲染counter状态和一些按钮来增加或减少counter状态:

return (
  <p>
    Counter: {counter} <br />
    <button onClick={() => handleCounter('add')}>+ Add</button>
    <button onClick={() => handleCounter('subtract')}>- Subtract</button>
  </p>
)

如果您点击+添加按钮一次,您应该在计数器中看到 1:

如果您连续点击减号按钮两次,那么您应该在计数器中看到-1:

正如您所看到的,useState Hook 在 React 中是一个改变游戏规则的东西,并且使得在功能组件中处理状态变得非常容易。

Hooks 的规则

React Hooks 基本上是 JavaScript 函数,但是您需要遵循两条规则才能使用它们。React 提供了一个 lint 插件来强制执行这些规则,您可以通过运行以下命令来安装它:

npm install --save-dev eslint-plugin-react-hooks 

让我们看看这两条规则。

规则 1:只在顶层调用 Hooks

来自官方 React 文档(reactjs.org/docs/hooks-rules.html):

不要在循环、条件或嵌套函数中调用 Hooks。相反,始终在 React 函数的顶层使用 Hooks。遵循此规则,您确保每次组件渲染时以相同的顺序调用 Hooks。这就是允许 React 在多次 useState 和 useEffect 调用之间正确保存 Hooks 状态的原因。”

规则 2:只从 React 函数调用 Hooks

来自官方 React 文档(reactjs.org/docs/hooks-rules.html):

“不要从常规 JavaScript 函数调用 Hooks。相反,您可以:

  • 从 React 函数组件调用 Hooks。

  • 从自定义 Hooks 调用 Hooks(我们将在下一页学习它们)。

遵循此规则,您确保组件中的所有有状态逻辑在其源代码中清晰可见。”

在下一节中,我们将学习如何将类组件迁移到使用新的 React Hooks。

将类组件迁移到 React Hooks

让我们转换一个当前正在使用类组件和一些生命周期方法的代码。在这个例子中,我们正在从 GitHub 仓库中获取问题并列出它们。

对于这个例子,您需要安装axios来执行获取操作:

npm install axios

这是类组件版本:

// Dependencies
import { Component } from 'react'
import axios from 'axios'

// Types
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

如果您渲染此组件,应该会看到类似于这样的东西:

现在,让我们将我们的代码转换为使用 React Hooks 的函数组件。我们需要做的第一件事是导入一些 React 函数和类型:

// Dependencies
import { FC, useState, useEffect } from 'react'
import axios from 'axios'

现在我们可以删除之前创建的PropsState类型,只留下Issue类型:

// Types
type Issue = {
  number: number
  title: string
  state: string
}

之后,您可以更改类定义以使用函数组件:

const Issues: FC = () => {...}

FC类型用于在 React 中定义函数组件。如果您需要将一些 props 传递给组件,可以这样传递:

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相同的效果

// 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 行)。

理解 React 效果

在本节中,我们将学习在类组件上使用的组件生命周期方法和新的 React 效果之间的区别。即使您在其他地方读到它们是相同的,只是语法不同,这是不正确的。

理解 useEffect

当您使用useEffect时,您需要思考效果。如果您想使用useEffect执行componentDidMount的等效方法,可以这样做:

useEffect(() => {
  // Here you perform your side effect
}, [])

第一个参数是您想要执行的效果的回调函数,第二个参数是依赖项数组。如果在依赖项中传递一个空数组([]),状态和 props 将具有它们的原始初始值。

然而,重要的是要提到,即使这是componentDidMount的最接近等价物,它并不具有相同的行为。与componentDidMountcomponentDidUpdate不同,我们传递给useEffect的函数在布局和绘制之后,在延迟事件期间触发。这通常适用于许多常见的副作用,比如设置订阅和事件处理程序,因为大多数类型的工作不应该阻止浏览器更新屏幕。

然而,并非所有的效果都可以延迟。例如,如果你需要改变文档对象模型DOM),你会看到一个闪烁。这就是为什么你必须在下一次绘制之前同步触发事件的原因。React 提供了一个叫做useLayoutEffect的 Hook,它的工作方式与useEffect完全相同。

有条件地触发效果

如果你需要有条件地触发一个效果,那么你应该向依赖数组中添加一个依赖项,否则,你将多次执行效果,这可能会导致无限循环。如果你传递一个依赖项数组,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的行为与componentDidMountcomponentDidUpdatecomponentWillUnmount的行为相同。

效果非常重要,但让我们也探索一些其他重要的新 Hook,包括useCallbackuseMemomemo

理解 useCallback,useMemo 和 memo

为了理解useCallbackuseMemomemo之间的区别,我们将做一个待办事项清单的例子。你可以使用create-react-app和 typescript 作为模板创建一个基本的应用程序:

create-react-app todo --template typescript

在那之后,你可以移除所有额外的文件(App.cssApp.test.tsindex.csslogo.svgreportWebVitals.tssetupTests.ts)。你只需要保留App.tsx文件,其中包含以下代码:

// Dependencies
import { useState, useEffect, useMemo, useCallback } from 'react'

// Components
import List, { Todo } from './List'

const initialTodos = [
  { id: 1, task: 'Go shopping' },
  { id: 2, task: 'Pay the electricity bill'}
]

function App() {
  const [todoList, setTodoList] = useState(initialTodos)
  const [task, setTask] = useState('')

  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) => setTask(e.target.value)} 
      />

      <button onClick={handleCreate}>Create</button>

      <List todoList={todoList} />
    </>
  )
}

export default App

基本上,我们正在定义一些初始任务并创建todoList状态,我们将把它传递给列表组件。然后你需要创建List.tsx文件,其中包含以下代码:

// Dependencies
import { FC, useEffect } from 'react'

// Components
import Task from './Task'

// Types
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数组的每个任务,并将task作为 prop 传递。我还添加了一个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

这就是我们应该看待待办事项清单的方式:

正如你所看到的,当我们渲染我们的待办事项列表时,默认情况下,我们会对Task组件执行两次渲染,对List执行一次渲染,对App组件执行一次渲染。

现在,如果我们尝试在输入框中写一个新的任务,我们会发现,每写一个字母,我们都会再次看到所有这些渲染:

正如你所看到的,只需写Go,我们就有了两批新的渲染,所以我们可以确定这个组件的性能不好,这就是memo可以帮助我们提高性能的地方。在接下来的部分,我们将学习如何实现memouseMemouseCallback来对组件,值和函数进行记忆化。

使用 memo 对组件进行记忆化

memo 高阶组件(HOC)类似于 React 类的PureComponent,因为它对 props 进行浅比较(意思是表面检查),所以如果我们一直尝试使用相同的 props 渲染组件,组件将只渲染一次并进行记忆。唯一重新渲染组件的方法是当一个 prop 改变其值时。

为了修复我们的组件,避免在输入时多次渲染,我们需要将我们的组件包装在memo HOC 中。

我们将要修复的第一个组件是我们的List组件,你只需要引入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时,让我们看看这一次我们得到了多少次渲染:

现在,我们只在第一次得到第一批渲染,然后,当我们写Go时,我们只得到App组件的另外两个渲染,这是完全可以接受的,因为我们正在改变的任务状态(输入值)实际上是App组件的一部分。

此外,我们可以看到当我们点击“创建”按钮创建一个新任务时,我们执行了多少次渲染:

如果你看到,前 16 次渲染是对“去看医生”字符串的字数统计,然后,当你点击“创建”按钮时,你应该看到Task组件的一次渲染,List的一次渲染,以及App组件的一次渲染。正如你所看到的,我们大大提高了性能,并且我们只执行了确实需要渲染的内容。

此时,你可能在想正确的方法是始终向我们的组件添加备忘录,或者你在想为什么 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.toLocaleLowerCase())
})

最后,我们需要在已经存在的创建按钮旁边添加一个新按钮:

<button onClick={handleSearch}>Search</button>

此时,我建议你删除或注释ListTask组件中的console.log,这样我们可以专注于过滤的性能:

当你再次运行应用程序时,你会看到过滤被执行了两次,然后App组件也是,一切看起来都很好,但是这有什么问题吗?尝试在输入框中再次输入“去看医生”,让我们看看你会得到多少次渲染和过滤:

如你所见,每输入一个字母,你会得到两次过滤调用和一次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())
}), [])

现在,如果您再次在输入框中输入内容,您会发现过滤不会一直执行,就像以前的情况一样:

这很好,但仍然有一个小问题。如果您尝试单击搜索按钮,它不会进行过滤,这是因为我们错过了依赖项。实际上,如果您查看控制台警告,您将看到此警告:

需要将termtodoList依赖项添加到数组中:

const filteredTodoList = useMemo(() => todoList.filter((todo: Todo) => {
  console.log('Filtering...')
 return todo.task.toLowerCase().includes(term.toLocaleLowerCase())
}), [term, todoList])

如果您现在写Go并单击搜索按钮,它应该可以工作:

在这里,我们必须使用与记忆相同的规则;直到绝对必要时才使用它。

使用useCallback来记忆函数定义

现在我们将添加一个删除任务的功能,以了解useCallback的工作原理。我们需要做的第一件事是在我们的App组件中创建一个名为handleDelete的新函数:

const handleDelete = (taskId: number) => {
  const newTodoList = todoList.filter((todo: Todo) => todo.id !== taskId)
  setTodoList(newTodoList)
}

然后,您需要将此函数作为属性传递给List组件:

<List todoList={filteredTodoList} handleDelete={handleDelete} />

然后,在我们的List组件中,您需要将该属性添加到Props接口中:

interface Props {
  todoList: Todo[]
  handleDelete: any
}

接下来,您需要从属性中提取它并将其传递给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>
  )
}

在这一点上,我建议您删除或注释ListTask组件中的console.log,这样我们就可以专注于过滤的性能。现在您应该看到任务旁边的 X 按钮:

如果您单击去购物的 X,应该可以将其删除:

到目前为止,还好,对吧?但是我们在这个实现中又遇到了一个小问题。如果您现在尝试在输入框中写一些内容,比如去看医生,让我们看看会发生什么:

如果您看到,我们再次执行了所有组件的71次渲染。此时,您可能会想,如果我们已经实现了 memo HOC 来记住组件,那么现在发生了什么?但现在的问题是,我们的handleDelete函数被传递给了两个组件,从AppList,再到Task,问题在于每次重新渲染时,这个函数都会被重新生成,也就是说,每次我们写东西时都会重新生成。那么我们如何解决这个问题呢?

useCallback Hook 在这种情况下是英雄,并且在语法上与useMemo非常相似,但主要区别在于,它不是像useMemo那样记住函数的结果值,而是记住函数定义

const handleDelete = useCallback(() => SomeFunctionDefinition, [])

我们的handleDelete函数应该像这样:

const handleDelete = useCallback((taskId: number) => {
  const newTodoList = todoList.filter((todo: Todo) => todo.id !== taskId)
  setTodoList(newTodoList)
}, [todoList])

现在,如果我们再次写去看医生,它应该可以正常工作:

现在,我们只有 23 个渲染,而不是 71 个,这是正常的,我们也能够删除任务:

正如您所看到的,useCallback Hook 帮助我们显着提高了性能。在下一节中,您将学习如何在useEffect Hook 中记忆作为参数传递的函数。

作为参数传递给 effect 的记忆函数

有一种特殊情况,我们需要使用useCallback Hook,这是当我们将一个函数作为参数传递给useEffect Hook 时,例如,在我们的App组件中。让我们创建一个新的useEffect块:

const printTodoList = () => {
  console.log('Changing todoList')
}

useEffect(() => {
  printTodoList()
}, [todoList])

在这种情况下,我们正在监听todoList状态的变化。如果您运行此代码并创建或删除任务,它将正常工作(请记得首先删除所有其他控制台):

一切都运行正常,但让我们将todoList添加到控制台中:

const printTodoList = () => {
  console.log('Changing todoList', todoList)
}

如果您使用的是 Visual Studio Code,您将收到以下警告:

基本上,它要求我们将printTodoList函数添加到依赖项中:

useEffect(() => {
  printTodoList()
}, [todoList, printTodoList])

但现在,在我们这样做之后,我们收到了另一个警告:

我们收到此警告的原因是我们现在正在操作一个状态(控制状态),这就是为什么我们需要在这个函数中添加useCallback Hook 来解决这个问题:

const printTodoList = useCallback(() => {
  console.log('Changing todoList', todoList)
}, [todoList])

现在,当我们删除一个任务时,我们可以看到todoList已经正确更新了:

在这一点上,这可能对您来说是信息过载,所以让我们快速回顾一下:

memo

  • 记忆组件

  • 当道具改变时重新记忆

  • 避免重新渲染

useMemo

  • 记忆计算值

  • 对于计算属性

  • 对于繁重的过程

useCallback

  • 记忆函数定义以避免在每次渲染时重新定义它。

  • 每当将函数作为效果参数传递时使用它。

  • 每当将函数作为道具传递给记忆组件时使用它。

最后,不要忘记黄金法则:除非绝对必要,否则不要使用它们。

在下一节中,我们将学习如何使用新的useReducer Hook。

理解 useReducer Hook

您可能有一些使用 Redux(react-redux)与类组件的经验,如果是这样,那么您将了解useReducer的工作原理。基本概念基本相同:动作、减速器、分发、存储和状态。即使在一般情况下,它似乎与react-redux非常相似,它们也有一些不同之处。主要区别在于react-redux提供了中间件和包装器,如 thunk、sagas 等等,而useReducer只是提供了一个您可以使用来分发纯对象作为动作的dispatch方法。此外,useReducer默认没有存储;相反,您可以使用useContext创建一个,但这只是重复造轮子。

让我们创建一个基本的应用程序来理解useReducer的工作原理。您可以通过创建一个新的 React 应用程序开始:

create-react-app reducer --template typescript

然后,像往常一样,您可以删除src文件夹中的所有文件,除了App.tsxindex.tsx,以启动全新的应用程序。

我们将创建一个基本的Notes应用程序,我们可以使用useReducer列出、删除、创建或更新我们的笔记。您需要做的第一件事是将我们稍后将创建的Notes组件导入到您的App组件中:

import Notes from './Notes'

function App() {
  return (
    <Notes />
  )
}

export default App

现在,在我们的Notes组件中,您首先需要导入useReduceruseState

import { useReducer, useState, ChangeEvent } from 'react'

然后,我们需要定义一些我们需要用于Note对象、Redux 动作和动作类型的 TypeScript 类型:

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'
  }
]

如果您记得减速器的工作原理,那么这将与我们使用switch语句处理减速器的方式非常相似,以执行ADDDELETEUPDATE等基本操作:

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 中获取笔记和dispatch方法(类似于useState),你需要传递reducer函数和initialNotesinitialState):

const Notes = () => {
  const [notes, dispatch] = useReducer(reducer, initialNotes)
  const [note, setNote] = useState('')
  ...
}

然后,我们有一个handleSubmit函数,当我们在输入框中写东西时,可以创建一个新的笔记。然后,我们按下Enter键:

const handleSubmit = (e: ChangeEvent<HTMLInputElement>) => {
  e.preventDefault()

  const newNote = {
    id: Date.now(),
    note
  }

  dispatch({ type: actionType.ADD, payload: newNote })
}

最后,我们使用map渲染我们的Notes列表,并创建两个按钮,一个用于删除,一个用于更新,然后输入框应该包装在<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

如果你运行应用程序,你应该看到以下输出:

正如你在 React DevTools 中所看到的,Reducer对象包含了我们定义的两个笔记作为初始状态。现在,如果你在输入框中写点东西,然后按下Enter,你应该能够创建一个新的笔记:

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

最后,你可以在输入框中写任何你想要的东西,如果你点击更新按钮,你将改变笔记的值:

不错,对吧?正如你所看到的,useReducer Hook 在dispatch方法、动作和 reducers 方面与 redux 基本相同,但主要区别在于这仅限于你的组件及其子组件的上下文,因此,如果你需要一个全局存储来自你整个应用程序,那么你应该使用react-redux

总结

希望你喜欢阅读这一章,其中包含了有关新的 React Hooks 的非常好的信息。到目前为止,你已经学会了新的 React Hooks 是如何工作的,如何使用 Hooks 获取数据,如何将类组件迁移到 React Hooks,效果是如何工作的,memouseMemouseCallback之间的区别,最后,你学会了useReducer Hook 的工作原理,以及与react-redux相比的主要区别。这将帮助你提高 React 组件的性能。

在下一章中,我们将介绍一些最流行的组合模式和工具。

第四章:探索流行的组合模式

现在,是时候学习如何使组件有效地相互通信了。React 之所以强大,是因为它让您构建由小型、可测试和可维护组件组成的复杂应用程序成为可能。应用这种范式,您可以控制应用程序的每个部分。

在本章中,我们将介绍一些最流行的组合模式和工具。

我们将涵盖以下主题:

  • 组件如何使用 props 和 children 相互通信

  • 容器和表示模式以及它们如何使我们的代码更易于维护

  • 高阶组件(HOCs)是什么,以及如何借助它们更好地构建我们的应用程序

  • 子组件模式的功能及其好处

技术要求

要完成本章,您将需要以下内容:

  • Node.js 12+

  • Visual Studio Code

您可以在书籍的 GitHub 存储库中找到本章的代码github.com/PacktPublishing/React-17-Design-Patterns-and-Best-Practices-Third-Edition/tree/main/Chapter04

通信组件

重用函数是我们作为开发人员的目标之一,在上一章中,我们看到了 React 如何轻松创建可重用组件。可重用组件可以在应用程序的多个领域之间共享,以避免重复。

具有清晰接口的小组件可以组合在一起,以创建同时强大又易于维护的复杂应用程序。

编写 React 组件非常简单;您只需在渲染中包含它们:

const Profile = ({ user }) => ( 
  <> 
 <Picture profileImageUrl={user.profileImageUrl} /> 
    <UserName name={user.name} screenName={user.screenName} /> 
  </> 
)

例如,您可以通过简单地将Picture组件组合到Profile组件中来显示配置文件图像,并将UserName组件组合到其中以显示用户的名称和屏幕名称。

通过这种方式,您可以非常快速地生成用户界面的新部分,只需编写几行代码。每当您组合组件时,就像前面的例子一样,您可以使用 props 在它们之间共享数据。Props 是父组件将其数据传递到树中每个需要它(或部分需要它)的组件的方式。

当一个组件将一些属性传递给另一个组件时,不管它们之间的父子关系如何,都称为所有者。例如,在前面的片段中,Profile不是Picture的直接父级(div标签是),但Profile拥有Picture,因为它将属性传递给它。

在下一节中,您将学习有关children属性以及如何正确使用它的知识。

使用 children 属性

有一个特殊的属性可以从所有者传递给其渲染内定义的组件——children

在 React 文档中,它被描述为不透明,因为它是一个不告诉您包含的值的属性。通常在父组件的渲染内定义的子组件会接收作为 JSX 组件本身的属性传递的属性,或者作为_jsx函数的第二个参数。组件也可以在其中定义嵌套组件,并且它们可以使用children属性访问这些子组件。

假设我们有一个Button组件,它有一个text属性表示按钮的文本:

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组件内部包装的任何元素都将作为button元素的子元素呈现,其中btn是类名。

例如,如果我们想在按钮内部呈现一张图片,并将一些文本包裹在span标签中,我们可以这样做:

<Button> 
  <img src="..." alt="..." /> 
  <span>Click me!</span> 
</Button>

前面的片段在浏览器中呈现如下:

<button class="btn"> 
  <img src="..." alt="..." /> 
  <span>Click me!</span> 
</button>

这是一种非常方便的方式,允许组件接受任何children元素,并将这些元素包装在预定义的父元素中。

现在,我们可以将图像、标签甚至其他 React 组件传递到Button组件中,并且它们将作为其子元素呈现。正如您在前面的示例中所看到的,我们将children属性定义为数组,这意味着我们可以将任意数量的元素作为组件的子元素传递。

我们可以传递单个子元素,如下面的代码所示:

<Button> 
 <span>Click me!</span> 
</Button> 

让我们在下一节中探索容器和展示模式。

探索容器和展示模式

在上一章中,我们看到了如何逐步使耦合的组件可重用。现在我们将看到如何将类似的模式应用到我们的组件中,使它们更清晰和更易维护。

React 组件通常包含逻辑呈现的混合。逻辑指的是与 UI 无关的任何内容,例如 API 调用、数据处理和事件处理程序。呈现是指在render中创建要显示在 UI 上的元素的部分。

在 React 中,有一些简单而强大的模式,称为容器展示,我们在创建组件时可以应用这些模式,帮助我们分离这两个关注点。

在逻辑和呈现之间创建明确定义的边界不仅使组件更具重用性,还提供了许多其他好处,您将在本节中了解到。再次强调,学习新概念的最佳方式之一是通过看到实际示例,所以让我们深入一些代码。

假设我们有一个组件,它使用地理位置 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)
  } 
}, [])

当浏览器返回数据时,我们使用以下函数将结果存储到状态中(将此函数放在useEffect Hook 之前):

const handleSuccess = ({ 
 coords: { 
    latitude, 
    longitude 
  } 
}: { coords: { latitude: number; longitude: number }}) => { 
  setLatitude(latitude) 
  setLongitude(longitude)
}

最后,我们显示latitudelongitude的值:

return ( 
  <div>
    <h1>Geolocation:</h1>
    <div>Latitude: {latitude}</div> 
    <div>Longitude: {longitude}</div> 
  </div> 
)

需要注意的是,在第一次render期间,latitudelongitudenull,因为我们在组件挂载时要求浏览器返回坐标。在真实世界的组件中,您可能希望显示一个加载动画,直到数据返回。为此,您可以使用我们在第二章,清理您的代码中看到的条件技术之一。

现在,这个组件没有任何问题,并且按预期工作。将它与请求和加载位置的部分分开以便更快地迭代,这不是件好事吗?

我们将使用容器和呈现模式来隔离呈现部分。在这种模式中,每个组件都分成两个较小的组件,每个组件都有其明确的责任。容器了解组件的所有逻辑,并且是调用 API 的地方。它还处理数据操作和事件处理。

呈现组件是定义 UI 的地方,并且以 props 的形式从容器接收数据。由于呈现组件通常是无逻辑的,因此我们可以将其创建为功能性的无状态组件。

没有规定呈现组件不能有状态的规则(例如,它可以在内部保留 UI 状态)。在这种情况下,我们需要一个组件来显示纬度和经度,因此我们将使用一个简单的函数。

首先,我们应该将我们的Geolocation组件重命名为GeolocationContainer

const GeolocationContainer = () => {...}

我们还将把文件名从Geolocation.tsx改为GeolocationContainer.tsx

这个规则并不严格,但它是 React 社区中广泛使用的最佳实践,即在Container组件名称的末尾添加Container并给原始名称呈现。

我们还必须更改render的实现并删除其中的所有 UI 部分,如下所示:

return ( 
  <Geolocation latitude={latitude} longitude={longitude} />
)

正如您在上面的片段中所看到的,我们不是在容器的return中创建 HTML 元素,而是只使用呈现元素(接下来我们将创建),并将状态传递给它。状态是latitudelongitude,默认情况下为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

功能组件是定义用户界面的一种非常优雅的方式。它们是纯函数,给定一个 state,返回其中的元素。在这种情况下,我们的函数从所有者那里接收 latitudelongitude,然后返回标记结构以显示它。

如果您第一次在浏览器中运行组件,浏览器将要求您允许其了解您的位置:

在您允许浏览器了解您的位置之后,您将看到类似于这样的东西:

遵循容器和展示模式,我们创建了一个愚蠢的可重用组件,我们可以将其放入我们的样式指南中,以便我们可以向其传递虚假坐标。如果在应用程序的其他部分中我们需要显示相同的数据结构,我们不需要创建一个新的组件;我们只需将这个组件包装到一个新的容器中,例如可以从不同的端点加载纬度和经度。

与此同时,我们团队中的其他开发人员可以通过添加一些错误处理逻辑来改进使用地理位置的容器,而不会影响其展示。他们甚至可以构建一个临时的展示组件来显示和调试数据,然后在准备就绪时用真正的展示组件替换它。

能够并行在同一个组件上工作对团队来说是一个巨大的胜利,特别是对于那些构建界面是一个迭代过程的公司。

这种模式简单但非常强大,当应用于大型应用程序时,它可以在开发速度和项目可维护性方面产生巨大差异。另一方面,没有真正的原因应用这种模式可能会给我们带来相反的问题,并使 代码库 变得不太有用,因为它涉及创建更多的文件和组件。

因此,当我们决定一个组件必须按照容器和展示模式进行重构时,我们应该仔细考虑。一般来说,正确的做法是从一个单一的组件开始,只有当逻辑和展示过于耦合时才进行拆分,而它们本不应该耦合在一起。

在我们的例子中,我们从一个单一的组件开始,然后意识到我们可以将 API 调用与标记分开。决定将什么放在容器中,什么放在展示中并不总是直截了当的;以下几点应该帮助您做出决定:

以下是容器组件的特点:

  • 它们更关注于行为。

  • 它们渲染它们的展示组件。

  • 它们进行 API 调用和数据操作。

  • 它们定义事件处理程序。

以下是展示组件的特点:

  • 它们更关注于视觉表现。

  • 它们渲染 HTML 标记(或其他组件)。

  • 它们以 props 的形式从父组件接收数据。

  • 它们通常被写成无状态的功能组件。

正如您所看到的,这些模式形成了一个非常强大的工具,将帮助您更快地开发您的 Web 应用程序。让我们在下一节中看看 HOCs 是什么。

理解 HOCs

在《第二章,清理你的代码》的函数式编程部分,我们提到了高阶函数HOFs)的概念,它们是这样的函数,给定一个函数,用一些额外的行为增强它,返回一个新的函数。当我们将 HOFs 的概念应用到组件上时,我们称之为高阶组件(或简称HOCs)。

首先,让我们看看HoC是什么样子的:

const HoC = Component => EnhancedComponent

HOCs 是以一个组件作为输入,并返回一个增强的组件作为输出的函数。

让我们从一个非常简单的例子开始,以了解增强组件是什么样子的。

假设出于某种原因,您需要将相同的className属性附加到每个组件上。您可以去改变所有的render方法,通过为每个方法添加className属性,或者您可以编写一个 HOC,比如下面这样:

const withClassName = Component => props => ( 
  <Component {...props} className="my-class" /> 
)

在 React 社区中,对于 HOCs 来说,使用with前缀是非常常见的。

最初,上面的代码可能有点难以理解;让我们一起来看一下。

我们声明一个withClassName函数,它接受一个Component并返回另一个函数。返回的函数是一个功能组件,它接收一些 props 并渲染原始组件。收集到的 props 被展开,并且一个带有"my-class"值的className属性被传递给功能组件。

HOC 通常会将它们接收到的 props 展开到组件上,因为它们倾向于是透明的,只添加新的行为。

这很简单,也不是很有用,但它应该让您更好地理解 HOCs 是什么样子的。现在让我们看看如何在我们的组件中使用withClassName HOC。

首先,我们创建一个无状态的函数组件,它接收类名并将其应用于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 => {
  return <Component {...props} />
}

您可能已经注意到 HOC 的命名模式。习惯上,使用with模式为增强组件提供一些信息的 HOC 添加前缀。

现在您需要定义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 () => { // <<< This emulates the componentWillUnmount
    window.removeEventListener('resize', handleResize)
  }
}, []) // <<< This emulates the componentDidMount

最后,原始组件以以下方式呈现:

return <Component {...props} innerWidth={innerWidth} />

正如您在这里所注意到的,我们正在传播 props,就像我们之前看到的那样,但我们也传递了innerWidth状态。

我们将innerWidth值存储为状态以实现原始行为,但我们不会污染组件的状态;我们使用 props 代替。

使用 props 始终是强制可重用性的好解决方案。

现在,使用 HOC 并获取innerWidth值非常简单。

新的 React Hooks 可以轻松地通过创建自定义 Hooks 来替代 HOC。

我们创建一个期望innerWidth作为属性的函数组件:

const MyComponent = ({ innerWidth }) => { 
  console.log('window.innerWidth', innerWidth)
  ... 
}

我们将其改进如下:

const MyComponentWithInnerWidth = withInnerWidth(MyComponent)

首先,我们不会污染任何状态,也不需要组件实现任何函数。这意味着组件和 HOC 没有耦合,它们都可以在应用程序中重复使用。

再次,使用 props 而不是 state 让我们的组件变得简单,这样我们就可以在我们的样式指南中使用它,忽略任何复杂的逻辑,只需传递 props。

在这种特殊情况下,我们可以为我们支持的不同innerWidth大小创建一个组件。

考虑以下示例:

<MyComponent innerWidth={320} /> 

或考虑以下情况:

<MyComponent innerWidth={960} /> 

正如您所看到的,通过使用 HOC,我们可以传递一个组件,然后返回一个具有额外功能的新组件。一些最常见的 HOC 是 Redux 中的connect和 Relay 中的createFragmentContainer

理解 FunctionAsChild

React 社区中有一种模式正在获得共识,被称为FunctionAsChild。它被广泛应用于流行的react-motion库中,我们将在第七章,为浏览器编写代码中看到。

主要概念是,我们不是以组件的形式传递子组件,而是定义一个可以从父组件接收参数的函数。让我们看看它是什么样子的:

const FunctionAsChild = ({ children }) => children()

正如你所看到的,FunctionAsChild是一个具有函数作为children属性定义的组件,而不是作为 JSX 表达式使用,它被调用。

前面的组件可以这样使用:

<FunctionAsChild> 
  {() => <div>Hello, World!</div>} 
</FunctionAsChild>

就像它看起来那样简单:render方法中触发children函数,并返回包裹在div标签中的Hello, World!文本,这将显示在屏幕上。

让我们深入一个更有意义的例子,父组件向children函数传递一些参数。

创建一个Name组件,它期望一个函数作为children并将World字符串传递给它:

const Name = ({ children }) => children('World')

前面的组件可以这样使用:

<Name> 
  {name => <div>Hello, {name}!</div>} 
</Name>

代码片段再次呈现Hello, World!,但这次名称是由父组件传递的。应该清楚这种模式是如何工作的,所以让我们看看这种方法的优势。

第一个好处是,我们可以在运行时包装组件,而不是使用 HOCs 时传递固定属性。

一个很好的例子是Fetch组件,它从 API 端点加载一些数据并将其返回给children函数:

<Fetch url="..."> 
  {data => <List data={data} />} 
</Fetch> 

其次,使用这种方法组合组件不会强制children使用一些预定义的属性名称。由于函数接收变量,它们的名称可以由使用组件的开发人员决定。这使得FunctionAsChild解决方案更加灵活。

最后但并非最不重要的是,包装器非常可重用,因为它不对接收到的children做任何假设——它只是期望一个函数。因此,相同的FunctionAsChild组件可以在应用程序的不同部分使用,为各种children组件提供服务。

总结

在本章中,我们学习了如何组合我们的可重用组件并使它们有效地进行通信。Props 是一种将组件解耦并创建清晰和明确定义接口的方法。

然后,我们学习了 React 中一些最有趣的组合模式。第一个是所谓的容器,另一个是表示模式。这些模式帮助我们将逻辑与呈现分离,并创建具有单一责任的更专业化的组件。

我们学会了如何处理上下文,而无需将我们的组件与其耦合,这要归功于 HOCs。最后,我们看到了如何通过遵循FunctionAsChild模式来动态组合组件。

在下一章中,我们将学习 GraphQL 以及如何创建 JWT 令牌,执行登录操作,并使用 Sequelize 创建模型。

第五章:通过一个真实项目了解 GraphQL

GraphQL是用于 API 的查询语言,可以帮助它们与您现有的数据进行交互。它提供了 API 中数据的完整描述,您只能请求确切需要的数据,而不会多余。它还使得改进 API 变得更容易,并且具有非常强大的开发人员工具。

在本章中,我们将学习如何在一个真实项目中使用 GraphQL,通过创建一个基本的登录和用户注册系统。

本章将涵盖以下主题:

  • 安装 PostgreSQL

  • 使用.env文件创建环境变量

  • 配置 Apollo Server

  • 定义 GraphQL 查询和变更

  • 与解析器一起工作

  • 创建 Sequelize 模型

  • 实施 JWT

  • 使用 GraphQL Playground

  • 执行身份验证

技术要求

要完成本章,您将需要以下内容:

您可以在本书的 GitHub 存储库中找到本章的代码:github.com/PacktPublishing/React-17-Design-Patterns-and-Best-Practices-Third-Edition/tree/main/Chapter05

安装 PostgreSQL

在本示例中,我们将使用 PostgreSQL 数据库,因此您需要安装 PostgreSQL 才能在您的机器上运行此项目。

如果您有一台 macOS 机器,安装 PostgreSQL 的最简单方法是使用 Homebrew。您只需要运行以下命令:

brew install postgres

安装完成后,您需要运行以下命令:

ln -sfv /usr/local/opt/postgresql/*.plist ~/Library/LaunchAgents

然后,您可以创建两个新的别名来启动和停止您的 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

如果您一切都做对了,您应该能看到类似于这样的东西:

如果你使用 Windows,你可以在www.postgresql.org/download/windows/下载 PostgreSQL,而对于使用 Linux(Ubuntu)的用户,你可以从www.postgresql.org/download/linux/ubuntu/下载。

PostgreSQL 数据库管理的最佳工具

PostgreSQL 数据库管理中最好的工具之一是pgAdmin 4www.pgadmin.org/download/)。我喜欢这个工具,因为它可以用来创建新的服务器、用户和数据库。我喜欢使用的另一个工具是OmniDBomnidb.org)。我强烈建议你安装这两个工具。

记得创建一个数据库以便在这个示例中使用。

有时,当你启动你的 PostgreSQL 服务器时,你可能会遇到一个错误,它可能会说

FATAL: lock file "postmaster.pid" already exists.

如果你遇到这个错误,你可以通过运行rm /usr/local/var/postgres/postmaster.pid命令来轻松修复它。然后,你就可以启动你的 PostgreSQL 服务器了。

创建我们的.env 文件和配置文件

首先,你需要在你的 GraphQL 项目中创建一个后端目录(graphql/backend),之后让我们来审查你需要安装的大量 NPM 包的列表(最相关的)。

npm init --yes

npm install @contentpi/lib @graphql-tools/load-files @graphql-tools/merge apollo-server dotenv express jsonwebtoken pg pg-hstore sequelize ts-node

npm install --save-dev husky jest prettier sequelize-mock ts-jest ts-node-dev typescript eslint @types/jsonwebtoken

你的package.json文件中应该有以下脚本:

"scripts": {
  "dev": "ts-node-dev src/index.ts",
  "start": "ts-node dist/index.js",
  "build": "tsc -p .",
  "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的文件;你只需要将其重命名并将你的连接数据放入其中。这将看起来像这样:

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": 5000
  },
  "security": {
    "secretKey": "C0nt3ntP1",
    "expiresIn": "7d"
  }
}

然后,您需要创建一个index.ts文件。这将使用dotenv包将我们在.env文件中定义的所有数据库连接信息导入,并导出三个配置变量,称为$db$security$server

// Dependencies
import dotenv from 'dotenv'

// Configuration
import config from './config.json'

// Loading .env vars
dotenv.config()

// Types
type Db = {
  dialect: string
  host: string
  port: string
  database: string
  username: string
  password: string
}

type Security = {
  secretKey: string
  expiresIn: string
}

type Server = {
  port: number
}

// Extracting data from .env file
const {
  DB_DIALECT = '',
  DB_PORT = '',
  DB_HOST = '',
  DB_DATABASE = '',
  DB_USERNAME = '',
  DB_PASSWORD = '',
} = process.env

const db: Db = {
  dialect: DB_DIALECT,
  port: DB_PORT,
  host: DB_HOST,
  database: DB_DATABASE,
  username: DB_USERNAME,
  password: DB_PASSWORD
}

// Configuration
const { security, server } = config

export const $db: Db = db
export const $security: Security = security
export const $server: Server = server

如果您的.env文件不在根目录中或不存在,那么所有变量都将是undefined

配置 Apollo Server

Apollo Server 是最流行的开源库,可以与 GraphQL(服务器和客户端)一起使用。它有很多文档,非常容易实现。

以下图解释了 Apollo Server 在客户端和服务器中的工作原理:

我们将使用 Express 来设置我们的 Apollo Server 和 Sequelize ORM 来处理我们的 PostgreSQL 数据库。因此,最初,我们需要进行一些导入。所需的文件可以在/backend/src/index.ts找到:

// Dependencies
import { ApolloServer, makeExecutableSchema } from 'apollo-server'

// Models
import models from './models'

// Type Definitions & Resolvers
import resolvers from './graphql/resolvers'
import typeDefs from './graphql/types'

// Configuration
import { $server } from '../config'

首先,我们需要使用makeExecutableSchema创建我们的模式,通过传递typeDefsresolvers

// Schema
const schema = makeExecutableSchema({
  typeDefs,
  resolvers
})

然后,我们需要创建一个ApolloServer的实例,在这里我们需要传递模式和上下文中的模型:

// Apollo Server
const apolloServer = new ApolloServer({
  schema,
  context: {
    models
  }
})

最后,我们需要同步 Sequelize。在这里,我们传递了一些可选变量(alterforce)。如果forcetrue并且更改了 Sequelize 模型,这将删除您的表,包括它们的值,并强制您重新创建表,而如果forcefalse并且altertrue那么您只会更新表字段,而不会影响您的值。因此,您需要小心使用此选项,因为您可能会意外丢失所有数据。然后,在同步之后,我们必须运行我们的 Apollo Server,它正在监听端口5000$server.port):

const alter = true
const force = false

models.sequelize.sync({ alter, force }).then(() => {
  apolloServer
    .listen($server.port)
    .then(({ url }) => {
      // eslint-disable-next-line no-console
      console.log(`Running on ${url}`)
    })
})

这将帮助我们将数据库与我们的模型同步,以便每当我们对模型进行更改时,表都将被更新。

定义我们的 GraphQL 类型,查询和变异

现在您已经创建了 Apollo Server 实例,您需要创建您的 GraphQL 类型。在这种情况下,我们将为用户创建一些类型,查询和变异。

您需要做的第一件事是在/backend/src/graphql/types/Scalar.graphql中定义您的标量类型:

scalar UUID
scalar Datetime
scalar JSON

现在,让我们创建我们的User.graphql文件,其中包含我们的初始User类型:

type User {
  id: UUID!
  username: String!
  password: String!
  email: String!
  privilege: String!
  active: Boolean!
  createdAt: Datetime!
  updatedAt: Datetime!
}

如您所见,我们正在使用一些标量类型,如UUIDDatetime,来定义我们User类型中的一些字段。在这种情况下,当您在 GraphQL 中定义类型时,您需要使用type关键字,后跟类型的大写名称。然后,您可以在大括号{}中定义您的字段。

在 GraphQL 中有一些原始数据类型,如StringBooleanFloatInt。您可以像我们使用UUIDDatetimeJSON一样定义自定义标量类型,还可以定义自定义类型,如User类型,并指定我们是否需要该类型的数组;例如,[User]

类型后面的!字符表示该字段是非空的。

查询

GraphQL 查询用于从数据存储中读取或获取值。

现在您知道如何定义自定义类型了,让我们定义我们的Query类型。在这里,我们将定义getUsersgetUserData。第一个将检索用户列表,而第二个将为我们带来特定用户的数据:

type Query {
 getUsers: [User!]
 getUserData(at: String!): User!
}

在这种情况下,我们的getUsers查询将返回一个用户数组([User!]),而我们的getUserData查询,需要at访问令牌)属性,将返回一个单一的User!。请记住,您在此添加的任何查询,稍后都需要在解析器下定义(我们将在下一节中进行)。

变异

变异用于编写或发布值-即修改数据存储中的数据-并在需要执行任何 POST、PUT 或 DELETE 操作的情况下返回一个值,如果您想进行一些与 REST 的比较。Mutation类型与Query类型完全相同,您需要在其中定义您的变异,并指定您将接收的参数和您将返回的数据:

type Mutation {
  createUser(input: CreateUserInput): User!
  login(input: LoginInput): AuthPayload!
}

如您所见,我们已经定义了两个变异。第一个是createUser,用于在我们的数据存储中注册或创建新用户,而第二个是执行login。正如您可能已经注意到的,两者都接收了input参数,并带有一些不同的值(CreateUserInputLoginInput),称为输入类型,这些类型用作查询或变异参数。最后,它们将分别返回User!类型和AuthPayload!。让我们学习如何定义这些输入:

input CreateUserInput {
  username: String!
  password: String!
  email: String!
  privilege: String!
  active: Boolean!
}

input LoginInput {
  email: String!
  password: String!
}

type AuthPayload {
  token: String!
}

输入通常与变异一起使用,但您也可以将其与查询一起使用。

合并我们的类型定义

现在我们已经定义了所有的类型、查询和变异,我们需要合并所有的 GraphQL 文件来创建我们的 GraphQL 模式,这基本上是一个包含所有我们的 GraphQL 定义的大文件。

为此,您需要创建一个名为/backend/src/graphql/types/index.ts的文件,其中包含以下代码:

import path from 'path'
import { loadFilesSync } from '@graphql-tools/load-files'
import { mergeTypeDefs } from '@graphql-tools/merge'

const typesArray = loadFilesSync(path.join(__dirname, './'), { extensions: ['graphql'] })

export default mergeTypeDefs(typesArray)

我们正在使用@graphql-tools包来加载我们的 GraphQL 文件,并使用mergeTypesDefs方法将它们合并到typesArray中。

创建我们的解析器

解析器是负责为 GraphQL 模式中的字段生成数据的函数。它通常可以以任何想要的方式生成数据,可以从数据库中获取数据,也可以使用第三方 API。

要创建我们的用户解析器,您需要创建一个名为/backend/src/graphql/resolvers/user.ts的文件。让我们创建一个解析器应该是什么样子的框架。在这里,我们需要指定在我们的 GraphQL 模式下定义的QueryMutation下定义的函数。因此,您的解析器应该如下所示:

export default {
  Query: {
    getUsers: () => {},
    getUserData: () => {},
  },
  Mutation: {
    createUser: () => {},
    login: () => {}
  }
}

正如您所看到的,我们返回了一个具有两个名为QueryMutation的主节点的对象,并且我们正在映射我们在 GraphQL 模式中定义的查询和变异(User.graphql文件)。当然,我们需要做一些更改来接收一些参数并返回一些数据,但我想先向您展示解析器文件的基本框架。

您需要做的第一件事是向文件添加一些导入:

// Lib
import { getUserData } from '../../lib/jwt'

// Interfaces
import {
  IUser,
  ICreateUserInput,
  IModels,
  ILoginInput,
  IAuthPayload
} from '../../types'

// Utils
import { doLogin, getUserBy } from '../../lib/auth'

我们将在下一节中创建doLogingetUserBy函数。

创建 getUsers 查询

我们的第一个方法将是getUsers查询。让我们看看我们需要如何定义它:

getUsers: (
  _: any,
  args: any,
  ctx: { models: IModels }
): IUser[] => ctx.models.User.findAll(),

在任何查询或变异方法中,我们总是接收四个参数:父级(定义为_),参数(定义为args),上下文(定义为ctx)和info(可选)。

如果您想简化代码,可以像这样解构上下文:

getUsers: (
  _: any,
  args: any,
  { models }: { models: IModels }
): IUser[] => models.User.findAll(),

在我们下一个解析器函数中,我们也将解构我们的参数。作为提醒,上下文是在我们的 Apollo Server 设置中传递的(我们之前做过这个):

// Apollo Server
const apolloServer = new ApolloServer({
  schema,
  context: {
    models
  }
})

当我们需要在解析器中全局共享一些东西时,上下文非常重要。

创建 getUserData 查询

这个函数需要是异步的,因为我们需要执行一些异步操作,比如通过at(访问令牌)获取已连接的用户,如果用户已经有一个有效的会话。然后,我们可以通过查看我们的数据库来验证这是否是一个真实的用户。这有助于阻止人们修改 cookie 或尝试进行某种形式的注入。如果我们找不到已连接的用户,那么我们将返回一个包含空数据的用户对象:

getUserData: 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,
        privilege: connectedUser.privilege,
        active: connectedUser.active
      },
      models
    )

    if (user) {
      return connectedUser
    }
  }

  return {
    id: '',
    username: '',
    password: '',
    email: '',
    privilege: '',
    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<IAuthPayload> => doLogin(input.email, input.password, models)
}

您需要将电子邮件、密码和模型传递给doLogin函数。

合并我们的解析器

就像我们对类型定义所做的那样,我们需要使用@graphql-tools包合并所有我们的解析器。您需要在/backend/src/graphql/resolvers/index.ts创建以下文件:

import path from 'path'
import { loadFilesSync } from '@graphql-tools/load-files'
import { mergeResolvers } from '@graphql-tools/merge'

const resolversArray = loadFilesSync(path.join(__dirname, './'))
const resolvers = mergeResolvers(resolversArray)

export default resolvers

这将把所有你的解析器合并成一个解析器数组。

创建 Sequelize 模型

在我们跳入身份验证功能之前,我们需要在 Sequelize 中创建我们的User模型。为此,我们需要在/backend/src/models/User.ts创建一个文件。我们的模型将具有以下字段:

  • id

  • username

  • password

  • email

  • privilege

  • active

让我们看看代码:

// Dependencies
import { encrypt } from '@contentpi/lib'

// Interfaces
import { IUser, IDataTypes } 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'
          }
        }
      },
      privilege: {
        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 Hook,它在数据保存之前帮助我们加密(使用sha1)用户密码。最后,我们返回User模型。

将 Sequelize 连接到 PostgreSQL 数据库

现在我们已经创建了用户模型,我们需要将 Sequelize 连接到我们的 PostgreSQL 数据库并将所有模型放在一起。您需要将以下代码添加到/backend/src/models/index.ts文件中:

// Dependencies
import { Sequelize } from 'sequelize'

// Configuration
import { $db } from '../../config'

// Interfaces
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

身份验证功能

我们正在一步一步地把所有的拼图拼在一起。现在,让我们来看看我们正在使用的身份验证功能,以验证用户是否已连接并获取用户的数据。为此,我们需要使用 JSON Web Tokens(JWTs)。

什么是 JSON Web Token?

JWT是一个开放标准 - RFC 7519 (tools.ietf.org/html/rfc7519) - 用于在各方之间传输信息作为 JSON 对象。JWT 的优势在于它们是数字签名的,这就是为什么它们可以被验证和信任的原因。它使用 HMAC 算法通过使用秘密或 RSA 或 ECDSA 的公钥对令牌进行签名。

JWT 功能

让我们创建一些函数来帮助验证 JWT 并获取用户数据。为此,我们需要创建jwtVerifygetUserDatacreateToken函数。该文件应该在/backend/src/lib/jwt.ts中创建:

// Dependencies
import jwt from 'jsonwebtoken'
import { encrypt, setBase64, getBase64 } from '@contentpi/lib'

// Configuration
import { $security } from '../../config'

// Interface
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, privilege, 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,
    privilege,
    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登录:

// Dependencies
import { AuthenticationError } from 'apollo-server'

// Utils
import { encrypt, isPasswordMatch } from '@contentpi/lib'

// Interface
import { IUser, IModels, IAuthPayload } from '../types'

// JWT
import { createToken } from './jwt'

export const getUserBy = async (
  where: any,
  models: IModels
): Promise<IUser> => {
  // We find a user by a WHERE condition
  const user = await models.User.findOne({
    where,
    raw: true
  })

  return user
}

export const doLogin = async (
  email: string,
  password: string,
  models: IModels
): Promise<IAuthPayload> => {
  // Finding a user by email
  const user = await getUserBy({ email }, models)

  // If the user does not exists we return Invalid Login
  if (!user) {
    throw new AuthenticationError('Invalid Login')
  }

  // We verify that our encrypted password is the same as the user.password 
  // value
  const passwordMatch = isPasswordMatch(encrypt(password), user.password)

  // We validate that the user is active
  const isActive = user.active

  // If the password does not match we return invalid login
  if (!passwordMatch) {
    throw new AuthenticationError('Invalid Login')
  }

  // If the account is not active we return an error
  if (!isActive) {
    throw new AuthenticationError('Your account is not activated yet')
  }

 // If the user exists, the password is correct and the account is active 
 // then we create the JWT token
  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
  privilege: string
  active: boolean
}

export type Sequelize = {
  _defaults?: any
  name?: string
  options?: any
  associate?: any
}

现在,让我们在/backend/src/types/interfaces.ts创建我们的接口:

// Types
import { User, Sequelize } from './types'

// Sequelize
export interface IDataTypes {
  UUID: string
  UUIDV4(): string
  STRING: string
  BOOLEAN: boolean
  TEXT: string
  INTEGER: number
  DATE: string
  FLOAT: number
}

// User
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 IAuthPayload {
  token: string
}

// Models
export interface IModels {
  User: any
  sequelize: any
}

最后,我们需要在/backend/src/types/index.ts中导出这两个文件:

export * from './interfaces'
export * from './types'

当您需要添加更多模型时,请记住始终将您的类型和接口添加到这些文件中。

最后,您需要在根目录创建您的tsconfig.json文件:

{
  "compilerOptions": {
    "baseUrl": "./src",
    "esModuleInterop": true,
    "module": "commonjs",
    "noImplicitAny": true,
    "outDir": "dist",
    "resolveJsonModule": true,
    "sourceMap": true,
    "target": "es6",
    "typeRoots": ["./src/@types", "./node_modules/@types"]
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules"]
}

在下一节中,我们将运行我们的项目并创建我们的表。

首次运行我们的项目

如果您按照前面的部分正确操作并运行npm run dev命令,您应该能够看到Users表正在被创建,并且 Apollo Server 正在端口5000上运行:

现在,假设您想修改用户模型并将"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:5000/

这将执行以下 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。将会发生以下情况:

如你所见,如果 forcetrue,它将执行 DROP TABLE IF EXISTS "Users" CASCADE;。这将完全删除你的表和值,然后从头开始重新创建你的表。这就是为什么当你使用 force 选项时需要小心。

此时,如果你打开 http://localhost:5000,你应该能够看到你的 GraphQL Playground:

现在,我们准备测试我们的查询和变更。

测试我们的 GraphQL 查询和变更

太棒了!此时,你非常接近执行你的第一个 GraphQL 查询和变更。我们将执行的第一个查询是 getUsers。以下是运行查询的正确语法:

query {
  getUsers {
    id
    username
    email
    privilege
  }
}

当你没有任何属性要传递给查询时,你只需要在 query {...} 块下指定查询的名称,然后在执行查询后指定要检索的字段。在这种情况下,我们想要获取 idusernameemailprivilege 字段。

如果你运行这个查询,你可能会得到一个空数组的数据。这是因为我们还没有注册任何用户:

这意味着我们需要执行我们的 createUser 变更以注册我们的第一个用户。我喜欢 GraphQL Playground 的一件事是你可以在右侧的 DOCS 选项卡中找到所有的模式文档。如果你点击 DOCS 选项卡,你会看到所有的查询和变更列出来。让我们点击那里并选择我们的 createUser 变更,看看需要调用什么以及可能返回什么数据:

如你所见,createUser 变更需要一个输入参数,即 CreateUserInput。让我们点击这个输入:

太棒了!现在,我们知道我们需要传递 usernamepasswordemailprivilegeactive 字段以创建一个新用户,并且我们将收到相同的字段,以及用户的生成 ID。让我们来做这个!

创建一个新的选项卡,这样你就不会丢失你的第一个查询的代码,然后写下变更:

mutation {
  createUser(
    input: {
      username: "admin",
      email: "admin@js.education",
      password: "123456",
      privilege: "god",
      active: true
    }
  ) {
    id
    username
    email
    password
    privilege
  }
}

如你所见,你的变更需要写在 mutation {...} 块下,并且你必须将输入参数作为对象传递。最后,你必须指定在变更正确执行后要检索的字段。如果一切正常,你应该会看到类似这样的东西:

如果你好奇并希望看一下你运行 Apollo Server 的终端,你会看到为这个用户执行的 SQL 查询:

Executing (default): INSERT INTO "Users" ("id","username","password","email","privilege","active","createdAt","updatedAt") VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING "id","username","password","email","privilege","active","createdAt","updatedAt";

VALUES变量由 Apollo Server 处理,所以你在那里看不到实际的值,但你可以找出在数据库中执行的操作。

现在,回到你的第一个查询(getUsers)并再次运行它!

不错 - 这是你在 GraphQL 中第一次正确执行的查询和变异。如果你想在数据库中看到这些数据,你可以使用 OmniDB 来查看你的 PostgreSQL 数据库中的Users表:

正如你所看到的,我们的第一条记录有它自己的id字段(UUID),并且还有一个加密的password字段(你还记得我们在用户模型中的beforeCreate Hook 吗?)。默认情况下,Sequelize 会创建createdAtupdatedAt字段。

验证

正如你可能记得的,关于我们的用户模型,你会想确保我们所做的所有验证都能正常工作,比如用户是否唯一,以及他们的电子邮件是否有效和唯一。你只需要再次执行完全相同的变异:

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

我们还会收到一个“电子邮件必须是唯一的”错误。现在,尝试将电子邮件更改为一些无效的内容,比如admin@myfakedomain

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

现在,我们有两个注册用户,都是非活跃账户(active = false)。

我喜欢 GraphQL 的一件事是,当你编写你的查询或突变时,如果你不记得某个字段,GraphQL 总是会显示该查询或突变的可用字段列表。例如,如果你只是写了字母p作为密码,你会看到类似这样的东西:

现在,我们准备尝试登录!

执行登录

我想祝贺你在这本书中达到了这一点 - 我知道我们已经涵盖了很多内容,但我们几乎到了!现在,我们将尝试使用 GraphQL 登录(这有多疯狂?)。

首先,我们需要编写我们的登录突变:

mutation {
  login(
    input: {
      email: "fake@email.com",
      password: "123456"
    }
  ) {
    token
  }
}

然后,我们需要使用"fake@email.com"作为我们的电子邮件和"123456"作为我们的密码来登录我们的用户。这些在我们的数据库中不存在:

因为电子邮件在我们的数据库中不存在,将返回一个"无效登录"错误消息。现在,让我们添加正确的电子邮件,但使用一个虚假的密码:

正如你所看到的,我们收到了完全相同的错误("无效登录")。这是因为我们不希望提供关于登录出了什么问题的太多信息,因为有人可能正在尝试黑入另一个用户。如果我们说诸如"无效密码""您的电子邮件在我们的系统中不存在"之类的话,我们就给了攻击者额外的信息,他们可能会发现有用。

现在,让我们尝试使用正确的用户和密码(admin@js.education / 123456)进行连接,看看会发生什么:

现在,我们收到了一个错误,指出"您的帐户尚未激活"。这没关系,因为我们的用户还没有被激活。通常,当用户在系统中注册时,您需要发送一个链接到他们的电子邮件,以便他们可以激活他们的帐户。我们目前没有这个功能,但假设我们发送了那封电子邮件,并且用户已经激活了他们的帐户。我们可以通过手动更改我们的数据库中的值来模拟这一点。我们可以通过执行UPDATE SQL 查询来做到这一点:

现在,让我们再次尝试登录!

不错 - 我们成功了!你现在

我们是匿名的,我们是一体的,我们不会原谅,我们不会忘记,期待我们!

现在我们已经登录并检索到了我们的 JWT,让我们复制那个巨大的字符串,并在我们的getUserData查询中使用它,看看我们是否可以获取用户的数据:

query {
  getUserData(at: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjoiZXlKcFpDSTZJalEzTnpsaU16QTJMV1U0TW1NdE5HVmtNUzFoWldNM0xXSXdaVEl5TWpSaU5UUTNaU0lzSW5WelpYSnVZVzFsSWpvaVlXUnRhVzRpTENKbGJXRnBiQ0k2SW1Ga2JXbHVRR3B6TG1Wa2RXTmhkR2x2YmlJc0luQnlhWFpwYkdWblpTSTZJbWR2WkNJc0ltRmpkR2wyWlNJNmRISjFaU3dpZEc5clpXNGlPaUpOUkdjeldWUkZNMXBVWjNwTmJWVjVXV3BWTWs1SFJtMWFiVTB6V21wTk5GbFVRWGxhVkdSb1RVUm9iVTFIVlROTmJWa3dXVlJrYWs1SFJUUmFSRUUxV1RKRmVrNTZXWGxaVjFreVRWZFZNVTlVVlhsTlJHc3dUVEpTYWsxcVdUQlBWRkp0VDBSck1FMVhTVDBpZlE9PSIsImlhdCI6MTYxNzY5ODY4OSwiZXhwIjoxNjE4MzAzNDg5fQ.6icaBFibjEOICUt5QQ0OPAoDsb7_ohb8W10JzHnbf7k") {
    id
 email
 privilege
 active
  }
}

如果一切顺利,那么你应该得到用户的数据:

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

现在我们的后端登录系统完美运行,是时候在前端应用程序中实现了。我们将在下一节中进行此操作。

使用 Apollo Client 构建前端登录系统

在上一节中,我们学习了如何使用 Apollo Server 构建登录系统的后端,以创建我们的 GraphQL 查询和变异。你可能会想,“太好了,我已经让后端工作了,但我怎么在前端使用呢?”你是对的 - 我总是喜欢用完整的例子来解释事情,而不仅仅是展示基本的东西,即使这样做会花费更长的时间,所以让我们开始吧!

您可以在本节的示例代码中找到github.com/PacktPublishing/React-17-Design-Patterns-and-Best-Practices-Third-Edition/tree/main/Chapter05/graphql/frontend

配置 Webpack 5

我们将不再使用create-react-app项目,而是使用 Webpack 5 和 Node 从头开始配置一个 React 项目。

我们需要做的第一件事是安装我们将要使用的所有软件包:

npm init --yes

npm install @apollo/client @contentpi/lib cookie-parser cors express express-session jsonwebtoken react react-dom react-cookie react-router-dom styled-components

npm install --save-dev @babel/core @babel/preset-env @babel/preset-react buffer cross-env crypto-browserify dotenv prettier stream-browserify ts-loader ts-node ts-node-dev typescript webpack webpack-cli webpack-dev-server html-webpack-plugin

缓冲区crypto-browserifystream-browserify是 Webpack <= 4 中默认包含的 polyfill。然而,在最新版本(Webpack 5)中,这些不再包含在内,所以你会得到以下错误:

您需要在您的package.json中有这些脚本:

"scripts": {
    "start": "ts-node src/server",
    "dev": "ts-node-dev src/server",
    "webpack": "cross-env NODE_ENV=development webpack serve --mode development",
    "build": "cross-env NODE_ENV=production webpack --mode production",
    "clean": "rimraf dist/ && rimraf public/app",
    "lint": "eslint . --ext .js,.tsx,.ts",
    "lint:fix": "eslint . --fix --ext .js,.tsx,.ts",
    "test": "jest src",
    "test:coverage": "jest src --coverage"
  }

让我们检查我们的 Webpack 5 配置文件(/frontend/webpack.config.ts):

// Dependencies
import path from 'path'
import webpack, { Configuration } from 'webpack'
import HtmlWebPackPlugin from 'html-webpack-plugin'

// Environment
const isProduction = process.env.NODE_ENV === 'production'

const webpackConfig: Configuration = {
  devtool: !isProduction ? 'source-map' : false,
  target: 'web',
  mode: isProduction ? 'production' : 'development',
  entry: './src/index.tsx',
  output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name].js',
    publicPath: '/'
  },
  resolve: {
    extensions: ['.ts', '.tsx', '.js', '.json'],
    fallback: { // This is to fix the polifylls errors
      buffer: require.resolve('buffer'),
      crypto: require.resolve("crypto-browserify"),
      stream: require.resolve("stream-browserify")
    }
  },
  module: {
    rules: [
      {
        test: /\.(ts|tsx)$/,
        use: {
          loader: 'ts-loader',
          options: {
            transpileOnly: true
          }
        },
        exclude: /node_modules/
      }
    ]
  },
  optimization: {
    splitChunks: { // This will split our bundles into vendor.js and 
    // main.js
      cacheGroups: {
        default: false,
        commons: {
          test: /node_modules/,
          name: 'vendor',
          chunks: 'all'
        }
      }
    }
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin(),
    new HtmlWebPackPlugin({
      template: './src/index.html',
      filename: './index.html',
      publicPath: !isProduction ? 'http://localhost:8080/' : '' // For dev 
      // we will read the bundle from localhost:8080 (webpack-dev-server)
    })
  ]
}

export default webpackConfig

在这一点上,您需要创建index.html文件,应该在/frontend/src/index.html

<!DOCTYPE html>
<html>
 <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1, 
      maximum-scale=1" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Login System</title>
 </head>

 <body>
 <div id="root"></div>
 </body>
</html>

在下一节中,我们将配置我们的 TypeScript。

配置我们的 TypeScript

我们的tsconfig.json文件应该是这样的:

{
  "compilerOptions": {
    "sourceMap": true,
    "target": "es5",
    "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,
    "types": ["node", "express"]
  },
  "include": ["src"]
}

现在,让我们学习如何配置 Express 服务器。

配置 Express 服务器

我们的应用程序需要 Express 服务器,以便我们可以进行验证。这将帮助我们找出用户是否已连接(使用自定义中间件,稍后我会解释),还可以配置我们的 Express 会话。我们网站上有四个主要路由:

  • /:我们的主页(由 React 处理)

  • /dashboard:我们的仪表板,受保护。只有具有 god 或 admin 权限的连接用户被允许(首先由 Express 处理,然后由 React 处理)

  • /login:我们的登录页面(由 React 处理)

  • /logout:这将删除我们现有的会话(由 Express 处理)

让我们看看我们的服务器代码。以下文件应存在于/frontend/src/server.ts

// Dependencies
import express, { Request, Response, NextFunction } from 'express'
import path from 'path'
import cookieParser from 'cookie-parser'
import cors from 'cors'
import session from 'express-session'

// Middleware
import { isConnected } from './lib/middlewares/user'

// Config
import config from './config'

// Express app
const app = express();
const port = process.env.NODE_PORT || 3000
const DIST_DIR = path.join(__dirname, '../dist')
const HTML_FILE = path.join(DIST_DIR, 'index.html')

// Making the dist directory static
app.use(express.static(DIST_DIR));

// Middlewares
app.use(
  session({
    resave: false,
    saveUninitialized: true,
    secret: config.security.secretKey
  })
)
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser(config.security.secretKey))
app.use(cors({ credentials: true, origin: true }))

// Routes
app.get('/dashboard',
  isConnected(
    true,
    ['god', 'admin'], // Those are the allowed permissions
    `/login?redirectTo=/dashboard` // If the user is not allowed will be 
    // redirect to this path
  ),
  (req: Request, res: Response, next: NextFunction) => {
    // If the user isConnected then we allow the access to the dashboard 
    // page otherwise will be redirect to /login
    next()
  }
)

// Forcing only No connected users to access to /login, if a connected user 
// try to access will be redirect to the homepage
app.get('/login', isConnected(false), (req: Request, res: Response, next: NextFunction) => {
  next()
})

app.get(`/logout`, (req: Request, res: Response) => {
  // This will cler our "at" cookie and redirect to home
  res.clearCookie('at')
  res.redirect('/')
})

app.get('*', (req: Request, res: Response) => {
  // We render our React application
  res.sendFile(HTML_FILE)
})

// Listening
app.listen(port, () => console.log(`Running at http://localhost:${port}`))

正如您所看到的,我们正在使用isConnected中间件保护我们的仪表板路由。在这里,我们正在验证我们只接受在login路由中未连接的用户。

创建我们的前端配置

现在,我们需要创建我们的前端配置。因此,让我们在/frontend/src/config/common.json创建common.json配置:

{
  "server": {
    "port": 3000
  },
  "security": {
    "secretKey": "C0nt3ntP1", // This needs to be the same as the backend 
      // secretKey
    "expiresIn": "7d"
  }
}

现在,让我们创建我们的local.json文件:

{
  "baseUrl": "http://localhost:3000",
  "apiUrl": "http://localhost:5000/graphql"
}

现在,我们需要创建我们的production.json文件;目前,由于我们没有实际的生产环境,我们将使用相同的本地主机 URL,但是一旦将此项目放入生产环境中,您将需要更改为实际的域名:

{
  "baseUrl": "http://localhost:3000",
  "apiUrl": "http://localhost:5000/graphql"
}

现在我们已经定义了我们的配置文件,我们需要创建一个index.ts文件,以便我们可以将我们的配置合并并导出为一个对象:

// Configuration
import common from './common.json'
import local from './local.json'
import production from './production.json'

// Interface
interface IConfig {
 baseUrl: string
 apiUrl: string
 server: {
 port: number
 }
 security: {
 secretKey: string
 expiresIn: string
 }
}

const { NODE_ENV = 'development' } = process.env

// development => local
let environment = 'local'

if (NODE_ENV !== 'development') {
 environment = NODE_ENV
}

// Configurations by environment
const config: IConfig = {
 ...common,
 ...(environment === 'local' ? local : production)
}

// Environments validations
export const isLocal = () => environment === 'local'
export const isProduction = () => environment === 'production'

export default config

现在,我们需要创建一个名为middleware的用户和jwt函数,以验证用户是否已连接并具有正确的权限。

创建用户中间件

中间件是一个函数,可以访问请求对象(req)、响应对象(res)和应用程序请求-响应周期中的下一个函数。当调用时,next 函数是 Express 路由中的一个函数,执行当前中间件后继的中间件。以下图表描述了中间件流程:

在我们的情况下,我们将创建isConnected中间件,以验证用户是否已连接并具有正确的权限。如果没有,我们将中断流程并将其重定向到登录页面。如果用户有效,我们将执行下一个中间件,这将呈现我们的 React 应用程序。

以下图表描述了这个过程:

让我们将理论部分应用到我们的代码中。所需的文件应该存在于/frontend/src/lib/middlewares/user.ts中:

// Dependencies
import { Request, Response, NextFunction } from 'express'

// Lib
import { getUserData } from '../jwt'

export const isConnected = (isLogged = true, privileges = ['user'], redirectTo = '/') => async (
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> => {
  // Getting the user information by passing our 'at' cookie
  const user = await getUserData(req.cookies.at)

  if (!user && !isLogged) {
    // This is to allow No connected users
    return next()
  }

  // Allowing just connected users and validating privileges...
  if (user && isLogged) {
    // If the user is connected and is god...
    if (privileges.includes('god') && user.privilege === 'god') {
      return next()
    }

    // If the user is conencted and is admin...
    if (privileges.includes('admin') && user.privilege === 'admin') {
      return next()
    }

    // If the user is connected but is not god or admin.
    res.redirect(redirectTo)
  } else {
    // If the user is not connected
    res.redirect(redirectTo)
  }
}

基本上,通过这个中间件,我们可以控制是否要验证用户是否连接(isLogged = true)。然后,我们可以验证特定的特权(privileges = ['god', 'admin'])并重定向用户,如果他们没有连接或没有正确的特权(redirectTo = '/')。

正如你所看到的,我们正在使用jwt中的getUserData函数。我们将在下一节中创建我们的jwt函数。

创建 JWT 函数

在前面的部分,当我解释后端代码时,我谈到了 JWT。在前端,我们需要这些函数来验证我们的令牌并获取用户的数据。让我们在/frontend/src/lib/jwt.ts中创建一个包含以下代码的文件:

// Dependencies
import jwt from 'jsonwebtoken'
import { getBase64 } from '@contentpi/lib'

// Configuration
import config from '../config'

// Getting our secretKey
const {
  security: { secretKey }
} = config

export function jwtVerify(accessToken: any, cb: any): void {
  // Validating our accessToken
  jwt.verify(accessToken, secretKey, (error: any, accessTokenData: any = 
   {}) => {
    const { data: user } = accessTokenData

    // If we got an error or the user is not connected we return false
    if (error || !user) {
      return cb(false)
    }

    // Getting the user data
    const userData = getBase64(user)

    return cb(userData)
  })
}

export async function getUserData(accessToken: any): Promise<any> {
  // This is an async function to retrieve the user data from the 
  // jwtVerify function
  const UserPromise = new Promise(resolve => jwtVerify(accessToken, (user: 
   any) => resolve(user)))

  const user = await UserPromise

  return user
}

正如你所看到的,我们的getUserData函数将使用从 cookies 中获取的accessToken来检索用户数据。JWT 的有效性非常重要。

创建我们的 GraphQL 查询和变异

我们已经在后端项目中创建了所需的查询和变异。在这一点上,我们需要创建一些文件来在前端项目中执行它们。现在,我们只需要定义我们的getUserData查询和我们的登录变异。

让我们在/frontend/src/graphql/user/getUserData.query.ts中创建我们的getUserData查询:

// Dependencies
import { gql } from '@apollo/client'

export default gql`
  query getUserData($at: String!) {
    getUserData(at: $at) {
      id
      email
      username
      privilege
      active
    }
  }
`

我们的登录变异应该在/frontend/src/graphql/user/login.mutation.ts中。

// Dependencies
import { gql } from '@apollo/client'

export default gql`
  mutation login($email: String!, $password: String!) {
    login(input: { email: $email, password: $password }) {
      token
    }
  }
`

现在我们已经定义了我们的查询和变异,让我们创建用户上下文,以便我们可以使用它们。

创建我们的用户上下文来处理登录和连接的用户

在我们的用户上下文中,我们将有一个登录方法,将执行我们的变异,并验证电子邮件和密码是否正确。我们还将导出用户数据。

让我们在/frontend/src/contexts/user.tsx中创建这个上下文:

// Dependencies
import { FC, createContext, ReactElement, useState, useEffect } from 'react'
import { useCookies } from 'react-cookie'
import { getGraphQlError, redirectTo, getDebug } from '@contentpi/lib'
import { useQuery, useMutation } from '@apollo/client'

// Mutations
import LOGIN_MUTATION from '../graphql/user/login.mutation'

// Queries
import GET_USER_DATA_QUERY from '../graphql/user/getUserData.query'

// 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 }): ReactElement => {
  const [cookies, setCookie] = useCookies()
  const [connectedUser, setConnectedUser] = useState(null)

  // Mutations
  const [loginMutation] = useMutation(LOGIN_MUTATION)

  // Queries
  const { data: dataUser } = useQuery(GET_USER_DATA_QUERY, {
    variables: {
      at: cookies.at || ''
    }
  })

  // Effects
  useEffect(() => {
    if (dataUser) {
      if (!dataUser.getUserData.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.getUserData)
      }
    }
  }, [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_DATA_QUERY来验证用户是否连接(验证数据库而不仅仅是使用 cookies)。

配置我们的 Apollo 客户端

到目前为止,我们已经创建了很多代码,但如果我们不配置我们的 Apollo 客户端,它们中的任何一个都不会起作用。要配置它,我们需要将它添加到我们的索引文件中/frontend/src/index.tsx

// Dependencies
import { render } from 'react-dom'

// Apollo
import { ApolloProvider, ApolloClient, InMemoryCache } from '@apollo/client';

// Components
import AppRoutes from './AppRoutes'

// Config
import config from './config'

// Apollo Client configuration
const client = new ApolloClient({
  uri: config.apiUrl,
  cache: new InMemoryCache()
});

render(
  <ApolloProvider client={client}>
    <AppRoutes />
 </ApolloProvider>
, document.querySelector('#root'))

基本上,我们正在传递config.apiUrl,这是 GraphQL Playground 正在运行的地方(http://localhost:5000/graphql),然后用ApolloProvider组件包装我们的AppRoutes组件。

创建我们的应用程序路由

我们将使用react-router-dom来创建我们的应用程序路由。让我们在/frontend/src/AppRoutes.tsx中创建所需的代码:

// Dependencies
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'

// Components
import HomePage from './pages/home'
import DashboardPage from './pages/dashboard'
import LoginPage from './pages/login'
import Error404 from './pages/error404'

const AppRoutes = () => (
  <Router>
 <Switch>
 <Route path="/" component={HomePage} exact />
      <Route path="/dashboard" component={DashboardPage} exact />
      <Route path="/login" component={LoginPage} exact />
      <Route component={Error404} />
 </Switch>
 </Router>
)pag

export default AppRoutes

正如您所看到的,我们正在为我们的路由添加一些页面,比如HomePageDashboardPage(受保护),和LoginPage。如果用户尝试访问不同的 URL,那么我们将显示一个Error404组件。我们将在下一节创建这些页面。

创建我们的页面

Home页面应该位于/frontend/src/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/pages/dashboard.tsx

// Components
import DashboardLayout from '../components/dashboard/DashboardLayout'

// Contexts
import UserProvider from '../contexts/user'

const Page = () => (
  <UserProvider>
 <DashboardLayout />
 </UserProvider>
)

export default Page

Login页面应该位于/frontend/src/pages/login.tsx

// Dependencies
import { FC, ReactElement } from 'react'
import { isBrowser } from '@contentpi/lib'

// Contexts
import UserProvider from '../contexts/user'

// Components
import LoginLayout from '../components/users/LoginLayout'

interface IProps {
  currentUrl: string
}

const Page: FC<IProps> = ({
  currentUrl = isBrowser() ? window.location.search.replace
    ('?redirectTo=', '') :''}): ReactElement => (
  <UserProvider page="login">
 <LoginLayout currentUrl={currentUrl} />
  </UserProvider>
)

export default Page

最后,我们需要创建我们的Error404页面(/frontend/src/pages/error404.tsx):

const Page = () => (
  <div className="error404">
 <h1>Error404</h1>
 </div>
)

export default Page

我们快要完成了。这个谜题的最后一块是创建LoginDashboard组件。我们将在下一节完成。

创建我们的登录组件

我为我们的登录和仪表板创建了一些基本组件。当然,它们的样式可以改进,但让我们看看它们是如何工作的,以及我们的登录系统将会是什么样子。

您需要创建的第一个文件是LoginLayout.tsx,位于/frontend/src/components/users/LoginLayout.tsx

// Dependencies
import { redirectTo } from '@contentpi/lib'
import { FC, ReactElement, useContext, useEffect } from 'react'

// Contexts
import { UserContext } from '../../contexts/user'

// Components
import Login from './Login'

// Interfaces
interface IProps {
  currentUrl: string
}

const Layout: FC<IProps> = ({ currentUrl }): ReactElement => {
  const { login } = useContext(UserContext)

  return (
    <Login login={login} currentUrl={currentUrl} />
  )
}

export default Layout

布局文件很好,当我们想要为我们的组件添加特定的布局时。它也很适合从上下文中消费数据并将数据或函数作为 props 传递。

我们的Login组件应该像这样(/frontend/src/components/users/Login.tsx):

// Dependencies
import { FC, ReactElement, useState, ChangeEvent } from 'react'
import { redirectTo } from '@contentpi/lib'

// Interfaces
import { IUser } from '../../types'

// Styles
import { StyledLogin } from './Login.styled'

interface IProps {
  login(input: any): any
  currentUrl: string
}

const Login: FC<IProps> = ({ login, currentUrl }) => {
  // States
  const [values, setValues] = useState({
    email: '',
    password: ''
  })
  const [errorMessage, setErrorMessage] = useState('')
  const [invalidLogin, setInvalidLogin] = useState(false)

  // Methods
  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) {
      // If the login is invalid...
      setInvalidLogin(true)
      setErrorMessage(response.message)
    } else {
      // If the login is correct...
      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={(): Promise<void> => 
 handleSubmit(values)}>
                Login
              </button>
            </div>
          </div>
        </div>
      </StyledLogin>
    </>
  )
}

export default Login

我们将在下一节创建Dashboard组件。

创建我们的仪表板组件

现在,让我们创建我们的Dashboard组件。第一个应该是DashboardLayout.tsx文件,位于/frontend/src/components/dashboard/DashboardLayout.tsx

// Dependencies
import { FC, ReactElement, useContext } from 'react'

// Contexts
import { UserContext } from '../../contexts/user'

// Components
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)。

  • 在你的前端项目中的第二个中,你需要构建你的项目 (npm run build)。

  • 在最后一个中,你需要在前端项目中运行节点服务器 (npm run dev)。

当你第一次打开 http://localhost:3000 时,你应该能够看到主页:

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

这是我们的登录表单。如果你尝试用一些虚假的凭据登录,你应该会收到一个错误:

如果你想查看 GraphQL 请求,你可以在 Chrome 网络选项卡上这样做:

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

正如你所看到的,我们收到了一个 "无效登录" 的错误消息,这就是为什么我们在我们的 Login 组件中呈现它。现在,让我们尝试用正确的帐户连接 (admin@js.education / 123456)。

如果你的登录是正确的,那么你应该被重定向到仪表板,你将看到以下页面:

此外,你可以看一下正在执行以检索用户数据的查询 (getUserData):

在这里,你将看到返回的有效负载:

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

现在,让我们尝试通过改变令牌的任意字母来修改 cookie。例如,让我们把前两个字母 (ey) 改成 XX

在这里,你将收到用户的空数据。这将使会话失效并再次将你重定向到登录页面:

到目前为止,你已经学会了如何在后端实现 GraphQL 以及如何在前端消耗查询和变异。

这个登录系统是我在 YouTube 上做的一个课程的一部分,我在课程中教观众如何从头开始开发一个无头 CMS,所以如果你渴望学到更多,可以在www.youtube.com/watch?v=4n1AfD6aV4M上查看课程。

总结

我真的希望你喜欢阅读这一章,其中包含了关于 GraphQL 以及如何创建 JWT、执行登录和使用 Sequelize 创建模型的大量信息。

现在是时候谈谈数据获取和单向数据流了,这是我们将在下一章中讨论的内容。

第六章:管理数据

适当的数据获取经历了一些最常见的模式,以使子代和父代使用回调进行通信。我们将学习如何使用一个共同的父代来在不直接连接的组件之间共享数据。然后我们将开始学习新的 React 上下文 API 和 React Suspense。

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

  • React 上下文 API

  • 如何使用 useContext 消耗上下文

  • 如何使用 React Suspense 与 SWR

技术要求

要完成本章,您将需要以下内容:

  • Node.js 12+

  • Visual Studio Code

您可以在书的 GitHub 存储库中找到本章的代码:github.com/PacktPublishing/React-17-Design-Patterns-and-Best-Practices-Third-Edition/tree/main/Chapter06

介绍 React 上下文 API

自版本 16.3.0 以来,React 上下文 API 已正式添加;在此之前,它只是实验性的。新的上下文 API 是一个改变游戏规则的东西。许多人正在摆脱 Redux,以使用新的上下文 API。上下文提供了一种在组件之间共享数据的方法,而无需将 prop 传递给所有子组件。

让我们看一个基本示例,我们可以在其中使用新的上下文 API。我们将在第三章 React Hooks中做相同的示例,我们在那里获取了一些 GitHub 问题,但现在使用上下文 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执行获取,然后我们渲染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中,然后当我们渲染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组件内部,我们可以使用我们的上下文并获取问题的值。

有时候很多人会感到困惑。如果您忘记用提供者包装您的组件,那么您就无法在组件内部使用您的上下文,而困难的部分是您可能不会得到任何错误;您只会得到一些未定义的数据,这使得很难识别。

使用 useContext 消费上下文

如果您已经在App.tsx中放置了IssueProvider,现在您可以通过使用useContext Hook 在Issues组件中消费您的上下文。

请注意,这里我们正在导入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

如果你做得正确,你应该能够看到问题列表:

当您想要将应用程序与数据分离并在其中执行所有获取操作时,上下文 API 非常有用。当然,上下文 API 有多种用途,也可以用于主题设置或传递函数;这完全取决于您的应用程序。

在下一节中,我们将学习如何使用 SWR 库实现 React Suspense。

介绍 React Suspense 与 SWR

React Suspense 是在 React 16.6 中引入的。现在(2021 年 4 月)这个功能仍然是实验性的,你不应该在生产应用程序中使用它。Suspense 允许您暂停组件渲染直到满足条件。您可以渲染一个加载组件或任何您想要的作为 Suspense 的回退。目前只有两种用例:

  • 代码拆分:当您拆分应用程序并等待在用户要访问时下载应用程序的一部分时

  • 数据获取:当您获取数据时

在这两种情况下,您可以呈现一个回退,通常可以是加载旋转器、一些加载文本,甚至更好的是占位符骨架。

警告:新的 React Suspense 功能仍处于实验阶段,因此我建议您不要在生产环境中使用它,因为它尚未在稳定版本中可用。

介绍 SWR

过时-同时重新验证SWR)是用于数据获取的 React Hook;它是一种 HTTP 缓存失效策略。SWR 是一种策略,首先从缓存中返回数据(过时),然后发送获取请求(重新验证),最后返回最新的数据,并由创建 Next.js 的公司 Vercel 开发。

构建宝可梦图鉴!

我找不到一个更好的例子来解释 React Suspense 和 SWR,比构建宝可梦图鉴更好的例子。我们将使用一个公共的宝可梦 API(pokeapi.co);* gotta catch 'em all *!

您需要做的第一件事是安装一些软件包:

npm install swr react-loading-skeleton styled-components

对于这个例子,您需要在src/components/Pokemon目录下创建 Pokemon 目录。我们需要做的第一件事是创建一个 fetcher 文件,我们将在其中执行我们的请求,以便使用 SWR。

此文件应创建在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

如果您注意到,如果响应不成功,我们将返回一个带有错误的对象。这是因为有时我们可以从 API 获取 404 错误,这可能导致应用程序崩溃。

创建了 fetcher 文件后,让我们修改App.tsx以配置SWRConfig并启用 Suspense:

// Dependencies
import { SWRConfig } from 'swr'

// Components
import PokeContainer from './Pokemon/PokeContainer'
import fetcher from './Pokemon/fetcher'

// Styles
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 定义了一个回退,即加载宝可梦图鉴...文本。您可以在其中呈现任何您想要的东西,React 组件或纯文本。然后,我们在 Suspense 中有我们的Pokedex组件。

现在让我们看看我们的Pokedex组件,我们将首次使用useSWR Hook 来获取数据:

// Dependencies
import { FC, Suspense } from 'react'
import useSWR from 'swr'

// Components
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 />回退(<StyledGrid />有一些 CSS 样式,使其看起来更漂亮),最后,我们将pokemonName传递给我们的<Pokemon>组件,这是因为第一次获取只带来了宝可梦的名字,但我们需要再次获取实际的宝可梦数据(名字、类型、力量等)。

然后,最后,我们的宝可梦组件将通过宝可梦的名字执行特定的获取并渲染数据:

// Dependencies
import { FC } from 'react'
import useSWR from 'swr'

// Styles
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

基本上,在这个组件中,我们汇总了所有的宝可梦数据(idnamespritestypes),然后渲染信息。正如你所看到的,我正在使用styled组件,这太棒了,所以如果你想知道我为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 悬念

一旦你的代码所有部分都运行正常,有一个技巧可以让你看到所有的悬念回退。通常,如果你有高速连接,很难看到它,但你可以减慢你的连接速度,看看所有东西是如何被渲染的。你可以在 Chrome 检查器的网络选项卡中选择慢速 3G 连接来做到这一点。

一旦你设置了慢速 3G 预设,并运行你的项目,你将看到的第一个回退是 Loading Pokedex...:

然后,你将看到正在渲染的宝可梦回退,为每个正在加载的宝可梦渲染SkeletonLoading

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

如果你等到所有数据都正确下载了,你现在应该可以看到有所有宝可梦的宝可梦图鉴了:

挺不错的,对吧?但还有一件事要提一下;就像我之前提到的,SWR 会首先从缓存中获取数据,然后会一直重新验证数据,看看是否有新的更新。这意味着每当数据发生变化时,SWR 都会执行另一个获取操作,以重新验证旧数据是否仍然有效,或者需要被新数据替换。即使你从宝可梦图鉴标签移出去然后再回来,你也会看到效果。你会发现你的网络终端第一次应该是这样的:

正如你所看到的,我们执行了 151 个初始请求(1 个用于宝可梦列表,另外 150 个,每个宝可梦一个),但如果你切换标签然后再回来,你会看到 SWR 再次获取数据:

现在你可以看到它正在执行 302 个请求(另外 151 个)。当你有实时数据想要每秒或每分钟获取时,这非常有用。

目前,React Suspense 还没有一个明确定义的使用模式,这意味着你可以找到不同的使用方式,目前还没有一些良好的实践方法。我发现 SWR 是使用 React Suspense 最简单和最容易理解的方式,我认为它是一个非常强大的库,甚至可以在没有 Suspense 的情况下使用。

总结

我真的希望你喜欢阅读这一章,其中包含了关于 React Context API 以及如何使用 SWR 实现 React Suspense 的大量信息。

在下一章中,我们将学习如何处理表单和动画。

第七章:为浏览器编写代码

在使用 React 和浏览器时,我们可以进行一些特定的操作。例如,我们可以要求用户使用表单输入一些信息,在本章中,我们将看看如何应用不同的技术来处理表单。我们可以实现不受控制的组件,让字段保持其内部状态,或者我们可以使用受控组件,在这种情况下,我们完全控制字段的状态。

在本章中,我们还将看看 React 中的事件是如何工作的,以及该库如何实现一些高级技术,为我们提供一个在不同浏览器中具有一致接口的解决方案。我们将看看 React 团队实现的一些有趣的解决方案,使事件系统非常高效。

在事件之后,我们将跳转到 refs,看看我们如何在 React 组件中访问底层 DOM 节点。这代表了一个强大的功能,但应该谨慎使用,因为它会破坏一些使 React 易于使用的约定。

在 refs 之后,我们将看看如何使用 React 附加组件和第三方库(如react-motion)轻松实现动画。最后,我们将学习在 React 中使用可伸缩矢量图形SVG)有多么容易,以及如何为我们的应用程序创建动态可配置的图标。

在本章中,我们将介绍以下主题:

  • 使用不同的技术在 React 中创建表单

  • 监听 DOM 事件并实现自定义处理程序

  • 使用 refs 在 DOM 节点上执行命令式操作的一种方式

  • 创建在不同浏览器中都有效的简单动画

  • 生成 SVG 的 React 方式

技术要求

要完成本章,您将需要以下内容:

  • Node.js 12+

  • Visual Studio Code

您可以在书的 GitHub 存储库中找到本章的代码:github.com/PacktPublishing/React-17-Design-Patterns-and-Best-Practices-Third-Edition/tree/main/Chapter07

理解并实现表单

在本章中,我们将学习如何使用 React 实现表单。一旦我们开始用 React 构建一个真正的应用程序,我们就需要与用户进行交互。如果我们想在浏览器中向用户询问信息,表单是最常见的解决方案。由于库的工作方式和其声明性的特性,使用 React 处理输入字段和其他表单元素是非常复杂的,但一旦我们理解了它的逻辑,就会变得清晰。在接下来的章节中,我们将学习如何使用不受控制和受控组件。

不受控制的组件

不受控制的组件就像常规的 HTML 表单输入,你将无法自己管理值,而是 DOM 会处理值,并且你可以使用 React ref 来获取这个值。让我们从一个基本的例子开始——显示一个带有输入字段和提交按钮的表单。

代码非常简单:

import { useState, ChangeEvent, MouseEvent } from 'react' const Uncontrolled = () => {
  const [value, setValue] = useState('')

  return (
    <form> 
<input type="text" /> 
      <button>Submit</button> 
 </form>  ) 
}

export default Uncontrolled

如果我们在浏览器中运行前面的片段,我们将看到完全符合预期的结果——一个输入字段,我们可以在其中输入一些内容,以及一个可点击的按钮。这是一个不受控制的组件的例子,我们不设置输入字段的值,而是让组件管理自己的内部状态。

很可能,我们希望在单击提交按钮时对元素的值做一些操作。例如,我们可能希望将数据发送到 API 端点。

我们可以通过添加一个onChange监听器来轻松实现这一点(我们将在本章后面更多地讨论事件监听器)。让我们看看添加监听器意味着什么。

我们需要创建handleChange函数:

const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
  console.log(e.target.value)
}

事件监听器接收到一个事件对象,其中target表示生成事件的字段,我们对其值感兴趣。我们首先只是记录它,因为逐步进行很重要,但很快我们将把值存储到状态中。

最后,我们渲染表单:

return (
  <form> 
 <input type="text" onChange={handleChange} /> 
    <button>Submit</button> 
 </form> 
)

如果我们在浏览器中渲染组件并在表单字段中输入React这个词,我们将在控制台中看到类似以下的内容:

R
Re
Rea
Reac
React

handleChange监听器在输入值改变时被触发。因此,我们的函数每输入一个字符就会被调用一次。下一步是存储用户输入的值,并在用户单击提交按钮时使其可用。

我们只需要改变处理程序的实现方式,将其存储在状态中而不是记录下来,如下所示:

const handleChange = (e: ChangeEvent<HTMLInputElement>) => { 
  setValue(e.target.value)
}

得知表单何时提交与监听输入字段的更改事件非常相似;它们都是在发生某些事件时由浏览器调用的。

让我们定义handleSubmit函数,我们只是记录这个值。在现实世界的场景中,你可以将数据发送到 API 端点或将其传递给另一个组件:

const handleSubmit = (e: MouseEvent<HTMLButtonElement>) => { 
  e.preventDefault()

  console.log(value)
}

这个处理程序非常简单;我们只是记录当前存储在状态中的值。我们还希望克服浏览器在提交表单时的默认行为,以执行自定义操作。这似乎是合理的,并且对于单个字段来说效果很好。现在的问题是,如果我们有多个字段怎么办?假设我们有数十个不同的字段?

让我们从一个基本的例子开始,手动创建每个字段和处理程序,并看看如何通过应用不同级别的优化来改进它。

让我们创建一个新的表单,包括名字和姓氏字段。我们可以重用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}`)
}

最后,我们渲染表单:

return ( 
  <form onSubmit={handleSubmit}> 
    <input type="text" onChange={handleChangeFirstName} /> 
    <input type="text" onChange={handleChangeLastName} /> 
    <button>Submit</button> 
  </form> 
)

我们已经准备好了:如果我们在浏览器中运行前面的组件,我们将看到两个字段,如果我们在第一个字段中输入Carlos,在第二个字段中输入Santana,当表单提交时,我们将在浏览器控制台中看到全名显示出来。

同样,这样做是可以的,我们可以以这种方式做一些有趣的事情,但它不能处理复杂的场景,而不需要我们编写大量的样板代码。

让我们看看如何优化一下。我们的目标是使用一个单一的 change 处理程序,这样我们就可以添加任意数量的字段而不需要创建新的监听器。

让我们回到组件,让我们改变我们的状态:

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}`)
}

实际上,我们将使用输入字段的value属性来设置它们的初始值,以及更新后的值:

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 加上驼峰命名的事件名称(例如 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)
  } 
}

通用事件处理程序接收事件对象并根据事件类型触发正确的操作。如果我们想在每个事件上调用一个函数(例如,分析),或者如果一些事件共享相同的逻辑,这将特别有用。

最后,我们将新的事件监听器附加到onClickonDoubleClick属性上:

return ( 
  <button 
    onClick={handleEvent} 
    onDoubleClick={handleEvent} 
  > 
    Click me! 
  </button> 
) 

从这一点开始,每当我们需要为同一组件创建一个新的事件处理程序时,我们可以只需添加一个新的情况到 switch,而不是创建一个新的方法并绑定它。

关于 React 中事件的另外一些有趣的事情是,合成事件是可重用的,并且存在单个全局处理程序。第一个概念意味着我们不能存储合成事件并在以后重用它,因为它在操作后立即变为 null。这种技术在性能方面非常好,但如果我们想出于某种原因将事件存储在组件状态中,可能会有问题。为了解决这个问题,React 在合成事件上给了我们一个persist方法,我们可以调用它使事件持久化,这样我们就可以存储并在以后检索它。

第二个非常有趣的实现细节再次涉及性能,它与 React 将事件处理程序附加到 DOM 的方式有关。

每当我们使用on属性时,我们正在描述我们想要实现的行为,但是库并没有将实际的事件处理程序附加到底层 DOM 节点上。

它所做的是将单个事件处理程序附加到根元素,通过事件冒泡监听所有事件。当我们感兴趣的事件被浏览器触发时,React 代表其调用特定组件上的处理程序。这种技术称为事件委托,用于内存和速度优化。

在我们的下一节中,我们将探索 React 引用并看看我们如何利用它们。

探索引用

人们喜欢 React 的一个原因是它是声明式的。声明式意味着你只需描述你想在屏幕上显示的内容,React 就会处理与浏览器的通信。这个特性使得 React 非常容易理解,同时也非常强大。

然而,可能会有一些情况需要访问底层的 DOM 节点来执行一些命令式操作。这应该被避免,因为在大多数情况下,有更符合 React 的解决方案来实现相同的结果,但重要的是要知道我们有这个选项,并知道它是如何工作的,以便我们能做出正确的决定。

假设我们想创建一个简单的表单,其中包含一个输入元素和一个按钮,当点击按钮时,输入字段获得焦点。我们想要做的是在浏览器窗口内调用输入节点的 focus 方法,即输入的实际 DOM 实例。

让我们创建一个名为 Focus 的组件;你需要导入 useRef 并创建一个 inputRef 常量:

import { useRef } from 'react'
 const Focus = () => {
  const inputRef = useRef(null)
}

export default Focus

然后,我们实现 handleClick 方法:

const handleClick = () => { 
  inputRef.current.focus()
} 

正如你所看到的,我们正在引用 inputRefcurrent 属性,并调用它的 focus 方法。

要理解它来自哪里,你只需检查 render 的实现。

return ( 
  <> 
    <input 
      type="text" 
      ref={inputRef} 
    /> 
    <button onClick={handleClick}>Set Focus</button> 
  </> 
)

这里是逻辑的核心。我们创建了一个带有输入元素的表单,并在其 ref 属性上定义了一个函数。

我们定义的回调函数在组件挂载时被调用,元素参数表示输入的 DOM 实例。重要的是要知道,当组件被卸载时,相同的回调会以 null 参数被调用以释放内存。

在回调中我们所做的是存储元素的引用,以便将来使用(例如,当触发 handleClick 方法时)。然后,我们有一个带有事件处理程序的按钮。在浏览器中运行上述代码将显示带有字段和按钮的表单,并且点击按钮将聚焦输入字段,如预期的那样。

正如我们之前提到的,一般来说,我们应该尽量避免使用 refs,因为它们会使代码更加命令式,变得更难阅读和维护。

实现动画

当我们考虑 UI 和浏览器时,我们一定也要考虑动画。动画化的 UI 对用户更加愉悦,它们是向用户展示发生了或即将发生的事情的非常重要的工具。

本节不旨在成为创建动画和美观 UI 的详尽指南;这里的目标是为您提供一些关于我们可以采用的常见解决方案的基本信息,以便为我们的 React 组件添加动画。

对于 React 这样的 UI 库,提供一种简单的方式让开发人员创建和管理动画是至关重要的。React 自带一个名为 react-addons-css-transition-group 的附加组件,它是一个帮助我们以声明方式构建动画的组件。再次,能够以声明方式执行操作是非常强大的,它使代码更容易理解并与团队共享。

让我们看看如何使用 React 附加组件对文本应用简单的淡入效果,然后我们将使用 react-motion 执行相同的操作,这是一个使创建复杂动画更容易的第三方库。

要开始构建一个动画组件,我们需要做的第一件事是安装这个附加组件:

npm install --save react-addons-css-transition-group @types/react-addons-css-transition-group

一旦我们完成了这个操作,我们就可以导入该组件:

import CSSTransitionGroup from 'react-addons-css-transition-group'

然后,我们只需包装我们想要应用动画的组件:

const Transition = () => ( 
  <CSSTransitionGroup 
    transitionName="fade" 
    transitionAppear 
    transitionAppearTimeout={500} 
  > 
    <h1>Hello React</h1> 
  </CSSTransitionGroup> 
)

正如你所看到的,有一些需要解释的属性。

首先,我们声明了 transitionName 属性。ReactCSSTransitionGroup 将该属性的名称应用到子元素的类中,以便我们可以使用 CSS 过渡来创建我们的动画。

使用单个类,我们无法轻松创建适当的动画,这就是为什么过渡组件根据动画状态应用多个类。在这种情况下,使用 transitionAppear 属性,我们告诉组件我们希望在屏幕上出现时对子元素进行动画处理。

因此,图书馆所做的是在组件被渲染时立即应用 fade-appear 类(其中 fadetransitionName 属性的值)。在下一个时刻,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; 
}

我们正在使用ease-in函数在500ms内将不透明度从0.01过渡到1。这很容易,但我们可以创建更复杂的动画,我们也可以动画化组件的不同状态。例如,当新元素作为过渡组的子元素添加时,*-enter*-enter-active类会被应用。类似的情况也适用于删除元素。

在我们的下一节中,我们将查看在 React 中创建动画最流行的库:react-motion,这个库由 Cheng Lou 维护。它提供了一个非常干净和易于使用的 API,为我们提供了一个非常强大的工具来创建任何动画。

React Motion

React Motion是一个用于 React 应用程序的动画库,它使得创建和实现逼真动画变得容易。一旦动画的复杂性增加,或者当我们需要依赖其他动画的动画,或者当我们需要将一些基于物理的行为应用到我们的组件上(这是一个更高级的技术),我们会意识到过渡组并不能帮助我们足够,所以我们可能会考虑使用第三方库。

要使用它,我们首先必须安装它:

npm install --save react-motion @types/react-motion

安装成功后,我们需要导入Motion组件和spring函数。Motion是我们将用来包装我们想要动画的元素的组件,而函数是一个实用工具,可以将一个值从其初始状态插值到最终状态:

import { Motion, spring } from 'react-motion'

让我们看看代码:

const Transition = () => ( 
  <Motion 
    defaultStyle={{ opacity: 0.01 }} 
    style={{ opacity: spring(1) }} 
  > 
    {interpolatingStyle => ( 
      <h1 style={interpolatingStyle}>Hello React</h1> 
    )} 
  </Motion> 
)

这里有很多有趣的东西。首先,您可能已经注意到这个组件使用了函数作为子模式(参见第四章,探索流行的组合模式),这是一种非常强大的技术,用于定义在运行时接收值的子元素。

然后,我们可以看到Motion组件有两个属性:第一个是defaultStyle,它表示初始的style属性。同样,我们将不透明度设置为0.01来隐藏元素并开始淡入。

style属性代表最终的样式,但我们不直接设置值;相反,我们使用spring函数,使得值从初始状态插值到最终状态。

spring函数的每次迭代中,子函数接收给定时间点的插值样式,只需将接收到的对象应用到组件的style属性,我们就可以看到不透明度的过渡。

这个库可以做一些更酷的事情,但首先要了解的是基本概念,这个例子应该能澄清它们。

比较过渡组和react-motion的两种不同方法也很有趣,以便能够选择适合你正在工作的项目的正确方法。

最后,在下一节中,我们将看到如何在 React 中使用 SVG。

探索 SVG

最后但同样重要的是,我们可以在浏览器中应用一种最有趣的技术来绘制图标和图形,那就是可缩放矢量图形SVG)。

SVG 很棒,因为它是一种描述矢量的声明性方式,它与 React 的目的完全契合。我们过去常常使用图标字体来创建图标,但它们有众所周知的问题,首先是它们不可访问。用 CSS 定位图标字体也相当困难,它们在所有浏览器中并不总是看起来美观。这就是我们应该为我们的 Web 应用程序更喜欢 SVG 的原因。

从 React 的角度来看,无论我们从render方法中输出div还是 SVG 元素,都没有任何区别,这就是它如此强大的原因。我们也倾向于选择 SVG,因为我们可以很容易地使用 CSS 和 JavaScript 在运行时修改它们,这使它们成为 React 功能方法的绝佳选择。

因此,如果我们将组件视为其 props 的函数,我们可以很容易地想象如何创建可以通过传递不同 props 来操作的自包含 SVG 图标。在 React 中创建 SVG 的常见方法是将我们的矢量图包装到一个 React 组件中,并使用 props 来定义它们的动态值。

让我们看一个简单的例子,我们画一个蓝色的圆,从而创建一个包装 SVG 元素的 React 组件:

const Circle = ({ x, y, radius, fill }) => ( 
  <svg> 
 <circle cx={x} cy={y} r={radius} fill={fill} /> 
  </svg> 
)

正如你所看到的,我们可以很容易地使用一个无状态的函数组件来包装 SVG 标记,它接受与 SVG 相同的 props。

一个示例用法如下:

<Circle x={20} y={20} radius={20} fill="blue" /> 

显然,我们可以充分利用 React 的功能,并设置一些默认参数,以便如果圆形图标在没有 props 的情况下呈现,我们仍然可以显示一些东西。

例如,我们可以定义默认颜色:

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" /> 
)

在这里,颜色是默认设置的,不能更改,而其他 props 会透明地传递给原始圆。

以下截图显示了由 React 使用 SVG 生成的两个圆,蓝色和红色:

我们可以应用这种技术,并创建圆的不同变体,比如SmallCircleRightCircle,以及构建 UI 所需的其他一切。

总结

在本章中,我们看了一下当我们用 React 来针对浏览器时可以做的不同事情,从表单创建到事件,从动画到 SVG。此外,我们学会了如何使用新的useRef Hook。React 为我们提供了一种声明性的方式来管理我们在创建 Web 应用程序时需要处理的所有方面。

如果需要,React 会以一种方式为我们提供对实际 DOM 节点的访问,这意味着我们可以对它们执行命令式操作,这在我们需要将 React 与现有的命令式库集成时非常有用。

下一章将讨论 CSS 和内联样式,它将阐明在 JavaScript 中编写 CSS 意味着什么。

第三部分:性能,改进和生产!

本节将解释如何提高 React 应用程序的性能,如何使用 CSS 模块和styled-components处理样式,最后如何将应用程序部署到生产环境。

我们将在本节中涵盖以下章节:

  • 第八章,让你的组件看起来漂亮

  • 第九章,为了乐趣和利润进行服务器端渲染

  • 第十章,提高应用程序的性能

  • 第十一章,测试和调试

  • 第十二章,React 路由

  • 第十三章,要避免的反模式

  • 第十四章,部署到生产环境

  • 第十五章,下一步

第八章:使您的组件看起来漂亮

我们的 React 最佳实践和设计模式之旅现在已经达到了我们想要让组件看起来漂亮的地步。为了做到这一点,我们将详细介绍为什么常规 CSS 可能不是样式化组件的最佳方法的所有原因,并且我们将了解各种替代解决方案。

从内联样式开始,然后是 Radium、CSS 模块和styled-components,本章将指导您进入 JavaScript 中 CSS 的神奇世界。

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

  • 规模上常见的常规 CSS 问题

  • 在 React 中使用内联样式及其缺点

  • Radium 库如何帮助解决内联样式的问题

  • 如何使用 Webpack 和 CSS 模块从头开始设置项目

  • CSS 模块的特性以及它们为什么是避免全局 CSS 的绝佳解决方案

  • styled-components,一种为 React 组件提供现代样式的新库

技术要求

要完成本章,您将需要以下内容:

  • Node.js 12+

  • Visual Studio Code

您可以在书籍的 GitHub 存储库中找到本章的代码:github.com/PacktPublishing/React-17-Design-Patterns-and-Best-Practices-Third-Edition/tree/main/Chapter08

JavaScript 中的 CSS

在社区中,每个人都同意在 2014 年 11 月,React 组件的样式发生了革命,当时 Christopher Chedea 在 NationJS 会议上发表了演讲。

在互联网上也被称为vjeux,Christopher 在 Facebook 工作并为 React 做出贡献。在他的演讲中,他详细介绍了他们在 Facebook 面临的所有与 CSS 相关的问题。值得理解所有这些问题,因为其中一些问题非常普遍,它们将帮助我们引入内联样式和本地作用域类名等概念。

以下是 CSS 存在的问题清单,基本上是在规模上出现的问题:

  • 全局命名空间

  • 依赖关系

  • 死代码消除

  • 最小化

  • 共享常量

  • 非确定性解决方案

  • 隔离

CSS 的第一个众所周知的问题是所有选择器都是全局的。无论我们如何组织我们的样式,使用命名空间或诸如元素修饰符BEM)方法之类的过程,最终我们总是在污染全局命名空间,我们都知道这是错误的。这不仅在原则上是错误的,而且在大型代码库中会导致许多错误,并且在长期内使可维护性非常困难。与大团队合作,要知道特定类或元素是否已经被样式化是非平凡的,大多数情况下,我们倾向于添加更多类而不是重用现有类。

CSS 的第二个问题涉及依赖关系的定义。事实上,很难清楚地说明特定组件依赖于特定的 CSS,并且必须加载 CSS 才能应用样式。由于样式是全局的,任何文件中的任何样式都可以应用于任何元素,失去控制非常容易。

第三个问题是前端开发人员倾向于使用预处理器来将他们的 CSS 拆分成子模块,但最终,会为浏览器生成一个大的全局 CSS 捆绑包。由于 CSS 代码库很快变得庞大,我们失去了对它们的控制,第三个问题与死代码消除有关。很难迅速确定哪些样式属于哪个组件,这使得删除代码非常困难。事实上,由于 CSS 的级联特性,删除选择器或规则可能会导致浏览器中出现意外结果。

与 CSS 工作的另一个痛点涉及选择器和类名在 CSS 和 JavaScript 应用程序中的缩小。这似乎是一项简单的任务,但实际上并非如此,特别是当类在客户端上应用或连接时;这是第四个问题。

无法缩小和优化类名对性能来说非常糟糕,并且它可能会对 CSS 的大小产生巨大影响。另一个常见的非平凡操作是在样式和客户端应用程序之间共享常量。我们经常需要知道标题的高度,例如,以便重新计算依赖于它的其他元素的位置。

通常,我们使用 JavaScript API 在客户端读取值,但最佳解决方案是共享常量并避免在运行时进行昂贵的计算。这代表了 vjeux 和 Facebook 的其他开发人员试图解决的第五个问题。

第六个问题涉及 CSS 的非确定性解析。实际上,在 CSS 中,顺序很重要,如果 CSS 按需加载,顺序就无法保证,这会导致错误的样式应用于元素。

例如,假设我们想优化请求 CSS 的方式,只有在用户导航到特定页面时才加载与该页面相关的 CSS。如果与最后一个页面相关的 CSS 具有一些规则,这些规则也适用于不同页面的元素,那么最后加载它可能会影响应用程序其余部分的样式。例如,如果用户返回到上一个页面,他们可能会看到一个 UI 略有不同于他们第一次访问时的页面。

控制各种样式、规则和导航路径的各种组合非常困难,但是,能够在需要时加载 CSS 可能会对 Web 应用程序的性能产生关键影响。

最后但同样重要的是,根据 Christopher Chedeau 的说法,CSS 的第七个问题与隔离有关。在 CSS 中,几乎不可能在文件或组件之间实现适当的隔离。选择器是全局的,很容易被覆盖。仅仅通过知道应用于元素的类名就很难预测元素的最终样式,因为样式不是隔离的,应用程序其他部分的其他规则可能会影响不相关的元素。这可以通过使用内联样式来解决。

在接下来的部分中,我们将看看在 React 中使用内联样式意味着什么,以及其优缺点。

理解并实现内联样式

官方的 React 文档建议开发人员使用内联样式来为他们的 React 组件设置样式。这似乎有点奇怪,因为多年来我们都学到了分离关注点很重要,我们不应该混合标记和 CSS。

React 试图通过将关注点的概念从技术的分离转移到组件的分离来改变。当标记、样式和逻辑紧密耦合且一个不能没有另一个而无法工作时,将它们分离到不同的文件中只是一种幻觉。即使它有助于保持项目结构更清洁,但它并没有提供任何真正的好处。

在 React 中,我们组合组件来创建应用程序,其中组件是我们结构的基本单位。我们应该能够在应用程序中移动组件,并且无论它们被渲染在哪里,它们都应该提供相同的逻辑和 UI 结果。

这是为什么在 React 中将样式与组件放在一起,并使用内联样式在元素上应用它们可能是有意义的原因之一。

首先,让我们看一个例子,看看在 React 中使用节点的样式属性来为我们的组件应用样式意味着什么。我们将创建一个带有文本 Click me! 的按钮,并为其应用颜色和背景颜色:

const style = { 
  color: 'palevioletred', 
  backgroundColor: 'papayawhip'
};

const Button = () => <button style={style}>Click me!</button>;

正如你所看到的,使用内联样式在 React 中很容易为元素设置样式。我们只需要创建一个对象,其中属性是 CSS 规则,值是我们在常规 CSS 文件中使用的值。

唯一的区别是,连字符的 CSS 规则必须转换为驼峰命名以符合 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

我们实现了一个简单的变更处理程序,其中我们使用事件的目标属性来检索字段的当前值:

const handleChange = (e: ChangeEvent<HTMLInputElement>) => { 
  setValue(Number(e.target.value))
}

最后,我们渲染number类型的输入文件,这是一个受控组件,因为我们通过使用状态来保持其值更新。它还有一个事件处理程序,每当字段的值改变时就会触发。

最后但并非最不重要的是,我们使用字段的样式属性来设置其font-size值。正如你所看到的,我们使用了 CSS 规则的驼峰命名版本,以遵循 React 的约定:

return ( 
  <input 
    type="number" 
    value={value} 
    onChange={handleChange} 
    style={{ fontSize: value }} 
  /> 
)

渲染前面的组件,我们可以看到一个输入字段,它根据其值更改其字体大小。它的工作方式是,当值改变时,我们将字段的新值存储在状态中。修改状态会强制组件重新渲染,我们使用新的状态值来设置字段的显示值和字体大小;这很简单又很强大。

计算机科学中的每个解决方案都有其缺点,并且总是代表一种权衡。在内联样式的情况下,不幸的是,问题很多。

例如,使用内联样式时,不可能使用伪选择器(例如:hover)和伪元素,如果你正在创建具有交互和动画的 UI,这是一个相当重要的限制。

有一些变通方法,例如,你总是可以创建真实的元素而不是伪元素,但对于伪类,需要使用 JavaScript 来模拟 CSS 行为,这并不理想。

同样适用于媒体查询,无法使用内联样式来定义,这使得创建响应式 Web 应用程序变得更加困难。由于样式是使用 JavaScript 对象声明的,也不可能使用样式回退:

display: -webkit-flex; 
display: flex;

JavaScript 对象不能具有相同名称的两个属性。应该避免使用样式回退,但如果需要,总是可以使用它们。

CSS 的另一个特性是动画,这是无法使用内联样式来模拟的。在这里的解决方法是全局定义动画,并在元素的 style 属性中使用它们。使用内联样式时,每当我们需要用常规 CSS 覆盖样式时,我们总是被迫使用!important关键字,这是一种不好的做法,因为它会阻止任何其他样式被应用到元素上。

使用内联样式最困难的事情是调试。我们倾向于使用类名在浏览器的开发工具中查找元素进行调试,并检查应用了哪些样式。使用内联样式时,所有项目的样式都列在它们的style属性中,这使得检查和调试结果非常困难。

例如,我们在本节早些时候创建的按钮以以下方式呈现:

<button style="color:palevioletred;background-color:papayawhip;">Click me!</button>

单独看起来并不难阅读,但是如果想象一下您有数百个元素和数百种样式,您会意识到问题变得非常复杂。

此外,如果您正在调试一个列表,其中每个项目都具有相同的style属性,并且如果您在浏览器中实时修改其中一个以检查结果,您会发现您只将样式应用于该项目,而不是所有其他兄弟项目,即使它们共享相同的样式。

最后但并非最不重要的是,如果我们在服务器端渲染我们的应用程序(我们将在第九章 为了乐趣和利润而进行服务器端渲染中涵盖此主题),那么使用内联样式时页面的大小会更大。

使用内联样式,我们将所有 CSS 内容放入标记中,这会向发送给客户端的文件添加额外的字节数,并使 Web 应用程序显得更慢。压缩算法可以帮助解决这个问题,因为它们可以轻松压缩相似的模式,并且在某些情况下,加载关键路径 CSS 是一个很好的解决方案;但总的来说,我们应该尽量避免使用内联样式。

事实证明,内联样式带来的问题比它们试图解决的问题更多。因此,社区创建了不同的工具来解决内联样式的问题,但同时保持样式在组件内部或局部,以获得两全其美。

在 Christopher Chedeau 的讲话之后,许多开发人员开始谈论内联样式,并进行了许多解决方案和实验,以找到在 JavaScript 中编写 CSS 的新方法。起初,有两三种解决方案,而今天已经有 40 多种。

在接下来的章节中,我们将介绍最受欢迎的解决方案。

探索 Radium 库

为了解决我们在前一节中遇到的内联样式问题而创建的最早的库之一是Radium。它由 Formidable Labs 的优秀开发人员维护,仍然是最受欢迎的解决方案之一。

在本节中,我们将看看 Radium 是如何工作的,它解决了哪些问题,以及为什么它是与 React 一起用于样式化组件的绝佳库。我们将创建一个非常简单的按钮,类似于本章前面示例中构建的按钮。

我们将从一个没有样式的基本按钮开始,然后添加一些基本样式,以及伪类和媒体查询,以便我们可以了解该库的主要特性。

我们将从以下方式创建按钮开始:

const Button = () => <button>Click me!</button>

首先,我们必须使用npm安装 Radium:

npm install --save radium @types/radium

安装完成后,我们可以导入库并将按钮包装在其中:

import Radium from 'radium'

const Button = () => <button>Click me!</button>

export default Radium(Button)

Radium函数是一个高阶组件HOC)(见第四章探索所有组合模式),它扩展了Button的功能,返回一个新的增强组件。如果我们在浏览器中渲染按钮,目前不会看到任何特别之处,因为我们没有对其应用任何样式。

让我们从一个简单的样式对象开始,我们在其中设置背景颜色、填充、大小和一些其他 CSS 属性。正如我们在前一节中看到的,React 中的内联样式是使用驼峰式 CSS 属性定义的 JavaScript 对象:

const styles = { 
  backgroundColor: '#ff0000', 
  width: 320, 
  padding: 20, 
  borderRadius: 5, 
  border: 'none', 
  outline: 'none'
}

前面的片段与 React 中的普通内联样式没有区别,如果我们将其传递给我们的按钮,我们可以在浏览器中看到应用于按钮的所有样式:

const Button = () => <button style={styles}>Click me!</button>

结果如下标记:

<button data-radium="true" style="background-color: rgb(255, 0, 0); width: 320px; padding: 20px; border-radius: 5px; border: none; outline: none;">Click me!</button>

您可以在这里看到的唯一区别是元素附加了data-radium属性设置为true

现在,我们已经看到内联样式不允许我们定义任何伪类;让我们看看如何使用 Radium 解决这个问题。

使用伪类,比如:hover,与 Radium 一起非常简单。我们必须在样式对象内创建一个:hover属性,Radium 会完成其余工作:

const styles = { 
  backgroundColor: '#ff0000', 
  width: 320, 
  padding: 20, 
  borderRadius: 5, 
  border: 'none', 
  outline: 'none', 
  ':hover': { 
    color: '#fff' 
  } 
}

如果您将这个样式对象应用于您的按钮并在屏幕上呈现它,您会看到将鼠标悬停在按钮上会导致按钮变成白色文本,而不是默认的黑色。这太棒了!我们可以同时使用伪类和内联样式。

然而,如果您打开 DevTools 并尝试在Styles面板中强制:hover状态,您会发现什么也没有发生。您可以看到悬停效果,但无法用 CSS 模拟它的原因是 Radium 使用 JavaScript 来应用和移除style对象中定义的悬停状态。

如果您在打开 DevTools 的情况下悬停在元素上,您会看到style字符串发生变化,并且颜色会动态添加到其中:

<button data-radium="true" style="background-color: rgb(255, 0, 0); width: 320px; padding: 20px; border-radius: 5px; border: none; outline: none; color: rgb(255, 255, 255);">Click me!</button> 

Radium 的工作方式是为可以触发伪类行为的每个事件添加事件处理程序并监听它们。

一旦其中一个事件被触发,Radium 会改变组件的状态,这将重新呈现具有正确状态样式的组件。这一开始可能看起来很奇怪,但这种方法没有真正的缺点,而且在性能方面的差异是不可感知的。

我们可以添加新的伪类,例如:active,它们也会起作用:

const styles = { 
  backgroundColor: '#ff0000', 
  width: 320, 
  padding: 20, 
  borderRadius: 5, 
  border: 'none', 
  outline: 'none', 
  ':hover': { 
    color: '#fff'
  }, 
  ':active': { 
    position: 'relative', 
    top: 2
  } 
}

Radium 启用的另一个关键功能是媒体查询。媒体查询对于创建响应式应用程序至关重要,Radium 再次使用 JavaScript 在我们的应用程序中启用了这个 CSS 特性。

让我们看看它是如何工作的 - API 非常相似;我们必须在我们的样式对象上创建一个新属性,并在其中嵌套必须在媒体查询匹配时应用的样式:

const styles = { 
  backgroundColor: '#ff0000', 
  width: 320, 
  padding: 20, 
  borderRadius: 5, 
  border: 'none', 
  outline: 'none', 
  ':hover': { 
    color: '#fff' 
  }, 
  ':active': { 
    position: 'relative', 
    top: 2
  }, 
  '@media (max-width: 480px)': { 
    width: 160 
  } 
}

我们必须做一件事才能使媒体查询正常工作,那就是将我们的应用程序包装在 Radium 提供的StyleRoot组件中。

为了使媒体查询正常工作,特别是在服务器端渲染中,Radium 将在文档对象模型DOM)中注入与媒体查询相关的规则,所有属性都设置为!important

这是为了避免在库弄清匹配查询之前应用于文档的不同样式之间闪烁。通过在style元素内实现样式,可以通过让浏览器执行其常规工作来防止这种情况。

因此,想法是导入Radium.StyleRoot组件:

import Radium from 'radium'

然后,我们可以将整个应用程序包装在其中:

const App = () => { 
  return ( 
    <Radium.StyleRoot> 
      ... 
    </Radium.StyleRoot> 
  ) 
}

因此,如果您打开 DevTools,您会看到 Radium 将以下样式注入到 DOM 中:

<style>@media (max-width: 480px) { .rmq-1d8d7428{width: 160px !important;}}</style>

rmq-1d8d7428类也已自动应用于按钮:

<button class="rmq-1d8d7428" data-radium="true" style="background-color: rgb(255, 0, 0); width: 320px; padding: 20px; border-radius: 5px; border: none; outline: none;">Click me!</button>

如果您现在调整浏览器窗口大小,您会发现按钮在小屏幕上变小,这是预期的。

在下一节中,我们将学习如何使用 CSS 模块。

使用 CSS 模块

如果您觉得内联样式不适合您的项目和团队,但仍希望将样式尽可能靠近组件,那么有一个适合您的解决方案,称为CSS 模块。CSS 模块是 CSS 文件,默认情况下所有类名和动画名称都是本地作用域的。让我们看看如何在我们的项目中使用它们;但首先,我们需要配置 Webpack。

Webpack 5

在深入研究 CSS 模块并了解它们的工作原理之前,重要的是要了解它们是如何创建的以及支持它们的工具。

第二章 清理您的代码中,我们看到了如何编写 ES6 代码并使用 Babel 及其预设进行转译。随着应用程序的增长,您可能还希望将代码库拆分为模块。

你可以使用 Webpack 或 Browserify 将应用程序分成小模块,需要时可以导入它们,同时为浏览器创建一个大捆绑。这些工具被称为模块捆绑器,它们的作用是将应用程序的所有依赖项加载到一个可以在浏览器中执行的单个捆绑中,浏览器本身没有任何模块的概念(尚未)。

在 React 世界中,Webpack 特别受欢迎,因为它提供了许多有趣和有用的功能,第一个功能是加载器的概念。使用 Webpack,您可以潜在地加载除 JavaScript 以外的任何依赖项,只要有相应的加载器。例如,您可以在捆绑包中加载 JSON 文件,以及图像和其他资产。

2015 年 5 月,CSS 模块的创作者之一 Mark Dalgleish 发现您也可以在 Webpack 捆绑包中导入 CSS,并推动了这一概念。他认为,由于 CSS 可以在组件中本地导入,所有导入的类名也可以本地作用域,这很棒,因为这将隔离样式。

设置项目

在本节中,我们将看看如何设置一个非常简单的 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 来创建捆绑包,我们将使用 Babel 加载器在 Webpack 内部转译我们的 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,这是一个插件,可以通过查看 Webpack 配置来即时创建一个 HTML 页面来托管我们的 JavaScript 应用程序,而无需我们创建一个常规文件。此外,我们需要安装fork-ts-checker-webpack-plugin包来使 TypeScript 与 Webpack 一起工作:

npm install --save-dev html-webpack-plugin fork-ts-checker-webpack-plugin typescript

最后但同样重要的是,我们安装reactreact-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.js的文件,它导出一个对象:

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 文件时该怎么做,并且它使用css-loader和启用modules标志来激活 CSS 模块。转换的结果然后传递给style-loader,它将样式注入到页面的头部。

最后,我们启用 HTML 插件来为我们生成页面,自动使用我们之前指定的入口路径添加script标签:

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.html',
    filename: './index.html'
  })
]

完整的webpack.config.js应该如下代码块所示:

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.html',
      filename: './index.html'
    })
  ],
  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": "es6"
  },
  "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 { render } from 'react-dom'

const App = () => {
  return <div>Hello World</div>
}

render(<App />, document.querySelector('#root'))

最后,您需要在src/index.html中创建初始 HTML 文件:

<!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="/vendor.12472959.js"></script>
    <script defer src="/main.12472959.js"></script> 
  </head> 
 <body>    <div id="root"></div>
  </body> 
</html>

完美-我们的 React 应用程序正在运行!现在让我们看看如何向我们的项目添加一些 CSS。

本地作用域的 CSS

现在,是时候创建我们的应用程序了,它将由一个简单的按钮组成,与我们在以前的示例中使用的相同类型。我们将用它来展示 CSS 模块的所有功能。

让我们更新src/index.tsx文件,这是我们在 Webpack 配置中指定的入口:

import { render } from 'react-dom'

然后,我们可以创建一个简单的按钮。像往常一样,我们将从一个非样式化的按钮开始,然后逐步添加样式:

 const Button = () => <button>Click me!</button>

最后,我们可以将按钮呈现到 DOM 中:

render(<Button />, document.querySelector('#root'))

现在,假设我们想要为按钮应用一些样式-背景颜色,大小等。我们创建一个名为index.css的常规 CSS 文件,并将以下类放入其中:

.button { 
  background-color: #ff0000; 
  width: 320px; 
  padding: 20px; 
  border-radius: 5px; 
  border: none; 
  outline: none; 
}

现在,我们说过使用 CSS 模块可以将 CSS 文件导入到 JavaScript 中;让我们看看它是如何工作的。

在我们定义按钮组件的 index.js 文件中,我们可以添加以下行:

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 模块中,并且当模块标志被激活时,所有类名都会被局部作用于导入的模块。正如我们之前提到的,我们导入的字符串并非随机,而是使用文件的哈希和一些其他参数生成的,以在代码库中是唯一的。

最后,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 文件,而不是将其从捆绑包中注入到浏览器中,以便我们可以获得更轻的捆绑包,并将 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 模块的一个非常强大的特性。在我开始写这本书的时候工作的公司 YPlan 中,我们试图将其推向更高一步,结合composes的强大功能和原子 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 一段时间,发现它使原型设计变得非常快速。

事实上,当所有基本规则都已生成时,将这些类应用到元素并创建新样式是一个非常快速的过程,这是很好的。其次,使用原子 CSS,我们可以控制 CSS 文件的大小,因为一旦我们创建了具有其样式的新组件,我们就使用现有的类,而不需要创建新的类,这对性能来说非常好。

因此,我们尝试使用 CSS 模块解决原子 CSS 的问题,并将这种技术称为原子 CSS 模块

实质上,您开始创建您的基本 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>

在这里,titlemb0fw6都会自动应用到元素上。它们也是局部作用域的,因此我们拥有 CSS 模块的所有优势。

React CSS 模块

最后但同样重要的是,有一个很棒的库可以帮助我们使用 CSS 模块。您可能已经注意到,我们使用style对象来加载 CSS 的所有类,因为 JavaScript 不支持连字符属性,我们被迫使用驼峰命名的类名。

此外,如果我们引用了 CSS 文件中不存在的类名,就无法知道它,undefined会被添加到类名列表中。出于这些和其他有用的功能,我们可能想尝试一个使使用 CSS 模块更加顺畅的包。

让我们通过回到我们在本节中之前使用普通 CSS 模块的index.tsx文件,将其更改为使用 React CSS 模块来看看这意味着什么。

该包名为react-css-modules,我们首先必须安装它:

npm install react-css-modules

安装完包后,我们在index.tsx文件中导入它:

import cssModules from 'react-css-modules'

我们将其作为 HOC 使用,将要增强的Button组件和我们从 CSS 中导入的styles对象传递给它:

const EnhancedButton = cssModules(Button, styles)

现在,我们必须改变按钮的实现,避免使用styles对象。使用 React CSS 模块,我们使用styleName属性,它会转换为常规类。

这样做的好处是,我们可以将类名作为字符串使用(例如,"button"):

const Button = () => <button styleName="button">Click me!</button>;

如果我们现在将 EnhancedButton 渲染到 DOM 中,我们会发现与之前相比,实际上没有什么变化,这意味着库是有效的。

假设我们尝试将 styleName 属性更改为引用一个不存在的类名,如下所示:

import { render } from 'react-dom'
import styles from './index.css'
import cssModules from 'react-css-modules'

const Button = () => <button styleName="button1">Click me!</button>

const EnhancedButton = cssModules(Button, styles)

render(<EnhancedButton />, document.querySelector('#root'))

通过这样做,我们将在浏览器的控制台中看到以下错误:

Uncaught Error: "button1" CSS module is undefined.

当代码库不断增长,我们有多个开发人员在不同的组件和样式上工作时,这将特别有帮助。

实现 styled-components

有一个非常有前途的库,因为它考虑了其他库在样式化组件方面遇到的所有问题。已经有了不同的路径来编写 JavaScript 中的 CSS,并且尝试了许多解决方案,因此现在是时候使用所有这些经验教训来构建一个库了。

该库由 JavaScript 社区中两位知名的开发人员 Glenn MaddernMax Stoiberg 构思和维护。它代表了解决问题的一种非常现代的方法,并且使用了 ES2015 的边缘功能和一些已应用于 React 的高级技术,为样式提供了一个完整的解决方案。

让我们看看如何创建与前几节中看到的相同的按钮,并检查我们感兴趣的所有 CSS 特性(例如伪类和媒体查询)是否与 styled-components 一起工作。

首先,我们必须通过运行以下命令来安装该库:

npm install styled-components

安装库后,我们必须在组件文件中导入它:

import styled from 'styled-components'

在那时,我们可以使用 styled 函数通过 styled.elementName 来创建任何元素,其中 elementName 可以是 div、按钮或任何其他有效的 DOM 元素。

第二件事是定义我们正在创建的元素的样式,为此,我们使用了一个名为 tagged template literals 的 ES6 特性,这是一种在不被插值的情况下将模板字符串传递给函数的方法。

这意味着函数接收到了包含所有 JavaScript 表达式的实际模板,这使得库能够充分利用 JavaScript 的全部功能来应用样式到元素上。

让我们从创建一个带有基本样式的简单按钮开始:

const Button = styled.button`
  backgroundColor: #ff0000; 
  width: 320px; 
  padding: 20px; 
  borderRadius: 5px; 
  border: none; 
  outline: none; 
`;

这种有点奇怪的语法返回一个名为Button的合适的 React 组件,它渲染一个按钮元素,并将模板中定义的所有样式应用于它。样式的应用方式是创建一个唯一的类名,将其添加到元素中,然后将相应的样式注入到文档的头部。

以下是被渲染的组件:

<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组件中,您可以向三个组件的子组件注入一个主题属性,这样就可以轻松地创建 UI,其中一部分样式在组件之间共享,而另一些属性取决于当前选择的主题。

毫无疑问,styled-components库在将样式提升到下一个级别时是一个改变游戏规则的工具,在开始时可能会感觉有点奇怪,因为它是通过组件实现样式,但一旦您习惯了,我保证它会成为您最喜欢的样式包。

总结

在本章中,我们涉及了许多有趣的话题。我们首先讨论了在规模上使用 CSS 时遇到的问题,具体来说,Facebook 在处理 CSS 时遇到的问题。我们了解了在 React 中如何使用内联样式,以及为什么将样式与组件共同定位是有益的。我们还看了内联样式的局限性。然后,我们转向了 Radium,它解决了内联样式的主要问题,为我们提供了一个清晰的接口来在 JavaScript 中编写 CSS。对于那些认为内联样式是一个不好的解决方案的人,我们进入了 CSS 模块的世界,从零开始设置了一个简单的项目。

将 CSS 文件导入到我们的组件中可以清晰地表明依赖关系,而在本地范围内命名类名可以避免冲突。我们看到了 CSS 模块的composes是一个很棒的功能,以及我们如何可以将其与原子 CSS 结合使用,创建一个快速原型的框架。

最后,我们简要地看了一下styled-components,这是一个非常有前途的库,旨在彻底改变我们处理组件样式的方式。

到目前为止,您已经学习了许多在 React 中使用 CSS 样式的方法,从内联样式到 CSS 模块,或者使用诸如styled-components之类的库。在下一章中,我们将学习如何实现并从服务器端渲染中获益。

第九章:为了乐趣和利润进行服务器端渲染

构建 React 应用程序的下一步是学习服务器端渲染的工作原理以及它可以给我们带来的好处。通用应用程序对于 SEO 更好,并且它们可以在前端和后端之间实现知识共享。它们还可以提高 Web 应用程序的感知速度,通常会导致转化率的提高。然而,将服务器端渲染应用于 React 应用程序是有成本的,我们应该仔细考虑是否需要它。

在本章中,您将看到如何设置服务器端渲染应用程序,并在相关部分结束时,您将能够构建一个通用应用程序,并了解该技术的利弊。

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

  • 理解通用应用程序是什么

  • 弄清楚为什么我们可能希望启用服务器端渲染

  • 使用 React 创建一个简单的静态服务器端渲染应用程序

  • 将数据获取添加到服务器端渲染,并理解脱水/水合等概念

  • 使用 Zeith 的Next.js轻松创建在服务器端和客户端上运行的 React 应用程序

技术要求

完成本章,您将需要以下内容:

  • Node.js 12+

  • Visual Studio Code

您可以在书籍的 GitHub 存储库中找到本章的代码,网址为github.com/PacktPublishing/React-17-Design-Patterns-and-Best-Practices-Third-Edition/tree/main/Chapter09

理解通用应用程序

通用应用程序是一种可以在服务器端和客户端上运行相同代码的应用程序。在本节中,我们将看看为什么要考虑使我们的应用程序通用,并学习如何在服务器端轻松渲染 React 组件。

当我们谈论 JavaScript Web 应用程序时,通常会想到存在于浏览器中的客户端代码。它们通常的工作方式是,服务器返回一个空的 HTML 页面,其中包含一个script标签来加载应用程序。当应用程序准备就绪时,它会在浏览器内部操作 DOM 以显示 UI 并与用户交互。这已经是过去几年的情况了,对于大量应用程序来说,这仍然是一种行之有效的方式。

在本书中,我们已经看到使用 React 组件创建应用程序是多么容易,以及它们在浏览器中的工作原理。我们还没有看到的是 React 如何在服务器上渲染相同的组件,为我们提供了一个称为服务器端渲染SSR)的强大功能。

在深入细节之前,让我们试着理解在服务器和客户端上都渲染应用程序意味着什么。多年来,我们习惯于为服务器和客户端拥有完全不同的应用程序:例如,使用 Django 应用程序在服务器上渲染视图,以及一些 JavaScript 框架,如 Backbone 或 jQuery,在客户端上。这些独立的应用程序通常需要由具有不同技能的两个开发团队进行维护。如果需要在服务器端渲染的页面和客户端应用程序之间共享数据,可以在脚本标签中注入一些变量。使用两种不同的语言和平台,没有办法在应用程序的不同方面共享通用信息,如模型或视图。

自从 Node.js 在 2009 年发布以来,JavaScript 在服务器端也因为诸如Express等 Web 应用程序框架而受到了很多关注和流行。在两端使用相同的语言不仅使开发人员可以轻松重用他们的知识,还可以在服务器和客户端之间实现不同的代码共享方式。

特别是在 React 中,同构 Web 应用程序的概念在 JavaScript 社区内非常流行。编写一个同构应用程序意味着构建一个在服务器和客户端上看起来相同的应用程序。使用相同的语言编写两个应用程序意味着可以共享大部分逻辑,这开启了许多可能性。这使得代码库更容易理解,并避免不必要的重复。

React 将这个概念推进了一步,为我们提供了一个简单的 API,在服务器上渲染我们的组件,并透明地应用所有必要的逻辑,使页面在浏览器上变得交互(例如,事件处理程序)。

术语同构在这种情况下并不适用,因为在 React 的情况下,应用程序是相同的,这就是为什么 React Router 的创始人之一 Michael Jackson 提出了这种模式更有意义的名称:Universal

实施 SSR 的原因

SSR 是一个很棒的功能,但我们不应该只是为了它而盲目使用。我们应该有一个真正坚实的理由开始使用它。在本节中,我们将看看 SSR 如何帮助我们的应用程序以及它可以为我们解决什么问题。在接下来的部分中,我们将学习关于 SEO 以及如何提高我们应用程序的性能。

实施搜索引擎优化

我们可能希望在服务器端渲染我们的应用程序的一个主要原因是搜索引擎优化(SEO)。

如果我们向主要搜索引擎的网络爬虫提供一个空的 HTML 骨架,它们将无法从中提取任何有意义的信息。如今,Google 似乎能够运行 JavaScript,但存在一些限制,而 SEO 通常是我们业务的关键方面。

多年来,我们习惯于编写两个应用程序:一个用于网络爬虫的 SSR 应用程序,另一个供用户在客户端使用。我们过去这样做是因为 SSR 应用程序无法给我们提供用户期望的交互水平,而客户端应用程序无法被搜索引擎索引。

维护和支持两个应用程序是困难的,使代码库不够灵活,也不够容易更改。幸运的是,有了 React,我们可以在服务器端渲染我们的组件,并以一种易于理解和索引内容的方式为网络爬虫提供我们应用程序的内容。

这不仅对 SEO 有好处,也对社交分享服务有好处。Facebook 或 Twitter 等平台为我们提供了一种定义在页面被分享时显示的片段内容的方式。

例如,使用 Open Graph,我们可以告诉 Facebook,对于特定页面,我们希望显示特定的图片,并使用特定的标题作为帖子的标题。使用仅客户端的应用程序几乎不可能做到这一点,因为从页面中提取信息的引擎使用服务器返回的标记。

如果我们的服务器对所有 URL 返回一个空的 HTML 结构,那么当页面在社交网络上分享时,我们的 Web 应用程序的片段也会是空的,这会影响它们的传播。

共同的代码库

我们在客户端没有太多选择;我们的应用程序必须用 JavaScript 编写。有一些语言可以在构建时转换为 JavaScript,但概念并未改变。在服务器端使用相同的语言的能力在维护性和公司内部知识共享方面具有重大优势。

能够在客户端和服务器之间共享逻辑使得在两侧应用任何更改变得容易,而不必做两次工作,这在大多数情况下会导致更少的错误和问题。

维护单一代码库的工作量要少于保持两个不同应用程序最新所需的工作量。你可能考虑在团队中引入服务器端 JavaScript 的另一个原因是前端和后端开发人员之间的知识共享。

在两侧重用代码的能力使得协作更容易,团队使用共同的语言,这有助于更快地做出决策和更改。

更好的性能

最后但并非最不重要的是,我们都喜欢客户端应用程序,因为它们快速且响应迅速,但存在一个问题——必须在用户可以在应用程序上采取任何操作之前加载和运行捆绑包。

在现代笔记本电脑或桌面计算机上使用快速互联网连接可能不是问题。然而,如果我们在使用 3G 连接的移动设备上加载一个巨大的 JavaScript 捆绑包,用户必须等待一小段时间才能与应用程序进行交互。这不仅对用户体验不利,而且还会影响转化率。大型电子商务网站已经证明,页面加载时间增加几毫秒可能会对收入产生巨大影响。

例如,如果我们在服务器上用一个空的 HTML 页面和一个script标签提供我们的应用程序,并在用户点击任何内容之前向他们显示一个旋转器,那么网站速度的感知性会受到显着影响。

如果我们在服务器端呈现我们的网站,用户在点击页面后立即开始看到一些内容,即使他们在真正做任何事情之前必须等待同样长的时间,他们也更有可能留下来,因为无论如何都必须加载客户端捆绑包。

这种感知性能是我们可以通过使用 SSR 大大改善的,因为我们可以在服务器上输出我们的组件并立即向用户返回一些信息。

不要低估复杂性

即使 React 提供了一个简单的 API 来在服务器上渲染组件,创建一个通用应用程序是有成本的。因此,我们应该在启用之前仔细考虑上述原因之一,并检查我们的团队是否准备好支持和维护通用应用程序。

正如我们将在接下来的章节中看到的,渲染组件并不是创建服务器端渲染应用程序所需完成的唯一任务。我们必须设置和维护一个带有其路由和逻辑的服务器,管理服务器数据流等等。潜在地,我们希望缓存内容以更快地提供页面,并执行许多其他任务,这些任务是维护一个完全功能的通用应用程序所必需的。

因此,我的建议是首先构建客户端版本,只有在 Web 应用程序在服务器上完全工作时,您才应该考虑通过启用 SSR 来改善体验。只有在严格必要时才应启用 SSR。例如,如果您需要 SEO 或者需要自定义社交分享信息,您应该开始考虑它。

如果您意识到您的应用程序需要很长时间才能完全加载,并且您已经进行了所有的优化(有关此主题的更多信息,请参阅第十章改进您的应用程序的性能),您可以考虑使用 SSR 来为用户提供更好的体验并提高感知速度。现在我们已经了解了什么是 SSR 以及通用应用程序的好处,让我们在下一节中跳入一些 SSR 的基本示例。

创建 SSR 的基本示例

现在,我们将创建一个非常简单的服务器端应用程序,以查看构建基本通用设置所需的步骤。这是一个故意简化的设置,因为这里的目标是展示 SSR 的工作原理,而不是提供全面的解决方案或样板,尽管您可以将示例应用程序用作真实应用程序的起点。

本节假设所有关于 JavaScript 构建工具(如 webpack 及其加载程序)的概念都是清楚的,并且需要一点 Node.js 的知识。作为 JavaScript 开发人员,即使您以前从未见过 Node.js 应用程序,也应该很容易跟上本节。

该应用程序将由两部分组成:

  • 在服务器端,我们将使用Express创建一个基本的 Web 服务器,并为服务器端渲染的 React 应用程序提供一个 HTML 页面

  • 在客户端,我们将像往常一样使用react-dom渲染应用程序。

在运行之前,应用程序的两侧都将使用 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让我们定义一组外部依赖项,这些依赖项我们不想添加到捆绑包中。实际上,在为服务器创建构建时,我们不想将我们使用的所有节点包添加到捆绑包中;我们只想捆绑我们的服务器代码。有一个包可以帮助我们做到这一点,我们可以简单地将其应用到我们的webpack配置中的外部条目,以排除所有模块:

npm install --save-dev webpack-node-externals

太好了。现在是时候在package.json的 npmscripts部分创建一个条目,这样我们就可以轻松地从终端运行build命令了:

"scripts": {
  "build": "webpack"
}

接下来,您需要在根路径下创建一个.babelrc文件:

{
  "presets": ["@babel/preset-env", "@babel/preset-react"]
}

我们现在必须创建配置文件,名为webpack.config.js,以告诉webpack我们希望如何捆绑我们的文件。

让我们开始导入我们将用来设置我们的节点外部的库。我们还将为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创建的对象设置模块加载程序。服务器配置略有不同;我们需要定义不同的entryoutput,并添加一些新的节点,例如targetexternalsresolve

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"],
  },
}

正如您所看到的,entryoutputmodule是相同的,只是文件名不同。

新的参数是target,在其中我们指定node以告诉webpack忽略 Node.js 的所有内置系统包,例如fsexternals,在其中我们使用我们之前导入的库告诉 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将其呈现在具有appID 的 DOM 元素中。

让我们现在转移到服务器。

首先要做的是创建一个template.ts文件,它导出一个我们将用来返回服务器将返回给浏览器的页面标记的函数:

export default body => `
  <!DOCTYPE html>
  <html>
 <head>
 <meta charset="UTF-8">
    </head>
 <body>
 <div id="root">${body}</div>
      <script src="/bundle.js"></script>
 </body>
 </html>`

这应该很简单。该函数接受body,我们稍后将看到它包含 React 应用程序,并返回页面的骨架。

值得注意的是,即使应用程序在服务器端呈现,我们也会在客户端加载捆绑包。 SSR 只是 React 用来呈现我们应用程序的工作的一半。我们仍然希望我们的应用程序是一个客户端应用程序,具有在浏览器中可以使用的所有功能,例如事件处理程序。

之后,您需要安装expressreactreact-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 服务器,并且还能够提供静态文件。

其次,我们导入 ReactReactDOM 来渲染 App,我们也导入了。请注意 import 语句中的 /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 渲染方法,它将在 DOM 中呈现相同的树。

body 变量的值类似于以下内容:

<div data-reactroot="" data-reactid="1" data-react-checksum="982061917">Hello React</div>

正如您所看到的,它代表了我们在 Apprender 方法中定义的内容,除了一些数据属性,React 在客户端使用这些属性将客户端应用程序附加到服务器端呈现的字符串上。

现在我们有了我们应用程序的 SSR 表示,我们可以使用 template 函数将其应用到 HTML 模板中,并在 Express 响应中将其发送回浏览器。

最后,但同样重要的是,我们必须启动 Express 应用程序:

app.listen(3000, () => {
  console.log('Listening on port 3000')
})

我们现在已经准备好了;只剩下几个操作。第一个是定义 npmstart 脚本并将其设置为运行节点服务器:

"scripts": {
  "build": "webpack",
  "start": "node ./dist/server"
}

脚本已经准备好了,所以我们可以首先使用以下命令构建应用程序:

npm run build 

当捆绑包创建完成后,我们可以运行以下命令:

npm start

将浏览器指向 http://localhost:3000 并查看结果。

这里有两件重要的事情需要注意。首先,当我们使用浏览器的查看页面源代码功能时,我们可以看到从服务器返回的应用程序的源代码,如果没有启用 SSR,我们是看不到的。

其次,如果我们打开 DevTools 并安装了 React 扩展,我们可以看到 App 组件也在客户端上启动了。

以下截图显示了页面的源代码:

太棒了!现在您已经使用 SSR 创建了您的第一个 React 应用程序,让我们在下一节中学习如何获取数据。

实现数据获取

前一节的示例应该清楚地解释了如何在 React 中设置通用应用程序。这很简单,主要集中在完成任务上。

然而,在现实世界的应用程序中,我们可能希望加载一些数据,而不是一个静态的 React 组件,例如示例中的App。假设我们想在服务器上加载 Dan Abramov 的gists并从我们刚刚创建的 Express 应用程序返回项目列表。

第六章的数据获取示例中,我们看到了如何使用useEffect来触发数据加载。这在服务器上不起作用,因为组件不会挂载在 DOM 上,生命周期钩子也不会被触发。

之前执行的 Hooks 也不起作用,因为数据获取操作是async的,而renderToString不是。因此,我们必须找到一种方法在之前加载数据并将其作为 props 传递给组件。

让我们看看如何将上一节的应用程序稍作修改,以便在 SSR 阶段加载gists

首先要做的是更改App.tsx以接受gists的列表作为prop,并在渲染方法中循环遍历它们以显示它们的描述:

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作为 prop 并循环遍历元素以渲染项目列表。现在,我们必须更改服务器以检索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。您应该能够看到一个服务器端渲染的gists列表:

npm run build && npm start

确保列表是从 Express 应用程序呈现的,您可以导航到view-source:http://localhost:3000,您将看到gists的标记和描述。

这很好,看起来很容易,但如果我们检查 DevTools 控制台,我们会看到 Cannot read property 'map' of undefined 错误。我们看到错误的原因是,在客户端,我们再次渲染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="/bundle.js"></script> 
    </body> 
 </html> 
`

template函数现在接受两个参数——应用程序的bodygists的集合。第一个插入到应用程序元素中,而第二个用于定义一个附加到window对象的全局gists变量,以便我们可以在客户端中使用它。

Express路由(server.js)中,我们只需要更改生成模板的行,传递 body,如下所示:

const html = template(body, gists)

最后,但同样重要的是,我们必须在client.tsx中使用附加到窗口的gists,这非常容易:

ReactDOM.hydrate( 
  <App gists={window.gists} />, 
  document.getElementById('app') 
)

水化是在 React 16 中引入的,它在客户端的渲染上类似于渲染,无论 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 疲劳,正如本书介绍中所述。

幸运的是,Facebook 开发人员和 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

然后,我们定义一个名为getInitialPropsstatic async函数,这是我们告诉 Next.js 我们想要在服务器端和客户端加载哪些数据的地方。该库将使函数返回的对象在组件内部作为 props 可用。

应用于类方法的staticasync关键字意味着该函数可以在类的实例外部访问,并且该函数会在其主体内部执行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,这将返回一个 promise。当 promise 解析时,我们可以返回带有gistsprops对象。

组件的render看起来与前面的非常相似:

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": "es6",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "moduleResolution": "node",
    "isolatedModules": true,
    "jsx": "preserve"
  },
  "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

摘要

SSR 之旅已经结束。您现在可以使用 React 创建一个服务器端渲染的应用程序,而且您应该清楚为什么它对您有用。SEO 显然是主要原因之一,但社交分享和性能也是重要因素。您学会了如何在服务器上加载数据并在 HTML 模板中去除水分,以便在浏览器上启动客户端应用程序时使其可用。

最后,您已经了解到像 Next.js 这样的工具如何帮助您减少样板代码,并隐藏一些通常会给代码库带来的服务器端渲染 React 应用程序设置复杂性。

在下一章中,我们将讨论如何提高 React 应用程序的性能。

第十章:改善应用程序的性能

Web 应用程序的有效性能对于提供良好的用户体验和提高转化率至关重要。React 库实现了不同的技术来快速渲染我们的组件,并尽可能少地触及文档对象模型DOM)。对 DOM 进行更改通常是昂贵的,因此最小化操作的数量至关重要。

然而,有一些特定的情景,React 无法优化这个过程,开发人员需要实现特定的解决方案来使应用程序顺利运行。

在本章中,我们将介绍 React 的基本概念,并学习如何使用一些 API 来帮助库找到更新 DOM 的最佳路径,而不会降低用户体验。我们还将看到一些常见的错误,这些错误可能会损害我们的应用程序并使其变慢。

我们应该避免仅仅为了优化而优化我们的组件,并且重要的是只在需要时应用我们将在接下来的章节中看到的技术。

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

  • 协调的工作原理以及我们如何帮助 React 使用键更好地完成工作

  • 常见的优化技术和常见的与性能相关的错误

  • 使用不可变数据的含义以及如何做到这一点

  • 有用的工具和库,使我们的应用程序运行更快

技术要求

要完成本章,您将需要以下内容:

  • Node.js 12+

  • Visual Studio Code

您可以在书的 GitHub 存储库中找到本章的代码github.com/PacktPublishing/React-17-Design-Patterns-and-Best-Practices-Third-Edition/tree/main/Chapter10

协调

大多数情况下,React 默认情况下足够快,您无需做任何其他事情来提高应用程序的性能。React 利用不同的技术来优化屏幕上组件的渲染。

当 React 需要显示一个组件时,它会调用其render方法以及其子组件的render方法。组件的render方法返回一棵 React 元素树,React 使用它来决定更新 UI 时必须执行哪些 DOM 操作。

每当组件状态发生变化时,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>树与两个<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键是新的,而20182019键只是移动了。

找到一个键并不难。您将要显示的元素可能已经有一个唯一的 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')
  }
})

为了实现最佳性能,我们不仅希望使用生产标志来创建捆绑包,还希望将捆绑包拆分为一个用于我们的应用程序,一个用于node_modules

为此,您需要在webpack中使用新的优化节点:

optimization: {
  splitChunks: {
    cacheGroups: {
      default: false,
      commons: {
        test: /node_modules/,
        name: 'vendor',
        chunks: 'all'
      }
    }
  }
}

由于 webpack 4 有两种模式,开发生产,默认情况下启用生产模式,这意味着在使用生产模式编译捆绑包时,代码将被最小化和压缩;您可以使用以下代码块指定它:

{
  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,这意味着如果我们将对象作为 prop 传递,并且我们改变了其中一个值,我们将无法获得预期的行为。

事实上,浅比较无法找到属性的变化,组件永远不会重新渲染,除非对象本身发生变化。解决此问题的一种方法是使用不可变数据,一旦创建,就无法改变。

例如,我们可以以以下方式设置状态:

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.memoshallowCompare发挥作用。最后,我们介绍了不同的工具和库,可以使您的应用程序更快。

在下一章中,我们将学习使用 Jest、React Testing Library 和 React DevTools 进行测试和调试。

第十一章:测试和调试

由于 React 具有组件,因此很容易测试我们的应用程序。有许多不同的工具可以用来创建 React 测试,我们将在这里介绍最流行的工具,以了解它们提供的好处。

Jest 是一个由 Facebook 的 Christopher Pojer 和社区内的贡献者维护的一站式测试框架解决方案,旨在为您提供最佳的开发者体验。

通过本章结束时,您将能够从头开始创建测试环境,并为应用程序的组件编写测试。

在本章中,我们将讨论以下主题:

  • 为什么测试我们的应用程序很重要,以及它们如何帮助开发人员更快地移动

  • 如何设置 Jest 环境以使用 Enzyme 测试组件

  • React Testing Library 是什么,以及为什么它对于测试 React 应用程序是必不可少

  • 如何测试事件

  • React DevTools 和一些错误处理技术

技术要求

为了完成本章,您将需要以下内容:

  • Node.js 12+

  • Visual Studio Code

您可以在书的 GitHub 存储库中找到本章的代码:github.com/PacktPublishing/React-17-Design-Patterns-and-Best-Practices-Third-Edition/tree/main/Chapter11

了解测试的好处

测试 Web 用户界面一直是一项困难的工作。从单元测试到端到端测试,界面依赖于浏览器、用户交互和许多其他变量,这使得实施有效的测试策略变得困难。

如果您曾经尝试为 Web 编写端到端测试,您将知道获得一致的结果有多么复杂,结果往往受到不同因素(如网络)的影响而产生假阴性。除此之外,用户界面经常更新以改善体验,最大化转化率,或者仅仅添加新功能。

如果测试很难编写和维护,开发人员就不太可能覆盖他们的应用程序。另一方面,测试非常重要,因为它们使开发人员对他们的代码更有信心,这反映在速度和质量上。如果一段代码经过了良好的测试(并且测试编写得很好),开发人员可以确信它可以正常工作并且已经准备好发布。同样,由于测试的存在,重构代码变得更容易,因为测试保证了功能在重写过程中不会改变。

开发人员往往会专注于他们当前正在实现的功能,有时很难知道应用程序的其他部分是否受到这些更改的影响。测试有助于避免回归,因为它们可以告诉我们新代码是否破坏了旧测试。对于编写新功能的更大信心会导致更快的发布。

测试应用程序的主要功能使代码基础更加稳固,每当发现新的 bug 时,都可以重现、修复并通过测试覆盖,以便将来不再发生。

幸运的是,React(以及组件时代)使得测试用户界面变得更加简单和高效。测试组件或组件树是一项较少费力的工作,因为应用程序的每个部分都有其责任和边界。如果组件以正确的方式构建,如果它们是纯净的,并且旨在可组合和可重用,它们可以被测试为简单的函数。

现代工具带给我们的另一个巨大优势是能够使用 Node.js 和控制台运行测试。为每个测试启动浏览器会使测试变慢且不太可预测,降低开发人员的体验;相反,使用控制台运行测试会更快。

在控制台中仅测试组件有时会在实际浏览器中呈现时产生意外行为,但根据我的经验,这种情况很少见。当我们测试 React 组件时,我们希望确保它们能正常工作,并且在给定不同的 props 集合时,它们的输出始终是正确的。

我们可能还希望覆盖组件可能具有的所有各种状态。状态可能会通过单击按钮而改变,因此我们编写测试来检查所有事件处理程序是否按预期进行。

当组件的所有功能都被覆盖时,但我们想要做更多时,我们可以编写测试来验证组件在边缘情况下的行为。边缘情况是组件在例如所有 props 都为null或出现错误时可能出现的状态。一旦测试编写完成,我们就可以相当有信心地认为组件的行为符合预期。

测试单个组件很好,但这并不能保证一旦它们放在一起,多个经过单独测试的组件仍然能够正常工作。正如我们将在后面看到的,使用 React,我们可以挂载一组组件并测试它们之间的集成。

我们可以使用不同的技术来编写测试,其中最流行的之一是测试驱动开发TDD)。应用 TDD 意味着首先编写测试,然后编写代码来通过测试。

遵循这种模式有助于我们编写更好的代码,因为我们被迫在实现功能之前更多地考虑设计,这通常会导致更高的质量。

使用 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

设置已经准备好,我们可以针对 ES6 代码运行 Jest 并将我们的组件渲染到 DOM 中,但还有一件事要做。

我们需要安装@testing-library/jest-dom@testing-library/react

npm install @testing-library/jest-dom @testing-library/react

安装了这些软件包之后,您必须创建jest.config.js文件:

 module.exports = {
  preset: 'ts-jest',
  setupFilesAfterEnv: ['<rootDir>/setUpTests.ts']
}

然后,让我们创建setUpTests.ts文件:

import '@testing-library/jest-dom/extend-expect'

现在,让我们假设我们有一个Hello组件:

import React, { FC } from 'react'

type Props = {
  name: string
}

const Hello: FC<Props> = ({ name }) => <h1 className="Hello">Hello {name || 'World'}</h1>

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)
})

然后,为了运行test,您需要执行以下命令:

npm test

您应该看到这个结果:

PASS标签表示所有测试都已成功通过;如果您至少有一个测试失败,您将看到FAIL标签。让我们更改其中一个测试以使其失败:

it('should render the name prop', () => {
  const wrapper = render(<Hello name="Carlos" />)
  expect(wrapper.getByText('Hello World')).toBeInTheDocument()
});

这是结果:

正如您所看到的,FAIL标签用X指定。此外,期望和接收值提供了有用的信息,您可以看到期望的值和接收的值。

如果您想查看所有单元测试的覆盖百分比,您可以执行以下命令:

npm run test:coverage

结果如下:

覆盖还生成了结果的 HTML 版本;它创建了一个名为coverage的目录,里面又创建了一个名为Icov-report的目录。如果您在浏览器中打开index.html文件,您将看到以下 HTML 版本:

现在您已经进行了第一次测试,并且知道如何收集覆盖数据,让我们在下一节中看看如何测试事件。

测试事件

事件在任何 Web 应用程序中都很常见,我们也需要测试它们,因此让我们学习如何测试事件。为此,让我们创建一个新的ShowInformation组件:

import { FC, useState, ChangeEvent } from 'react'

const ShowInformation: FC = () => {
  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: 33 } })

    expect(nameInput.value).toBe('Carlos')
    expect(ageInput.value).toBe('33')
  })

  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)
})

如果您运行测试并且工作正常,您应该会看到这个:

使用 React DevTools

当在控制台中进行测试不够时,我们希望在应用程序在浏览器中运行时检查它,我们可以使用 React DevTools。

您可以在以下网址安装此 Chrome 扩展程序:chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=en

安装后会在 Chrome DevTools 中添加一个名为React的选项卡,您可以检查组件的渲染树,以及它们在特定时间点接收到的属性和状态。

Props 和 states 可以被读取,并且可以实时更改以触发 UI 中的更新并立即查看结果。这是一个必不可少的工具,在最新版本中,它有一个新功能,可以通过选中“Trace React Updates”复选框来启用。

启用此功能后,我们可以使用我们的应用程序并直观地看到在执行特定操作时更新了哪些组件。更新的组件会用彩色矩形突出显示,这样就很容易发现可能的优化。

使用 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文件:

// Dependencies
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension';

// Root Reducer
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 Router 在应用程序中实现路由。

第十二章:React 路由器

与 Angular 不同,React 是一个库而不是一个框架,这意味着特定功能(例如路由或 PropTypes)不是 React 核心的一部分。相反,路由由一个名为React Router的第三方库处理。

在本章中,您将看到如何在应用程序中实现 React 路由器,并在相关部分结束时,您将能够添加动态路由并了解 React 路由器的工作原理。

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

  • 了解react-routerreact-router-domreact-router-native包之间的区别

  • 如何安装和配置 React 路由器

  • 添加<Switch>组件

  • 添加exact属性

  • 向路由添加参数

技术要求

要完成本章,您将需要以下内容:

  • Node.js 12+

  • Visual Studio Code

您可以在书的 GitHub 存储库中找到本章的代码github.com/PacktPublishing/React-17-Design-Patterns-and-Best-Practices-Third-Edition/tree/main/Chapter12

安装和配置 React 路由器

使用create-react-app创建新的 React 应用程序后,您需要做的第一件事是安装 React Router v5.x,使用以下命令:

npm install react-router-dom @types/react-router-dom

您可能会困惑为什么我们要安装react-router-dom而不是react-router。React Router 包含react-router-domreact-router-native的所有常见组件。这意味着如果您在 Web 上使用 React,您应该使用react-router-dom,如果您在使用 React Native,则需要使用react-router-native

react-router-dom包最初是为了包含版本 4 而创建的,而react-router使用版本 3。react-router-dom包在react-router上有一些改进。它们在这里列出:

  • 改进的<Link>组件(渲染<a>)。

  • 包括<BrowserRouter>,它与浏览器window.history交互。

  • 包括<NavLink>,它是一个知道自己是否活动的<Link>包装器。

  • 包括<HashRouter>,它使用 URL 中的哈希来渲染组件。如果您有一个静态页面,您应该使用这个组件而不是<BrowserRouter>

创建我们的章节

让我们创建一些部分来测试一些基本路由。我们需要创建四个无状态组件(AboutContactHomeError404),并将它们命名为它们各自目录中的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 { render } from 'react-dom'
import { BrowserRouter as Router } from 'react-router-dom'

// Routes
import AppRoutes from './routes'

render( 
  <Router>
 <AppRoutes />
 </Router>, 
  document.getElementById('root')
)

现在,我们需要创建routes.tsx文件,在用户访问根路径(/)时渲染我们的Home组件:

// Dependencies
import { Route } from 'react-router-dom'

// Components
import App from './App'
import Home from './components/Home'

const AppRoutes = () => ( 
  <App>
 <Route path="/" component={Home} /> 
 </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组件:

现在,当用户尝试访问任何其他路由时,让我们添加Error404

// Dependencies
import { Route } from 'react-router-dom'

// Components
import App from './App'
import Home from './components/Home'
import Error404 from './components/Error404'

const AppRoutes = () => (
  <App>
 <Route path="/" component={Home} />
    <Route component={Error404} />
 </App>
)

export default AppRoutes

让我们再次运行应用程序。您将看到HomeError404组件都被渲染:

您可能想知道为什么会发生这种情况。这是因为我们需要使用<Switch>组件,只有当它匹配路径时才执行一个组件。为此,我们需要导入Switch组件,并将其添加为我们路由的包装器:

// Dependencies
import { Route, Switch } from 'react-router-dom'

// Components
import App from './App'
import Home from './components/Home'
import Error404 from './components/Error404'

const AppRoutes = () => (
  <App>
    <Switch>
      <Route path="/" component={Home} />
      <Route component={Error404} />
    </Switch>
  </App>
)

export default AppRoutes

现在,如果您转到根目录(/),您将看到Home组件和Error404不会同时执行,但是如果我们转到/somefakeurl,我们将看到Home组件也被执行,这是一个问题:

为了解决问题,我们需要在要匹配的路由中添加exact属性。问题在于/somefakeurl将匹配我们的根路径(/),但是如果我们想非常具体地匹配路径,我们需要在Home路由中添加exact属性:

const AppRoutes = () => (
  <App>
    <Switch>
      <Route path="/" component={Home} exact />
      <Route component={Error404} />
    </Switch>
  </App>
)

现在,如果您再次访问/somefakeurl,您将能够看到 Error404 组件:

现在,我们可以添加其他组件(AboutContact):

// Dependencies
import { Route, Switch } 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>
 <Switch>
      <Route path="/" component={Home} exact />
      <Route path="/about" component={About} exact />
      <Route path="/contact" component={Contact} exact />
      <Route component={Error404} />
 </Switch>
 </App>
)

export default AppRoutes

现在,您可以访问/about

或者,您现在可以访问/contact

现在你已经实现了你的第一个路由,现在让我们在下一节中向路由添加一些参数。

向路由添加参数

到目前为止,你已经学会了如何使用 React Router 来进行基本路由(单层路由)。现在,我将向你展示如何向路由添加一些参数并将它们传递到我们的组件中。

在这个例子中,我们将创建一个Contacts组件,当我们访问/contacts路由时,它将显示联系人列表,但当用户访问/contacts/:contactId时,它将显示联系人信息(namephoneemail)。

我们需要做的第一件事是创建我们的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组件,你需要将它导入到我们的路由文件中:

// Dependencies
import { Route, Switch } from 'react-router-dom'

// Components
import App from './components/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>
    <Switch>
      <Route path="/" component={Home} exact />
      <Route path="/about" component={About} exact />
      <Route path="/contact" component={Contact} exact />
      <Route path="/contacts" component={Contacts} exact />
      <Route component={Error404} />
    </Switch>
  </App>
)

export default AppRoutes

现在,如果你去到/contacts的 URL,你就能看到Contacts组件了:

现在Contacts组件已经连接到 React Router,让我们将我们的联系人渲染为列表:

import { FC, useState } from 'react'
import { Link } 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 = (props) => {
 // 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>
 <Switch>
      <Route path="/" component={Home} exact />
      <Route path="/about" component={About} exact />
      <Route path="/contact" component={Contact} exact />
      <Route path="/contacts" component={Contacts} exact />
      <Route path="/contacts/:contactId" component={Contacts} exact />
      <Route component={Error404} />
 </Switch>
 </App>
)

React Router 有一个特殊的属性叫做match,它是一个包含与路由相关的所有数据的对象,如果我们有参数,我们将能够在match对象中看到它们:

import { FC, useState } from 'react'
import { Link } from 'react-router-dom'
import './Contacts.css'

const data = [
  {
    id: 1,
    name: 'Carlos Santana',
    email: 'carlos.santana@js.education',
    phone: '415-307-3112'
  },
  {
    id: 2,
    name: 'John Smith',
    email: 'john.smith@js.education',
    phone: '223-344-5122'
  },
  {
    id: 3,
    name: 'Alexis Nelson',
    email: 'alexis.nelson@js.education',
    phone: '664-291-4477'
  }
]

type Contact = {
  id: number
  name: string
  email: string
  phone: string
}

type Props = {
  match: any
}

const Contacts: FC<Props> = (props) => {
  // 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)

 // Let's see what contains the match object.
  console.log(props)

  const { match: { params: { contactId } } } = props

  // By default our selectedNote is false
  let selectedContact: any = false

  if (contactId > 0) {
 // If the contact id is higher than 0 then we filter it from our
 // contacts array.
    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

match属性看起来像这样:

正如你所看到的,match属性包含了很多有用的信息。React Router 还包括了对象的历史和位置。此外,我们可以获取我们在路由中传递的所有参数;在这种情况下,我们接收到了contactId参数。

如果你再次运行应用程序,你应该能够看到你的联系人就像这样:

如果你点击约翰·史密斯(他的contactId2),你会看到联系人的信息:

在此之后,你可以在App组件中添加一个导航栏来访问所有的路由:

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;
}

最后,你会看到类似这样的东西:

现在你知道如何向你的应用程序添加带有参数的路由了 - 这太棒了,对吧?

总结

我们的 React Router 之旅已经结束,现在你知道如何安装和配置 React Router,如何创建基本路由,以及如何向嵌套路由添加参数。

在下一章中,我们将看到如何避免 React 中一些最常见的反模式。

第十三章:要避免的反模式

在本书中,您已经学会了在编写 React 应用程序时应用最佳实践。在最初的几章中,我们重新审视了基本概念以建立扎实的理解,然后在接下来的章节中,我们深入了解了更高级的技术。

现在,您应该能够构建可重用的组件,使组件彼此通信,并优化应用程序树以获得最佳性能。然而,开发人员会犯错误,本章就是关于在使用 React 时应避免的常见反模式。

查看常见错误将帮助您避免它们,并有助于您了解 React 的工作原理以及如何以 React 方式构建应用程序。对于每个问题,我们将看到一个示例,展示如何重现和解决它。

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

  • 使用属性初始化状态

  • 使用索引作为键

  • 在 DOM 元素上扩展属性

技术要求

完成本章,您将需要以下内容:

  • Node.js 12+

  • Visual Studio Code

您可以在书的 GitHub 存储库中找到本章的代码:github.com/PacktPublishing/React-17-Design-Patterns-and-Best-Practices-Third-Edition/tree/main/Chapter13

使用属性初始化状态

在本节中,我们将看到如何使用从父级接收的属性初始化状态通常是一种反模式。我使用“通常”这个词,因为正如我们将看到的,一旦我们清楚了这种方法的问题是什么,我们可能仍然决定使用它。

学习某事的最佳方法之一是查看代码,因此我们将从创建一个简单的组件开始,其中包含一个+按钮来增加计数器。

该组件是使用类实现的,如下面的代码片段所示:

import { FC, useState } from 'react'

type Props = {
  count: number
}

const Counter: FC<Props> = (props) => {}

export default Counter

现在,让我们设置我们的count状态:

const [state, setState] = useState<any>(props.count)

单击处理程序的实现非常简单直接-我们只需将1添加到当前的count值中,并将结果值存储回state中:

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元素,我们会注意到PropsState具有相似的值:

<Counter>
Props
  count: 1
State
  count: 1

这使得在组件内部和向用户显示时不清楚当前和可信的值是哪个。

更糟糕的是,点击+一次会使值发散。此发散的示例如下代码所示:

<Counter>
Props
  count: 1
State
  count: 2

在这一点上,我们可以假设第二个值代表当前计数,但这并不明确,可能会导致意外行为,或者在树下面出现错误的值。

第二个问题集中在 React 如何创建和实例化类上。组件的useState函数只在创建组件时调用一次。

在我们的Counter组件中,我们读取count属性的值并将其存储在状态中。如果该属性的值在应用程序的生命周期中发生更改(假设它变为10),则Counter组件永远不会使用新值,因为它已经被初始化。这会使组件处于不一致的状态,这不是最佳的,并且很难调试。

如果我们真的想要使用 prop 的值来初始化组件,并且我们确信该值将来不会改变呢?

在这种情况下,最佳做法是明确表示并给属性命名,以明确您的意图,例如initialCount。例如,让我们以以下方式更改Counter组件的 prop 声明:

type Props = {
  initialCount: number
}

const Counter: FC<Props> = (props) => {
  const [count, setState] = useState<any>(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)
}

最后,在render中,我们显示列表和+按钮,以在列表顶部添加baz项目:

return ( 
  <div> 
    <ul> 
      {items.map((item, index) => ( 
        <li key={index}>{item}</li> 
      ))} 
    </ul> 

    <button onClick={handleClick}>+</button> 
  </div> 
) 

如果您在浏览器中运行组件,将不会看到任何问题;单击+按钮会在列表顶部插入一个新项目。但让我们做一个实验。

让我们以以下方式更改render,在每个项目旁边添加一个输入字段。然后我们使用输入字段,因为我们可以编辑它的内容,这样更容易找出问题:

return ( 
  <div> 
    <ul> 
      {items.map((item, index) => ( 
        <li key={index}> 
          {item} 
          <input type="text" /> 
        </li> 
      ))} 
    </ul> 
    <button onClick={handleClick}>+</button> 
  </div> 
)

如果我们在浏览器中再次运行此组件,复制输入字段中项目的值,然后单击+,我们将得到意外的行为。

如下截图所示,项目向下移动,而输入元素保持在原位,这样它们的值不再与项目的值匹配:

运行组件,单击+,并检查控制台应该给我们所有需要的答案。

我们可以看到的是,React 不是在顶部插入新元素,而是交换了两个现有元素的文本,并将最后一个项目插入到底部,就好像它是新的一样。它这样做的原因是我们将map函数的索引用作键。

实际上,即使我们将一个新项目推送到列表顶部,索引始终从0开始,因此 React 认为我们更改了现有两个的值,并在索引2处添加了一个新元素。行为与根本不使用键属性时相同。

这是一个非常常见的模式,因为我们可能认为提供任何键都是最佳解决方案,但实际情况并非如此。键必须是唯一且稳定的,只能标识一个项目。

为了解决这个问题,我们可以,例如,使用项目的值,如果我们期望它在列表中不重复,或者创建一个唯一标识符。

在 DOM 元素上扩展属性

最近,有一种常见的做法被丹·阿布拉莫夫描述为反模式;当您在 React 应用程序中这样做时,它还会触发控制台中的警告。

这是社区中广泛使用的一种技术,我个人在现实项目中多次看到过。我们通常将属性扩展到元素上,以避免手动编写每个属性,如下所示:

<Component {...props} />

这非常有效,并且通过 Babel 转译为以下代码:

_jsx(Component, props)

然而,当我们将属性扩展到 DOM 元素时,我们有可能添加未知的 HTML 属性,这是不好的实践。

问题不仅与扩展运算符有关;逐个传递非标准属性也会导致相同的问题和警告。由于扩展运算符隐藏了我们正在传递的单个属性,因此更难以弄清楚我们正在传递给元素的内容。

要在控制台中看到警告,我们可以执行以下基本操作:渲染以下组件:

const Spread = () => <div foo="bar" />

我们得到的消息看起来像下面这样,因为foo属性对于div元素是无效的:

Unknown prop `foo` on <div> tag. Remove this prop from the element

在这种情况下,正如我们所说的,很容易弄清楚我们正在传递哪个属性并将其删除,但是如果我们使用扩展运算符,就像以下示例中一样,我们无法控制从父级传递的属性:

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 应用程序的性能和行为。

针对每一个问题,我们都使用了一个示例来重现问题,并提供了需要应用的更改来解决问题。

我们了解到为什么使用属性来初始化状态可能会导致状态和属性之间的不一致。我们还看到了如何使用错误的键属性可能会对协调算法产生不良影响。最后,我们了解到为什么将非标准属性扩展到 DOM 元素被视为一种反模式。

在下一章中,我们将探讨如何将我们的 React 应用部署到生产环境中。

第十四章:部署到生产环境

现在您已经完成了您的第一个 React 应用程序,是时候学习如何将其部署到世界上了。为此,我们将使用名为DigitalOcean的云服务。

在本章中,您将学习如何在 DigitalOcean 的 Ubuntu 服务器上使用 Node.js 和 nginx 部署您的 React 应用程序。

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

  • 创建一个 DigitalOcean Droplet 并对其进行配置

  • 配置 nginx、PM2 和域名

  • 实施 CircleCI 进行持续集成

技术要求

要完成本章,您将需要以下内容:

  • Node.js 12+

  • Visual Studio Code

创建我们的第一个 DigitalOcean Droplet

我已经使用 DigitalOcean 六年了,我可以说这是我尝试过的最好的云服务之一,不仅因为价格实惠,而且配置起来非常简单快捷,社区也有很多更新的文档来解决与服务器配置相关的常见问题。

在这一点上,您需要投入一些资金来获得这项服务。我将向您展示最便宜的方法来做到这一点,如果将来您想增加 Droplets 的性能,您将能够在不重新配置的情况下增加容量。最基本的 Droplet 的最低价格是每月 5.00 美元(每小时 0.007 美元)。

我们将使用 Ubuntu 20.04(但也可以使用最新版本 21.04);您将需要了解一些基本的 Linux 命令来配置您的 Droplet。如果您是 Linux 的初学者,不用担心,我会尽量以非常简单的方式向您展示每一步。

注册 DigitalOcean

如果您还没有 DigitalOcean 账户,可以在cloud.digitalocean.com/registrations/new注册。

您可以使用 Google 账户注册,也可以手动注册。一旦您使用 Google 注册,您将看到如下的账单信息视图:

您可以使用信用卡支付,也可以使用 PayPal 支付。一旦您配置了付款信息,DigitalOcean 将要求您提供一些关于您的项目的信息,以便更快地配置您的 Droplet:

在接下来的部分,我们将创建我们的第一个 Droplet。

创建我们的第一个 Droplet

我们将从头开始创建一个新的 Droplet。按照以下步骤操作:

  1. 选择“New Droplet”选项,如下截图所示:

  1. 选择 Ubuntu 20.04(LTS)x64,如下所示:

  1. 然后,选择基本计划,如下所示:

  1. 然后,您可以在付款计划选项中选择$5/月:

  1. 选择一个地区。在这种情况下,我们将选择旧金山地区:

  1. 创建一个根密码,添加 Droplet 的名称,然后点击“Create Droplet”按钮,如下所示:

  1. 创建 Droplet 大约需要 30 秒。创建完成后,您将能够看到它:

  1. 现在,在您的终端中,您可以使用以下命令访问 Droplet:
ssh root@THE_DROPLET_IP
  1. 第一次访问时会要求输入指纹,只需输入 Yes,然后需要输入密码(创建 Droplet 时定义的密码)。

现在我们已经准备好安装 Node.js 了,我们将在下一节中进行介绍。

安装 Node.js

现在您已连接到 Droplet,让我们对其进行配置。首先,我们需要使用个人软件包存档安装最新版本的 Node.js。撰写本书时的当前 Node 版本为 14.16.x。按照以下步骤安装 Node.js:

  1. 如果在阅读本段时,Node 有新版本,请在setup_14.x命令中更改版本:
cd ~
curl -sL https://deb.nodesource.com/setup_14.x -o nodesource_setup.sh
  1. 一旦获得nodesource_setup.sh文件,运行以下命令:
sudo bash nodesource_setup.sh 
  1. 然后,通过运行以下命令安装 Node:
sudo apt install nodejs -y
  1. 如果一切正常,可以使用以下命令验证已安装的 Node 和npm的版本:
node -v
v14.16.1
npm -v
6.14.12

如果您需要更新版本的 Node.js,您可以随时升级。

配置 Git 和 GitHub

我创建了一个特殊的存储库,以帮助您将第一个 React 应用程序部署到生产环境(github.com/D3vEducation/production)。

在您的 Droplet 中,您需要克隆这个 Git 仓库(或者如果您的 React 应用程序已准备好部署,则使用您自己的仓库)。生产仓库是公开的,但通常您会使用私有仓库;在这种情况下,您需要将 Droplet 的 SSH 密钥添加到您的 GitHub 帐户中。要创建此密钥,请按照以下步骤操作:

  1. 运行ssh-keygen命令,然后按Enter三次,不写任何密码:

如果您的终端闲置超过五分钟,您的 Droplet 连接可能会被关闭,您需要重新连接。

  1. 创建 Droplet SSH 密钥后,您可以通过运行以下命令查看它:
 vi /root/.ssh/id_rsa.pub

您会看到类似这样的东西:

  1. 复制您的 SSH 密钥,然后访问您的 GitHub 帐户。转到设置| SSH 和 GPG 密钥(github.com/settings/ssh/new)。然后,在文本区域中粘贴您的密钥并为密钥添加标题:

  1. 点击“添加 SSH 密钥”按钮后,您将看到您的 SSH 密钥,如下所示:

  1. 现在您可以使用以下命令克隆我们的仓库(或您的仓库):
git clone git@github.com:FoggDev/production.git
  1. 当您第一次克隆它时,您将收到一条消息,询问您是否允许 RSA 密钥指纹:

  1. 你必须输入 yes 然后按Enter来克隆它:

  1. 然后,您需要转到production目录并安装npm包:
cd production
npm install
  1. 如果要测试应用程序,只需运行start脚本:
npm start
  1. 然后打开浏览器,转到您的 Droplet IP 并添加端口号。在我的情况下,它是http://144.126.222.17:3000

  1. 这将以开发模式运行项目。如果要以生产模式运行,则使用以下命令:
npm run start:production

你应该看到 PM2 正在运行,如下截图所示:

  1. 如果运行它并在 Chrome DevTools 的网络选项卡中查看,您将看到加载的捆绑包:

我们现在的 React 应用程序在生产中运行,但让我们在下一节中看看我们可以用 DigitalOcean 做些什么。

关闭我们的 Droplet

要关闭 Droplet,请按照以下步骤操作:

  1. 如果要关闭 Droplet,可以转到电源部分,或者可以使用开/关开关:

  1. DigitalOcean 只有在您的 Droplet 处于开启状态时才会向您收费。如果单击开关以关闭它,那么您将收到以下确认消息:

通过这种方式,您可以控制您的 Droplet,并在不使用 Droplet 时避免不必要的支付。

配置 nginx,PM2 和域名

我们的 Droplet 已经准备好用于生产,但是正如你所看到的,我们仍然在使用端口3000。我们需要配置 nginx 并实现代理,将流量从端口80重定向到3000;这意味着我们将不再需要直接指定端口。Node 生产进程管理器PM2)将帮助我们在生产环境中安全运行 Node 服务器。通常,如果我们直接使用nodebabel-node命令运行 Node,并且应用程序出现错误,那么它将崩溃并停止工作。PM2 会在发生错误时重新启动节点服务器。

首先,在您的 Droplet 中,您需要全局安装 PM2:

npm install -g pm2 

PM2 将帮助我们以非常简单的方式运行 React 应用程序。

安装和配置 nginx

要安装 nginx,您需要执行以下命令:

sudo apt-get update
sudo apt-get install nginx

安装 nginx 后,您可以开始配置:

  1. 我们需要调整防火墙以允许端口80的流量。要列出可用的应用程序配置,您需要运行以下命令:
sudo ufw app list
Available applications:
 Nginx Full
 Nginx HTTP
 Nginx HTTPS
 OpenSSH
  1. Nginx Full表示它将允许从端口80(HTTP)和端口443(HTTPS)的流量。我们还没有配置任何带 SSL 的域名,所以现在我们应该限制流量只能通过端口80(HTTP)发送:
sudo ufw allow 'Nginx HTTP'
Rules updated
Rules updated (v6) 

如果尝试访问 Droplet IP,您应该看到 nginx 正在工作:

  1. 您可以使用以下命令管理 nginx 进程:
Start server: sudo systemctl start nginx
Stop server: sudo systemctl stop nginx 
Restart server: sudo systemctl restart nginx

Nginx 是一个非常流行的出色的 Web 服务器。

设置反向代理服务器

如我之前提到的,我们需要设置一个反向代理服务器,将流量从端口80(HTTP)发送到端口3000(React 应用程序)。为此,您需要打开以下文件:

sudo vi /etc/nginx/sites-available/default 

步骤如下:

  1. 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;
}
  1. 保存文件后,您可以使用以下命令验证 nginx 配置中是否存在语法错误:
sudo nginx -t
  1. 如果一切正常,那么您应该看到这个:

  1. 最后,您需要重新启动 nginx 服务器:
sudo systemctl restart nginx

现在,您应该能够访问 React 应用程序而不需要端口,如下面的屏幕截图所示:

我们快要完成了!在下一节中,我们将向我们的 Droplet 添加一个域名。

将域名添加到我们的 Droplet

使用 IP 访问网站并不好;我们总是需要使用域名来帮助用户更容易地找到我们的网站。如果您想在 Droplet 上使用域名,您需要将您的域名的域名服务器更改为指向 DigitalOcean DNS。我通常使用 GoDaddy 来注册我的域名。要使用 GoDaddy 这样做,请按照以下步骤:

  1. 转到dcc.godaddy.com/manage/YOURDOMAIN.COM/dns,然后转到 Nameservers 部分:

  1. 单击“更改”按钮,选择“自定义”,然后指定 DigitalOcean DNS:

  1. 通常,DNS 更改需要 15 到 30 分钟才能反映出来;现在,在更新了您的 Nameservers 之后,转到您的 Droplet 仪表板,然后选择添加域选项:

  1. 然后,输入您的域名,选择您的 Droplet,然后单击“添加域”按钮:

  1. 现在,您需要为 CNAME 创建一个新记录。选择 CNAME 选项卡,在 HOSTNAME 中写入www;在别名字段中写入@;默认情况下,TTL 为43200。所有这些都是为了使用www前缀访问您的域名:

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

太棒了,现在您已经正式将您的第一个 React 应用程序部署到生产环境!

实施 CircleCI 进行持续集成

我已经使用 CircleCI 有一段时间了,我可以告诉你,这是最好的 CI 解决方案之一:个人使用免费,无限的仓库和用户;每月有 1,000 分钟的构建时间,一个容器和一个并发作业;如果你需要更多,你可以升级计划,初始价格为每月 50 美元。

你需要做的第一件事是使用你的 GitHub 账户(或者如果你喜欢的话,Bitbucket)在网站上注册。如果你选择使用 GitHub,你需要在你的账户中授权 CircleCI,如下截图所示:

在下一节中,我们将向 CircleCI 添加我们的 SSH 密钥。

向 CircleCI 添加 SSH 密钥

现在你已经创建了你的账户,CircleCI 需要一种方式来登录到你的 DigitalOcean Droplet 来运行部署脚本。按照以下步骤完成这个任务:

  1. 使用以下命令在 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
  1. 之后,让我们将密钥添加到我们的authorized_keys中:
cat id_rsa_droplet.pub >> authorized_keys
  1. 现在,你需要下载私钥。为了验证你是否可以使用新密钥登录,你需要将其复制到你的本地机器,如下所示:
# 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:

  1. 复制你的id_rsa_droplet.pub密钥的内容,然后转到你的仓库设置(app.circleci.com/settings/project/github/YOUR_GITHUB_USER/YOUR_REPOSITORY):

  1. 前往 SSH 密钥,如下所示:

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

  1. 粘贴你的私钥,然后为主机名字段提供一个名称;我们将其命名为DigitalOcean

现在让我们在下一节中配置我们的 CircleCI 实例。

配置 CircleCI

现在你已经为 CircleCI 配置了对 Droplet 的访问权限,你需要向你的项目添加一个config文件,以指定你想要执行的部署过程中的作业。这个过程如下所示:

  1. 为此,您需要创建.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
  1. 当您有一个.yml文件时,您需要小心缩进;它类似于 Python,如果您没有正确使用缩进,将会出现错误。让我们看看这个文件的结构。

  2. 指定我们将使用的 CircleCI 版本。在这种情况下,您正在使用版本2.1(在撰写本书时的最新版本):

version: 2.1
  1. jobs内部,我们将指定它需要配置容器;我们将使用 Docker 创建它,并概述部署过程的步骤。

  2. working_directory将是我们用来安装 npm 包和运行部署脚本的临时目录。在这种情况下,我决定使用tmp目录,如下所示:

jobs:
  build:
    working_directory: ~/tmp
  1. 如我之前所说,我们将创建一个 Docker 容器,在这种情况下,我选择了一个包含node: 14.16.1的现有镜像。如果您想了解所有可用的镜像,您可以访问circleci.com/docs/2.0/circleci-images
docker:
  - image: cimg/node:14.16.1
  1. 对于代码情况,首先执行git checkoutmaster,然后在每个运行句子中,您需要指定要运行的脚本:
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;'

按照以下步骤进行:

  1. 首先,您需要使用npm install安装 npm 包,以便执行下一个任务。

  2. 使用npm run lint执行 ESLint 验证。如果失败,它将中断部署过程,否则将继续下一次运行。

  3. 使用npm run test执行 Jest 验证;如果失败,它将中断部署过程,否则将继续下一次运行。

  4. 在最后一步中,我们连接到我们的 DigitalOcean Droplet,传递StrictHostKeyChecking=no标志以禁用严格的主机密钥检查。然后,我们使用$DROPLET_USER$DROPLET_IP ENV 变量连接到它(我们将在下一步中创建它们),最后,我们将使用单引号指定我们将在 Droplet 内执行的所有命令。这些命令如下所示:

cd production:授予对生产环境(或您的 Git 存储库名称)的访问权限。

git checkout master:这将检出主分支。

git pull:从我们的存储库拉取最新更改。

npm run start:production:这是最后一步,它以生产模式运行我们的项目。

最后,让我们向 CircleCI 添加一些环境变量。

在 CircleCI 中创建 ENV 变量

如您之前所见,我们正在使用$DROPLET_USER$DROPLET_IP变量,但是我们如何定义这些变量呢?请按照以下步骤进行:

  1. 您需要再次转到项目设置,并选择环境变量选项。然后,您需要创建DROPLET_USER变量:

  1. 然后,您需要使用您的 Droplet IP 创建DROPLET_IP变量:

  1. 现在,您需要将config文件推送到您的存储库,然后您就可以开始使用了。现在 CircleCI 已连接到您的存储库,每当您将更改推送到主分支时,它都会触发一个构建。

通常,前两个或三个构建可能会因为语法错误、配置中的缩进错误,或者因为我们有 linter 错误或单元测试错误而失败。如果失败,您将看到类似于这样的内容:

  1. 如您从上述截图中所见,第一个构建在底部失败,显示构建错误,第二个构建显示工作流构建-部署。这基本上意味着在第一个构建中,config.yml文件中有语法错误。

  2. 在您修复config.yml文件中的所有语法错误和 linter 或单元测试的所有问题后,您应该看到一个成功的构建,就像这样:

  1. 如果您点击构建编号,您可以看到 CircleCI 在发布 Droplet 的新更改之前执行的所有步骤:

  1. 如您所见,步骤的顺序与我们在config.yml文件中指定的顺序相同;您甚至可以通过点击每个步骤来查看每个步骤的输出:

  1. 现在,假设您的 linter 验证或某些单元测试出现错误。在这种情况下,让我们看看会发生什么,如下所示:

如您所见,一旦检测到错误,它将以代码1退出。这意味着它将中止部署并将其标记为失败,如您所见,在npm run lint之后的步骤都没有执行。

另一个很酷的事情是,如果您现在转到 GitHub 存储库并检查您的提交,您将看到所有成功构建的提交和所有失败构建的提交。

这太棒了-现在你的项目已经配置好自动部署,并且连接到你的 GitHub 仓库。

总结

我们的部署过程之旅已经结束,现在你知道如何将你的 React 应用部署到世界(生产环境),以及如何实现 CircleCI 进行持续集成。

在下一章中,我们将学习如何发布npm包。

第十五章:下一步

React 是过去几年中发布的最令人惊奇的库之一,不仅因为库本身及其出色的功能,更重要的是由于围绕它构建的生态系统。

跟随 React 社区是非常令人兴奋和鼓舞的;每一天都有新的项目和工具可以学习和玩耍。不仅如此,还有会议和聚会,您可以在现实生活中与人交谈并建立新的关系,可以阅读博客文章来提高技能和学习更多知识,以及许多其他方法来成为更好的开发人员。

React 生态系统鼓励最佳实践和对开源开发者的热爱,这对我们职业生涯的未来非常棒。

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

  • 如何通过提出问题和拉取请求来为 React 库做出贡献

  • 为什么重要回馈社区并分享您的代码

  • 如何发布一个npm包以及如何使用语义版本控制

技术要求

要完成本章,您将需要以下内容:

  • Node.js 12+

  • Visual Studio Code

为 React 做出贡献

人们在使用 React 一段时间后经常想做的一件事情是为库做出贡献。React 是开源的,这意味着它的源代码是公开的,任何签署了贡献者许可协议(CLA)的人都可以帮助修复错误,编写文档,甚至添加新功能。

您可以在以下网址阅读完整的 CLA 条款:code.facebook.com/cla

您需要确保您在 React 的 GitHub 存储库中发布的任何错误是 100%可复制的。一旦您验证了这一点,并且如果您想在 GitHub 上提交问题,您可以转到https😕/github.com/facebook/react/issues/new。正如您将看到的,该问题附带了一些预填的说明,其中之一是设置最小演示。其他问题帮助您解释问题并描述当前和预期行为。

在参与或贡献到存储库之前,你需要阅读Facebook 行为准则,网址为code.facebook.com/codeofconduct。该文件列出了所有社区成员期望的良好行为,每个人都应该遵循。问题提交后,你需要等待核心贡献者之一来检查并告诉你他们决定如何处理这个 bug。根据 bug 的严重程度,他们可能会修复它,或者要求你修复它。

在第二种情况下,你可以 fork 存储库并编写代码来解决问题。重要的是要遵循编码风格指南,并为修复编写所有测试。同样重要的是,所有旧测试都通过,以确保新代码不会在代码库中引入退化。当修复准备就绪并且所有测试都通过时,你可以提交一个拉取请求,并等待核心团队成员审查。他们可能决定合并它,或者要求你做一些更改。

如果你没有找到 bug,但仍然想为项目做贡献,你可以查看 GitHub 上标记为 good first issue 的问题:github.com/facebook/react/labels/good%20first%20issue。这是开始贡献的好方法,很棒的是 React 团队给了每个人,特别是新贡献者,成为项目的一部分的可能性。

如果你找到一个好的第一个 bug 问题,而且还没有被其他人占用,你可以在问题上添加评论,表示你有兴趣去解决它。核心成员之一会与你联系。在开始编码之前,一定要与他们讨论你的方法和路径,这样你就不必多次重写代码了。

改进 React 的另一种方式是添加新功能。重要的是要说 React 团队有一个计划要遵循,主要功能是由核心成员设计和决定的。

如果你对库接下来的步骤感兴趣,你可以在 GitHub 上的 Type: Big Picture 标签下找到其中一些:github.com/facebook/react/labels/Type%3A%20Big%20Picture

也就是说,如果你有一些关于应该添加到库中的功能的好主意,首先要做的是提出一个问题并开始与 React 团队交谈。在向他们提问之前,你应该避免花时间编写代码并提交拉取请求,因为你心中的功能可能不符合他们的计划,或者可能与他们正在开发的其他功能产生冲突。

分发你的代码

为 React 生态系统做出贡献不仅意味着将代码推送到 React 存储库中。为了回馈社区并帮助开发人员,你可以创建软件包,撰写博客文章,回答 Stack Overflow 上的问题,以及执行许多其他活动。

例如,假设你创建了一个解决复杂问题的 React 组件,并且你认为其他开发人员使用它会比花时间构建他们自己的解决方案更有益。最好的做法是将其发布到 GitHub,并使其可供所有人阅读和使用。然而,将代码推送到 GitHub 只是一个大过程中的一个小动作,并且伴随着一些责任。因此,你应该对你的选择背后的原因有一个清晰的想法。

你想要分享你的代码的动机有助于提高你作为开发人员的技能。一方面,分享你的代码迫使你遵循最佳实践并编写更好的代码。另一方面,它使你的代码暴露于其他开发人员的反馈和评论之中。这是一个很好的机会,让你接收建议并改进你的代码,使其更好。

除了与代码本身相关的建议之外,将代码推送到 GitHub,你可以从其他人的想法中受益。事实上,你可能已经考虑过你的组件可以解决一个问题,但另一个开发人员可能会以稍微不同的方式使用它,为其找到新的解决方案。此外,他们可能需要新功能,他们可以帮助你实现这些功能,以便每个人,包括你自己,都能从中受益。共同构建软件是提高自己技能和软件包的一个很好的方式,这就是为什么我坚信开源的原因。

开源还能给你带来的另一个重要机会是让你与来自世界各地的聪明和热情的开发人员联系在一起。与具有不同背景和技能的新人密切合作是保持开放思维和提高自身能力的最佳途径之一。

共享代码也会给您带来一些责任,并且可能会耗费时间。事实上,一旦代码是公开的,人们可以使用它,您就必须对其进行维护。

维护存储库需要承诺,因为它变得越来越受欢迎,越来越多的人使用,问题和疑问的数量就会越来越多。例如,开发人员可能会遇到错误并提出问题,因此您必须浏览所有这些并尝试重现问题。如果问题存在,那么您必须编写修复程序并发布库的新版本。您可能会收到其他开发人员的拉取请求,这可能会很长,很复杂,需要进行审核。

如果您决定邀请其他人共同维护项目,并帮助您处理问题和拉取请求,您必须与他们协调,分享您的愿景并共同做出决策。

在推送开源代码时了解最佳实践

我们可以介绍一些好的实践,可以帮助您创建更好的存储库,并避免一些常见的陷阱。

首先,如果您想发布您的 React 组件,您必须编写一套全面的测试。对于公共代码和许多人的贡献,测试在许多方面都非常有帮助:

  • 他们使得代码更加健壮。

  • 他们帮助其他开发人员理解代码的功能。

  • 他们使得在添加新代码时更容易找到回归。

  • 他们使其他贡献者更有信心编写代码。

第二件重要的事情是添加一个带有组件描述、使用示例和可用的 API 和 props 文档的README。这有助于包的用户,但也避免了人们提出关于库如何工作以及如何使用它的问题。

还必须向存储库添加一个LICENSE文件,以使人们了解他们可以做什么,以及不能做什么。GitHub 有很多现成的模板可供选择。在您能做到的情况下,您应该保持包的体积小,并尽量减少依赖。当开发人员必须决定是否使用库时,他们往往会仔细考虑大小。请记住,庞大的包对性能有不良影响。

不仅如此,过多地依赖第三方库可能会在其中任何一个未得到维护或存在错误时造成问题。

在共享 React 组件时,一个棘手的部分是决定样式。共享 JavaScript 代码非常简单,而附加 CSS 并不像您想象的那么容易。事实上,您可以采取许多不同的路径来提供它:从向包中添加 CSS 文件到使用内联样式。要牢记的重要一点是 CSS 是全局的,通用的类名可能会与导入组件的项目中已经存在的类名发生冲突。

最好的选择是包含尽可能少的样式,并使组件对最终用户高度可配置。这样,开发人员更有可能使用它,因为它可以适应其自定义解决方案。

为了展示您的组件是高度可定制的,您可以向存储库添加一个或多个示例,以便让每个人都能轻松理解它的工作原理和接受哪些属性。示例也很有用,这样您就可以测试组件的新版本,并查看是否存在意外的破坏性更改。

正如我们在第三章React Hooks中看到的,诸如React Storybook之类的工具可以帮助您创建生动的样式指南,这样您就更容易维护,包的使用者也更容易导航和使用。

一个非常好的例子是使用 Storybook 展示所有这些变化的高度可定制库是来自 Airbnb 的react-dates。您应该将该存储库视为如何将 React 组件发布到 GitHub 的完美示例。

正如您所看到的,他们使用 Storybook 来展示组件的不同选项:

最后但同样重要的是,您可能不仅想分享您的代码 - 您可能还想分发您的包。JavaScript 最流行的包管理器是npm,我们在本书中一直使用它来安装包和依赖项。

在下一节中,我们将看到使用npm发布新包是多么容易。

除了npm之外,一些开发人员可能需要将您的组件作为全局依赖项添加并在没有包管理器的情况下使用它。

正如我们在第一章开始使用 React中看到的,您可以通过添加一个指向unpkg.com/的脚本标签来轻松使用 React。给您的库的用户提供相同的选择是很重要的。

因此,为了提供包的全局版本,您还应该构建通用模块定义UMD)版本。使用 webpack,这非常简单;您只需在配置文件的输出部分设置libraryTarget

发布 npm 包

将包发布给开发者最流行的方式是通过将其发布到npm,这是 Node.js 的包管理器。

我们在本书的所有示例中都使用了它,您已经看到安装包有多么容易;只需运行npm install包,就可以了。您可能不知道的是发布包也同样容易。

首先,假设您进入一个空目录,并在终端中输入以下内容:

npm init

将创建一个新的package.json文件,并显示一些问题。第一个是包名称,默认为文件夹名称,然后是版本号。这些是最重要的,因为第一个是您的包的用户在安装和使用时将引用的名称;第二个帮助您安全地发布新版本的包,而不会破坏其他人的代码。

版本号由三个由点分隔的数字组成,它们都有意义。右侧包的最后一个数字代表补丁,当推送修复 bug 的新版本库时,应该增加这个数字。

中间的数字表示发布的次要版本,并且当向库添加新功能时应该更改。这些新功能不应该破坏现有的 API。最后,左侧的第一个数字代表主要版本,当发布包含破坏性更改的版本时,它必须增加。

遵循这种称为语义化版本控制SemVer)的方法是一个良好的实践,它会让您的用户更加自信,因为他们需要更新您的包时会更加放心。

包的第一个版本通常是0.1.0

要发布一个npm包,您必须拥有一个npm账户,您可以通过在控制台中运行以下命令轻松创建,其中$username是您选择的名称:

npm adduser $username

用户创建后,您可以运行以下命令:

npm publish

新条目将被添加到注册表中,其中包含您在package.json中指定的包名称和版本。

每当您在库中更改内容并且想要推送新版本时,您只需运行$type,其中一个补丁是次要的或主要的:

npm version $type

该命令将自动在您的package.json文件中提升版本,并且如果您的文件夹处于版本控制下,它还将创建一个提交和一个标签。

一旦版本号增加,您只需再次运行npm publish,新版本将可供用户使用。

摘要

在这次环绕 React 世界的旅程的最后一站,我们看到了使 React 变得伟大的一些方面 - 其社区和生态系统 - 以及如何为它们做出贡献。

您学会了如何在发现 React 中的错误时提出问题,以及采取的步骤使其核心开发人员更容易修复它。您现在知道在开源代码时的最佳实践,以及随之而来的好处和责任。

最后,您看到了在npm注册表上发布软件包有多么容易,以及如何选择正确的版本号以避免破坏其他人的代码。

posted @ 2024-05-16 14:50  绝不原创的飞龙  阅读(0)  评论(0编辑  收藏  举报