193-React Hooks 核心原理与实战

开篇词 | 全面拥抱 Hooks，掌握最新 React 开发方式

你好，我是王沛。欢迎和我一起学习 React。

我在前端领域深耕 15 年了，先后工作于 IBM 和 SAP，目前在 eBay 担任中国区资深研发技术专家的职位。这期间，既做过前端框架的自研，也构建过大型前端开发的项目。

其实，早在上学阶段，我就对前端开发很感兴趣，研二时就出版了当时国内第一本专门讲 Web 2.0 的书籍：《征服 Web 2.0: Ajax 开发技术详解》。之后的工作也一直专注于前端框架的开发。可以说，我经历了前端的整个发展历史，也深知前端开发的各种痛点、难点，并见证了它们是如何被一个个出现的框架和工具不断攻破的。

而在前端不断推陈出新的技术中，React 的出现让我眼前一亮，它解决了很多前端开发的本质性问题，如数据流、状态维护等，于是我也开始将 React 应用在自己的项目中。经过数十个大型项目的实操，我把自己对 React 的一些可重用的经验，做成了开源项目，比如 Rekit、React Form Builder 等，在 GitHub 上有超过 5000 个 Star。

同时，作为国内最早一批的 React 实践者，我也不断将自己使用 React 的经验，通过 InfoQ 专栏、QCon 演讲和诸多同行分享，推动了 React 在国内的普及；每年也都会在公司内部开设培训课程，帮助各个团队迅速掌握 React 的常见设计模式和最佳实践。

随着对 React 的深入研究、实战操作、案例积累，以及输出分享，我拥有了一套从核心原理到实战的 React Hooks 知识总结。所以这一次，我想把我多年积累的经验分享给你。

React，前端开发的正确打开方式

和其他技术一样，前端开发技术在不断地更迭变幻。正因如此，面对某个新技术时，我一直抱着非常谨慎的态度。

但是 React 的出现，让我感觉这才是前端开发的正确打开方式。这主要是因为在当时它有两个非常颠覆式的创新点。

第一个是推出了虚拟 DOM 机制，大幅度降低了 UI 上的操作难度。

React 会自动用最优的方式将虚拟 DOM 更新到真实的 DOM 节点，这样一来，我们就不需要再使用浏览器 API 来维护复杂的 DOM 结构了，这就简化了我们在 UI 上的操作。

第二个是 JSX 语法的使用，不仅使代码前所未有的简洁和直观，同时也减轻了内部沟通成本，提高了维护效率。

React 用类似 HTML 标记的方式去实现组件化，数据变化以后，UI 也会自动变化，我们再也不用去关注实现细节了，而且保证了 UI 的一致性。

这两个机制完美地解决了当时前端开发的痛点：模板语言不够灵活，DOM API 又太繁琐，且难以维护。因此， React 在 2013 年首次宣布以后，就迅速获得了大家的认可，成为了最为流行的前端框架。

从下面这张图也可以看到，React 自诞生以来，下载量就处于一直上升的趋势。这在前端轮子倍出的背景之下，可以说是一个不俗的成绩了。

值得注意的是，在其他前端框架发生翻天覆地变化的同时，React 的 API 则非常稳定。所以在我看来，无论你是专职从事前端开发，还是以后端为主，但也需要自己做一些 UI 界面，React 作为目前最为流行的前端框架，基本上都是你的首要选择。

但即便在这样稳定的背景下，React 还是在 2019 年 2 月发布的 16.8 版本中引入了 Hooks 概念。在让人耳目一新的同时，也让广大早就恐惧于各种新轮子的前端同学心头一紧，产生本能的抗拒：

Class 组件不是挺好的吗，为什么又要学一个新轮子？

Hooks 刚出来一两年，会不会昙花一现？

现在项目代码都是 Class 的，难道都得改一遍吗？

我先给你吃一颗定心丸，这种担忧完全没有必要，这不仅不会增加你的使用难度，反而还会拓展你的 React 开发思路，为你提供了一种更多的选择。

全面拥抱 React Hooks，让前端代码结构更加清晰

事实上，在 Hooks 刚出来时，我带领的团队中也有很多同学对 Hooks 产生了抗拒心理。这很正常，毕竟，我们初次使用任何新技术时大都会有怀疑心理，或者是畏惧。正如你第一次走一条不熟悉的路时，总会觉得前路漫漫，有点儿迷茫。

即便如此，出于对 React 官方团队的信赖，我们还是决定开始尝试。惊喜之处就在于，在我们不断了解和使用之后，逐渐发现 Hooks 真是太好用了。原来很复杂的一些问题，现在用 Hooks 就可以很优雅地解决！

举个例子来说。在 Hooks 出现之前，业务逻辑的重用可以说是 React 开发的一大痛点和难点。比如，你需要在组件中去监听窗口大小的变化，以便在布局上做调整。那么我们就得在类组件的不同生命周期中做事件监听的绑定和解绑。

其中的困难和复杂可想而知，毕竟，这种需要分散在不同方法中的逻辑是很难去重用的。因此，我们不得不借助高阶组件这样复杂而又晦涩的设计模式来解决。

但现在呢，有了 Hooks，我们就能很轻松地应对了。我们用 Hooks 就可以非常自然地去解决。我们只需要通过 Hooks 的方式进行封装，将其变成一个可绑定的数据源。这样当窗口大小发生变化时，Hook 的组件会自动重新渲染。同时，代码也更加易懂，并且不会产生额外的组件节点。

可以说，引入 Hooks 的概念之后，函数组件就具备了状态管理、生命周期管理等能力，几乎可以实现原来的 Class 组件具有的所有能力。函数组件能和已有 Class 组件无缝衔接，一起工作。这样老的代码无需做任何改变，新的代码可以根据具体情况采用 Hooks 的方式去实现。这些都大大加速了 Hooks 的普及。

而且，函数组件结合 Hooks 带来的简洁的写法，以及直观的逻辑重用能力，让函数组件成为了 React 中的一等公民。目前，在整个 React 生态中，几乎所有开发项目都已经是围绕着 Hooks 展开的了。

所以，在这次课程中，我会从函数组件和 Hooks 的角度出发，带你重新认识 React，帮助你在深入理解 Hooks 的基础上，使用 Hooks 的思想去开发前端应用，真正享受 Hooks 带来的诸多好处。

不过，对于初次接触 Hooks 的同学来说，肯定会经常遇到各种问题。比如：

为什么编译又不过了？

我的函数组件怎么又死循环了？

Redux 的 Store 要怎么访问？

有这些问题是很正常的，因为从 Class 组件到函数组件的转变，不仅仅是一个写法的区别，更是整个开发思路的转变。而我们只有学会用 Hooks 的角度去思考问题，才算把这条路真正走顺。

那么，在这个课程里面，我会将我在使用 Hooks 的过程中，以及作为面试官在面试的过程中遇到的、看到的各种痛难点问题，以及最需要理解的核心原理呈现出来，帮助你把路走顺，且少走弯路，快速掌握并使用 React Hooks 思想进行前端开发。

想用好 React，你该这么学

现在，你一定觉得 Hooks 简直太神奇了，迫不及待地想要学习。别急，深入技术点之前，你得知道科学的学习路径。

对于 React Hooks 而言，在我看来最重要的是准确地知道 Hooks 的功能边界，也就是知道它能做什么。否则，就有可能陷入什么功能都想用 Hooks 去做的，或者是在最适合使用 Hooks 的功能中，却想不到用它去处理。

在建立起对 Hook 的全局认知之后，就要掌握核心概念和原理了。任何技术，脱离了原理本身，学到的大概率只是皮毛。原理或许不能让你即学即用，但这却是你应对实际复杂业务问题的关键能力。

当然，把原理和实际业务场景结合起来，才能让自己在 Hooks 进阶这条路上走得更快、更稳。所以，你就必须清楚日常开发中的具体场景，在真实的场景中透彻理解 Hooks 是如何解决问题的。而我们最高阶的目标，就是让你在实战中掌握 Hooks 的思考方式，拥有举一反三的能力。

刚刚说的这些，其实也正是咱们这个课程的核心设计理念。据此，我把学习这门课的路径画成了一张图，你可以对照着来学习。

课程是如何设计的？

接下来，我就说说这门课具体是怎么设计的。整个课程会分为三个部分。

基础篇：抓住问题本质，提高解决复杂问题的能力

刚才我们讲到，原理的学习是必要且重要的，那么，为了让你快速消化吸收这些原理，学得有趣，我也做了一些精心的小设计。

在“基础篇”，我会抽丝剥茧，从“问题的本质是什么”这一角度出发来讲解每个机制，包括：React 为什么要发明 Hooks？useEffect 就是生命周期函数吗？等等。理解了最核心的那个问题，你也就拥有了举一反三的能力。

实战篇：业务场景驱动，掌握解决实际问题的思考方式

在理解核心概念的基础之上，我们就拥有了解决问题的抓手，接下来，我会带你进入实战部分。

在这里，我精心设计了 9 个不同的典型业务场景，并基于 React Hooks 去剖析一些常见设计模式。相信通过实战锻炼，你会快速掌握这些通用原则和最佳实践，形成自己的知识库。在遇见类似的场景时，可以灵活调取。

扩展篇：了解工具、生态和未来，开拓你的技术视野

我们学习 Hooks，是为了更好地使用 React，因此，在最后一个模块，我们必须要将视角扩展至 React 的整个生态。

我会给你介绍 React 应用该如何打包，整个生态有哪些可用的工具和资源，该如何测试，以及未来会有哪些发展的趋势。有了完整体系的积累，你才能真正独当一面，完成整个应用的开发、测试、打包和部署。

最后，希望这门从函数组件角度出发的 React Hooks 课程，不仅能够帮助你全面掌握 React 的开发技能，同时也能够给你一个很好的进阶起点，让你在 React 的开发路程上更加高效地前进。

当然，如果你在学习过程中有任何问题或者心得，也欢迎在留言区和我交流互动，我们一定可以共同进步。

01｜认识 React：如何创建你的第一个 React 应用？

你好，我是王沛。欢迎来到我们的第一节课：认识 React ，并创建你的第一个 React 应用。

在这一讲，我会先带你了解 React 的创新之处，从而理解为什么它能成为最为主流的前端框架。然后再来学习和理解 React 的基本概念，帮助你了解 React 。最后，我会通过一个实战项目，带着你创建你的第一个 React 应用。

React 的颠覆式创新

React 的中文含义是“反应”或“响应”，它描述了 React 这样一个前端框架的核心原理：当数据发生变化时，UI 能够自动把变化反映出来。这在 React 当时出现的背景之下，可以说是一个颠覆式的创新。

我之所以用“颠覆”这个词，是因为它不仅提供了一个框架，而且彻底改变了前端的开发思路，甚至电脑桌面、手机应用的开发也受到了 React 开发思路的影响。

在 2013 年 React 出现之时，主流的开发 UI 的方式仍然是基于浏览器 DOM 的 API，去精细地控制 DOM 节点的创建、修改和删除。为了保证 UI 上的一致性，我们需要非常小心地处理因各种数据的变化而导致的 UI 的变化。

举个例子。对于一个聊天应用，当来了一条新消息时，我们一方面需要在聊天框内添加一条新消息，同时也要在显示消息数量的地方让数字加 1，这样才能保证 UI 的一致性。

在 React 之前，我们需要调用 DOM 的 API 来修改 DOM 树的结构，从而改变 UI 的展现。而在有了 React 之后，我们只需要在业务状态和 UI 状态之间建立一个绑定的关系就行了。绑定完成后，我们就不需要再关心怎么去精细控制 UI 的变化，因为 React 会在数据发生变化时，帮助我们完成 UI 的变化。

下面这张图就展示了这样的一个机制：

可以看到，我们可以通过 JSX 语法，用声明式的方式来描述数据和 UI 之间的关系，那么数据在发生变化时，UI 也会自动发生变化。这样的话，无论是收到一条还是多条消息，React 都会自动完成 UI 的展现，我们也就不再需要去关心怎么产生变化的细节。那么基于同一个数据，比如我们需要在通知栏里显示消息的数量，那么显示消息数量的组件，只需要绑定到消息的长度上，它也会自动更新，这样很容易就保证 UI 上的一致性了。

通过我刚才对这张图的解释，你可能还会有些不理解，主要是出现了一些比较陌生的概念。别着急，接下来我就带你理解 React 的基本概念，学完之后，你会对 React 是如何工作的有更全面的认识。

理解 React 的基本概念

React 本身其实是一个非常简单的框架，要理解它的用法，无外乎就是理解下面三个概念：组件、状态和 JSX。下面我们分别来看。

使用组件的方式描述 UI

在 React 中，所有的 UI 都是通过组件去描述和组织的。可以认为，React 中所有的元素都是组件，具体而言分为两种。

内置组件。内置组件其实就是映射到 HTML 节点的组件，例如 div、input、table 等等，作为一种约定，它们都是小写字母。

自定义组件。自定义组件其实就是自己创建的组件，使用时必须以大写字母开头，例如 TopicList、TopicDetail。

和 DOM 的节点定义类似，React 组件是以树状结构组织到一起的，一个 React 的应用通常只有一个根组件。此外，在实际开发时，我们会把 UI 划分成不同的组件，然后组织到一起，例如对于下面这样一个评论框的 UI：

我们可以用基于组件的方式去描述：

function CommentBox() {
  return (
    <div>
      <CommentHeader />
      <CommentList />
      <CommentForm />
    </div>
  );
}

比如说评论框可以分为三个部分，包括头部、评论的列表和一个用于提交新的评论的表单，这样我们就可以把这三个部分分别定义成组件，让我们可以根据实际的场景把复杂的 UI 模块化为独立的组件。这样代码不仅看起来更加直观，而且也更容易维护。

使用 state 和 props 管理状态

正如我刚才提到的，React 的核心机制是能够在数据发生变化的时候自动重新渲染 UI，那么势必要有一个让我们保存状态的地方，这个保存状态的机制就是 state。而 props 就是类似于 Html 标记上属性的概念，是为了在父子组件之间传递状态。

在函数组件中，我们可以使用 useState 这样一个 Hook 来保存状态，那么状态在发生变化时，也会让 UI 自动发生变化。比如下面这段代码，展示了一个简单计数器的实现的例子：

import React from "react";

export default function Counter() {
  const [count, setCount] = React.useState(0);
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>{count}</button>
    </div>
  );
}

可以看到，通过 useState 定义这样一个状态，让这个状态来保持计数器的数值，那么在值发生变化时，组件就会自动重新刷新。这里我们先不用太关心 useState 这个 API 的细节，我在第 4 讲会有一个详细的介绍。

那么，现在使用了 state 来维护组件的状态，接下来要关心的就是组件之间的交互，这正是 props 提供的作用。

无论是 div、span 这样的内置组件，还是自定义组件，都可以在使用时把接收属性作为参数。而当这个参数发生变化时，组件也就会自动重新渲染。

例如，我们在计数器这个例子中使用一个组件来渲染 count 这个值，要求在值大于 10 的时候显示为红色，否则就为蓝色：

import React from "react";

function CountLabel({ count }) {
  // 子组件用于显示颜色
  const color = count > 10 ? "red" : "blue";
  return <span style={{ color }}>{count}</span>;
}

export default function Counter() {
  // 定义了 count 这个 state
  const [count, setCount] = React.useState(0);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        <CountLabel count={count} />
      </button>
    </div>
  );
}

可以看到，我们定义了一个新的组件 CountLabel，在值大于 10 的时候显示红色，否则为蓝色。并且，我们还要在 Counter 组件里使用 CountLabel 这个子组件，这样的话我们就可以通过 props 把 count 这个值从父组件传递到子组件，那么在 count 发生变化时，CountLabel 也会重新渲染。此外，所有通过属性定义在这个 Tag 上的参数，都会作为一个对象传递给函数组件，这样在函数组件内部就可以使用这些参数了。

理解 JSX 语法的本质

我们刚刚通过一个简单的例子对 React 组件有了一个比较直观的印象，但如果你之前没有接触过 React，也许会觉得在代码中同时包含 JavaScript 和 HTML 标记的写法很别扭。但这种写法其实正是 React 中的“模板语言”：JSX。

注意，这里的“模板语言”是加了引号的，因为从本质上来说，JSX 并不是一个新的模板语言，而可以认为是一个语法糖。也就是说，不用 JSX 的写法，其实也是能够写 React 的。这是什么意思呢？我们不妨再看一下上面计数器的例子，如果不用 JSX 应该如何写代码：

React.createElement(
  "div",
  null,
  React.createElement(
    "button",
    { onClick: function onClick() {
        return setCount(count + 1);
      } },
    React.createElement(CountLabel, { count: count })
  )
);

在这段代码中，JSX 的部分我们是用 JavaScript 的方式去实现的，并且用到了 React.createElement 这样一个 API，它的作用就是创建一个组件的实例。此外，这个 API 会接收一组参数：

第一个参数表示组件的类型；

第二个参数是传给组件的属性，也就是 props；

第三个以及后续所有的参数则是子组件。

所以呢，通过 createElement 这个 API，我们可以构建出需要的组件树，而 JSX 只是让这种描述变得更加直观和高效。所以我们说 JSX 其实是一种语法糖。理解这一点非常重要，因为它意味着两点：

JSX 的表达能力等价于 JavaScript 的表达能力，那么所有我们可能需要的机制，比如循环、条件语句等等，JSX 其实都能灵活表达。

JSX 几乎不需要学习，只要你会用 JavaScript，就也会用 JSX。

所以这也是 React 的“模板语言”区别于 Angluar 和 Vue 的地方，JSX 不是一个新的概念，而只是原生 JavaScript 的另一种写法。但是换成这种写法，就会大大降低你上手 React 的难度。

使用脚手架工具创建 React 应用

经过刚才的学习，我们已经对 React 有了一个基本的了解，那么现在我们就来创建一个应用，试用一下 React。

在实际的项目开发中，我们其实不仅需要把 React 作为 UI 层，还可能需要路由管理、状态管理等框架。与此同时，我们还需要使用最新的 JavaScript 语言特性、CSS 的预处理框架等等。所以一般需要结合 Webpack 等打包工具来使用 React。

完成这些配置本身就是一个比较繁琐的步骤，因此我们通常都不会从头开始创建一个项目，而是使用脚手架工具来创建一个项目。我在这里跟你介绍两个工具：

一个是 codesandbox.io，主要用于学习 React，能够快速试验一些 React 的特性。

另一个则是 Facebook 官方提供的 create-react-app 命令行工具，用来创建一个基础的 React 项目。

首先看 Codesandbox，它是一个在线的工具，可以通过 https://codesandbox.io 访问使用。例如今天这节课所有可运行的示例代码就都是放在 Codesandbox 上的，用浏览器打开即可。你可以在线写代码，并实时查看运行的效果。使用起来其实也非常简单，打开主页后就可以看到如下页面：

点击 Create Sandbox，就可以创建一个前端的应用。需要说明的是，它不仅支持 React，还能够创建 Angular、Vue 等前端框架的应用。

第二个 create-react-app 则是我们做正式项目开发时会使用的基本工具。它会提供一个完善的 Webpack 配置，让我们能够立刻开始使用 React、JavaScript 语言的最新特性和 CSS Module 等主流的技术方案。

这里要特别注意一点，create-react-app 的使用需要 Node.js 的环境，具体版本是 Node >= 10.16 版本以及 npm >=5.6 版本。安装好 Node.js 之后，你就可以用如下命令行来创建一个 React 应用：

npx create-react-app my-app
cd my-app
npm start

创建的项目结构非常简单：

- public
- src
  - App.css
  - App.js
  - index.js
  - index.css
- package.json

这里的 src/index.js 是程序的入口，App.js 则是整个应用程序的根组件，主要做一些配置的事情。create-react-app 创建的项目作为一个基本的模板，为我们提供的便利主要是配置好开发环境、Webpack 等等，但是它不包含 Redux 或者 Router 等非必须的功能。那么基于这个模板，我们后面的几节课会在 App.js 中添加 Redux、React Router 等配置。

实战：在组件中发送请求并显示数据

好了，我们已经对 React 有了一个大概的了解，并能够创建项目，试验代码执行的效果。现在我们来通过一个异步请求的例子，以强化对 React 的理解，这个例子也是日常开发中最为常见的需求。

设想这样一个场景：页面上有一个按钮，点击后，我们可以发起一个请求获取一个用户列表，并要求显示在页面上。在这个过程中，我们需要考虑数据状态，Loading 的状态，以及请求出错的处理。那么可以用下面的代码实现：

import React from "react";

export default function UserList() {
  // 使用三个 state 分别保存用户列表，loading 状态和错误状态
  const [users, setUsers] = React.useState([]);
  const [loading, setLoading] = React.useState(false);
  const [error, setError] = React.useState(null);

  // 定义获取用户的回调函数
  const fetchUsers = async () => {
    setLoading(true);
    try {
      const res = await fetch("https://reqres.in/api/users/");
      const json = await res.json();
      // 请求成功后将用户数据放入 state
      setUsers(json.data);
    } catch (err) {
      // 请求失败将错误状态放入 state
      setError(err);
    }
    setLoading(false);
  };

  return (
    <div className="user-list">
      <button onClick={fetchUsers} disabled={loading}>
        {loading ? "Loading..." : "Show Users"}
      </button>
      {error &&
        <div style={{ color: "red" }}>Failed: {String(error)}</div>
      }
      <br />
      <ul>
        {users.length > 0 &&
          users.map((user) => {
            return <li key={user.id}>{user.first_name}</li>;
          })}
      </ul>
    </div>
  );
}

程序的运行结果如下图所示：

当点击 Show Users 按钮时，会显示 Loading 的文本，并将 button 设为 disabled。请求成功后，则显示获取的用户列表。如果请求失败，则显示一段错误信息的文本。

在 React 组件中，任何一个 state 发生变化时，整个函数组件其实是被完全执行一遍的，而且除了 state，多次的执行之间没有任何关系。所以在考虑这样一个场景的实现时，我们的思考方式就是要首先考虑这个组件有哪些状态（state），这些状态的变化是由什么触发的，从而将整个功能串联起来。

当然，这个例子只是一个最简单的实现，但如果你细究一下，应该还会提出下面的问题：

函数中定义了回调函数 fetchUsers，但函数每次都是全部重新执行，那会不会重复定义很多次呢？

如果另外一个组件可能也需要使用到 Users 这个数据，比如一个下拉框，那么是不是每次都要重复这个发起请求的逻辑呢？

这其实正是后面的课程会解决的问题，我在这里先简单回答下：

是的，这种写法会重复定义很多函数。不过为了避免这样的问题，React 提供了 useCallback 这样一个 Hook 来缓存回调函数，关于这一问题，我在第 4 讲会有详细的讲解。

对于异步请求逻辑的重用，这其实也意味着跨组件状态的重用，我会在第 7 讲利用 Redux 这样一个全局状态管理框架来实现异步逻辑的复用。

小结

React 本身其实是一个上手非常简单的 UI 框架，它的核心 API 在这节课我们基本都已经用到了，比如函数组件，JSX，useState，等等。但是 React 本身毕竟只是解决了 UI 的问题，在真正开发时，我们还需要路由、需要状态管理等等，这也正是后续课程中会介绍的。

不过也正是因为 React 本身的这种纯粹和简单，让它的整个生态圈变得非常繁荣。针对每一个具体的问题，可能都存在特定的解决方案。所以在实际使用中，我们需要根据具体的需求，来寻求最合适的方案。

最后，我把这节课用到的例子链接放在这里，供你参考。

计数器的例子：https://codesandbox.io/s/admiring-christian-20vzg?file=/src/01/Counter.js

异步请求的例子： https://codesandbox.io/s/admiring-christian-20vzg?file=/src/01/UserList.js

思考题

作为第一讲的思考题，我先来提一个开放式的问题。在你看来，React 最打动你的特性是什么？或者说你认为它的最大优点有哪些？

欢迎在留言区写下你的思考和想法，我们一起交流讨论。如果今天的内容让你有所收获，也欢迎你把课程分享给你的同事、朋友，我们一起共同进步！

02｜理解 Hooks：React 为什么要发明 Hooks？

你好，我是王沛。

React 作为目前最为主流的前端框架，自 2013 年诞生至今已经有近 8 年的时间了。这 8 年来，在其他前端框架发生翻天覆地变化的同时，React 的 API 则非常稳定，几乎从来没有出现过重大的向后兼容的问题，而且每一次版本的升级也都非常顺滑。

这不仅说明 React 在 API 的设计上经受住了考验，同时也可以看到 React 团队在稳定 API 上所做的努力。

但是即使在这样稳定的背景下，React 还是在两年前的 16.8 版本中推出了一套全新的 Hooks 机制。新版本的发布，在让人耳目一新的同时，也让广大早就恐惧于各种新轮子的前端同学心头一紧，产生本能的抗拒：这是什么东西？有什么用？难道现在的 API 不好吗？

如果你也有这样的疑问，那我要先给你吃一颗定心丸，这样的担忧是没有必要的。

其实对于 React 开发而言，这只是多了一个选择。因为原来的基于 Class 的组件完全可以继续使用，这意味着这两种开发方式可以并存，已有代码不需要做任何改动，而新的代码可以根据具体情况采用 Hooks 的方式来实现就行了。

那么，既然在追求极致向后兼容性的原则下，React 还是推出了新的 Hooks API，一定有其过人的一面。所以今天这节课，我就跟你一起探讨它的过人之处究竟是什么；为什么要发明 Hooks；以及它所解决的问题是什么。

重新思考 React 组件的本质

React 组件的模型其实很直观，就是从 Model 到 View 的映射，这里的 Model 对应到 React 中就是 state 和 props。如下图所示：

在过去，我们需要处理当 Model 变化时，DOM 节点应该如何变化的细节问题。而现在，我们只需要通过 JSX，根据 Model 的数据用声明的方式去描述 UI 的最终展现就可以了，因为 React 会帮助你处理所有 DOM 变化的细节。而且，当 Model 中的状态发生变化时，UI 会自动变化，即所谓的数据绑定。

所以呢，我们可以把 UI 的展现看成一个函数的执行过程。其中，Model 是输入参数，函数的执行结果是 DOM 树，也就是 View。而 React 要保证的，就是每当 Model 发生变化时，函数会重新执行，并且生成新的 DOM 树，然后 React 再把新的 DOM 树以最优的方式更新到浏览器。

既然如此，使用 Class 作为组件是否真的合适呢？Class 在作为 React 组件的载体时，是否用了它所有的功能呢？如果你仔细思考，会发现使用 Class 其实是有点牵强的，主要有两方面的原因。

一方面，React 组件之间是不会互相继承的。比如说，你不会创建一个 Button 组件，然后再创建一个 DropdownButton 来继承 Button。所以说，React 中其实是没有利用到 Class 的继承特性的。

另一方面，因为所有 UI 都是由状态驱动的，因此很少会在外部去调用一个类实例（即组件）的方法。要知道，组件的所有方法都是在内部调用，或者作为生命周期方法被自动调用的。

所以你看，这两个 Class 最重要的特性其实都没有用到。而在 React 出现之时，主流的方式还是基于对象去考虑问题。例如获得一个对话框的实例，然后通过 dialog.show(), dialog.hide() 这样的方式细粒度地去控制 UI 的变化。

而这在 React 中其实是没有必要的，因为所有的 UI 都是声明出来的，不用处理细节的变化过程。因此，通过函数去描述一个组件才是最为自然的方式。这也是为什么 React 很早就提供了函数组件的机制。

只是当时有一个局限是，函数组件无法存在内部状态，必须是纯函数，而且也无法提供完整的生命周期机制。这就极大限制了函数组件的大规模使用。

那么我们自然就知道了，Class 作为 React 组件的载体，也许并不是最适合，反而函数是更适合去描述 State => View 这样的一个映射，但是函数组件又没有 State ，也没有生命周期方法。以此来看，我们应该如何去改进呢？

Hooks 是如何诞生的？

其实顺着函数组件的思路继续思考，就会发现，如果我们想要让函数组件更有用，目标就是给函数组件加上状态。这看上去似乎并不是难事。

简单想一下，函数和对象不同，并没有一个实例的对象能够在多次执行之间保存状态，那势必需要一个函数之外的空间来保存这个状态，而且要能够检测其变化，从而能够触发函数组件的重新渲染。

再进一步想，那我们是不是就是需要这样一个机制，能够把一个外部的数据绑定到函数的执行。当数据变化时，函数能够自动重新执行。这样的话，任何会影响 UI 展现的外部数据，都可以通过这个机制绑定到 React 的函数组件。

在 React 中，这个机制就是 Hooks。

所以我们现在也能够理解这个机制为什么叫 Hooks 了。顾名思义，Hook 就是“钩子”的意思。在 React 中，Hooks 就是把某个目标结果钩到某个可能会变化的数据源或者事件源上，那么当被钩到的数据或事件发生变化时，产生这个目标结果的代码会重新执行，产生更新后的结果。

对于函数组件，这个结果是最终的 DOM 树；对于 useCallback、useMemo 这样与缓存相关的组件，则是在依赖项发生变化时去更新缓存。所以 Hooks 的结构可以如下图所示：

从图中可以看到，一个执行过程（Execution），例如是函数组件本身，可以绑定在（钩在）传统意义的 State，或者 URL，甚至可以是窗口的大小。这样当 State、URL、窗口大小发生变化时，都会重新执行某个函数，产生更新后的结果。

当然，既然我们的初衷是为了实现 UI 组件的渲染，那么在 React 中，其实所有的 Hooks 的最终结果都是导致 UI 的变化。但是正如 React 官方曾经提到过的，Hooks 的思想其实不仅可以用在 React，在其它一些场景也可以被利用。

通过这样的思考，你应该能够理解 Hooks 诞生的来龙去脉了。比起 Class 组件，函数组件是更适合去表达 React 组件的执行的，因为它更符合 State => View 这样的一个逻辑关系。但是因为缺少状态、生命周期等机制，让它一直功能受限。而现在有了 Hooks，函数组件的力量终于能真正发挥出来了！

不过这里有一点需要特别注意，Hooks 中被钩的对象，不仅可以是某个独立的数据源，也可以是另一个 Hook 执行的结果，这就带来了 Hooks 的最大好处：逻辑的复用。

Hooks 带来的最大好处：逻辑复用

在之前的 React 使用中，有一点经常被大家诟病，就是非常难以实现逻辑的复用，必须借助于高阶组件等复杂的设计模式。但是高阶组件会产生冗余的组件节点，让调试变得困难。不过这些问题可以通过 Hooks 得到很好的解决。所以如果有人问你 Hooks 有什么好处，那么最关键的答案就是简化了逻辑复用。

就以刚才我们提到的绑定窗口大小的场景为例。如果有多个组件需要在用户调整浏览器窗口大小时，重新调整布局，那么我们需要把这样的逻辑提取成一个公共的模块供多个组件使用。以 React 思想，在 JSX 中我们会根据 Size 大小来渲染不同的组件，例如：

function render() {
  if (size === "small") return <SmallComponent />;
  else return <LargeComponent />;
}

这段代码看上去简单，但如果在过去的 Class 组件中，我们甚至需要一个比较复杂的设计模式来解决，这就是高阶组件。所以接下来我们不妨通过实际的代码来进行一下比较，如果之前没有用过 React 也没有关系，你只需根据字面含义进行大概的理解即可。

在 Class 组件的场景下，我们首先需要定义一个高阶组件，负责监听窗口大小变化，并将变化后的值作为 props 传给下一个组件。

const withWindowSize = Component => {
  // 产生一个高阶组件 WrappedComponent，只包含监听窗口大小的逻辑
  class WrappedComponent extends React.PureComponent {
    constructor(props) {
      super(props);
      this.state = {
        size: this.getSize()
      };
    }
    componentDidMount() {
      window.addEventListener("resize", this.handleResize);
    }
    componentWillUnmount() {
      window.removeEventListener("resize", this.handleResize);
    }
    getSize() {
      return window.innerWidth > 1000 ? "large" ："small";
    }
    handleResize = ()=> {
      const currentSize = this.getSize();
      this.setState({
        size: this.getSize()
      });
    }
    render() {
      // 将窗口大小传递给真正的业务逻辑组件
      return <Component size={this.state.size} />;
    }
  }
  return WrappedComponent;
};

这样，在你的自定义组件中可以调用 withWindowSize 这样的函数来产生一个新组件，并自带 size 属性，例如：

class MyComponent extends React.Component{
  render() {
    const { size } = this.props;
    if (size === "small") return <SmallComponent />;
    else return <LargeComponent />;
  }
}
// 使用 withWindowSize 产生高阶组件，用于产生 size 属性传递给真正的业务组件
export default withWindowSize(MyComponent);

在这里，我们可以看到，为了传递一个外部的状态，我们不得不定义一个没有 UI 的外层组件，而这个组件只是为了封装一段可重用的逻辑。更为糟糕的是，高阶组件几乎是 Class 组件中实现代码逻辑复用的唯一方式，其缺点其实比较显然：

代码难理解，不直观，很多人甚至宁愿重复代码，也不愿用高阶组件；

会增加很多额外的组件节点。每一个高阶组件都会多一层节点，这就会给调试带来很大的负担。

可以说，正因为这些缺点被抱怨已久，React 团队才终于提出了 Hooks 这样一个全新的方案。那么现在我们不妨看一下，同样的逻辑如果用 Hooks 和函数组件该如何实现。首先我们需要实现一个 Hooks：

const getSize = () => {
  return window.innerWidth > 1000 ? "large" : "small";
}
const useWindowSize = () => {
  const [size, setSize] = useState(getSize());
  useEffect(() => {
  const handler = () => {
      setSize(getSize())
    };
    window.addEventListener('resize', handler);
    return () => {
      window.removeEventListener('resize', handler);
    };
  }, []);

  return size;
};

这样，我们在组件中使用窗口大小就会非常简单：

const Demo = () => {
  const size = useWindowSize();
  if (size === "small") return <SmallComponent />;
  else return <LargeComponent />;
};

可以看到，窗口大小是一个外部的一个数据状态，我们通过 Hooks 的方式对其进行了封装，从而将其变成一个可绑定的数据源。这样当窗口大小发生变化时，使用这个 Hook 的组件就都会重新渲染。而且代码也更加简洁和直观，不会产生额外的组件节点。

Hooks 的另一大好处：有助于关注分离

除了逻辑复用之外，Hooks 能够带来的另外一大好处就是有助于关注分离，意思是说 Hooks 能够让针对同一个业务逻辑的代码尽可能聚合在一块儿。这是过去在 Class 组件中很难做到的。因为在 Class 组件中，你不得不把同一个业务逻辑的代码分散在类组件的不同生命周期的方法中。

所以通过 Hooks 的方式，把业务逻辑清晰地隔离开，能够让代码更加容易理解和维护。

仍然以上面监听浏览器窗口大小的变化为例，我们来看 Hooks 是如何做到关注分离的。在过去的 Class 组件中，我们需要在 componentDidMount 中监听事件，在 componentWillUnmount 中去解绑事件。而在函数组件中，我们可以把所有逻辑写在一起。

React 社区曾用一张图直观地展现了对比结果：

图的左侧是 Class 组件，右侧是函数组件结合 Hooks。蓝色和黄色代表不同的业务功能。可以看到，在 Class 组件中，代码是从技术角度组织在一起的，例如在 componentDidMount 中都去做一些初始化的事情。而在函数组件中，代码是从业务角度组织在一起的，相关代码能够出现在集中的地方，从而更容易理解和维护。

小结

Hooks 作为 React 自诞生以来最大的一个新功能，可以说得到了普遍好评，成为了 React 开发的主流方式。而它也在一定程度上更好地体现了 React 的开发思想，即从 State => View 的函数式映射。

此外， Hooks 也解决了 Class 组件存在的一些代码冗余、难以逻辑复用的问题。但是正如我在这节课开头提到的，两种方式在 React 开发中几乎是完全等价的，没有绝对的优劣。如果你决定开始使用 Hooks，那么对于已有的 Class 组件，其实是完全没必要进行立刻重构的。只要在新的功能上，再来使用函数组件和 Hooks 就可以了。

思考题

文中举了窗口大小作为 Hooks 的数据源的例子，你还能想到哪些可能需要绑定到 React 组件的数据源？

欢迎在评论区写下你的想法和思考，我们一起交流讨论。如果今天这节课让你有所收获的话，也欢迎你把课程分享给你的同事、朋友，我们一起共同进步。

03｜内置 Hooks（1）：如何保存组件状态和使用生命周期？

你好，我是王沛。这节课我们一起来学习基本 Hooks 的用法。

如果你用过基于类的组件，那么对组件的生命周期函数一定不会陌生，例如 componentDidMount, componentDidUpdate，等等。如果没有使用过，那也没关系，因为在今天这节课里，你会看到基于 Hooks 去考虑组件的实现，这会是一个非常不同的思路，你完全不用去关心一个组件的生命周期是怎样的。

特别是如果你已经习惯了类组件的开发，那么要做的，甚至是彻底忘掉那些生命周期方法。不要遇到一个需求，就映射到这个功能该在哪个生命周期中去做，然后又要去想原来的声明周期方法在函数组件中应该怎么用 Hooks 去实现。

正确的思路应该是遇到需求时，直接考虑在 Hooks 中去如何实现。

React 提供的 Hooks 其实非常少，一共只有 10 个，比如 useState、useEffect、useCallback、useMemo、useRef、useContext 等等。这一讲我们会先学习 useState 和 useEffect 这两个最为核心的 Hooks。下一讲则会介绍另外四个常用的 Hooks。掌握了这些 Hooks，你就能进行 90% 的 React 开发了。

不过在讲之前我想强调一点，这些 Hooks 的功能其实非常简单，多看看官方文档就可以了。因为这节课的目的，其实是让你学会如何用 Hooks 的思路去进行功能的实现。

useState：让函数组件具有维持状态的能力

在第一讲中，你已经知道了 state 是 React 组件的一个核心机制，那么 useState 这个 Hook 就是用来管理 state 的，它可以让函数组件具有维持状态的能力。也就是说，在一个函数组件的多次渲染之间，这个 state 是共享的。下面这个例子就显示了 useState 的用法：

import React, { useState } from 'react';

function Example() {
  // 创建一个保存 count 的 state，并给初始值 0
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>
        +
      </button>
    </div>
  );
}

在这个例子中，我们声明了一个名为 count 的 state，并得到了设置这个 count 值的函数 setCount。当调用 setCount 时，count 这个 state 就会被更新，并触发组件的刷新。那么 useState 这个 Hook 的用法总结出来就是这样的：

useState(initialState) 的参数 initialState 是创建 state 的初始值，它可以是任意类型，比如数字、对象、数组等等。

useState() 的返回值是一个有着两个元素的数组。第一个数组元素用来读取 state 的值，第二个则是用来设置这个 state 的值。在这里要注意的是，state 的变量（例子中的 count）是只读的，所以我们必须通过第二个数组元素 setCount 来设置它的值。

如果要创建多个 state，那么我们就需要多次调用 useState。比如要创建多个 state，使用的代码如下：

// 定义一个年龄的 state，初始值是 42
const [age, setAge] = useState(42);
// 定义一个水果的 state，初始值是 banana
const [fruit, setFruit] = useState('banana');
// 定一个一个数组 state，初始值是包含一个 todo 的数组
const [todos, setTodos] = useState([{ text: 'Learn Hooks' }]);

从这段代码可以看到，useState 是一个非常简单的 Hook，它让你很方便地去创建一个状态，并提供一个特定的方法（比如 setAge ）来设置这个状态。

如果你之前用过类组件，那么这里的 useState 就和类组件中的 setState 非常类似。不过两者最大的区别就在于，类组件中的 state 只能有一个。所以我们一般都是把一个对象作为 一个 state，然后再通过不同的属性来表示不同的状态。而函数组件中用 useState 则可以很容易地创建多个 state，所以它更加语义化。

可以说，state 是 React 组件非常重要的一个机制，那么什么样的值应该保存在 state 中呢？这是日常开发中需要经常思考的问题。通常来说，我们要遵循的一个原则就是：state 中永远不要保存可以通过计算得到的值。比如说：

从 props 传递过来的值。有时候 props 传递过来的值无法直接使用，而是要通过一定的计算后再在 UI 上展示，比如说排序。那么我们要做的就是每次用的时候，都重新排序一下，或者利用某些 cache 机制，而不是将结果直接放到 state 里。

从 URL 中读到的值。比如有时需要读取 URL 中的参数，把它作为组件的一部分状态。那么我们可以在每次需要用的时候从 URL 中读取，而不是读出来直接放到 state 里。

从 cookie、localStorage 中读取的值。通常来说，也是每次要用的时候直接去读取，而不是读出来后放到 state 里。

不过，state 虽然便于维护状态，但也有自己的弊端。一旦组件有自己状态，意味着组件如果重新创建，就需要有恢复状态的过程，这通常会让组件变得更复杂。

比如一个组件想在服务器端请求获取一个用户列表并显示，如果把读取到的数据放到本地的 state 里，那么每个用到这个组件的地方，就都需要重新获取一遍。

而如果通过一些状态管理框架，去管理所有组件的 state 的话，比如我在第 7 讲会介绍的 Redux，那么组件本身就可以是无状态的。无状态组件可以成为更纯粹的表现层，没有太多的业务逻辑，从而更易于使用、测试和维护。

useEffect：执行副作用

useEffect ，顾名思义，用于执行一段副作用。

什么是副作用呢？通常来说，副作用是指一段和当前执行结果无关的代码。比如说要修改函数外部的某个变量，要发起一个请求，等等。也就是说，在函数组件的当次执行过程中，useEffect 中代码的执行是不影响渲染出来的 UI 的。

我们先来看一下它的具体用法。useEffect 可以接收两个参数，函数签名如下：

useEffect(callback, dependencies)

第一个为要执行的函数 callback，第二个是可选的依赖项数组 dependencies。其中依赖项是可选的，如果不指定，那么 callback 就会在每次函数组件执行完后都执行；如果指定了，那么只有依赖项中的值发生变化的时候，它才会执行。

对应到 Class 组件，那么 useEffect 就涵盖了 ComponentDidMount、componentDidUpdate 和 componentWillUnmount 三个生命周期方法。不过如果你习惯了使用 Class 组件，那千万不要按照把 useEffect 对应到某个或者某几个生命周期的方法。你只要记住，useEffect 是每次组件 render 完后判断依赖并执行就可以了。

举个例子，某个组件用于显示一篇 Blog 文章，那么这个组件会接收一个参数来表示 Blog 的 ID。而当 ID 发生变化时，组件需要发起请求来获取文章内容并展示：

import React, { useState, useEffect } from "react";

function BlogView({ id }) {
  // 设置一个本地 state 用于保存 blog 内容
  const [blogContent, setBlogContent] = useState(null);

  useEffect(() => {
    // useEffect 的 callback 要避免直接的 async 函数，需要封装一下
    const doAsync = async () => {
      // 当 id 发生变化时，将当前内容清楚以保持一致性
      setBlogContent(null);
      // 发起请求获取数据
      const res = await fetch(`/blog-content/${id}`);
      // 将获取的数据放入 state
      setBlogContent(await res.text());
    };
    doAsync();
  }, [id]); // 使用 id 作为依赖项，变化时则执行副作用

  // 如果没有 blogContent 则认为是在 loading 状态
  const isLoading = !blogContent;
  return <div>{isLoading ? "Loading..." : blogContent}</div>;
}

这样，我们就利用 useEffect 完成了一个简单的数据请求的需求。在这段代码中，我们把 ID 作为依赖项参数，这样就很自然地在 ID 发生变化时，利用 useEffect 执行副作用去获取数据。如果在之前的类组件中要完成类似的需求，我们就需要在 componentDidUpdate 这个方法里，自己去判断两次 ID 是否发生了变化。如果变了，才去发起请求。这样的话，逻辑上就不如 useEffect 来得直观。

useEffect 还有两个特殊的用法：没有依赖项，以及依赖项作为空数组。我们来具体分析下。

没有依赖项，则每次 render 后都会重新执行。例如：

useEffect(() => {
  // 每次 render 完一定执行
  console.log('re-rendered');
});

空数组作为依赖项，则只在首次执行时触发，对应到 Class 组件就是 componentDidMount。例如：

useEffect(() => {
  // 组件首次渲染时执行，等价于 class 组件中的 componentDidMount
  console.log('did mount');
}, [])

除了这些机制之外，useEffect 还允许你返回一个函数，用于在组件销毁的时候做一些清理的操作。比如移除事件的监听。这个机制就几乎等价于类组件中的 componentWillUnmount。举个例子，在组件中，我们需要监听窗口的大小变化，以便做一些布局上的调整：

// 设置一个 size 的 state 用于保存当前窗口尺寸
const [size, setSize] = useState({});
useEffect(() => {
  // 窗口大小变化事件处理函数
  const handler = () => {
    setSize(getSize());
  };
  // 监听 resize 事件
  window.addEventListener('resize', handler);

  // 返回一个 callback 在组件销毁时调用
  return () => {
    // 移除 resize 事件
    window.removeEventListener('resize', handler);
  };
}, []);

通过这样一个简单的机制，我们能够更好地管理副作用，从而确保组件和副作用的一致性。

总结一下，useEffect 让我们能够在下面四种时机去执行一个回调函数产生副作用：

每次 render 后执行：不提供第二个依赖项参数。比如

useEffect(() => {})。

仅第一次 render 后执行：提供一个空数组作为依赖项。比如

useEffect(() => {}, [])。

第一次以及依赖项发生变化后执行：提供依赖项数组。比如

useEffect(() => {}, [deps])。

组件 unmount 后执行：返回一个回调函数。比如

useEffect() => { return () => {} }, [])。

理解 Hooks 的依赖

在 useEffect Hook 中你已经看到了依赖项的概念。其实除了在 useEffect 中会用到外，在第 5 讲的 useCallback 和 useMemo 中也会用到。所以接下来我们就先透彻了解它的工作机制，帮助你在实际开发中能够正确地使用。

正如在第 2 讲中介绍的，Hooks 提供了让你监听某个数据变化的能力。这个变化可能会触发组件的刷新，也可能是去创建一个副作用，又或者是刷新一个缓存。那么定义要监听哪些数据变化的机制，其实就是指定 Hooks 的依赖项。

不过需要注意的是，依赖项并不是内置 Hooks 的一个特殊机制，而可以认为是一种设计模式。有类似需求的 Hooks 都可以用这种模式去实现。

那么在定义依赖项时，我们需要注意以下三点：

依赖项中定义的变量一定是会在回调函数中用到的，否则声明依赖项其实是没有意义的。

依赖项一般是一个常量数组，而不是一个变量。因为一般在创建 callback 的时候，你其实非常清楚其中要用到哪些依赖项了。

React 会使用浅比较来对比依赖项是否发生了变化，所以要特别注意数组或者对象类型。如果你是每次创建一个新对象，即使和之前的值是等价的，也会被认为是依赖项发生了变化。这是一个刚开始使用 Hooks 时很容易导致 Bug 的地方。例如下面的代码：

function Sample() {
  // 这里在每次组件执行时创建了一个新数组
  const todos = [{ text: 'Learn hooks.'}];
  useEffect(() => {
    console.log('Todos changed.');
  }, [todos]);
}

代码的原意可能是在 todos 变化的时候去产生一些副作用，但是这里的 todos 变量是在函数内创建的，实际上每次都产生了一个新数组。所以在作为依赖项的时候进行引用的比较，实际上被认为是发生了变化的。

掌握 Hooks 的使用规则

Hooks 本身作为纯粹的 JavaScript 函数，不是通过某个特殊的 API 去创建的，而是直接定义一个函数。它需要在降低学习和使用成本的同时，还需要遵循一定的规则才能正常工作。因而 Hooks 的使用规则包括以下两个：只能在函数组件的顶级作用域使用；只能在函数组件或者其他 Hooks 中使用。

Hooks 只能在函数组件的顶级作用域使用

所谓顶层作用域，就是 Hooks 不能在循环、条件判断或者嵌套函数内执行，而必须是在顶层。同时 Hooks 在组件的多次渲染之间，必须按顺序被执行。因为在 React 组件内部，其实是维护了一个对应组件的固定 Hooks 执行列表的，以便在多次渲染之间保持 Hooks 的状态，并做对比。

比如说下面的代码是可行的，因为 Hooks 一定会被执行到：

function MyComp() {
  const [count, setCount] = useState(0);
  // ...
  return <div>{count}</div>;
}

而下面的代码是错误的，因为在某些条件下 Hooks 是不会被执行到的：

function MyComp() {
  const [count, setCount] = useState(0);
  if (count > 10) {
    // 错误：不能将 Hook 用在条件判断里
    useEffect(() => {
      // ...
    }, [count])
  }

  // 这里可能提前返回组件渲染结果，后面就不能再用 Hooks 了
  if (count === 0) {
    return 'No content';
  }

  // 错误：不能将 Hook 放在可能的 return 之后
  const [loading, setLoading] = useState(false);

  //...
  return <div>{count}</div>
}

所以 Hooks 的这个规则可以总结为两点：第一，所有 Hook 必须要被执行到。第二，必须按顺序执行。

Hooks 只能在函数组件或者其它 Hooks 中使用

Hooks 作为专门为函数组件设计的机制，使用的情况只有两种，一种是在函数组件内，另外一种则是在自定义的 Hooks 里面。

这个规则在函数组件和类组件同时存在的项目中，可能会造成一定的困扰，因为 Hooks 简洁、直观，我们可能都倾向于用 Hooks 来实现逻辑的重用，但是如果一定要在 Class 组件中使用，那应该如何做呢？其实有一个通用的机制，那就是利用高阶组件的模式，将 Hooks 封装成高阶组件，从而让类组件使用。

举个例子。我们已经定义了监听窗口大小变化的一个 Hook：useWindowSize。那么很容易就可以将其转换为高阶组件：

import React from 'react';
import { useWindowSize } from '../hooks/useWindowSize';

export const withWindowSize = (Comp) => {
  return props => {
    const windowSize = useWindowSize();
    return <Comp windowSize={windowSize} {...props} />;
  };
};

那么我们就可以通过如下代码来使用这个高阶组件：

import React from 'react';
import { withWindowSize } from './withWindowSize';

class MyComp {
  render() {
    const { windowSize } = this.props;
    // ...
  }
}

// 通过 withWindowSize 高阶组件给 MyComp 添加 windowSize 属性
export default withWindowSize(MyComp);

这样，通过 withWindowSize 这样一个高阶组件模式，你就可以把 useWindowSize 的结果作为属性，传递给需要使用窗口大小的类组件，这样就可以实现在 Class 组件中复用 Hooks 的逻辑了。

使用 ESLint 插件帮助检查 Hooks 的使用

刚才你已经看到了使用 Hooks 的一些特性和要遵循的规则，那么应用到日常的开发中，就必须时刻注意不能写错。我总结了一下，包括这么三点：

在 useEffect 的回调函数中使用的变量，都必须在依赖项中声明；

Hooks 不能出现在条件语句或者循环中，也不能出现在 return 之后；

Hooks 只能在函数组件或者自定义 Hooks 中使用。

那么你可能要问，要保证完全遵循规则，看上去好像挺困难的，必须得非常小心，不知道有什么好办法可以帮助掌握吗？贴心的 React 官方为我们提供了一个 ESLint 的插件，专门用来检查 Hooks 是否正确被使用，它就是 eslint-plugin-react-hooks 。

通过这个插件，如果发现缺少依赖项定义这样违反规则的情况，就会报一个错误提示（类似于语法错误的提示），方便进行修改，从而避免 Hooks 的错误使用。

使用的方法也很简单。首先，我们通过 npm 或者 yarn 安装这个插件：

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

然后在你的 ESLint 配置文件中加入两个规则：rules-of-hooks 和 exhaustive-deps。如下：

{
  "plugins": [
    // ...
    "react-hooks"
  ],
  "rules": {
    // ...
    // 检查 Hooks 的使用规则
    "react-hooks/rules-of-hooks": "error",
    // 检查依赖项的声明
    "react-hooks/exhaustive-deps": "warn"
  }
}

要知道，这个插件几乎是 React 函数组件开发必备的工具，能够避免很多可能看上去很奇怪的错误。所以作为开始开发的第一步，一定要安装并配置好这个插件。

小结

在这节课，我们学习了 useState 和 useEffect 这两个核心 Hooks 的用法，一个用于保存状态，一个用于执行副作用。可以说，掌握了这两个 Hooks，几乎就能完成大部分的 React 的开发了。

同时，根据这两个 Hooks 的用法实例，我们还进一步学习了 Hooks 依赖项的含义，以及使用规则。通过定义不同类型的依赖项，你就可以在组件的不同生命周期中去执行不同的逻辑。

要知道，理解了这些机制是一劳永逸的，因为无论是其它的内置 Hooks，还是自定义 Hooks，都是一样的，这将有助于后面 Hooks 的学习。

思考题

在 useEffect 中如果使用了某些变量，却没有在依赖项中指定，会发生什么呢？

对于这节课中显示的 Blog 文章的例子，我们在 useEffect 中使用了 setBlogContent 这样一个函数，本质上它也是一个局部变量，那么这个函数需要被作为依赖项吗？为什么？

欢迎在评论区分享你的想法和思考，我会和你一起交流讨论！我们下节课再见！

04｜内置 Hooks（2）：为什么要避免重复定义回调函数？

你好，我是王沛。这节课我们来继续学习内置 Hooks 的用法。

在上节课你已经看到了 useState 和 useEffect 这两个最为核心的 Hooks 的用法。理解了它们，你基本上就掌握了 React 函数组件的开发思路。

但是还有一些细节问题，例如事件处理函数会被重复定义、数据计算过程没有缓存等，还都需要一些机制来处理。所以在这节课，你会看到其它四个最为常用的内置 Hooks （包括 useCallback、useMemo、useRef 和 useContext）的作用和用法，以及如何利用这些 Hooks 进行功能开发。

useCallback：缓存回调函数

在 React 函数组件中，每一次 UI 的变化，都是通过重新执行整个函数来完成的，这和传统的 Class 组件有很大区别：函数组件中并没有一个直接的方式在多次渲染之间维持一个状态。

比如下面的代码中，我们在加号按钮上定义了一个事件处理函数，用来让计数器加 1。但是因为定义是在函数组件内部，因此在多次渲染之间，是无法重用 handleIncrement 这个函数的，而是每次都需要创建一个新的：

function Counter() {
  const [count, setCount] = useState(0);
  const handleIncrement = () => setCount(count + 1);
  // ...
  return <button onClick={handleIncrement}>+</button>
}

你不妨思考下这个过程。每次组件状态发生变化的时候，函数组件实际上都会重新执行一遍。在每次执行的时候，实际上都会创建一个新的事件处理函数 handleIncrement。这个事件处理函数中呢，包含了 count 这个变量的闭包，以确保每次能够得到正确的结果。

这也意味着，即使 count 没有发生变化，但是函数组件因为其它状态发生变化而重新渲染时，这种写法也会每次创建一个新的函数。创建一个新的事件处理函数，虽然不影响结果的正确性，但其实是没必要的。因为这样做不仅增加了系统的开销，更重要的是：每次创建新函数的方式会让接收事件处理函数的组件，需要重新渲染。

比如这个例子中的 button 组件，接收了 handleIncrement ，并作为一个属性。如果每次都是一个新的，那么这个 React 就会认为这个组件的 props 发生了变化，从而必须重新渲染。因此，我们需要做到的是：只有当 count 发生变化时，我们才需要重新定一个回调函数。而这正是 useCallback 这个 Hook 的作用。

它的 API 签名如下：

useCallback(fn, deps)

这里 fn 是定义的回调函数，deps 是依赖的变量数组。只有当某个依赖变量发生变化时，才会重新声明 fn 这个回调函数。那么对于上面的例子，我们可以把 handleIncrement 这个事件处理函数通过 useCallback 来进行性能的优化：

import React, { useState, useCallback } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  const handleIncrement = useCallback(
    () => setCount(count + 1),
    [count], // 只有当 count 发生变化时，才会重新创建回调函数
  );
  // ...
  return <button onClick={handleIncrement}>+</button>
}

在这里，我们把 count 这个 state ，作为一个依赖传递给 useCallback。这样，只有 count 发生变化的时候，才需要重新创建一个回调函数，这样就保证了组件不会创建重复的回调函数。而接收这个回调函数作为属性的组件，也不会频繁地需要重新渲染。

除了 useCallback，useMemo 也是为了缓存而设计的。只不过，useCallback 缓存的是一个函数，而 useMemo 缓存的是计算的结果。那么接下来，我们就一起学习下 useMemo 的用法吧。

useMemo：缓存计算的结果

useMemo 的 API 签名如下：

useMemo(fn, deps);

这里的 fn 是产生所需数据的一个计算函数。通常来说，fn 会使用 deps 中声明的一些变量来生成一个结果，用来渲染出最终的 UI。

这个场景应该很容易理解：如果某个数据是通过其它数据计算得到的，那么只有当用到的数据，也就是依赖的数据发生变化的时候，才应该需要重新计算。

举个例子，对于一个显示用户信息的列表，现在需要对用户名进行搜索，且 UI 上需要根据搜索关键字显示过滤后的用户，那么这样一个功能需要有两个状态：

用户列表数据本身：来自某个请求。

搜索关键字：用户在搜索框输入的数据。

无论是两个数据中的哪一个发生变化，都需要过滤用户列表以获得需要展示的数据。那么如果不使用 useMemo 的话，就需要用这样的代码实现：

import React, { useState, useEffect } from "react";

export default function SearchUserList() {
  const [users, setUsers] = useState(null);
  const [searchKey, setSearchKey] = useState("");

  useEffect(() => {
    const doFetch = async () => {
      // 组件首次加载时发请求获取用户数据
      const res = await fetch("https://reqres.in/api/users/");
      setUsers(await res.json());
    };
    doFetch();
  }, []);
  let usersToShow = null;

  if (users) {
    // 无论组件为何刷新，这里一定会对数组做一次过滤的操作
    usersToShow = users.data.filter((user) =>
      user.first_name.includes(searchKey),
    );
  }

  return (
    <div>
      <input
        type="text"
        value={searchKey}
        onChange={(evt) => setSearchKey(evt.target.value)}
      />
      <ul>
        {usersToShow &&
          usersToShow.length > 0 &&
          usersToShow.map((user) => {
            return <li key={user.id}>{user.first_name}</li>;
          })}
      </ul>
    </div>
  );
}

在这个例子中，无论组件为何要进行一次重新渲染，实际上都需要进行一次过滤的操作。但其实你只需要在 users 或者 searchKey 这两个状态中的某一个发生变化时，重新计算获得需要展示的数据就行了。那么，这个时候，我们就可以用 useMemo 这个 Hook 来实现这个逻辑，缓存计算的结果：

//...
// 使用 userMemo 缓存计算的结果
const usersToShow = useMemo(() => {
    if (!users) return null;
    return users.data.filter((user) => {
      return user.first_name.includes(searchKey));
    }
  }, [users, searchKey]);
//...

可以看到，通过 useMemo 这个 Hook，可以避免在用到的数据没发生变化时进行的重复计算。虽然例子展示的是一个很简单的场景，但如果是一个复杂的计算，那么对于提升性能会有很大的帮助。这也是 userMemo 的一大好处：避免重复计算。

除了避免重复计算之外，useMemo 还有一个很重要的好处：避免子组件的重复渲染。比如在例子中的 usersToShow 这个变量，如果每次都需要重新计算来得到，那么对于 UserList 这个组件而言，就会每次都需要刷新，因为它将 usersToShow 作为了一个属性。而一旦能够缓存上次的结果，就和 useCallback 的场景一样，可以避免很多不必要的组件刷新。

这个时候，如果我们结合 useMemo 和 useCallback 这两个 Hooks 一起看，会发现一个有趣的特性，那就是 useCallback 的功能其实是可以用 useMemo 来实现的。比如下面的代码就是利用 useMemo 实现了 useCallback 的功能：

 const myEventHandler = useMemo(() => {
   // 返回一个函数作为缓存结果
   return () => {
     // 在这里进行事件处理
   }
 }, [dep1, dep2]);

理解了这一点，相信你一下子会对这两个 Hooks 的机制有更进一步的认识，也就不用死记硬背两个 API 都是干嘛的了，因为从本质上来说，它们只是做了同一件事情：建立了一个绑定某个结果到依赖数据的关系。只有当依赖变了，这个结果才需要被重新得到。

useRef：在多次渲染之间共享数据

函数组件虽然非常直观，简化了思考 UI 实现的逻辑，但是比起 Class 组件，还缺少了一个很重要的能力：在多次渲染之间共享数据。

在类组件中，我们可以定义类的成员变量，以便能在对象上通过成员属性去保存一些数据。但是在函数组件中，是没有这样一个空间去保存数据的。因此，React 让 useRef 这样一个 Hook 来提供这样的功能。

useRef 的 API 签名如下：

const myRefContainer = useRef(initialValue);

我们可以把 useRef 看作是在函数组件之外创建的一个容器空间。在这个容器上，我们可以通过唯一的 current 属设置一个值，从而在函数组件的多次渲染之间共享这个值。

你可能会有疑问，useRef 的这个功能具体有什么用呢？我们可以看一个例子。

假设你要去做一个计时器组件，这个组件有开始和暂停两个功能。很显然，你需要用 window.setInterval 来提供计时功能；而为了能够暂停，你就需要在某个地方保存这个 window.setInterval 返回的计数器的引用，确保在点击暂停按钮的同时，也能用 window.clearInterval 停止计时器。那么，这个保存计数器引用的最合适的地方，就是 useRef，因为它可以存储跨渲染的数据。代码如下：

import React, { useState, useCallback, useRef } from "react";

export default function Timer() {
  // 定义 time state 用于保存计时的累积时间
  const [time, setTime] = useState(0);

  // 定义 timer 这样一个容器用于在跨组件渲染之间保存一个变量
  const timer = useRef(null);

  // 开始计时的事件处理函数
  const handleStart = useCallback(() => {
    // 使用 current 属性设置 ref 的值
    timer.current = window.setInterval(() => {
      setTime((time) => time + 1);
    }, 100);
  }, []);

  // 暂停计时的事件处理函数
  const handlePause = useCallback(() => {
    // 使用 clearInterval 来停止计时
    window.clearInterval(timer.current);
    timer.current = null;
  }, []);

  return (
    <div>
      {time / 10} seconds.
      <br />
      <button onClick={handleStart}>Start</button>
      <button onClick={handlePause}>Pause</button>
    </div>
  );
}

这里可以看到，我们使用了 useRef 来创建了一个保存 window.setInterval 返回句柄的空间，从而能够在用户点击暂停按钮时清除定时器，达到暂停计时的目的。

同时你也可以看到，使用 useRef 保存的数据一般是和 UI 的渲染无关的，因此当 ref 的值发生变化时，是不会触发组件的重新渲染的，这也是 useRef 区别于 useState 的地方。

除了存储跨渲染的数据之外，useRef 还有一个重要的功能，就是保存某个 DOM 节点的引用。我们知道，在 React 中，几乎不需要关心真实的 DOM 节点是如何渲染和修改的。但是在某些场景中，我们必须要获得真实 DOM 节点的引用，所以结合 React 的 ref 属性和 useRef 这个 Hook，我们就可以获得真实的 DOM 节点，并对这个节点进行操作。

比如说，你需要在点击某个按钮时让某个输入框获得焦点，可以通过下面的代码来实现：

function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  const onButtonClick = () => {
    // current 属性指向了真实的 input 这个 DOM 节点，从而可以调用 focus 方法
    inputEl.current.focus();
  };
  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

这段代码是 React 官方文档提供的一个例子，可以看到 ref 这个属性提供了获得 DOM 节点的能力，并利用 useRef 保存了这个节点的应用。这样的话，一旦 input 节点被渲染到界面上，那我们通过 inputEl.current 就能访问到真实的 DOM 节点的实例了。

useContext：定义全局状态

在第 2 节课中你已经知道了，React 组件之间的状态传递只有一种方式，那就是通过 props。这就意味着这种传递关系只能在父子组件之间进行。

看到这里你肯定会问，如果要跨层次，或者同层的组件之间要进行数据的共享，那应该如何去实现呢？这其实就涉及到一个新的命题：全局状态管理。

为此，React 提供了 Context 这样一个机制，能够让所有在某个组件开始的组件树上创建一个 Context。这样这个组件树上的所有组件，就都能访问和修改这个 Context 了。那么在函数组件里，我们就可以使用 useContext 这样一个 Hook 来管理 Context。

useContext 的 API 签名如下：

const value = useContext(MyContext);

正如刚才提到的，一个 Context 是从某个组件为根组件的组件树上可用的，所以我们需要有 API 能够创建一个 Context，这就是 React.createContext API，如下：

const MyContext = React.createContext(initialValue);

这里的 MyContext 具有一个 Provider 的属性，一般是作为组件树的根组件。这里我仍然以 React 官方文档的例子来讲解，即：一个主题的切换机制。代码如下：

const themes = {
  light: {
    foreground: "#000000",
    background: "#eeeeee"
  },
  dark: {
    foreground: "#ffffff",
    background: "#222222"
  }
};
// 创建一个 Theme 的 Context

const ThemeContext = React.createContext(themes.light);
function App() {
  // 整个应用使用 ThemeContext.Provider 作为根组件
  return (
    // 使用 themes.dark 作为当前 Context
    <ThemeContext.Provider value={themes.dark}>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

// 在 Toolbar 组件中使用一个会使用 Theme 的 Button
function Toolbar(props) {
  return (
    <div>
      <ThemedButton />
    </div>
  );
}

// 在 Theme Button 中使用 useContext 来获取当前的主题
function ThemedButton() {
  const theme = useContext(ThemeContext);
  return (
    <button style={{
      background: theme.background,
      color: theme.foreground
    }}>
      I am styled by theme context!
    </button>
  );
}

看到这里你也许会有点好奇，Context 看上去就是一个全局的数据，为什么要设计这样一个复杂的机制，而不是直接用一个全局的变量去保存数据呢？

答案其实很简单，就是为了能够进行数据的绑定。当这个 Context 的数据发生变化时，使用这个数据的组件就能够自动刷新。但如果没有 Context，而是使用一个简单的全局变量，就很难去实现了。

不过刚才我们看到的其实是一个静态的使用 Context 的例子，直接用了 thems.dark 作为 Context 的值。那么如何让它变得动态呢？

比如说常见的切换黑暗或者明亮模式的按钮，用来切换整个页面的主题。事实上，动态 Context 并不需要我们学习任何新的 API，而是利用 React 本身的机制，通过这么一行代码就可以实现：

<ThemeContext.Provider value={themes.dark}>

可以看到，themes.dark 是作为一个属性值传给 Provider 这个组件的，如果要让它变得动态，其实只要用一个 state 来保存，通过修改 state，就能实现动态的切换 Context 的值了。而且这么做，所有用到这个 Context 的地方都会自动刷新。比如这样的代码：

// ...

function App() {
  // 使用 state 来保存 theme 从而可以动态修改
  const [theme, setTheme] = useState("light");

  // 切换 theme 的回调函数
  const toggleTheme = useCallback(() => {
    setTheme((theme) => (theme === "light" ? "dark" : "light"));
  }, []);

  return (
    // 使用 theme state 作为当前 Context
    <ThemeContext.Provider value={themes[theme]}>
      <button onClick={toggleTheme}>Toggle Theme</button>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

在这段代码中，我们使用 state 来保存 theme，从而达到可以动态调整的目的。

可以看到，Context 提供了一个方便在多个组件之间共享数据的机制。不过需要注意的是，它的灵活性也是一柄双刃剑。你或许已经发现，Context 相当于提供了一个定义 React 世界中全局变量的机制，而全局变量则意味着两点：

会让调试变得困难，因为你很难跟踪某个 Context 的变化究竟是如何产生的。

让组件的复用变得困难，因为一个组件如果使用了某个 Context，它就必须确保被用到的地方一定有这个 Context 的 Provider 在其父组件的路径上。

所以在 React 的开发中，除了像 Theme、Language 等一目了然的需要全局设置的变量外，我们很少会使用 Context 来做太多数据的共享。需要再三强调的是，Context 更多的是提供了一个强大的机制，让 React 应用具备定义全局的响应式数据的能力。

此外，很多状态管理框架，比如 Redux，正是利用了 Context 的机制来提供一种更加可控的组件之间的状态管理机制。因此，理解 Context 的机制，也可以让我们更好地去理解 Redux 这样的框架实现的原理。

小结

最后来总结一下今天的所学。在这节课，你看到了 4 个常用的 React 内置 Hooks 的用法，包括：useCallback、useMemo、useRef 和 useContext。事实上，每一个 Hook 都是为了解决函数组件中遇到的特定问题。

因为函数组件首先定义了一个简单的模式来创建组件，但与此同时也暴露出了一定的问题。所以这些问题就要通过 Hooks 这样一个统一的机制去解决，可以称得上是一个非常完美的设计了。

有了这节课介绍的 4 个 Hooks，加上上节课我们学习的 useState 和 useEffect 这两个 核心 Hooks，你几乎就能完成所有 React 功能的开发了。

当然，可能仍然会有一些边缘且复杂的特别场景，我们在这两节课中学习的 Hooks 并不能完全覆盖，那么我建议你可以去参考官方的 API 文档，先知道 React Hooks 还有哪些能力，以便在需要的时候能够查阅文档并使用。

思考题

useState 其实也是能够在组件的多次渲染之间共享数据的，那么在 useRef 的计时器例子中，我们能否用 state 去保存 window.setInterval() 返回的 timer 呢？

欢迎把你的想法和思考分享在留言区，我们一起交流讨论。下节课再见！

05｜进一步认识 Hooks ：如何正确理解函数组件的生命周期？

你好，我是王沛。今天我们来聊聊 React 组件的生命周期。

这一讲我会带你从 Hooks 的角度进一步理解 React 函数组件的生命周期。你可能会有疑问，前面几节课我们已经学习了 Hooks 的概念和内置 Hooks 的用法，那为什么还会有专门一讲来进一步介绍 Hooks 呢？

原因主要有两个。一方面，如果你已经熟悉了 Class 组件的生命周期，那么还需要一个转换编程思想到 Hooks 的过程，这样才能避免表面上用了 Hooks，却仍然用 Class 组件的方式思考问题。

另一方面，如果你是新接触 React，那么这一讲也能帮你了解原来 Class 组件的工作方式，从而如果看到或接手已有的 React 项目，也能从容应对。

忘掉 Class 组件的生命周期

基于 Class 的组件作为 React 诞生之处就存在的机制，它的用法早已深入人心，甚至至今为止 React 的官方文档中仍然是以 Class 组件为基础的，而函数组件和 Hooks 则是作为新特性做了补充说明和解释。

这其实有两个原因：

一是 React 团队尽最大努力保持 API 的稳定，不希望给你造成一种 Class 组件将被废弃的感觉；

二是大量的存量应用其实还都是用 Class 组件实现的，无论是对于维护者还是加入者来说，了解 Class 组件都是很有必要的。

所以尽管函数组件和 Hooks 是未来发展的趋势，但你还是应该对 Class 组件的用法有一个了解，达到至少是能看懂的程度。

Class 组件和函数组件是两种实现 React 应用的方式，虽然它们是等价的，但是开发的思想有很大不同。如果你是从 Class 组件转换到 Hooks 的方式，那么很重要的一点就是，你要学会忘掉 Class 组件中的生命周期概念，千万不要将原来习惯的 Class 组件开发方式映射到函数组。

比如如何在函数组件中实现 componentDidMount, componentDidUpdate 这样的 Class 组件才有的生命周期方法，你应该通过理解 Hooks 的方式去思考业务需求应该如何实现。

为了理解函数组件的执行过程，我们不妨思考下 React 的本质：从 Model 到 View 的映射。假设状态永远不变，那么实际上函数组件就相当于是一个模板引擎，只执行一次。但是 React 本身正是为动态的状态变化而设计的，而可能引起状态变化的原因基本只有两个：

用户操作产生的事件，比如点击了某个按钮。

副作用产生的事件，比如发起某个请求正确返回了。

这两种事件本身并不会导致组件的重新渲染，但我们在这两种事件处理函数中，一定是因为改变了某个状态，这个状态可能是 State 或者 Context，从而导致了 UI 的重新渲染。

对于第一种情况，其实函数组件和 Class 组件的思路几乎完全一样：通过事件处理函数来改变某个状态；对于第二种情况，在函数组件中是通过 useEffect 这个 Hook 更加直观和语义化的方式来描述。对应到 Class 组件，则是通过手动判断 Props 或者 State 的变化来执行的。

比如对于在第三讲介绍的例子，一个用于显示博客文章的组件接收一个文章的 id 作为参数，然后根据这个 id 从服务器端获取文章的内容并显示出来。那么当 id 变化的时候，你就需要检测到这个变化，并重新发送请求，显示在界面上。在 Class 组件中，你通常要用如下的代码实现：

class BlogView extends React.Component {
  // ...
  componentDidMount() {
    // 组件第一次加载时去获取 Blog 数据
    fetchBlog(this.props.id);
  }
  componentDidUpdate(prevProps) {
    if (prevProps.id !== this.props.id) {
      // 当 Blog 的 id 发生变化时去获取博客文章
      fetchBlog(this.props.id);
    }
  }
  // ...
}

可以看到，在 Class 组件中，需要在两个生命周期方法中去实现副作用，一个是首次加载，另外一个则是每次 UI 更新后。而在函数组件中不再有生命周期的概念，而是提供了 useEffect 这样一个 Hook 专门用来执行副作用，因此，只需下面的代码即可实现同样的功能：

function BlogView({ id }) {
  useEffect(() => {
    // 当 id 变化时重新获取博客文章
    fetchBlog(id);
  }, [id]); // 定义了依赖项 id
}

可以看到，在函数组件中你要思考的方式永远是：当某个状态发生变化时，我要做什么，而不再是在 Class 组件中的某个生命周期方法中我要做什么。

所以如果你是从 Class 组件转型到函数组件，那么你要做的就是忘掉 Class 组件的生命周期机制，去逐渐习惯函数组件的思考方式。相信这样你就能够体会到函数组件带来的直观、简洁的好处了。

当然，要一下子彻底转变是很难的，你需要在实际开发中不断练习。那具体这样才能算是用 Hooks 方式思考呢？接下来我们就一起来对比看个例子，当一个需求来的时候，基于类组件的生命周期机制是如何做的，而在函数组件中又是如何做的。

重新思考组件的生命周期

在传统的类组件中，有专门定义的生命周期方法用于执行不同的逻辑，那么它们在函数组件的存在的形式又是什么样的呢？接下来我就带你一起看看在函数组件中，是如何思考组件的生命周期的。

构造函数

在类组件中有一个专门的方法叫 constructor，也就是构造函数，在里面我们会做一些初始化的事情，比如设置 State 的初始状态，或者定义一些类的实例的成员。

而现在，函数组件只是一个函数，没有所谓的对象，或者说类的实例这样的概念，那自然也就没有构造函数的说法了。

那么在函数组件中，我们应该如何去做一些初始化的事情呢？答案是：函数组件基本上没有统一的初始化需要，因为 Hooks 自己会负责自己的初始化。比如 useState 这个 Hook，接收的参数就是定义的 State 初始值。而在过去的类组件中，你通常需要在构造函数中直接设置 this.state ，也就是设置某个值来完成初始化。

但是要注意了，我提到的“基本上没有初始化需要”，也就是并不是完全没有。严格来说，虽然需求不多，但类组件中构造函数能做的不只是初始化 State，还可能有其它的逻辑。那么如果一定要在函数组件中实现构造函数应该怎么做呢？

这时候我们不妨思考下构造函数的本质，其实就是：在所以其它代码执行之前的一次性初始化工作。在函数组件中，因为没有生命周期的机制，那么转换一下思路，其实我们要实现的是：一次性的代码执行。

虽然没有直接的机制可以做到这一点，但是利用 useRef 这个 Hook，我们可以实现一个 useSingleton 这样的一次性执行某段代码的自定义 Hook，代码如下：

import { useRef } from 'react';

// 创建一个自定义 Hook 用于执行一次性代码
function useSingleton(callback) {
  // 用一个 called ref 标记 callback 是否执行过
  const called = useRef(false);
  // 如果已经执行过，则直接返回
  if (called.current) return;
  // 第一次调用时直接执行
  callBack();
  // 设置标记为已执行过
  called.current = true;
}

从而在一个函数组件中，可以调用这个自定义 Hook 来执行一些一次性的初始化逻辑：

import useSingleton from './useSingleton';

const MyComp = () => {
  // 使用自定义 Hook
  useSingleton(() => {
    console.log('这段代码只执行一次');
  });

  return (
    <div>My Component</div>
  );
};

代码中可以看到，useSingleton 这个 Hook 的核心逻辑就是定义只执行一次的代码。而是否在所有代码之前执行，则取决于在哪里调用，可以说，它的功能其实是包含了构造函数的功能的。

所以你在日常开发中，是无需去将功能映射到传统的生命周期的构造函数的概念，而是要从函数的角度出发，去思考功能如何去实现。比如在这个例子中，我们需要的其实就是抓住某段代码只需要执行一次这样一个本质的需求，从而能够更自然地用 Hooks 解决问题。

三种常用的生命周期方法

在类组件中，componentDidMount，componentWillUnmount，和 componentDidUpdate 这三个生命周期方法可以说是日常开发最常用的。业务逻辑通常要分散到不同的生命周期方法中，比如说在上面的 Blog 文章的例子中，你需要同时在 componentDidMount 和 componentDidUpdate 中去获取数据。

而在函数组件中，这几个生命周期方法可以统一到 useEffect 这个 Hook，正如 useEffect 的字面含义，它的作用就是触发一个副作用，即在组件每次 render 之后去执行。

在第三讲中其实你已经看到了 useEffect 的用法，下面的代码演示了这三个生命周期方法是如何用 useEffect 实现的：

useEffect(() => {
  // componentDidMount + componentDidUpdate
  console.log('这里基本等价于 componentDidMount + componentDidUpdate');
  return () => {
    // componentWillUnmount
    console.log('这里基本等价于 componentWillUnmount');
  }
}, [deps])

可以看到，useEffect 接收的 callback 参数，可以返回一个用于清理资源的函数，从而在下一次同样的 Effect 被执行之前被调用。

你可能已经注意到了，在代码里我用了“基本等价于”这个说法，什么意思呢？指的就是这个写法并没有完全等价于传统的这几个生命周期方法。主要有两个原因。

一方面，useEffect(callback) 这个 Hook 接收的 callback，只有在依赖项变化时才被执行。而传统的 componentDidUpdate 则一定会执行。这样来看，Hook 的机制其实更具有语义化，因为过去在 componentDidUpdate 中，我们通常都需要手动判断某个状态是否发生变化，然后再执行特定的逻辑。

另一方面，callback 返回的函数（一般用于清理工作）在下一次依赖项发生变化以及组件销毁之前执行，而传统的 componentWillUnmount 只在组件销毁时才会执行。

第二点可能有点难理解，我们不妨继续看博客文章这个例子。假设当文章 id 发生变化时，我们不仅需要获取文章，同时还要监听某个事件，这样在有新的评论时获得通知，就能显示新的评论了。这时候的代码结构如下：

import React, { useEffect } from 'react';
import comments from './comments';

function BlogView({ id }) {
  const handleCommentsChange = useCallback(() => {
    // 处理评论变化的通知
  }, []);
  useEffect(() => {
    // 获取博客内容
    fetchBlog(id);
    // 监听指定 id 的博客文章的评论变化通知
    const listener = comments.addListener(id, handleCommentsChange);

    return () => {
      // 当 id 发生变化时，移除之前的监听
      comments.removeListener(listener);
    };
  }, [id, handleCommentsChange])
}

那么可以比较清楚地看到，useEffect 接收的返回值是一个回调函数，这个回调函数不只是会在组件销毁时执行，而且是每次 Effect 重新执行之前都会执行，用于清理上一次 Effect 的执行结果。

理解这一点非常重要。useEffect 中返回的回调函数，只是清理当前执行的 Effect 本身。这其实是更加语义化的，因此你不用将其映射到 componentWillUnmount，它也完全不等价于 componentWillUnmount。你只需记住它的作用就是用于清理上一次 Effect 的结果就行了，这样在实际的开发中才能够使用得更加自然和合理。

其它的生命周期方法

刚才我列举了几个 Class 组件中最为常用的生命周期方法，并介绍了对于同样的需求，在函数组件中应该如何去用 Hooks 的机制重新思考它们的实现。这已经能覆盖绝大多数的应用场景了。

但是 Class 组件中还有其它一些比较少用的方法，比如 getSnapshotBeforeUpdate, componentDidCatch, getDerivedStateFromError。比较遗憾的是目前 Hooks 还没法实现这些功能。因此如果必须用到，你的组件仍然需要用类组件去实现。

已有应用是否应该迁移到 Hooks？

说了这么多，你可能会觉得写 React 应用就一定非 Hooks 不可了，其实也并非绝对。比如说很多时候，你面临的可能并不是开始一个全新的项目，而是参与到一个已有的项目中。那么就很可能会遇到这样一个问题：对于已有项目中的 Class 组件，是否要重构到函数组件和 Hooks 呢？

答案其实很明确：完全没必要。

在 React 中，Class 组件和函数组件是完全可以共存的。对于新的功能，我会更推荐使用函数组件。而对于已有的功能，则维持现状就可以。除非要进行大的功能改变，可以顺便把相关的类组件进行重构，否则是没有必要进行迁移的。

因为终究来说，能正确工作的代码就是好代码。React 组件的两种写法本身就可以很好地一起工作了：

类组件和函数组件可以互相引用；

Hooks 很容易就能转换成高阶组件，并供类组件使用。

总结来说，我们完全没必要为了迁移而迁移。

小结

这一讲我们主要学习了函数组件和类组件在思考方式上的区别，虽然 Hooks 在功能上基本可以映射到传统的 Class 组件的生命周期方法，但是它们却又不是完全等价的。在实现具体的业务功能的时候，都应该尽量从 Hooks 的语义角度出发去思考组件是如何展现和交互的，这样才能更加顺滑地切换到函数组件的开发方式。

思考题

今天这节课我们只描述了 componentWillUnmount 近似的实现：组件销毁和文章 id 变化时执行。那么在函数组件中如果要实现严格的 componentWillUnmount，也就是只在组件销毁时执行，应该如何实现？

欢迎把你的思考和想法分享在留言区，我会和你交流讨论。下节课再见！

06｜自定义Hooks ：四个典型的使用场景

你好，我是王沛。

我在开篇词就说过，要用好 React Hooks，很重要的一点，就是要能够从 Hooks 的角度去思考问题。要做到这一点其实也不难，就是在遇到一个功能开发的需求时，首先问自己一个问题：这个功能中的哪些逻辑可以抽出来成为独立的 Hooks？

这么问的目的，是为了让我们尽可能地把业务逻辑拆成独立的 Hooks ，这样有助于实现代码的模块化和解耦，同时也方便后面的维护。如果你基础篇的知识掌握得牢固的话，就会发现，这是因为 Hooks 有两个非常核心的优点：

一是方便进行逻辑复用；

二是帮助关注分离。

接下来我就通过一个案例，来带你认识什么是自定义 Hooks，以及如何创建。然后，我们再通过其它 3 个典型案例，来看看自定义 Hooks 具体有什么用，从而帮你掌握从 Hooks 角度去解决问题的思考方式。

如何创建自定义 Hooks？

自定义 Hooks 在形式上其实非常简单，就是声明一个名字以 use 开头的函数，比如 useCounter。这个函数在形式上和普通的 JavaScript 函数没有任何区别，你可以传递任意参数给这个 Hook，也可以返回任何值。

但是要注意，Hooks 和普通函数在语义上是有区别的，就在于函数中有没有用到其它 Hooks。

什么意思呢？就是说如果你创建了一个 useXXX 的函数，但是内部并没有用任何其它 Hooks，那么这个函数就不是一个 Hook，而只是一个普通的函数。但是如果用了其它 Hooks ，那么它就是一个 Hook。

举一个简单的例子，在第 3 讲中我们看到过一个简单计数器的实现，当时把业务逻辑都写在了函数组件内部，但其实是可以把业务逻辑提取出来成为一个 Hook。比如下面的代码：

import { useState, useCallback }from 'react';

function useCounter() {
  // 定义 count 这个 state 用于保存当前数值
  const [count, setCount] = useState(0);
  // 实现加 1 的操作
  const increment = useCallback(() => setCount(count + 1), [count]);
  // 实现减 1 的操作
  const decrement = useCallback(() => setCount(count - 1), [count]);
  // 重置计数器
  const reset = useCallback(() => setCount(0), []);

  // 将业务逻辑的操作 export 出去供调用者使用
  return { count, increment, decrement, reset };
}

有了这个 Hook，我们就可以在组件中使用它，比如下面的代码：

import React from 'react';

function Counter() {
  // 调用自定义 Hook
  const { count, increment, decrement, reset } = useCounter();

  // 渲染 UI
  return (
    <div>
      <button onClick={decrement}> - </button>
      <p>{count}</p>
      <button onClick={increment}> + </button>
      <button onClick={reset}> reset </button>
    </div>
  );
}

在这段代码中，我们把原来在函数组件中实现的逻辑提取了出来，成为一个单独的 Hook，一方面能让这个逻辑得到重用，另外一方面也能让代码更加语义化，并且易于理解和维护。

从这个例子，我们可以看到自定义 Hooks 的两个特点：

名字一定是以 use 开头的函数，这样 React 才能够知道这个函数是一个 Hook；

函数内部一定调用了其它的 Hooks，可以是内置的 Hooks，也可以是其它自定义 Hooks。这样才能够让组件刷新，或者去产生副作用。

当然，这只是一个非常简单的例子，实现了计数器业务逻辑的拆分和重用。不过通过这个例子，你也看到了创建自定义 Hook 是如此之简单，和过去的高阶组件设计模式相比，简直是天上地下的区别。也正因如此，Hooks 出现后就得到了迅速的普及。

那么，在日常开发的时候，除了解耦业务相关的逻辑，还有哪些场景需要去创建自定义 Hooks 呢？下面我就再给你介绍三个典型的业务场景。

封装通用逻辑：useAsync

在组件的开发过程中，有一些常用的通用逻辑。过去可能会因为逻辑重用比较繁琐，而经常在每个组件中去自己实现，造成维护的困难。但现在有了 Hooks，就可以将更多的通用逻辑通过 Hooks 的形式进行封装，方便被不同的组件重用。

比如说，在日常 UI 的开发中，有一个最常见的需求：发起异步请求获取数据并显示在界面上。在这个过程中，我们不仅要关心请求正确返回时，UI 会如何展现数据；还需要处理请求出错，以及关注 Loading 状态在 UI 上如何显示。

我们可以重新看下在第 1 讲中看到的异步请求的例子，从 Server 端获取用户列表，并显示在界面上：

import React from "react";

export default function UserList() {
  // 使用三个 state 分别保存用户列表，loading 状态和错误状态
  const [users, setUsers] = React.useState([]);
  const [loading, setLoading] = React.useState(false);
  const [error, setError] = React.useState(null);

  // 定义获取用户的回调函数
  const fetchUsers = async () => {
    setLoading(true);
    try {
      const res = await fetch("https://reqres.in/api/users/");
      const json = await res.json();
      // 请求成功后将用户数据放入 state
      setUsers(json.data);
    } catch (err) {
      // 请求失败将错误状态放入 state
      setError(err);
    }
    setLoading(false);
  };

  return (
    <div className="user-list">
      <button onClick={fetchUsers} disabled={loading}>
        {loading ? "Loading..." : "Show Users"}
      </button>
      {error &&
        <div style={{ color: "red" }}>Failed: {String(error)}</div>
      }
      <br />
      <ul>
        {users && users.length > 0 &&
          users.map((user) => {
            return <li key={user.id}>{user.first_name}</li>;
          })}
      </ul>
    </div>
  );
}

在这里，我们定义了 users、loading 和 error 三个状态。如果我们在异步请求的不同阶段去设置不同的状态，这样 UI 最终能够根据这些状态展现出来。在每个需要异步请求的组件中，其实都需要重复相同的逻辑。

事实上，在处理这类请求的时候，模式都是类似的，通常都会遵循下面步骤：

创建 data，loading，error 这 3 个 state；

请求发出后，设置 loading state 为 true；

请求成功后，将返回的数据放到某个 state 中，并将 loading state 设为 false；

请求失败后，设置 error state 为 true，并将 loading state 设为 false。

最后，基于 data、loading、error 这 3 个 state 的数据，UI 就可以正确地显示数据，或者 loading、error 这些反馈给用户了。

所以，通过创建一个自定义 Hook，可以很好地将这样的逻辑提取出来，成为一个可重用的模块。比如代码可以这样实现：

import { useState } from 'react';

const useAsync = (asyncFunction) => {
  // 设置三个异步逻辑相关的 state
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  // 定义一个 callback 用于执行异步逻辑
  const execute = useCallback(() => {
    // 请求开始时，设置 loading 为 true，清除已有数据和 error 状态
    setLoading(true);
    setData(null);
    setError(null);
    return asyncFunction()
      .then((response) => {
        // 请求成功时，将数据写进 state，设置 loading 为 false
        setData(response);
        setLoading(false);
      })
      .catch((error) => {
        // 请求失败时，设置 loading 为 false，并设置错误状态
        setError(error);
        setLoading(false);
      });
  }, [asyncFunction]);

  return { execute, loading, data, error };
};

那么有了这个 Hook，我们在组件中就只需要关心与业务逻辑相关的部分。比如代码可以简化成这样的形式：

import React from "react";
import useAsync from './useAsync';

export default function UserList() {
  // 通过 useAsync 这个函数，只需要提供异步逻辑的实现
  const {
    execute: fetchUsers,
    data: users,
    loading,
    error,
  } = useAsync(async () => {
    const res = await fetch("https://reqres.in/api/users/");
    const json = await res.json();
    return json.data;
  });

  return (
    // 根据状态渲染 UI...
  );
}

通过这个例子可以看到，我们利用了 Hooks 能够管理 React 组件状态的能力，将一个组件中的某一部分状态独立出来，从而实现了通用逻辑的重用。

不过在这里你可能会有一个疑问：这种类型的封装我写一个工具类不就可以了？为什么一定要通过 Hooks 进行封装呢？

答案很容易就能想到。因为在 Hooks 中，你可以管理当前组件的 state，从而将更多的逻辑写在可重用的 Hooks 中。但是要知道，在普通的工具类中是无法直接修改组件 state 的，那么也就无法在数据改变的时候触发组件的重新渲染。

监听浏览器状态：useScroll

虽然 React 组件基本上不需要关心太多的浏览器 API，但是有时候却是必须的：

界面需要根据在窗口大小变化重新布局；

在页面滚动时，需要根据滚动条位置，来决定是否显示一个“返回顶部”的按钮。

这都需要用到浏览器的 API 来监听这些状态的变化。那么我们就以滚动条位置的场景为例，来看看应该如何用 Hooks 优雅地监听浏览器状态。

正如 Hooks 的字面意思是“钩子”，它带来的一大好处就是：可以让 React 的组件绑定在任何可能的数据源上。这样当数据源发生变化时，组件能够自动刷新。把这个好处对应到滚动条位置这个场景就是：组件需要绑定到当前滚动条的位置数据上。

虽然这个逻辑在函数组件中可以直接实现，但是把这个逻辑实现为一个独立的 Hooks，既可以达到逻辑重用，在语义上也更加清晰。这个和上面的 useAsync 的作用是非常类似的。

我们可以直接来看这个 Hooks 应该如何实现：

import { useState, useEffect } from 'react';

// 获取横向，纵向滚动条位置
const getPosition = () => {
  return {
    x: document.body.scrollLeft,
    y: document.body.scrollTop,
  };
};
const useScroll = () => {
  // 定一个 position 这个 state 保存滚动条位置
  const [position, setPosition] = useState(getPosition());
  useEffect(() => {
    const handler = () => {
      setPosition(getPosition(document));
    };
    // 监听 scroll 事件，更新滚动条位置
    document.addEventListener("scroll", handler);
    return () => {
      // 组件销毁时，取消事件监听
      document.removeEventListener("scroll", handler);
    };
  }, []);
  return position;
};

有了这个 Hook，你就可以非常方便地监听当前浏览器窗口的滚动条位置了。比如下面的代码就展示了“返回顶部”这样一个功能的实现：

import React, { useCallback } from 'react';
import useScroll from './useScroll';

function ScrollTop() {
  const { y } = useScroll();

  const goTop = useCallback(() => {
    document.body.scrollTop = 0;
  }, []);

  const style = {
    position: "fixed",
    right: "10px",
    bottom: "10px",
  };
  // 当滚动条位置纵向超过 300 时，显示返回顶部按钮
  if (y > 300) {
    return (
      <button onClick={goTop} style={style}>
        Back to Top
      </button>
    );
  }
  // 否则不 render 任何 UI
  return null;
}

通过这个例子，我们看到了如何将浏览器状态变成可被 React 组件绑定的数据源，从而在使用上更加便捷和直观。当然，除了窗口大小、滚动条位置这些状态，还有其它一些数据也可以这样操作，比如 cookies，localStorage, URL，等等。你都可以通过这样的方法来实现。

拆分复杂组件

函数组件虽然很容易上手，但是当某个组件功能越来越复杂的时候，我发现很多同学会出现一个问题，就是组件代码很容易变得特别长，比如超过 500 行，甚至 1000 行。这就变得非常难维护了。

设想当你接手某个项目，发现一个函数动辄就超过了 500 行，那会是什么感受？所以“保持每个函数的短小”这样通用的最佳实践，同样适用于函数组件。只有这样，才能让代码始终易于理解和维护。

那么现在的关键问题就是，怎么才能让函数组件不会太过冗长呢？做法很简单，就是尽量将相关的逻辑做成独立的 Hooks，然后在函数组中使用这些 Hooks，通过参数传递和返回值让 Hooks 之间完成交互。

这里可以注意一点，拆分逻辑的目的不一定是为了重用，而可以是仅仅为了业务逻辑的隔离。所以在这个场景下，我们不一定要把 Hooks 放到独立的文件中，而是可以和函数组件写在一个文件中。这么做的原因就在于，这些 Hooks 是和当前函数组件紧密相关的，所以写到一起，反而更容易阅读和理解。

为了让你对这一点有更直观的感受，我们来看一个例子。设想现在有这样一个需求：我们需要展示一个博客文章的列表，并且有一列要显示文章的分类。同时，我们还需要提供表格过滤功能，以便能够只显示某个分类的文章。

为了支持过滤功能，后端提供了两个 API：一个用于获取文章的列表，另一个用于获取所有的分类。这就需要我们在前端将文章列表返回的数据分类 ID 映射到分类的名字，以便显示在列表里。

这时候，如果按照直观的思路去实现，通常都会把逻辑都写在一个组件里，比如类似下面的代码：

function BlogList() {
  // 获取文章列表...
  // 获取分类列表...
  // 组合文章数据和分类数据...
  // 根据选择的分类过滤文章...

  // 渲染 UI ...
}

你可以想一下，如果你是在写一个其它的普通函数，会不会将其中一些逻辑写成单独的函数呢？相信答案是肯定的，因为这样做可以隔离业务逻辑，让代码更加清楚。

但我却发现很多同学在写函数组件时没有意识到 Hooks 就是普通的函数，所以通常不会这么去做隔离，而是习惯于一路写下来，这就会造成某个函数组件特别长。还是老生常谈的那句话，改变这个状况的关键仍然在于开发思路的转变。我们要真正把 Hooks 就看成普通的函数，能隔离的尽量去做隔离，从而让代码更加模块化，更易于理解和维护。

那么针对这样一个功能，我们甚至可以将其拆分成 4 个 Hooks，每一个 Hook 都尽量小，代码如下：

import React, { useEffect, useCallback, useMemo, useState } from "react";
import { Select, Table } from "antd";
import _ from "lodash";
import useAsync from "./useAsync";

const endpoint = "https://myserver.com/api/";
const useArticles = () => {
  // 使用上面创建的 useAsync 获取文章列表
  const { execute, data, loading, error } = useAsync(
    useCallback(async () => {
      const res = await fetch(`${endpoint}/posts`);
      return await res.json();
    }, []),
  );
  // 执行异步调用
  useEffect(() => execute(), [execute]);
  // 返回语义化的数据结构
  return {
    articles: data,
    articlesLoading: loading,
    articlesError: error,
  };
};
const useCategories = () => {
  // 使用上面创建的 useAsync 获取分类列表
  const { execute, data, loading, error } = useAsync(
    useCallback(async () => {
      const res = await fetch(`${endpoint}/categories`);
      return await res.json();
    }, []),
  );
  // 执行异步调用
  useEffect(() => execute(), [execute]);

  // 返回语义化的数据结构
  return {
    categories: data,
    categoriesLoading: loading,
    categoriesError: error,
  };
};
const useCombinedArticles = (articles, categories) => {
  // 将文章数据和分类数据组合到一起
  return useMemo(() => {
    // 如果没有文章或者分类数据则返回 null
    if (!articles || !categories) return null;
    return articles.map((article) => {
      return {
        ...article,
        category: categories.find(
          (c) => String(c.id) === String(article.categoryId),
        ),
      };
    });
  }, [articles, categories]);
};
const useFilteredArticles = (articles, selectedCategory) => {
  // 实现按照分类过滤
  return useMemo(() => {
    if (!articles) return null;
    if (!selectedCategory) return articles;
    return articles.filter((article) => {
      console.log("filter: ", article.categoryId, selectedCategory);
      return String(article?.category?.name) === String(selectedCategory);
    });
  }, [articles, selectedCategory]);
};

const columns = [
  { dataIndex: "title", title: "Title" },
  { dataIndex: ["category", "name"], title: "Category" },
];

export default function BlogList() {
  const [selectedCategory, setSelectedCategory] = useState(null);
  // 获取文章列表
  const { articles, articlesError } = useArticles();
  // 获取分类列表
  const { categories, categoriesError } = useCategories();
  // 组合数据
  const combined = useCombinedArticles(articles, categories);
  // 实现过滤
  const result = useFilteredArticles(combined, selectedCategory);

  // 分类下拉框选项用于过滤
  const options = useMemo(() => {
    const arr = _.uniqBy(categories, (c) => c.name).map((c) => ({
      value: c.name,
      label: c.name,
    }));
    arr.unshift({ value: null, label: "All" });
    return arr;
  }, [categories]);

  // 如果出错，简单返回 Failed
  if (articlesError || categoriesError) return "Failed";

  // 如果没有结果，说明正在加载
  if (!result) return "Loading...";

  return (
    <div>
      <Select
        value={selectedCategory}
        onChange={(value) => setSelectedCategory(value)}
        options={options}
        style={{ width: "200px" }}
        placeholder="Select a category"
      />
      <Table dataSource={result} columns={columns} />
    </div>
  );
}

通过这样的方式，我们就把一个较为复杂的逻辑拆分成一个个独立的 Hook 了，不仅隔离了业务逻辑，也让代码在语义上更加明确。比如说有 useArticles、useCategories 这样与业务相关的名字，就非常易于理解。

虽然这个例子中抽取出来的 Hooks 都非常简单，甚至看上去没有必要。但是实际的开发场景一定是比这个复杂的，比如对于 API 返回的数据需要做一些数据的转换，进行数据的缓存，等等。那么这时就要避免把这些逻辑都放到一起，而是就要拆分到独立的 Hooks，以免产生过于复杂的组件。到时候你也就更能更体会到 Hooks 带给你的惊喜了。

小结

好了，这一讲我主要给你介绍了自定义 Hooks 的概念，以及典型的四个使用场景：

抽取业务逻辑；

封装通用逻辑；

监听浏览器状态；

拆分复杂组件。

其中，我通过四个案例来帮助你真正理解 Hooks ，并熟练掌握自定义 Hooks 的用法。应始终记得，要用 Hooks 的思路去解决问题，发挥 Hooks 的最大价值，就是要经常去思考哪些逻辑应该封装到一个独立的 Hook，保证每个 Hook 的短小精悍，从而让代码更加清晰，易于理解和维护。

思考题

在 useCounter 这个例子中，我们是固定让数字每次加一。假如要做一个改进，允许灵活配置点击加号时应该加几，比如说每次加 10，那么应该如何实现？

欢迎在留言区分享你的思考和想法，我会和你交流讨论。我们下节课再见！

07｜全局状态管理：如何在函数组件中使用 Redux？

你好，我是王沛。今天我们来聊聊怎么在函数组件中使用 Redux。

Redux 作为一款状态管理框架啊，是公认的 React 开发中最大的一个门槛，但同时呢，它也是 React 开发人员必须掌握的一项技能。因为只有熟练应用 Redux，你才能更加灵活地使用 React，来从容应对大型项目的开发难题。

这里我要说句题外话。Redux 诞生于 2015 年，也就是 React 出现之后一年多。虽然一开始是由第三方开发者开源，不是 Facebook 官方，但是也迅速成为了最为主流的 React 状态管理库。而且，之后 Redux 跟它的开发者 Dan Abbramov 和 Andrew Clark 一起，都被 Facebook 收编，成为 React 官方生态的一部分。侧面可以看到 Redux 在 React 中的重要作用。

需要说明的是，Redux 作为一套独立的框架，虽然可以和任何 UI 框架结合起来使用。但是因为它基于不可变数据的机制，可以说，基本上就是为 React 量身定制的。

不过你可能会说，Redux 上手比较难，该怎么办呢？的确是这样，因 Redux 引入了一些新的编程思想，还有比较繁琐的样板代码，确实带来了一定的上手难度。

但是你不要担心，今天这节课，我会通过具体的例子带你上手 Redux。而且我会讲解 Redux 要解决什么问题，引入了什么样的新概念，争取能从本质上去理解 Redux 的理念和使用方法，提高你举一反三的能力。

Redux 出现的背景

很多同学一开始可能不太明白状态管理框架的作用。但是如果随着对 React 使用的深入，你会发现组件级别的 state，和从上而下传递的 props 这两个状态机制，无法满足复杂功能的需要。例如跨层级之间的组件的数据共享和传递。我们可以从下图的对比去理解：

其中左图是单个 React 组件，它的状态可以用内部的 state 来维护，而且这个 state 在组件外部是无法访问的。而右图则是使用 Redux 的场景，用全局唯一的 Store 维护了整个应用程序的状态。可以说，对于页面的多个组件，都是从这个 Store 来获取状态的，保证组件之间能够共享状态。

所以从这张对比图，我们可以看到 Redux Store 的两个特点：

Redux Store 是全局唯一的。即整个应用程序一般只有一个 Store。

Redux Store 是树状结构，可以更天然地映射到组件树的结构，虽然不是必须的。

我们通过把状态放在组件之外，就可以让 React 组件成为更加纯粹的表现层，那么很多对于业务数据和状态数据的管理，就都可以在组件之外去完成（后面课程会介绍的 Reducer 和 Action）。同时这也天然提供了状态共享的能力，有两个场景可以典型地体现出这一点。

跨组件的状态共享：当某个组件发起一个请求时，将某个 Loading 的数据状态设为 True，另一个全局状态组件则显示 Loading 的状态。

同组件多个实例的状态共享：某个页面组件初次加载时，会发送请求拿回了一个数据，切换到另外一个页面后又返回。这时数据已经存在，无需重新加载。设想如果是本地的组件 state，那么组件销毁后重新创建，state 也会被重置，就还需要重新获取数据。

因此，学会 Redux ，才能真正用 React 去灵活解决问题。下面我们就来了解下 Redux 中的一些基本概念。

理解 Redux 的三个基本概念

Redux 引入的概念其实并不多，主要就是三个：State、Action 和 Reducer。

其中 State 即 Store，一般就是一个纯 JavaScript Object。

Action 也是一个 Object，用于描述发生的动作。

而 Reducer 则是一个函数，接收 Action 和 State 并作为参数，通过计算得到新的 Store。

它们三者之间的关系可以用下图来表示：

在 Redux 中，所有对于 Store 的修改都必须通过这样一个公式去完成，即通过 Reducer 完成，而不是直接修改 Store。这样的话，一方面可以保证数据的不可变性（Immutable），同时也能带来两个非常大的好处。

可预测性（Predictable）：即给定一个初始状态和一系列的 Action，一定能得到一致的结果，同时这也让代码更容易测试。

易于调试：可以跟踪 Store 中数据的变化，甚至暂停和回放。因为每次 Action 产生的变化都会产生新的对象，而我们可以缓存这些对象用于调试。Redux 的基于浏览器插件的开发工具就是基于这个机制，非常有利于调试。

这么抽象的解释，你可能不好理解，别着急，我给你举个例子，来帮助你理解这几个概念。这个例子是开发一个计数器的逻辑。比如说要实现“加一”和“减一”这两个功能，对于 Redux 来说，我们需要如下代码：

import { createStore } from 'redux'

// 定义 Store 的初始值
const initialState = { value: 0 }

// Reducer，处理 Action 返回新的 State
function counterReducer(state = initialState, action) {
  switch (action.type) {
    case 'counter/incremented':
      return { value: state.value + 1 }
    case 'counter/decremented':
      return { value: state.value - 1 }
    default:
      return state
  }
}

// 利用 Redux API 创建一个 Store，参数就是 Reducer
const store = createStore(counterReducer)

// Store 提供了 subscribe 用于监听数据变化
store.subscribe(() => console.log(store.getState()))

// 计数器加 1，用 Store 的 dispatch 方法分发一个 Action，由 Reducer 处理
const incrementAction = { type: 'counter/incremented' };
store.dispatch(incrementAction);
// 监听函数输出：{value: 1}

// 计数器减 1
const decrementAction = { type: 'counter/decremented' };
store.dispatch(decrementAction)
// 监听函数输出：{value: 0}

通过这段代码，我们就用三个步骤完成了一个完整的 Redux 的逻辑：

先创建 Store；

再利用 Action 和 Reducer 修改 Store；

最后利用 subscribe 监听 Store 的变化。

需要注意的是，在 Reducer 中，我们每次都必须返回一个新的对象，确保不可变数据（Immutable）的原则。一般来说，我们可以用延展操作符（Spread Operator）来简单地实现不可变数据的操作，例如：

return {
  ...state, // 复制原有的数据结构
  value: state.value + 1, // 变化 value 值使其 + 1
}

这在大多数场景下已经足够使用。当然对于复杂的数据结构，也有一些第三方的库可以帮助操作不可变数据，比如 Immutable、Immer 等等。

通过这个例子，我们看到了纯 Redux 使用的场景，从而更加清楚地看到了 Store、Action 和 Reducer 这三个基本概念，也就能理解 State + Action => New State 这样一个简单却核心的机制。

如何在 React 中使用 Redux

在刚才的讲解和具体例子中，我们并没有把 Redux 和 React 建立联系，这主要是为了帮助你先理解概念。要知道，在实际场景中，Redux Store 中的状态最终一定是会体现在 UI 上的，即通过 React 组件展示给用户。那么如何建立 Redux 和 React 的联系呢？

主要是两点：

React 组件能够在依赖的 Store 的数据发生变化时，重新 Render；

在 React 组件中，能够在某些时机去 dispatch 一个 action，从而触发 Store 的更新。

要实现这两点，我们需要引入 Facebook 提供的 react-redux 这样一个工具库，工具库的作用就是建立一个桥梁，让 React 和 Redux 实现互通。

在 react-redux 的实现中，为了确保需要绑定的组件能够访问到全局唯一的 Redux Store，利用了 React 的 Conext 机制去存放 Store 的信息。通常我们会将这个 Context 作为整个 React 应用程序的根节点。因此，作为 Redux 的配置的一部分，我们通常需要如下的代码：

import React from 'react'
import ReactDOM from 'react-dom'

import { Provider } from 'react-redux'
import store from './store'

import App from './App'

const rootElement = document.getElementById('root')
ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  rootElement
)

这里使用了 Provider 这样一个组件来作为整个应用程序的根节点，并将 Store 作为属性传给了这个组件，这样所有下层的组件就都能够使用 Redux 了。

完成了这样的配置之后，在函数组件中使用 Redux 就非常简单了：利用 react-redux 提供的 useSelector 和 useDispatch 这两个 Hooks。

在第二讲我们已经提到，Hooks 的本质就是提供了让 React 组件能够绑定到某个可变的数据源的能力。在这里，当 Hooks 用到 Redux 时可变的对象就是 Store，而 useSelector 则让一个组件能够在 Store 的某些数据发生变化时重新 render。

我在这里仍然以官方给的计数器例子为例，来给你讲解如何在 React 中使用 Redux：

import React from 'react'
import { useSelector, useDispatch } from 'react-redux'

export function Counter() {
  // 从 state 中获取当前的计数值
  const count = useSelector(state => state.value)

  // 获得当前 store 的 dispatch 方法
  const dispatch = useDispatch()

  // 在按钮的 click 时间中去分发 action 来修改 store
  return (
    <div>
      <button
        onClick={() => dispatch({ type: 'counter/incremented' })}
      >+</button>
      <span>{count}</span>
      <button
        onClick={() => dispatch({ type: 'counter/decremented' })}
      >-</button>
    </div>
  )
}

此外，通过计数器这个例子，我们还可以看到 React 和 Redux 共同使用时的单向数据流：

需要强调的是，在实际的使用中，我们无需关心 View 是如何绑定到 Store 的某一部分数据的，因为 React-Redux 帮我们做了这件事情。总结来说，通过这样一种简单的机制，Redux 统一了更新数据状态的方式，让整个应用程序更加容易开发、维护、调试和测试。

使用 Redux 处理异步逻辑

学完了如何在 React 中使用 Redux，接下来我们就进入到 Redux 的进阶场景中。

在 Redux 中，处理异步逻辑也常常被称为异步 Action，它几乎是 React 面试中必问的一道题，可以认为这是 Redux 使用的进阶场景。

虽然 Redux 的官方文档中已经将异步逻辑的原理写得很清楚，但是大部分同学仍然只能说个大概，或者蹦出 Thunk、Saga 之类的几个单词。造成这种现象的很大一部分原因可能在于，仅满足于根据参考示例写出可运行的代码，而没有深究背后的原理。

但是要明白一点，只有能够解释清楚异步 Action，才算是真正理解了 Redux，才能在实际开发中灵活应用。

在 Redux 的 Store 中，我们不仅维护着业务数据，同时维护着应用程序的状态。比如对于发送请求获取数据这样一个异步的场景，我们来看看涉及到 Store 数据会有哪些变化：

请求发送出去时：设置 state.pending = true，用于 UI 显示加载中的状态；

请求发送成功时：设置 state.pending = false, state.data = result。即取消 UI 的加载状态，同时将获取的数据放到 store 中用于 UI 的显示。

请求发送失败时：设置 state.pending = false, state.error = error。即取消 UI 的加载状态，同时设置错误的状态，用于 UI 显示错误的内容。

前面提到，任何对 Store 的修改都是由 action 完成的。那么对于一个异步请求，上面的三次数据修改显然必须要三个 action 才能完成。那么假设我们在 React 组件中去做这个发起请求的动作，代码逻辑应该类似如下：

function DataList() {
  const dispatch = useDispatch();
  // 在组件初次加载时发起请求
  useEffect(() => {
    // 请求发送时
    dispatch({ type: 'FETCH_DATA_BEGIN' });
    fetch('/some-url').then(res => {
      // 请求成功时
      dispatch({ type: 'FETCH_DATA_SUCCESS', data: res });
    }).catch(err => {
      // 请求失败时
      dispatch({ type: 'FETCH_DATA_FAILURE', error: err });
    })
  }, []);

  // 绑定到 state 的变化
  const data = useSelectore(state => state.data);
  const pending = useSelector(state => state.pending);
  const error = useSelector(state => state.error);

  // 根据 state 显示不同的状态
  if (error) return 'Error.';
  if (pending) return 'Loading...';
  return <Table data={data} />;
}

从这段代码可以看到，我们使用了三个（同步）Action 完成了这个异步请求的场景。这里我们将 Store 完全作为一个存放数据的地方，至于数据哪里来， Redux 并不关心。尽管这样做是可行的。

但是很显然，发送请求获取数据并进行错误处理这个逻辑是不可重用的。假设我们希望在另外一个组件中也能发送同样的请求，就不得不将这段代码重新实现一遍。因此，Redux 中提供了 middleware 这样一个机制，让我们可以巧妙地实现所谓异步 Action 的概念。

简单来说，middleware 可以让你提供一个拦截器在 reducer 处理 action 之前被调用。在这个拦截器中，你可以自由处理获得的 action。无论是把这个 action 直接传递到 reducer，或者构建新的 action 发送到 reducer，都是可以的。

从下面这张图可以看到，Middleware 正是在 Action 真正到达 Reducer 之前提供的一个额外处理 Action 的机会：

我们刚才也提到了，Redux 中的 Action 不仅仅可以是一个 Object，它可以是任何东西，也可以是一个函数。利用这个机制，Redux 提供了 redux-thunk 这样一个中间件，它如果发现接受到的 action 是一个函数，那么就不会传递给 Reducer，而是执行这个函数，并把 dispatch 作为参数传给这个函数，从而在这个函数中你可以自由决定何时，如何发送 Action。

例如对于上面的场景，假设我们在创建 Redux Store 时指定了 redux-thunk 这个中间件：

import { createStore, applyMiddleware } from 'redux'
import thunkMiddleware from 'redux-thunk'
import rootReducer from './reducer'

const composedEnhancer = applyMiddleware(thunkMiddleware)
const store = createStore(rootReducer, composedEnhancer)

那么在我们 dispatch action 时就可以 dispatch 一个函数用于来发送请求，通常，我们会写成如下的结构：

function fetchData() {
  return dispatch => {
    dispatch({ type: 'FETCH_DATA_BEGIN' });
    fetch('/some-url').then(res => {
      dispatch({ type: 'FETCH_DATA_SUCCESS', data: res });
    }).catch(err => {
      dispatch({ type: 'FETCH_DATA_FAILURE', error: err });
    })
  }
}

那么在我们 dispatch action 时就可以 dispatch 一个函数用于来发送请求，通常，我们会写成如下的结构：

import fetchData from './fetchData';

function DataList() {
  const dispatch = useDispatch();
  // dispatch 了一个函数由 redux-thunk 中间件去执行
  dispatch(fetchData());
}

可以看到，通过这种方式，我们就实现了异步请求逻辑的重用。那么这一套结合 redux-thunk 中间件的机制，我们就称之为异步 Action。

所以说异步 Action 并不是一个具体的概念，而可以把它看作是 Redux 的一个使用模式。它通过组合使用同步 Action ，在没有引入新概念的同时，用一致的方式提供了处理异步逻辑的方案。

小结

尽管 Redux 有令人诟病的地方，例如函数式的概念比较难以理解，样板代码过多等问题。但其带来的好处也是很明显的，比如可以让代码更容易理解，维护和测试。因此有超过 60% 的 React 应用都使用了 Redux。

所以即使对于一些小型的应用，不一定需要使用 Redux。但是对于开发人员来说，学会和理解 Redux 仍然是一项必须掌握的既能。

思考题

只考虑 Redux 部分，对于计数器应用，目前每次是固定加减 1，如果要能够在每次调用时增加或减少指定的变量值，应该如何实现？

欢迎把你的思考和想法分享在留言区，我会和你交流讨论。也欢迎你把课程分享给你的朋友和同事，我们共同进步！

08｜复杂状态处理：如何保证状态一致性？

你好，我是王沛。今天我们聊聊 React 中的状态管理。

从这节课开始，我们就进入到了实战篇的训练。React Hooks 中其实有一些通用原则和常见设计模式，所以我设计了几个典型的业务场景，这样你就可以对这些原则和模式有一个具体的印象，之后在遇到类似场景时，也能从容应对。

今天我们先从状态一致性这个需求开始讲起。我在基础篇就反复提到过，React 中 UI 完全是通过状态驱动的。所以在任何时刻，整个应用程序的状态数据就完全决定了当前 UI 上的展现。毫不夸张地说，React 的开发其实就是复杂应用程序状态的管理和开发。因此，这就需要你去仔细思考，该怎么去用最优、最合理的方式，去管理你的应用程序的状态。

所以今天这节课我会带你围绕状态一致性这个需求，介绍两个基本原则，它们能帮助我们避免很多复杂的状态管理逻辑，简化应用程序的开发。

原则一：保证状态最小化

新接触 React 的同学经常会有一个错误的习惯，就是把 State 当变量用，很容易把过多的数据放到 State 里，可以说这是对 State 的一种滥用。

那到底该怎么使用 State 呢？我们需要遵循一个原则，即：在保证 State 完整性的同时，也要保证它的最小化。什么意思呢？

就是说，某些数据如果能从已有的 State 中计算得到，那么我们就应该始终在用的时候去计算，而不要把计算的结果存到某个 State 中。这样的话，才能简化我们的状态处理逻辑。

举个例子。你要做一个功能，需要对一个列表的结果进行关键字搜索，我们假设是一个显示电影标题的列表，需要能够对标题进行搜索。最终的效果如下图所示：

可以看到，这个功能包括一个搜索框和一个电影标题的列表。那么，在考虑怎么实现这个功能的时候，应该从哪里着手呢？

按照 React 的状态驱动 UI 的思想，第一步就是要考虑整个功能有哪几个状态。直观上来说，页面可能包含三个状态：

电影列表的数据：可能来自某个 API 请求；

用户输入的关键字：来自用户的输入；

搜索的结果数据：来自原始数据结合关键字的过滤结果。

那么很多同学这时候就会在组件中去定义这三个状态，一般的实现代码如下：

function FilterList({ data }) {
  // 设置关键字的 State
  const [searchKey, setSearchKey] = useState('');
  // 设置最终要展示的数据状态，并用原始数据作为初始值
  const [filtered, setFiltered] = useState(data);

  // 处理用户的搜索关键字
  const handleSearch = useCallback(evt => {
    setSearchKey(evt.target.value);
    setFiltered(data.filter(item => {
      return item.title.includes(evt.target.value)));
    }));
  }, [filtered])
  return (
    <div>
      <input value={searchKey} onChange={handleSearch} />
      {/* 根据 filtered 数据渲染 UI */}
    </div>
  );
}

看上去没有太大问题，整段代码也能正常工作。但是如果仔细思考，你会发现其中隐藏的一致性的问题：展示的结果数据完全由原始数据和关键字决定，而现在却作为一个独立的状态去维护了。这意味着你始终要在原始数据、关键字和结果数据之间保证一致性。

在代码中，我们已经做了一部分维护一致性的工作，那就是当关键字变化时，我们会同时更新关键字和最终结果这两个状态，从而让这两个状态始终保持一致。

不过还有部分一致性的工作没有被考虑到，那就是如果原始数据 data 属性变化了，最终的结果却没有使用新的数据。

这个时候你可能就又会问了：我在处理关键字变化的同时，再处理一下 data 属性变化的场景，这样不就可以保证三个状态的一致性了吗？比如再加上下面这段代码。

function FilterList({ data }) {
  // ...
  // 在 data 变化的时候，也重新生成最终数据
  useEffect(() => {
    setFiltered(data => {...})
  }, [data, searchKey])
  // ...
}

现在，我们终于能够保证三个状态的一致性了，整个搜索列表也能正常工作了！但是我们在获得一些成就感的同时，是不是也有一些小小的抱怨：这状态管理确实还挺复杂的，需要写这么多的逻辑来保证一致性，从而让功能正常工作。

那么，我想说的是，这种复杂性其实完全不需要。因为复杂性的根源就在于没有遵循状态最小化的原则，而是设计了一个多余的状态：过滤后的结果数据。由于这个结果数据实际上完全由原始数据和过滤关键字决定，那么我们在需要的时候每次重新计算得出就可以了。

那时候你可能又有疑问了，如果每次都计算，不是会有性能问题吗？其实在第 4 讲我已经提到，React 提供的 useMemo 这个 Hook 正是为了解决这个问题，可以缓存计算的结果。所以实现的代码可以修改如下：

import React, { useState, useMemo } from "react";

function FilterList({ data }) {
  const [searchKey, setSearchKey] = useState("");

  // 每当 searchKey 或者 data 变化的时候，重新计算最终结果
  const filtered = useMemo(() => {
    return data.filter((item) =>
      item.title.toLowerCase().includes(searchKey.toLowerCase())
    );
  }, [searchKey, data]);

  return (
    <div className="08-filter-list">
      <h2>Movies</h2>
      <input
        value={searchKey}
        placeholder="Search..."
        onChange={(evt) => setSearchKey(evt.target.value)}
      />
      <ul style={{ marginTop: 20 }}>
        {filtered.map((item) => (
          <li key={item.id}>{item.title}</li>
        ))}
      </ul>
    </div>
  );
}

可以看到，除了通过属性传递进来的 data 状态，我们只定义了一个 searchKey 这个状态。然后通过计算的方式，就可以得到最终需要展现的结果。这样，状态的一致性就得到了天然的保证。你看，通过使用状态最小化的原则，管理就变得非常简单了。

虽然这是一个比较简单的例子，但是在实际开发的过程中，很多复杂场景之所以变得复杂，如果抽丝剥茧来看，你会发现它们都有定义多余状态现象的影子，而问题的根源就在于它们没有遵循状态最小化的原则。

所以我们在定义一个新的状态之前，都要再三拷问自己：这个状态是必须的吗？是否能通过计算得到呢？在得到肯定的回答后，我们再去定义新的状态，就能避免大部分多余的状态定义问题了，也就能在简化状态管理的同时，保证状态的一致性。

原则二：避免中间状态，确保唯一数据源

上面的例子其实定义的多余状态比较明显，但在有的场景下，特别是原始状态数据来自某个外部数据源，而非 state 或者 props 的时候，冗余状态就没那么明显。这时候你就需要准确定位状态的数据源究竟是什么，并且在开发中确保它始终是唯一的数据源，以此避免定义中间状态。

还是拿刚才讲的可搜索电影列表的例子来说，我们需要在用户体验上做一个改进，要让搜索的结果做到可分享。这个功能就类似 Baidu 这样的搜索引擎，通过一个链接就能分享搜索结果。比如说，你通过“http://www.baidu.com/s?wd= 极客时间”就可以看到极客时间的搜索结果。

想象一下，如果搜索引擎没有这个功能，那使用起来会有多么不方便。每次要分享一个搜索结果，都必须告诉别人关键字是什么，让他 / 她自己打开 Baidu 去搜索。所以将关键字放到 URL 中也是实际开发中经常遇到的一个需求。

那么要实现这个功能，我们就需要让 URL 中包含搜索关键字的信息，这样任何人用这个 URL ，就都能看到和我一样的搜索关键字和结果了。

在考虑这个功能实现，尤其是基于已有功能做改进的时候，通常来说，直观的思路都是：首先把 URL 上的参数数据保存在一个 State 中，当 URL 变化时，就去改变这个 State。然后在组件中再根据这个 State 来实现搜索的业务逻辑。

按照这个思路来改进电影列表的话，我们就可以用类似下面的代码来实现：

// getQuery 函数用户获取 URL 的查询字符串
import getQuery from './getQuery';
// history 工具可以用于改变浏览器地址
import history from './history';

function SearchBox({ data }) {
  // 定义关键字这个状态，用 URL 上的查询参数作为初始值
  const [searchKey, setSearchKey] = useState(getQuery('key'));
  // 处理用户输入的关键字
  const handleSearchChange = useCallback(evt => {
    const key = evt.target.value;
    // 设置当前的查询关键状态
    setSearchKey(key);
    // 改变 URL 的查询参数
    history.push(`/movie-list?key=${key}`);
  })
  // ....
  return (
    <div className="08-search-box">
      <input
        value={searchKey}
        placeholder="Search..."
        onChange={handleSearchChange}
      />
      {/* 其它渲染逻辑*/}
    </div>
  );
}

可以看到，组件的核心逻辑基本没变，只是做了两个小的变化：

把 URL 上的查询参数作为关键字的默认值；

当用户输入搜索关键字时，我们不但更新了内部 State，同时还改变了 URL 的查询参数。

通过这两个小的变化，我们就实现了搜索结果的可分享。看上去似乎没有什么问题：保证了关键字状态，还有 URL 参数的一致性。但是正如我刚才强调的，一旦涉及到主动保持一致性的逻辑，我们就要考虑状态是否真的有必要。

所以我们再仔细思考下，就会发现上面的逻辑是有漏洞的。因为从 URL 参数到内部 State 的同步只有组件第一次渲染才会发生，而后面的同步则是由输入框的 onChange 事件保证的，那么一致性就很容易被破坏。

也就是说，如果 URL 不是由用户在组件内搜索栏去改变的，而是其它地方，比如说组件外的某个按钮去触发改变的，那么组件由于已经渲染过了，其实内部的 searchKey 这个 State 是不会被更新的，一致性就会被破坏。

要解决这个问题，一个比较容易想到的思路就是我们要有更加完善的机制，让在 URL 不管因为什么原因而发生变化的时候，都能同步查询参数到 searchKey 这个 State。但是如果沿着这个思路，那么状态管理就会一下子变得非常复杂。因为我们需要维护三个状态的一致性。

其实，从根源上来说，产生这个复杂度的问题在于我们定义了 searchKey 这样一个多余的中间状态，而且这个 searchKey 状态来源于两个数据源：一是用户输入；二是 URL 上的参数。这就导致逻辑非常复杂。

那么如果我们遵循唯一数据源这个原则，把 URL 上的查询关键字作为唯一数据源，逻辑就会变得简单了：只需要在用户输入查询关键字时，直接去改变 URL 上的查询字符串就行。这个转变可以用下面的图做一个对比，你会更直观地理解：

通过对比可以看到，左边引入了一个多余的 State 作为关键字这个状态，而为了保证一致性，就需要很多复杂的同步逻辑，比如说以下几点：

URL 变化时，同步查询关键字到 State；

State 变化时，同步查询关键字到输入框；

用户在输入框输入的时候，同步关键字到 URL 和 State。

但是，在去掉多余的 State 后，我们就只需要在输入框和 URL 的查询参数之间做一个同步。那么实现的代码可以简化如下：

import React, { useCallback, useMemo } from "react";
import { useSearchParam } from "react-use";

function SearchBox({ data }) {
  // 使用 useSearchParam 这个 Hook 用于监听查询参数变化
  const searchKey = useSearchParam("key") || "";
  const filtered = useMemo(() => {
    return data.filter((item) =>
      item.title.toLowerCase().includes(searchKey.toLowerCase())
    );
  }, [searchKey, data]);

  const handleSearch = useCallback((evt) => {
    // 当用户输入时，直接改变 URL
    window.history.pushState(
      {},
      "",
      `${window.location.pathname}?key=${evt.target.value}`
    );
  }, []);
  return (
    <div className="08-filter-list">
      <h2>Movies (Search key from URL)</h2>
      <input
        value={searchKey}
        placeholder="Search..."
        onChange={handleSearch}
      />
      <ul style={{ marginTop: 20 }}>
        {filtered.map((item) => (
          <li key={item.id}>{item.title}</li>
        ))}
      </ul>
    </div>
  );
}

可以看到，当用户输入参数的时候，我们是直接改变当前的 URL，而不是去改变一个内部的状态。所以当 URL 变化的时候，我们使用了 useSearchParam 这样一个第三方的 Hook 去绑定查询参数，并将其显示在输入框内，从而实现了输入框内容和查询关键字这个状态的同步。

从本质上来说，这个例子展示了确保状态唯一数据源的重要性。我们是直接将 URL 作为唯一的数据来源，那么状态的读取和修改都是对 URL 直接进行操作，而不是通过一个中间的状态。这样就简化了状态的管理，保证了状态的一致性。

实战演练：创建自定义受控组件

在前面两个例子中，你看到了状态管理的两个很重要的原则，一个是确保状态最小化，另一个则是找到正确的状态来源，并直接使用这个来源，避免中间状态。那么接下来我再通过一个日常开发中非常常见的例子，来帮助你理解和掌握这两个原则的实际应用。

这个例子就是创建一个受控表单组件。比如，一个用于输入价格的表单组件，需要用户既能输入价格的数量，还能选择货币的种类，最终的效果类似下面这个图：

首先我要解释下什么是受控组件。在 React 中，对表单组件的处理可以分为两种，受控组件和非受控组件：

受控组件：组件的展示完全由传入的属性决定。比如说，如果一个输入框中的值完全由传入的 value 属性决定，而不是由用户输入决定，那么就是受控组件，写法是：< input value={value} onChange={handleChange} />。这也是为什么只给 < input/> 传了一个 value 值但是没有传 onChange 事件，那么键盘怎么输入都没有反应。

非受控组件：表单组件可以有自己的内部状态，而且它的展示值是不受控的。比如 input 在非受控状态下的写法是：< input onChange={handleChange}/>。也就是说，父组件不会把 value 直接传递给 input 组件。

在日常开发中，大部分的表单元素其实都是受控组件，我们会通过外部的状态完全控制当前组件的行为。

那么对于这个例子，价格输入框作为一个受控组件，它需要定义两个属性：value 和 onChange。这样它就和一个普通的表单元素具有相同用法了。其中 value 的值是一个对象，同时包含数值和货币两个属性，比如：

{
  amount: 0,
  currency: 'rmb',
}

那现在我们就来看如何实现这个受控组件。在这里我就不再演示错误的写法是什么样的了，而是直接给你看正确的实现方式。

在实际项目中，我经常看到很多同学会把这个简单的功能做得逻辑非常复杂，甚至还不断出现 Bug，总不能保证 UI 和数据的一致性。这其实都是因为没有仔细思考状态的准确来源是什么，以及是否定义了多余的状态。

我们先直接来看正确的实现：

import React, { useState, useCallback } from "react";

function PriceInput({
  // 定义默认的 value 的数据结构
  value = { amount: 0, currency: "rmb" },
  // 默认不处理 onChange 事件
  onChange = () => {}
}) {
  // 定义一个事件处理函数统一处理 amount 或者 currency 变化的场景
  const handleChange = useCallback(
    (deltaValue) => {
      // 直接修改外部的 value 值，而不是定义内部 state
      onChange({
        ...value,
        ...deltaValue
      });
    },
    [value, onChange]
  );
  return (
    <div className="exp-02-price-input">
      {/* 输入价格的数量 */}
      <input
        value={value.amount}
        onChange={(evt) => handleChange({ amount: evt.target.value })}
      />
      {/* 选择货币种类*/}
      <select
        value={value.currency}
        onChange={(evt) => handleChange({ currency: evt.target.value })}
      >
        <option value="rmb">RMB</option>
        <option value="dollar">Dollar</option>
      </select>
    </div>
  );
}

可以看到，这个自定义组件包含了 input 和 select 两个基础组件，分别用来输入价格数量和选择货币。在它们发生变化的时候，直接去触发 onChange 事件让父组件去修改 value 值；同样的，它们自己显示的值，则完全来自于传递进来的 value 属性。所以这其中的思考逻辑在于：

避免多余的状态：我们不需要在 PriceInput 这个自定义组件内部，去定义状态用于保存的 amount 或者 currency。

找到准确的唯一数据源：这里内部两个基础组件的值，其准确且唯一的来源就是 value 属性，而不是其它的任何中间状态。

因此，通过这样的做法，整个自定义的组件逻辑就变得非常简单，甚至不需要任何内部的状态，就实现了这样一个非常常见的需求。

而在我看到的实际项目代码中，发现很多同学都习惯于用多余的内部状态去分别保存 amount 和 currency，然后再和外部传进来的 value 属性进行同步，以此来保证一致性，这就造成了状态逻辑的复杂。所以我们在做类似功能的时候，一定要避免这种不合理的做法，尽量用最简洁的逻辑去实现需要的功能。

小结

好了，这节课的内容就是这些，我简单小结一下。你可以把 React 的开发看作是复杂状态的管理和维护。那么为了保证状态的一致性，我们就一定要简化状态处理的逻辑。其中有两个重要的原则需要遵循：

一个是状态最小化原则，也就是说要避免冗余的状态；

另一个则是唯一数据源原则，避免中间状态。

所以，在任何时候想要定义新状态的时候，都要问自己一下：这个状态有必要吗？是否能通过计算得到？是否只是一个中间状态？只有每次都仔细思考了，才能找到需要定义的最本质的状态。然后围绕这个最本质的状态去思考某个功能具体的实现，从而让 React 的开发更加简洁和高效。

这节课所有的示例代码，你可以通过 codesandbox 查看：https://codesandbox.io/s/react-hooks-course-20vzg.

思考题

在第二个在 URL 中包含查询关键字的例子中，我们用到了 userSearchParams 这样一个第三方的 Hook，用于绑定 URL 上的查询字符串参数。如果让你实现这个 Hook，你会怎么做呢？

欢迎在评论区写下你的思考和想法，我会和你交流讨论。如果今天的实战演练让你有所收获，也欢迎把课程分享给你的同事、朋友，我们共同进步！

09｜异步处理：如何向服务器端发送请求？

你好，我是王沛。这节课我们来学习如何在 React Hooks 中处理 Rest API 请求。

我遇到很多 React 的初学者，刚把 Hello World 跑起来，问的第一个问题就是：我该怎么发请求拿数据呢？

可见，这是最为常见也是最为重要的一个需求。毕竟 90% 以上的前端 App 都是和服务器端打交道，然后通过各种 API 完成各种功能。

虽然发请求拿数据有很多种做法，但基本上都会遵循一定的规律，而这正是咱们这节课要介绍的内容。

其实在第一讲，我们就已经看到了异步请求的一个实例，当时我们在一个组件内直接发起了一个请求，并处理了返回结果。但这个简单的例子只是演示了组件中发请求的基本流程，在实际的项目中我们很少会直接这么写，因为还需要考虑更多的逻辑。

比如说，如何给所有请求都带上一个 Token 供服务器端验证？如何发起并发请求？等等。此外，咱们整个课程都是从 Hooks 角度去思考问题，那么 Hooks 又能给异步逻辑处理带来怎样的优化？这些都是接下来要具体介绍的内容。

实现自己的 API Client

无论大小项目，在开始实现第一个请求的时候，通常我们要做的第一件事应该都是创建一个自己的 API Client，之后所有的请求都会通过这个 Client 发出去。而不是上来就用 fetch 或者是 axios 去直接发起请求，因为那会造成大量的重复代码。

实现这样一个 Client 之后，你就有了一个统一的地方，去对你需要连接的服务端做一些通用的配置和处理，比如 Token、URL、错误处理等等。

这个步骤呢，虽然跟 React 和 Hooks 没有直接的关系，但是我经常看到很多同学是在多个组件内分别实现了异步请求处理，写了很多重复代码之后，才开始考虑把这个功能提取出来，增加了额外工作量。所以这里我就先简单介绍下，实现这样一个 API Client 需要考虑哪些因素。

通常来说，会包括以下几个方面：

一些通用的 Header。比如 Authorization Token。

服务器地址的配置。前端在开发和运行时可能会连接不同的服务器，比如本地服务器或者测试服务器，此时这个 API Client 内部可以根据当前环境判断该连接哪个 URL。

请求未认证的处理。比如如果 Token 过期了，需要有一个统一的地方进行处理，这时就会弹出对话框提示用户重新登录。

从我的经验来看，我更推荐把 axios 作为基础来实现这个功能。原因就在于，axios 比起 fetch，提供了更为方便，也更加语义化的 API，比如请求拦截。此外，还很容易创建多个实例，让代码逻辑更简洁。所以我就以 axios 为例，提供一个示例实现：

import axios from "axios";

// 定义相关的 endpoint
const endPoints = {
  test: "https://60b2643d62ab150017ae21de.mockapi.io/",
  prod: "https://prod.myapi.io/",
  staging: "https://staging.myapi.io/"
};

// 创建 axios 的实例
const instance = axios.create({
  // 实际项目中根据当前环境设置 baseURL
  baseURL: endPoints.test,
  timeout: 30000,
  // 为所有请求设置通用的 header
  headers: { Authorization: "Bear mytoken" }
});

// 听过 axios 定义拦截器预处理所有请求
instance.interceptors.response.use(
  (res) => {
    // 可以假如请求成功的逻辑，比如 log
    return res;
  },
  (err) => {
    if (err.response.status === 403) {
      // 统一处理未授权请求，跳转到登录界面
      document.location = '/login';
    }
    return Promise.reject(err);
  }
);

export default instance;

这样我们就定义了一个简单的 API Client，之后所有的请求都可以通过 Client 连接到指定的服务器，从而不再需要单独设置 Header，或者处理未授权的请求了。当然，虽然例子中用的是 axios，但如果你更倾向于原生的 fetch API，那么使用 fetch 也是完全可以的。这两种方式没有绝对的优劣，你可以按照自己的喜好进行选择。

有了这个 Client，那接下来的例子中，我们都会使用这个 Client ，来连接指定的 server。

使用 Hooks 思考异步请求：封装远程资源

在第 2 讲我曾经介绍过 Hooks 的一个本质特性，那就是万物皆可钩，意思是说我们可以把任何一个数据源变成 React 组件中可以绑定的一个数据源。

这个特性对于远程的 Get 类型的请求非常有用。因为 Get 请求，通常就是用来获取数据的。所以从 Hooks 角度来说，我们可以认为一个 Get 请求就是一个远程数据源。那么把这个数据源封装成 Hooks 后，使用远程 API 将会非常方便。

下面这张图可以比较直观地描述这种模式。对于一个 Get 类型的 API，我们完全可以将它看成一个远程的资源。只是和本地数据不同的地方在于，它有三个状态，分别是：

Data: 指的是请求成功后服务器返回的数据；

Error: 请求失败的话，错误信息将放到 Error 状态里；

Pending: 请求发出去，在返回之前会处于 Pending 状态。

有了这三个状态，我们就能够在 UI 上去显示 loading，error 或者获取成功的数据了。使用起来会非常方便。

要实现这样一个 Hook 其实也并不难，比起在组件内部直接发请求，我们只是把代码换了个地方，也就是写到了 Hook 里面。下面是代码的实现：

import { useState, useEffect } from "react";
import apiClient from "./apiClient";

// 将获取文章的 API 封装成一个远程资源 Hook
const useArticle = (id) => {
  // 设置三个状态分别存储 data, error, loading
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  useEffect(() => {
    // 重新获取数据时重置三个状态
    setLoading(true);
    setData(null);
    setError(null);
    apiClient
      .get(`/posts/${id}`)
      .then((res) => {
        // 请求成功时设置返回数据到状态
        setLoading(false);
        setData(res.data);
      })
      .catch((err) => {
        // 请求失败时设置错误状态
        setLoading(false);
        setError(err);
      });
  }, [id]); // 当 id 变化时重新获取数据

  // 将三个状态作为 Hook 的返回值
  return {
    loading,
    error,
    data
  };
};

那么要显示一篇文章的时候，你的脑子里就不再是一个具体的 API 调用，而可以把它看作一个数据，只不过是个远程数据，于是很自然就有加载状态或者错误状态这些数据了。使用的时候，我们就可以把组件的表现层逻辑写得非常简洁：

import useArticle from "./useArticle";

const ArticleView = ({ id }) => {
  // 将 article 看成一个远程资源，有 data, loading, error 三个状态
  const { data, loading, error } = useArticle(id);
  if (error) return "Failed.";
  if (!data || loading) return "Loading...";
  return (
    <div className="exp-09-article-view">
      <h1>
        {id}. {data.title}
      </h1>
      <p>{data.content}</p>
    </div>
  );
};

可以看到，有了这样一个 Hook，React 的函数组件几乎不需要有任何业务的逻辑，而只是把数据映射到 JSX 并显示出来就可以了，在使用的时候非常方便。

事实上，在我们的项目中，可以把每一个 Get 请求都做成这样一个 Hook。数据请求和处理逻辑都放到 Hooks 中，从而实现 Model 和 View 的隔离，不仅代码更加模块化，而且更易于测试和维护。

这里有同学可能会问，为什么要给每个请求都定义一个 Hook 呢？我们直接提供一个通用的 Hook，比如 useRemoteData，然后把 API 地址传进去，难道不可以吗？

不是完全不可以，但这其实是为了保证每个 Hook 自身足够简单。一般来说，为了让服务器的返回数据满足 UI 上的展现要求，通常需要进一步处理。而这个对于每个请求的处理逻辑可能都不一样，通过一定的代码重复，能够避免产生太复杂的逻辑。

同时呢，某个远程资源有可能是由多个请求组成的，那么 Hooks 中的逻辑就会不一样，因为要同时发出去多个请求，组成 UI 展现所需要的数据。所以，将每个 Get 请求都封装成一个 Hook ，也是为了让逻辑更清楚。

当然，这个模式仅适用于 Get 请求的逻辑，对于其它类型，我在第 6 讲中有提到过。当时举的例子是 useAsync 这样一个自定义的 Hook，同样也是用 Hook 的思想，把请求的不同状态封装成了一个数据源供组件使用。

多个 API 调用：如何处理并发或串行请求？

在刚才讲的文章显示的例子中，我们只是简单显示了文章的内容，要知道，实际场景肯定比这个更复杂。比如，还需要显示作者、作者头像，以及文章的评论列表。那么，作为一个完整的页面，就需要发送三个请求：

获取文章内容；

获取作者信息，包括名字和头像的地址；

获取文章的评论列表；

这三个请求同时包含了并发和串行的场景：文章内容和评论列表是两个可以并发的请求，它们都通过 Article ID 来获取；用户的信息需要等文章内容返回，这样才能知道作者的 ID，从而根据用户的 ID 获取用户信息，这是一个串行的场景。

如果用传统的思路去实现，可能会写下这样一段代码，或者类似的代码：

// 并发获取文章和评论列表
const [article, comments] = await Promise.all([
  fetchArticle(articleId),
  fetchComments(articleId)
]);
// 得到文章信息后，通过 userId 获取用户信息
const user = await fetchUser(article.userId);

但是我们知道，React 函数组件是一个同步的函数，没有办法直接使用 await 这样的同步方法，而是要把请求通过副作用去触发。因此如果按照上面这种传统的思维，是很难把逻辑理顺的。

这时候我们就要回到 React 的本质，那就是状态驱动 UI。这意味着我们可以从状态变化的角度去组织异步调用。

函数组件的每一次 render，其实都提供了我们根据状态变化执行不同操作的机会，我们思考的路径，就是利用这个机制，通过不同的状态组合，来实现异步请求的逻辑。

那么刚才这个显示作者和评论列表的业务需求，主要的实现思路就包括下面这么四点：

组件首次渲染，只有文章 ID 这个信息，产生两个副作用去获取文章内容和评论列表；

组件首次渲染，作者 ID 还不存在，因此不发送任何请求；

文章内容请求返回后，获得了作者 ID，然后发送请求获取用户信息；

展示用户信息。

可以看到，这里的任何一个副作用，也就是异步请求，都是基于数据的状态去进行的。只有当文章的数据返回之后，我们才能得到作者 ID，从而再发送另外一个请求来获取作者信息。这样基于一个数据状态的变化，我们就实现了串行发送请求这个功能。

所以，在代码层面，我们首先需要对 useUser 这个 Hook 做一个改造，使得它在没有传入 ID 的情况下，就不发送请求。对比上面的 useArticle 这个 Hook，唯一的变化就是在 useEffect 里加入了ID 是否存在的判断：

import { useState, useEffect } from "react";
import apiClient from "./apiClient";

export default (id) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  useEffect(() => {
    // 当 id 不存在，直接返回，不发送请求
    if (!id) return;
    setLoading(true);
    setData(null);
    setError(null);
    apiClient
      .get(`/users/${id}`)
      .then((res) => {
        setLoading(false);
        setData(res.data);
      })
      .catch((err) => {
        setLoading(false);
        setError(err);
      });
  }, [id]);
  return {
    loading,
    error,
    data
  };
};

那么，在文章的展示页面，我们就可以使用下面的代码来实现：

import { useState } from "react";
import CommentList from "./CommentList";
import useArticle from "./useArticle";
import useUser from "./useUser";
import useComments from "./useComments";

const ArticleView = ({ id }) => {
  const { data: article, loading, error } = useArticle(id);
  const { data: comments } = useComments(id);
  const { data: user } = useUser(article?.userId);
  if (error) return "Failed.";
  if (!article || loading) return "Loading...";
  return (
    <div className="exp-09-article-view">
      <h1>
        {id}. {article.title}
      </h1>
      {user && (
        <div className="user-info">
          <img src={user.avatar} height="40px" alt="user" />
          <div>{user.name}</div>
          <div>{article.createdAt}</div>
        </div>
      )}
      <p>{article.content}</p>
      <CommentList data={comments || []} />
    </div>
  );
};

这里，结合代码我们再理一下其中并发和串行请求的思路。

因为文章的 ID 已经传进来了，因此 useArticle 和 useComments 这两个 Hooks 会发出两个并发的请求，来分别获取信息。而 userUser 这个 Hook 则需要等 article 内容返回后，才能获得 userId 信息，所以这是一个串行的请求：需要等文章内容的请求完成之后才能发起。

那么，最终完整的页面显示的效果如下图所示：

实际的运行效果你也可以通过文末的代码链接来查看。

小结

在这一讲，我们看到了怎么从 Hooks 的角度去组织异步请求。

首先，我们要定义一个自己的 API Client，封装整个应用中异步请求的一些通过设置，以及统一处理，方便在 Hooks 中使用。

然后，我们需要充分利用 Hooks 能让数据源变得可绑定的能力，让一个远程 API 对应的数据成为一个语义化的数据源，既可以把业务逻辑和 UI 展现很好地分开，也有利于测试和维护。

最后呢，我们学习了针对多请求的处理，怎么利用状态的组合变化来实现并发和串行请求。

学到这里，你会发现，这节课的内容可能和传统的思考方式有较大区别，所以你一定要结合课程代码，仔细思考和体会，才能将这种思考方式熟记于心，并熟练运用到实际的开发当中。

课程中的例子地址是：https://codesandbox.io/s/react-hooks-course-20vzg。你可以在线查看代码和运行效果。

思考题

在课程的例子中，每次调用 useArticle 这个 Hook 的时候都会触发副作用去获取数据。但是有时候，我们希望在有些组件自动获取，但有的组件中需要点击某个按钮才去获取数据，那么你会如何设计这个 Hook？（可能这道题有一点难度。）

课程中的 Hook 都是使用的 useState 保存了状态数据，也就意味着状态的范围限定在组件内部，组件销毁后，数据就没了。那么如果希望数据直接缓存到全局状态，应该如何做呢？

欢迎把你的思考和想法分享在留言区，我会把其中不错的回答进行置顶，供同学们交流讨论。期待你的分享！

10｜函数组件设计模式：如何应对复杂条件渲染场景？

你好，我是王沛。今天我们来聊聊函数组件中的设计模式。

所谓设计模式，就是针对特定场景，提供一种公认的最佳实践。在前面的课程中，我们已经提到了不少模式，比如保证状态的唯一数据源，语义化的拆分复杂组件，等等。熟练掌握这些模式，可以让我们的代码更加简洁直观。

那么今天这节课我会介绍另外两个模式：

一个和 Hooks 相关，用于解决 Hooks 无法在条件语句中执行带来的一些难题；

另一个则是经典的 render props 模式，用于实现 UI 逻辑的重用。

容器模式：实现按条件执行 Hooks

第 2 讲我们介绍了 Hooks 的一个重要规则，即：Hooks 必须在顶层作用域调用，而不能放在条件判断、循环等语句中，同时也不能在可能的 return 语句之后执行。换句话说，Hooks 必须按顺序被执行到。

这个规则存在的原因就在于，React 需要在函数组件内部维护所用到的 Hooks 的状态，所以我们无法在条件语句中使用 Hooks，这因而会给我们实现业务逻辑带来一定的局限。

比如说，对于一个对话框组件，通过 visible 属性来控制是否显示。那么在 visible 为 false 的时候，其实不应该执行任何对话框内部的逻辑，因为还没展示在 UI 上。

需要注意，只有在 visible 为 true 的时候才应该去执行业务逻辑，展现数据。那么我们期望的代码可能是下面的方式：

import { Modal } from "antd";
import useUser from "../09/useUser";

function UserInfoModal({ visible, userId, ...rest }) {
  // 当 visible 为 false 时，不渲染任何内容
  if (!visible) return null;
  // 这一行 Hook 在可能的 return 之后，会报错！
  const { data, loading, error } = useUser(userId);

  return (
    <Modal visible={visible} {...rest}>
      {/* 对话框的内容 */}
    </Modal>
  );
};

可以看到，我们期望在对话框隐藏时通过返回 null 不去渲染任何内容，这个逻辑看上去非常自然直观。

但是呢，它却通不过编译，因为在 return 语句之后使用了 useUser 这个 Hook。所以在你的编辑器配置了 React Hooks 的 ESLint 插件之后，会给出下面的错误提示：

可以看到，因为 Hooks 使用规则的存在，使得有时某些逻辑无法直观地实现。换句话说，Hooks 在带来众多好处的同时，也或多或少带来了一些局限。因此，我们需要用一个间接的模式来实现这样的逻辑，可以称之为容器模式。

具体做法就是把条件判断的结果放到两个组件之中，确保真正 render UI 的组件收到的所有属性都是有值的。

针对刚才我们讲的例子，就可以在 UserInfoModal 外层加一个容器，这样就能实现条件渲染了。实现的代码如下：

// 定义一个容器组件用于封装真正的 UserInfoModal
export default function UserInfoModalWrapper({
  visible,
  ...rest, // 使用 rest 获取除了 visible 之外的属性
}) {
  // 如果对话框不显示，则不 render 任何内容
  if (!visible) return null;
  // 否则真正执行对话框的组件逻辑
  return <UserInfoModal visible {...rest} />;
}

这样的话，我们就间接实现了按条件去执行 Hooks 的逻辑。

在实际的使用场景中，可能判断条件不止 visible 一个属性，而会是一些属性的组合，来具体决定 render 什么内容。虽然这样的做法不够直观，但其实也能带来一些好处。比如说，在函数组件你会少写一些条件判断语句，并且确保每个组件尽量短小，这样反而更加易读和维护。

在容器模式中我们其实也可以看到，条件的隔离对象是多个子组件，这就意味着它通常用于一些比较大块逻辑的隔离。所以对于一些比较细节的控制，其实还有一种做法，就是把判断条件放到 Hooks 中去。

比如上节课的例子，我们需要先发送请求，获得文章信息，从而知道作者的 ID 是什么，这样才能用 useUser 这个 Hook 去获取用户数据。

那么直观的写法是下面这样的：

const ArticleView = ({ id }) => {
  const { data: article, loading } = useArticle(id);
  let user = null;
  if (article?.userId) user = useUser(article?.userId).data;
  // 组件其它逻辑
}

可以看到，我们需要的 article 这个对象获取到之后，才能去用 useUser 这个 Hook 再去获取用户信息。那么同样的，既然 Hook 不能放到条件语句中，那我们应该如何做呢？

事实上，上一讲的例子已经给出了答案，那就是把条件语句自包含在 Hook 之中。这样当没有传递 userId 给 useUser 这个 Hook 的时候，副作用里实际上什么也不做，比如：

function useUser(id) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  useEffect(() => {
    // 当 id 不存在，直接返回，不发送请求
    if (!id) return
    // 获取用户信息的逻辑
  });
}

可以看到，在 useEffect 中我们会判断 ID 是否存在。如果不存在，就不发送请求。这样的话，这个 Hook 就可以在组件中无条件使用了。

总体来说，通过这样一个容器模式，我们把原来需要条件运行的 Hooks 拆分成子组件，然后通过一个容器组件来进行实际的条件判断，从而渲染不同的组件，实现按条件渲染的目的。这在一些复杂的场景之下，也能达到拆分复杂度，让每个组件更加精简的目的。

使用 render props 模式重用 UI 逻辑

对于 React 开发而言，如果要挑选一个最重要的设计模式，那一定是 render props。因为它解决了 UI 逻辑的重用问题，不仅适用于 Class 组件，在函数组件的场景下也不可或缺。

鉴于大家日常交流都习惯用这个英文的名字，所以这里我也就不翻译成中文了。顾名思义，render props 就是把一个 render 函数作为属性传递给某个组件，由这个组件去执行这个函数从而 render 实际的内容。

在 Class 组件时期，render props 和 HOC（高阶组件）两种模式可以说是进行逻辑重用的两把利器，但是实际上，HOC 的所有场景几乎都可以用 render props 来实现。可以说，Hooks 是逻辑重用的第一选择。

不过在如今的函数组件情况下，Hooks 有一个局限，那就是只能用作数据逻辑的重用，而一旦涉及 UI 表现逻辑的重用，就有些力不从心了，而这正是 render props 擅长的地方。所以，即使有了 Hooks，我们也要掌握 render props 这个设计模式的用法。

为了方便你理解 render props 这个模式，我先给你举一个数据逻辑重用的简单例子。这个例子仍然是我们熟悉的计数器。有两个按钮，加一和减一，并将当前值显示在界面上。执行的效果如下图所示：

如果不考虑 UI 的展现，这里要抽象的业务逻辑就是计数逻辑，包括三个部分：

count: 当前计数值；

increase: 让数值加 1 的方法；

decrease: 让数值减 1 的方法。

如果用 render props 模式把这部分逻辑封装起来，那就可以在不同的组件中使用，由使用的组件自行决定 UI 如何展现。下面的代码就是这个计数器的 render props 的实现：

import { useState, useCallback } from "react";

function CounterRenderProps({ children }) {
  const [count, setCount] = useState(0);
  const increment = useCallback(() => {
    setCount(count + 1);
  }, [count]);
  const decrement = useCallback(() => {
    setCount(count - 1);
  }, [count]);

  return children({ count, increment, decrement });
}

可以看到，我们要把计数逻辑封装到一个自己不 render 任何 UI 的组件中，那么在使用的时候可以用如下的代码：

function CounterRenderPropsExample() {
  return (
    <CounterRenderProps>
      {({ count, increment, decrement }) => {
        return (
          <div>
            <button onClick={decrement}>-</button>
            <span>{count}</span>
            <button onClick={increment}>+</button>
          </div>
        );
      }}
    </CounterRenderProps>
  );
}

这里利用了 children 这个特殊属性。也就是组件开始 tag 和结束 tag 之间的内容，其实是会作为 children 属性传递给组件。那么在使用的时候，是直接传递了一个函数过去，由实现计数逻辑的组件去调用这个函数，并把相关的三个参数 count，increase 和 decrease 传递给这个函数。

当然，我们完全也可以使用其它的属性名字，而不是 children。我们只需要把这个 render 函数作为属性传递给组件就可以了，这也正是 render props 这个名字的由来。

这个例子演示了纯数据逻辑的重用，也就是重用的业务逻辑自己不产生任何 UI。那么在这种场景下，其实用 Hooks 是更方便的，在第 6 讲中，我其实已经给过这么计数器的 Hooks 实现的例子，代码如下：

import { useState, useCallback }from 'react';

function useCounter() {
  // 定义 count 这个 state 用于保存当前数值
  const [count, setCount] = useState(0);
  // 实现加 1 的操作
  const increment = useCallback(() => setCount(count + 1), [count]);
  // 实现减 1 的操作
  const decrement = useCallback(() => setCount(count - 1), [count]);

  // 将业务逻辑的操作 export 出去供调用者使用
  return { count, increment, decrement };
}

很显然，使用 Hooks 的方式是更简洁的。这也是为什么我们经常说 Hooks 能够替代 render props 这个设计模式。但是，需要注意的是，Hooks 仅能替代纯数据逻辑的 render props。如果有 UI 展示的逻辑需要重用，那么我们还是必须借助于 render props 的逻辑，这就是我一再强调必须要掌握 render props 这种设计模式的原因。

为了解释这个用法，我给你举一个例子。比如，我们需要显示一个列表，如果超过一定数量，则把多余的部分折叠起来，通过一个弹出框去显示。下面这张图可以比较直观地展示这个需求的实际运行效果：

可以看到，这里展示了两个列表。一个只显示用户名，这在一些社交软件的界面上很常见，只显示几个点赞的用户，多余的用一个数字来表示，鼠标移上去则跳转或者显示完整列表。

另外一个是表格，但是也只显示前面 5 个，多余的折叠到 “更多...” 里面。比如说，对于第一个，鼠标移上去后的效果如下图所示：

我们来分析一下。对于这一类场景，功能相同的部分是：数据超过一定数量时，显示一个 “更多...”的文字；鼠标移上去则弹出一个框，用于显示其它的数据。

功能不同的部分是：每一个列表项如何渲染，是在使用的时候决定的。

因此，对于这一类具有 UI 逻辑重用需求的场景，我们就无法通过 Hooks 实现，而是需要通过 render props 这个设计模式。

下面这段代码展示了如何实现这个包含了 render props 的 ListWithMore 组件：

import { Popover } from "antd";

function ListWithMore({ renderItem, data = [], max }) {
  const elements = data.map((item, index) => renderItem(item, index, data));
  const show = elements.slice(0, max);
  const hide = elements.slice(max);
  return (
    <span className="exp-10-list-with-more">
      {show}
      {hide.length > 0 && (
        <Popover content={<div style={{ maxWidth: 500 }}>{hide}</div>}>
          <span className="more-items-wrapper">
            and{" "}
            <span className="more-items-trigger"> {hide.length} more...</span>
          </span>
        </Popover>
      )}
    </span>
  );
}

可以看到，这个组件接收了三个参数，分别是：

renderItem：用于接收一个函数，由父组件决定如何渲染一个列表项；

data：需要渲染的数据；

max：最多显示几条数据。

这样，任何有类似需求的场景就都可以用这个组件去实现了。下面这段代码展示了上面示意图中两个场景的实现代码，你可以体会一下：

// 这里用一个示例数据
import data from './data';

function ListWithMoreExample () => {
  return (
    <div className="exp-10-list-with-more">
      <h1>User Names</h1>
      <div className="user-names">
        Liked by:{" "}
        <ListWithMore
          renderItem={(user) => {
            return <span className="user-name">{user.name}</span>;
          }}
          data={data}
          max={3}
        />
      </div>
      <br />
      <br />
      <h1>User List</h1>
      <div className="user-list">
        <div className="user-list-row user-list-row-head">
          <span className="user-name-cell">Name</span>
          <span>City</span>
          <span>Job Title</span>
        </div>
        <ListWithMore
          renderItem={(user) => {
            return (
              <div className="user-list-row">
                <span className="user-name-cell">{user.name}</span>
                <span>{user.city}</span>
                <span>{user.job}</span>
              </div>
            );
          }}
          data={data}
          max={5}
        />
      </div>
    </div>
  );
};

可以看到，代码里使用了两个 ListWithMore 组件，通过 renderItem 这个属性，我们可以自主决定该如何渲染每一个列表项，从而把一部分 UI 逻辑抽象出来，形成一个可复用的逻辑，以简化不同场景的使用。

小结

在今天这节课，我们介绍了两个设计模式。

一个是容器模式，可以实现类似于按条件执行 Hooks 的功能。虽然这是一个间接的方式，但是能够帮助我们更好地做组件逻辑的分离。

第二个则是经典的 render props 模式，虽然它和 Hooks 没有任何关系，但它可以作为 Hooks 的一个补充。在我们需要重用某些 UI 逻辑的时候，提供一个实现方案。

课程中的代码，以及在线运行效果的链接在这里： https://codesandbox.io/s/react-hooks-course-20vzg 。你可以 fork 后自己动手尝试。

思考题

思考一下你做过的项目，你能想到哪些 render props 模式的使用场景呢？欢迎交流分享。

11｜事件处理：如何创建自定义事件？

你好，我是王沛。今天我们来聊聊 React 中的事件处理。

我们知道，在 React 中，父子组件的交互是通过 props。这个机制其实是双向的，父组件通过 props 把值传递给子组件，而子组件则通过暴露一些事件，给父组件反馈到一些状态或数据。这两个环节是组件之间通信的基础，所以都需要熟练掌握。

我们前面的课程已经有很多通过 props 从父组件传递参数的子组件的场景了，那么今天这节课，我们就来看看在 React 中事件的机制是怎样的，从原理出发，帮助你深入理解。同时呢，也来学习一下对于自定义事件，一般都有哪些典型的应用场景，帮助你学以致用。

在 React 中使用原生事件

在 React 中进行事件监听的语法，和原生 DOM 事件的写法是非常类似的，都是在一个节点上加一个回调函数的属性来实现。比如下面的方式：

<button onClick={handler}>Hello</button>

在前面的课程中我们已经看到了很多类似的例子。不过还要特别说明一点，对于原生 DOM 的事件，标准的的写法可能是都小写，比如 onclick。但是在 React 中，都是约定使用骆驼体（Camel Case）。

通过这个规律，我们基本上不用刻意去记事件的名称。我们要始终记得，只要原生 DOM 有的事件，在 React 中基本都可以使用，只是写法上采用骆驼体就可以了，比如 onMouseOver、onChange 等。

此外，关于事件处理函数，那就要回到很多同学之前在留言区提到的一个问题：是不是所有的回调函数都需要用 useCallback 进行封装呢？是不是简单的回调函数就可以不用封装了呢？

其实是否需要 useCallback ，和函数的复杂度没有必然关系，而是和回调函数绑定到哪个组件有关。这是为了避免因组件属性变化而导致不必要的重新渲染。

而对于原生的 DOM 节点，比如 button、input 等，我们是不用担心重新渲染的。所以呢，如果你的事件处理函数是传递给原生节点，那么不写 callback，也几乎不会有任何性能的影响。

但是如果你使用的是自定义组件，或者一些 UI 框架的组件，那么回调函数还都应该用 useCallback 进行封装。

##React 原生事件的原理：合成事件（Synthetic Events）

因为原生事件的机制比较特别，所以我在这里要特别强调一下它的原理。

由于虚拟 DOM 的存在，在 React 中即使绑定一个事件到原生的 DOM 节点，事件也并不是绑定在对应的节点上，而是所有的事件都是绑定在根节点上。然后由 React 统一监听和管理，获取事件后再分发到具体的虚拟 DOM 节点上。

在 React 17 之前，所有的事件都是绑定在 document 上的，而从 React 17 开始，所有的事件都绑定在整个 App 上的根节点上，这主要是为了以后页面上可能存在多版本 React 的考虑。

具体来说，React 这么做的原因主要有两个。

第一，虚拟 DOM render 的时候， DOM 很可能还没有真实地 render 到页面上，所以无法绑定事件。

第二，React 可以屏蔽底层事件的细节，避免浏览器的兼容性问题。同时呢，对于 React Native 这种不是通过浏览器 render 的运行时，也能提供一致的 API。

这里有一点我要多解释下。那就是为什么事件绑定在某个根节点上，也能触发实际 DOM 节点的事件。

我们知道，在浏览器的原生机制中，事件会从被触发的节点往父节点冒泡，然后沿着整个路径一直到根节点，所以根节点其实是可以收到所有的事件的。这也称之为浏览器事件的冒泡模型。

因此，无论事件在哪个节点被触发， React 都可以通过事件的 srcElement 这个属性，知道它是从哪个节点开始发出的，这样 React 就可以收集管理所有的事件，然后再以一致的 API 暴露出来。

这样的话，我们在写原生事件的时候，就再也不用再担心浏览器兼容性的问题了。如下图所示，就展示了事件机制冒泡模型的原理：

对于原生事件的处理，可以说是 React 的一个创新，不仅能够让你像以前在原生 DOM 节点上定义事件的方式一样，而且还提供了一致的 API，让我们不用再担心浏览器的兼容问题，使用起来也更加容易。

创建自定义事件

对于一个自定义组件，除了可以从 props 接收参数并用于渲染之外，还很可能需要和父组件进行交互，从而反馈信息。这个时候，我们就需要为组件创建自定义事件，这也是 React 整个 UI 模型中非常重要的一个环节。

需要注意的是，虽然自定义事件和原生事件看上去类似，但是两者的机制是完全不一样的：

原生事件是浏览器的机制；

而自定义事件则是纯粹的组件自己的行为，本质是一种回调函数机制。

可能你一听自定义事件，下意识就觉得有些麻烦。但其实在 React 中，自定义事件不用通过任何特殊的 API，只需要通过 props 给组件传递一个回调函数，然后在组件中的某个时机，比如用户输入，或者某个请求完成时，去调用这个传过来的回调函数就可以了。

当然，习惯上我们都会将这样的回调函数命名为 onSomething 这种以“ on ”开头的名字，方便在使用的时候理解。

其实在第 6 讲中，我们在创建一个价格输入组件时已经实现了自定义事件，但是并没有具体地解释。那么在这里再给你举一个简单的例子，实现一个 on/off 的切换按钮，那么理解自定义事件，其实就非常简单了。

下面就是实现的代码，你也可以通过文末的链接在线查看代码和运行效果：

import { useState } from "react";

// 创建一个无状态的受控组件
function ToggleButton({ value, onChange }) {
  const handleClick = () => {
    onChange(!value);
  };
  return (
    <button style={{ width: "60px" }} onClick={handleClick}>
      <span>{value ? "On" : "Off"}</span>
    </button>
  );
}

可以看到，所谓自定义事件，就是定义了一个 onChange 这样的属性，允许传递一个回调函数给这个组件，在某个时机去调用这个回调函数，从而实现事件的功能。

那么下面的代码就演示了如何使用这样一个组件：

import { useState } from "react";
import ToggleButton from './ToggleButton';

function ToggleButtonExample() {
  const [on, setOn] = useState(true);
  return (
    <>
      <h1>Toggle Button</h1>
      <ToggleButton value={on} onChange={(value) => setOn(value)} />
    </>
  );
};

这里可以看到，ToggleButton 组件内部调用了 onChange 这个通过属性传递进来的回调函数，并传递了当前值给回调函数，从而实现了子组件到父组件的通信。

使用 Hooks 封装键盘事件

开篇词就提到，整个课程我们都是围绕 React Hooks 展开的，那么 Hooks 在事件处理方面又可以带来哪些新的思路呢？

在第 6 讲我们已经看到了一个 useScroll 的例子，通过 Hooks 绑定了滚动条位置这个数据源。那么这里再给大家看一个绑定键盘按键的例子，看看如何使用 Hooks，让代码语义更加清晰。

相比 useScroll，键盘事件可能看上去更加和 Hooks 没有关系。因为滚动条位置至少在直观上是一个很明显的数据，但是键盘呢，却完全是一个个独立的事件。

不过，如果你充分认识到 Hooks 具备绑定任何数据源的能力，那你就会知道，键盘输入正是一个非常常见的数据源。这样，我们就可以把键盘输入变成某个状态，方便在组件中使用。

在没有 Hooks 的时候，比如说我们要让某个显示表格的页面，支持通过左右键进行翻页的功能。那么我们就需要在 useEffect 里去做 window.addEventListner，然后在返回的回调函数里去 window.removeEventListner，实现起来就很麻烦。

但是，如果我们用一个 Hook 来实现这个功能，那么只要实现一次，就可以在多个组件中使用了。下面就是这个 Hook 的实现代码：

import { useEffect, useState } from "react";

// 使用 document.body 作为默认的监听节点
const useKeyPress = (domNode = document.body) => {
  const [key, setKey] = useState(null);
  useEffect(() => {
    const handleKeyPress = (evt) => {
      setKey(evt.keyCode);
    };
    // 监听按键事件
    domNode.addEventListener("keypress", handleKeyPress);
    return () => {
      // 接触监听按键事件
      domNode.removeEventListener("keypress", handleKeyPress);
    };
  }, [domNode]);
  return key;
};

有了这个 Hook，我们在使用的时候就非常方便，无需做任何事件的绑定，而是只要把键盘按键看做是一个不断变化的数据源，这样，就可以去实时监听某个 DOM 节点上触发的键盘事件了。

比如下面就是去显示当前按键的一个简单使用例子：

import useKeyPress from './useKeyPress';

function UseKeyPressExample() => {
  const key = useKeyPress();
  return (
    <div>
      <h1>UseKeyPress</h1>
      <label>Key pressed: {key || "N/A"}</label>
    </div>
  );
};

小结

在这节课，我们主要学习了 React 中的事件机制。事件其实分为两种。

一种是原生的 DOM 事件，在用法上和标准的 DOM API 是基本一一映射的，通过事件合成的机制，React 能够提供统一的 API 来抹平浏览器之间的差异，让开发更加简单。

另一种事件则是自定义事件，所谓的自定义事件，其实就是利用了属性传递回调函数给子组件，实现事件的触发。本质上，它和原生事件的机制是完全不一样的，原生事件是浏览器层面的事件，而自定义事件则是纯组件实现的一种机制。

最后呢，我们分析了一个例子：如何用 Hooks 去简化事件的处理。将键盘按键这个事件封装成了一个 Hook，就可以简化键盘事件的使用，也很好地展示了 Hooks 的思路带给我们的惊喜，可以让本来很技术的一些实现，比如事件的监听和解绑，变得更加具有语义，从而也让代码更容易理解和维护。

最后，附上这节课所有的代码和运行结果的链接：https://codesandbox.io/s/react-hooks-course-20vzg。

思考题

在键盘按键的例子中，我们只是实现了单个按键的监听，如果我们要实现同时两个按键的监听呢？比如同时按下 A 和 B，那么用 Hooks 怎么去比较好的实现？

这节课的题目有点难度，但是会对你的学习很有帮助，希望你可以深度思考一下，并在评论区分享你的想法和思考。同时，我也会把一些不错的回答进行置顶，供大家学习交流。

12｜项目结构：为什么要按领域组织文件夹结构？

你好，我是王沛。今天我们来聊聊如何组织项目文件夹结构。

很多同学在做项目开发时都会有这样的体验：项目初期，做得爽，进展快。但等功能做得差不多了，比如已经完成 90%，会突然感觉进展慢下来了。在剩下 10% 的功能开发上花的时间，可能比前面做 90% 的功能还要多。

这种现象，有人称之为“90-90 定律”。这就导致我们很难预估项目究竟什么时候才能做完。

产生这个现象的原因是多种多样的，但其中一个很重要的原因就在于，随着功能的增加，项目会变得越来越复杂。而在项目的后期，每增加一个新功能，或者修改已有的一些功能，都会带来很大的工作量。毕竟在做这些新的改动时，你需要考虑它们会对已有的功能产生什么影响。

而要改善或者解决这个问题，关键就在于：每增加一个新的功能，整个应用程序的复杂度不应该明显上升。这样才能保证我们的应用程序始终可扩展，可维护。

那么怎么在 React 应用中做到这一点呢？这正是我们这节课要讨论的问题。

软件复杂度的根源：复杂的依赖关系

我们经常会说，某个项目看上去好复杂。那么这个“复杂”，到底该怎么定义呢？

如果仔细思考就会发现，当某个功能需要层层嵌套的模块依赖，那么即使开发时觉得思路很顺，但是自己再回头去看，或者要让别人理解某个功能实现，就不得不去翻阅很深的调用链。这就是让你觉得复杂的直接原因。

那么我们可以这么说，软件复杂度的根源完全来自复杂的依赖关系。

找到了问题的根源，接下来就是解决问题了。在功能不断增加的时候，我们该怎么避免线性地产生复杂度呢？

在寻找答案之前，我们不妨先想象两个场景。

第一个场景，我们需要开发一个前端的问卷调查页面。页面有三个选项，一个提交按钮，这些就是所有的 UI 元素了。

很显然，这是一个极其简单的场景。可能在你的团队中，交给一个新手，甚至让实习生去练手，无论他们采用什么技术框架，你都会十分放心。因为复杂度完全可控，那么即使代码写得再初级也没有关系，只要功能满足要求就可以了。

第二个场景，我们需要开发一个有 50 个页面的应用，功能很多。但是呢，每个页面都是独立的，所有功能都是在各自的页面内实现，彼此之间没有任何共享的模块。

可以看到，在这个场景中，即使增加了新的页面，但事实上，也没有增加太多复杂度。而且，在修改已有功能的时候，我们也不用担心会破坏其他页面。

现在，我们不妨把这两个场景放在一起进行对比。你会发现，场景二实际上就是由多个场景一堆砌而成的。每一个功能都足够独立，这样每个功能就很容易实现，而且也容易维护。照此来看，要隔离复杂度，那我们要做的就是把一个复杂的系统模块化，并把每个模块之间的依赖降到最低。这样，每当增加新的功能时，也就不会增加整个系统的复杂度了。

事实上，在我近 5 年的工作中，很大一部分时间都是在思考和解决这个问题：如何降低依赖，让整个大型应用的复杂度始终在可控范围内？

只有这样，在团队内，无论是代码写得比较初级的新手，还是总想尝试新技术新方式的探索者，再或者是代码写得很漂亮的老手。他们都能在各自的功能模块内完成业务功能，而且尽可能少地互相影响，从而始终保证整个架构的可扩展。

你可能会说，这个目标听上去有点理想化啊，毕竟模块之间不可能没有互相依赖。我完全理解这样的想法，但是我更想强调的是：只有先制定一个清晰且正确的目标，那么我们之后在实际项目开发中才能尽量动用各种手段去靠近这个目标。

那么既然要隔离复杂度，首先要做的就是在物理层面，让源代码能够按功能模块组织在一起，也就是要正确地按领域去组织你的项目文件夹结构。

按领域组织文件夹结构

如果你已经用 create-react-app 创建过一个项目，你会发现，默认的文件夹组织类似如下结构：

import CommentList from './CommentList';
function ArticleView() {
  return (
    <div className="article-view">
      <MainContent />
      <CommentList />
    </div>
  );
}

也就是说，源代码是从技术角度进行划分的。这对于一个小的功能，比如只有一个页面的问卷调查，没有什么问题。但是对于一个大型的前端应用来说，这种做法就会导致整个应用的不可扩展。

我看到很多同学的做法是，在 components、actions、Hooks 等文件夹下，再按照功能进行分类。而这个分类的做法呢，经常是按照技术功能进行进一步划分，比如 table、modals、pages 等。这种做法其实会增加项目结构的复杂度，开发起来也很不方便，主要体现在两个方面。

一方面，对于一个功能，我们无法直观地知道它相关的代码散落在哪些文件夹中。

比如内容管理系统中的分类功能，可能有列表、下拉框、对话框、异步请求逻辑等，它们都在不同的文件夹中。

另一方面，开发一个功能时，切换源代码会非常不方便。

比如你在写分类列表功能时，就需要在组件、样式文件、action、reducer 等文件之间频繁地来回切换。而且，如果项目很大，那么你就需要展开很长的树结构，才能找到相应的文件，或者借助文件搜索去导航。不过，文件搜索导航的前提是，你还需要对整个功能的逻辑非常了解，知道有哪些文件。

产生这种开发难度的本质就在于，源代码没有按照业务功能组织在一起，而是从技术角度进行了拆分。所以呢，对于文件夹的组织，我们一定要按领域去组织源代码。一个与领域相关的文件夹，就类似于刚才讲的第一个场景，自身包含了自己需要的所有技术模块，这样无论是理解代码实现，还是开发时切换导航，都会非常方便。

下面这张图描述了这样的项目结构，展示了对于一个博客应用，我们该如何组织项目代码：

可以看到，整个应用至少包含了文章管理、评论、分类、用户等模块。首先我们知道，一个 React 应用，一定是由一些技术部件组成的，比如 components、routing、actions、store 等，图中我用不同的颜色对这些部件进行了区分。

但是呢，如果我们将这些技术部件分散到不同的领域文件夹中，而每个领域文件夹都有自己的 compoents、routing、actions、store 等。这样的话，每一个文件夹就相当于一个小型的项目，包含了与自己相关的所有源代码，就便于理解和开发。

如下所示，演示了对于这样一个博客应用，文件夹的结构应该是什么样的：

可以看到，我们把业务模块的文件夹都放在了一个名为 features 的文件夹下，这样就可以和其它一些全局配置的代码区分开来。

一旦我们按照领域组织了项目的功能文件夹，那么，在每一个独立的功能下面，无论怎么组织源代码，都不会有太大的问题，因为都是很小的文件夹。比如在这张图中，我们把所有的组件和样式代码都放在了 articles 这样的文件夹下，而把 actions、hooks 等放到了独立的文件夹下。

同时呢，我们也要尽量扁平化地组织所有代码，而不是再去按小的功能去增加嵌套的文件夹。否则，如果你再回头去看代码，或者新加入的成员去看，会增加理解成本。

处理模块间的依赖

当我们通过文件夹对业务模块进行了隔离之后，接下来就要考虑该怎样在模块之间进行交互了。

这里需要注意的是，尽管各个模块的代码已经处在独立的文件夹之中了，但其实还是可以互相引用的，也就是可以任意依赖。

直观上来说，依赖是在代码中 import 了另外一个模块，但是如果对应到业务，那么它的本质是什么呢？仔细思考下，我们其实可以把依赖从技术层面提升到业务层面，也就是一个业务功能对另外一个业务功能的依赖。

从业务功能去理解，依赖可以分为两种。

第一种是硬依赖。如果功能 A 的实现必须基于功能 B，也就是说没有功能 B，功能 A 就是不可运行的，那么我们可以说 A 硬依赖于 B。

比如对于博客系统，评论功能肯定是基于文章功能的，因为评论的对象就是文章，脱离了文章，评论功能就没有意义。

第二种是软依赖。如果功能 B 扩展了功能 A，也就是说，没有功能 B，功能 A 自身也是可以独立工作的，只是缺少了某些能力。

同样对于博客应用，文章管理是主要的功能，而评论功能则可以认为是增强了文章的功能。照此来看，即使没有评论功能，文章功能也是可以独立运行的。这样就可以认为文章功能软依赖于评论功能。

在从业务功能上区分了两种依赖之后，我们还要落地到技术层面，也就是从技术角度对两者进行区分：我们应该让文章功能相关的代码，不要硬依赖于评论功能的代码。

什么是技术层面的硬依赖呢？比如说，在增加了评论功能之后，我们的文章页面可能就会使用如下的代码：

import CommentList from './CommentList';
function ArticleView() {
  return (
    <div className="article-view">
      <MainContent />
      <CommentList />
    </div>
  );
}

可以看到，文章页面依赖了评论列表这个组件。如果评论列表组件不存在，那么文章页面也会无法显示。也就是说，当我们为应用增加了评论功能之后，就增加了技术组件的硬依赖，这样的话，系统的复杂度必然也会显著增加。

想象一下，按照这种做法，如果还要增加点赞功能、相册功能等，势必会导致复杂度不断上升，因为这些功能都需要在 ArticleView 这个组件内展示。

此外，如果你想去重构，或者移除某个功能，从技术角度来看，也是极为复杂的。比如说要移除评论功能，你就需要移除分散在各个领域的文件夹下的相关代码，就像这里的 ArticleView。

而我们要达到的目标其实是：删除一个功能，就像删除一个文件夹那么简单。这才是真正松耦合的系统。

所以呢，虽然在业务功能上是一个软依赖，但是在代码实现层面，却往往做成了硬依赖。这就导致随着功能的不断增加，整个应用变得越来越复杂，最终降低了整体的开发效率。

既然如此，我们就必须想办法，让模块之间的交互不再通过硬依赖。

在这里，我跟你介绍一种架构，这是我参与的项目基本上都会采用的，那就是扩展点机制：在任何可能产生单点复杂度的模块中，通过扩展点的方式，允许其它模块为其增加功能。

还拿博客文章这个例子来说，ArticleView 这个组件就是会产生单点复杂度的组件，因为它允许其它业务模块在文章页面上根据上下文数据去渲染额外的组件，那么我们可以使用扩展点机制来隔离这种复杂度。示意的实现代码如下：

function ArticleView({ id }) {
  const { article } = useArticle(id);
  return (
    <div className="article-view">
      <MainContent article={article} />
      {/* 定义了一个名为 article.footer 的扩展点 */}
      <Extension name="article.footer" args={article} />
    </div>
  );
}

在代码中，我们定义了一个名为 article.footer 的扩展点，允许其它模块为其贡献额外的 React 组件，使其在页面的底部渲染。接着，这个扩展点会将当前的 article 作为参数传递给额外的组件。这样，我们就可以在评论功能自己的文件夹内去扩展这个扩展点，从而具体渲染评论列表的组件了。

同样的，如果要增加点赞、打分等功能，我们也可以利用这个扩展点去实现 UI 的渲染。使用这个扩展点的评论列表模块，可以用类似的代码来实现：

extensionEngine.register('article.footer', article => {
  return <CommentList article={article} />
});

可以看到，我们往扩展点引擎中注册了一个对 article.footer 这个扩展点的实现代码，从而可以在 渲染时被调用，最终渲染出评论列表。

同时呢，因为我们把这块逻辑可以隔离出来了，那就可以放在评论功能自己的文件夹下。如果文件夹被删除，那么文章页面也不会受影响，因为如果这个扩展点没有被任何逻辑扩展，那就不会渲染任何内容。

不过这里还有一个问题，我们该怎么去实现这样一个扩展点引擎呢？可能直觉上挺复杂，但其实并不困难，我们可以利用类似事件的订阅和发布模型去建立这样一个机制。

我自己呢，也将这个功能发布成了一个开源的 npm 模块，名为 js-plugin，一共就 100 多行的代码，你可以直接使用；或者作为参考，自己动手实现这样一个引擎。项目的 github 地址是：https://github.com/rekit/js-plugin。

小结

在这节课，我们分析了软件复杂度产生的根源，来自复杂的依赖关系。随着功能的增加，系统复杂度也在不断增加，那么整个项目就会到达一个不可维护的状态。

所以我们首先需要从项目结构层面，去对复杂度做物理上的隔离，确保业务模块相关的代码都能在独立的文件夹中。

其次，我们要妥善地处理业务模块之间的依赖关系。不仅需要在业务上区分硬依赖和软依赖。同时呢，在技术的实现层面也要能做到模块的松耦合。

当然，上面的所有介绍要落实到实际的项目，还有很多细节问题需要考虑，比如如何避免在单点模块定义所有的路由，如何避免一个导航菜单组件包含了所有业务功能的导航逻辑，等等。

总结来说，这节课我们主要介绍的是整个隔离复杂度的思路，你可以根据实际场景进行有针对性的思考，进而解决复杂度的问题。

同时更为重要的是，在进行实际项目开发，尤其是大型项目的架构设计时，一定要时刻有管理系统复杂度的意识，不能只考虑功能是否实现，而不管复杂度，那样终究会导致系统越来越复杂，不断降低开发和维护的效率，甚至导致项目失败。

思考题

如果项目使用了 Redux，那么一个应用一般全局只有唯一的 Store，然后通过 dispatch action 和 reducer 去管理这个 Store。那么采用了按领域组织的文件夹结构后，怎样才能让业务功能相关的 Redux 代码也实现在各自的文件夹下呢？

欢迎把你的想法和思考分享在留言区，我会和你交流。同时，我也会把其中一些不错的回答在留言区置顶，供大家学习讨论。

13｜Form：Hooks 给 Form 处理带来了哪些新变化？

你好，我是王沛。今天我们来聊聊如何在 React 中使用表单。

表单作为用户交互最为常见的形式，但在 React 中实现起来却并没有那么容易。甚至可以说，使用表单，是 React 开发中最为困难的一部分。

主要有两方面的原因。一方面，React 都是状态驱动，而表单却是事件驱动，比如点击按钮、输入字符等，都是一个个离散的事件。因此，一般来说我们都需要将这些独立的事件转换成一定的应用程序状态，最终来完成表单的交互。

另一方面，表单元素一般都有自己的内在状态，比如原生的 input 节点就允许用户输入，这就需要我们在元素状态和表单状态之间做同步。

要能够很好地处理这些问题，我们首先需要对表单的机制有深入的理解，然后再从 React Hooks 的角度去思考问题的解决方案。

所以在今天这节课，我会从这三个方面来讲。

首先，介绍 React 中使用表单的基本原理，帮助你理解受控组件和非受控组件的概念，以及各自的适用场景。

然后看看 Hooks 出现后，给表单处理带来了哪些思路上的新变化。

最后，我们会学习几个常见的开箱即用的 React 表单解决方案，让你在理解实现原理的基础上，可以选择最适合自己的开源方案。

在表单中使用 React 组件：受控组件和非受控组件

在第 8 讲，我简单介绍了受控组件和非受控组件的概念。虽然在一般情况下，表单中的元素都是受控组件。也就是说，一个表单组件的状态完全由 React 管控。但是在有的时候，为了避免太多的重复渲染，我们也会选择非受控组件。

所以今天这节课，我们就来看下这两种形式在 React 中分别该怎么实现，并了解它们的优缺点以及适用场景。

首先我们来看下受控组件应该如何使用。下面的例子展示了受控组件的用法：

function MyForm() {
  const [value, setValue] = useState('');
  const handleChange = useCallback(evt => {
    setValue(evt.target.value);
  }, []);
  return <input value={value} onChange={handleChange} />;
}

可以看到，输入框的值是由传入的 value 属性决定的。在 onChange 的事件处理函数中，我们设置了 value 这个状态的值，这样输入框就显示了用户的输入。

需要注意的是，React 统一了表单组件的 onChange 事件，这样的话，用户不管输入什么字符，都会触发 onChange 事件。而标准的 input 的 onchange 事件，则只有当输入框失去焦点时才会触发。React 的这种 onChange 的机制，其实让我们对表单组件有了更灵活的控制。

不过，受控组件的这种方式虽然统一了表单元素的处理，有时候却会产生性能问题。因为用户每输入一个字符，React 的状态都会发生变化，那么整个组件就会重新渲染。所以如果表单比较复杂，那么每次都重新渲染，就可能会引起输入的卡顿。在这个时候，我们就可以将一些表单元素使用非受控组件去实现，从而避免性能问题。

所谓非受控组件，就是表单元素的值不是由父组件决定的，而是完全内部的状态。联系第 8 讲提到的唯一数据源的原则，一般我们就不会再用额外的 state 去保存某个组件的值。而是在需要使用的时候，直接从这个组件获取值。

下面这段代码演示了一个非受控组件应该如何使用：

import { useRef } from "react";

export default function MyForm() {
  // 定义一个 ref 用于保存 input 节点的引用
  const inputRef = useRef();
  const handleSubmit = (evt) => {
    evt.preventDefault();
    // 使用的时候直接从 input 节点获取值
    alert("Name: " + inputRef.current.value);
  };
  return (
    <form onSubmit={handleSubmit}>
      <label>
        Name:
        <input type="text" ref={inputRef} />
      </label>
      <input type="submit" value="Submit" />
    </form>
  );
}

可以看到，通过非受控组件的方式，input 的输入过程对整个组件状态没有任何影响，自然也就不会导致组件的重新渲染。

不过缺点也是明显的，输入过程因为没有对应的状态变化，因此要动态地根据用户输入做 UI 上的调整就无法做到了。出现这种情况，主要也是因为所有的用户输入都是 input 这个组件的内部状态，没有任何对外的交互。

总结来说，在实际的项目中，我们一般都是用的受控组件，这也是 React 官方推荐的使用方式。不过对于一些个别的场景，比如对性能有极致的要求，那么非受控组件也是一种不错的选择。

使用 Hooks 简化表单处理

回顾我们对受控组件的处理，会发现对于每一个表单元素，其实都会遵循下面两个步骤：

设置一个 State 用于绑定到表单元素的 value；

监听表单元素的 onChange 事件，将表单值同步到 value 这个 state。

也就是说，我们可以用类似如下的代码来实现对受控组件的处理：

function MyForm() {
  const [value1, setValue1] = useState();
  const [value2, setValue2] = useState();
  // 更多表单元素状态...

  return (
    <form>
      <Field1 value={value1} onChange={setValue1} />
      <Field1 value={value2} onChange={setValue2} />
      {/*更多表单元素*/}
    </form>
  )
}

可以看到，一个表单的整体状态正是有一个个独立表单元素共同组成的。那么下图就展示了这样一个关系，对于每一个表单元素都需要手动的进行 value 和 onChange 属性的绑定：

对于复杂的表单，这样的逻辑显然是比较繁琐的。但正如上图所示，维护表单组件的状态逻辑，核心在于三个部分：

字段的名字；

绑定 value 值；

处理 onChange 事件。

既然对每个表单元素的处理逻辑都是一致的，那我们是不是可以用 Hooks 实现逻辑的重用呢？

答案是肯定的，主要的思想就是在这个 Hook 去维护整个表单的状态，并提供根据名字去取值和设值的方法，从而方便表单在组件中的使用。下面的代码演示了一个基本的实现：

import { useState, useCallback } from "react";

const useForm = (initialValues = {}) => {
  // 设置整个 form 的状态：values
  const [values, setValues] = useState(initialValues);

  // 提供一个方法用于设置 form 上的某个字段的值
  const setFieldValue = useCallback((name, value) => {
    setValues((values) => ({
      ...values,
      [name]: value,
    }));
  }, []);

  // 返回整个 form 的值以及设置值的方法
  return { values, setFieldValue };
};

有了这样一个简单的 Form，我们就不再需要很繁琐地为每个表单元素单独设置状态了。比如下面的代码就演示了该如何使用这样一个 Hook：

import { useCallback } from "react";
import useForm from './useForm';

export default () => {
  // 使用 useForm 得到表单的状态管理逻辑
  const { values, setFieldValue } = useForm();
  // 处理表单的提交事件
  const handleSubmit = useCallback(
    (evt) => {
      // 使用 preventDefault() 防止页面被刷新
      evt.preventDefault();
      console.log(values);
    },
    [values],
  );
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Name: </label>
        <input
          value={values.name || null}
          onChange={(evt) => setFieldValue("name", evt.target.value)}
        />
      </div>

      <div>
        <label>Email:</label>
        <input
          value={values.email || null}
          onChange={(evt) => setFieldValue("email", evt.target.value)}
        />
      </div>
      <button type="submit">Submit</button>
    </form>
  );
};

那么，通过将表单状态管理的逻辑提取出来，使之成为一个通用的 Hook，这样我们就简化了在 React 中使用表单的逻辑。

虽然这看上去只是一个很简单的实现，但是基本上一些开源的表单方案都是基于这么一个核心的原理：把表单的状态管理单独提取出来，成为一个可重用的 Hook。这样在表单的实现组件中，我们就只需要更多地去关心 UI 的渲染，而无需关心状态是如何存储和管理的，从而方便表单组件的开发。

处理表单验证

当我们基于 Hooks 实现了一个基本的表单状态管理机制之后，现在，我们就要在这个机制的基础之上，再增加一个表单处理必备的业务逻辑：表单验证。

在考虑这个验证逻辑如何实现的时候，我们同样也是遵循状态驱动这个原则：

首先：如何定义这样的错误状态；

其次：如何去设置这个错误状态。

下面的代码演示了我们如何给已有的 useForm 这个 Hook 增加验证的 API 接口：

// 除了初始值之外，还提供了一个 validators 对象，
// 用于提供针对某个字段的验证函数
const useForm = (initialValues = {}, validators) => {
  const [values, setValues] = useState(initialValues);
  // 定义了 errors 状态
  const [errors, setErrors] = useState({});

  const setFieldValue = useCallback(
    (name, value) => {
      setValues((values) => ({
        ...values,
        [name]: value,
      }));

      // 如果存在验证函数，则调用验证用户输入
      if (validators[name]) {
        const errMsg = validators[name](value);
        setErrors((errors) => ({
          ...errors,
          // 如果返回错误信息，则将其设置到 errors 状态，否则清空错误状态
          [name]: errMsg || null,
        }));
      }
    },
    [validators],
  );
  // 将 errors 状态也返回给调用者
  return { values, errors, setFieldValue };
};

那么我们就可以在使用的时候传递下面的 validators 对象给 useForm 这个 Hook：

function MyForm() {
  // 用 useMemo 缓存 validators 对象
  const validators = useMemo(() => {
    return {
      name: (value) => {
        // 要求 name 的长度不得小于 2
        if (value.length < 2) return "Name length should be no less than 2.";
        return null;
      },
      email: (value) => {
        // 简单的实现一个 email 验证逻辑：必须包含 @ 符号。
        if (!value.includes("@")) return "Invalid email address";
        return null;
      },
    };
  }, []);
  // 从 useForm 的返回值获取 errors 状态
  const { values, errors, setFieldValue } = useForm({}, validators);
  // UI 渲染逻辑...
}

这样，我们就将表单验证的逻辑也封装到了通用的 useForm 这个 Hook 中了。

虽然这个 API 只支持通过函数执行进行验证，但是，我们很容易扩展支持更多的类型，比如正则匹配、值范围等等。

总结来说，通过更丰富和更便捷的 API，就可以让验证逻辑的使用更加方便。这个例子完整的可运行代码你也可以通过文末的链接查看，下图显示了实际的执行效果。

常用的 React Form 框架

刚才我们介绍了在 React 中实现表单功能的基本原理，并自己动手实现了一个可重用的 Form 的状态管理机制。虽然看上去有点简单，但其实是完全可以在项目使用的。

不过呢，所有项目的表单处理总体来说都是类似的，自己动手实现，虽然可以更精确地满足项目需要，但也意味着有了额外的工作量。事实上，我们完全可以利用一些开源来实现，比如 antd, formik, react hook form 等等。

虽然各个框架 API 风格上有所差异，但是背后的原理都是类似的：把表单的状态逻辑和 UI 展示逻辑基于 Hooks 进行分离。

接下来，我就简单介绍三个比较主流的表单框架，让你对它们有一个总体的理解，知道他们的特性和优缺点，从而在做技术选型时可以有所参考。这些框架之间也没有绝对的优劣，你可以按照自己的喜好进行选择。

Antd Form

在国内，蚂蚁金服的 Ant.Design 可以说是最主流的 React UI 库。作为面向重交互的企业级软件设计的 UI 库，其表单机制自然也非常强大。所以，如果你用 antd 作为 UI 库，那么几乎一定是使用其自带的表单管理功能了。

和其它表单框架的地方不同的地方就在于，antd 不仅实现了表单的状态逻辑管理，还提供了 UI 层的支持，比如表单如何布局，错误信息如何展示，等等。

当然，这也意味着 Antd Form 只能和 Antd 的 UI 库一起使用。Antd Form 最早是基于高阶组件实现状态逻辑的重用，在 Hooks 出现之后，也基于 Hooks 重构了 Form 的实现。项目的地址是：https://ant.design/components/form/。

Formik

Formik 应该是最早将 React 中的 form 数据逻辑单独提取出来的表单框架，因此它也积累了大量的用户。而且，Formik 出现的时候还没有 Hooks，所以它其实利用的是 render props 设计模式实现了状态逻辑的重用。

典型的 Formik 代码如下：

import { Formik, Form } from 'formik';

// ...

<Formik
  initialValues={{ email: '', password: '' }}
  validate={values => { }}
  onSubmit={(values, { setSubmitting }) => {}}
>
{({
  values,
  errors,
  touched,
  handleChange,
  handleBlur,
  handleSubmit,
  isSubmitting,
}) => (
  <Form></Form>
)}
</Formik>

可以看到，Formik 将所有的表单状态都，通过 render props 的回调函数传递给了表单的 UI 展现层。这样，你就可以根据这些状态进行 UI 的渲染。当然，在 Hooks 出现之后，Formik 也提供了 Hook 的 API 去实现表单逻辑。

另外一点就是，Formik 只提供了表单状态逻辑的重用，并没有限制使用何种 UI 库，这就意味着 Formik 在提供灵活性的同时，也意味着你要自己管理如何进行 UI 布局以及错误信息的展示。Formik 的项目地址是：https://formik.org/。

React Hook Form

React Hook Form ，顾名思义，就是在 Hooks 出现之后，完全基于 Hooks 实现的表单状态管理框架。

区别于 antd 和 formik 的一个最大特点是，React Hook Form 是通过非受控组件的方式进行表单元素的管理。正如上文提到，这可以避免很多的表单重新渲染，从而对于复杂的表单组件可以避免性能问题。

此外，和 formik 一样，React Hook Form 也没有绑定到任何 UI 库，所以你同样需要自己处理布局和错误信息的展示。它的项目地址是：https://react-hook-form.com/。

小结

在这节课，我们学习了在 React 中使用 Form 的基本流程。Form 最为核心的机制就是我们将表单元素的所有状态提取出来，这样表单就可以分为状态逻辑和 UI 展现逻辑，从而实现数据层和表现层的分离。

在一些传统的表单解决方案之中，这个状态一般会用 Context 或者 Redux 去管理，而现在有了 Hooks，那我们基于 Hooks 就可以很容易实现一个简单的表单逻辑管理模块。

而对于一些流行的表单解决方案，其实也就是在 Hooks 的基础上，加入了更多的便捷 API，比如更丰富的验证逻辑，来简化 React 中表单的创建。在理解了背后的原理之后，相信你能利用已有的方案提高开发的效率。

文章中所有的实例代码你也可以通过下面地址进行查看：https://codesandbox.io/s/react-hooks-course-20vz 。

思考题

今天有两个思考题：

原生表单有一个重要的机制：重置（reset）。在文中的自定义 useForm Hook 中，如果要提供 reset 的 API，你会如何实现呢？

同样的，在文中自定义 useForm 的 Hook 中，表单的验证逻辑是通过一个同步调用的函数实现的，如果要用支持异步验证，比如通过服务器端 API 判断 name 是否已存在，应该如何实现呢？

欢迎把你的思考和想法分享在留言区，我会和你交流。同时，我也会把其中一些不错的回答置顶，供大家学习。

14 | 使用浮动层：如何展示对话框，并给对话框传递参数？

你好，我是王沛。今天我们来聊聊如何在 React 中处理对话框。

对话框是前端应用中非常常用的一种界面模式，它们通常是应用中的一个独立窗口，用于展示信息或者输入信息。

但是在 React 中，使用对话框其实并不容易，主要原因在于两点：

一方面，对话框需要先在父组件中声明，才能在子组件中控制其是否显示。

比如说我们需要同时在布局的 header 和 sider 上用菜单去控制某个对话框是否显示，那么这个对话框就必须定义在根组件上。

另一方面，给对话框传递参数只能由 props 传入，这意味着所有的状态管理都需要在更高级别的组件上。而实际上呢，这个对话框的参数可能只在子组件中才会维护，这时我们就需要利用自定义事件将参数回传，非常麻烦。

案例导入：处理对话框的误区

为了方便你理解这两点，我给你举一个实际场景的例子，你就能明白为什么说在 React 中，常用的对话框是比较难处理的。比如说我们需要实现下面这个截图演示的功能：

在这个例子中，我们有一个左右布局的页面。左边栏有一个新建用户的按钮，右边是一个用户列表。点击新建用户的按钮，或者点击表格中的编辑按钮，都会显示同一个对话框。这个对话框根据是否传入用户数据作为参数，来决定是新建还是编辑用户。

这个页面的代码一般会用下面的 JSX 去实现：

<div className="main-layout">
  <Sider />
  <UserList />
</div>

可以看到，这里的主布局包含了 Sider 和 UserList 两个同层级的组件。但是它们要使用同一个对话框以显示编辑用户。而我们都知道，在 React 中，所有的 UI 都是状态驱动，这意味着我们必须将对话框相关的状态，以及状态管理逻辑提升到父组件中去实现，也就是这里的 Layout 组件。

那么，一般会用类似下面的代码逻辑去实现：

function MainLayout() {
  const [modalVisible, setModalVisible] = useState(false);
  const [user, setUser] = useState(null);
  const showUserModal = (user) => {
    setModalVisible(true);
    setUser(user);
  }
  return (
    <div className="main-layout">
      <Sider onNewUser={showUserModal}/>
      <UserList onEditUser={user => showUserModal(user)}/>
      <UserInfoModal visible={modalVisible} user={user} />
    </div>
  );
}

在这段代码中，我们将 UserInfoModal 这个对话框组件定义在了父组件 Layout 中，通过 visible 控制其是否显示。然后再在 Sider 和 UserList 这两个组件中，用自定义事件来告知父组件，用户点击了某个按钮了，应该显示对话框。

这样的用法固然是可以正确工作的，也是我看到的大多数同学的常规写法。但这种写法其实隐含着如下两个问题。

第一，语义隔离不明确。MainLayout 这个组件应该只做布局的事情，而不应该有其他的业务逻辑。但是在这里，由于我们加入了用户信息处理的逻辑，就让本不相关的两块功能产生了依赖。

而且，如果要增加另外一个对话框，那意味着又要在 Layout 上增加新的业务逻辑了。这样的话，代码很快就会变得臃肿，且难以理解和维护。

第二，难以扩展。现在我们只是在 MainLayout 下面的两个组件共享了对话框，但是如果和 MainLayout 同级的组件也要访问这个对话框呢？又或者， MainLayout 下面的某个深层级的孙子组件也要能显示同一个对话框呢？

这样处理的话就会非常麻烦。前者意味着代码需要重构，继续提升状态到父组件；后者意味着业务逻辑处理更复杂，需要通过层层的自定义事件回调来完成。

所以，按照 React 方式的做法，或者大多数教程上演示的对话框的用法，其实在实际项目中是会遇到上面所述的各种问题。而这些问题的本质就是，一个实现业务逻辑的 Modal 究竟应该在哪个组件中去声明？又该怎么和它进行交互呢？

接下来，我会和你分享在一个比较大型的项目中，如何用一个统一的方式去管理对话框，从而让对话框相关的业务逻辑能够更加模块化，以及和其他业务逻辑进行解耦。

不过也特别说明一下，这种方式更多的是我个人经验的总结，并不一定是唯一的，或者说最佳的方式。所以如果你有任何疑问，欢迎在留言区和我交流讨论。

思路：使用全局状态管理所有对话框

要解决上面例子中演示的问题，我们可以先仔细思考下对话框这种 UI 模式的本质。

对话框在本质上，其实是独立于其他界面的一个窗口，用于完成一个独立的功能。

如果从视觉角度出发，你会发现在使用对话框的时候，你完全不会关心它是从哪个具体的组件中弹出来的，而只会关心对框本身的内容。

比如说，一个设置用户选项的对话框，它可能是从顶部菜单中点出来的，也可能是在某个具体页面的按钮点出来的，但都自动显示了上下文相关的设置选项。

对话框的这样一个本质，就决定了在组件层级上，它其实是应该独立于各个组件之外的。虽然很可能在一开始这个对话框的实现和某个组件非常高的相关度，但是在整个应用的不断开发和演进过程中，是很可能不断变化的。

所以，在定义一个对话框的时候，其定位基本会等价于定义一个具有唯一 URL 路径的页面。只是前者由弹出层实现，后者是页面的切换。

对于页面级别的 UI 切换，我们很容易理解，就是定义全局的路由嘛。那么同样的，如果我们以同样的方式去思考对话框，其实就是将对话框全局化，然后通过一个全局的机制来管理这些对话框。

这个过程和页面 URL 的切换非常类似，那么我们就可以给每一个对话框定义一个全局唯一的 ID，然后通过这个 ID 去显示或者隐藏一个对话框，并且给它传递参数。

基于这样一个设想，我们就来尝试去设计一个 API 去做对话框的全局管理。假设我们将这个对话框的实现命名为 NiceModal，那么我们的目标就是能够用以下的方式去操作对话框：

// 通过 create API 创建一个对话框，主要为了能够全局的控制对话框的展现
const UserInfoModal = NiceModal.create(
  'user-info-modal',
  RealUserInfoModal
);

// 创建一个 useNiceModal 这样的 Hook，用于获取某个 id 的对话框的操作对象
const modal = useNiceModal('user-info-modal');
// 通过 modal.show 显示一个对话框，并能够给它传递参数
modal.show(args);
// 通过 modal.hide 关闭对话框
modal.hide();

可以看到，如果有这样的 API，那么无论在哪个层级的组件，只要知道某个 Modal 的 ID，那就都可以统一使用这些对话框，而不再需要考虑该在哪个层级的组件去定义了，使用起来会更加直观。

所以，通过上面的思考和验证，我们可以认为对话框这种模式的本质就是一个独立的窗口，它和一个拥有独立 URL 的页面在功能上和形式上都是极为类似的。这就意味着我们可以用和 URL 一样的方法去实现通用的对话框管理。

实现：创建 NiceModal 组件和相关 API

下面，我们就来看看如何去实现这样的一个 NiceModal 机制。为了让你比较好地理解实现的逻辑，我尽量通过代码注释的方式来解释实现思路和原理，所以你要仔细阅读代码，确保理解了实现的细节。

首先要考虑的便是如何管理全局状态，在这里我们以 Redux 为例，来创建一个可以处理所有对话框状态的 reducer：

const modalReducer = (state = { hiding: {} }, action) => {
  switch (action.type) {
    case "nice-modal/show":
      const { modalId, args } = action.payload;
      return {
        ...state,
        // 如果存在 modalId 对应的状态，就显示这个对话框
        [modalId]: args || true,
        // 定义一个 hiding 状态用于处理对话框关闭动画
        hiding: {
          ...state.hiding,
          [modalId]: false,
        },
      };
    case "nice-modal/hide":
     const { modalId, force } = action.payload;
      // 只有 force 时才真正移除对话框
      return action.payload.force
        ? {
            ...state,
            [modalId]: false,
            hiding: { [modalId]: false },
          }
        : { ...state, hiding: { [.modalId]: true } };
    default:
      return state;
  }
};

这段代码的主要思路就是通过 Redux 的 store 去存储每个对话框状态和参数。在这里，我们设计了两个 action ，分别用来显示和隐藏对话框。

特别要注意的是，这里我们加入了 hiding 这样一个状态，用来处理对话框关闭过程的动画，确保用户体验。

为了让 Redux 的 action 使用起来更方便，我们可以定义一个 useNiceModal 这样的 Hook，在其内部封装对 Store 的操作，从而实现对话框状态管理的逻辑重用，并以更友好的方式暴露给用户：

// 使用 action creator 来创建显示和隐藏对话框的 action
function showModal(modalId, args) {
  return {
    type: "nice-modal/show",
    payload: {
      modalId,
      args,
    },
  };
}

function hideModal(modalId, force) {
  return {
    type: "nice-modal/hide",
    payload: {
      modalId,
      force,
    },
  };
}

// 创建自定义 Hook 用于处理对话框逻辑
export const useNiceModal = (modalId) => {
  const dispatch = useDispatch();
  // 封装 Redux action 用于显示对话框
  const show = useCallback((args) => {
    dispatch(showModal(modalId, args));
  }, [
    dispatch,
    modalId,
  ]);
  // 封装 Redux action 用于隐藏对话框
  const hide = useCallback((force) => {
    dispatch(hideModal(modalId, force));
  }, [
    dispatch,
    modalId,
  ]);

  const args = useSelector((s) => s[modalId]);
  const hiding = useSelector((s) => s.hiding[modalId]);

  // 只要有参数就认为对话框应该显示，如果没有传递 args，在reducer 中会使用
  // 默认值 true
  return { args, hiding, visible: !!args, show, hide };
};

同时，我们可以实现 NiceModal 这样一个组件，去封装通用的对话框操作逻辑。比如关闭按钮，确定按钮的事件处理，等等。为了方便演示，我们以 Ant Design 中的 Modal 组件为例：

function NiceModal({ id, children, ...rest }) {
  const modal = useNiceModal(id);
  return (
    <Modal
      onCancel={() => modal.hide()} // 默认点击 cancel 时关闭对话框
      onOk={() => modal.hide()} // 默认点击确定关闭对话框
      afterClose={() => modal.hide(true)} // 动画完成后真正关闭
      visible={!modal.hiding}
      {...rest} // 允许在使用 NiceModal 时透传参数给实际的 Modal
    >
      {children}
    </Modal>
  );
}

最后呢，我们用一个第 10 讲提到的容器模式，它会在对话框不可见时直接返回 null，从而不渲染任何内容；并且确保即使页面上定义了 100 个对话框，也不会影响性能：

export const createNiceModal = (modalId, Comp) => {
  return (props) => {
    const { visible, args } = useNiceModal(modalId);
    if (!visible) return null;
    return <Comp {...args} {...props} />;
  };
};

这样，我们就实现了一个 NiceModal 这样的全局对话框管理框架。基于这样一个框架，使用对话框的时候就会非常方便。比如下面的代码：

import { Button } from "antd";
import NiceModal, {
  createNiceModal,
  useNiceModal,
} from "./NiceModal";

const MyModal = createNiceModal("my-modal", () => {
  return (
    <NiceModal id="my-modal" title="Nice Modal">
      Hello NiceModal!
    </NiceModal>
  );
});

function MyModalExample() {
  const modal = useNiceModal("my-modal");
  return (
    <>
      <Button type="primary" onClick={() => modal.show()}>
        Show Modal
      </Button>
      <MyModal />
    </>
  );
}

在这个例子中，我们首先定义了一个简单的 MyModal 组件，这样我们就可以把多画框逻辑写在单独的组件中，而不是嵌入到父组件。在这个 MyModal 组件内部使用了 NiceModal 作为基础，从而可以绑定对话框 ID，并重用通用的对话框逻辑。

通过这个 Modal ID，我们就能够在应用的任何组件中去管理这个对话框了。

可以看到，在这部分我们基本完整实现了一个 NiceModal 的机制，它可以帮助你很好地去全局管理对话框。不过你再仔细点的话，会发现这里其实还缺少了一个直观的机制，那就是如何处理对话框的返回值。

处理对话框返回值

如果说对话框和页面这两种 UI 模式基本上是一致的，都是独立窗口完成独立逻辑。但是在用户交互上，却是有一定的差别，

对话框可能需要返回值给调用者；

而页面切换一般不会关心页面执行的结果是什么。

那么基于上面的 NiceModal 实现逻辑，现在的问题就是，我们应该如何让调用者获得返回值呢？

考虑到我们可以把用户在对话框中的操作看成一个异步操作逻辑，那么用户在完成了对话框中内容的操作之后，就认为异步逻辑完成了。因此我们可以利用 Promise 来完成这样的逻辑。

那么，我们要实现的 API 如下所示：

const modal = useNiceModal('my-modal');
// 实现一个 promise API 来处理返回值
modal.show(args).then(result => {});

事实上，要实现这样一个机制并不困难，就是在 useNiceModal 这个 Hook 的实现中提供一个 modal.resolve 这样的方法，能够去 resolve modal.show 返回的 Promise。

实现的代码思路如下所示：

const modal = useNiceModal('my-modal');
// 实现一个 promise API 来处理返回值
modal.show(args).then(result => {});

代码的核心思路就是将 show 和 resolve 两个函数通过 Promise 联系起来。因为两个函数的调用位置不一样，所以我们使用了一个局部的临时变量，来存放 resolve 回调函数。通过这样的机制，就可以在对话框中去调用 modal.resolve 来返回值了。

下面的代码演示了具体使用的一个例子：

// 使用一个 object 缓存 promise 的 resolve 回调函数
const modalCallbacks = {};
export const useNiceModal = (modalId) => {
  const dispatch = useDispatch();
  const show = useCallback(
    (args) => {
      return new Promise((resolve) => {
        // 显示对话框时，返回 promise 并且将 resolve 方法临时存起来
        modalCallbacks[modalId] = resolve;
        dispatch(showModal(modalId, args));
      });
    },
    [dispatch, modalId],
  );
  const resolve = useCallback(
    (args) => {
      if (modalCallbacks[modalId]) {
        // 如果存在 resolve 回调函数，那么就调用
        modalCallbacks[modalId](args);
        // 确保只能 resolve 一次
        delete modalCallbacks[modalId];
      }
    },
    [modalId],
  );

  // 其它逻辑...

  // 将 resolve 也作为返回值的一部分
  return { show, hide, resolve, visible, hiding };
};

这段示意代码包括两个部分。

首先是在 UserList 的表格组件中，由编辑按钮触发对话框的显示，并在对话框返回后，将用户输入更新到表格。

第二部分则是在对话框中，用户点击了确定按钮后调用 modal.resolve 方法，将用户输入返回给 UserList 组件，从而完成整个编辑流程。

小结

在这节课我们主要学习了在 React 中使用对话框的一种实践方式：利用全局状态来管理对话框。

其核心思路在于从 UI 模式的角度出发，认识到对话框和页面在很多时候是非常类似的，都是一个独立功能的 UI 展现。

因此，用全局的方式去管理对话框就是一种非常合理的方式。这样，我们就能解决很多在 React 开发中经常遇到的各种对话框实现难题，从而让组件的语义更加清楚，代码更容易理解和维护。

这里要着重强调一点。在实现部分，我们用到了 Redux 作为全局状态管理框架来管理对话框的状态，并利用了自定义 Hook useNiceModal 去实现状态管理逻辑的重用。

虽然看上去是实现了一个框架级别的机制，但是实际上核心代码只有 100 行左右，你在实际项目中完全可以将其复制到你的项目中，并在理解的基础上，根据自己的需求和场景去定制使用。到时候你就能感受到这个机制带给你的惊喜了。

这里也要说明一点，使用了全局方式管理对话框，并不意味着你就不能使用本地状态的对话框了。对于一些非常简单的场景，比如你很确定某个对话框一定只在某个组件内才被使用，也是可以继续使用本地声明的对话框的。我们要明白，全局方式和本地方式是完全不冲突的，是可以共存的。

文中所有的示例代码和运行效果都可以通过 codesandbox 查看：https://codesandbox.io/s/react-hooks-course-20vzg 。

思考题

在本文中，我们使用的是 Redux 来管理所有对话框的所有状态。但有时候你的项目并不一定使用了 Redux，那么我们其实也可以使用 Context 来管理对话框的全局状态。那么请你思考一下，如果基于 Context ，应该如何实现 NiceModal 呢？

欢迎把你的想法和思考分享在留言区，我会和你交流。同时，我也会把其中一些不错的回答在留言区置顶，供大家学习讨论。

15 | 路由管理：为什么每一个前端应用都需要使用路由机制？

你好，我是王沛。今天我们来聊聊如何在 React 中使用路由管理。

所谓路由管理，就是让你的页面能够根据 URL 的变化进行页面的切换，这是前端应用中一个非常重要的机制，同时也是 Web 应用区别于桌面应用的一个重要特征。

我们之所以说路由非常重要，主要体现在两个方面。

一方面，路由机制提供了按页面去组织整个应用程序的能力，页面之间的交互可以主要通过 URL 来进行，从而可以让各个业务功能互相独立，实现逻辑的解耦。

另一方面，URL 的全称是 _Uniform Resource Locator_，中文意思是“统一资源定位符”，表明 URL 是用于唯一的定位某个资源的。

所以对于能够通过前端应用展现的每一个资源，你都要考虑 URL 是否能唯一地定位到这个资源。这要求 URL 不仅能定位到某个页面，还要能定位到一些状态，比如当前的 Tab 页是什么，或者页面列表应该是第几页，等等。而路由机制，就可以让你做到这一点。

所以，对于每一个前端应用，无论大小，都应该要有自己的路由机制。而我们在开发每一个功能的时候，在设计之初就应该思考当前实现的功能和 URL 是否需要有什么联系。这样就可以让 URL 能够更准确地定位资源，进而提升用户体验。同时，由于我们将某些状态通过 URL 进行了管理，相关的业务逻辑语义也会更加明确。

那么在今天这节课，我就会跟你完整地介绍前端路由的工作原理，以及典型的使用场景，帮助你能够在自己的应用中熟练掌握路由的使用。

理解路由的工作原理：自己实现一个简单的路由机制

在 React 中的路由管理，一般都会通过路由框架 React Router 去实现。可能很多同学一听到“框架”，下意识地就觉得它的实现非常复杂。其实不然，在 React 中实现一个自己路由框架非常容易，只要利用最基础的 React 机制就能够实现。

所以我们首先就来看看，如果要自己实现这样一个机制应该如何去做，这样你就能够真正理解 React Router 这样的路由框架的工作原理了。理解了原理，你也就能在使用时更加得心应手，遇到问题也能自主解决。

在一个前端应用中，所谓的页面切换，或者导航，其实是一个页面的局部内容要使用不同的组件去展示。比如说下图，一个页面通常由 header、sider、content 等部分组成，但是在实际的页面切换的时候，改变的其实是 content 部分的内容。

所以这也是和传统的 Web 应用不一样的地方：

在服务器端渲染的页面中，每个 URL 的变化都是全部页面内容的切换。

而在前端路由管理中，则一般只在主内容区域 Content 部分变化， Header 和 Sider 是不会变化的。

那么，实现路由机制的核心逻辑就是根据 URL 路径这个状态，来决定在主内容区域显示什么组件。示意的代码如下：

const MyRouter = ({ children }) => {
  const routes = _.keyBy(
    children.map((c) => c.props),
    "path",
  );
  const [hash] = useHash();
  const Page = routes[hash.replace("#", "")]?.component;
  // 如果路由不存在就返回 Not found.
  return Page ? <Page /> : "Not found.";
};

const Route = () => null;

这样，我们就简单实现了一个 MyRouter 这样的路由容器组件。我们通过 URL 中的 hash，也就是“#”后面的部分来决定具体渲染哪个组件到主区域。

同时我们定义了一个空组件 Route，来接收路由的具体参数 path 和 component，从而以声明式的方式去定义路由。

那么在使用的时候，就可以用下面的代码实现：

function SamplePages {
  return (
    <div className="sample-pages">
      {/* 定义了侧边导航栏 */}
      <div className="sider">
        <a href="#page1">Page 1</a>
        <a href="#page2">Page 2</a>
        <a href="#page3">Page 3</a>
        <a href="#page4">Page 4</a>
      </div>
      <div className="exp-15-page-container">
        {/* 定义路由配置 */}
        <MyRouter>
          <Route path="page1" component={Page1} />
          <Route path="page2" component={Page2} />
          <Route path="page3" component={Page3} />
          <Route path="page4" component={Page4} />
        </MyRouter>
      </div>
    </>
  );
};

通过个例子，一方面，你就掌握了一个简单路由机制的实现，从而可以在一些简单的应用中直接使用，避免引入太多依赖。

另一方面，理解了路由实现的原理，也就能更从容地去使用更完善的第三方路由管理框架了。完整的代码和运行效果你也可以通过文末的链接查看。

使用 React Router

React Router 作为 React 全家桶三大成员之一，是几乎所有 React 应用的必备。虽然在刚才的例子中，我们自己实现的机制也能简单地进行路由管理。

但是在实际项目中，还有更多的情况需要考虑，比如路由嵌套、URL 的模式匹配、参数提取等。这些细节上的考虑，意味着我们要做一个完整的路由框架，还是需要很大的工作量的，所以你完全可以用 React Router 这样更成熟的开源方案去管理路由，而无须自己开发。

幸运的是，React Router 的使用非常简单，几乎不需要学习成本，你看一个使用的例子就明白了。

如果你细心的话会发现，我们课程的所有示例页面，就是用 React Router 去管理的。下面就是我们课程 App 的根组件。为了缩短这里代码的长度，就以定义了两讲的示例代码页面路由为例：

// 从 react-router-dom 引入路由框架提供的一些组件
import { BrowserRouter, Switch, Route, Link } from "react-router-dom";
// 引入了两个课程示例页面
import Counter from "./01/Counter";
import UserList from "./01/UserList";

// 使用数组定义了页面组件和导航的标题，从而方便下面的渲染逻辑
const routes = [
  ["01 Counter", Counter],
  ["01 UserList", UserList],
];
function App() {
  return (
    <BrowserRouter>
      <div className="app">
        <ul className="sider">
          {routes.map(([label]) => (
            <li>
              <Link to={`/${label.replace(" ", "/")}`}>{label}</Link>
            </li>
          ))}
        </ul>
        <div id="pageContainer" className="page-container">
          <Switch>
            {routes.map(([label, Component]) => (
              <Route key={label} path={`/${label.replace(" ", "/")}`}>
                <Component />
              </Route>
            ))}
            {/* 定义一个默认的路由 */}
            <Route path="/" exact>
              <h1>Welcome!</h1>
            </Route>
            <Route path="*">Page not found.</Route>
          </Switch>
        </div>
      </div>
    </BrowserRouter>
  );
}

这里需要注意，React Router 不仅支持浏览器，还支持 React Native，以及一些用 Web 实现的移动 App，所以它提供了多个 npm 模块。

代码里看到的 react-router-dom，其实正是支持浏览器的模块。我们在开发运行于浏览器的 Web App 时，只要引入这个包就可以了。

同时，我们也看到了 BrowserRouter、Link、Route、Switch 等组件的用法。接下来我就简单解释一下组件的作用。

BrowserRouter：标识用标准的 URL 路径去管理路由，比如 /my-page1 这样的标准 URL 路径。除此之外，还有 MemoryRouter，表示通过内存管理路由；HashRouter，标识通过 hash 管理路由。我们自己实现的例子其实就是用的 hash 来实现路由。

Link：定义一个导航链接，点击时可以无刷新地改变页面 URL，从而实现 React Router 控制的导航。

Route: 定义一条路由规则，可以指定匹配的路径、要渲染的内容等等。

Switch：在默认情况下，所有匹配的 Route 节点都会被展示，但是 Switch 标记可以保证只有第一个匹配到的路由才会被渲染。

这样，我们就使用了 React Router 提供的最基础的功能，实现了页面级别的路由管理。

但是我们要知道，React Router 的能力并不止于此，它的功能非常强大。我强烈建议你去完整阅读下它的官方文档，它的文档都是示例的形式，并不长，很容易看完，这样你就能够知道它的所有能力，从而在遇到具体需求的时候灵活运用。

那么接下来的内容，我们就以 React Router 为基础，来看看路由管理有哪些具体使用场景。相信通过这些例子，你能够进一步了解 React Router 提供的功能。

使用嵌套路由：实现二级导航页面

所谓嵌套路由，也称为子路由，就是一个页面组件内部，还需要通过 URL 上的信息来决定组件内部某个区域该如何显示。

嵌套路由对应到业务功能，一般就是一个两级导航页面。比如左边是侧边栏导航，用于控制右侧页面的展示。而右侧某个页面呢，又要通过顶部菜单来控制组件的展示。下面这张图就展示了这样一个布局：

这就需要路由框架具备两个能力：

能够模糊匹配。比如 /page1/general 、/page1/profile 这样两个路由，需要都能匹配到 Page1 这样一个组件。然后 Page1 内部呢，再根据 general 和 profile 这两个子路由决定展示哪个具体的页面。

Route 能够嵌套使用。在我们自定义 Route 的例子中，Route 组件仅用于收集路由定义的信息，不渲染任何内容。如果需要路由能嵌套使用，那就意味着需要在 Route 下还能嵌套使用 Route。而这在 React Router 是提供支持的。

下面的代码就展示了嵌套路由的用法：

import { BrowserRouter, Route, Link } from "react-router-dom";

const Page1 = () => {
  return (
    <div className="exp-15-page1">
      <div className="exp-15-page1-header">
        <Link to="/page1/general">General</Link>
        <Link to="/page1/profile">Profile</Link>
        <Link to="/page1/settings">Settings</Link>
      </div>
      <div className="exp-15-page1-content">
        <Route path="/page1/general">General Page</Route>
        <Route path="/page1/profile">Profile Page</Route>
        <Route path="/page1/settings">Settings Page</Route>
      </div>
    </div>
  );
};
const Page2 = () => "Page 2";
const Page3 = () => "Page 3";

function NestedRouting() {
  return (
    <BrowserRouter>
      <h1>Nested Routing</h1>
      <div className="exp-15-nested-routing">
        <div className="exp-15-sider">
          <Link to="/page1">Page 1</Link>
          <Link to="/page2">Page 2</Link>
          <Link to="/page3">Page 3</Link>
        </div>
        <div className="exp-15-page-container">
          <Route path="/page1"><Page1 /></Route>
          <Route path="/page2"><Page2 /></Route>
          <Route path="/page3"><Page3 /></Route>
        </div>
      </div>
    </BrowserRouter>
  );
}

可以看到，我们首先在 NestedRouting 组件内部定义了一级导航，让以“/page1”开头的路径都使用 Page1 这个组件，而在 Page1 组件内部，就继续使用 Route 组件来定义二级导航。那么这时候的路径，就都是以 “/page1”开头的，比如 “/page1/general”。通过这样的机制，我们很容易就完成了二级导航这样一个常用的页面导航模式。

在 URL 中保存页面状态

这节课一开始我就提到了，拥有 URL 是 Web 应用和传统应用最大的一个区别。这意味这 Web 应用的内容是很容易分享的，只要发送一个 URL，别人就能和我们看到同样的内容。

所以在 Web 应用中我们一定要利用好这个特性，一方面可以提升用户体验，另一方面也可以简化页面之间的交互。

要做到这一点，就需要能将页面的一些状态存放到 URL 中。那么底下我们就通过一个例子，来看看应该如何利用 React Router 来管理 URL 中的参数。下面这张图就展示了这样一个场景：

页面上有一个 Tabs 组件，在两个 Tab 下分别有两个可以分页的表格。如果要将页面的内容分享给别人，一方面需要定位到当前展开的是哪个 Tab，同时还应该知道当前是表格的第几页，这样别人通过这个 URL 才能看到和你一样的内容。所以我们只要将当前 Tab 状态，和页码的状态放到 URL 中，就可以实现这样的功能。

那么如何去做呢？首先，我们需要利用 React Router 的参数功能来定义一条路由：

<Route path="/tabs-page/:activeTab" component={TabsPage} />

这里可以看到，匹配路径中定义参数的方法，就是用冒号定义一个参数名字，这样如果访问 "/tabs-page/users" 这样的路径时，“users”这个名字就可以通过 React Router 提供的 API 拿到。而对于页码信息，我们可以通过查询字符串去实现。

下面的代码演示了完整的实现：

import { useCallback } from "react";
import { Tabs, Table } from "antd";
import { useHistory, useParams } from "react-router-dom";
import data from "../10/data";
import { useSearchParam } from "react-use";

const { TabPane } = Tabs;

export default () => {
  // 通过 React Router 的 API 获取 activeTab 这个参数信息
  const { activeTab = "users" } = useParams();
  // 通过查询字符串获取当前的页码信息
  const page = parseInt(useSearchParam("page"), 10) || 1;

  // 通过 React Router 提供的 history 对象来操作 URL
  const history = useHistory();
  const handleTabChange = useCallback(
    (tab) => history.push(`/15/TabsPage/${tab}`),
    [history],
  );
  // 定义表格的翻页功能
  const pagination = {
    pageSize: 3,
    current: page,
    onChange: (p) => {
      history.push(`/15/TabsPage/${activeTab}?page=${p}`);
    },
  };
  return (
    <div>
      <h1>Tabs Page</h1>
      <Tabs activeKey={activeTab} onChange={handleTabChange}>
        <TabPane tab="Users" key="users">
          <Table
            dataSource={data}
            columns={[
              { dataIndex: "name", title: "User Name" },
              { dataIndex: "city", title: "City" },
            ]}
            pagination={pagination}
          />
        </TabPane>
        <TabPane tab="Jobs" key="jobs">
          <Table
            dataSource={data}
            columns={[{ dataIndex: "job", title: "Job Title" }]}
            pagination={pagination}
          />
        </TabPane>
      </Tabs>
    </div>
  );
}

借由这样的实现，我们就可以通过 "/tabs-page/users?page=2" 来唯一的确定某个页面和页面的状态了，这样的 URL 是更语义化，并且可被分享的。

还有特别需要注意的一点是，在这个实现中，我们遵循了唯一数据源的原则，避免定义中间状态去存储 tab 和页码的信息，而是直接去操作 URL，这样可以让代码逻辑更加清晰和直观。

路由层面实现权限控制

在一个前端应用中，路由机制不仅能够管理我们的导航，另外一个常用的场景就是进行权限的控制。比如说，只有登录了的用户才能访问某些页面，否则就会显示为“未授权”，并提示登录。那么应该如何利用 React Router 去实现权限控制呢？

我们完全可以利用前端路由的动态特性。你已经看到了，路由是通过 JSX 以声明式的方式去定义的，这就意味着路由的定义规则是可以根据条件进行变化的，也就是所谓的动态路由。

所以我们只需要根据用户是否登录这样一个状态，通过 Route 组件去声明不同的路由就可以了。比如说下面的代码，就展示了权限控制功能应该如何实现：

import { useState } from "react";
import { Button } from "antd";
import { Route, Link } from "react-router-dom";

// 定义了两个示例页面组件
const Page1 = () => "Page 1";
const Page2 = () => "Page 2";

// 定义了一个组件用户展示未登录状态
const UnauthedPage = () => (
  <span style={{ color: "red" }}>Unauthorized, please log in first.</span>
);
export default () => {
  // 模拟用户是否登录的状态，通过一个按钮进行切换
  const [loggedIn, setLoggedIn] = useState(false);

  // 定义了两套路由，一套用于登录后，一套用于未登录状态
  const routes = loggedIn
    ? [
        {
          path: "/15/RouterAuth",
          component: Page1,
        },
        {
          path: "/15/RouterAuth/page1",
          component: Page1,
        },
        {
          path: "/15/RouterAuth/page2",
          component: Page2,
        },
      ]
      // 如果未登录，那么对于所有 /15/RouterAuth 开头的路径，显示未授权页面
    : [{ path: "/15/RouterAuth", component: UnauthedPage }];

  return (
    <div>
      <h1>Router Auth</h1>
      <Button
        type={loggedIn ? "primary" : ""}
        onClick={() => setLoggedIn((v) => !v)}
      >
        {loggedIn ? "Log Out" : "Log In"}
      </Button>

      <div className="exp-15-router-auth">
        <div className="exp-15-sider">
          <Link to="/15/RouterAuth/page1">Page 1</Link>
          <Link to="/15/RouterAuth/page2">Page 2</Link>
        </div>
        <div className="exp-15-page-container">
          {/* */}
          {routes.map((r) => (
            <Route path={r.path} component={r.component} />
          ))}
        </div>
      </div>
    </div>
  );

代码中核心的机制就在于我们根据登录状态，创建了不同的路由规则，这样就能在源头上对权限进行集中控制，避免用户未经授权就访问某些受保护的页面。

同时呢，因为在相同的 URL 下进行了信息提示，那么也就更容易实现用户登录后还能返回原页面的功能。

小结

在这节课我们主要介绍了在 React 应用中如何进行路由的管理。

首先我们了解了路由的重要性，它不仅能起到导航的作用，还能帮助我们更好地组织业务模块的代码。

随后我们自己动手，实现了一个简单的路由机制，认识到路由在本质上就是根据状态渲染不同的组件。注意，这里的状态就是 URL 的路径，而组件则是需要导航的页面。理解了这一点，也就能够理解一些开源框架的具体工作原理，那么你在使用时也就会更加从容。

接着，我们介绍了 React 生态中最为主流的 React Router 框架，它提供了很多灵活的 API，既可以让我们可以实现路由的定义和配置，比如灵活的模式匹配、参数、嵌套路由等等，也能让我们在实际项目中更加容易地去实现路由功能。

最后，通过两个例子，我们看到了路由机制在整个前端应用中的作用。要知道，它不仅仅可以起到导航的作用，还能语义化地标识页面状态，以及进行权限控制。前者提升了用户体验，让前端页面真正做到可分享。而后者则起到关注分离的作用，通过将权限控制逻辑在路由层面实现，就可以让这部分逻辑独立出来，而无须和业务逻辑写在一起，让代码更容易理解和维护。

思考题

文中提到在 URL 中存储更多状态可以简化页面之间的交互，那么为什么说可以简化交互呢？你能想到哪些场景呢？

欢迎把你的想法和思考分享在留言区，我会和你交流。同时，我也会把其中一些不错的回答在留言区置顶，供大家学习讨论。

16 | 按需加载：如何提升应用打开速度？

你好，我是王沛。今天我们来聊聊如何提升 React 应用的打开速度。

随着前端技术的不断演进，以及浏览器性能的不断提高，前端应用的开发也变得越来越复杂，因为更多的功能被放到了前端去实现。

这也让我们面临这样一个挑战：如何控制好整个应用的大小，以及如何提升加载性能，才能确保应用的打开速度够快。

我们来分析一下，看怎么解决这个问题。在应用加载的性能指标中，其中一个很重要的指标就是首屏打开速度。也就是说，当用户打开你的应用之后，他要花多久才能看到首页面。根据统计显示，如果首页打开时间超过 3 秒，那么就会流失掉 40% 的用户。所以这项指标的重要性不言而喻，也让我们在完成需求开发的同时，要时刻有意识地去控制包的大小。要知道，包越小，首屏打开速度就越快。

照此来看，为了提高首屏加载速度，我们就需要对应用进行分包。

首先，在打开某个页面时，只加载这个页面相关的内容，也就是按需加载。

同时，为了提升后续应用的打开速度，就需要采用高效的缓存策略，避免前端资源的重复下载。

所以这节课，我们首先来看看在 React 应用中如何实现按需加载。然后呢，我会和你介绍下 Service Worker 技术，看看怎么利用 Service Worker 来缓存前端资源。虽然它和 React 没有必然的联系，但是作为目前最为有效的提升加载性能的手段，你也需要进行了解并能正确使用。

如何实现按需加载？

使用 import 语句，定义按需加载的起始模块

所谓按需加载，就是指在某个组件需要被渲染到页面时，才会去实际地下载这个页面，以及这个页面依赖的所有代码。

比如当用户打开 /users/profile 这个页面时，才会真正加载对应组件的代码。如下图所示，就展示了这样一个结构，其中 Profile Page 就是需要动态加载的组件。

这样的话，就不需要在一开始时就载入所有代码，影响首屏打开速度。而是通过按需加载，只下载与页面相关的部分。

要实现这个功能，本质上在于，我们需要有动态加载模块的能力。也就是在运行时去加载模块，而不是静态地去 import 一个模块。

对于这个需求，ECMA Script 标准有一个提案，专门用于动态加载模块，语法是 import(someModule)。

注意，这里的 import 和我们一般用于引入模块的静态声明方式不同，比如 import something from 'somemodule' 。但这里的 import 是作为一个函数动态运行的，这个 import() 函数会返回一个 Promise。这样，在模块加载成功后，我们就可以在 Promise 的 then 回调函数中去使用这个模块了。

虽然这只是一个提案，并没有成为标准，但是 Webpack 等打包工具利用了这样的语法去定义代码的分包。也就是说，Webpack 实现了这样的语法。

下面的代码演示了如何使用 import 语句来定义一个分包的拆分点：

function ProfilePage() {
  // 定义一个 state 用于存放需要加载的组件
  const [RealPage, setRealPage] = useState(null);

  // 根据路径动态加载真正的组件实现
  import('./RealProfilePage').then((comp) => {
    setRealPage(Comp);
  });
  // 如果组件未加载则显示 Loading 状态
  if (!RealPage) return 'Loading....';

  // 组件加载成功后则将其渲染到界面
  return <RealPage />
}

在这段代码中，我们定义了 ProfilePage 这样一个实现按需加载功能的组件。这个组件只有被执行时，也就是渲染到界面上时，才会真正加载具体的实现代码。

这里要理解的关键点就在于，import() 这个语句完全是由 Webpack 进行处理的。Webpack 会将以“./RealProfilePage”模块为起点的所有依赖模块，单独打成一个包。并且，Webpack 还会生成代码，用于按需加载这个模块。

理解了这一点，我们也就知道了按需加载的实现原理：Webpack 利用了动态 import 语句，自动实现了整个应用的拆包。而我们在实际开发中，其实并不需要关心 Webpack 是如何做到的，而只需要考虑：该在哪个位置使用 import 语句去定义动态加载的拆分点。

很显然，我们不可能对任何模块都用动态 import 去加载，因为这会损害用户体验，导致每个功能都得等模块加载。总体需要采用的策略是：按业务模块为目标去做隔离，尽量在每个模块的起始页面去定义这个拆分点。

使用 react-lodable，实现组件的异步加载

上面的例子你已经看到了如何去实现一个 React 组件的动态加载，它的总体思路其实主要就是三个部分：

定义一个加载器组件，在使用的地方依赖于这个加载器组件而不是原组件；

在加载器组件的执行过程中，使用 import 去动态加载真实的实现代码；

处理加载过程，和加载出错的场景，确保用户体验。

显然，这样的逻辑是可以提取出来重用的。而 react-loadable，正是这样一个开源的 npm 模块，专门用于 React 组件的按需加载。

因此，在实际的项目开发中，我们一般都会直接使用 react-loadable 来完成按需加载，而不用自己去实现。

我们先看一下它的示例用法。

import Loadable from "react-loadable";

// 创建一个显示加载状态的组件
function Loading({ error }) {
  return error ? 'Failed' : 'Loading';
}
// 创建加载器组件
const HelloLazyLoad = Loadable({
  loader: () => import("./RealHelloLazyLoad"),
  loading: Loading,
});

react-lodable 本身是通过高阶组件来实现的，这个高阶组件实现了模块加载、loading 状态以及错误处理的功能。你只要通过它提供的简单 API，就可以很容易实现组件的动态加载。

在代码中，我们可以看到 Loadable 这个高阶组件主要就是两个 API。

loader：用于传入一个加载器回调，在组件渲染到页面时被执行。在这个回调函数中，我们只需要直接使用 import 语句去加载需要的模块就可以了。

loading：表示用于显示加载状态的组件。在模块加载完成之前，加载器就会渲染这个组件。如果模块加载失败，那么 react-loadable 会将 errors 属性传递给 Loading 组件，方便你根据错误状态来显示不同的信息给用户。

可以说，有了 react-loadable，我们就不需要实现完整的模块加载器逻辑了，只需要告诉 Loadable 这个高阶组件应该加载哪个模块，加载过程如何显示，就可以将一个组件封装成能够自动按需加载的组件。

按需加载可以说是减少首屏加载时间最为有效的手段，它可以让用户在打开应用时，无需加载所有代码就能开始使用，从而提升用户体验。

但是对于加载性能的优化，除了按需加载之外，还有一块就是前端资源的缓存。合理的缓存策略，可以保证同样的前端资源永远只下载一次，从而让用户在使用过一次你的应用之后，就永远不用重新下载同样的资源，从而极大提高随后 App 的打开速度。

所以下面就跟大家介绍下如何用 Service Worker 来优化加载性能。

使用 service worker 缓存前端资源

Service Worker 其实是很容易被大家忽视的一种缓存机制，因为它通常是和 PWA （Progressive Web Application）联系在一起的，用于开发离线的 Web 应用。

这个时候，很多人会觉得如果我不开发离线应用，那是不是就不需要 Service Worker 了？

其实不然。Service Worker 还提供了拦截前端请求的能力，使得它能够结合 Local Storage，成为一个独立的缓存方案。所以它不仅可以用于实现前端静态资源的缓存，还能用来开发离线的 Web 应用。

这样的话，我们可以把 Service Worker 看作一种前端的资源请求代理。每一个前端页面发出的请求都会先由这个代理进行处理，然后你再决定请求是直接发送到服务器端，还是从本地的 Local Storage 读取内容返回。

如下图所示，展示了请求的流程：

可以看到，Service Worker 会拦截所有浏览器发送出来的请求，你可以通过代码去控制 Rest API 这些请求发送到服务器；而 JS、CSS 等静态资源，则通过 Cache Storage 存储在浏览器端。

和浏览器自动的资源缓存机制相比，Service Worker 加上 Cache Storage 这个缓存机制，具有更高的准确性和可靠性。因为它可以确保两点：

缓存永远不过期。你只要下载过一次，就永远不需要再重新下载，除非主动删除。

永远不会访问过期的资源。换句话说，如果发布了一个新版本，那么你可以通过版本化的一些机制，来确保用户访问到的一定是最新的资源。

这样，你的前端应用就像一个只需要安装一次的 App，安装过之后，就不需要再重新下载了，这样使用起来加载速度会更快。

下面就给你介绍下如何使用 Service Worker 以及 Cache Storage。

Service Worker 是一段独立于页面之外的 JavaScript 脚本，它并不在 Web 页面中运行，但是会在 Web 页面加载时，由一段代码去触发注册、下载和激活。一旦安装完成之后，Service Worker 就会拦截所有当前域名下的所有请求，由代码逻辑决定应该如何处理。

要使用 Service Worker，基本上分为注册、初始化、拦截请求等步骤，下面我们就看一下各个部分应该如何用代码实现。

注册 Service Worker

使用 Service Worker 的第一步，就是告诉浏览器当前域名下我需要使用 Service Worker。

我们可以使用下面的代码来实现：

if ('serviceWorker' in navigator) {
  navigator.serviceWorker
    .register('/sw.js')
    .then(() => {
      // 注册成功
      console.log('Service worker registered.');
    }, (err) => {
      // 注册失败
      console.log('ServiceWorker registration failed: ', err);
    });
  });
}

从代码中可以看到，首先我们需要判断当前浏览器是否支持 Service Worker。如果支持，那么就用 register 方法注册一个 Service Worker，它的参数是 Service Worker 脚本的代码路径，例子中的代码路径就是根路径下的 sw.js 这个文件。如果浏览器不支持，则什么都不做。

在 Service Worker 安装之后初始化缓存机制

在 Service Worker 的实现代码被下载和执行后，会触发安装完成的事件，这个时候，你就可以在 sw.js 里监听这个事件，从而初始化自己的缓存机制。

比如下面的代码，就演示了如何在安装事件中配置 Cache Storage：

const cacheName = 'my_app_cache';
// 在 sw.js 中监听安装完成事件
self.addEventListener('install', function(e) {
  console.log('Service worker installed.');
  // 初始化 Cache Storage
  const cacheOpenPromise = caches.open(cacheName);
  // 安装过程中，等待 Cache Storage 配置完成
  e.waitUntil(cacheOpenPromise);
});

如果你之前没有了解过 Cache Storage，也不要紧，我简单说明下你就能明白。

Cache Storage 也是浏览器提供的一种缓存机制，专门用于缓存一个请求的 request 和 response 的配对关系。此外，它还提供了 API，用来判断某个 request 是不是有对应的 response 已经缓存。所以 Cache Storage 也可以认为是专门为 Service Worker 提供的缓存机制。

有了这样一段代码，我们就完成了 Service Worker 的安装。需要特别注意的是，每次打开 Web 页面时，页面其实都会调用 Service Worker 的 register 方法去注册。但是浏览器会判断脚本内容是否有变化，只有发生了变化才会重新安装。

拦截请求

当 Service Worker 安装完成后，接下来就是处于运行状态，能够拦截前端的请求了。你可以通过监听 fetch 事件来处理所有的请求，然后根据请求内容等条件来决定如何处理请求。

比如使用本地缓存或者发送到服务器端，实现的方式就是在 sw.js 文件中加入下面的代码：

// 监听所有的请求
self.addEventListener('fetch', function(e) {
  // 如果请求的路径不是 js 结尾，就通过 return false 来告诉
  // service worker 这个请求应该发送到服务器端
  if (!request.url.endsWith('.js')) return false;

  // 否则检查 cache 中是否有对应的 response
  const promise = caches.open(cacheName).then(cache => {
    // 使用 cache.match
    return cache.match(e.request).then(res => {
      if (res) {
        // 如果缓存存在则直接返回结果
        return Promise.resolve(res);
      } else {
        // 否则发出请求，并存到 cache
        const req = new Request(e.request.url);
        return fetch(corsRequest).then(res => {
          // 更新 cache
          cache.put(request, res.clone());
          return res;
        })
      }
    });
  });
  // 使用 e.respondWith 方法给请求返回内容
  e.respondWith(promise);
});

在这段代码中，采用的是一种缓存优先的策略。如果发现缓存存在，就使用缓存。否则发送请求到服务器端，然后把响应存放到缓存，并同时返回给调用者。

这是一种最为高效的静态资源缓存策略，因为只会下载一次，但同时也对静态资源的打包有一定要求，那就是任何一次代码更新，都需要有唯一的路径。在实际的项目中，一般会通过加入时间戳，或者版本化的命名静态资源文件来实现。

当然，在实际的项目中，使用 Service Worker 其实还有更多的考虑因素，比如何时删掉旧版本缓存，如何处理请求失败等等。但是核心机制基本就是示例代码中的内容，相信你在真正使用时，能够完善地加入对应的细节处理。

小结

在这一讲，我们主要学习了如何提升 React 应用的加载性能。主要分为两个部分来讲。

第一部分是资源的分包，用于实现按需加载的功能。在这里，我们主要利用了 import 语句和 Webpack 对分包的支持，这样就能够实现按需加载，从而提高首屏页面的打开速度。

第二个是 Service Worker 的概念和用法。不同于 PWA，这里的 Service Worker 仅仅用作前端静态资源的缓存。这是一个非常高效的缓存机制，可以保证静态资源仅被加载一次，从而极大地提高第二次以及后续打开 App 的速度。

因为 Service Worker 通常和 PWA 联系在一起，所以这也是一个很容易被忽视的技术。但通过今天这一课的学习，你应该能够体会到它作为缓存机制的应用场景，只要用很少的成本就带来很不错的用户体验，所以你一定要在实际项目中加以利用。

思考题

除了按需加载和 Service Worker，你还能想到哪些可以提升应用加载性能的方法？

欢迎把你的思考和想法分享在留言区，我会和你交流讨论。也欢迎把课程分享给你的同事或朋友，共同进步。我们下节课再见！

答疑解惑01｜如何转换应用React Hooks 的思路？

你好，我是王沛。今天我们来对第一章的内容进行集中答疑。

不知不觉我们的基础篇已经讲完了，也很高兴看到你在交流区积极留言，提出了很多有意义的问题。所以这节课呢，我会针对一些具有代表性的问题，进行集中答疑。一方面算是对课程内容作一个有针对性的补充，另外一方面也希望能对更多的同学有所帮助。

我会先对评论区的提出的几个具有代表性的问题进行讲解，然后再对课程中的几个思考题，做一个回答参考，并给出代码示例。

留言区的问题

02 讲

问题：文中的例子里说，窗口大小发生变化，组件就会更新。对于这一点我不太理解，Class 封装的还可以理解为，state 发生改变了，导致重新 render 了，但 Hooks 感觉这么理解并不通顺，Hook 哪里写得就类似一个纯函数调用，是怎么驱动组件重新更新的呢？

讲解：一个很好的问题，这是很多初学 Hooks 的同学都会有的困惑：一个普通函数怎么就让组件刷新了呢？其实答案也特别简单，那就是自定义 Hooks 中也是通过 useState 这样的内置 Hook 来完成组件的更新的。

可能你会觉得，自定义 Hooks 中一定会用到 state 吗？如果你写多了就会发现，自定义 Hooks 要实现的逻辑，要么用到 state，要么用到副作用，是一定会用到内置 Hooks 或者其它自定义 Hooks 的。

如果对比 Class 组件，state 发生变化，导致重新 render。这个在 Hooks 中是完全一样的，也是 state 发生变化，导致重新 render，只是我们可以在 Hooks 这样额外的函数里去 set state，而 Class 组件只能在当前 Class 中 set state。

03 讲

问题 1：函数体也是每次 render 都会执行，那么，需要每次都会 render 执行的语句是放在 无依赖的 useEffect 中呢，还是直接放在函数体中比较好呢？

讲解：这两种情况的语义是不一样的。useEffect 代表副作用，是在函数 render 完后执行。而函数体中的代码，是直接影响当次 render 的结果。

所以在写代码的时候，我们一定要理解每个 API 的语义，副作用一定是和当前 render 的结果没关系的，而只是 render 完之后做的一些额外的事情。

问题 2：老师你好，我看到 redux 官网实现 useActions 函数，让我很困惑：

地址：https://react-redux.js.org/api/hooks#recipe-useactions

摘录源码，它的依赖数组是动态的，这肯定是不对的，但是如何在 eslint-plugin-react-hooks 规则下写这个函数的呢？：

`react
import { bindActionCreators } from 'redux'
import { useDispatch } from 'react-redux'
import { useMemo } from 'react'

export function useActions(actions, deps) {
const dispatch = useDispatch()
return useMemo(
() => {
if (Array.isArray(actions)) {
return actions.map(a => bindActionCreators(a, dispatch))
}
return bindActionCreators(actions, dispatch)
},
// 这个依赖数组不是常量的
deps ? [dispatch, ...deps] : [dispatch]
)
}

讲解：需要注意的是，ESLint 的作用是帮助你发现可能存在的错误，而 Hooks 本身并不需要依赖数组是常量，只要你确定写法没有问题，那么这种可以忽略 eslint 的配置，比如加上 //eslint-disable-line 这样的注释。类似的，如果要用 Hooks 实现一个 componentDidMount 这样的功能，我们看到是需要传递一个空的数组作为 useEffect 的依赖项，那么这时候即使副作用内部使用了某些变量，那么只要你确定它只有第一次需要用到，后面无需再关心，那么也可以在这一行禁用 ESLint 的检查。所以 ESLint 主要是一个辅助的作用，代码的正确性是可以完全由自己来判断的。

04 讲

问题 1：老师，有两种写法，请问在性能方面是否后者优于前者？写法如下：

const handleIncrement = useCallback(() => setCount(count + 1), [count]);

const handleIncrement = useCallback(() => setCount(q => q + 1), []);

我的理解是这样的：后者只创建了一次函数，但是又调用了多次在 setCount 的回调函数。前者只会在 count 变化的时候创建新的回调函数。这样分析下来我又觉得两者没什么差异。我不是太清楚这两者的优缺点，希望得到老师的解答。

讲解：确实后者是更好的写法，因为 handleIncrement 不会每次在 count 变化时都使用新的。从而接收这个函数的组件 props 就认为没有变化，避免可能的性能问题。

但是有时候如果 DOM 结构很简单，其实怎么写都没什么影响。但两种代码实际上都是每次创建函数的，只是第二种写法后面创建的函数是被 useCallback 忽略的。

所以这里也看到了 setState 这个 API 的另外一种用法，就是可以接收一个函数作为参数：setSomeState(previousState => {})。这样在这个函数中通过参数就可以直接获取上一次的 state 的值了，而无需将其作为一个依赖项。这样做可以减少一些不必要的回调函数的创建。

问题 2：是任何场景，函数都用 useCallback 包裹吗？那种轻量的函数是不是不需要？

讲解：对于简单的 DOM 结构，或者函数不被作为属性传递到依赖或者组件的属性，那么用不用 useCallback 都可以。和函数是否轻量无关，主要和组件的复杂度有关。但是始终使用 useCallback 是个比较好的习惯。

课后思考题

接下来主要是针对第 6、7 讲的思考题进行回答。前几节课的思考题，我看到评论区已经有非常优秀的回答，而且有的同学也贴出了自己的代码示例，这都是非常不错的学习方法。而且我也把这些优秀的回答进行了置顶，方便大家交流讨论。

06 讲

题目：在 useCounter 这个例子中，我们是固定让数字每次加一。假如要做一个改进，允许灵活配置点击加号时应该加几，比如说每次加 10，那么应该如何实现？

讲解：这里要考察的是你有没有意识到 Hooks 就是普通函数，是可以给它传递任意参数的。所以我们只要由调用这决定加几就可以了：

function useCounter(n) {
  // 定义 count 这个 state 用于保存当前数值
  const [count, setCount] = useState(0);
  // 实现加 n 的操作
  const increment = useCallback(() => setCount(count + n), [count]);
  // 实现减 n 的操作
  const decrement = useCallback(() => setCount(count - n), [count]);
  // 重置计数器
  const reset = useCallback(() => setCount(0), []);

  // 将业务逻辑的操作 export 出去供调用者使用
  return { count, increment, decrement, reset };
}

07 讲

题目：只考虑 Redux 部分，对于计数器应用，目前每次是固定加减 1，如果要能够在每次调用时增加或减少指定的变量值，应该如何实现？

讲解：这和上一讲的思考题几乎一样，只是这里考察的是，有没有注意到 Redux 的 action 就是一个普通的 object，我们可以在其中加入任何需要的参数，只要 reducer 能处理就可以了。

代码如下：

const incrementAction = {
  type: 'counter/incremented',
  n: 5, // 实现每次加5
};

function counterReducer(state = initialState, action) {
  switch (action.type) {
    case 'counter/incremented':
      // 从 action 中去拿每次加几
      return { value: state.value + action.n }
    default:
      return state
  }

好了，这次的答疑课就是这些内容。欢迎同学们更积极的留言互动，确保每一个知识点都能准确掌握。

17 | 打包部署：你的应用是如何上线的？

你好，我是王沛。今天我们来聊聊 React 应用的打包和部署。

从这节课开始，我们就进入了扩展篇的学习。通过基础篇和实战篇的学习，你应该对如何开发一个 React 应用已经心中有数了。但是我们也要知道，仅仅学会开发是不够的。在此基础之上，掌握打包部署、单元测试，以及了解 React 生态圈的一些常用项目，才能真正完成一个应用的开发。

所以在扩展篇我会对上述内容做一个整体的介绍，让你使用 React 进行开发时，没有知识盲区，能够完整应对应用的整个开发过程。

同时，我需要强调的是，我们在扩展篇的学习目标是总体把握，消除知识盲区。所以我会以介绍重要知识点为主，将我认为最重要或者最常用的概念、机制拎出来，让你有的放矢地进行学习。当然，每一篇的内容如果你想要深入学习，还需要去官方文档或者专门的教程去详细了解。

今天这节课，我将介绍最主流的打包工具 Webpack，通过了解它的基本概念和常用配置，让你能在完成 React 的应用开发之后，知道该怎么打包成可部署的应用。

为什么需要了解 Webpack？

我个人一直认为，每个前端开发者不一定要精通 Webpack，但有必要进行了解，至少要能看得懂一个 Webpack 的配置文件，以及遇到问题时能知道是 Webpack 的问题，还是自己代码的问题。

通常来说，我们都是通过脚手架工具创建一个应用，或者加入到一个已有的项目开发中。这个时候，我们并不需要知道该怎么配置 Webpack ，只要能开发业务逻辑就可以了。这与使用 Java、Swift 等等编程语言是有些类似的，我们不太需要关注源代码是通过什么编译器如何打包成最后的应用程序的，因为 IDE 已经帮我们把这些事情做好了。

所以说，我们可以不精通 Webpack，但要知道它是什么，帮我们做了哪些事情。这样的话，在遇到问题的时候，我们就能知道是哪个环节出了问题，以便进一步寻找解决问题的方案。

举个例子，在进行 React 开发时，如果遇到下面的错误，如果是你，会从哪里着手解决呢？

实际上，这是在 React 开发时经常遇到的一个错误。很多同学一遇到时，就不知该从哪里下手，这正是对应用的打包流程不够了解造成的。

我们仔细观察下错误提示，可以发现两个重要的信息：

Failed to compile：表示这是在编译阶段报的错误，也就是从你的源代码编译到可以由浏览器运行的代码。

SyntaxError：表明这个错误是个语法错误。要么是你的语法真的写错了，要么就是编译器没有正确配置，因此无法识别这样的代码。比如在这个例子中，错误提示其实已经很明确了，JSX 的语法没有启用。而 JSX 是由 babel-loader 处理的，因此你的着手点就应该确认 babel 有没有在 Webpack 中正确配置。

所以我们可以看到，只有了解代码的打包流程，才能在遇到问题时找到正确的解决方向。下面我们就来看下 Webpack 的基本工作原理，了解你的源代码最终是如何打包成最终在浏览器中运行的代码的。

Webpack 的基本工作原理

Webpack 是目前最为主流的前端应用打包工具，它的核心思路是将源代码以及图片、样式文件等资源文件都视为模块，然后通过提供对不同类型资源的处理器，将它们进行统一处理，形成最终可在浏览器运行的代码。

下面这张图就显示了 Webpack 的工作机制：

Webpack 不仅是用于打包最终发布出去的应用程序，而且还能在开发时，为我们提供开发时服务器。它可以通过监测源代码的变化并实时编译，让我们能在代码发生变化时，及时看到运行的效果。

Webpack 对于开发环境和生产环境的配置会有所区别，但基本流程是一致的。总体来说，Webpack 的配置会分为下面三个部分。

输入输出配置：定义你的应用程序的入口，以及打包结果输出的文件夹位置。

配置对于每一类资源文件的处理器：比如说，对 JavaScript 是用 babel-loader 去编译；对 less 文件则是用 less-loader 去编译；图片则用 file-loader 去处理。你在项目中能使用哪些技术或者资源，完全取决于配置了哪些 loader。

插件配置：除了核心的源代码编译和打包流程，Webpack 还支持插件扩展功能，可以通过插件生成额外的打包结果，或者进行一些其它的处理。比如打包过程生成 index.html，源代码分析报表，提取 CSS 到独立文件，代码压缩，等等。

为了方便你理解，这里给你举一个简单的例子，我们来看看一个标准的 Webpack 配置文件究竟长什么样：

const HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');

module.exports = {
  entry: {
    // 定义应用的入口点 src/app.js，并命名为 main
    main: path.resolve(__dirname, './src/app.js'),
  },
  output: {
    // 打包输出的文件名，这里将生成 main.bundle.js
    filename: '[name].bundle.js',
    // 定义打包结果的输出位置
    path: path.resolve(__dirname, 'build'),
  },
  module: {
    // 定义处理源文件的规则，rules 下会按顺序使用匹配的规则
    rules: [
      {
        // 遇到 .js 结尾的文件则使用这个规则
        test: /\.js$/,
        // 忽略 node_modules 目录下的 js 文件
        exclude: /node_modules/,
        use: {
          // 使用 babel-loader 处理 js
          loader: 'babel-loader',
          // babel-loader 的一些选项
          options: {
            presets: ['@babel/preset-env'],
          },
        },
      },
    ],
  },
  plugins: [
    // 使用 HtmlWebpackPlugin 生成一个 index.html，其中自动引入 js
    // 并配置了页面的 title
    new HtmlWebpackPlugin({
      title: 'Webpack Output',
    }),
  ],
};

针对这段配置代码，我们一般会把它存储为 webpack.config.js 这样一个文件，这样在我们的项目下运行 webpack 命令，就会使用这个文件作为配置。

代码中其实已经很直观了，我们不仅定义了输入输出，还配置了 babel-loader，用于编译 JavaScript 文件到兼容主流浏览器的代码。同时，还为 babel-loader 设置了参数 presets，例子中这个参数的值 @babel/preset-env 可以确保 Babel 能够处理 JSX 等语法。最后，我们通过一个 HtmlWebpackPlugin，来自动生成 index.html。

在这几块配置中，主要的复杂度其实都集中在 loader 和 plugin。

理解 loader 和 plugin

loader 和 plugin 是 Webpack 最核心的两个概念，了解了这两个核心概念，我们就能掌握 Webpack 是如何处理你的代码，并最终生成打包结果。

为了理解它们的工作机制，我们来看一个 Less 文件处理的例子，看看要如何配置 Webpack，才能在项目中使用 Less 作为 Css 的预处理器。

Less 允许我们通过更强大的机制去写 Css，比如可以定义变量，允许嵌套的规则定义，等等。要让一个 Less 文件最终打包到目标文件中，并被浏览器运行，那么首先需要把 Less 代码转换成 Css，再通过 style 标记插入到浏览器中。

这个过程涉及到三个 loader，如下：

less-loader：用于将 Less 代码转换成 Css。

css-loader：用于处理 Css 中的 import、url 等语句，以便能分析出图片等静态资源打包到最终结果。

style-loader：会自动生成代码，并将打包后的 Css 插入到页面 style 标签。这个 loader 会将 Css 打包到 js 文件中，在应用运行时，自动生成的代码再把这些 css 应用到页面上。

从中可以看到，这个过程涉及到 loader 的一个重要机制：链式使用。前面一个 loader 的输出结果，可以作为后一个 loader 的输入，这样的话，整个编译过程可以由各个独立的 loader 完成不同的步骤，一方面让每个步骤的任务更加明确，另外也可以让 loader 得以重用。

比如说如果项目要支持 sass 作为 Css 预处理器，那么顺序就是 sass-loader -> css-loader -> style-loader。可以看到，我们只要替换 less-loader 为 sass-loader，后两个 loader 是完全一样的。

那么，支持 css loader 的 Webpack 配置就可以用如下代码来实现：

module.exports = {
  // ...
  module: {
    rules: [
      // ...
      {
        // 检测 less 文件
        test: /\.less$/,
        // 使用了三个 loader，注意执行顺序是数组的倒序
        // 也就是先执行 less-loader
        use: ['style-loader', 'css-loader', 'less-loader'],
      },
    ],
  },
  //...
};

可以看到，在 module.rules 配置项中我们增加了一条规则，用于 Less 文件的处理。并使用了三个 loader ，用于将 less 代码最终打包到 JavaScript 文件中。

可能有同学会问了，为什么 CSS 代码会进入到 JavaScript 文件中呢？最终它是怎么应用到页面的呢？其实背后的过程主要是，生成的 CSS 代码会以字符串的形式作为一个模块打包到最终结果，然后在运行时由 style-loader 提供的一个函数 injectStylesIntoStyleTag ，来将这个模块加入到页面的 style 标签中，从而最终生效。

比如如下代码，就展示了 injectStylesIntoStyleTag 这个函数的核心部分：创建 style 标签。这里你不需要完全理解代码的内容，只需要知道它是用来动态使用 CSS 代码就可以了。

function insertStyleElement(options) {
  var style = document.createElement('style');
  var attributes = options.attributes || {};

  if (typeof attributes.nonce === 'undefined') {
    var nonce =  true ? __webpack_require__.nc : undefined;

    if (nonce) {
      attributes.nonce = nonce;
    }
  }

  Object.keys(attributes).forEach(function (key) {
    style.setAttribute(key, attributes[key]);
  });

  if (typeof options.insert === 'function') {
    options.insert(style);
  } else {
    var target = getTarget(options.insert || 'head');

    if (!target) {
      throw new Error("Couldn't find a style target. This probably means that the value for the 'insert' parameter is invalid.");
    }

    target.appendChild(style);
  }

  return st

通过上面的例子，你应该已经能明白 loader 的工作原理了。想要在我们的项目中使用不同的语言，只需增加相应的 loader 就行了。比如要支持 typescript，就是配置 ts-loader，要支持 Vue 就是配置 vue-loader。

在这里，我们也看到 CSS 代码之所以能进入到最终的 JavaScript 包，是因为 style-loader 做了这个事情。那么如果我们想让生成的 CSS 文件和 JavaScript 文件分开，应该如何做呢？

这就需要使用到 plugin 了。同时呢，我们还要从 rules 中去掉 style-loader 这个配置，以避免 CSS 进入到 JavaScript 文件中。

实现提取 CSS 模块到单独 CSS 文件的 plugin 是 mini-css-extract-plugin，下面的代码就展示了这个 plugin 的用法：

const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  // ...
  module: {
    rules: [
      // ...
      {
        test: /\.less$/,
        // 去掉 style-loader
        use: ['css-loader', 'less-loader'],
      },
    ],
  },
  plugins: [
    // ...
    // 引入提取 CSS 的插件以及参数
    new MiniCssExtractPlugin({
      filename: 'static/css/[name].[contenthash:8].css',
    }),
  ],
};

这样，你只需要简单地引入 mini-css-extract-plugin 这个 plugin，就能识别到所有的 CSS 模块，完成 CSS 文件的生成了。

同时，通过刚才讲的例子，你应该也能看到 plugin 和 loader 的一个区别，就是 loader 主要用于处理不同类型的资源，将它们转换成模块；而 plugin 通常用于生成一些除了 JavaScript bundle 之外的一些打包结果，比如例子中的 index.html 和 css 文件。

小结

Webpack 作为目前前端开发中最为主流的构建和打包工具，它的功能非常强大。这也意味着要完整掌握它的用法，需要花费不少的精力。

当然，它虽然很重要，但并不意味着我们每个人都需要精通它的配置，以及 loader 、plugin 的开发方式。大多数情况下，只需要理解它的基本工作机制就可以了，这样已经足够你在遇到问题时，能够定位到究竟是代码问题，还是打包配置的问题。

总结来说，从这样一个前提出发，这节课我们主要介绍了 Webpack 的基本工作原理，以及 loader、plugin 这两个最核心的概念。学习的目标就是能够读懂一个 Webpack 的配置，知道自己的代码是如何最终转换成最后的 Web 应用的。当然，如果你对 Webpack 非常感兴趣，想要深入学习，可以多看官方文档或者专门的 Webpack 教程。

思考题

在进行源代码打包时，通常还有一个重要的步骤，就是代码的混淆和压缩。那么在理解了 loader 和 plugin 之后，你觉得混淆和压缩这个功能，应该用 loader 还是 plugin 去实现呢？

欢迎把你的思考和想法分享在留言区，我会和你交流讨论。也欢迎把课程分享给你的同事或朋友，共同进步。我们下节课再见！

18 | 单元测试：自定义 Hooks 应该如何进行单元测试？

你好，我是王沛。今天这节课我们来学习如何对 Hooks 进行单元测试。

在课程的一开始，我想首先强调一下单元测试的重要性。因为我发现很多同学在实现业务功能的时候，干劲十足，非常感兴趣。但是一旦要去写测试用例，就顿时觉得枯燥和无趣。

产生这种现象的原因，正是因为没有意识到测试的重要性，以及测试能给你带来的好处。要知道，最终应用的质量和稳定性在很大程度上决定着项目的成败，而为了保证软件的质量，唯一的途径其实就是测试。

当然，测试带来的好处不仅体现在能够保证最终发布的应用的质量，更为重要的是，它能 ** 让你在开发新功能，或者修复 Bug 时，对自己做的改动更有信心。只要有足够的测试覆盖率，那么你就不用太担心自己的改动可能会破坏已有的功能。同时，单元测试还能够帮助你在开发过程中，更好地组织代码，追求模块的松耦合。

因为如果你时刻有意识地去思考自己的每一段代码该如何去测试，那么在实现代码时，就会自觉地去做模块的隔离，反过来提升了代码的质量。这也是为什么很多团队会推崇测试驱动开发的原因。

好了，测试的重要性就不多说了。接下来我们就进入正题，看看对于自定义 Hooks，应该怎么进行单元测试。课程的学习目标仍然是以总体把握为目标，更多地还是思考测试代码是如何运行的，以及测试框架提供了哪些能力帮助我们运行单元测试。

如何使用 Jest 和 React Testing Library 进行单元测试

工欲善其事，必先利其器。要开始单元测试，要做的第一件事就是选择你的测试框架。这其实包括两个部分的选择：

一个是通用的 JavaScript 测试框架，用于组织和运行你的测试用例；

另一个则是 React 的测试框架，用于在内存中渲染 React 组件并提供工具库用于验证测试的结果。

这两部分市面上其实都有挺多选择，比如通用的 JS 测试框架有 Mocha，Jasmine，Jest 等等；而测试 React 的框架有 Enzyme，Testing Library 等。

如果你已经有一些前端开发的基础，那么可能已经熟悉某些框架。但今天我们使用的则是 Jest 和 React Testing Library 这两个，原因很简单，因为他们是 React 官方推荐的框架，这也意味这它们是可靠而且稳定的。而且通过 create-react-app 创建的 React 项目，已经默认包含了这两个框架，并做好了配置，我们只要直接开始写测试用例就可以了。

当然，如果你更倾向于其他的选择也没关系，因为这节课我更多地是去介绍测试的原理。理解了这些原理，你就相当于拿到了使用框架的抓手，完全可以将原理用在其它框架上。

接下来，我就假设你已经使用 create-react-app 创建了项目，这样可以省去安装和配置的步骤。所以我们就直接来看看应该如何使用 Jest 和 Testing Library 进行单元测试。

使用 Jest 创建单元测试

Jest 是 Facebook 推出的 JavaScript 的单元测试框架，主要特点是开箱即用，零配置就能提供并发测试、测试覆盖率、Mock 工具、断言 API 等功能，非常易于上手。

要快速开始使用 Jest，主要需要了解以下三点：

Jest 从哪里寻找测试文件；

如何创建一个测试用例，并用断言验证测试结果；

如何运行测试。

我们可以通过一个简单的例子来了解这三点。首先，我们需要在 src 目录下创建一个 add.js 文件，包含如下内容：

export default (a, b) => a + b;

然后，再在 src 目录下创建一个 add.test.js 文件。因为咋爱默认设置中，Jest 会寻找 src 目录下所有的以 .test.js(ts, jsx, tsx 等） 结尾的文件，以及 tests 文件夹中的文件，并将其作为测试文件。

add.test.js 的文件内容如下：

import add from './add';

// 通过 test 函数创建一个测试用例
test('renders learn react link', () => {
  // 执行 add 函数得到结果
  const s = add(1, 2);
  // 使用 Jest 提供的 expect 函数断言结果等于3
  expect(s).toBe(3);
});

如代码注释中所述，这里主要利用了 test 函数来创建一个测试用例，并用 expect 函数断言了执行的结果。expect 的功能其实非常强大，在例子中，我们仅仅演示了如何断言两个值相等。完整的 API 你可以参考官方文档。

再接着，创建完测试用例后，我们就可以在项目根目录下通过命令 npx jest --coverage 来运行测试。

实际执行的结果如下图所示：

可以看到，我们的测试用例运行成功。而且，我们通过给 jest 命令加上 --coverage 参数，还得到了自动生成的覆盖率报告。

就这样，我们用 Jest 完成了我们的第一个单元测试。但这仅仅是纯 JavaScript 逻辑的测试，对于 React 应用这样的需要浏览器环境的组件，就需要引入接下来为你介绍的 Test Library 了。

使用 React Testing Library 测试 React 组件

我们可以先想一下，要对一个 React 组件进行单元测试，需要提供什么样的运行环境去运行测试用例呢？其实主要可以分为下面三点：

需要有一个浏览器运行环境。这个主要通过 jsdom 这样一个 npm 的模块去实现。它可以在 nodejs 环境中提供一个虚拟的浏览器环境，包括了几乎所有的浏览器 API，比如 document, window 等等，从而你的组件可以在内存中运行。

需要能够解析 JSX，以及项目中用到的最新的 JavaScript 语法 **。这是通过在 Jest 配置 Babel 去完成的。

需要能够方便地渲染一个 React 组件，并对结果进行验证。这正是 Testing Library 可以提供的功能。

为了方便你理解 Testing Library 提供的功能，我们就看一下项目中自带的 App.test.js 的文件内容，其中就使用了 Testing Library：

// 引入 testing-library 提供的相关工具
import { render, screen } from '@testing-library/react';
// 引入要测试的组件
import App from './App';

// 创建一个测试用例
test('renders learn react link', () => {
  // 使用 render 方法渲染 App 组件
  render(<App />);
  // 通过 screen 提供的 getByText 找到页面上的 DOM 元素
  const linkElement = screen.getByText(/learn react/i);
  // 断言这个元素应该在页面上
  expect(linkElement).toBeInTheDocument();
});

这里用到了 Testing Library 提供的三个 React 相关的测试 API：

render：用于在内存中 render 一个 React 组件。

screen：提供了工具方法，用于获取屏幕上的元素。比如这里的 screen.getByText，就是用来根据文本获取 DOM 元素的。

expect 扩展：Testing Library 扩展了 expect 的功能，以方便对 UI 元素进行断言判断。比如例子中的 toBeInTheDocument ，就是用于断言 DOM 元素需要存在于 Document 中。

虽然这只是 Testing Library 最简单的用法，但通过这个例子，相信你能够理解 Testing Library 从哪些方面为 React 组件提供了单元测试机制，详细的 API 文档你可以参考官方文档。

如何对自定义 Hooks 进行单元测试

介绍完单元测试的框架 Jest 和 React Testing Library，你应该对在 React 中如何进行单元测试有大概的了解了。那下面我们就进入今天的主题：如何对 React Hooks 进行单元测试。

提到 Hooks 的单元测试，可能很多同学会觉得那很简单啊，Hooks 不就是普通的函数嘛，肯定要比组件的测试要容易多了。

事实上，虽然我在之前的课程中曾多次提到，我们要把 Hooks 看成普通函数，但这是有一个前提的：Hooks 只有在函数组件中使用时，我们才可以把它看成普通的函数。因为 Hooks 的使用是需要有 React 运行的上下文的。这也是 Hooks 的使用原则：Hooks 只能在函数组件或者自定义 Hooks 中调用。

所以，在单元测试的运行环境中，要想脱离函数组件，单独运行 Hooks 进行单元测试，那是不可行的。

到这里，你应该就能理解了。要对 Hooks 进行单元测试，我们还是要借助函数组件。

比如说，我们要对第 6 讲的 useCounter 这个自定义的计数器 Hook 进行单元测试，应该怎么去做呢？

首先，我们来回顾下 useCounter 的示意代码：

import { useState, useCallback } from 'react';

export default function useCounter() {
  const [count, setCount] = useState(0);
  const increment = useCallback(() => setCount(c => c + 1), []);
  const decrement = useCallback(() => setCount(c => c - 1), []);

  return { count, increment, decrement };

可以看到，这个 Hook 就是管理了 count 这个变量，并提供了对其加一减一的方法。而我们要做的测试，就是要能够调用这两个方法，并检测 count 值是否能正确地发生变化。

为此，一个思路上比较直观的做法就是创建一个测试组件，在这个测试组件内部使用这个 Hook。因此对于 Hook 的测试，就可以转换为对组件的测试，那么实现的代码如下：

import { render, fireEvent, screen } from '@testing-library/react';
import useCounter from './useCounter';

test('useCounter', () => {
  // 创建一个测试组件，使用 useCounter 的所有逻辑
  const WrapperComponent = () => {
    const { count, increment, decrement } = useCounter();
    return (
      <>
        <button id="btnMinus" onClick={decrement}>-</button>
        <span id="result">{count}</span>
        <button id="btnAdd" onClick={increment}>+</button>
      </>
    );
  };

  // 渲染这个测试组件
  render(<WrapperComponent />);

  // 找到页面的三个 DOM 元素用于执行操作以及验证结果
  const btnAdd = document.querySelector('#btnAdd');
  const btnMinus = document.querySelector('#btnMinus');
  const result = document.querySelector('#result');

  // 模拟点击加一按钮
  fireEvent.click(btnAdd);
  // 验证结果是不是 1
  expect(result).toHaveTextContent('1');
  // 模拟点击减一按钮
  fireEvent.click(btnMinus);
  // 验证结果是不是 0
  expect(result).toHaveTextContent('0');
}

可以看到，通过创建一个测试组件来使用要测试的 Hooks，再通过测试组件的功能，我们可以间接完成对 Hooks 的测试。

但是呢，这样做的缺点也是显而易见的：我们需要写很多与 Hooks 测试本身无关的代码。比如页面上的这些 DOM 元素。

这么说来，我们能否更直接地操作 Hooks 的 API 呢？其实也是可以的。我们可以将 useCounter 这个 Hook 的返回值暴露到函数组件之外，然后由测试代码直接调用这些 API 并验证结果。下面的代码就演示了这种做法：

import { render, act } from '@testing-library/react';
import useCounter from './useCounter';

test('useCounter', () => {
  const hookResult = {};
  // 创建一个测试组件，仅运行 Hook，不产生任何 UI
  const WrapperComponent = () => {
    // 将 useCounter 的返回值复制给外部的 hookResult 对象
    Object.assign(hookResult, useCounter());
    return null;
  };
  // 渲染测试组件
  render(<WrapperComponent />);

  // 调用 hook 的 increment 方法
  act(() => {
    hookResult.increment();
  });
  // 验证结果为 1
  expect(hookResult.count).toBe(1);
  // 调用 hook 的 decrement 方法
  act(() => {
    hookResult.decrement();
  });
  // 验证结果为 0
  expect(hookResult.count).toBe(0);
});

从这段代码可以看到，我们把 Hook 的返回值暴露到了函数组件之外，这样就可以直接对 Hook 进行操作了。

这里需要注意的是，我们使用 了 act 这样一个函数来封装对 Hook 返回值的方法调用。这个其实是 React 提供的一个测试用的函数，用于模拟一个真实的 React 组件的执行步骤，从而保证在 act 的回调函数执行完成后，还会等 React 组件的生命周期都执行完毕，比如 useEffect。这样才能在随后对组件的渲染结果进行验证。

关于 act 的进一步说明，你也可以参考 React 的官方文档。

通过上面这种直接操作 Hooks API 的方式，我们简化了 Hooks 的测试逻辑，本质上其实也是利用了一个容器组件去使用 Hooks 来进行测试。但例子中的组件本身其实什么都没有做，只是调用了 useCounter 这个 Hook。

那么进一步来说，我们是不是能做成一个工具函数来专门测试 Hook 呢？答案是肯定的，而且这正是 Testing Library 提供的一个专门的 React Hooks 测试包：@testing-library/react-hooks。使用它的方式如下面代码所示：

import { renderHook, act } from '@testing-library/react-hooks';
import useCounter from './useCounter';
test('useCounter3', () => {
  // 使用 renderHook API 来调用一个 Hook
  const { result } = renderHook(() => useCounter());
  // Hook 的返回值会存储在 result.current 中
  // 调用加一方法
  act(() => {
    result.current.increment();
  });
  // 验证结果为 1
  expect(result.current.count).toBe(1);
  // 调用减一方法
  act(() => {
    result.current.decrement();
  });
  // 验证结果为 0
  expect(result.current.count).toBe(0);
});

可以说，有了 @testing-library/react-hooks 这个包，我们就能更加语义化地去创建自定义 Hooks 的单元测试。虽然使用这个包的原理还是通过一个组件去调用 Hooks，但是测试代码中，你就不需要自己创建多余的测试组件了。

最后，还需要强调一个问题，你应该也想到了：我们是否需要对每一个 Hook 都进行单元测试呢？

其实不然。在第 6 讲时，我们曾经学习过自定义 Hooks 的典型使用场景。其中有一个场景是拆分复杂组件，将一个复杂组件的逻辑用自定义 Hooks 的方式进行逻辑的隔离。那么这种场景下，自定义 Hooks 其实完全可以由对组件的单元测试去覆盖，而不用去单独测试。

总结来说，只有对那些可重用的 Hooks，才需要单独的单元测试。

小结

这一课我们学习了在 React 中进行单元测试的方法。要进行 React 的单元测试，需要两块内容：一个是包含了虚拟浏览器环境的通用单元测试框架，这是由 Jest 提供的；另外一块则是对于 React 组件和 Hooks 的渲染的支持，这个则是由 React Testing Library 提供的。

课程中主要从思考 React 组件和 Hooks 的单元测试需要哪些机制为出发点，介绍了 Jest 和 Testing Library 是如何提供这些功能的。这样你就能从本质上理解测试框架的工作原理，从而在实际使用时做到心中有数，遇到问题知道该从哪里着手去解决。

当然，和上一节课一样，这节课的学习目标仍然是以入门为主，并没有复杂的使用场景介绍。如果要进一步学习，还是要参考官方文档。

思考题

在 renderHook 方法中，接收的参数 callback 是一个函数，内部调用了你要测试的 Hooks，那么你觉得 renderHook 是怎么调用这个 callback 函数的呢？

19｜第三方工具库：最常用的第三方工具库有哪些？

你好，我是王沛。今天我们来聊聊在 React 开发过程中有哪些常用的第三方工具库。

如今的软件开发已经离不开开源社区提供的各类工具库了，合理的使用它们，不仅可以帮助节省重复的开发时间。而且通常来说，流行的开源库具有更充分的测试和更高的质量。因此，在 React 的开发过程中，知道 React 生态圈有哪些常用的工具库，了解它们能解决什么问题，是非常有必要的，我们要学会站在前人的肩膀上去成就自己嘛。

那么今天这节课，我会和你分享我自己最常用的四个工具库，分别是 lodash、Material UI、Ant.Design 和 react-use。我会通过一些例子来演示它们的部分功能和用法，让你对它们有一个总体的了解。

lodash

首先想和你分享的就是 lodash。在我看来，它是每一个前端开发者都应该熟练掌握的。

lodash 提供了非常多的工具函数，就像一把强大的瑞士军刀，能够满足各种前端开发需求，从而可以大大提高开发的效率。而且，lodash 不仅能用在浏览器端，也可以用在 NodeJS 的开发中。所以说，掌握了 lodash ，也能提升 NodeJS 的开发效率。

下面我会通过三个短小的例子，来让你体会 lodash 带来的方便，这样我就不用给你解释什么叫惊喜了。

使用 keyBy，将数组快速转换成对象

lodash 提供了大量操作数据结构的工具函数，其中 keyBy 是我最常用的方法之一。通常来说，我们从 API 会拿到数组存储的一组数据，为了对数据进行操作，我们一般需要根据数据的某个属性，快速找到数组元素，而 keyBy 可以帮助我们快速做到这一点。

比如，对于如下的数据结构：

const data = [
  {
    id: "1",
    name: "Kennedy",
    job: "Chief Mobility Orchestrator",
    city: "North Alec",
  },
  {
    id: "2",
    name: "Lucius",
    job: "Internal Research Manager",
    city: "Littleland",
  },
  {
    id: "3",
    name: "Carlos",
    job: "Lead Configuration Analyst",
    city: "South Lillian",
  },
]

如果要将其变成一个以 name 为 key 的 map 结构，那么只要用如下的代码就能完成。

import _ from 'lodash';
const byName = _.keyBy(data, 'name');

可以看到，一行代码就实现了这个转换，而且语义上也非常清楚。

使用 debounce 函数，实现输入防抖

在带有搜索框的页面里，我们通常会将搜索框的用户输入，保存为 searchKey 这样一个 state，当 searchKey 发生变化的时候去刷新搜索结果。但是，如果每次用户敲击一下键盘都去刷新搜索，其实是没有必要的，因为用户很可能还没有输入完关键字。而且，多余的搜索，还有可能会引起输入过程的卡顿。

为了解决这个问题，我们通常会使用防抖机制，也就是只有在用户停止输入后一个很短的时间内才进行搜索，比如说 300 毫秒，以保证更好的用户体验。而 lodash 就专门提供了 debouce 这样的一个函数。

它的用法如下：

import React, { useMemo } from "react";
import _ from "lodash";
import { useSearchParam } from "react-use";

function SearchBox({ data }) {
  const searchKey = useSearchParam("key") || "";
  const filtered = useMemo(() => {
    return data.filter((item) =>
      item.title.toLowerCase().includes(searchKey.toLowerCase()),
    );
  }, [searchKey, data]);

  // 使用 _.debounce 处理用户输入，300ms 后才执行
  const handleSearch = _.debounce((evt) => {
    window.history.pushState(
      {},
      "",
      `${window.location.pathname}?key=${evt.target.value}`,
    );
  }, 300);
  return (
    <div className="08-filter-list">
      <h2>Movies (Debounced Search)</h2>
      <input
        defaultValue={searchKey}
        placeholder="Search..."
        onChange={handleSearch}
      />
      <ul style={{ marginTop: 20 }}>
        {filtered.map((item) => (
          <li key={item.id}>{item.title}</li>
        ))}
      </ul>
    </div>
  );

在这里我们使用了 lodash 的 debounce 方法封装了 handleSearch 这个事件处理函数，这样的话，就只有在停止输入 300 毫秒之后，才真正去执行搜索这个动作。

实际的效果你也可以参考在线的示例：https://codesandbox.io/s/react-hooks-course-20vzg 。

使用 _.template，实现简单的模板引擎

lodash 提供了一个简单的模板引擎，可以非常方便地让你去实现一些复杂的字符串生成。

比如下面就是一个例子：

var compiled = _.template('<% _.forEach(users, function(user) { %><li><%- user %></li><% }); %>');
compiled({ 'users': ['fred', 'barney'] });
// => '<li>fred</li><li>barney</li>'

可以看到，_.template 通过一定的开始和结束标记，让你能够在其中使用条件判断、循环等功能。这在我们需要去生成类似 HTML、Markdown 等大段的文档时，就会非常有用。此外，将它作为一些样本代码的生成器，也是非常好用的。

当然，lodash 的功能远远不止于此，这里介绍的三个例子只是让你先对它有一个直观的了解和感受，更多的惊喜等待你自己去发掘。详细的 API 你可以去参考官方文档。

UI 库：Ant Design 和 Material UI

UI 组件库是前端开发必不可少的一部分，因此在开始开发 React 时，要做的第一件事大概就是选择 UI 库了。这里我主要介绍两个常用的 UI 库。

一个是 Material UI （以下简称 MUI）。它采用了 Google 的 Material Design 的设计语言，提供了丰富的 UI 组件库。

另一个则是国内蚂蚁金服开源的 Ant.Design （以下简称 AntD） 组件库，主打企业级应用的场景。

Material UI

虽然 MUI 是基于 Google 的 Material Design，但是它提供了非常灵活的主题定制功能，这也是区别于 Ant Design 的一大特色。这就意味着基于 MUI 有一个庞大的主题市场，你可以从中选择自己需要的主题。

下图就是 MUI 官网提供的一些主题：

MUI 实现了一个强大的主题系统，主要原因就在于使用了 React Context 和 Styled Component 来实现 JavaScript 控制的动态主题系统，这也是它的基本原理。

比如下面这段代码，就展示了该如何定制一个主题：

import React from 'react';
import styled, { ThemeProvider } from 'styled-components';
import { createMuiTheme } from '@material-ui/core/styles';
import { palette, spacing, typography } from '@material-ui/system';
// 使用 JavaScript 定制样式
const Box = styled.div`${palette}${spacing}${typography}`;
const theme = createMuiTheme();
export default function Demo() {
  return (
    <ThemeProvider theme={theme}>
      <Box
        color="primary.main"
        bgcolor="background.paper"
        fontFamily="h6.fontFamily"
        p={{ xs: 2, sm: 3, md: 4 }}
      >
        @material-ui/system
      </Box>
    </ThemeProvider>
  );
}

这样，通过在你的组件中组合主题中定义的各种样式的规则，比如间距、字体、颜色等等，让你的组件就能够去适应各种不同的主题，从而保持 UI 样式的一致性。因此，如果你的应用对个性化的主题有比较高的需求，那么 Material UI 是不错的选择。

虽然 MUI 很酷炫，但它也有一个很大的缺点：组件库中缺少一些高级的功能。比如复杂的 Table、Tree 等，通常都需要依赖第三方来实现，而不是 MUI 自身就包含的。我们都知道，Table 和 Tree 是企业级应用最常用的组件。如果要依赖第三方，那质量和稳定性就参差不齐了。

那你可能就会问了，我到底该使用 MUI 还是 AntD 呢？我的一个标准是：要看你的应用是 To C 还是 To B。

如果是 To C，就用 MUI，因为个性化的外观很重要。而如果是 To B，那就建议使用接下来要介绍的 AntD，因为它就是面向企业级应用设计的，风格以极简为主，组件库更丰富，功能也更强大。

Ant Design

Ant Design 在国内有很高的普及率，它使用 TypeScript 开发，提供了完整的类型系统；样式则是使用 Less 去实现。而且它的单元测试覆盖率超过 99%，因此功能非常稳定，这也是它能够得以普及的一个重要原因。

下图就显示了 AntD 提供的一个后台管理系统的模板：

AntD 的强大之处主要在于提供了各种高级组件，可以满足企业级的复杂交互应用场景。下面我对几个组件的特性进行一下简要的介绍，方便你有一个直观的了解：

Table：主要功能有虚拟滚动、列锁定、树状展开、内置的排序和过滤支持等等。这些功能对于企业级的大数据量的 UI 展现提供了很好的支持。

Tree：主要功能有动态加载节点数据、支持节点的拖拽、支持节点多选框、可内置于下拉框等等。

Form：提供了表单状态管理、布局系统、丰富的验证机制等功能。可以一站式满足表单的开发需求。

消息和对话框：AntD 提供了非常易用的 API 用于一些快速的状态反馈。比如 Modal.success 用于显示一个弹出对话框；message.success 用于显示一个顶部的自动消失的反馈消息；notification.open 则用于显示一个通知栏消息，等等。

当然，这些只是整个 AntD UI 库的一小部分功能，但我们已经可以看到它面向企业级应用的设计原则，它将重点放在了提供更多开箱即用的高级组件上，以提高开发效率。那么更完整的 API，你可以去参考官方文档。

react-use

在 React Hooks 推出之后，来自 React 开发社区的 Vadim Dalecky 将一些常用的需求封装成了可重用的 Hooks，方便了 React 的开发。这个就是 react-use 库，提供了各种的工具 Hooks，了解并熟练使用它们，可以节省很多重复的工作量。

下面就一起来看看我认为最为常用的四个 Hooks 吧：

useSearchParams

其实在前面第 8 讲你已经看到了这个 Hook 的用法，就是用于获取 URL 中的查询字符串：

const searchKey = useSearchParam("key")

这样，你就能获得 key 这个参数的值，并且在值发生变化时触发组件的刷新。

useEvent

如果用 DOM 的原生 API 去绑定事件，通常我们需要在组件创建时去监听，再在组件销毁时取消监听，比较繁琐。而 useEvent 这个 Hook 封装了这部分逻辑，让我们使用起来非常方便。

使用方法如下：

useEvent('keydown', onKeyDown);

这样的话，我们就可以在页面上监听键盘按键的事件，而无需关心该在何时绑定监听，以及何时取消监听。

useCookie

Cookie 也是常用的数据源之一，但浏览器自带的 Cookie API 使用起来很不方便，需要自己去解析 Cookie 的值，而 useCookie 则可以让你很方便地去读取、更新或者删除某个 Cookie。

使用的方法如下：

const [value, updateCookie, deleteCookie] = useCookie("my-cookie");

需要注意的是，这里的 Hook 的作用域仅限于当前组件。如果 Cookie 是在组件外部被修改，则是无法触发当前组件的刷新的。你可以把这个 Hook 的作用看作是跟 useState 类似的，只是 State 的值是持久化在 Cookie 里存储的。

usePrevious

这个 Hook 可以让你获取某个 State 的上一个值，有时我们需要比较前后值的变化，这时我们就能感受到 usePrevious 这个 Hook 的好用了。

使用的方法如下：

const [count, setCount] = React.useState(0);
// 获取 cound 的上一个值
const prevCount = usePrevious(count);

小结

这节课我们主要介绍了 React 生态圈的四个常用工具库。主要的学习目的呢，就是为了让你对有助于 React 开发的重要项目做一个了解，从而避免在遇到一些需求时，自己费了很大力气去实现，却发现原来早有开源的方案。

这里尤其要强调的是 lodash。虽然它和 React 没有必然的关系，但是 lodash 非常擅长数据结构的各种转换，因此对 React 中的状态管理非常有帮助。那么熟悉它的 API，自然也是非常有必要的。

虽然这节课我只介绍了几个工具库，但是整个开源生态是非常庞大的，你在开发过程中也一定会去使用各种开源的项目。那么在这里我也和你分享一下开源项目选择的几个考虑因素。

项目的作者是很认真地去提供开源产品，比如有完善的文档、充分的测试，以及及时的 Bug 修复速度。通常来说，只有这样才能做出可靠的产品。

要全面了解选择的开源项目。如果你只是用到了一小部分内容，那么就要看它是否提供了模块化的机制，让你可以只使用其中的一部分，否则就会让你的项目越来越大。

把 Github Star 和 npm 下载量作为参考指标：通常来说，越流行的项目也意味着更高的质量。如果一个项目开源了很久，而每周却只有几十个下载量，那你就要慎重考虑了。

总之呢，开源社区是一个大宝藏，有很多很好的项目等待着你去发掘。

思考题

文中主要分享了我个人一直使用的工具库，你有什么觉得用着非常顺手的工具库呢？和大家分享一下吧，说说它们给你带来的惊喜。

欢迎把你的想法和思考分享在留言区，我会和你交流讨论。也欢迎把课程分享给你的朋友和同事，大家共同进步。

20 | React 的未来：什么是服务器端组件？

你好，我是王沛。今天我们来聊聊 React 还有哪些值得期待的新特性。

React 自诞生到现在，已经有七八年的时间了，不知道你是不是和我有一样的疑惑。React 好像这么多年来就发布了一个 React Hooks，除此之外就没有什么其他新功能了。那么，React 团队到底在做哪些事情呢？其实我们只要仔细想一想，这个疑惑也挺容易解开。

一方面，React 的 API 足够稳定，这就让我们开发人员不太能感知到一些内部的优化。比如在 2017 年，React 就基于 Fiber 的架构重写了整个 React，优化了渲染的机制，为之后的 Suspense 等特性提供了基础。

另一方面，React 团队其实一直在探索一些新的前端开发方式，只是不到足够成熟就不会正式发布。比如 Suspense，作为一个试验特性，它已经推出有三年多了，但官方一直没有宣布正式可用。而最近提出的一个新的服务器端组件的概念，虽然让人眼前一亮，但同样也还处于探索和开发阶段。

不过，了解 React 17.0 版本，还是十分有必要的，能让我们对 React 的未来会有哪些新特性，做到心中有数。

所以今天这节课，我们就来看下 React 17.0 这个没有新特性的版本带来了什么新变化。然后再通过例子，去学习 Suspense 和服务器端组件，看看它们究竟是什么，试图去解决哪些问题。

React 17.0：没有新特性的新版本

React 17.0 是一个非常特殊的版本，虽然大版本从 16 升到了 17，但是从开发的角度来看，却没有新特性。用 Facebook 官方的话来说，大版本升级主要是为以后的新功能作铺垫，核心的改变就在于提供了 React 逐步升级的可能性，同时，还提供了新的 JSX 编译的机制。

接下来我们就来看看这些新的变化。

渐进升级

框架的升级一直是软件开发的一大痛点。从业务角度来看，技术上的升级不仅会耗费巨大的工作量，而且对业务功能也没有什么帮助，甚至还会带来 Bug。而从技术角度来看，越晚升级，欠下的技术债务则越多。所以 React 17 这次带来的渐进升级，就提供了一种新的方案。

所谓渐进升级的支持，就是一个应用可以同时有多个 React 的版本。这样的话，升级 React 的过程可以更为平滑，不用一次性升级整个应用，而是某些新功能可以用新版本的 React，而旧的功能呢，则可以继续使用老版本。

新的事件模型

在第 11 讲，我们曾经看到，React 中所有的事件都是合成事件，实现的机制是在根节点上监听所有事件，然后 React 统一处理后，再将事件发送到虚拟 DOM 节点。

在 React 17 中，为了支持多版本 React 的共存，React 的事件模型做了一个修改。让我们不需要再通过 Document 去监听事件，而是在 React 组件树的根节点上去监听。这样的话，多个版本的 React 就不会有事件的冲突了。

下面这张来自官网的图，就描述了这个变化。

从技术上来说，过去是 document.addEventListener() 来监听事件，而现在则是 rootNode.addEventListener()。这一改变，不仅解决了多版本 React 的问题，而且还让 React 在和其它一些技术栈（比如 JQuery）一起使用时，降低事件冲突的可能性。

新的 JSX 编译机制

在过去，如果我们要在 React 组件中使用 JSX，那么就需要使用 import 语句引入 React。这么做的原因就在于，在编译时 JSX 会被翻译成 React.createElement 这样的 API，所以就需要引入 React。

比如下面的源代码：

import React from 'react';
function App() {
  return <h1>Hello World</h1>;
}

在过去，会被翻译成如下所示的结果：

import React from 'react';
function App() {
  return React.createElement('h1', null, 'Hello world');
}

而现在，JSX 采用了新的编译机制，因此我们的代码不需要再引入 React 了。比如：

function App() {
  return <h1>Hello World</h1>;
}

编译后的结果则是：

// 由编译器自动引入
import {jsx as _jsx} from 'react/jsx-runtime';
function App() {
  return _jsx('h1', { children: 'Hello world' });
}

虽然编译后的结果非常类似，但是 _jsx 这个函数的引入是由编译器自动完成的。所以从开发角度看，带来的最明显的好处就是代码更直观了。因为在这里 JSX 被看作一种真正的语法，因而在组件中就需要再引入 React 了。

Suspense: 悬停渲染

Suspense，顾名思义，就是挂起当前组件的渲染，直到异步操作完成。虽然这是一个“旧特性”，毕竟早在 2018 年 10 月发布的 React 16.6 版本中就已经引入了，但是因为一直处于试验状态，没有正式宣布可用，所以很多同学其实一直都不太理解它究竟是做什么的。那接下来我就做一个简单的介绍。

我们都知道，React 组件都是状态驱动的，当状态发生变化时，整个组件树就会进行一次整体的刷新。这个过程是完全同步的。

也就是说，React 会将所有的 DOM 变化一次性渲染到浏览器中。这在应用非常复杂的场景下，会成为一个潜在的性能瓶颈。所以 React 就提出了 Suspense 这样一个概念，它允许组件暂时挂起刷新操作，让整个渲染过程可以被切分成一个个独立的部分，从而为优化性能提供了空间。

其实这是 React Fiber 带来的一个非常底层的技术基础，不过的确能带来很多用处，不仅仅是在性能方面。

就像现在我们看到的文档，其实大多是用来解决异步请求获取数据的问题。要知道，在过去，异步请求都是在副作用中完成，然后副作用去修改状态，再由状态驱动组件的刷新。

而现在有了 Suspense，异步的请求就不再需要由组件去触发。组件不仅可以作为状态的展现层，同时也能变成异步请求的展现层。

下面这张图显示了这样一个转换的过程：

所以，从解决什么问题的角度出发，我们可以看到，React 希望把异步处理逻辑独立出来，让它成为一个单独的业务逻辑，既不依赖于组件，也不依赖于 State。随后，组件在渲染的时候，通过判断异步过程的不同阶段，从而进行不同的渲染。

需要特别说明的是，这个异步逻辑，不仅仅局限于异步请求，还有可能是按需加载的模块等等。

那 Suspense 到底该怎么使用呢？下面我们以异步数据请求为例来讲解一下。

首先，我们要按照 Suspense 的规范提供一个异步请求的实现。比如：

function fetchData() {
  let status = "pending";
  let result;
  // 发起请求获取数据，返回 suspender 这个 promise
  let suspender = apiClicent.fetch('/topic/1').then(
    (r) => {
      status = "success";
      result = r;
    },
    (e) => {
      status = "error";
      result = e;
    }
  );
  // 无论何时调用 fetchTopic，都直接返回一个结果
  return {
    readTopic() {
      if (status === "pending") {
        // 如果还在请求中直接抛出一个 promise
        throw suspender;
      } else if (status === "error") {
        // 如果请求出错，抛出 error
        throw result;
      } else if (status === "success") {
        // 如果请求成功，返回数据
        return result;
      }
    }
  }

}

就这样，我们实现了 fetchData 这样一个可供 Suspense 使用的 API。接着，我们来看看该如何在 Suspense 中使用这个 API。

import React, { Suspense } from "react";
import fetchTopic from './fetchTopic';

const data = fetchData();

function TopicDetail() {
  // 调用了 data.readTopic() 来获取数据
  const topic = data.readTopic();
  // 直接同步的返回了 JSX
  return (
    <div>
      <h1>{topic.title}</h1>
      <p>{topic.content}</p>
    </div>
  )
}

function TopicPage() {
  return (
    <Suspense
      fallback={<h1>Loading...</h1>}
    >
      <TopicDetail />
    </Suspense>
  );

我们先来看 TopicDetail 这个组件的实现。这里有一点非常关键，那就是我们可以直接用同步的写法去获取异步请求的数据。

对于这种组件，在使用的时候，需要放在某个 Suspense 标记下面。通过给 Suspense 提供一个 fallback 的属性，用于渲染加载状态的界面。

结合 fetchData 这个 API 的实现，我们可以看到，请求在组件渲染之前就已经发生了，而不再是由组件的渲染触发的。这正是 Suspense 想要带来的效果，隔离了副作用和 UI 渲染的过程，让你能够用同步的写法去实现异步逻辑。

总体来说，Suspense 提供了一种实现异步逻辑管理的新的机制，可以替代很多框架提供的不同的副作用处理逻辑，比如 Redux、Saga 等。

遗憾的是，Suspense 目前并没有得以普及。一方面，是因为它还处于试验探索阶段，并没有正式发布。另一方面，它也需要一些第三方框架的支持，从而提供能兼容 Suspense 的异步 API，方便 Suspense 的使用。

目前来说，只有 React 的 GraphQL 的 Relay 客户端实现了完整的 Suspense 支持。所以，除非你把 Relay 作为客户端，那么我们暂时只要对 Suspense 作个了解即可，而不要将其用在正式项目中。等未来稳定后，可以再把它用在正式项目中。

Server Components：服务器端 React 组件

自从 Hooks 之后，React 在 2020 年 12 月提出的 Server Components 这个提案，大概 React 是最为有趣的一个新特性了。虽然目前来说，Server Components 还处于探索和开发实现的阶段，但我们可以通过官方的介绍视频和一个示例项目去了解它的功能和使用方式，从而有一个直观了解。

Server Components 最明显的功能，就是能够在组件级别实现服务器端的渲染。也就是说，一个前端页面中，有些组件是客户端渲染的，而有的组件则可以是服务器端渲染的。为了帮助你加深理解，我们直接来看一段示意的代码。

比如说有这么三个组件，分别是 App.server.js、Article.server.js、Comments.client.js。我们可以通过后缀来区分哪个是 server 端的组件，哪个是 client 端的组件。那么 App.server.js 的示意代码如下所示：

import { Suspense } from 'react';
import Article from 'Article.server.js';
import Comments from 'Comments.client.js';
function App() {
  // 从 URL 获取 articleId
  const articleId = useSearchParams('articleId')
  return (
    <div>
      <Suspense fallback={<>Loading...</>}>
        <Article id={}
      </Suspense>
      <Comments articleId={articleId} />
    </div>
  );
}

在这段代码里，我们使用的 App 和 Article 这两个组件是服务器端组件。在第一次渲染时，由服务器端返回到前端。而在随后的渲染中，比如 articleId 发生了变化，那么 Article 这个组件会通过服务器端去重新渲染。然后渲染的结果，再发送到浏览器端来展示。因为 Article 是一个需要异步渲染的组件，所以也会包含在上面提到的 Suspense 标记中。

那么 Server 端组件能够带来什么好处呢？其实通过 Article 这个组件，我们可以很直接的看到这样两个好处。

第一，Article 组件在服务器端运行，所以在代码中可以直接去读取文件系统、查询数据库等，数据逻辑会非常简单。

比如说，Article 的组件代码可能如下所示，其中就直接查询了数据库：

export default function Article({ articleId}) {
  // 服务器端组件可以直接查询数据库
  const article = db.query(
    `select * from articles where id=${articleId}`
  ).rows[0];

  return (
    <div>
      <h1>{article.title}</h1>
      <p>{article.content}</p>
    </div>
  );
}

第二，Article 所需要的任何依赖只需要存在于服务器端。比如说，它依赖了 moment 这个 library 去做日期的格式化，那么 moment 就不需要打包到前端，只需要在服务器端存在就可以了，我们也就不再需要担心包的大小了。

所以，通过这两点其实可以看到，Server Components 为我们开发具有极致性能的前端应用，提供了一个良好的基础。一方面，它能够让我们通过用服务器端渲染更多的组件，把前端包的大小控制得很小，从而提升加载性能。

另一方面，它能让页面中的一部分能够在服务器端渲染。不但能够简化前端异步请求的逻辑，而且在组件内需要多个数据请求时，也会提升渲染速度。

不过看到这里，你可能又会问了，这个和以前的服务器端渲染（SSR）有什么区别呢？其实我们可以把 Server Components 看作 SSR 的进阶版本。传统的 SSR，每次只能 render 整个页面，而现在则可以在组件级别 render 了，大大提升了灵活性。

因而我们应该说，Server Components 是一个非常令人期待的特性，尤其是对于电商页面这种对性能有极致需求的服务器端渲染的应用，会带来极大的帮助。那么等到 Server Components 正式发布之后，社区中还会有哪些令人兴奋的框架级别支持呢？就让我们拭目以待吧。

小结

这节课我们主要学习了三部分内容。首先，我们看到了最近刚刚发布的 React 17 的变化。虽然在 API 上没有明显的变化，但是它为以后的渐进式版本更新提供了基础，并且基于新的 JSX 的处理机制，让你不再需要在代码中 import React。

接着，我们了解了 React 中一个“古老”的新特性——Suspense。这个特性虽然仍然处于实验阶段，但其实已经非常稳定了。而我们要做的，就是等待它正式发布，然后去看看社区中还会提供哪些更好用的开发方式。

最后，我介绍了一个非常令人兴奋的服务器端组件的提案。可以说，它为我们去开发极致性能的 React 应用提供了一个很好的基础。虽然功能还在开发中，但是非常值得期待。

好了，到这里，我们整个课程的主体部分就正式结束了。希望通过基础篇、实战篇和扩展篇的学习，你能对 React 和 React Hooks 有一个更本质的认识。当然，我更希望的，是你能通过不断思考和琢磨，找到自己学习方法，多思考每一个技术的本质，以及要解决的问题，从而能够举一反三，不断提升 React 的开发效率。

思考题

前面提到，React 17 中新的事件模型可以在和其它框架或者不同版本 React 使用时，避免一些潜在的事件冲突。你能想到如果事件绑定在 document 上，什么场景下会产生事件冲突呢？

这是我们这门课的最后一道思考题了，欢迎把你的思考和想法分享在留言区，我会和你交流讨论。也欢迎把课程分享给你的同事、朋友，一起共同进步。

答疑解惑02

你好，我是王沛。实战模块是我们这门课的学习重点，我看到不少同学不仅跟上了学习进度，而且还跟着课程，动手写代码，并把其中讲解的思路应用到了自己的实际项目中。理论结合实践，学以致用，这是一种很有效的学习方法。

与此同时，我也看到有同学在留言区提出了很多有价值的问题。所以这节课呢，我会针对你在实战模块提出的一些具有代表性的问题，进行一个集中的答疑。一方面算是对课程内容做一个有针对性地补充，另外一方面也希望能对更多的同学有所启发和帮助。

第 9 讲

题目 1：article?.userId 和 article&&article.userId 的作用是一样的吗？第一次见这种写法，感觉好简洁。

回答：虽然这是一个 JS 的语法问题，但是因为是一个新语法，所以既然有同学问了，就拿出来讲一下。简单来说，“?." 是一个名为 optional chaining 的新语法，是刚刚进入 ECMAScript 的标准。借助于 Babel 我们现在可以放心使用。

article?.userId 和 article && article.userIde 这两种写法功能是基本等价的，就是判断 article 是否存在，如果存在则获取 userId 属性，否则就是 undefined。这样的话可以避免 JS 运行时的报错。唯一的一点区别在于，后者 && 的写法其实如果 article 为 null 或者 undefined 或者 0 等 falsy 的值时，会返回这个 falsy 的值本身，比如 null，undefined 或者 0。虽然这在大多数情况下是不用考虑这种差别的。

很显然，optional chaining 这种语法更简洁语义也更明确。下面的代码展示了它的用法：

// 静态属性
const c = foo?.bar?.c;
// 动态属性，数组属性
const a = obj?.[key].a;
// 方法调用
obj.getSomething?.();

题目 2：对于 loading 和错误处理，我们项目是在做全局处理的，且 loading 是通过 redux 管理的。那么这种做法，跟这节课所讲的方法相比，究竟应该使用哪种方法呢？

回答：两者不冲突，在 Hooks 中课程里是用的 useState，但是如果 loading 和 error 是在 Redux 中，那么可以用 useSelector 去获取状态。全局处理的好处是方便多组件去重用状态，而且可以避免重复的请求。

比如两个组件都需要去使用 Article 数据，如果用 useState 保存数据，那么两个组件就都有各自的数据。如果放到 redux 中，那么逻辑就可以根据是否已经在 loading 来决定是不是要发起请求，还是等待已有的请求结束。

第 10 讲

题目：HOC 存在的意义在哪里呢？感觉能够用 HOC 的场景，其实都可以用 render props 来替代。而且从逻辑角度讲，render props 的逻辑更清晰，HOC 的使用逻辑则更加冗余。所以实在想不出来有什么场景是一定需要使用 HOC 的。

回答：HOC 如果作为 Controller ，而不是提供额外属性时，其语义是更加清晰的。比如说在第 14 课提到的浮动层，我们希望对话框在不可见时就不执行任何逻辑，那么可以用 HOC 来实现一个这样的 Controller，比如下面的代码：

const wrapModal = Modal
  => ({visible, ...props})
  => visible ? <Modal visible {...props} /> : null;

对于一个 Modal 组件，当 visible 为 false 时，就直接返回 null 而不是去 render Modal 组件。这里就用 HOC 的模式新建了一个 Controller 性质的组件，来重用这种逻辑。

第 12 讲

题目 3：js-plugin 的用处感觉比较大。但是在 react 组件中， 这样的写法，我确实不太能理解。就像是一个容器组件，通过传入不同的参数，返回不同的组件。因为我使用 ts，感觉这样又少了类型检查。

回答：是的，很类似一个容器组件。但是这个容器组件的内容来自于松耦合的不同部分的内容。不是从容器组件内部依赖其它实现，而是反方向，外部的功能自己提供内容到这个容器组件。对于 TypeScript，确实是不够友好的，因为太动态了。但是其实没有类型检查不会是太大问题，因为内容提供者和消费者两者唯一的连接点是 article.footer 这个扩展点以及其函数签名。当然，如果一定要支持 TypeScript，也是可行的，只是比较繁琐。那就是为每个扩展点提供一个类型定义文件，然后在创建 plugin 时，plugin 对象需要满足所有支持的扩展点的类型定义。

## 第 13 讲

题目 1：虽然这个 API 只支持通过函数执行进行验证，但是我们很容易进行扩展，以支持更多的类型，比如正则匹配、值范围等等。这个能演示一下吗？

回答：要支持更多类型的验证，就是判断当前提供的 validator 的类型，比如如果发现提供的是正则，就用正则匹配，如果是一个包含了 min，max 属性的对象就进行自动的范围判断，如果是函数，那么就调用函数，比如下面的代码在原有的基础上提供了这几种验证机制的支持：

if (validators[name]) {
  const validator = validators[name];
  let errMsg;
  if (typeof validator === 'function') {
    // 如果是函数，就调用函数验证
    errMsg = validator(value);
  } else if (validator.min) {
    // 如果指定了 min，就判断是否小于 min
    if (value < validator.min) errMsg = `数值需要小于 ${value}`;
  } else if (validator.max) {
    // 如果提供了 max，就判断是否大于 max
    if (value > validator.max) errMsg = `数值需要大于 ${value}`;
  } else if (validator.test) {
    // 简单的通过是否有 test 方法来确定是不是正则表达式
    if (!validator.test(value)) errMsg = `${name} 需要匹配正则。`
  }

  setErrors((errors) => ({
    ...errors,
    [name]: errMsg || null,
  }));
}

这样我们就支持了多种形式的验证，但其实只要提供了函数类型，就能够支持任意形式的同步验证了。加入更多类型只是为了方便这个 Form 机制的使用。

第 14 讲

题目 1：NiceModal 里面有 const modal = useNiceModal(id); 在 MyModalExample 也用了 const modal = useNiceModal("my-modal"); 用了两次 useNiceModal，返回值的两个 modal 应该是不同的对象吧？ modal 里面的 hide、show 函数应该也是不一样的，对吗？不过数据都存在 const store = createStore(modalReducer) 的 store 里了，是全局的。

回答：是的，两个 modal 是不同的对象，show，hide 函数也不一样。useModal 返回的只是用于控制某个对话框的一个 handler。这里的关键在于要理解自定义 Hook 就相当于一个自定义组件，内部使用的任何其它 Hooks，比如 useState，useEffect 都是和外层组件毫无关系的。他们都是存在于自己的作用域里，通过参数和返回值和调用者交互。所以，要实现 Hooks 之间的状态共享，仍然需要 Context，Redux 等全局的状态管理机制。

题目 2：在课程中，我们使用的是 Redux 来管理所有对话框的所有状态。但有时候你的项目并不一定使用了 Redux，那么我们其实也可以使用 Context 来管理对话框的全局状态。那么请你思考一下，如果基于 Context ，应该如何实现 NiceModal 呢？

回答：在基础篇中你曾看到过 useContext 的用法，它也是实现全局状态管理的一个机制。但是，当时并没有提到 useReducer 这个从 Redux 借鉴过来的 API。没有介绍的原因其实在于我个人觉得 useReducer 其实并不常用，因为通常来说我们使用 Redux 等独立的框架会更加方便，因为 API 更加丰富，以及生态更完善，比如 Redux 的一些中间件等等。

但是，在这道题目中，虽然可以不用 useReducer，但是如果使用的话，是可以几乎完全重用课程中例子的代码的。因为 Redux 和 useReducer 的 reducer 和 action 的实现是完全兼容的。这也是为什么很多人说，Context + useReducer 是几乎可以替代 Redux 的。下面的代码展示了使用 Context 的实现：

import React, { useContext, useReducer } from 'react';

// 使用一个 Context 来存放 Modal 的状态
const NiceModalContext = React.createContext({});
// modalReducer 和 Redux 的场景完全一样
const modalReducer = ...
function NiceModalProvider({ children }) {
  const [modals, dispatch] = useReducer(modalReducer,{});
  return (
    <NiceModalContext.Provider value={{ modals, dispatch }}>
      {children}
    </NiceModalContext.Provider>
  );
};

因为使用了 Context，所以需要在整个应用的根节点创建 Context 并提供所有对话框的状态管理机制。这里也看到了一个非常关键的组合，就是使用了 useReducer 来管理一个复杂的状态，然后再将这个状态作为 Context 的 value。需要注意的是，useReducer 管理的仍然是一个组件的本地状态，和 useState 类似，但其实单个组件很少有状态需要 useReducer 这样的高级功能来管理，所以一般其实 useReducer 都是和 Context 一起使用的，去管理一个更大范围的数据状态。

第 15 讲

题目 1：原文写：“如果你的事件处理函数是传递给原生节点的，那么不写 callback，也几乎不会有任何性能的影响。”为什么这么说呢？是不是因为原生节点，本来就要不断渲染呢？

回答：要回答这个问题，关键还在于理解在 useCallback 究竟是为了解决什么问题。问题的本质在于，React 组件是通过引用比较来判断某个值是否发生了变化，如果变化了，那么组件就需要重新渲染，以及虚拟 DOM 的 diff 比较。那么，如果每次传过去不同的函数，即使这些函数的功能完全一样，那也会导致组件被刷新。所以，useCallback, useMemo 就是通过缓存上一次的的结果来确保如果功能没变，那么就使用同样的函数，来避免重新渲染。

所以，useCallback，useMemo 只是为了避免 React 组件的重复渲染而导致的性能损失。而对于原生的节点，比如 div, input 这些，它们已经是原子节点了，不再有子节点，所以不存在重复刷新带来的性能损失。

第 16 讲

题目 1：对于 react-loadable 和 Service Worker 有两点比较疑惑的地方。

（1）react-loadable 和 React.lazy() 的使用场景有什么不一样吗？还是说，使用 react-loadable 的地方都可以使用 React.lazy() 代替？

回答：确实，两者确实可以解决一样的问题，毕竟 import() 这个语句是由 Webpack 来处理，最终实现分包的。所以结合 React.lazy 和 Suspense 可以实现 react-loadable 的功能。但是呢，react-loadabale 提供了更丰富的 API，比如可以设置如果加载事件小于 300 毫秒，就不显示 loading 状态。这在使用 service worker 时可以让用户感知不到按需加载的存在，体验更好。因为有研究表明，如果加载时间本来很短，你却一闪而过一个 loading 状态，会让用户觉得时间很长。另外就是，react-loadable 提供了服务器端渲染的支持，而 React.lazy 是不行的。

（2）在 Service Worker 中使用 Cache Storage 来缓存静态资源，是否有容量大小的限制呢？如果是在缓存 svg、png 等其他格式的静态资源的时候，是否有什么限制呢？

回答：Cache 的大小并没有统一的规定，各个浏览器会提供不同的 size，但一个共识是根据当前磁盘剩余大小去调整，从而决定是否能继续存储数据。但是，无论 Size 是多少，我们都需要及时清除不需要的数据，以及一定要处理存储失败的场景，转而使用服务器端的数据。从而保证即使 Cache 满了，也不影响功能的使用。

结束语｜抓住本质，方能事半功倍

你好，我是王沛。

如果你留心的话，会发现我在这个专栏中，写得最多的一句话就是“如果我们从本质上来看”。没错儿，从本质出发思考问题，是我写这个专栏的初衷，也是我认为学习 React Hooks 的正确打开方式。

所以在专栏的结束语这一篇，我就来和你聊聊“本质”这个话题。不过在聊“本质”这个略微有点虚的话题之前，我想先聊聊“认真”。因为在我看来，只有以一种认真的态度去做事，才能更好地深入到本质。在这里，我先分享一个我自己亲身经历的故事吧。

凡事须认真，方能有收获

我刚毕业时，有一次和一位前辈聊天，他说：“让我在公司混日子，我是做不到的。要么全力以赴，要么立马走人。”当时听到这话还挺震撼的，因为我总觉得这话说的就是我。

我当时所在的公司是知名外企，但同时也是别人口中的养老大厂。而自己做的事情，就是按部就班地完成任务。做得好与不好，似乎并不重要。感觉只要自己的技术能力还在，那么遇到棘手的问题，临时抱佛脚，突击一下也能解决。这好像是自己最有成就感的时候了。

但等一两年后，眼看着周围的同事一个个升职加薪，或者另寻高就。再看看自己，似乎还在原地踏步，工作氛围也是一片祥和。这才让我明白一个道理，“完成”和“认真完成”，在结果上有质的差别。

而长期来看，在职业的发展上，甚至可以造成不可逾越的鸿沟。所以当你以为你用一段轻松的时光，换取了一份不错的收入，但实际上，错失的就是一个个快速成长的机会。

那么所谓的“认真”，在我看来，其实就是让自己保持一种在不断思考的状态，不是按部就班、没有想法地做事，而是主动思考问题。

抓住本质，让复杂事情简单化

我在面试与 React 相关的候选人时，都会问他们这样一个问题：为什么 Facebook 要发明 React Hooks？React Hooks 解决了什么问题？

这个问题其实不难，但凡对 React Hooks 有点了解，都可以说出个所以然来。比如，让代码更简洁、让代码更易维护、让开发效率更高，等等。

然而从这些回答中你会发现，这些候选人对 React Hooks 的理解还停留在表面，没有深入到本质。当然，如果你认真学完了我们这门课，答案其实也很简单：简化了逻辑重用。

你可能会说，不去深究本质这样的问题，我照样能把 Hooks 用得很不错。

的确，当我们突然拿到一个临时的开发任务，或者要使用一个之前不熟悉的技术时，也许看下官方文档，就能解决当下的问题了。但是我想强调的是，理解了本质，你就相当于找到了问题之源，可以以不变应万变。

正如影片《教父》中那句广为人知的台词：“在一秒钟内看到本质的人和花半辈子也看不清一件事本质的人，自然是不一样的命运。”

同样地，能抓住 React Hooks 的本质，就能把复杂问题简单化，事半功倍。具体来说，抓住 React Hooks 的本质，至少能让你收获如下三方面的好处。

首先，能把 Hooks 用在更加正确的场景。如果只是停留在 API 形式上的使用，那么写出来的代码可能就不是很合适。如果等发现不合适再去修改，那浪费的精力就太多了。

其次，了解了本质，我们就能非常准确地知道这个 Hook 解决问题的出发点是什么，与原来方案相比有什么不同，从而提升开发效率。

事实上，当我们学习任何一个前端技术，也是一样的道理。从本质出发，帮助我们构建自己的知识体系，进而形成对技术的敏锐判断力。

要知道，在“轮子”层出不穷的前端圈，如何选择合适的技术就显得尤为重要。因为我们一直面临着这样的问题：这个新技术能解决什么问题？它的优缺点是什么？给项目带来的利多，还是弊多？我们到底该不该用？

而要回答诸如此类的问题，就需要我们洞察不同技术的本质，形成自己的判断，避免随波逐流。可以这样说，我们要学会看技术不是技术，而是要看其背后的思想和逻辑。再进一步，我们也就能够举一反三，用一套体系灵活应对不同的功能场景了。

多问为什么，帮你发现问题的本质

说了这么多关于本质的理解，那么到底怎么才能深入本质呢？这里想和你分享我从写专栏收获的一些心得。

对我个人而言，写完专栏，算是了了自己的一桩心愿。因为在我的脑海里，一直有一个关于 Hooks 课程的大概模样：不止是介绍 API 及其用法，而是更多地从本质出发，深入思考每一个 API 设计背后的思想和逻辑。当然，理想总是清晰且美好的。

在真正下笔的那一刻，才发现“教”远比“学”要艰难得多。我需要从教学的视角出发，不仅要呈现“是什么”，还要讲出“为什么”：

我为什么要讲这个知识点，而不是那个？

我为什么要以这个思路来讲？

通过不断追问为什么，来逐渐把握所要讲述的问题的本质。所以在我看来，深入本质，就是要非常朴实地去追问几个为什么。通过不断询问自己一个又一个为什么，我们就能更好地思考问题，从而获得更为深刻的洞察力。

所以在这门课的最后，我想说，学习没有捷径，只有通过多实践，多思考，才能获得高倍速地成长。所谓的躺平，那只是自嘲而已。

最后的最后，我也为你准备了一份毕业问卷，题目不多，希望你可以花一到两分钟填一下。十分期待能听到你的反馈，说说你对这门课程的想法和建议。