React-挂钩设计正确之道-全-
React 挂钩设计正确之道(全)
原文:
zh.annas-archive.org/md5/fe1abc1bd797b71d4c5f36921b619687译者:飞龙
前言
React 最近一直是我主要的开发工具。在我的经验中,无论是作为开发者还是计算机用户,我发现最终我喜欢的都是一些轻量级的东西。虽然大公司来来去去很常见,但总有某些东西被保留下来。例如,过去二十年中,构建网站的方式已经重塑和改进,但构建网站的一般过程并没有太大变化。你仍然需要创建一个 HTML 文件,设计布局,并在服务器上的某个地方托管它。
当谈到 用户界面 (UI) 时,还有一个可以以类似方式帮助你的话题。那就是状态。从 jQuery 和 Angular 到 React,从网页到其他非桌面平台(如 Electron 或 React Native),无论你走到哪里,都有一个技术问题需要你现在回答——屏幕如何知道需要应用变化?当我还在大学的时候,我从未问过这类问题。我通常认为计算机就是这样工作的。
当然,现在我知道计算机是这样工作的,因为有人创造了它。UI 最吸引人的地方是当状态出现。在早期,我们根本不谈论状态。但现在状态无处不在,尽管还没有教科书定义它或我们应该如何学习它。但可以肯定的是,状态在网页开发行业中仍然是一个相对较新的话题。
在这本书中,我将尝试使用 React 作为底层技术,探索和学习状态是如何引入和实现的。我希望通过这样做,我们最终能对“渲染引擎是如何由状态驱动的?”这个问题有一个更清晰的了解。
这本书面向的对象
这本书的理想读者是一个已经编写了几年 JavaScript 的工程师,但不一定有 React 和/或函数组件的经验。对于经验较少的 JavaScript 读者,我们通过 CodePen 提供了一个实时沙盒,这样你可以立即尝试每个主题。
如果你已经有 React 的经验,或者甚至有 Hooks 的经验,那也很好;这本书将向你展示 Hooks 在函数组件中的实现方式。此外,每个章节还包含与每个 Hook 相关的简化版 React 源代码,如果你是一个有经验的 React 程序员,这将帮助你更深入地理解。
这本书涵盖的内容
第一章,介绍函数组件,通过解释其属性和基本的父子关系来解释什么是函数组件。然后你将获得一些关于如何编写函数组件的技巧。在章节的结尾,你将看到一个实际的函数组件示例,Nav。
第二章,在函数组件中构建状态,展示了如何构建一个称为状态的特殊变量。我们将看到状态可以提供哪些好处,包括请求新的更新和监听值的变化。我们还将看到一个将状态应用于单页应用(SPA)的例子。我们还将仔细研究状态在UI中扮演的角色。
第三章,深入 React,探讨了我们在创建良好的状态解决方案时面临的挑战,然后我们将看到 React 架构师如何通过底层的 Hook 提供解决方案。然后我们将介绍 Hooks,了解它们的调用顺序,并学习如何在实际应用中避免遇到条件 Hook 问题。
第四章,使用 State 启动组件,涵盖了内置 Hooks,从useState Hook 开始。我们首先解释状态在 React 中的使用,然后了解useState背后的数据结构和源代码,并描述常见的状态分发用法。我们将对useState进行测试,并提供将useState应用于Avatar和Tooltip组件的两个实用示例。
第五章,使用 Effect 处理副作用,介绍了副作用,了解了useEffect背后的数据结构和源代码,并提供了各种触发效果的场景。我们还将演示使用useEffect的一些陷阱以及避免它们的方法。然后我们将使用useEffect在两个实用示例中,窗口大小和 Fetch API 中。
第六章,使用 Memo 提升性能,解释了在典型的 Web 应用中我们如何遇到性能下降问题。然后我们将了解useMemo背后的设计和源代码,并描述各种条件重用值的方法。然后我们将优化技术应用于两个常见情况,点击搜索和搜索防抖。
第七章,使用 Context 覆盖区域,介绍了区域更新以及 React contexts 如何用于将值共享到区域。然后,我们将了解useContext背后的数据结构和源代码以消费共享值。在章节末尾,我们将提供两个将上下文应用于主题和表格的实用示例。
第八章,使用 Ref 隐藏内容,解释了如何通过 ref 访问DOM元素,我们将了解useRef Hook 背后的设计和源代码。我们还将描述如何在不触发更新的情况下处理持久值。最后,我们将把 refs 应用于一些实际问题,例如点击菜单外,避免内存泄漏,设置中继站,和定位当前值。
第九章,使用自定义钩子重用逻辑,汇集了我们迄今为止所学的所有钩子,并解释了如何创建一个自定义钩子来满足我们的需求。我们将逐步介绍自定义钩子,并编写几个自定义钩子,包括useToggle、useWindow、useAsync、useDebounced、useClickOutside、useCurrent和useProxy。
第十章,使用 React 构建网站,一般性地讨论了 React,特别是 React 在网页开发中的作用。我们将从三个角度来探讨这个话题,看看 React 如何将资源组合起来构建一个网站,包括 JavaScript ES6 特性、CSS-in-JS 方法以及将类似 HTML 的行转换为 JavaScript 表达式的转换。
为了充分利用这本书,
本书的一个目标是通过使用 React 和 Hooks,让你获得实际操作经验。以下是一些你可以在开始之前采取的选项,以充分利用内容。
温习 React 知识
如果你最近没有使用过 React 或者不熟悉其前沿特性,我建议你跳转到 第十章,使用 React 构建网站,以了解 React 依赖的三个构建块来构建网站:JavaScript、CSS 和 HTML 的概览。
在阅读书籍时,如果你遇到不熟悉的语法,或者只是想更深入地了解每个构建块在 React 中的使用方法,请随时查看这一章节。
使用无需构建代码的浏览器
如果你没有本地环境来编写代码,或者你只是不想构建代码,你可以从在线服务器codepen.io/windmaomao/pen/ExvYPEX访问示例。你很快就会在屏幕上看到打印出的 Hello World。每个章节都附带了一些操场链接,你可以点击它们来跟随。它们在书中的显示如下:
操场 – Hello World
你可以随意在这个在线示例codepen.io/windmaomao/pen/ExvYPEX上尝试。
自己编写代码
如果你是一个喜欢动手实践的人,并且想在每一章中逐步跟随代码,你需要在你的一些项目中安装 React。从头开始设置 React 项目的说明如下。
npm
访问 Node.js 网站,nodejs.org,获取 Node.js 和 npm 的最新版本。选择适合你操作系统的正确版本并安装它。为了检查是否已正确安装,打开终端,并运行以下命令:
node -v
如果你看到前一个命令返回的版本号,说明 Node.js 已安装。
创建 React 应用
你可以通过以下命令快速启动你的 React 项目:
npx create-react-app my-app
将my-app替换为你希望的应用程序名称。一旦项目准备就绪,你可以进入my-app文件夹并启动它:
cd my-app
yarn start
就这样,你应该能在你的本地计算机上看到一个应用程序。现在你可以通过将我们的代码粘贴到你的项目中并本地编译来尝试源代码。
下载彩色图像
我们还提供了一个包含本书中使用的截图和图表的彩色图像的 PDF 文件。您可以在此处下载:static.packt-cdn.com/downloads/9781803235950_ColorImages.pdf。
使用的约定
本书使用了多种文本约定。
文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“元素可以像h1、一个div元素那样简单,也可以是一个执行不同操作的人工元素。”
代码块设置如下:
fetch('/giveMeANumber').then(res => {
ReactDOM.render(<Title />, rootEl)
})
当我们希望引起你对代码块中特定部分的注意时,相关的行或项目将被设置为粗体:
let c = 3
function add(a, b) {
console.log(a, b)
return a + b + c
}
粗体:表示新术语、重要单词或你在屏幕上看到的单词。例如,菜单或对话框中的单词会显示为粗体。以下是一个示例:“此标志可用于决定 UI 是否应显示注销或登录按钮。”
小贴士或重要注意事项
看起来像这样。
联系我们
我们读者的反馈总是受欢迎的。
在邮件主题中提及书籍标题,并发送至customercare@packtpub.com。
勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您向我们报告。请访问www.packtpub.com/support/errata并填写表格。
通过链接材料发送至copyright@packt.com。
如果你有兴趣成为作者:如果你在某个领域有专业知识,并且你感兴趣的是撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享你的想法
一旦你阅读了《正确设计 React Hooks》这本书,我们很乐意听听你的想法!请点击此处直接进入亚马逊评论页面 packt.link/r/1803235950/并为这本书分享你的反馈。
你的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。
第一章:介绍函数组件
在本章中,我们将首先简要介绍过去二十年中开发的UI组件的历史,并了解React如何使用UI组件来构建应用。你将学习函数组件是什么,以及它的 props 和基本的父子关系。然后,你将获得一些关于如何编写函数组件的技巧。最后,你将看到一个实际的函数组件示例,Nav。本章还包括附录部分的一个额外主题:React 支持多少种组件类型?
本章我们将涵盖以下主题:
-
UI组件的历史
-
使用组件构建应用
-
介绍函数组件
-
编写函数组件
-
函数组件的示例
-
问答
-
附录
UI 组件的历史
当我们对技术感到着迷时,观察它随着时间的推移缓慢演变也可能很有趣。在我们的案例中,是HTML。从表面上看,它似乎在过去 20 年里没有发生变化。你可以通过比较现在编写的典型网页代码与 20 年前编写的代码,并看到它们看起来非常相似,如果不是完全相同的话,来得到这个想法。
以下代码片段显示了典型的HTML页面代码的样子:
<HTML>
<head>
<meta charset="utf-8">
</head>
<style>
h1 { color: red; }
</style>
<script>
console.log('start...')
</script>
<body>
<h1>Hello World</h1>
</body>
</HTML>
我们这些在这个行业工作很长时间的人都知道,网络已经被重塑了几次。特别是,大量的努力被投入到如何生成前面的HTML。
网页工程师们试图将文件分成多个部分,包括HTML、JavaScript和CSS,然后在文件在屏幕上渲染时将其重新组合。他们还尝试在服务器上加载一个或两个部分,其余部分在客户端计算机上加载。他们还尝试了各种编译器或构建器,在源代码每次更改后自动生成文件。他们尝试了很多事情。实际上,关于HTML的几乎所有你能想到的事情在过去都尝试过几次,而且人们不会因为别人尝试过而停止尝试。从某种意义上说,网络技术时不时地被重新发明。
随着每天向网络添加大量新内容,工程师们发现HTML文件有点难以管理。一方面,需求是用户希望看到更多可操作的项目,并希望有更快的响应,另一方面,屏幕上的许多可操作项目给工程师管理工作量并维护代码库带来了挑战。
因此,工程师们一直在寻找更好的方法来组织HTML文件。如果这种组织方式得当,它可以帮助他们不被屏幕上众多元素所淹没。同时,良好的文件组织意味着可扩展的项目,因为团队可以将项目分解成更小的部分,并以分而治之的方式逐一工作。
让我们回顾一下使用 JavaScript 的技术是如何帮助这些主题的历史。我们将在这个对话中选择四种技术 – jQuery、Angular、React 和 LitElement。
jQuery
jQuery 是一个用于操作屏幕上 Document Object Model (DOM) 元素的库。它认识到直接与 DOM 一起工作的挑战,因此提供了一个实用层来简化查找、选择和操作 DOM 元素的语法。它于 2006 年开发,自那时以来已被数百万个网站使用。
关于 jQuery 的好处是,它可以通过使用著名的 $ 符号在它周围创建包装器来与现有的 HTML 一起工作,如下面的代码所示:
$(document).ready(function(){
$("button").click(function(){
$(this).css("background-color", "yellow");
$("#div3").fadeIn(3000);
$("#p1").css("color", "red")
.slideUp(2000)
.slideDown(2000);
});
});
function appendText() {
var txt1 = "<p>Text.</p>";
var txt2 = $("<p></p>").text("Text.");
var txt3 = document.createElement("p");
txt3.innerHTML = "Text.";
$("body").append(txt1, txt2, txt3);
}
jQuery 在改变元素的颜色、字体或任何运行时属性方面没有太多竞争。它使得将大量业务逻辑代码组织成存储在多个文件中的函数成为可能。它还通过当时的某个插件提供了一种模块化的方式来创建可重用的 UI 小部件。
在当时,强烈倾向于在 HTML 和 JavaScript 之间实现完全分离。当时,人们认为这种方式做事有助于提高生产力,因为处理网站样式和行为的人可以来自两个部门。主题化,这个词描述了将样式应用于网站的方式,正在变得流行,一些工作正在寻找能够使网站看起来像 Photoshop 设计的开发者。
Angular
Angular 是一个用于开发 Single-Page Application (SPA) 的网络框架。它由 Google 于 2010 年发明。在当时,它相当革命性,因为你可以用它来构建前端应用程序。这意味着在 Angular 中编写的代码可以在运行时接管 HTML 的主体,并对其中所有元素应用逻辑。所有代码都在浏览器级别运行,导致“前端”这个词开始在简历上出现。从那时起,网络开发者大致分为“后端”、“前端”和“全栈”(这意味着前端和后端)。
Angular 使用的代码继续通过附加额外的标签来构建在现有的 HTML 上,如下所示:
<body>
<div ng-app="myApp" ng-controller="myCtrl">
<p>Name: <input type="text" ng-model="name" /></p>
</div>
<script>
var app = angular.module('myApp', []);
app.controller('myCtrl', function($scope) {
$scope.name= "John";
});
</script>
</body>
Angular 引入的控制器和模块可以为具有独特作用域的 HTML 部分注入业务逻辑。Angular 默认支持组件和指令,这使得我们可以在单个文件中引用所有相关的 HTML 和 JavaScript(尽管 HTML 文件仍然需要单独编写):
function HeroListController($scope, $element, $attrs) {
var ctrl = this;
ctrl.updateHero = function(hero, prop, value) {
hero[prop] = value;
};
ctrl.deleteHero = function(hero) {
var idx = ctrl.list.indexOf(hero);
if (idx >= 0) {
ctrl.list.splice(idx, 1);
}
};
}
angular.module('heroApp').component('heroList', {
templateUrl: 'heroList.html',
controller: HeroListController
});
通过 Angular 创建的组件可以在之后在 HTML 文件中重用。
React
React,也称为 React.js,由 Facebook 开发并于 2013 年发布,是一个用于构建 UI 组件的 JavaScript 库。尽管它并没有被特别宣传为一个网络框架,但开发者们已经用它来构建单页或移动应用,特别是受到了初创公司的青睐。
当时的争议在于它如何处理 HTML 语句。它并没有将它们留在 HTML 文件中,而是实际上要求将它们移出,并放在组件的 render 函数下,如下所示:
<div id="root"></div>
<script type="text/babel">
class App extends React.Component {
render() {
return <h1>Hello World</h1>
}
}
ReactDOM.render(App, document.getElementById('root'));
</script>
这种独特的方法比 HTML 文件的完整性更倾向于组件设计。这几乎是第一次你可以在同一个文件下将 HTML 和 JavaScript 一起使用。我们在这里称之为 HTML,因为它看起来像 HTML,但实际上 React 创建了一个包装器,将 HTML 转换为 JavaScript 语句。
当 React 被引入时,它附带了一个类组件,并在 2015 年增加了对函数组件的支持,因此你可以将逻辑写在函数而不是类中:
<script type="text/babel">
const App = function() {
return <h1>Hello World</h1>
}
</script>
使用 React,HTML 文件不像以前那样经常被修改;事实上,它们根本就没有改变,因为所有的 HTML 内容都被重新定位到了 React 组件中。这种做法今天仍然可能引起争议,因为那些不关心 HTML 位置的人会很容易地接受,而那些关心传统 HTML 编写方式的人则会保持距离。这里也有一个心态的转变;使用 React,JavaScript 成为了网络开发的焦点。
LitElement
Polymer 由 Google 开发并于 2015 年发布,旨在使用网络组件构建网络应用。2018 年,Polymer 团队宣布任何未来的开发都将转移到 LitElement 以创建快速和轻量级的网络组件:
@customElement('my-element')
export class MyElement extends LitElement {
...
render() {
return html`
<h1>Hello, ${this.name}!</h1>
<button @click=${this._onClick}>
Click Count: ${this.count}
</button>
<slot></slot>
`;
}
}
React 和 LitElement 之间有很多相似之处,因为它允许你使用 render 函数定义一个类组件。LitElement 的独特之处在于,一旦元素被注册,它可以像 DOM 元素一样行为:
<body>
<h1>Hello World</h1>
<my-element name="abc">
<p>
Let's get started.
</p>
</my-element>
</body>
将 LitElement 集成到 HTML 中没有明显的入口点,因为它在使用之前不需要控制 body 元素。我们可以在其他地方设计该元素,当它被使用时,它更像是一个 h1 元素。因此,它在保持 HTML 文件完整性的同时,将额外的功能外包给其他可以设计的自定义元素。
LitElement 的目标是让网络组件在任何框架中的任何网页上都能工作。
20 年前,我们不知道网络会变成什么样。从对 jQuery、Angular、React 和 LitElement 的简要历史回顾中可以看出,一个拥有 UI 组件的想法已经出现。一个组件,就像一块乐高积木,可以做到以下事情:
-
将功能封装在内
-
可在其他地方重用
-
不会损害现有网站
因此,当我们使用组件时,它采用以下语法:
<component attr="Title">Hello World</component>
实际上,这与我们开始编写 HTML 的地方并没有太大的不同:
<h1 title="Title">Hello World</h1>
这里对组件有一个隐藏的要求。虽然组件可以单独设计,但最终它们必须组合起来以实现更高的目的,即完成网站的构建。因此,尽管每个组件都是原子的,但仍需要一个通信层来允许块之间进行通信。
只要组件正常工作并且它们之间有通信,应用程序就可以作为一个整体运行。这实际上是组件设计和构建网站时的一个假设。
那么,我们的书属于哪个类别呢?我们的书是关于在 React 下构建组件,特别是构建可以作为可重用块使用的智能组件,并且能够适应应用程序。我们在这里选择的技术是函数组件内部的 hooks。
在我们深入组件和 hooks 的细节之前,让我们先简要地看看组件是如何组合起来构建一个应用程序的。
使用组件构建应用程序
要开始构建应用程序,以下是一个你可以开始的 HTML 块:
<!doctype HTML>
<HTML lang="en">
<body>
<div id="root"></div>
</body>
</HTML>
现在,我们越来越多地使用 SPAs 来动态更新页面的一部分,这使得使用网站的感觉就像是一个原生应用程序。我们追求的是快速响应时间。JavaScript 是实现这个目标的语言,从显示用户界面到运行应用程序逻辑以及与网络服务器通信。
要添加逻辑,React 会接管 HTML 的一部分来启动一个组件:
<script>
const App = () => {
return <h1>Hello World.</h1>
}
const rootEl = document.getElementById("root")
ReactDOM.render(<App />, rootEl)
</script>
在前面的代码中,由 ReactDOM 提供的 render 函数接受两个输入参数,这两个参数是一个 React 元素和一个 DOM 元素 rootEl。rootEl 是你希望 React 渲染的地方,在我们的例子中,是一个带有 root ID 的 DOM 节点。React 在 rootEl 上渲染的内容可以在一个函数组件 App 中找到定义。
在 React 中区分 App 和 <App /> 是很重要的。App 是一个组件,必须有一个定义来描述它能做什么:
const App = () => {
return <h1>Hello World</h1>
}
<App /> 是 App 组件的一个实例。一个组件可以创建很多实例,这与大多数编程语言中类的实例非常相似。从组件中创建实例是可重用的第一步。
如果我们在浏览器中运行前面的代码,我们应该看到它显示以下 Hello World 标题:

图 1.1 – Hello World
Playground – Hello World
你可以自由地在这个例子上在线玩耍:codepen.io/windmaomao/pen/ExvYPEX。
要有一个完全功能的应用程序,通常我们需要多个页面。让我们看看第二个页面。
多页面
构建一个 "Hello World" 组件是第一步。但这样一个单独的组件是如何支持多个页面,以便我们可以从一个页面导航到另一个页面的呢?
假设我们有两个页面,都在组件中定义,分别是 Home 和 Product:
const Home = () => {
return <h1>Home Page</h1>
}
const Product = () => {
return <h1>Product Page</h1>
}
要显示 Home 或 Product,我们可以创建一个辅助组件:
const Route = ({ home }) => {
return home ? <Home /> : <Product />
}
前面的 Route 组件略有不同;它携带一个从函数定义中传入的输入参数 home。home 包含一个布尔值,根据它,Route 组件可以在显示 <Home /> 或 <Product /> 之间切换。
现在的问题是要确定 App 中的 home 的值:
const App = () => {
const home = true
return <Route home={home} />
}
在前面的代码中,App 组件被修改以包含一个 home 变量,该变量被传递给 Route 组件。
你可能已经注意到,当前的代码只会显示首页,因为我们已经将 home 设置为 true。不用担心,整本书都是关于教你如何设置 home 值的。现在,只需想象根据用户的鼠标点击,home 的值会从 true 切换到 false,暂时你可以手动更改 home 的值。
随着 App 组件下添加越来越多的组件以及这种路由机制,App 组件可以变得更大。这也是为什么在 React 应用程序中第一个组件被命名为 App 的部分原因。虽然你可以随意命名,但请记住,第一个字母要大写。
操场 – 首页
欢迎在线尝试这个示例:codepen.io/windmaomao/pen/porzgOy。
现在,我们可以看到 React 是如何组合一个应用的,所以无需多言,让我们直接进入 React 的组件。
React 主要支持两种组件类型——类组件和函数组件。本书将专注于函数组件。如果你对其他组件类型感兴趣,请查看本章末尾的 附录 A – React 支持多少种组件类型? 部分。
介绍函数组件
“这种模式旨在鼓励创建这些简单的组件,这些组件应该构成你应用程序的大部分。” – Sophie Alpert
在本节中,我们将向您介绍函数组件。当函数组件首次在 2015 年 8 月的 React 0.14 版本中引入时,它被命名为无状态纯函数:
function StatelessComponent(props) {
return <div>{props.name}</div>
}
主要目的是“无状态的纯函数组件给我们提供了更多的性能优化机会。”
默认情况下,没有状态的函数组件被设计成以下函数形式:

图 1.2 – 函数组件定义
我们将在下一小节中详细探讨函数组件的各个部分。
函数属性
这个函数的输入参数被称为道具。道具采用一个对象格式,我们可以定义任何属性。每个属性都被称为道具。例如,图 1.2 定义了一个带有 text 道具的 Title 组件。
因为道具是对象,所以没有限制可以定义多少个道具在该对象下:
const Title = ({ name, onChange, on, url }) => {...}
道具(prop)的工作,类似于输入参数,是将一个值传递给函数。在道具的类型上也没有限制。由于每个道具都是对象的属性,它可以是一个字符串、一个数字、一个对象、一个函数、一个数组,或者任何可以用 JavaScript 表达式赋值的任何东西,如下面的例子所示:
const Title = ({ obj }) => {
return <h1>{obj.text}</h1>
}
const Title = ({ fn }) => {
return <h1>{fn()}</h1>
}
在前面的代码中,第一种情况传递了一个带有 text 属性的 obj 道具,而第二种情况传递了一个在内部调用的 fn 道具。
一旦定义了一个函数组件,就可以通过其实例在其他地方多次使用:
const App = () => {
return <Title text="Hello World" />
}
在前面的代码中,在 App 组件的定义中使用了 Title 组件实例。
当 App 组件更新时,一个字符串 "Hello World" 被分配给 Title 组件的 text 道具。Title 组件的使用让我们想起了 HTML 语句,而 text 道具让我们想起了 DOM 元素的属性。
我们实际上在开始时也看到了 App 组件实例的使用:
ReactDOM.render(<App />, rootEl)
简而言之,你可以定义一个组件,但要看到它在屏幕上显示的内容,需要使用其实例。
子道具
函数组件的所有道具都应该像输入参数一样明确定义。但是,有一个值得早期了解的道具,它并不遵循这个规则。这被称为 children 道具:
const App = () => {
return (
<Title>
Hello World
</Title>
)
}
你可能在使用前面的代码时并不知道 "Hello World" 字符串是如何被连接到 Title 组件的。有趣的是,这个字符串是通过 children 道具连接到组件的。当我们到达 Title 组件的定义时,这会变得清楚:
const Title = ({ children }) => {
return <h1>{children}</h1>
}
实际上,App 组件在定义之前将 "Hello World" 分配给 children 道具,然后调用 Title 组件实例。你可能想知道如果我们忘记在定义 Title 组件时包含 children 道具会发生什么:
const Title = () => {
return <h1>Haha, you got me</h1>
}
在那种情况下,"Hello World" 被忽略,App 组件简化为以下情况:
const App = () => {
return <Title />
}
显然,这不是预期的,因为如果你在组件下放置子元素,那么在函数定义中必须明确定义 children 道具。这意味着 children 道具仍然需要在函数接口上明确写出。
事实上,children 道具是组件可以嵌套在其他组件下的原因。React 使用这个 children 机制来重现 HTML 通常是如何编写的。
父亲和子组件
在 React 中,props 是组件之间通信的机制。我们可以通过使用两个通常参与通信的组件——父组件和子组件——来概括这个想法,正如我们在 App 和 Title 中已经看到的那样:
const Title = ({ text }) => {
return <h1>{text}</h1>
}
const App = ({ flag }) => {
const text = flag ? "Hello" : "World"
return <Title text={text} />
}
在前面的例子中,Title 组件接受 text 作为其 props 之一。如果标志 flag 为 true,则 App 组件将 "Hello" 文本发送到 Title 组件,否则,它将 "World" 文本发送到 Title。
谁将 flag 信息发送到 App 组件?那将是 App 的父组件。这可以很容易地构建成一个树,其中我们有分支和子分支,并且它延伸到末端的叶子。请注意,这种结构完全是通过在每个节点(组件)上使用 props 来实现的。
一旦信息进入一个组件,prop 就将其值绑定到一个局部作用域变量上。从那时起,管理局部变量的任务就落在了子组件身上。它可以相当灵活地使用,但有一个限制。它不应该被改变!或者,如果你改变了它,这个变化就不会反映在父组件中。这种行为与我们在使用带有输入参数和其内部作用域的函数时的行为相同。信息传递是一张单程票。
现在出现了一个大问题。如果我们想反映子组件对父组件所做的更改,怎么办?如何让一张单程票带回来信息?
这也是通过一个 prop 来实现的。正如我提到的,prop 可以采用任何格式,因此我们可以使用一个函数 prop:
const Child = ({ change }) => {
const onChange = () => {
change()
}
return <input onChange={onChange} />
}
const Parent = () => {
const change = () => {
console.log("child notify me")
}
return <Child change={change} />
}
在前面的代码中,我们通过 change prop 发送了在 Parent 中定义的函数。在 Child 组件内部,当用户开始在 input 框中输入任何字符时,它会触发一个 onChange 事件,我们可以在其中调用 change 函数。每次发生这种情况时,你都会在 Parent 组件中看到child notify me的消息。
实质上,这种技术就是我们所说的 JavaScript 中的回调。父组件通过使用回调函数提供了一种通知更改的机制。一旦创建了回调函数,它就可以发送给任何子组件,以获得通知父组件的能力。
在 React 中的典型父/子关系中,建议子组件不应该更改 prop 的值。相反,应该通过函数 prop 来完成。当将 React 与其他库进行比较时,我们用“单程票”来指代这种行为。在 React 社区中,我们很少使用这个词,因为这是从其诞生起就设计好的行为。
既然我们已经知道了函数组件的定义以及 props 在构建组件中的作用,让我们看看通常我们是如何编写一个函数组件的。
编写函数组件
函数,代表一个组件,定义了屏幕上要更新的内容。它返回一个由一些类似 HTML 的代码组成的值。你应该非常熟悉 <ul> 和 <li> 等元素;React 还允许在这些元素下添加 JavaScript 表达式。当它们一起使用时,需要将 JavaScript 表达式包裹在一对括号 {} 中。这个表达式的任务是提供动态 HTML 内容。
例如,如果我们有一个 text 变量并希望显示它,我们可以这样做:
const Title = () => {
const text = "Hello World1"
return <h1>{text}</h1>
}
或者,如果文本是从函数返回的,我们可以这样做:
const Title = () => {
const fn = () => "Hello World"
return <h1>{fn()}</h1>
}
我们知道这个 JavaScript 表达式是填充在 children 属性的位置。
子元素不一定要是一个单独的元素;它也可以是一个元素数组:
const Title = () => {
const arr = ['Apple', 'Orange']
return (
<ul>
{arr.map((v) => (
<li>{v}</li>
))}
</ul>
)
}
在前面的代码中,这似乎有点复杂,所以让我们先看看代码试图通过查看结果来实现什么:
return (
<ul>
{[<li>Apple</li>, <li>Orange</li>]}
</ul>
)
基本上,它想要输出两个 li 元素。为了达到这个目的,我们使用一个 JavaScript 表达式创建一个包含两个元素的数组。一旦它变成了括号 {} 包裹的 JavaScript 表达式,任何 JavaScript 中的内容都可以被重构和编程,就像我们想要的那样。我们可以使用 arr.map 来形成这个数组:
{['Apple', 'Orange'].map(v => (
<li>{v}</li>
))}
代码重构做得很好!
在前面的陈述中展示了如此多的不同括号,包括 {}, [] 和 ()。因此,请随意花一点时间来理解每一对括号的作用。难以相信在 React 中写作的一个挑战就是括号。
这是一个很好的例子,展示了当你将事物包裹在 JavaScript 表达式中时,它们可以被重构,就像我们通常编程那样。在这种情况下,由于 arr 是一个常数,不需要在 Title 组件内部定义,我们可以将 arr 提取到函数外部:
const arr = ['Apple', 'Orange']
const Title = () => {
return (
<ul>
{arr.map((v) => (
<li>{v}</li>
))}
</ul>
)
}
一旦你习惯了使用 JavaScript 表达式和类似 HTML 的代码,迟早你会发展出自己的编程风格,因为在这个练习背后是 JavaScript 语言。
现在你已经了解了这个过程,让我们一起来编写一个示例代码。
函数组件示例
一个网站由页面组成,其中每个页面包含一个侧边栏、一个页眉、一个内容区域和一个页脚。所有这些都可以用组件来建模。布局组件可以位于树的顶部。当你放大时,你会发现在其内部有子结构。就像蜘蛛网(参见 图 1.3)一样,树结构从外层级级联到内层级。

图 1.3 – 网络应用程序布局
作为UI工程师,我们专注于每个组件的设计。此外,我们非常关注组件之间的关系。我们想知道Title是否在主要内容或侧边栏内部构建。我们想知道是否需要多个页面共享标题。你将开始掌握在组件树之间导航的技能。
假设我们想在页面顶部显示一组导航链接。如果需要,每个链接都可以被禁用。对于启用的链接,我们可以点击它导航到相应的URL。参见图 1.4:
![Figure 1.4 – Nav component]
![Figure 1.04_B17963.jpg]
图 1.4 – Nav 组件
导航链接可以预先定义在一个链接对象的数组中:
const menus = [
{ key: 'home', label: 'Home' },
{ key: 'product', label: 'Product' },
{ key: 'about', label: 'About' },
{ key: 'secure', label: 'Secure', disabled: true },
]
在前面的每个链接中,key属性提供了一个标识符,label属性指定了显示的标题,而disabled属性表示用户是否可以点击它。
我们还希望在当前选中的链接下方显示一条线。基于这些要求,我们提出了带有selected和items属性的实现:
const Nav = ({ selected, items }) => {
const isActive = item => item.key === selected
const onClick = item => () => {
window.location.href = item.url
}
return ...
}
在前面的Nav组件中,items属性包含链接列表,而selected属性包含当前选中项的键。Nav组件的职责是显示列表:
return (
<ul>
{items.map(item => (
<li
key={item.key}
className={isActive(item) ? 'active' : ''}
>
<button
disabled={item.disabled}
onClick={onClick}
>
{item.label}
</button>
</li>
))}
</ul>
)
在前面的return语句中,items通过循环逐个迭代,并使用ul/li结构显示链接。每个链接都显示为一个支持disabled属性的按钮。如果它是当前选中的链接,它还会标记链接的CSS类为active。
注意每个项目中的key属性。这个属性对于React来说,是必须的,因为它能知道列表中每个li元素的位置。有了提供的唯一标识符,React可以快速找到正确的元素来执行比较和更新屏幕。当返回一个元素数组时,key是一个必备的属性。
操场 – Nav 组件
欢迎在线尝试这个示例:codepen.io/windmaomao/pen/porzQjV。
现在,我们可以使用以下行显示Nav。哇哦:
<Nav items={menus} selected="home" />
为了使每个菜单项易于开发和维护,我们可以提取行以形成一个单独的组件:
const NavItem = ({
label, active, disabled, onClick
}) => (
<li className={active ? 'active' : ''}>
<button disabled={disabled} onClick={onClick}>
{label}
</button>
</li>
)
在前面的代码中,创建了一个NavItem组件来接受label、active、disabled和onClick属性。我们不需要过度思考这些属性名,因为它们自然地来源于前面的Nav组件。我们可以将NavItem重新插入到Nav中:
const Nav = ({ selected, items }) => {
const isActive = item => item.key === selected
const onClick = item => () => {
window.location.href = item.url
}
return (
<ul>
{items.map(item => (
<NavItem
key={item.key}
label={item.label}
disabled={item.disabled}
active={isActive(item)}
onClick={onClick(item)}
/>
))}
</ul>
)
}
这种重构练习相当常见且有效。这样,Nav和NavItem组件在未来的维护中都会变得更加容易。
摘要
在本章中,我们首先通过查看四个库——jQuery、Angular、React和LitElement——来回顾 UI 组件的历史,以了解组件的概念以及组件是如何组合在一起来构建应用的。然后,我们学习了函数组件是什么,以及对其属性和父子关系的介绍。接着,我们学习了如何一般性地编写函数组件,最后我们逐步构建了一个Nav组件。
在下一章中,我们将从头开始构建函数组件的状态,并看看动作如何从中受益。
问题与答案
这里有一些问题和答案来刷新你的知识:
-
什么是函数组件?
函数组件是一个函数,它以属性作为输入参数,并返回元素。对于一个
App组件,我们可以通过其实例形式<App />来显示它。构建一个应用,就是将一个组件作为子组件放在另一个组件下面,并不断优化这个过程,直到我们最终得到一个组件树。 -
你该如何编写一个函数组件?
要熟练编写函数组件的方法与编写函数的方法非常相似。问问自己组件的属性规范是什么,以及返回给显示的内容是什么。在一个典型的应用中,大约有一半的组件是为业务需求设计的,但另一半通常来自代码重构。对函数式编程(FP)的研究通常对你有益,并能将你的 UI 技能提升到下一个层次。
附录
附录 A – React 支持多少种组件类型?
在发布的React文档中,它支持两种组件类型。一种是一个函数组件,另一种是一个类组件。React从一开始就支持类组件:
class ClassComponent extends React.Component {
render() {
const { name } = this.props;
return <h1>Hello, { name }</h1>;
}
}
尽管类组件的render函数看起来与函数组件返回的内容非常相似,而且大多数时候我们可以在它们之间进行转换,但在React的更新过程中,类组件和函数组件的处理方式是不同的。因此,这本书有意避免提及类组件,以免混淆任何新接触React的新手。
通常情况下,函数组件可以写得更短、更简单,在开发和测试方面也更加容易,因为它只有简单的输入和输出。此外,它没有this关键字,这可能会让新开发者甚至有时是资深开发者感到畏惧。然而,使用函数组件的缺点是它在编程世界中相对较新,而且从面向对象编程(OOP)到函数式编程(FP)的心态转变可能会让你感到压力,如果你没有做好准备的话。更不用说,作为新事物,可能存在不同的方法,我们需要在解决旧问题之前学习和吸收这些方法。
除了类和函数组件之外,内部实际上React支持更多组件类型,如下例所示:
import { memo } from 'react'
const Title = memo(() => <h1>Hello</h1>)
const App = () => <Title />
当memo函数应用于Title组件时,它创建了一个具有组件类型MemoComponent的组件。我们不需要深入了解这些组件类型的细节,但只需知道,每个组件类型在更新到屏幕时都会获得自己的更新算法。
第二章:在函数中构建状态
在上一章中,我们学习了如何用 React 编写函数组件。在本章中,我们将在函数组件中构建一个特殊变量,称为状态。我们将看到状态能给我们带来什么好处,包括请求新的更新、使变量持久化、监听值的变化,以及在挂载时执行任务。我们还将看到一个将状态应用于单页应用程序的例子。最后,我们将仔细研究状态在 UI 中扮演的角色。
本章我们将涵盖以下主题:
-
在函数组件中构建状态
-
将状态应用于单页应用程序
-
状态如何与 UI 一起工作
-
问题和答案
技术要求
在开始之前,我想让你了解一下时间线草图:
|--x---x---x-x--x--x------> user event
时间线草图是一种独特的图表类型,它显示了一个时间段内的一系列事件。左侧的条(|)代表时间起点,表示第一次更新。水平破折号(-)随时间从左向右移动,并在末尾有一个箭头 >。每个字母或数字,如 x,表示在这个时间线中发生的一个事件。在这本书中,我们将使用时间线草图来更好地理解在时间线上同时发生多个事件的情况。
在函数组件中构建状态
当你访问一个典型的网页时,它会要求你输入用户名和密码。登录后,它会按时间顺序显示网站提供的内容,如博客、推文或视频。你可以对它们进行投票并在那里发表评论——这是当今非常典型的网络体验。
当你作为一个用户浏览这样的网站时,你不会过多地思考任何动作是如何实现的,也不会关心每个动作被触发的顺序。然而,当你自己构建网站时,每个动作以及每个动作被触发的时机开始变得重要。
当用户点击按钮、悬停在图标上、向下滚动段落、在键盘上输入等动作时,会触发动作处理程序。用户事件和动作处理程序之间的一种典型关系如下所示:
|--x---x---x-x--x--x------> user event
|--a---a---a-a--a--a------> action handler
在前面的草图中,基本上,user event 系列中的一个 x 后面跟着 user event 系列中的一个 a。基于此,我们可以开始处理用户动作。
让我们转向一个包含按钮的 "Hello World" Title 组件。每次我们点击按钮,计数器就会增加一,并附加在 Hello World+ 之后,如图 图 2.1 所示:

图 2.1 – 无状态的 Hello World
为了实现这一点,我们从一个初始化为 0 的 count 变量开始:
function Title() {
let count = 0
const onClick = () => {
count = count + 1
}
return (
<>
<button onClick={onClick}>+</button>
<h1>Hello World+{count}</h1>
</>
)
}
在前面的 Title 组件中,用户点击的响应是通过一个 React 事件处理程序 onClick 实现的,该处理程序连接到一个 button 元素。
React事件处理器的编写方式略不同于DOM事件处理器。你可以从onClick驼峰命名法中看出,而不是onclick小写名称。React事件是一个跨浏览器的合成事件,它是对浏览器原生事件的包装。在这本书中,我们希望它们的行为完全相同。
多亏了JavaScript闭包,我们可以在事件处理程序中直接访问任何组件变量。count变量不需要作为函数输入参数传递给onClick以供访问。
如果我们运行代码,我们预计标题会显示console.log到两个位置。
一个放在count = count + 1之前,以确认增量后的count值。另一个放在return语句之前,以确认当Title组件更新时的更新后的count值。它们在以下代码中标记为➀和➁:
function Title() {
let count = 0
const onClick = () => {
console.log('clicked', count) ➀
count = count + 1
}
console.log('updated', count) ➁
return ...
}
放置这两个日志后,我们可以重新运行代码并生成一个新的时间线草图:
|----0--1-2--3-4----5------> clicked ➀
0--------------------------> updated ➁
从前面的打印输出中,➀处的clicked系列显示了按钮点击时的count数字,并且它被点击了六次。让我们转向另一个日志,➁处的updated系列;count值更新了一次,值为0,这解释了为什么显示仍然是Hello World+0。
只有在最初打印一次的updated系列表明在第一个更新之后没有更多的更新。这是一个相当大的发现。如果没有更多的更新,我们怎么能期待在屏幕上看到变化呢?
操场 – 无状态
您可以免费在此在线尝试此示例:codepen.io/windmaomao/pen/jOLNXzO.
正如你可能已经意识到的,点击后我们需要请求一个新的更新。
请求新的更新
为了进行更新,目前,我们可以借用React提供的render函数,因为我们已经用它来更新了rootEl元素:
ReactDOM.render(<Title />, rootEl)
让我们花一分钟时间看看React通常是如何更新屏幕的(见图 2.2)。涉及更新的细节可能相当复杂;现在,让我们将其视为一个黑盒。我们将在本书的后面部分深入了解:

图 2.2 – React 更新
当一个应用启动时,它会进入一个更新。这个第一个更新有点特殊。因为所有 DOM 元素都需要被创建,所以我们把这个更新称为挂载。
需要知道的重要一点是,除非请求,否则不会到来新的更新,就像我们调用render函数一样。当人们第一次来到 React 时,他们可能会认为它像游戏引擎一样工作。
例如,一个游戏引擎会在幕后每 1/60 秒请求一个新的更新。但React并不这样做!相反,开发者应该精确控制何时请求新的更新。而且,大多数时候,频率要低得多,这更多或更少地取决于用户在网站上如何快速行动。
所以,为了将新的count值带到屏幕上,我们需要手动请求另一个更新;如果我们借用render,我们可以在count递增后使用它:
const onClick = () => {
console.log('clicked', count) ➀
count = count + 1
ReactDOM.render(<Title />, rootEl)
}
如果我们在前面的代码中添加render,时间线草图将变为以下内容:
|----0--0-0--0-0----0------> clicked ➀
0----0--0-0--0-0----0------> updated ➁
让我们惊讶的是,显示的所有数字都是0。查看updated系列在➁处,注意我们得到了七次打印,这意味着我们在第一次更新之上又进行了六次更新。然而,clicked系列在➀处显示,count值变为了0并停止了进一步的递增。奇怪吗?!
“count值怎么会卡在0呢?新的更新肯定发生了某些事情,但render函数不可能就是那个将count值重置回0的函数,对吧?”
重要的是要知道,当调用render函数并更新函数组件时,定义组件的函数会被调用,如图图 2.3所示:

图 2.3 – 函数组件的 React 渲染
带着这个知识,让我们再次看看Title函数:
const Title = () => {
let count = 0
// omitting the onClick statement
console.log('updated', count) ➁
// omitting the return statement
}
在前面的代码中,我们故意省略了onClick和return语句,以使代码更简洁。剩下的就是一个let count = 0声明语句。在每次更新中,Title函数都会被调用,从而创建一个新的函数作用域。在这个作用域内,有一个局部创建的count变量值,用来保存0这个数字。所以这段代码看起来似乎没有做什么。
现在不难看出为什么count值保持在0,不是吗?无论我们是否添加了onClick或return语句的逻辑,每次更新后,整个函数作用域都会获得一个新的作用域,其中count值被声明并设置为0。这解释了为什么console.log语句后面跟着打印的0。
这实际上就是为什么函数组件在最初被引入到React时被命名为无状态函数的原因。这里的“无状态”指的是函数组件不能携带或共享值到另一个更新。简单来说,函数在每次更新中都会以相同的输出重新运行。
好的,现在我们理解了问题。所以,这让我们考虑将count值保存在某个地方,并使其在另一个更新中持久化。
使值持久化
JavaScript支持函数作用域:在函数内部定义的变量不能从函数外部访问,因此每个函数都有自己的作用域。如果你多次调用一个函数,会有多个作用域。但无论我们调用多少次,它都不会创建不同的输出,就像电影* Groundhog Day*中发生的那样。
注意
电影* Groundhog Day*是一部 1993 年的奇幻喜剧片,其中菲尔每天醒来发现自己经历了前一天的事件重复发生,并相信他在经历似曾相识的感觉。
对于我们的count值,我们可以在图 2.4中可视化两次更新在两个不同作用域中发生的情况:

图 2.4 – 两次更新中的两个函数作用域
幸运的是,JavaScript以一种方式支持函数作用域,它可以访问定义在其定义作用域内的所有变量。在我们的情况下,如果一个变量在Title函数外部定义,我们可以在Title函数内部访问这个变量,因为这个值现在在多个Title函数之间是共享的。
分享的最简单方式是创建一个全局变量,因为全局变量位于JavaScript代码的最外层作用域,因此可以在任何函数内部访问。
注意
不要被本章中使用的全局变量吓倒。在第三章“Hooking into React”中,我们将完善这种方法,并看看React如何在一个更好的位置定义变量。
这样,每个局部count值都可以设置/获取这个全局count值,如图 2.5 所示:

图 2.5 – 两次更新之间的共享值
好吧,有了这个新的全局变量想法,让我们看看我们是否可以摆脱我们的土拨鼠日情况:
let m = undefined
function _getM(initialValue) {
if (m === undefined) {
m = initialValue
}
return m
}
function _setM(value) {
m = value
ReactDOM.render(<Title />, rootEl)
}
在前面的代码中,分配了一个全局变量m,它附带_getM获取器和_setM设置器方法。_getM函数返回值但设置第一次的初始值。_setM函数设置值并请求新的更新。让我们将_getM和_setM应用于我们的Title组件:
function Title() {
let count = _getM(0)
const onClick = () => {
console.log('clicked', count) ➀
count = count + 1
_setM(count)
}
console.log('updated', count) ➁
return ...
}
在前面的修改后的Title组件中,所有更新中的count变量都通过_getM和_setM的帮助相互链接。如果我们重新运行代码,我们可以看到以下时间线草图:
|----0--1-2--3-4----5------> clicked ➀
0----1--2-3--4-5----6------> updated ➁
哇!第一次点击后屏幕变为Hello World+1,并且随着更多点击进一步增加,如图 2.6 所示:

图 2.6 – 使用状态的 Hello World 计数器
恭喜!你刚刚在函数组件中创建了一个状态。
操场 – 计数状态
欢迎在线尝试这个例子:codepen.io/windmaomao/pen/KKvPJdg。
“状态”一词指的是它对所有更新都是持久的。为了方便起见,我们还在更改状态并随后请求新的更新以反映屏幕上的更改。
因此,现在我们知道了如何使用状态来处理用户操作。让我们看看我们是否可以将这个想法进一步扩展以支持多个状态而不是一个状态。
支持多个状态
能够在函数组件内建立持久状态真是太好了。但我们想要更多这样的状态。一个应用通常包含很多按钮、开关和可操作项;每个都需要持久状态。因此,支持同一应用中的多种状态是必须的。
假设我们需要两个按钮,每个按钮都需要由一个状态驱动。让我们扩展我们从单一状态中学到的知识:
const Title = () => {
let countH = _getM(0)
let countW = _getM(0)
const onClickH = () => {
countH = countH + 1
_setM(countH)
}
const onClickW = () => {
countW = countW + 1
_setM(countW)
}
return (
<>
<button onClick={onClickH}>+</button>
<h1>Hello+{countH}</h1>
<button onClick={onClickW}>+</button>
<h1>World+{countW}</h1>
</>
)
}
在前面的代码中,我们首先创建了两个按钮,一个具有onC1ickH和onClickW,分别。我们还对它们应用了_getM和_setM,并在以下时间轴草图上安装了一些日志来帮助调试:
|----0--1-2----------------> clickedH
|------------3-4----5------> clickedW
0----1--2-3--4-5----6------> updatedH
0----1--2-3--4-5----6------> updatedW
从前面的草图来看,我们点击了updatedH和updatedW系列。然而,这两个系列似乎是不可分割且同步的,这意味着点击一个按钮会同时增加两个值!
游戏场 – 链接状态
欢迎在线尝试此示例:codepen.io/windmaomao/pen/qBXWgay.
好吧,找出我们实际上将相同状态连接到两个按钮上的错误并不难;难怪它们会同时更新:
let countH = _getM(0)
let countW = _getM(0)
虽然这不是我们想要达到的效果,但看到两个按钮共享一个状态是很有趣的。从视觉上看,我们链接了两个按钮;点击一个会触发另一个的点击。
那么,如果我们想要有两个独立的状态,每个控制一个按钮,我们能做什么呢?嗯,我们只需添加另一个状态。这次,我们希望使用列表来更通用地存储任意数量的状态。
在 JavaScript 中跟踪一系列值的方法有很多;其中一种方法是在对象中使用键/值对:
let states = {}
function _getM2(initialValue, key) {
if (states[key] === undefined) {
states[key] = initialValue
}
return states[key]
}
function _setM2(v, key) {
states[key] = v
ReactDOM.render(<Title />, rootEl)
}
在前面的代码中,我们声明了一个states对象来存储所有状态值。_getM2和_setM2函数几乎与之前我们制作的单一值版本相似,但这次我们是在states[key]下存储每个状态而不是m,因此需要一个key来识别每个状态。有了这个变化,让我们修改Title组件:
function Title() {
let countH = _getM2(0, 'H')
let countW = _getM2(0, 'W')
const onClickH = () => {
console.log('clickedH', countH)
countH = countH + 1
_setM2(countH, 'H')
}
const onClickW = () => {
console.log('clickedW', countW)
countW = countW + 1
_setM2(countW, 'W')
}
console.log('updatedH', countH)
console.log('updatedW', countW)
return ...
}
在前面的修改版本中,我们给两个状态分别赋予H和W作为键。当涉及到状态时,我们需要这个键来进行set和get操作。重新运行代码并查看时间轴草图:
|----0--1-2----------------> clickedH
|------------0-1----2------> clickedW
0----1--2-3--3-3----3------> updatedH
0----0--0-0--1-2----3------> updatedW
再次点击,countH和countW实际上是分别增加的,正如你在updatedH和updatedW系列中看到的那样。
当我们点击“World”按钮时,countH在第一次点击后保持在3。这正是我们想要的结果,两个独立的状态,如图 2.7 所示:

图 2.7 – 具有两种状态的 Hello 和 World 按钮
游戏场 – 多种状态
欢迎在线尝试此示例:codepen.io/windmaomao/pen/dyzbaVr.
我们迄今为止构建的状态请求新的更新。这是在函数组件中使用持久性的一个好例子;因为持久性实际上是一个非常通用的功能,它应该被用于许多不同的目的。那么,我们还能用它做什么呢?让我们看看状态的另一种用法。
监听值变化
你可能会想知道为什么我们需要监听值变化。难道不是开发者控制值的改变吗?就像前面的例子一样,我们使用事件处理器来改变计数器。在这种情况下,我们知道值何时被更改。
对于这个案例来说,这是真的,但还有其他情况。你可能会通过属性将值发送到子组件,或者可能有两个组件同时接触一个值。在这两种情况下,你可能会失去跟踪值变化的那一刻,但你仍然想在值变化时执行操作。这意味着你想要有监听值变化的能力。让我们设置一个例子来演示这一点。
假设在我们的 count 变化中,我们想知道这个值是否最近被更改过:
function Changed({ count }) {
let flag = 'N'
return <span>{flag}</span>
}
在前面的 Changed 组件中,有一个 count 属性是从其父组件发送的,比如说任何 Y 或 N,这取决于 count 值是否已更改。我们可以在 Title 组件中使用这个 Changed 组件:
function Title() {
...
return (
<>
<button onClick={onClickH}>+</button>
<h1>Hello+{countH}</h1>
<Changed count={countH} />
<button onClick={onClickW}>+</button>
<h1>World+{countW}</h1>
</>
)
}
注意,在前面的代码中,我们在两个按钮之间添加了 Changed 组件,我们想要看到的是当我们点击 Changed 组件时显示 Y,当我们点击 World 按钮时显示 N。本质上,我们想知道变化是否来自 Hello 按钮。但当我们运行代码时,在时间轴草图上我们得到了以下结果:
0----1--2-3--3-3----3------> updatedH
0----0--0-0--1-2----3------> updatedW
N----N--N-N--N-N----N------> Changed flag
从前面的草图可以看出,无论哪个按钮被点击,Changed flag 系列中显示的 flag 都是 N。这并不令人惊讶,因为你可能已经注意到 Changed 组件内部的 flag 被固定在 N,所以它不会按我们想要的方式工作。但我们之所以在那里写 N 是因为我们不知道该写什么来翻转 flag。
当 countH 值,如在 updatedH 系列中,增加到 3。同样,当 countW 值,如在 updatedW 系列中,增加到 3。然而,请注意,随着 countW 值的增加,countH 值也会被打印出来;参见 updatedH 系列中的 3-3-3。
这表明对于每次更新,return 语句下的每个元素都会被更新。countW 或 countH 发生变化;这导致 Title 组件的新更新,从而更新所有 button 和 h1 元素。同样适用于 Changed 组件;无论哪个按钮发生变化,都会调用 Changed 函数。因此,我们无法确定 Changed 组件的更新是由于 Hello 按钮还是 World 按钮。
如果我们在 Changed 组件下打印出 count 属性,它看起来将与 updatedH 系列中的相同:
0----1--2-3--3-3----3------> count
观察前面的 count 值,为了生成是否从上一个值变化的变化 flag,我们需要再次使值持久化——在这种情况下,是为了获取上一个值。例如,0 到 1 是一个变化,但 3 到 3 并不是。
好的,为了将这个想法付诸实践,让我们借用状态方法,但这次将其应用于 prev 值:
let prev
function _onM(callback, value) {
if (value === prev) return
callback()
prev = value
}
在前面的代码中,我们分配了一个 prev 全局变量和一个 _onM 工具函数。onM 函数旨在在 value 发生变化时运行 callback 函数。它首先检查 value 是否等于 prev 值。如果没有变化,则返回。但如果确实有变化,则调用 callback 函数,并将当前 value 替换为 prev 值。让我们将这个 _onM 函数应用到 Changed 组件上:
function Changed({ count }) {
let flag = 'N'
_onM(() => { flag = 'Y' }, count)
return <span>{flag}</span>
}
在进行上述更改后,我们重新运行代码并查看更新的时间线草图:
0----1--2-3--3-3----3------> updatedH
0----0--0-0--1-2----3------> updatedW
Y----Y--Y-Y--N-N----N------> Changed flag
有趣的是,当我们点击 Y 时,当我们点击 N 时,如 Figure 2.8 所示:
![Figure 2.8 – 监听值变化
![Figure 2.08_B17963.jpg]
Figure 2.8 – 监听值变化
太棒了!请注意,在 Changed flag 系列中,安装时的第一个 Y,这是 countH 从 undefined 变为 0 的时候。请在此处做笔记;我们将在下一节讨论它。
Playground – 监听状态变化
欢迎在线尝试这个示例:codepen.io/windmaomao/pen/MWvgxLR。
能够监听值的变化非常有用,因为它为我们提供了执行任务的另一途径。没有它,我们必须依赖于事件处理器,这通常是由用户操作驱动的。有了 _onM,我们可以在值变化时执行任务,这个变化可能来自任何其他过程。
在监听值变化时,存在一个挂载时刻。这意味着我们可以因为这个原因在挂载时执行任务。让我们更仔细地看看它。
挂载时执行任务
组件根据业务需求的出现和消失进行挂载和卸载。在挂载时,通常想要执行一些任务,比如初始化一些变量、计算一些公式,或者调用 API 从互联网上获取一些资源。让我们用一个 API 调用作为例子。
假设需要从名为 /giveMeANumber 的在线服务中获取 count 值。当这个获取操作成功返回时,我们希望将变化反映到屏幕上:
fetch('/giveMeANumber').then(res => {
ReactDOM.render(<Title />, rootEl)
})
前面的代码是我们想要做的;然而,我们立即遇到了一个技术问题。尽管可以请求新的更新,但我们如何将返回的数据发送到 Title 组件?
也许我们可以在Title组件上设置一个 prop 来发送它。然而,这样做将需要我们更改组件接口。由于我们已经有了用于发出新更新的状态,让我们尝试这种方法:
fetch('./giveMeANumber').then(res => {
_setM(res.data)
})
function Title() => {
const count = _getM("")
return <h1>{count}</h1>
}
在前面的代码中,通过在获取返回后使用_setM,我们可以使用接收到的res.data更新状态,并在之后请求新的更新。新的更新调用Title并通过_getM从状态中读取最新的count。
目前,我们定义的fetch函数与Title组件平行,但这并不是正确的位置,因为我们只想在挂载时进行获取。为了解决这个问题,我们可以监听挂载,就像我们在上一节中学到的那样:
_onM(() => { ... }, 0)
使用前面的行,我们可以监听挂载时刻。请注意,我们监视的是一个常量0而不是任何变量。在挂载期间,_onM监听到的值从undefined变为0,但对于未来的其他更新,该值保持在0;因此,...回调只在挂载时被调用一次。让我们在这个回调中编写fetch:
function Title() => {
const count = _getM(0)
_onM(() => {
fetch('./giveMeANumber').then(res => {
_setM(res.data)
})
}, 0)
console.log('u')
return <h1>{count}</h1>
}
如果我们运行前面的代码,时间线草图应该生成以下内容:
u-----u-------------------> log
在Title组件挂载时,count状态最初被设置为0。立即执行fetch函数,在先前的updates系列中表现为第一个u。只有当fetch成功返回时,count状态才会更新为新值并刷新到屏幕上。新的更新在updates系列中表现为第二个u。
操场 – 挂载时的任务
您可以自由地在这个示例中在线玩耍:codepen.io/windmaomao/pen/PoKobVZ。
在第一次和第二次更新之间,这是 API 完成所需的时间。API、状态和两个更新之间的关系在图 2.9中说明。本质上,在 API 返回后,它将新更新将从中继续的位置通知给共享状态:

图 2.9 – 状态组件内的 Fetch API
现在我们已经创建了一个状态,并且也看到了状态如何灵活地用于创建新的更新或监听值的变化,让我们动手实践,将所学应用到应用程序中。
将状态应用于单页应用程序
我们希望继续在上一章中开始构建单页应用程序的工作。当时我们无法完成它,因为我们缺乏切换到除主页之外的其他页面的方法。我们已组装了一个Nav组件:
const Nav = ({ items, selected }) => { ... }
给定一系列页面,Nav组件将它们显示为导航链接。同时还需要提供当前selected页面。现在我们知道了如何定义状态,让我们使用它来跟踪selected页面:
const App = () => {
const selected = _getM("home")
return (
<div>
<Nav
items={menus}
selected={selected}
onSelect={_setM}
/>
...
</div>
)
}
在前面的App组件中,我们使用一个状态selected来保存初始的home键,然后将其传递到Nav组件。为了允许在用户点击后更新状态,我们需要通过添加对onSelect回调函数的支持来修改Nav:
const Nav = ({ items, selected, onSelect }) => {
const isActive = item => item.key === selected
const onClick = item => () => {
onSelect(item.key)
}
...
}
在前面修改过的Nav组件中,传递了一个onSelect属性,以便在onClick之后,父App组件可以通过_setM函数通知更新selected页面。
为了确认用户确实到达了不同的页面,基于当前选中的页面,我们可以使用一个Route组件在页面内容之间进行切换:
const Route = ({ selected }) => {
return (
<div>
{selected === 'home' && <Home />}
{selected === 'product' && <Product />}
</div>
)
}
前面的Route组件所做的就是根据selected页面显示页面内容。注意,它使用了一个&&符号,这是React代码中常见的行。它等同于以下内容:
{selected === 'home' ? <Home /> : false}
如果左侧的条件匹配,它返回<Home />;否则,它返回false。根据React,任何true、false、null或undefined值都是有效的元素,但在更新时,它们都会被忽略而不显示。本质上,如果左侧部分的条件不满足,则不显示任何内容。
将Nav和Route组件组合起来,我们可以修改App组件:
const Home = () => <h1>Home page</h1>
const Product = () => <h1>Product page</h1>
const App = () => {
const selected = _getM("home")
return (
<div>
<Nav
items={menus}
selected={selected}
onSelect={_setM}
/>
<Routes selected={selected} />
</div>
)
}
最后,我们得到了两个页面可以正常工作,如图 2.10 所示!如果您点击产品链接,它将跳转到产品页面:

图 2.10 – 使用状态的单一页面应用程序
总结一下,App组件定义了一个selected状态来保存当前选中的页面。Nav组件用于显示所有链接,并允许通过点击链接选择不同的页面。Route组件用于根据selected状态显示页面。本质上,基于这种设置,添加更多页面只是简单地在Route组件下添加新的组件。
操场 – 单一页面应用程序
欢迎在线尝试这个示例:codepen.io/windmaomao/pen/PoKoWPG。
在结束本章之前,让我们花一分钟时间看看在React中状态是如何驱动 UI 的。
状态如何与 UI 协同工作
随着状态在函数组件中的引入,我们有时会因它所扮演的角色而感到困惑。我们将使用三个组件来阐述,如图 2.11 所示:

图 2.11 – 组件中的属性
我们有三个组件用实心框表示。外层组件包含中间组件作为子组件,中间组件又包含内层组件作为子组件。属性,用穿过实心框边界的箭头线表示,从父组件传递到子组件。
React是一个状态机。对于给定的一组固定变量,它会以相同的方式绘制屏幕。由于每个组件仅由其属性决定,所以使用属性非常直接。现在,让我们将状态添加到图中,如图图 2.12所示。状态,以带有圆圈和点的符号表示,是在每个组件内部定义的:

图 2.12 – 组件中的状态和属性
首先考虑C内部组件,它没有定义任何状态。因此,它仍然由其属性决定。
B 中间组件定义了一个状态。当其属性固定时,对应组件的屏幕仍然可以变化,因为这种状态可以在每次更新时取不同的值。
A 外部组件定义了两种状态。同样,当所有属性固定时,对应的屏幕仍然可以变化。这种变化可以来自其两种状态中的任何一种,也可以来自B组件的状态,因为父组件和子组件的状态可以在更新时独立工作。
因此,我们可以得出结论,要为A组件绘制屏幕,我们需要固定其内部及其所有子组件的所有属性和状态。这并不是一个数学理论,但考虑到多个组件的状态,这个观察结果是明显的。
简而言之,属性和状态现在都作为组件的输入。状态可以特别生动,因为它们的值可以是,但并不总是与外部系统连接。外部系统可以是浏览器事件或API获取,或任何其他东西。因为状态可以通过属性发送到子组件,所以状态的影响可以迅速地级联到应用树的深处。
摘要
在本章中,我们开始在函数组件内构建一个新事物,称为状态。状态在更新期间是持久的,可以用来请求新的更新、监听值的变化,以及在挂载时执行任务。后来,我们将开发的状态应用于单页应用程序,以创建一个具有路由系统的简化Nav。最后,我们简要研究了在React下状态如何影响UI。
在下一章中,我们将向您介绍 React 钩子的概念以及这种持久状态是如何在React引擎下设计的。
问题和答案
这里有一些问题和答案来更新您的知识:
-
状态是什么?
对于函数组件,状态是在组件生命周期内创建的用于持久化的值。从每次更新(包括挂载)中,这个值都可以在函数内部访问。
-
状态有哪些用途?
如果一个任务不能在一个更新周期内完成,那么这就是我们可以考虑使用状态来引用可以在多个更新周期中访问的内存的时候。我们通常使用状态来请求新的更新、监听值的变化,以及在挂载时执行任务。但状态可以非常灵活。
-
状态对 UI 做了什么?
为了确定与组件对应的屏幕,我们需要知道它的状态以及它的属性。虽然属性是在组件接口上被动定义的,但状态是在组件内部定义的,以积极调整其行为。使用状态构建的应用可以随时间变化,由用户交互或任何其他外部过程驱动。
第三章:Hooking into React
在上一章中,我们学习了如何在函数组件内部使用我们自定义的状态来执行操作。在本章中,我们将探讨在创建良好的状态解决方案时面临的挑战,然后看看 React 如何通过底层的 Hook 构建解决方案。然后我们将介绍什么是 hook,并了解它的调用顺序,以及如何在实际应用中避免遇到条件 hook 问题。本章还包括附录部分的两篇附加主题,React Fiber 和 Current and WorkInProgress Scenes。
在本章中,我们将涵盖以下主题:
-
创建良好的状态解决方案
-
介绍 React Hook
-
什么是 hook?
-
问答
-
附录
创建良好的状态解决方案
状态非常强大。一个没有状态组件就像一个没有变量的函数。它将缺乏推理能力。一块 UI 逻辑依赖于状态来处理来自用户的连续交互。
在上一章中,我们按照以下方式构建了一个自定义状态:
let states = {}
function _getM2(initialValue, key) {
if (states[key] === undefined) {
states[key] = initialValue
}
return states[key]
}
function _setM2(v, key) {
states[key] = v
ReactDOM.render(<Title />, rootEl)
}
虽然这种方法可行,但在我们可以认真考虑使用 React 之前,我们需要解决一些问题。我们将逐一提及这些问题。
状态分配的位置是第一个主要问题:
let states = {}
前面的 states 变量被分配为一个全局变量,但通常我们首先感兴趣的会是特定于组件的状态。换句话说,我们需要找到一个地方来定义局部状态。
使用状态的第二大问题是每个状态的唯一键:
const a = _getM2(0, 'comp_a')
就像在先前的状态使用中一样,在将状态命名为comp_a之后,我们必须为涉及此状态的任何操作携带这个键。在一个典型的应用中,我们可能有大量的状态;如果每个都必须用唯一的字符串定义,我们就必须想出很多唯一的名称。跟踪所有使用的名称的工作量会相当大,更不用说函数组件内部持有状态的变量已经有一个名字,a。同时拥有变量名和键字符串会有些繁琐。
除了这两个主要问题之外,还有一些其他的小事情我们需要考虑。在演示状态的使用时,当我们需要请求新的更新时,我们会渲染Title组件:
ReactDOM.render(<Title />, rootEl)
明确知道我们为每个执行的操作需要更新哪个组件可能对开发者来说是一个挑战。如果引擎能帮助我们在这里隐藏这个细节,找出需要更新的组件,那就更好了。这正是 React 最擅长的;我们应该将其与引擎连接起来以执行正确的更新。最后但同样重要的是,我们知道状态可以用于不同的目的,因为其底层概念是一个持久化机制。如果做得恰当,我们应该能够创建某种基础设施,在此基础上我们可以添加额外的功能。
前面列出的都是一个好的状态解决方案应该考虑的问题。考虑到这些,让我们看看 React 是如何处理这个状态问题的。
引入 React Hook
状态主要位于组件内部,至少就本书的内容而言是这样的。存储状态的天然位置应该是在组件实例下,因为 React 中的组件定义了一块 UI。那么,React 中函数组件的组件实例存储在哪里呢?
结果表明,组件并不是 React 中的最小单元。有一个更细粒度的结构叫做纤维,它用于表示一个元素。纤维为这个元素执行所有任务。元素可以是像 h1、div 这样的简单元素,也可以是执行不同操作的伪元素。例如,“片段”元素可以组合其他元素而不显示自己,或者“memo”元素可以记住上一次更新中的所有元素。
实际上,函数组件是纤维表示的伪元素之一。函数组件的作用是允许我们定义它可以显示的元素,所以每次它被调用时,它都能确定屏幕需要更新哪些 DOM 元素。你可以在本章末尾的 附录 A – React Fiber 中找到更多信息。
因此,现在我们找到了组件实例的单位;这正是 React 决定存储状态的地方。React 使用 Hook 结构在 memoizedState 属性下存储它们,如图 3.1 所示:

图 3.1 – 纤维下的 Hooks
我们在这里引入的 Hook 是一个用于保存状态的(或类)结构。这并不完全等同于我们稍后将要介绍的 React Hook(函数)。不幸的是,React 在这两个地方都使用了相同的词。为了区分它们,我们故意使用 Hook(带大写 H)来表示结构,而使用 hook(带小写 h)来表示函数。
Hook 结构的主要功能是在 state 属性下保存单个状态。而不是在数组(或对象)中保存多个状态,多个状态通过链表链接在一起,如图 3.2 所示。一个 Hook 通过其 next 属性指向另一个 Hook。当它到达列表的末尾时,最后一个 Hook 的 next 属性被设置为 null。这就是编程中典型的链表工作方式。如果有的话,第一个 Hook 存储在纤维的 memoizedState 下;这样,纤维就可以找到第一个之后的所有 Hooks。

图 3.2 – 链表中的 Hooks
为了让引擎知道屏幕上是否有任何变化,需要更新纤维。在更新函数中,这就是 Hook 初始化的地方。所以接下来,让我们看看更新函数。
更新函数组件
React通过updateFunctionComponent函数更新一个函数组件。输入参数接受一个Component函数及其props输入:
let updatingFiber = ...
function updateFunctionComponent(Component, props) {
prevHook = null
let children = Component(props)
...
}
更新函数的主要任务是调用Component(props)以了解新的children元素。以Title组件为例,当它需要更新时,updateFunctionComponent函数会调用Title()。有了这个,引擎会比较返回的元素和屏幕上的元素,并提交差异。
在前面的更新函数中定义了两个全局变量。它们很容易理解。updatingFiber代表当前由引擎更新的纤维,prevHook指向这个纤维之前工作的 Hook。在组件被调用之前,updatingFiber由引擎填充,例如Title,而prevHook被设置为null。
组件第一次更新,就像挂载一样,是创建这个纤维的第一个 Hook 的时候。
在挂载时创建 Hook
要在当前正在更新的纤维下挂载一个 Hook,React会创建一个新的 Hook 对象并将其附加到链表中:
function mountHook() {
const Hook = {
state: null
next: null
}
if (prevHook === null) {
updatingFiber.memoizedState = Hook
prevHook = Hook
} else {
prevHook.next = Hook
prevHook = prevHook.next
}
return Hook
}
在前面的mountHook函数中,首先分配了一个空的 Hook 对象,并将state和next都设置为null。如果是第一个到达纤维的 Hook,由于preHook是null,它会被存储在纤维的memoizedState下。否则,它会被附加到前一个 Hook 的next属性上。之后,返回分配的 Hook。
在更新时获取 Hook
在挂载之后的任何其他更新中,我们可以访问React在挂载时创建的 Hook:
function updateHook() {
var Hook
if (prevHook === null) {
Hook = updatingFiber.memoizedState
} else {
Hook = prevHook.next
}
prevHook = Hook
return Hook
}
在前面的updateHook函数中,通过在纤维下查找第一个memoizedState Hook 来获取一个 Hook 对象。在第一个 Hook 之后,它通过跟随prevHook的next属性来获取。React也会在我们沿着列表移动时保持prevHook的最新状态。获取到的 Hook 会被返回。
使用 Hook
现在我们已经使 Hook 对所有更新持久化,我们可以在函数组件中使用它,类似于我们在上一章中编写的_getM或_getM2函数。
让我们这次创建一个接受initialState值的_useHook函数:
function _useHook(initialState) {
let Hook
if (isFiberMounting) {
Hook = mountHook()
Hook.state = initialState
} else {
Hook = updateHook()
}
return Hook.state
}
根据组件是否处于挂载状态,通过isFiberMounting标志,前面的_useHook函数获取一个持久化的 Hook。如果是挂载状态,React将initialState分配给 Hook。对于任何其他更新,Hook 不会被修改。在所有情况下,Hook 下的state都会被返回。
你可能会想知道React是如何确定isFiberMounting标志的;因为它与引擎的连接更深,所以我们把这个材料放在本章末尾的附录 B – 当前和 WorkInProgress 场景中。
到目前为止,我们已经了解了React在引擎下如何实现 Hook。我们刚刚咬下了硬骨头,现在让我们看看我们如何使用它。
什么是 Hook?
现在我们已经揭示了简化版的 React 钩子基础设施,并使用它创建了一个函数,让我们在一个函数组件中试一试:
const Title = () => {
const a = _useHook(0)
}
在挂载时,前一个 a 变量被分配一个 0 数字,然后它作为后续更新的状态。
_useHook 技术上是一个 React 钩子函数。虽然它不是官方支持的钩子,但我们在这里创建它来演示基础设施,但它具有钩子函数的所有特性。让我们仔细看看它。
注意
为了区分我们创建的教育性钩子与官方支持的钩子,我们用 _ 前缀命名钩子,例如 _useHook。
我们将在下一节进一步解释钩子作为函数的本质以及其调用顺序。
钩子是一个函数
钩子是一个接受输入参数并返回值的函数,并且按照惯例带有 use 前缀。
如果我们将 useHook 视为一个通用钩子,以下是用不同输入参数使用钩子的示例用法:
const Title = () => {
const a = useHook()
const b = useHook(1)
const c = useHook(1, 2, "Hello")
const d = useHook({ text: "Hello World"})
}
钩子可以接受零个或任意数量的输入参数。输入参数可以用作初始条件,例如在 _useHook 中的 initialState 参数。重要的是要知道,并非所有输入参数都用于初始化目的,因为,正如你在实现中可以看到的,例如 initialState 这样的输入参数会被发送到每次更新中,但更新是否需要使用输入参数取决于更新本身。
作为函数,钩子如果需要的话可以返回一个值。返回值可以设计成任何格式:
const Title = () => {
useHook(...)
const i = useHook(...)
const [j, k] = useHook(...)
const { value } = useHook(...)
}
并非所有钩子都返回值。如果返回值,它可以是一个 null、一个数字、一个字符串、一个数组、一个对象或任何 JavaScript 表达式。
由于一个返回值可以成为另一个的输入参数,因此看到钩子的链式使用并不罕见,如下所示。
const Title = ({ text }) => {
const i = useHook(...)
const j = useHook(i)
const k = useHook(i, j, text)
}
在前面的代码中,i 和 j 是从两个钩子返回的,然后通过输入参数注入到另一个钩子中,从而得到 k。此外,一个 text 属性被作为输入参数发送到钩子。实际上,钩子语句与局部赋值语句并没有太大的区别。
总的来说,从技术上讲,钩子是一个函数。不要因为它是钩子就感到害怕。你了解的大部分关于函数的知识都适用于钩子。话虽如此,钩子是一个特殊的函数,它有一个需要注意的注意事项——其调用顺序。
钩子的调用顺序
到目前为止,我们知道钩子函数可以在函数组件中使用多次而不会引起冲突,因为每个状态都指向一个独立的内存空间:
const Title = () => {
const a = _useHook(0)
const b = _useHook("Hello")
}
记得我们创建 _getM2 的原始版本以支持多个状态时,我们必须使用一个键来区分 a 变量和 b 变量吗?现在,有了钩子基础设施,我们不再这样做。你有没有想过没有状态键是如何做到这一点的?
在挂载时,在函数组件中使用 a 的第一个钩子函数之前,还没有创建任何钩子:
const a = _useHook(0)
在运行前面的语句后,React 创建了一个钩子并将其放在纤维之下。然后,它看到了另一个针对 b 的钩子函数:
const b = _useHook("Hello")
在看到前面的语句后,React 创建了另一个钩子并将其放在第一个钩子之后,按照链表顺序。第一次挂载更新已完成。
现在是第二次更新;当它再次看到 a 的第一个钩子函数时,它会查看纤维下的链式钩子并获取第一个钩子。同样,当它看到 b 的第二个钩子函数时,它会继续查看列表并找到第一个钩子之后的第二个钩子。
实际上,React 不使用键,因为列表的顺序充当键,键被称为钩子的调用顺序。只要 a 的第一个钩子首先调用,b 的第二个钩子其次调用,列表下存储的状态位置就会被正确标记。因此,我们不必有意识地跟踪键,因为在我们写下所有钩子语句之后,调用顺序应该已经确定。
这种没有开发者提供显式键的设计相当容易使用。除了有一个需要注意的地方;如果我们能避免遇到它,这种设计在实际中就像魔法一样有效。
所以,这里有一个需要注意的地方。这个调用顺序在代码编译期间并不是固定的;相反,它在运行时确定。有什么区别?区别在于运行时的事情是可以改变的。为了给你一个例子,我们可以使用一个 if 语句来设置一个案例。
条件钩子问题
假设我们有一个以下 Title 组件,它使用了两次钩子:
const Title = ({ flag }) => {
const a = flag ? _useHook('a') : ' '
const b = _useHook('b')
return <h1>Hello World+{a}{b}</h1>
}
在前面的代码逻辑中,我们的意图是将 'a' 和 'b' 字符分别存储在 a 和 b 变量中。但是,当 flag 为 false 时,空字符 ' ' 被存储在 a 变量中。
为了确认代码是否工作,让我们在翻转 flag 属性的同时对这个组件进行两次更新。假设第一次更新时 flag 属性设置为 true,而第二次更新时它被更改为 false。对于这个设置,它生成了以下时间线草图:
|T-------F---------------> flag
|a------- ---------------> a
|b-------a---------------> b
在第一次更新时,变量 a 和 b 都被正确分配。但当进行第二次更新时,变量 b 被设置为 'a' 字符。这有点奇怪,因为我们从未在代码中要求将 'a' 字符设置为变量 b。这是怎么发生的?!
_useHook('b') 语句怎么会返回一个 'a' 字符,而 'a' 字符又是从哪里来的?为了回答这些问题,我们需要深入挖掘 Title 组件背后的纤维下的钩子:
|T-------F---------------> flag
|a-------a---------------> Hook1
|b-------b---------------> Hook2
在前面的时间线草图中,我们打印出了两个钩子下存储的状态。Hook1 存储了 'a' 字符,Hook2 存储了 'b' 字符,对于两次更新。让我们仔细看看第二次更新;编译器看到的是以下代码:
const Title = () => {
const a = ' '
const b = _useHook('b')
return <h1>Hello World+{a}{b}</h1>
}
在前面的代码排列中,我们硬编码了flag属性为false。正因为如此,a钩子的第一次使用被省略了,我们最终只有一个针对b的钩子语句。你可以在图 3.3中看到这个信息,其中我们展示了两个钩子以及每个钩子语句读取的内容:

图 3.3 – 条件钩子不匹配 I
在第一次更新中,a和b变量从Hook1和Hook2读取。但在第二次更新中,由于第一个钩子语句执行,b变量发生了偏移并从Hook1读取。在这个更新中,也没有任何内容从Hook2读取。因此,b变量现在读取的是'a'字符。
操场 – 条件钩子 I
欢迎尝试这个在线示例:codepen.io/windmaomao/pen/RwLrxbp。
在这种情况下,我们将flag属性从T更改为F;我们也可以通过将flag属性从F更改为T来测试这个条件情况。如果我们这样做,让我们看看时间线草图:
|F-------T---------------> flag
| -------b---------------> a
|b-------b---------------> b
|b-------b---------------> Hook1
|~-------b---------------> Hook2
从前面的运行中,我们打印了a和b变量以及两个钩子状态。你可以看到,在第二次更新中,a变量读取了'b'字符!我们可以使用图 3.4来更清楚地说明这个情况:

图 3.4 – 条件钩子不匹配 II
这个情况发生了以下情况。在第一次更新中,由于标志是F,我们为b有一个钩子使用。由于这是挂载,'b'字符被初始化为Hook1,而Hook2被省略了。当进行第二次更新时,由于Hook1已经被初始化,其值不能再次初始化,因此它继续保留'b'字符。而这次Hook2最终被初始化为'b'字符。这就是为什么在第二次更新后,a和b都存储了'b'字符。这真是太令人震惊了,不是吗?从某种意义上说,这个情况比之前的情况更糟,当然;两者都是错误实现的。
从这两个案例中,我们可以得出结论,使用if语句与钩子语句会导致奇怪的行为。而且这完全是因为钩子的调用顺序在更新之间发生了变化,因此状态键被搞混了,状态不能按预期读取。
操场 – 条件钩子 II
欢迎尝试这个在线示例:codepen.io/windmaomao/pen/oNGbEzq。
实际上,不仅仅是if;任何涉及条件的钩子语句都不能使用。这里有一个另一个例子:
const Title = ({ arr }) => {
const names = arr.map(v => _useHook(v))
return <div>{names.join('')}</div>
}
在前面的代码中,我们在迭代 arr 数组的循环中嵌入了一个钩子。猜猜这个情况下我们会遇到多少个钩子语句?不确定?是的,你猜对了 – 我们不知道 arr 属性包含多少个元素;这只能在运行时确定。我们不会详细说明这个情况,但你可以看到,如果 arr 的长度从 0 变为 1,或从 1 变为 2,等等,代码很容易遇到奇怪的问题。
React 在其在线文档中给出了他们的建议:"不要在循环、条件或嵌套函数中调用钩子。相反,始终在 React 函数的最顶层使用钩子,在所有早期返回之前。" 现在你对为什么他们会这么说有了更深的理解。
React 完全清楚这个问题的严重性,因为它可能会危及钩子的使用。因此,在代码编译时,编译器实际上会在发现条件钩子使用时提醒开发者。此外,如果在编译时错过了捕捉到的情况,在运行时,React 会监控钩子列表,以确定在新的更新中是否有钩子顺序的混乱。如果它发现了一个,你将看到一个警告,如图 3.5 所示:

图 3.5 – React 条件钩子运行时警告
避免使用条件钩子
现在我们知道我们不应该编写任何条件钩子语句,但我们如何避免它?或者,换一种说法,如果我们必须实现涉及钩子的某些条件逻辑,正确的做法是什么?
这个问题的解决方案并不困难。我们仍然可以写条件语句,只是不能写条件钩子语句。只要我们有固定数量的钩子和一致的调用顺序,我们就可以随意编写钩子语句。
让我们尝试修复我们的示例,首先从将 flag 从 T 设置为 F 开始。我们可以在之前声明两个 _useHook 而不是有条件地声明:
const Title = ({ flag }) => {
const _a = _useHook('a')
const b = _useHook('b')
const a = flag ? _a : ' '
return <h1>Hello World+{a}{b}</h1>
}
在前面的代码中,我们使用一个辅助的 _a 变量来持有 'a' 字符。b 变量仍然持有 'b' 字符。这样,无论什么情况,所有钩子都在所有更新中保持固定的调用顺序。
现在,有了这个,我们可以将 a 的条件逻辑部分重新定位到钩子语句之后。我们可以通过查看生成的时间线草图来验证这是否有效:
|T-------F---------------> flag
|a-------a---------------> Hook1
|b-------b---------------> Hook2
|a------- ---------------> a
|b-------b---------------> b
同样,我们可以生成将 flag 从 F 更改为 T 的时间线:
|F-------T---------------> flag
|a-------a---------------> Hook1
|b-------b---------------> Hook2
| -------a---------------> a
|b-------b---------------> b
现在两种情况都正确实现了。a 变量可以根据 flag 的值持有 'a' 字符或空 ' ',而 b 变量始终持有 'b' 字符。
操场 – 条件钩子 I
你可以自由地在这个在线示例codepen.io/windmaomao/pen/KKXVQWV中玩耍。
操场 – 条件钩子 II
你可以自由地在这个在线示例codepen.io/windmaomao/pen/MWEKQQJ中玩耍。
将钩子语句移动到函数前面的写法是 React 推荐的,并且也可以应用于循环情况:
const Title = ({ arr }) => {
const t = _useHook(arr)
const names = t.map((v, i) => t[i] || '')
return <div>{names.join('')}</div>
}
在前面的代码中,我们不知道 arr 的长度,所以最好不要在循环中遍历每个钩子语句。相反,我们可以将整个 arr 存储到状态中,然后迭代这个数组。这样,我们消除了有变量数量的钩子语句的可能性。
幸运的是,之前提到的注意事项是 React 钩子唯一的缺陷,如果我们遇到条件语句,我们可以通过将钩子语句放在函数的前面来应用“正确”的方式。
简而言之,React 钩子是一个特殊的函数,它允许函数组件拥有持久的状态。开箱即用,React 提供了相当多的基于这个基础设施的钩子。从下一章开始,我们将详细了解其中的一些常见钩子,包括 useState、useEffect、useMemo、useContext 和 useRef。在 第九章 “使用自定义钩子重用逻辑”中,我们将了解如何创建我们自己的自定义钩子来满足我们的特定需求。
摘要
在本章中,你学习了什么构成了一个好的状态解决方案,并了解了 React 如何构建 Hook 来提供这个解决方案。你还学习了钩子是什么以及它的调用顺序,以及如何在实际应用中避免遇到条件钩子问题。
在下一章中,我们将深入了解 React 家族的第一个钩子,通过它 React 允许我们定义一个状态来驱动 UI 显示。
问题和答案
这里有一些问题和答案来刷新你的知识:
-
什么是 React 钩子?
React 钩子是一个特殊的函数,它允许我们在函数组件中拥有持久的状态。钩子的调用顺序被用作状态的内部键,因此,当我们使用钩子时,我们不需要指定一个键。我们可以在一个函数组件下拥有尽可能多的钩子,每个钩子可以用于不同的目的。
-
我们如何避免条件钩子?
每个具有特定调用顺序的钩子都会存储在钩子列表中。React 不允许在运行时更改这个调用顺序,因此我们应该避免在条件、循环或任何改变调用顺序的结构中使用钩子。相反,我们可以将所有的钩子语句移动到函数的前面部分,预先确定它们的调用顺序,然后在返回语句之前留下条件逻辑。
附录
附录 A – React Fiber
在用户与网站的会话期间,会生成一系列操作。我们期望这些操作被分发,并将更改应用到 文档对象模型 (DOM) 上。这个周期使得它成为一个典型的网络体验。

]
图 3.6 – 带有 Render 和 Commit 语句的 React Fiber
React 为我们做的事情是允许分发的动作更新屏幕上的更改。React 将每次更新分为两个主要阶段,渲染 和 提交,如图所示。渲染所做的就是逐个遍历所有元素并收集所有更改,而提交则一次性将更改应用到 UI 上。
这台引擎有一个代号,Fiber。为了方便所有这些操作,React 创建了一个内部对象,称为纤维,来表示每个元素。正如我们之前所介绍的,元素可以是经典元素,例如 DOM 元素,也可以是人工元素,例如函数组件。在物理 DOM 和 React 元素之间有一个层的好处是双重的。
函数组件(或类组件)更容易让开发者将 UI 以及逻辑组织成一个功能单元。有一个纤维包裹这样的单元可以提供一些通用元素行为,并为特定元素添加特殊处理。例如,我们介绍了 updateFunctionComponent 用于更新函数组件,但对于其他元素,有不同的更新函数。
另一方面,在 UI 引擎中添加一个额外的层允许优化。实际上,React Fiber 并不会盲目地更新到屏幕上。在第一次更新,就像挂载一样,每个纤维都会被创建,所有 DOM 元素都会从头开始创建。这次更新应该非常接近经典的更新。
然而,之后的一切都不同了。对于新的更新,假设只有屏幕的一小部分需要调整。因此,在 React 更新屏幕之前,它会遍历之前更新中存储的所有纤维,并将它们与新的渲染元素进行比较。这种比较被称为协调,即比较新元素与之前的 DOM 元素,以确定在本次更新中需要应用的新 DOM 变更。React 使这种协调非常高效,以便只将必要的更改应用到屏幕上。
优化不仅限于协调。为了确保事情可以高效完成,纤维也充当了工作单元。在每次更新期间,所有纤维都会被发送到一个管道中,每个纤维依次被处理。这样做有一些优势。因此,更新工作不再被视为非此即彼。引擎可以在资源不足时暂停,一旦获得足够的计算时间,就会回来完成最后一个单元。其中一个直接的好处是能够快速响应对浏览器中更紧急的工作。
附录 B – 当前和工作进行中场景
当我们说 React 在内部为每个元素创建一个纤维时,我们是在撒谎。实际上,对于每个元素,React 会创建两个纤维,这两个纤维的名字分别是 current 和 workInProgress。
想象一下用户屏幕就像一个有幕布的舞台。面向观众的舞台是当前场景,而幕布后面还有一个正在准备下一个要展示给观众的内容的工作中场景。当观众享受观看当前场景时,工作中场景也在同时准备。只有当时机成熟时,才会有一个轮子旋转当前场景背后的幕布,并将工作中场景推向观众,如图图 3.7所示:
![图 3.7 – React 的当前和 workInProgress 两个场景
![img/Figure_3.07_B17963.jpg]
图 3.7 – React 的当前和 workInProgress 两个场景
这是一种在所有商业表演背后的常见机制,包括计算机屏幕。React也不例外。为了在提交前/后的屏幕过渡中提供平滑性,它使用内存中的两个场景,分别命名为current和workInProgress。
初始时,这两个场景都是空的,因为表演还没有开始。我们在workInProgress场景上工作,而current是空的;这一步骤被称为workInProgress完成,阶段旋转,使得workInProgress成为current。在编程中,这只是一种指针赋值。
在任何未来的操作之后,workInProgress场景开始再次准备。这次,由于可以从current中克隆未更改的内容,因此不需要从头创建workInProgress,这一步骤被称为workInProgress完成,阶段旋转,使得workInProgress成为新的current场景。
在挂载(mount)或更新(update)过程中,我们选择workInProgress来处理未来的场景,同时将current保留为上一次完成的作业,除了在挂载期间,current中没有任何内容。因此,为了判断任何组件是否处于挂载或更新状态,我们可以检查current是否为空:
const isFiberMounting = current === null
除非你在引擎上工作,否则你不会同时获得两个场景,因为核心外的开发者工作在workInProgress上,而用户观看current。对他们所有人来说,只有一个场景。
第四章:使用状态启动组件
在上一章中,我们学习了React如何设计一个hook基础设施来提供函数组件的持久性。在本章中,我们将开始学习React中的内置钩子,从useState钩子开始。我们首先解释状态的概念在React中的使用,然后我们将遍历useState背后的数据结构和源代码,并描述一些更改状态的常见用例。我们将对useState进行测试,并在本章末尾提供两个将useState应用于Avatar和Tooltip组件的实际示例。
在本章中,我们将涵盖以下主题:
-
React 中的状态
-
useState设计 -
分派状态
-
测试
useState钩子 -
useState示例 -
问题与答案
-
附录
React 中的状态
到现在为止,你应该对什么是状态有一些了解。为了回顾,状态是存储在纤维中的一部分内存,在第三章**,Hooking into React中引入的。当与属性结合时,状态可以确定性地表示一个UI*屏幕。

Figure 4.1 – 包含源纤维的纤维树
例如,假设我们构建了一个网站,最终得到一个纤维树(如图4.1所示)。当用户进行操作(如点击)时,操作通过事件处理程序向纤维(如图4.1中的红色点)发送信号。我们称这个纤维为源纤维。
现在,假设派发的事件将计数器从0更改为1。React应根据此用户操作安排更新,并为屏幕准备所有文档对象模型(DOM)元素。假设红色线条是需要更改的纤维,React 如何找出这一点?
在收到此更新请求后,React从根开始遍历纤维树。相当多的纤维(显示为灰色线条)与此更新无关,因此它们是从上一场景克隆的。当更新到达源纤维时,让我们想象纤维携带一个函数组件并调用一个名为updateFunctionComponent的更新函数:
let updatingFiber = ...
function updateFunctionComponent(Component, props) {
let prevHook = null
let children = Component(props)
...
reconcileChildren(children)
return updatingFiber.child
}
我们在第三章,Hooking into React中介绍了updateFunctionComponent函数的第一部分。该函数的第二部分接受Component函数返回的子元素,并通过reconcileChildren将它们转换为纤维。在过程结束时,第一个子纤维告诉引擎下一步要做什么。这会一直持续到访问源纤维下的所有纤维——即Figure 4.1中显示的红色区域。
通过这种方式,状态变化通过该分支传播到子纤维中。当一个父组件更新时,子组件在更新之前会得到一组新的 props,从而携带状态的影响。这就是状态在 React 生态系统中发挥作用的基本方式。现在,让我们深入探讨 React 是如何创建useState钩子来支持这种行为的。
useState设计
React 提供了一个useState钩子来管理函数组件内的状态。以下代码示例展示了它的常见用法:
const Title = () => {
const [state, dispatch] = useState(initialState)
const onClick = () => {
dispatch(newState)
}
return <button onClick={onClick} />
}
useState函数接受一个initialState参数作为输入参数,并返回一个state对象和一个dispatch函数。dispatch函数可以用来请求将状态更改为newState 对象。
你是否曾经好奇过 React 在幕后是如何设计useState钩子的?为什么它返回一个数组?我们如何知道新的分发是否成功?最重要的是,我们如何确保每次渲染的当前状态?
为了回答这些问题,我们将打开引擎并查看其内部结构。在我们深入研究其各种用途之前,我们将阅读源代码的简化版本,以获得有关此钩子架构的鸟瞰图。让我们首先从数据结构开始。
useState数据结构
使useState工作所需的数据结构包括一个Hook类型、一个Queue类型以及一个Update类型,如图4.2所示:

图 4.2 – useState 钩子的数据结构
一个钩子使用一个state属性来存储状态,以及一个指向下一个钩子的next属性。我们已经在第三章,“React 中的钩子”中解释了这种架构。现在新的地方是,为了支持分发功能,添加了一个queue属性,其中它提供了一个dispatch函数来分发一个带有新状态的action对象。在队列中,一系列更新存储在一个名为pending的属性下。队列的职责是维护一个待处理的更新列表,以便于这个纤维——这样,用户就可以向纤维分发多个更新。
更新被定义为包含一个需要由用户提供的action函数,以计算下一个状态。每个更新通过一个名为next的属性链接到另一个更新,形成一个循环链表(见图 4.3)。链表类似于钩子的链接方式,除了更新是环形链接的,最后一个更新始终指向第一个更新。

图 4.3 – 钩子的队列及其待处理的更新
在前面的图中,队列中有三个更新,pending property指向最后一个,使pending.next指向列表的第一个更新。当我们需要在大脑或尾部插入或删除更新时,这个循环列表变得很有用。
现在我们已经看到了useState的数据结构,是时候查看源代码,看看这个数据结构是如何在实现中使用的。
useState的源代码以典型的hook方式结构化,它根据纤维是否处于mount或update状态(如第三章,React 中的钩子)来接受mountState或updateState路径:
function useState(initialState) {
if (isFiberMounting) {
return mountState(initialState)
}
else {
return updateState(initialState)
}
}
挂载状态
当一个组件处于mount状态时,mountState通过创建一个钩子来获取钩子:
function mountState(initialState) {
const hook = mountHook ()
if (typeof initialState === 'function') {
initialState = intialState()
}
hook.state = initialState
hook.queue = {
pending: null
dispatch: dispatchAction.bind(
null,
updatingFiber,
hook.queue
)
}
return [hook.state, hook.queue.dispatch]
}
然后,它开始执行钩子的初始化工作。根据提供的initialState对象的形式,它可以使用值或函数初始化钩子的state对象:
useState(1) // a value
useState(() => 1) // a function
在初始化状态后,它创建一个空的queue对象,没有挂起的更新。此外,它设置一个dispatch函数并将其存储在queue对象下。让我们仔细看看这个函数,因为它是useState钩子的重要部分之一。
分发一个动作
dispatch函数被设计用来分发一个带有新状态的动作。它是通过一个实用函数dispatchAction创建的,该函数接受一个纤维、一个队列和一个动作。
在将dispatchAction函数分配给队列后,它将更新纤维和队列绑定在一起,这样dispatch函数就可以接受action对象作为唯一的输入参数:
function dispatchAction(fiber, queue, action) {
const update = {
action
next: null
}
const pending = queue.pending
if (pending === null) {
update.next = update
}
else {
update.next = pending.next
pending.next = update
}
queue.pending = update
// Appendix A: Skip dispatch
scheduleUpdateOnFiber(fiber)
}
该函数从输入参数中获取一个action对象,然后创建一个新的update对象并将其追加到queue对象中。前面的与pending相关的代码都是列表操作,所有这些都将update对象追加到列表的末尾,同时确保队列继续形成一个循环链表,如图4.3所示。
一个action对象可以是值或函数更新器,正如initialState对象一样,因此在我们调用dispatch对象时支持这两种格式。以下是一个示例:
dispatch(1) // a value
dispatch(() => 1) // a function
在队列更新后,它通过一个scheduleUpdateOnFiber函数请求更新,这个函数本质上会将React启动到我们在本章开头介绍的更新过程中。这是React处理用户动作的主要途径。
React在引擎内部有很多优化。其中一些不是公开可访问的,因为它们是引擎代码的一部分。例如,有一个隐藏的路径,可以在不调用scheduleUpdateOnFiber函数的情况下取消分发或整个更新。如果你感兴趣,你可以在本章末尾的附录 A – 跳过分发部分找到更多关于这个路径的信息。
更新状态
组件挂载后,下一次它被更新并到达useState钩子时,它会进入updateState并通过克隆一个钩子来获取:
function updateState(initialState) {
const hook = updateHook()
const queue = hook.queue
let updates = queue.pending
queue.pending = null
if (updates != null) {
const first = updates.next
let newState = hook.state
let update = first
do {
const action = update.action
newState = typeof action === 'function'
? action(newState) : action
update = update.next
}
while (update !== null && update !== first)
if (!Object.is(newState, hook.state)) { … }
hook.state = newState
}
return [hook.state, hook.queue.dispatch]
}
一旦我们有了钩子,我们可以在queue.pending对象下检查它是否有任何挂起的更新。pending对象可以有更新,是因为dispatch函数已经被调用过。它通过第一个pending.next更新,并按照update.next更新的顺序迭代它们。对于每个更新,它都会取存储的action对象并将其应用于之前存储的状态,形成一个newState对象,最后将其存储回钩子中。
更新后的newState对象与之前的state对象进行比较,以确定是否发生变化:
// Appendix B - Bailing out an update
if (!Object.is(newState, hook.state)) {
didReceiveUpdate = true
}
如果newState对象与之前的状态不同,React会设置一个didReceiveUpdate标志,指示更新纤维是否包含任何更改。React在这里使用全局标志的原因是,可以有很多其他钩子附加到这个纤维上,因此,它必须等待所有钩子处理完毕后,才能确定纤维是否应该更新或退出。如果您对退出过程的细节感兴趣,请参阅本章末尾的附录 B – 退出更新部分的路径。
返回钩子
对于mountState或updateState函数,返回state和dispatch函数:
return [hook.state, hook.queue.dispatch]
它们以包含两个元素的数组形式返回。这里使用的数组格式很有趣,因为我们本可以使用另一种格式,例如具有键的对象:
return {
state: hook.state,
dispatch: hook.queue.dispatch
}
之前的关键值设计同样可以工作。相反,React决定使用数组,因为这样做有一个优势——那就是我们不必记住键名来引用任何值。以下是一些演示这一点的例子:
const [state, dispatch] = useState("")
const [count, setCount] = useState(0)
const [a, d] = useState(null)
如您所见,我们可以使用任何我们想要的名称来重命名state和dispatch函数,只要它在当时逻辑上合适即可。这在实际操作中非常方便。
总的来说,state和dispatch函数直接映射到底层钩子的state对象和queue.dispatch函数。如果状态没有变化,它返回之前的状态。dispatch函数在挂载期间创建,并保持所有未来更新的相同函数实例。
useState 的流程解析
我们刚刚已经走过了useState钩子的所有代码。为了让您感觉更好,React包含的代码量是我们所展示的代码量的五倍。使用简化版,它很容易理解与它设计解决的问题相关的关键工作流程以及它采取的方法。让我们看看图 4.4中的工作流程草图。

图 4.4 – useState 钩子工作流程
让我们来解释一下我们在 图 4.4 中看到的内容。在更新过程中,当调用 useState 钩子时,它首先检查是否处于 mount 或 update 状态。如果是 mount 状态,它将存储 initialState,创建一个 dispatch 函数,然后返回。如果是 update 状态,它将检查任何 pending 更新并将它们应用到新的 state 上。在两种情况下,都返回 [state, dispatch]。
当调用 dispatch 函数时,它会创建一个带有提供的 action 对象的更新并将其附加到 pending 更新中。然后,将请求新的更新安排到 React。
重要的是要注意,新的更新是分配 state 对象的地方。dispatch 函数的目的是仅请求更改,但 真正的更改不会在下一个更新中应用。
既然我们已经了解了 useState 背后的设计,我们就可以在下一节讨论如何一般性地分发状态。
分发状态
在本章中,我们了解到由 useState 钩子提供的 dispatch 函数允许我们在想要更改状态时进行请求。表示动作的输入参数可以是字符串、数字、对象、数组或任何 JavaScript 表达式:
dispatch(state)
dispatch({ state })
dispatch([ state ])
dispatch(null)
我们知道,内部输入参数也支持函数式更新格式:
dispatch(state => state + 1)
在这里使用函数式更新格式的优点是,它有机会在向下一个状态移动之前读取前一个状态。如果你构建的新状态需要旧状态,这有时会很有用。
如果更改,在最终调用之前会将分发的状态与当前状态进行比较。这意味着并非所有分发的最终结果都会导致状态改变。以下代码可以作为例子:
const [state, dispatch] = useState(1)
const onClick = () => { dispatch(3) }
如果状态以数字 1 开始,我们可以在第一次点击时将状态更改为 3。对于后续的点击,由于它已经是 3,因此无法将数字更改为 3。因此,在多次点击后,所做的更改是 1, 3,而不是 1, 3, 3, … – 无论用户点击多少次。让我们详细看看这种比较是如何进行的。
比较状态
我们之前提到,React 在比较两个状态时始终使用 Object.is 函数。这是一个 JavaScript 原生函数,与 JavaScript 严格相等运算符 (===) 非常相似,用于确定两个值是否相同。
对于原始类型,例如数字或字符串,这种比较是直接的:
1 === 1 true
"Hello" === "World" false
false === true false
理解比较数字 1 和 1 应返回 true 以及比较两个字符串 Hello 和 World 应返回 false 并不难。
JavaScript 有七个原始数据类型:字符串、数字、BigInt、布尔值、undefined、symbol 和 null。这些数据类型 一旦在内存中创建后就不能更改:
null === null true
undefined === undefined true
原始比较 是我们通常理解为 按值比较 的一种。
对于JavaScript中的非原始类型,例如对象或数组,使用引用(也称为指针)来指向特定的内存空间:
{} === {} false
v === v true
这意味着如果你分配了两个新的对象,它们不能指向相同的内存空间。因此,比较两个对象{}和{}应该返回false,即使它们包含完全相同的内容。相比之下,比较相同的对象(例如,v和v)应该返回true,无论对象的内容如何变化。让我们通过一个例子来更好地理解这一点:
const [v, dispatch] = useState({})
const onClick = () => {
v.abc = 3
dispatch(v)
}
你能猜到之前的派遣在用户点击时是否做了什么吗?答案是没有。从Object.is函数的角度来看,改变一个对象的内容并不算作变化,因为v变量仍然指向相同的内存位置,即使其中一个属性已经改变。
在这种情况下,唯一引起变化的方法是派遣一个指向不同内存空间的状态,我们可以通过创建一个新的来做到这一点:
const [v, dispatch] = useState({})
const onClick = () => {
dispatch({ ...v, abc: 3 })
}
通过 JavaScript 的{ ...v }帮助创建一个新的对象,同时将abc属性更改为3,克隆v的内容。对于对学习更多关于JavaScript ES6 语法感兴趣的读者,请参阅第十章的JavaScript ES6部分,使用 React 构建网站。
适应使用Object.is函数或严格相等运算符(===)可能需要一些时间。你可以问自己一个简单的问题:要比较的值是否可变? 如果是,它通过引用进行比较。如果不是,它通过值进行比较。
在React中,如果你无法管理值的变化,你就不能正确地派遣变化。因此,理解object.is非常重要,因为它被广泛用于所有钩子值比较,正如你在本书的其余部分将看到的那样。
多次派遣
当我们在一个事件处理器内部执行多次派遣时,会出现一个有趣的情况。在React中,连续的多次派遣被设计成一起处理,如下面的例子所示:
const [state, dispatch] = useState(1)
const onClick = () => {
dispatch(3)
...
dispatch(5)
}
当用户点击时,如果我们两次调用派遣(P-Code)函数,最终只会引起一个变化,因为每次派遣都会将一个更新添加到队列中。当我们达到下一次更新时,队列中所有注册的动作都会被迭代以形成一个单一的新状态。在我们的例子中,状态从1变为5,跳过了3。但为什么两个派遣只触发一个更新?难道每个派遣没有调用scheduleUpdateOnFiber函数吗?
每次派遣都会调用scheduleUpdateOnFiber来启动React的更新过程。然而,这个函数被设计成这样的方式,它在做出最终更新之前会等待来自同一动作的所有派遣。因此,使用这个功能,多个派遣可以被合并为一个更新操作,作为一个延迟执行。
这个好处是,你可以像写赋值语句一样轻松地写一个dispatch语句,而不必担心它可能会给 DOM 带来不必要的操作。这不仅在实际使用中很方便,而且使更新非常高效。
现在我们已经了解了dispatch函数,我们可以开始使用useState钩子。
测试驱动 useState 钩子
状态是React中驱动用户交互的最常见技术之一。让我们考虑一个带有按钮的组件。每次我们点击按钮时,它都会增加一个数字并将其附加到Hello World字符串上(见图图 4.5)。

图 4.5 – Hello World 计数器
我们可以在Title组件中捕获这种行为:
const Title = () => {
let [count, setCount] = useState(0)
const onClick = () => {
setCount(count + 1)
}
return (
<>
<button onClick={onClick}>+</button>
<h1>Hello World+{count}</h1>
</>
)
}
在这里,我们使用[count, setCount]来跟踪count状态。然后,我们在页面的h1元素中显示count,并在button元素的点击处理程序中调用setCount。每次点击按钮时,它应该增加count值。
为了确认底层发生了什么,让我们在两个位置添加console.log:
function Title() {
let [count, setCount] = useState(0)
const onClick = () => {
setCount(count + 1)
console.log('clicked', count) ➀
}
console.log('rendered', count) ➁
return ...
}
第一个放在setCount之后,以确认每次dispatch后的count值。第二个放在return语句之前,以便我们可以确认更新何时到达以及在那个更新中count值是多少。它们标记为➀和➁:
|-----0-----1------2-----> clicked ➀
0-----1-----2------3-----> updated ➁
从➁版本的updated系列开始,数字从➀版本的clicked系列增加1。在挂载期间,count值从0开始,每次点击后,它都会快速更新到一个新的带有更新数字的状态,如图图 4.6所示。
➀版本的clicked系列确认在dispatch之后,count值不会更新到新的count + 1值。相反,它继续保留在定义onClick对象的更新中的当前状态。

图 4.6 – Hello World 计数器
太好了!这就是我们通常使用useState的方式。让我们看看useState的另一个流行用法,即在父组件中安装它并允许子组件驱动它。
让孩子开车
从父组件向子组件发送dispatch函数并期望子组件从父组件请求状态变化是非常常见的:
const App = () => {
const [count, setCount] = useState(0)
const onClick = () => {
console.log('clicked', count) ➀
setCount(count + 1)
}
console.log('rendered', count) ➁
return <Title onClick={onClick} />
}
const Title = ({ onClick }) => {
return <button onClick={onClick}>+</button>
}
在前面的例子中,Title组件有一个按钮,当它被点击时,它会改变App组件中的count状态。我们将设置两个console.log语句来确认更新:
|-----0-----1------2-----> clicked ➀
0-----1-----2------3-----> updated ➁
它按预期工作 – 点击来自子组件,但其他一切与上一个例子相同。基本上,我们已经赋予了子组件改变在父级创建的count值的能力。
这实际上非常方便。它告诉我们,无论我们在哪里定义状态,如果其子组件(或孙组件)需要它,它都可以通过 prop 访问它。这包括状态和改变状态的能力。这是在 React 中使用状态的最有效策略之一,我们称之为 提升。
向父组件提升
由于其设计,React 不允许直接将信息发送到元素。相反,所需的机制是使用一个 prop,将信息从父组件传递到子组件,然后到子组件的子组件,依此类推。
另一方面,为了在两个子组件之间共享信息,信息需要首先对父组件可用,然后再发送给每个子组件:
const App = () => {
return (
<>
<Title />
<Content />
</>
)
}
const Title = () => {
const [count, setCount] = useState(0)
return <button>+</button>
}
const Content = () => {
return ...
}
在前面的设置中,我们有一个父组件 App,渲染两个子组件 Title 和 Content。安装到 Title 对象中的 count 对象不能被其兄弟组件 Content 或其父组件 App 访问。因此,为了使 count 对象可访问,我们需要将 count 对象移到 App:
const App = () => {
const [count, setCount] = useState(0)
const onClick = () => {
setCount(count + 1)
}
return (
<>
<Title onClick={onClick} />
<Content count={count} />
</>
)
}
在前面的代码中,useState 在 App 中声明,因此我们可以将 onClick 对象发送到 Title,并将 count 对象发送到 Content。因此,我们可以通过 提升 这些东西到父组件来允许与兄弟组件共享东西。这突出了 React 设计的一个重要方面:如果你的父组件有它,你也可以有它。这是我们设计 React 应用时最基本和最有效的行为之一。
重要提示
如果你是一个 React 初学者,你应该尽可能多地尝试使用 props。它们不仅易于理解,而且也是确保一切连接正确的途径。
现在我们已经对 useState 钩子进行了测试,让我们看看更多实际应用如何使用 useState 来驱动 UI 行为。
useState 示例
在本节中,我们将探讨两个示例,展示 useState 钩子在实践中的应用。
创建头像组件
假设你想显示从互联网上获取的人物的图片。大多数情况下,它将是一张好图片(见 Figure 4.7)。但有时,由于网络或权限问题,图片可能无法下载。当这种情况发生时,浏览器会抛出一个损坏的图标(Figure 4.7 中中间的标志),看起来并不那么美观。最新的用户体验研究显示,如果我们用更独特的东西(如 Figure 4.7 右侧所示的用户名或首字母)替换任何损坏的图像图标,这将提高用户体验。

Figure 4.7 – 使用 useState 的头像组件
为了在图像和文本之间切换,我们可以使用 useState 来定义一个条件。我们还需要一个事件处理器来通知我们当图像 URL 损坏时。如果我们把这些逻辑组合起来,我们得到一个 Avatar 组件:
const Avatar = ({ src, username }) => {
const [error, setError] = useState(false)
const onError = () => { setError(true) }
return (
<AvatarStyle>
{error ? (
<div>{username}</div>
) : (
<img
src={src}
alt={username}
onError={onError}
/>
)}
</AvatarStyle>
)
}
在前面的代码中,首先,我们使用useState定义了一个状态,error,并将其初始状态设置为false,假设在加载图像之前没有错误发生。
在组件的return中,它遵循以下简单逻辑:
{ error ? A : B }
如果error为true,它将显示A。否则,它将显示B。在我们的例子中,A将返回用户的首字母,而B将返回一个图像。因此,它最初显示图像。如果图像成功加载,任务就完成了。然而,如果图像加载失败,它将触发一个onError事件处理程序。在onError事件处理程序中,它将发送一个指令将error标志翻转为true。在下一次更新中,随着error标志变为true,它将显示用户的首字母。所以,任务完成了——太棒了!
为了便于使用,Avatar组件由两个属性构建,src和username,其中第一个属性是图像 URL,第二个属性是用户名字符串。以下是代码的示例:
const LOGO = 'https://gravatar.com/avatar/7aa1ac6'
const App = () => {
return <Avatar src={LOGO} username="F" />
}
游戏场 – 头像组件
欢迎在线尝试这个示例:codepen.io/windmaomao/pen/VwzaqEo。
AvatarStyle组件是一个样式组件,它允许我们在组件内部编写 CSS。如果你对这种方法感兴趣,请参阅第十章中的采用 CSS-in-JS 方法部分,使用 React 构建网站,以获取更多详细信息。
创建自定义提示组件
这里是使用useState的另一个示例。假设你有一个头像(你可以从上一个示例中借用),当鼠标悬停在它上面时,你希望看到一些提示文本(如图图 4.8所示)。这必须是一个自定义提示,因为我们希望它允许自定义边框、颜色、字体,甚至包括段落。浏览器的内置提示不会在title属性中提供这些选项。

图 4.8 – 使用 useState 的自定义提示组件
为了支持这个弹出效果,我们可以使用useState设置一个布尔状态来指示鼠标是否悬停在头像区域上。我们还需要两个事件处理程序来监控鼠标进入或离开头像区域。我们可以将这个逻辑放入一个Tooltip组件中:
const Tooltip = ({ children, tooltip }) => {
const [entered, setEntered] = useState(false)
return (
<TooltipStyle>
<div
onMouseEnter={() => { setEntered(true) }}
onMouseLeave={() => { setEntered(false) }}
>
{children}
</div>
{entered && (
<div className="__tooltip">
{tooltip}
</div>
)}
</TooltipStyle>
)
}
我们定义了一个状态,entered,并将其初始值设置为false(因为我们第一次看到这个组件时,提示不会可见)。我们将setEntered连接到onMouseEnter和onMouseLeave事件处理程序以翻转状态。
注意,这次我们没有使用?运算符进行提示的条件显示,而是使用了&&运算符:
{ entered && A }
这是因为在Tooltip中没有B。根据鼠标是否在正确区域,A将被显示或隐藏。因此,&&运算符充当短路——如果条件不满足,它将跳过下一个语句。
Tooltip组件接受children和tooltip作为属性,这允许它托管任何组件作为Avatar对象,以及任何组件作为提示内容,如下面的代码所示:
const TooltipBox = <div>Account</div>
const Title = () => {
return (
<Tooltip tooltip={<TooltipBox />}>
<Avatar>
</Tooltip>
)
}
在前面的代码块中,我们定义了一个自定义的TooltipBox组件,通过tooltip属性传入Tooltip组件。
操场 - 提示组件
你可以自由地在这个示例上在线玩耍:codepen.io/windmaomao/pen/qBXZvKV。
这是Tooltip组件的最好部分。它不仅仅被设计为一个满足单一用例需求的组件——相反,它被设计为一个机制,允许你构建灵活的提示行为。
使用useState,我们可以定制我们的函数组件成为有状态的引擎,使得处理各种用户交互成为可能。
摘要
在本章中,你学习了在React中状态的概念。你深入了解了useState的设计,它分为挂载状态和更新状态。我们学习了各种分发状态的方法以及确定状态是否改变的方法。然后,我们还了解到分发可以支持值格式或函数更新器格式,并且我们了解到我们可以在一个事件处理器中多次分发。然后,我们测试了useState,学习了如何通过属性将状态变化发送到子组件。我们还学习了一种称为提升的常见技术,它涉及将状态提升到父组件。最后但同样重要的是,我们设计了两个组件——头像组件和提示组件——来学习如何在组件设计中应用useState。
在下一章中,我们将探索React家族中的第二个钩子。我们还将看到React如何定义一个称为effect的动作,并允许你在状态变化后调用它。
问题和答案
这里有一些问题和答案来刷新你的知识:
-
什么是
useState?useState钩子是React中的一个内置钩子,它允许你在函数组件中定义状态并分发一个动作来改变它。 -
useState最常用的用途是什么?useState钩子可能是React钩子家族中最常见的钩子。无论何时你需要一个变量来改变UI元素,你通常都可以求助于useState来完成这个任务。触摸小部件、点击点赞按钮、悬停在图标上、切换复选框等等,都可以使用useState来实现。
附录
附录 A - 跳过分发
我们说并非所有分发的状态都会导致变化。但实际上,并非所有分发都会导致成功的分发。当鼠标点击时,它会进入dispatch函数。它有一个特殊的路径,当你满足那个条件并发现没有状态变化时,它可以提前返回而不执行分发:
function dispatchAction(fiber, queue, action) {
...
if (NoWorkUnderFiber) {
const currentState = queue.lastRenderedState
const newState = typeof action === 'function'
? action(currentState) : action
if (Object.is(newState, currentState)) {
return
}
}
scheduleUpdateOnFiber(fiber)
}
在前面的dispatchAction函数中,当它检测到纤维下目前没有工作时要计算一个新的状态。它计算newState值的方式与updateState函数中的计算方式类似,只是这里只处理一个action对象。基本上,它询问这个动作是否导致从最后更新的状态中发生状态变化。
如果最终结果显示没有任何变化,它将不带更新返回,假装什么都没发生。这导致没有任何UI更新。这个路径很重要,因为它可能会非常频繁地发生(例如,当用户反复执行相同的操作而没有任何状态变化时)。
附录 B – 回退更新
对于任何已经更新的纤维,都会有一个集体标志被添加到它上面,称为didReceiveUpdate,它表示纤维是否发生了变化。在开始对纤维进行工作之后,任何导致变化的钩子都可以将这个标志设置为true。之后,如果工作完成且标志仍然是false,这意味着纤维绝对没有任何变化,所以React通过从上一个场景克隆它来回退纤维,然后继续处理下一个纤维:
let updatingFiber = ...
function updateFunctionComponent(Component, props) {
let prevHook = null
let didReceiveUpdate = false
let children = Component(props)
if (!isFiberMounting && !didReceiveUpdate) {
return bailout(updatingFiber)
}
...
}
在前面的updateFunctionComponent函数中,在调用Component函数之后,它检查两个标志。一个是isFiberMounting,因为在站点处于挂载状态时,由于所有纤维仍然需要创建,所以无法进行回退。另一个标志是didReceiveUpdate。当这两个标志都为假时,它将触发纤维的回退。
它通过从当前树中克隆子纤维来回退纤维,这反过来又携带了所有完成的工作,包括旧的属性和渲染的DOM。基本上,通过回退,它不需要执行常规的协调工作来找出新的子纤维。而且更好,如果发现这个纤维的子纤维下没有工作,整个分支都会回退。这对应于图 4.1中的所有灰色线条。
第五章:使用 Effect 处理副作用
在上一章中,我们学习了useState是如何设计的,以及如何使用它来管理useEffect中的状态变化以管理副作用。我们首先介绍什么是副作用,然后我们将遍历useEffect背后的数据结构和源代码,并提供各种调用效果的场景。我们还将演示使用useEffect的一些陷阱,并讨论一些避免它们的方法。在本章的结尾,我们将使用两个实际示例来使用useEffect:查找窗口大小和获取 API 资源。本章还包括附录部分中的三个附加主题:React 副作用、刷新被动效果和是否是异步分发。
在本章中,我们将涵盖以下主题:
-
什么是副作用?
-
理解useEffect设计 -
创建效果
-
测试驱动
useEffect -
useEffect示例 -
问答
-
附录
什么是副作用?
以下函数没有副作用:
function add(a, b) {
return a + b
}
这个函数相当纯净,从某种意义上说,如果以相同的输入参数集调用它,我们应该得到相同的结果——也就是说,add(1, 1)将返回2。这种纯净函数易于理解、测试和开发。原因是该函数只依赖于输入参数,没有其他隐藏的依赖。
你可能会想知道隐藏的依赖项可能是什么?信不信由你,这相当容易发生。在下面的代码中,我们将故意引入两行,每行都会添加一个隐藏的依赖:
let c = 3
function add(a, b) {
console.log(a, b)
return a + b + c
}
第一行添加了来自c变量的外部依赖。因为c是一个全局变量,它绕过了输入参数列表。如果我们现在调用add(1, 1)函数,它可以返回任何数字(甚至非数字)。这是因为c在调用add时可以是任何东西。这适用于所有全局实例。
让我们来看看另一个隐藏的依赖项。在add函数内部,第一行添加了一个来自console.log函数的外部依赖。我们的意图是将a和b变量记录到屏幕上。然而,console.log函数在运行时可能是任何东西。例如,如果console不存在,调用console.log时可能会出错。
从这些先前的例子中,我们可以看到我们可以与一个不纯净的函数一起工作,而不知道这一点。关于不纯净函数,有一件重要的事情需要记住——那就是它们容易出错。例如,在先前的例子中,如果有人更改了任何隐藏的依赖项,开发者很难知道。当涉及到重构代码时,这可能会变成一场噩梦。
为了使我们的代码健壮,我们倾向于开发策略来避免隐藏的依赖项,无论是通过移除它们还是尽可能减少它们的影响,这样我们就可以在开发和维护代码时充满信心。
通常,有两种策略可以解决函数的杂质问题。一种方法是通过将依赖关系添加到输入参数中来移除它,这样它们就不再 隐藏 了:
function add(a, b, c, log) {
log(a, b)
return a + b + c
}
这可以是一个非常有效的方法。在之前的代码更改中,c 变量和 log 函数被明确地写为输入参数。在 add(1, 1, 0, console.log) 的情况下测试这个方法应该会花费更少的精力。这个方法的唯一缺点是,要实现它,你需要知道依赖关系并明确声明它们。这意味着输入参数的列表可能会变得非常长,从而影响函数的有效性。
这带我们来到了第二种策略。我们不仅可以移除杂质,还可以将其打包并延迟到稍后执行。以下是一个如何 延迟 杂质的例子:
function addFunc(c, log) {
function add(a, b) {
log(a, b)
return a + b + c
}
return add
}
addFunc 函数返回一个 add 函数。要使用 add 函数,我们需要调用 addFunc 来获取我们的 add 函数的句柄(也称为回调):
const add = addFunc(3, console.log)
那么,这有什么不同呢?c 和 log 的依赖关系出现在输入参数中,因此 addFunc 是一个 纯 函数。本质上,我们将任何杂质打包并声明到更高一级,因此在 addFunc 的上下文中,新的 add 函数看起来和运作得更加纯净。
在某种意义上,我们保留了原始代码,但将其包装起来以获取一个回调函数,这样我们就可以稍后执行它。这有助于保护主代码的完整性,同时将杂质重新定位。这种延迟策略通常被称为 副作用:
let c = 1, d = 2
function add() {
c = 2
const a = d
}
在前面的代码中,add 函数内部 c 变量的赋值是一个副作用,因为它 改变 了全局值;a 变量的赋值是另一个副作用,因为它 读取 了全局值。从这里,你可以看到 console 是一个稳定的副作用,因为它是一个写入终端屏幕的外部服务。
在一个松散连接的开放系统中,例如网络,副作用是不可避免的。如果你要执行一系列操作,而其中一个操作恰好没有被内部系统定义,那么这个操作就涉及到访问外部系统。虽然我们无法避免副作用,但我们可以在正确的时间打包副作用,以便它访问外部系统。
介绍被动效果
在 React 中,副作用 指的是我们尝试从 外部系统 中 读取 或 写入 的情况。外部系统可以是 DOM 元素,例如 document 或 window 对象,或者是对 web 服务器的获取。

图 5.1 – React 被动效果
当用户执行操作时,会安排一个 dispatch 来触发一个 render,随后是一个 commit 来形成更新(如图 5.1 所示)。在更新过程中,React 不允许立即调用自定义副作用。相反,React 会等待提交结束后再调用它们。
如果在更新过程中遇到了两个副作用,这两个副作用都会被延迟,然后在提交之后依次调用。这种类型的副作用在内部被称为被动副作用。被动副作用是 React 支持的效果类型之一。如果您对其他类型的效果感兴趣,请参阅本章末尾的附录 A – React 副作用部分。
它被称为被动,是因为它在更新期间被调用的方式。React 允许我们在每次更新中调用被动副作用,或者在响应值变化时有条件地调用它。因此,效果不像用户事件那样被积极绑定,而是在值变化时创建、排队,然后稍后调用。从某种意义上说,效果可以通过被动的“事件”来调用。
被动副作用被建模为一个回调函数。在这个例子中,让我们假设它被称为create。调用create函数执行副作用,并返回一个destroy函数来执行与副作用相关的清理工作。
既然我们已经知道了 React 副作用是什么,让我们深入探讨useEffect钩子是如何设计来促进这一过程的。
理解useEffect设计
React 提供了一个useEffect钩子来设置一个在更新后要调用的回调函数:
const Title = () => {
useEffect(() => {
window.title = "Hello World"
return () => {
window.title = "Notitle"
}
}, [])
}
useEffect函数将其第一个输入参数称为create的回调函数定义为效果。在先前的例子中,该效果在组件挂载时将window.title设置为Hello World。
一个create函数可以返回一个名为destroy的函数来执行清理工作。这里有趣的是,destroy函数是由create函数作为返回值提供的。在先前的例子中,当组件卸载时,清理操作将window.title对象还原为NoTitle。
useEffect参数列表中的第二个参数是一个名为deps的依赖数组。如果deps没有提供,则效果在每次更新期间都会被调用,而如果提供了deps,则效果仅在deps数组发生变化时被调用。
useEffect钩子的数据结构
在本节中,我们将通过以源代码的简化版本为例来解释useEffect是如何设计的。首先,让我们看看使其发生的数据结构。

图 5.2 – useEffect钩子的数据结构
为了跟踪副作用,React 在UpdateQueue类型的纤维下创建了一个updateQueue属性(如图 5.2 所示)。在这个队列中,一个效果列表存储在lastEffect属性下。效果通过循环链表链接在一起(见图 5.3),类似于我们在useState中看到的挂起队列:

图 5.3 – useEffect 的纤维更新队列
列表中的每个效果,它将效果函数存储在create属性中,并将清理函数存储在destroy属性中。
useEffect钩子遵循典型的钩子设置,它根据纤维是否在安装或更新中,通过isFiberMounting标志(如第三章,Hooking into React)采取mountEffect或updateEffect的路径。
function useEffect(create, deps) {
if (isFiberMounting) {
mountEffect(create, deps)
}
else {
updateEffect(create, deps)
}
}
useEffect钩子接受create函数以及deps数组,并且不返回任何值。
安装效果
当组件处于安装状态时,mountEffect函数首先创建一个钩子:
function mountEffect(create, deps) {
const hook = mountHook()
hook.state = pushEffect(
create,
undefined,
deps,
)
}
一旦它获得钩子,它将效果存储在钩子的state下。效果是通过pushEffect函数创建的:
function pushEffect(create, destroy, deps) {
const effect = {
create,
destroy,
deps,
next: null,
}
let queue = updatingFiber.updateQueue
if (queue === null) {
queue = { lastEffect: null }
updatingFiber.updateQueue = queue
queue.lastEffect = effect.next = effect
}
else { …
queue.lastEffect = effect
}
return effect
}
pushEffect函数使用所有效果信息创建一个效果,例如create、destroy、deps和next。然后,它找到当前正在更新的纤维下的updateQueue函数。如果队列是空的,新的效果将被附加。否则,新的效果将被附加到队列中。无论如何,它都将新的效果作为队列中的lastEffect对象附加。由于它是一个循环链表,在所有先前的指针操作完成后,它确保lastEffect.next对象仍然指向列表中的第一个效果。
更新效果
组件安装后,下一次它更新并达到useEffect钩子时,它进入updateEffect并通过克隆一个来获取钩子:
function updateEffect(create, deps) {
const hook = updateHook()
let destroy = undefined
const prevEffect = hook.state
destroy = prevEffect.destroy
if (deps) {
const prevDeps = prevEffect.deps
if (areDepsEqual(deps, prevDeps)) {
return
}
}
hook.state = pushEffect(
create,
destroy,
deps,
)
}
一旦我们有了钩子,我们就可以检查在安装中设置的先前效果,并比较deps数组是否已更改。如果deps数组没有从存储在prevEffect中的prevDeps对象中更改,它将返回而不将效果推送到updateQueue。
一个名为areDepsEqual的实用函数用于比较当前和先前的依赖数组。我们将在本章后面的创建效果部分详细检查此函数。
安排效果
效果有一些非常特殊的地方:效果被推入每个纤维的queue中,但它们是在屏幕即将更改时安排的。
对于每个效果,都有两个需要安排的回调函数:一个是create,另一个是destroy。以create为例,每个create函数都是通过enqueueEffect函数收集到一个列表中:
function enqueueEffect(fiber, effect) {
effectCreateList.push(effect)
if (!rootDoesHaveEffects) {
rootDoesHaveEffects = true
scheduleCallback(() => { flushEffects() })
}
}
之前的enqueueEffect函数将纤维和效果推入effectsCreateList数组。然后,它安排flushEffects回调。数组之所以不立即处理(或刷新)是因为它必须等待更新结束。在这里,React 使用一个全局标志(rootDoesHaveEffects)来确保它只触发此安排一次。
同样的过程也发生在每个 destroy 函数上。对于每个更新,我们最终会得到两个效果列表:一个用于 effectCreateList,另一个用于 effectDestroyList。尽管相似,这两个列表不一定包含相同的效果列表,因为有些效果没有 destroy 回调。此外,当组件卸载时,需要将 destroy 回调添加到 effectDestroyList。
scheduleCallback 函数相当有趣。它不是立即刷新并执行效果,而是稍后执行,就像在新的 JavaScript 任务中的异步任务一样。如果您对此感兴趣,请参阅本章末尾的 附录 B – 清除被动效果 部分的相关细节。
清除效果
只有在屏幕更新了 DOM 更改之后,React 才能再次访问效果列表:
function flushEffects() {
effectDestroyList.forEach(effect => {
const destroy = effect.destroy
effect.destroy = undefined
if (typeof destroy === 'function') {
destroy()
}
})
...
}
上述代码遍历 effectDestroyList,并对找到的每个 destroy 函数进行调用。在所有 destroy 函数被调用之后,React 清除 create 的效果列表:
function flushEffects() {
...
effectCreateList.forEach(effect => {
const create = effect.create
effect.destroy = create()
})
}
在前面的代码中,effectCreateList 通过在每个 effect 对象下调用 create 函数而被清除。create 函数的结果随后被用作 destroy 函数。
注意 React 遍历两个列表的顺序 – 它从 destroy 开始,然后转到 create。由于这两个列表都是从所有纤维中收集的,因此 create 函数可能包含对即将被销毁或清理的组件变量的引用。为了给 create 函数一个完全了解这种情况的机会,必须在发生之前调用 destroy 函数。简而言之,所有之前的效果都需要清理,然后才能考虑新的效果。
useEffect 钩子的使用方法
这是一项艰巨的工作,我们刚刚已经浏览了 useEffect 钩子源代码的简化版本。为了帮助我们从更高层次理解这一点,以下图表概述了 useEffect 钩子在 React 中的效果工作流程(参见 Figure 5.4):
![Figure 5.4 – useEffect 钩子和 React 效果工作流程]
![Figure 5.04_B17963.jpg]
Figure 5.4 – useEffect 钩子和 React 效果工作流程
让我们快速浏览一下 Figure 5.4。在更新过程中,当 useEffect 钩子被调用时,如果组件处于挂载状态,它将创建效果。如果组件处于更新状态,它将根据是否有任何依赖项更改来创建效果。如果没有依赖项数组的更改,则跳过效果。在所有情况下,当效果被创建时,它会被附加到纤维的 updateQueue 并存储在钩子的 state 中。
在屏幕更新之前,React 从所有纤维中获取所有效果,并安排一个刷新(在图 5.4中以虚线表示)。在将所有纤维更改应用到 DOM 之后,React 通过逐个调用它们来刷新它们,从之前的destroy效果开始,然后是新的create效果。
创建效果
效果可以被跳过。事实上,为了产生效果,需要在更新中创建效果。这种行为由一个名为deps的依赖项数组捕获。React 使用一个名为areDepsEqual的实用函数来帮助决定这个数组是否发生变化。让我们更仔细地看看这个函数:
function areDepsEqual(deps, prevDeps) {
if (!prevDeps) {
return false
}
for (let i = 0;
i < prevDeps.length && i < nextDeps.length;
i++)
{
if (Object.is(deps[i], prevDeps[i])) {
continue
}
return false
}
return true
}
areDepsEqual函数用于比较前一个prevDeps数组和当前deps数组之间的两个依赖项数组,如果所有元素都匹配,则返回true。虽然这听起来很容易执行,但它会根据依赖项数组中的元素遇到各种情况。我们将在以下列表中解释所有这些情况:
-
未提供
prevDeps,并且使用时省略了数组:useEffect(fn)当这种情况发生时,
areDepsEqual函数总是返回false,因此效果在每次更新中都会被创建。 -
除了第一次更新之外,
areDepsEqual返回true,因为对于挂载,deps数组仍然被认为是从undefined变化而来的。因此,效果只创建一次,之后不再创建。 -
deps不为空,前一个和当前依赖项之间的每个元素都会执行一个Object.is比较。我们已经在第四章中详细讨论了Object.is函数,使用状态启动组件。在这里,每个元素对都会进行这个比较,以确定数组是否发生变化:useEffect(fn, [a, b])除了挂载之外,如果任何元素发生变化,例如
a或b,效果也会被创建。
创建和销毁
如果从效果create函数中提供了一个destroy函数,这种情况也需要考虑。记住,我们有两个独立的数组,分别跟踪挂载和卸载的情况。一般来说,destroy函数在create函数之前被调用。
因此,这里有一个简短的列表来总结所有这些情况。
-
在挂载后运行一次
create。 -
对于任何
deps的变化,它都会运行一次destroy和create。 -
在卸载后运行一次
destroy。 -
如果没有提供
destroy函数,这个过程简化为以下单个情况。 -
它对任何
deps的变化都运行,包括挂载。
既然我们已经了解了useEffect钩子的设计以及所有调用回调的场景,让我们来实际测试一下useEffect钩子。
测试驱动useEffect钩子
效果回调在useEffect钩子的第一个输入参数中定义:
function Title() {
useEffect(() => {
window.title = ""
})
}
使用create函数的最常见方式可以通过使用() => {})来定义。对于对学习更多关于 JavaScript ES6 感兴趣的读者,请参阅第十章中的拥抱 JavaScript ES6 部分,使用 React 构建网站*。
关于这个效果函数的有趣事实之一是,多亏了 JavaScript 闭包,它可以访问在功能组件中定义的所有变量:
function Title({ text }) {
const a = 2
useEffect(() => {
console.log(a)
console.log(text)
})
}
之前代码中的create回调函数引用了a变量和text。如果没有 JavaScript 闭包,这两个变量必须通过输入参数显式地传递到内联函数中。
关于useEffect钩子的另一个有趣的事实是,效果是一个回调,其中通常可以看到涉及状态变化的回调。让我们看看一个例子:

图 5.5 – 可点击文本的按钮
假设我们有一个Title组件,它接收一个text属性。在其内部,有一个按钮。当这个按钮被点击时,它可以增加一个count状态。最初,count的值被设置为0,并且每当text属性发生变化时,它可以将count值重置回0。请注意,用户点击和text属性更改可能是完全无关的,并由不同的机制驱动。前者来自用户操作,而后者来自父组件的变化:
const Title = ({ text }) => {
const [count, setCount] = useState(0)
useEffect(() => {
setCount(0)
}, [text])
const onClick = () => {
setCount(count + 1)
}
console.log('count', count)
return (
<button onClick={onClick}>
{text}: {count}
</button>
)
}
为了实现所描述的行为,我们使用useEffect来分发setCount,并将deps数组设置为text属性。以下代码示例显示了两个点击和一个从a字母到b字母的文本更改的时间线草图:
|-----x-----x------------> click
a-----------------b------> text
R-----R-----R-----RR-----> update
0-----1-----2-----20-----> count
当挂载开始时,第一次更新以文本a和计数0开始。它还创建了一个效果,但由于count值已经是0,因此跳过了setCount分发。
当用户进行第一次点击时,onClick事件处理程序被调用,因此将count设置为1。同样适用于第二次点击,以便达到count值为2。当父组件将text属性从a更改为b时,它引发另一个更新。
在相同的更新中,未更改的count值再次被打印出来。但这次,useEffect的依赖数组检测到由于[text]引起的更改。因此,它创建了一个效果来调用setCount(0)。正如我们所知,setCount安排了另一个更新,之后它将count值恢复到0。
操场 – 带有父文本的按钮
您可以在此在线示例中自由玩耍codepen.io/windmaomao/pen/rNGOVor。
哇!这就是useEffect的工作方式。通过在回调函数中使用setState,可以请求额外的更新来更新屏幕。因此,效果在另一个更新中生效。此外,为了使效果生效,deps数组需要与正确的状态变化连接起来,因为如果我们遗漏了它,效果可能会停滞。让我们看看一个发生这种情况的例子。
缺少的依赖关系
让我们回到一个简单的设置:
const Title = () => {
const [count, setCount] = useState(0)
useEffect(() => {
console.log(count)
}, [])
}
如果你打算每次count变化时都打印出来,前面的代码缺少了正确的依赖关系——正确的应该是[count]而不是[]。
如果这个例子太明显了,让我们尝试一个不那么明显的例子:
const Title = ({ text }) => {
const [count, setCount] = useState(0)
useEffect(() => {
console.log(text + count)
}, [text])
}
在前面的代码中,我们引入了一个text属性并将其放入deps数组中。在这种情况下,我们得到的是当text变化时,它会打印出text + count。然而,如果count值因为用户的点击而变化,屏幕不会改变。为了解决这个问题,我们可以将count值添加到依赖数组中:
useEffect(() => {
console.log(text + count)
}, [count, text])
这里似乎出现了一个模式——如果一个效果回调使用了变量,那么这个变量需要包含在deps数组中。这个说法实际上有 99.9%的正确性。如果你故意不想在变量变化时更新屏幕,你可以跳过将其添加到依赖数组中。然而,React 并不推荐这样做。React 甚至添加了一个eslint-plugin-react-hooks插件来帮助我们找出我们遗漏了潜在依赖关系的案例。
你可能会想知道为什么 React 不希望我们遗漏任何依赖关系。这是因为,在 React 中,每个值(或状态)都应该与当前屏幕保持同步,并且默认情况下不能有违反这一规则的例外。在第八章,“使用 Ref 隐藏内容”,我们将展示如果你坚持要隐藏内容不被 React 看到的一个推荐方法。
现在我们已经看到了同时使用效果和状态的情况,让我们看看另一个我们可能会遇到问题的例子。
无限循环
将状态和效果结合起来可能会引起另一个有趣的问题,因为一个效果可以改变状态并因此安排一个新的更新,然后新的更新可以创建一个新的效果并改变状态,这可以无限期地继续下去。这可能会导致一个无限循环。
我们可以通过一个快速示例来演示这一点:
function Title() {
const [count, setCount] = useState(0)
useEffect(() => {
setCount(count+1)
}, [count])
return <h1>{count}</h1>
}
在前面的代码中,有一个效果会在任何count变化后增加count状态。在调用setCount之后,在下一次更新中,useEffect会检测到依赖数组中的变化,从而再次调用setCount。因为每次发生这种情况我们都会得到一个新的count数字,这个过程不会停止,就像你在下面的时间线草图中所看到的那样:
RRRRRRRRRR> update
0123456789> count
通常情况下,我们不会故意这样做,但我们在更复杂的设置中可能会意外地遇到这种情况,特别是当涉及许多不同的状态和效果时。我们的任务是尽可能避免无限循环,因为 React 没有考虑到这一点。
那么,我们如何处理代码中的无限循环呢?我们可以使用 if 语句跳出循环,这通常是成本效益最高的方法:
useEffect(() => {
if (count >= 1) return
setCount(count+1)
}, [count])
在前面的代码中,我们在效果的第一行内部添加了 if 语句后,时间线确认我们没有无限循环了:
RR--------> update
01--------> count
如您所见,跳出循环并不太难。您可以将这视为您想要达到的平衡状态——效果确实需要被触发以进行状态改变,但一旦达到平衡状态,它就会停止而不会继续循环。
现在我们已经了解了如何使用 useEffect,让我们看看两个实际的应用案例。
useEffect 示例
useEffect 钩子通常用于任何副作用——无论是从外部对象读取还是写入外部对象。在接下来的几节中,我们将看到两个更多示例:查找窗口大小 和 获取 API 资源。
查找窗口大小
假设我们想在运行时知道当前浏览器窗口的大小,以便问候标题可以完美地显示在屏幕上(见图 5.6):

图 5.6 – 查找窗口大小
这可以通过正常的 CSS 媒体查询来完成,但这次,我们想通过 JavaScript 来实现,因为获得的运行时 JavaScript 变量可以用于除 CSS 之外的其他目的:
const Greeting = () => {
const [width, setWidth] = useState(0)
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth)
}
window.addEventListener("resize", handleResize)
handleResize()
return () => {
window.removeEventListener("resize", handleResize)
}
}, [setWidth])
const on = width > 600
return <h1>{on ? "Hello World" : "Hello"}</h1>
}
useEffect 钩子在这里非常适用。组件挂载后,我们可以监听由 window 对象提供的 resize 事件。一旦开始监听该事件,每当窗口大小改变时,就会触发一个 handleResize 函数,将 width 状态设置为新的窗口大小。我们还在挂载时调用 handleResize 以获取初始窗口大小。
在这个例子中,如果当前宽度大于 600 像素,我们知道它可以在屏幕上显示 Hello World 字符串。否则,将使用 Hello 字符串。这表明我们可以使用 JavaScript 在运行时根据窗口大小动态控制显示。
操场 – 查找窗口大小
您可以自由地在这个在线示例中尝试 codepen.io/windmaomao/pen/BadRoNN。
为了防止内存泄漏,我们从 useEffect 回调函数中返回一个 destroy 函数,在组件卸载时移除注册的事件监听器。
这里有一个需要注意的微妙细节——依赖数组中包含 setWidth,因为我们已经在 useEffect 函数中引用了 setWidth。如果您还记得从 第四章 的内容,即 使用状态启动组件,我们知道 setWidth 函数实例在挂载后不会改变,所以实际上,[setWidth] 在这里是可以省略的。但 React 强制要求我们添加它,因为当 setWidth 改变时,效果需要被重新创建。
获取 API 资源
useEffect 的一个流行用法是获取 API 资源并在屏幕上显示数据:
![图 5.7 – 获取 API 资源,加载状态]
![图片 5.07_B17963.jpg]
图 5.7 – 获取 API 资源,加载状态
这里的想法是使用 fetch JavaScript 函数来获取资源。在加载期间,屏幕应该显示 loading...(如图 5.7)。资源成功获取后,loading... 字符串应该被移除,并在屏幕上替换为获取的文本:
const Title = () => {
const [text, setText] = useState("")
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch("https://google.com").then(res => {
setText(res.title)
setLoading(false)
})
}, [setText, setLoading])
if (loading) return "loading..."
return (<h1>{text}</h1>)
}
在前面的代码中,我们使用一个 text 状态来存储获取的文本,并使用一个 loading 标志状态来存储加载状态。使用 useEffect 钩子来获取资源,当成功时,它会更新 text 和 loading 标志。
游戏场 – 获取 API 资源
欢迎您在此在线示例中尝试 codepen.io/windmaomao/pen/ZEJKbev。
在这个例子中,效果没有返回任何内容。实际上,在这种情况下需要清理,但我们将在第八章,使用 Ref 隐藏内容中详细探讨这一点。
注意,在 return 语句之前使用了一个 if 语句。在第三章,Hooking into React中,我们提到 if 不能在钩子语句之间使用。因此,在这种情况下,我们将它移动到所有钩子语句之后,并放在最后一个返回语句之前。
在这种情况下,if 语句充当短路。如果 loading 状态为 true,它不会做任何进一步的操作,只是返回一个 loading... 字符串。这种 if 语句方法是最经济实惠的确保屏幕仅在内容可用时渲染材料的方法之一。
摘要
我们在本章中做了很多工作。首先,我们了解了什么是副作用,并深入研究了 useEffect 钩子的设计。我们发现了如何创建一个效果,然后在 UI 更新后稍后调用它。我们还学习了基于依赖数组的创建效果的多种场景。之后,我们探讨了使用 useEffect 时缺少依赖项、过时值和无穷循环的陷阱。最后但同样重要的是,我们学习了如何在实际组件中应用 useEffect,例如在浏览器中查找窗口大小和从在线服务器获取 API 资源的示例。
在下一章中,我们将发现 React 家族中的下一个钩子,并关注如何通过使用前一个更新的值来应用优化以提升性能。
问题和答案
这里有一些问题和答案来刷新您的知识:
-
什么是副作用?
副作用是指函数依赖于或修改其输入参数之外的内容。一个非常常见的副作用是在函数内部使用
console.log。 -
什么是
useEffect?useEffect钩子是在 React 中定义一个在屏幕更新后调用的副作用回调的方式。效果可以每次调用或当其依赖项之一发生变化时调用。因此,useEffect钩子也可以用于监听值的变化。 -
useEffect最常用的用途是什么?useEffect钩子是 React 家族中的常见钩子。如果与useState钩子一起使用,它可以轻松与外部资源通信并在屏幕上显示结果。外部资源可以是 Web 服务器、DOM 元素、window对象、document对象或任何第三方实体。
附录
附录 A – React 副作用
当谈到 React 时,最知名的效果是被动效果,正如本章所介绍和详细解释的那样。然而,React 支持不同类型的效果,并且在未来可能会添加更多。目前另外两种是突变效果和布局效果。
所有效果都与某些功能共享,例如在屏幕更新之前从纤维中收集。但它们在某些方面也有所不同。以突变效果为例。在引擎下,这类效果是最重要的效果,因为每个突变效果都跟踪 DOM 元素的添加、删除或更改。因此,所有纤维协调最终都会产生被提交到屏幕上的突变效果。DOM 元素的突变也是更新的一部分,或者更准确地说,是更新的提交阶段。而被动效果在更新之后运行,所有突变效果都在被动效果之前发生。
为了纠正被动效果在更新之后运行的事实(因为那时可能已经太晚执行某些操作),创建了布局效果以稍早调用。关于布局效果的各个方面都与被动效果相似,除了它在突变效果之后立即调用并在更新结束时刷新。所有三种效果之间的关系和时机可以总结如下,如图 5.8中概述的提交阶段:

图 5.8 – 提交阶段中的 React 效果
注意,在提交阶段,只有突变和布局效果会被刷新出来。被动效果最初被安排并随后入队,但不会在提交之后刷新。请继续阅读到附录 B – 刷新被动效果部分以获取更多详细信息。
附录 B - 刷新被动效果
为了理解被动效果是如何被安排和刷新的,我们首先需要提到JavaScript 任务。

图 5.9 – JavaScript 任务
在图 5.9中,我们可以看到三个JavaScript任务。什么是任务?任务是任何由标准机制安排运行的JavaScript代码。在左边的第一个任务中,我们完成了一次更新。通常,我们只需要了解这么多关于运行JavaScript代码的情况。
然而,由于JavaScript是一个单线程引擎,在当前任务的执行过程中,可能会有更多的工作添加到待处理队列中。一个典型的例子是setTimeout调用,它将回调添加到队列中而不是立即在同一任务中调用它。API 调用(如 promises)通常也属于这一类。这是这些回调被称为异步操作的主要原因。
没有规定每个任务应该持续多长时间。当一个任务完成时,它会查找待处理队列中的所有工作,然后逐个调用它们,等所有事情都完成后,它再次查看待处理队列。这个过程会永远重复。这就是JavaScript引擎所做的事情。
在我们的情况下,我们有一个非常短暂的任务(图 5.9中的中间任务)后面跟着第三个任务。猜猜看——这就是被动效果开始刷新的时候。从这一点我们可以确定,useEffect回调中的回调是异步调用的。
我们提到多个状态分发也是以延迟方式打包和执行的——那么setState分发也是一个异步调用吗?为了回答这个问题,请继续阅读附录 C - 分发是异步的吗部分。
附录 C – 分发是异步的吗?
由于被动效果是在新任务中调用的,在这个时候,你可能想知道setState分发是在同一个任务中运行还是在新的任务中。这是一个非常好的问题。
为了回答这个问题,我们需要一个时间参考点。假设我们有一个事件处理程序,并在其中有一个setState分发调用:
onClick = () => { setState(1) }
onClick事件是一个通过用户操作请求作为回调的事件。假设调用onClick事件的任务是称为Task 1。
在 React 17(不是当前版本)中,setState代码是同步的,这意味着它在整个更新过程中都在同一个Task 1中运行。React 决定将它们都在同一个任务中完成更有效率。那么,我们为什么说setState对象通常是延迟的呢?
onClick = () => {
setState(1)
// state value isn't changed yet
}
这是因为在setState之后,值还没有被改变。只有下一次更新才会将状态设置为新的版本。但是将setState称为异步操作并不完全准确(如果不是错误的话),因为所有这些过程都是在同一个JavaScript任务中执行的。
如果我们将setState放入useEffect钩子中呢?这会不会改变同步或异步的讨论?
useEffect = (() => {
setState(1)
}, [])
到现在为止,我们知道useEffect回调会在一个新的JavaScript任务中被调用——让我们称这个任务为任务 1。而setState也是在同一个任务 1中运行的。这使得它的行为与之前讨论的事件处理器(如onClick)非常相似。因此,我们也可以将被动效果视为一个比事件处理器更被动的"事件"。
这并不阻止我们真正想要进行异步分发。让我们看看一个例子:
const [state, dispatch] = useState(1)
const onClick = () => {
setTimeout(() => {
dispatch(3)
}, 0)
dispatch(5)
}
在前面的例子中,setTimeout被用来以异步方式触发一个回调。在鼠标点击后,首先调用dispatch(5)。更新后,即使超时时间设置为0,也会调用dispatch(3)。
请记住,如果你这样做,你不仅是以异步的方式运行回调,而且还打破了 React 的调度周期。你可能想要这样做的原因是在 DOM 变化期间可能会出现冲突,例如在拖放处理期间。为了在我们进行状态更改之前完成我们的代码,我们可以将分发推到下一个JavaScript任务队列。
第六章:使用 Memo 提升性能
在上一章中,我们学习了useEffect钩子的设计和如何使用它来管理React中的副作用。在本章中,我们将转向一个优化主题,用于重用最后的赋值。我们首先介绍我们如何在典型的 Web 应用程序中遇到性能下降。然后,我们将详细介绍useMemo的设计和源代码,并描述各种条件重用值的多种方式。接着,我们将优化技术应用于两个常见案例:点击搜索和搜索防抖。最后,本章还包括附录部分的两个附加主题,非经典记忆和跳过子组件更新。
在本章中,我们将涵盖以下主要主题:
-
性能下降
-
理解
useMemo的设计 -
重用最后的赋值
-
测试驱动
useMemo -
useMemo示例 -
问答
-
附录
性能下降
当我们构建一个网站时,我们通常从一个草案或原型版本开始,其中包含一些页面,这些页面使用示例数据和初步逻辑进行布局。目的是从小处着手,看看网站是否有增长潜力。虽然这是一个非常常见的做法,但有趣的是,大多数与性能相关的问题在这个阶段并没有显现出来。当具有真实业务逻辑的网站开始增长时,我们开始体验到性能下降的问题。了解这些问题最初是如何产生的非常有价值,因为它有助于我们规划网站的扩展。
让我们从零开始构建这样一个案例。一个在函数组件体内定义的变量在其被调用时会被评估:
const Title = ({ text }) => {
const a = 1
...
}
在前面的代码中,a变量被赋值为一个1常量。当text属性每次变化时,存储这样的数字不应该花费我们太多,因为a每次都会被重新赋值。但如果我们需要计算一些重量级的操作,比如在一个大数组中匹配文本呢?
const Title = ({ text }) => {
const found = matchTextInArray(text)
...
}
假设前面的matchTextInArray函数平均需要 200 毫秒来完成,这有点昂贵。我们可以争辩说,只有当text发生变化时,found变量才会被赋值,所以如果text不经常变化,我们不必担心found会频繁赋值。让我们对此进行一些思考。
函数组件可以因各种原因被调用。在React中,函数组件的更新主要是由状态变化触发的。然而,状态可能来自Title组件内部或来自Title组件的父组件(或祖父组件)。因此,我们不能仅仅通过观察组件来假设组件的更新频率。
想象一下,如果用户决定刷新页面;它应该导致此页面的所有组件更新。从某种意义上说,组件只能确定哪些内容需要更新,但不能完全决定何时更新。换句话说,当涉及到更新时,组件也依赖于其父组件的行为。
让我们看看以下示例:
const Title = ({ text, flag }) => {
const found = matchTextInArray(text)
...
}
在前面的代码中,Title 组件获得了一个额外的 flag 属性。因此,每当 flag 属性翻转时,它就会渲染并花费 200 毫秒来计算 a。你可以想象这个标志就像是一个来自父组件的用户投票按钮。
如果用户持续点击按钮,多个 200 毫秒可以迅速累积成为一秒或两秒,应用程序的性能现在直接与用户翻旗的速度挂钩。当这种情况发生时,用游戏术语来说,它会导致延迟,或者帧率下降。用户应该开始感觉到响应延迟,并失去使用网站的信心。
当这种情况发生在游戏中时,我们该怎么办?嗯,那正是玩家需要升级他们的游戏硬件的时候。但这也可能是开发者利用优化机会的时候。
重复使用之前的赋值
那么,在性能下降的情况下,我们该怎么办?让我们看看当前时间线之后我们有什么。当 flag 或 text 属性发生变化时,found 变量会得到一个新的赋值:
|----TFTF--------TF------> flag flip
----------a---------b----> text change
c----cccc-c------cc-c----> new assignment
所以,在我们的情况下,我们想要承认的是,matchTextInArray 并不依赖于 flag,因为该函数除了 text 之外没有其他输入参数。
为什么我们想在之前的赋值仍然有效时执行新的赋值?当 flag 属性变化时,我们能否跳过赋值?
我们在 第五章,使用 Effect 处理副作用 中了解到,效果可以用来监听值的变化。在我们的情况下,我们能否监听 text 属性的变化,在忽略其他值的同时进行赋值?让我们试一试:
const Title = ({ text, flag }) => {
const [found, setFound] = useState("")
useEffect(() => {
setFound(matchTextInArray(text))
}, [text])
...
}
在前面的代码中,我们使用 useEffect 的依赖数组来对 text 变化做出反应,并在 matchTextInArray 之后更新 found 状态。这可以通过以下时间线草图得到证实:
|----TFTF--------TF------> flag flip
----------a---------b----> text change
-c---------c---------c---> new assignment
这个解决方案直接可用。标志来回翻转很多次(六次),但由于文本变化,我们只有三次新的赋值。因此,赋值并不依赖于翻转。这真是太好了,因为我们成功地克服了潜在的性能下降。
虽然这个解决方案是可行的,但我们希望对其进行改进,因为它使用副作用来处理赋值,而我们看到这个赋值不必是一个副作用。由于副作用在更新后收集,它必须等待下一次更新。这意味着赋值的行为与直接赋值,如 a = 1,非常不同。最后但同样重要的是,使用 found 状态来保存赋值值,而这个值不必是一个状态。如果能解决所有这些问题就更好了。
React 添加了一个 useMemo 钩子来做到这一点,而不需要其他障碍。钩子的任务是允许我们在更新中重用之前的赋值。让我们首先看看 useMemo 的设计。
理解useMemo的设计
React 提供了一个useMemo钩子,通过一个函数支持值赋值,该函数可以返回新值或从上一个更新返回旧值:
const Title = () => {
const label = useMemo(() => {
return "Hello World"
}, [])
}
useMemo函数将其第一个输入参数作为create函数。如果被调用,该函数返回一个新值。第二个参数是deps依赖数组,类似于useEffect中的deps。在前面的例子中,"Hello World"在挂载后只被分配给label变量一次。
除了基本的钩子支持外,不需要额外的数据结构来支持useMemo,如图 6.1 所示:

图 6.1 – useMemo 的数据结构
钩子的状态在更新之间持续存在,每个钩子函数都必须定义它想要持久化什么(或以何种格式)。例如,useState钩子存储状态数组,useEffect钩子存储效果对象,现在useMemo钩子存储与赋值相关的内容。实际上,useMemo采用分配值和依赖数组的格式作为[value, deps]。
useMemo的源代码在典型的钩子设置中以mountMemo和updateMemo的形式组织,这取决于纤维是否处于挂载状态或通过isFiberMounting标志进行更新,如第三章中所述,Hooking into React:
function useMemo(create, deps) {
if (isFiberMounting) {
return mountMemo(create, deps)
} else {
return updateMemo(create, deps)
}
}
useMemo钩子接受create赋值函数和deps依赖数组作为输入。name、create表示它在被调用时创建一个新值。
当处于挂载状态时,它首先通过创建一个来获取钩子对象:
function mountMemo(create, deps) {
const hook = mountHook()
const value = create()
hook.state = [value, deps]
return value
}
如果没有提供deps,则默认转换为null,并通过调用create赋值函数来存储初始值。在返回值之前,初始值及其依赖都通过数组存储在钩子下的state属性中。
当处于更新状态时,它通过克隆一个来获取钩子:
function updateMemo(create, deps) {
const hook = updateHook()
const prevState = hook.state
if (prevState !== null) {
if (deps !== null) {
const prevDeps = prevState[1]
if (areDepsEqual(ndeps, prevDeps)) {
return prevState[0]
}
}
}
const value = create()
hook.state = [value, deps]
return value
}
一旦我们有了钩子,它就从钩子的state中获取prevState,这是一个包含上一个值和上一个依赖数组的数组。
它通过areDepsEqual检查依赖是否已更改。如果没有变化,它将简单地返回上一个值。如果有变化,它将再次调用create赋值函数以更新到新value。在我们返回值之前,值和依赖都存储在钩子的state中。以下是useMemo工作流程的总结:

图 6.2 – useMemo 工作流程
与 useState 和 useEffect 相比,useMemo 非常直接。它不涉及任何分发或效果。相反,你可以将其视为一个特殊的赋值语句。当满足 deps 依赖项时,它就会创建一个值并将其作为当前值返回。在所有情况下,都会返回当前值。因此,更准确地说,在所有情况下都会进行赋值;当条件不满足时,会重用旧值。
既然我们已经了解了 useMemo 的设计,现在就让我们带你了解这个特殊赋值可以做到的所有场景。
重用最后一个赋值
重用值和记住值有时会指相似的行为。然而,值得注意的是,useMemo 钩子只能记住过去的一个值,即最后一个值。
默认情况下,单个 JavaScript 变量具有一个作用,除非被新的赋值覆盖,否则它将保留之前分配的值。因此,在阅读“memo”这个词时要小心。如果你将“memo”视为单个值而不是记住所有值,那么它可以帮助你正确地可视化它,就像 React 设计的那样。如果你对经典记忆感兴趣,请查看本章末尾的 附录 A – 非经典记忆 部分。
useMemo 如何重用之前的赋值是由 deps 依赖项数组控制的,它使用 areDepsEqual 工具函数来比较前一次和当前更新之间的两个依赖项数组。我们已经在 第五章 中考察了这个函数,使用 Effect 处理副作用。在这里我们将跳过源代码,直接进入与每个依赖项数组配置相对应的场景。
同样,这里也有三种情况:无依赖项、空依赖项和某些依赖项:
-
没有提供依赖项的
useMemo不常见,但很容易看到useMemo钩子语句和直接赋值可以非常快速地相互替换,因为它们在...中共享赋值语句。 -
提供了
deps数组但没有元素,这意味着值不依赖于任何东西。因此,值在挂载后只创建一次:const v = useMemo(() => {...}, [])如果你想要在所有更新中保持一个静态值,这是一个很好的用途。你可能会想知道为什么我们不能在组件外部声明静态值。这是因为赋值仍然可以使用组件内部的变量。
-
useMemo。当deps的元素不为空时,它会比较前一次和下一次依赖项之间的每个元素,以确定是否有变化:const v = useMemo(() => {...}, [a, b])如果任何元素发生变化,值将被重新赋值。
有一个需要注意的事项 – 在所有提到的情况下,都会为所有更新执行赋值。尽管有时赋值似乎被跳过了,但我们的真正意思是赋值是从上次满足条件时重用的。
赋值值类型
从赋值返回的值类型可以是任何格式——字符串、数字、对象,甚至是函数。这使得 useMemo 在满足所有需要重用值的场景时更加灵活:
const a = useMemo(() => {
return b + 3
}, [b])
以下 useMemo 的使用给 b 加上 3 并将结果赋值给 a,当 b 发生变化时。同样,我们也可以构建一个使用对象的用法:
const obj = useMemo(() => {
return { name }
}, [name])
在前面的使用中,当 name 发生变化时,一个具有 name 属性的对象被赋值给 obj。我们甚至可以构建一个函数的用法:
const fn = useMemo(() => {
return () => {
return 1
}
}, [])
在前面的代码中,我们构建了一个赋值,在挂载后创建了一个函数实例。为了使事情更容易理解,我们可以将 useMemo 取出,看看原始赋值是什么:
const fn = () => { return 1 }
当 useMemo 应用于非原始值,如对象、数组或函数时,有一些微妙之处。在创建这些值时,你会得到一个指向新内存空间的新值。这意味着当依赖条件不满足时,将使用旧内存空间。
总的来说,useMemo 钩子可以用作特殊的赋值来返回任何类型的值。
跳过更新的神话
我们可能都会从“memo”这个词的使用中想到,也许 useMemo 可以帮助我们跳过更新:
const Title = () => {
const a = useMemo(() => { ... }, [])
return <Child a={a} />
}
在前面的代码中,由于 a 变量在挂载后没有获得新值,可能 Child 组件也没有收到任何新的更新。不幸的是,情况并非如此。
这个问题的答案可以追溯到是什么让 React 中的更新发生。我们在本章开头提到,Title 组件或其父组件的状态变化可以触发新的更新,但 a 变量不是一个状态,也没有人为更新这个值进行分发。
在某种程度上,useMemo 钩子与更新没有直接关系。它没有像 useState 或 useEffect 那样直接挂钩到更新的功能。事实上,useMemo 除了条件赋值之外,没有做任何其他的事情。
基于属性变化跳过更新可以使用 React 提供的 memo 函数来完成。memo 和 useMemo 是两回事;我们在这本书中不讨论 memo。如果你真的想用 useMemo 钩子跳过更新,我们在本章末尾的 附录 B – 跳过子组件更新 部分提供了一个特殊的用法。
现在我们已经知道了 useMemo 是什么以及它能做什么,不能做什么,让我们来试驾一下。
测试 useMemo
让我们通过改进本章开头看到的 useMemo 钩子示例来获得一些性能提升:
const Title = ({ text, flag }) => {
const found = useMemo(() => {
console.log('created') ➀
return matchTextInArray(text))
}, [text])
console.log('updated', found) ➁
...
}
以下代码用 useMemo 替换了 useState 和 useEffect。让我们看看时间线,看看它带来了什么变化:
|----TFTF--------TF------> flag
----------a---------b----> text
R----RRRR-R---------R----> updated ➁
c---------c---------c----> created ➀
当 text 发生变化时,在 "created" 序列中创建了一个新值,与 flag 的翻转无关。这次甚至更好,因为现在在分配 found 值和接收 text 变化之间没有延迟,因为这是一个在同一更新下的直接赋值。
重要的一点是,无论是否有 useMemo,都没有引入大的代码结构变化来解决性能问题。事实上,要切换回非优化版本,我们可以省略依赖项,或者简单地通过一行或两行代码移除 useMemo 的使用。
好的,我们现在已经看到了如何使用 useMemo 钩子。接下来,让我们看看两个例子,展示我们如何将其应用于一些真实性能问题。
useMemo 示例
useMemo 钩子通常是我们想要优化网站性能和/或提升用户体验时使用的钩子。因此,它通常用于解决问题。在接下来的几节中,我们将通过两个与搜索相关的例子来展示如何将 useMemo 作为优化工具的应用。
点击搜索
假设你有一个水果列表,并且你想要通过输入框和按钮搜索它以找到匹配的水果。例如,输入 "bl" 应该从水果列表中返回黑莓和蓝莓。

图 6.3 – 点击搜索用户界面
这里是一个在 fruits 全局变量中定义的水果列表:
const fruits = ["Apple", "Banana", "Blackberries", ...]
我们使用一个 text 状态来存储用户当前输入的字符串。当用户点击时,text 被发送到 query 状态作为当前搜索查询字符串:
const Title = () => {
const [text, setText] = useState('')
const [query, setQuery] = useState('')
const matched = fruits.filter(v => v.includes(query))
const onType = e => { setText(e.target.value) }
const onSearch = () => { setQuery(text) }
console.log('updated', text) ➁
return (
<>
<input value={text} onChange={onType} />
<button onClick={onSearch}>Search</button>
{{matched.join(',')}
</>
)
}
fruits 列表通过 query 字符串进行过滤以找到我们的 matched 水果。前面的代码是现成的。但是,在我们将这段代码部署到生产环境中后,我们收到了一些反馈,指出当用户快速在键盘上输入或频繁更正错误时,UI 会变得有些卡顿。
在深入调查问题时,我们发现问题是由以下代码行引起的:
const matched = titles.filter(v => v.includes(query))
我们可以查看时间线来帮助我们可视化问题:
|----kkkk--------kk------> user type
----------x---------x----> search click
R-----RRRR-R------RR-R---> updated ➁
m-----mmmm-m------mm-m---> created ➁
用户输入的每个按键都转化为一个更新,其中创建 matched 值。这正是我们在介绍中讨论的相同问题,只是 text 和 query 都是状态而不是属性。问题的本质是相同的。
因此,这里的解决方案是看看我们是否可以限制 matched 值只在 query 变化时创建。这样,当用户输入时,我们就不需要不断创建新的 matched 值。让我们用 useMemo 来尝试这个想法:
const matched = useMemo(() => {
console.log('created', query) ➀
return titles.filter(v => v.includes(query))
}, [query])
注意,我们将 query 添加到了 deps 变化依赖数组中。如果其他东西发生了变化——例如,用户输入——它不应该影响 matched 值。让我们通过以下时间线来确认这一点:
|----kkkk--------kk------> user type
----------x---------x----> search click
R-----RRRR-R------RR-R---> updated ➁
m----------m---------m---> created ➀
现在,我们得到的新匹配值数量减少了,性能也不再与用户如何输入键盘按键有关。这对于我们所有人来说都是一件好事,因为如今我们都能快速打字。
操场 – 点击搜索
你可以自由地在这个在线示例codepen.io/windmaomao/pen/OJjmjBv中尝试。
点击搜索是用户体验的经典例子之一,当在网络上执行搜索时,它作为一个稳固的模式。虽然这种方法仍在使用,但如今,用户对响应式网站有更高的期望,因此存在一种更受欢迎的方法来通过这个问题来提高用户体验。
搜索去抖动
我们都曾在某个时刻使用过Google进行搜索。当我们输入搜索栏时,下拉菜单会滑下来,提供与用户输入最接近的匹配项。这是一个非常流畅的用户体验,因为Google已经训练我们几十年来习惯它,如图 6.4 所示:
![图 6.4 – 搜索去抖动 UI]
![图 6.4]
图 6.4 – 搜索去抖动 UI
用户现在不再有可以点击的搜索按钮;用户需要做的只是继续输入。当用户停止输入时,匹配的列表就会显示出来。那么,我们该如何实现这个功能呢?首先,让我们移除搜索按钮:
const Title = () => {
const [text, setText] = useState('')
const [query, setQuery] = useState('')
const matched = useMemo(() => {
console.log('created', query) ➀
return fruits.filter(v => v.includes(query))
}, [query])
const onType = e => {
const v = e.target.value
setText(v)
setQuery(v)
}
console.log('updated', text) ➁
return (
<>
<input value={text} onChange={onType} />
{matched.join(',')}
</>
)
}
尽管我们没有点击的按钮,但点击搜索的本质并没有改变。某种意义上,我们仍然需要在用户即将完成输入并期望发生搜索的时候找到一个“点击”的时刻。因此,这里的想法是找到正确的“点击”时刻。
我们究竟如何知道这样的时机,即在某个事情即将发生但尚未发生的时候?实际上,这个问题有一个完美的类比。你是否想过电梯在关闭门之前如何等待所有乘客进入?门是如何知道何时是关闭门的正确时间的?它是如何预判在下一秒是否还有其他人想要挤进来的?
给定一个fn函数,如果收到调用它的请求,它不会立即执行。相反,它会等待一段时间。在这段时间内,如果没有更多的请求来调用它,它将在时间结束时调用。这种行为被称为去抖动。
去抖动最初是为了机械开关和继电器而引入的——巧合吗?为了解决按键过于频繁的问题,键盘处理器通过“组合”它们在时间上形成一个单一的点击。相当多的库实现了debounce函数;为了避免重复造轮子,这本书从名为Lodash的库中借用了它:
const debouncedFn = debounce(fn, dt)
debounce函数接受原始的fn函数和一个等待时间dt,在调用fn之前等待。它返回一个新的具有去抖动行为的debouncedFn函数。我们现在调用的是debouncedFn而不是fn。
让我们应用它来找到“点击”的正确时刻:
const setDebouncedQuery = debounce(
t => { setQuery(t) }, 300
)
const onType = e => {
const v = e.target.value
setText(v)
setDebouncedQuery(v)
}
在前一个更改中,每当用户输入时,都会调用去抖动的setDebouncedQuery版本。但并非所有输入都会通过setQuery更新查询;相反,它会等待 300 毫秒,以确保用户恰好停止输入,那一刻就是我们的“点击”时刻。多个用户按键组合成一个单一的setQuery。正如我们所设置的,每次query更改后,useMemo钩子都会创建一个新的搜索。
剩下只有一个小问题需要解决,以便让一切正常工作;setDebouncedQuery函数在每次用户按键时创建一个新的实例,这并不是我们想要的。相反,我们希望有一个setDebouncedQuery的实例,以便所有按键都可以去抖动到同一个setQuery函数。我们如何重用最后一个函数实例?是的——使用我们刚刚学到的useMemo钩子:
const setDebouncedQuery = useMemo(() => {
return debounce(t => {
console.log('clicked') ➂
setQuery(t))
}, 300)
}, [setQuery])
在前面的代码中,我们通过使用useMemo钩子改进了setDebouncedQuery,并在其中为setQuery插入了debounce。现在,让我们通过以下时间线来确认:
|----kkkk--------kk------> user type
---------x---------x-----> "clicked" ➂
R-----RRRRR-------RRR----> updated ➁
m---------m---------m----> created ➀
太棒了!对于两组用户会话,它执行了两次搜索。将这个时间线与之前的经典点击搜索时间线进行比较,你可以看到它们非常相似。物理按钮点击被一个想象的“点击”所取代,我们得到了更好的用户体验。
操场 – 搜索去抖动
欢迎免费尝试这个在线示例:codepen.io/windmaomao/pen/xxLdPga。
通过这两个示例,我们现在应该知道如何有效地应用useMemo。
摘要
在本章中,我们首先了解了一个新的钩子,useMemo。我们首先简要地回顾了什么会导致性能下降,然后我们学习了useMemo的设计,并逐行阅读了如何构建一个优化方案来重用最后一个值,而无需每次都创建一个新的值。然后,我们探讨了基于依赖数组的所有重用值的场景。我们尝试了useMemo,最后,我们看到了它如何应用于两个经典示例:点击搜索和搜索去抖动。
在下一章中,我们将深入了解 React 家族中的另一个钩子,其中状态更改可以分发到多个位置以进行区域更新。
问题
这里有一些问题和答案来刷新你的知识:
-
什么是
useMemo?useMemo钩子是一个赋值语句,其中当依赖项之一发生变化时创建新值。它可以用来最小化值的创建,所以它有时表现得像赋值被“跳过”一样。 -
useMemo的常见用法是什么?它主要用作优化,以避免在每次渲染时进行繁重的操作。如果某些评估被过度使用,从而阻塞 UI,那么考虑使用
useMemo限制其使用到仅相关的条件是正确的时间。例如,如果输入与该任务无关,我们可以将其从依赖列表中移除。 -
你如何使用
useMemo进行记忆化?useMemo并不记住所有过去值,只记住最后创建的值。因此,最佳用法是将其用作特殊赋值替换,而不是缓存机制。
附录
附录 A – 非经典记忆化
很容易将 useMemo 与计算机科学中使用的 记忆化 混淆,正如其名称所暗示的。
记忆化是计算机程序中的一种优化技术,主要设计用于通过存储昂贵操作的结果并返回在相同条件下之前已计算出的缓存结果来加速过程。最后一部分,“在相同条件下之前”,是使其特殊化的部分。
斐波那契 序列是一个经典的记忆化问题。如果使用递归算法编写,可能会非常昂贵;因此,我们倾向于使用缓存存储来存储所有过去计算出的值:
const fibs = { 0: 1, 1: 1 }
function fib(n) {
if (!fibs[n]) {
fibs[n] = fibs[n - 1] + fibs[n - 2]
}
return fibs[n]
}
上述代码提供了一个专门的 fib 函数;如果你按顺序从 1、2 等调用它,它可以不费太多力气地给出下一个数字。
在 React 应用中,我们通常从 n=0 开始;除非 n 移动到下一个数字,我们不想计算新的值。我们可以添加 useMemo:
const Title = ({ n, text }) => {
const f = useMemo(() => fib(n), [n])
return <div>{text} – {f}</div>
}
我们首先迅速发现的是,它实际上重用了 fib 函数,这意味着它们的函数性完全不重叠。从另一方面来说,useMemo 并不做 fib 做的事情。太神奇了!
另一个发现是,Title 组件所做的确保当另一个 text 属性改变时,它不会再次计算 f。但如果我们把 n 从 3 改为 2,它仍然会创建一个新的数字。这意味着代码可以简化为以下内容:
const Title = ({ n, text }) => {
const f = fib(n)
return <div>{text} - {f}</div>
}
哈哈,现在有点好笑了。我们实际上移除了 useMemo。为什么?因为通过 fib 已经实现了节省计算。此外,useMemo 钩子不提供任何这种存储。
useMemo 为 最后一个值 建立了一个记忆化存储。如果你能利用这一点,那就太好了。否则,你只是使事情过于复杂。
不要期望 useMemo 给你更多值,因为它甚至没有多个值的键/值映射。React 本身是一个大状态机;给定一个变化,它会移动到新的状态。它所关心的只是它即将进入的当前和下一个场景。从 t-1 移动到 t 是 React 擅长的事情,但不是从 t 移动到 t-2。因此,useMemo 不能适应记忆化上下文,这通常是缓存所涉及的内容。
附录 B – 跳过子组件更新
我们将使用由 React Developer Tools 提供的 profiler 图表来检查浏览器内 React 组件层次结构,如图 图 6.5 所示:

图 6.5 – 使用 Child bailout 失败的 Profiler 渲染
Profiler 图表提供的是一次更新中所有访问到的 fibers 的拓扑概览。拓扑本身就能说明它们是如何像树一样连接的。此外,我们可以根据它们的颜色来判断它们发生了什么。
一个实心颜色的条形表示来自该组件的更新。另一方面,如果颜色是灰色阴影,这意味着 React 决定跳过更新该组件,这被称为 bailout。
当我们研究网站的性能时,这个图表很有用,因为它可以告诉我们在给定更新中替换了多少个 fibers,重用了多少个,以及是否有特定的 fiber 被访问过。
useMemo 创建的值可以用于任何目的。为了影响子组件,它可以与 prop 连接起来,将信息发送得更深:
const Title = () => {
const a = useMemo(() => { ... }, [])
return <Child a={a} />
}
如果我们得到一个将变量 a 通过 prop 连接到 Child 组件的先前 assignment,这意味着我们可以对 Child 进行条件更新吗?这是一个好问题。
从 Title 更新时对应所有组件的 profiler 图表(见图 图 6.5),我们可以快速找到答案是否定的。前面的代码不能跳过 Child 更新,因为当 Title 父组件更新时,它会通过 Child fiber 的 reconciliation 生成一组新的 props。换句话说,它触发了 Child 的更新,无论单个 prop,如 a,是否有新值。
那么,我们如何确切地使用 useMemo 来手动跳过子组件的更新呢?让我们尝试一些不同的方法。记住 useMemo 的设计目的是 – 保留从上次更新以来的值,直到再次满足条件:
const Title = () => {
const child = useMemo(() => {
return <Child a="Hello World" />
}, [])
return child
}
在前面的 useMemo 代码中,我们不是返回一个字符串,而是用它来保存 <Child /> 组件实例。<Child /> 是什么?它是 Child 函数组件返回的一个元素对象。因此,只要元素保持不变,我们期望屏幕也是一样的。让我们通过 profiler 图表来确认这种方法,如图 图 6.6 所示:

图 6.6 – 使用 Child bailout 成功的 Profiler 渲染
在挂载后,正如 [] 依赖数组所示,child 保留了上一次的相同 Child 更新。现在,当 Title 组件更新时,它不会更新 Child 组件。这是因为,对 React 来说,Title 组件下面没有派发的更改,所以它跳过了下面的所有内容。
第七章:使用上下文覆盖区域
在上一章中,我们学习了 useMemo 的设计和如何使用 useMemo 有条件地重用最后一个值。在本章中,我们将讨论如何传播更改以覆盖区域更新。我们将介绍什么是区域更新以及 React 上下文如何用于在该区域内共享值。然后,我们将遍历 useContext 后面的数据结构和源代码以消费共享值。最后,我们将提供两个将上下文应用于主题和表格的实际示例。本章还包括 附录 部分的两个额外主题:传播上下文 和 上下文作用域和值。
在本章中,我们将涵盖以下主题:
-
那么区域更新是什么?
-
介绍 React 上下文
-
理解
useContext设计 -
测试
useContext -
useContext示例 -
问答
-
附录
什么是区域更新?
在一个典型的网站上,一旦网站加载完成,它就开始监听所有用户动作。每个接收到的动作都会按照先来先服务的原则进行处理。通常,每个动作的影响仅限于屏幕上的一个小区域,使用 UI 术语,就是一个单个组件。然而,有时一个用户动作可以做得更多。
让我们用计算机作为类比。假设你决定更改系统的颜色设置。一旦颜色更改,计算机就会遍历所有打开的窗口并将该颜色应用到它们上。因此,这个动作可以影响屏幕上分散的多个应用程序。这成为了一个区域更新。
你是否想过,当受影响的组件彼此之间非常遥远时,我们如何进行区域更新?为了回答这个问题,让我们首先回顾一下在 第四章 中引入的单个更新,使用状态启动组件。
当接收到一个动作时,纤维树会安排一个单个更新。这个更新围绕接收动作的源纤维进行本地化(图 7.1 中的红色点)。React 跟随源纤维,收集与之相关的所有更改,然后将它们应用到 DOM 上(红色线条)。这是 React 中的单个更新模式。

图 7.1 – 由用户动作引发的单个更新
假设源纤维是一个 Title 组件:
const Title = () => {
const [count, dispatch] = useState(0)
const onClick = () => {
dispatch(count + 1)
}
return (
<>
<div onClick={onClick}>{count}</div>
...
</>
)
}
当用户点击 count 时,前面的 Title 组件会进行更新。它也应该更新 ... 部分中的其他子组件。
现在,考虑另一个名为 ThumbUp 的组件:
const ThumbUp = () => {
// made up count
return count > 5 ? "Awesome" : ""
}
ThumbUp 组件的目的是响应 count。当它大于 5 时,我们希望在屏幕上打印出 "Awesome" 消息。ThumbUp 组件位于 图 7.2:

图 7.2 – 对单个用户动作响应的两个更新
目前,ThumbUp组件中的count变量是一个虚构的变量,因为我们不知道它如何从Title组件传递过来。我们所知道的是,我们想在两个组件之间共享这个count,而挑战似乎在于它们之间的关系。
显然,这两个组件之间并不是父子关系。除非我们首先向上移动多个步骤到达Branch组件(图 7.2中的暗点)然后再向下选择另一条路径,否则没有直接从一方到另一方的路径。本质上,Branch是它们的共同祖先。那么我们究竟如何在这两个不是直接父子关系的节点之间发送信息呢?
让我们通过两种方法来探讨这个问题,首先从 props 方法开始。
Props 方法
由于 React 的 props 只适用于父子设置,为了将 props 应用到这个问题上,我们需要将相关的状态提升到共同祖先,即Branch组件:
const Branch = () => {
const [count, dispatch] = useState(0)
const onClick = () => {
dispatch(count + 1)
}
return (
<>
<div>
...
<Title count={count} onClick={onClick} />
...
</div>
<div>
...
<ThumbUp count={count} />
...
</div>
</>
)
}
在前面的代码中,count状态从Title移动到了Branch。从那里,count和dispatch通过 props 发送到(遥远的)子组件。现在,如果状态发生变化,Branch组件会更新所有子组件,包括Title和ThumbUp。好吧,我们成功共享了count状态。
虽然 props 方法在一般情况下是可行的,但它确实要求我们修改Title和ThumbUp组件的 props。由于Branch和每个组件之间的路径上可能有其他组件,所以沿途的所有组件都需要进行修改。考虑到典型的网站构建,共享状态的需求通常发生在项目周期的后期阶段。采用这种方法可能会因为需要修改的组件数量而变得非常昂贵。
那么,有没有一种更好的方法来共享 prop,而不需要修改这么多组件?观察图 7.2,一个直观的方法可能是同时向Title组件和ThumbUp组件发送更新。这种组合影响可能会产生覆盖两者的区域更新。让我们看看这个想法是否可行。
组合分发方法
为了实现组合分发,我们需要允许从事件处理器访问两个分发。这有点棘手,因为通过useState提供的分发函数通常定义在一个组件内部,无论是Title还是ThumbUp。为了解决这个问题,让我们假设我们目前使用一个全局变量:
let dispatch2
const ThumbUp = () => {
const [count, dispatch] = useState(0)
dispatch2 = dispatch
return count > 5 ? "Awesome" : ""
}
在前面的代码中,我们修改了ThumbUp组件,并添加了一个count状态和一个dispatch函数。然后我们使用一个全局变量dispatch2来指向这个dispatch函数,以便其他组件可以调用它。现在,通过这个变化,我们可以要求Title组件执行两个分发:
const Title = () => {
const [count, dispatch] = useState(0)
const onClick = () => {
dispatch(v => v + 1)
dispatch2(v => v + 1)
}
return <div onClick={onClick}>{count}</div>
}
从前面的设置来看,当用户点击count时,它会从Title执行dispatch操作,并从Greeting执行dispatch2操作。尽管每个组件中的两个count状态并没有指向共享的值,但它们分别从这两个dispatch操作中各自增加。React将这些两个dispatch操作批处理为一次屏幕更新,如果用户继续点击数字,当count状态大于5时,你会在屏幕上看到一条"Awesome"消息。
你可能不相信这种类似黑客的方法会有效,但它确实有效。我们为另一个组件调用dispatch函数的事实略超出了React设计useState的目的,但这并不错误。这种方法需要对代码结构进行较少的修改。没有涉及任何属性。所有更改都是在Title和ThumbUp的本地侧进行的。更重要的是,它作为一个教育案例,表明影响多个组件的更新可以由多个更新组成。
好的,从这两种方法中我们学到的是,我们想要避免在这个非父/子情况下一路向下传递属性,我们还想共享值以及触发一种随值变化而进行的组合更新。此外,在实践中,如果解决方案可以提供开箱即用的联合dispatch,并且只需要对现有代码结构进行少量修改,那就更好了。
到 2017 年底,React团队发现了这样的需求,并引入了一个新版本的上下文来解决当时存在的限制。新上下文的作用是帮助通过多个组件层级共享一个值,而不需要在每个层级中传递属性。它还提供了一个机制,允许远端子组件访问祖先组件的值。让我们来看看这个React上下文。
介绍 React 上下文
上下文通过一个包含_currentValue值的ReactContext数据类型进行建模。

Figure 7.3 – React 上下文数据结构
我们可以使用createContext函数创建一个上下文。例如,如果我们想共享一些用户信息,我们可以创建一个UserContext并持有defaultValue:
const UserContext = createContext(defaultValue)
export default UserContext
创建的上下文可以通过JavaScript的export语句进行共享,这样,当任何其他文件或组件需要它时,它可以被导入。
上下文允许我们通过Provider属性将值提供给所有下层的消费者:
import UserContext from './UserContext'
const Branch = () => {
return (
<UserContext.Provider value={...}>
...
</UserContext.Provider>
)
}
一个上下文提供者,如UserContext.Provider,接受一个用于共享的value属性。提供者是一个特殊组件。与函数组件不同,它有自己的更新实现。当它由于value的变化而更新时,它会启动一个搜索。
搜索会遍历其子级及其子级的子级,直到访问到所有内容。对于所有使用上下文的消费者,它们都被标记为需要更新。如果你对上下文提供者感兴趣,请查看本章末尾的附录 A – 上下文的传播。
因此,提供者的工作就是共享一个值。如果需要多个不同目的的值,我们可以将它们堆叠在一起。比如说,在UserContext之上,我们还有一个ThemeContext上下文来共享:
const Branch = ({ user, theme }) => {
return (
<ThemeContext.Provider value={theme}>
<UserContext.Provider value={user}>
...
</UserContext.Provider>
</ThemeContext.Provider>
)
}
之前的使用方法将允许...部分中的任何消费者读取theme或user的值,或者两者都读取。
关于提供者设计的一个有趣方面是,一个提供者可以嵌套在同一个提供者下面以覆盖共享值:
const Branch = ({ theme1, theme2 }) => {
return (
<ThemeContext.Provider value={theme1}>
// A. value = theme1
<ThemeContext.Provider value={theme2}>
// B. value = theme2
</ThemeContext.Provider>
// C. value = theme1
</ThemeContext.Provider>
// D. value = defaultTheme
)
}
在前面的代码中,theme1和theme2是由同一个ThemeContext定义的两个提供者提供的。那么,消费者会看到哪个上下文值,你可能想知道?
在前面的代码中,标记了四个位置A、B、C和D。消费者看到的值取决于它被消费的位置。对于位置A,消费者看到的值是theme1,对于位置B,它是theme2,而对于位置C,它又回到了theme1。
从本质上讲,上下文提供者(以某种方式)设计得像JavaScript函数。你可以在不同的函数作用域中获取不同的值。为了确定特定位置的正确值,你需要找到提供value的第一个祖先父级。最近的祖先提供者应该提供正确的上下文值。这应该解释了为什么位置C得到theme1而不是theme2。
那么,在没有任何提供者作用域之外,例如在位置D,上下文值是什么?由于它碰巧不是一个你可以找到在Branch组件之上的祖先提供者。这就是defaultValue发挥作用的地方:
const ThemeContext = React.createContext(defaultTheme)
当找不到其他提供者来提供值时,它将采用在创建上下文时提供的defaultValue。这意味着默认值不是必需的,但它可以在早期查看上下文类型格式时很有用:
const defaultTheme = {
mode: 'light'
}
内部,React使用一个栈来推送和弹出上下文,以跟踪所有提供者作用域的上下文值。如果你对这个细节感兴趣,请查看本章末尾的附录 B – 上下文作用域和值。
好的,现在我们知道了如何提供上下文值,让我们看看我们如何在消费者/子组件中消费它。React提供了一个useContext钩子来完成这项工作。
理解 useContext 设计
React提供了一个useContext钩子来消费上下文:
import UserContext from './UserContext'
const Title = () => {
const user = useContext(UserContext)
return <div>{user.name}</div>
}
useContext钩子函数接受一个输入参数context,并返回共享的值。context通常从一个定义文件中导入。
useContext 数据结构
我们将解释如何使用简化版源代码来设计useContext。让我们看看使其发生的数据结构:

图 7.4 – useContext 钩子数据结构
要消费(或读取)上下文值,每个纤维都会获得一个新的dependencies属性来跟踪它所消费的所有上下文。这是因为一个组件可以使用多个useContext,并且每次使用都会消费不同的上下文,例如UserContext和ThemeContext。
dependencies属性被设计为一个链表,其firstContext属性用于保存第一个具有ContextItem类型的上下文。每个ContextItem都包含一个ReactContext(之前介绍过的上下文)和一个指向下一个上下文的next属性。依赖列表是提供者如何在传播更新时找到所有消费者的方式。
useContext钩子被设计用来读取所有更新中的上下文值:
function useContext(context) {
const contextItem = {
context: context,
next: null,
}
if (lastDependency === null) {
lastDependency = contextItem
updatingFiber.dependencies = {
firstContext: contextItem,
}
} else {
lastDependency =
lastDependency.next = contextItem
}
...
}
在前面的代码块中,useContext的主要任务是返回context中的_currentValue。在返回上下文值之前,它还将context追加到纤维的dependencies列表中。
lastDependency是一个辅助变量,用于跟踪更新的最后一个上下文。如果是第一个上下文,则将其创建为firstContext,否则将其追加到列表的next中。在更新的开始时,lastDependency被设置为null。因此,每个纤维的dependencies列表在每次更新时都会重新创建,这提醒我们钩子是如何在每次更新中创建的。
useContext 工作流程
您已经通过简化版的useContext代码进行了学习。以下是提供者和useContext的总结工作流程:

图 7.5 – useContext 和 ContextProvider 工作流程
让我们快速回顾一下。上下文通过提供者提供。在提供者的值发生变化的情况下,它会找到所有消费者并传播更新。否则,它会跳过更新。
在更新过程中,当使用给定上下文的useContext钩子被调用时,它会将上下文追加到纤维的dependencies列表中,并返回上下文的当前值。
现在我们已经完成了整体设计,让我们来对其进行一次全面的测试。
测试useContext
上下文的使用很有趣,并且有相当多的常见应用。考虑一个在屏幕右上角显示用户标志的网站:

图 7.6 – 包含 App、Header 和 Logo 的应用程序
该网站使用App组件创建,包含Header和Main。然后,Header包含Logo,而Site包含Greeting。App、Header和Logo的定义如下:
function App({ initialUser }) {
const user = initialUser
return (
<div>
<Header user={user} />
<Main user={user} />
</div>
)
}
const Header = ({ user }) => {
return <Logo user={user} />
}
const Logo = ({ user }) => {
return (
<div>
<img url={ user.imageUrl } />
<span>{ user.username }</span>
</div>
)
}
假设用户经过认证作为initialUser发送到App,它将通过属性传递给Header和Logo。在Logo中,它通过其imageUrl属性和username被消费以显示一个标志。注意所有组件的接口都需要携带user属性。由于我们想在Main下显示问候消息,因此user也需要通过属性传递。
让我们看看上下文如何帮助简化这个设置。假设我们定义一个UserContext:
const UserContext = React.createContext()
export default UserContext
在App应用中,我们可以通过UserContext.Provider提供initialUser:
import UserContext from './UserContext'
const App = ({ initialUser }) => {
const [user, changeUser] = useState(initialUser)
const value = { user, changeUser }
return (
<UserContext.Provider value={value}>
<div>
<Header />
<Site />
</div>
</UserContext.Provider>
)
}
注意,Header和Main不再需要携带user属性。
App组件创建一个user状态来保存initialUser。changeUser函数可以在需要时替换当前用户。然后它从user和changeUser组装一个value对象,通过UserContext.Provider共享。
在Logo组件内部,我们可以消费提供的值,包括user属性:
import UserContext from './UserContext'
const Logo = () => {
const { user } = useContext(UserContext)
return (
<div>
<img url={ user.imageUrl } />
<span>{ user.username }</span>
</div>
)
}
在前面的代码中,通过 ES6 语法读取UserContext来获取user对象。对于那些对 ES6 用法感兴趣的读者,可以查看第十章中的拥抱 JavaScript ES6部分,使用 React 构建网站。一旦获取到user,我们可以使用user的imageUrl和username属性快速将其连接到现有的Logo组件。
同样适用于Main下的组件,例如Greeting,它根据当前登录用户显示Hello Fang消息。我们可以利用上下文轻松完成这项工作:
import UserContext from './UserContext'
const Greeting = () => {
const { user } = useContext(UserContext)
return <h1>Hello {user.username}</h1>
}
重要的是要注意,使用像UserContext这样的上下文,我们的应用在访问user对象方面变得更加易于管理和可扩展。因为信息变成了上下文信息,只要组件属于这个上下文,它就可以访问它。
现在我们知道了如何读取用户对象,让我们看看是否可以从initialUser中更改它,例如允许用户登录和退出应用。
更改上下文值
假设我们添加一个Logo。当这个按钮被点击时,当前用户会注销,这意味着他们的标志将不再可用于显示:
import UserContext from './UserContext'
const Logo = () => {
const { user, changeUser } = useContext(UserContext)
const { imageUrl, username } = user
const authenticated = username != undefined
const onLogout = () => { changeUser({}) }
const onLogin = () => { // redirect to LoginForm }
return (
<div>
{authenticated ? (
<>
<img url={ imageUrl } />
<span>{ username }</span>
<button onClick={onLogout}>Logout</button>
</>
) : (
<button onClick={onLogin}>Login</button>
)}
</div>
)
}
在前面的代码中,changeUser函数通过UserContext提供。它根据username是否存在定义了一个authenticated标志。这个标志可以用来决定UI是否应该显示注销或登录按钮。
如果用户经过认证作为initialUser发送到App,它将通过属性传递给Header和Logo。在Logo中,它通过其imageUrl属性和username被消费以显示一个标志。注意所有组件的接口都需要携带user属性。由于我们想在Main下显示问候消息,因此user也需要通过属性传递。
如果用户未认证,将触发onLogin事件处理器,我们可以将用户重定向到LoginForm(此处省略重定向代码):
const LoginForm = () => {
const { changeUser } = useContext(UserContext)
const onSubmit = (_user) => {
changeUser(_user)
// redirect to home page
}
return ...
}
LoginForm也消耗UserContext并读取changeUser属性。在表单成功提交后,我们可以再次使用changeUser将空用户更改为认证用户。同样,新的更新将传播到所有消费者,包括Logo和Greeting。因此,用户在登录后将在右上角看到他们的标志和用户名,以及在主体上的问候消息。参见图 7.6。
对于一个网站来说,用户登录和注销通常是重要的操作,因为它对网站的认证和授权有直接影响。特别是当完全刷新页面不是一个选项时,上下文在读取和写入用户对象方面是一个完美的选择,无需担心各个组件之间的不同步时刻。
现在我们已经看到了在上下文改变的情况下,上下文更新如何协调所有组件的更新,让我们看看两个使用上下文在真实应用中的示例。
useContext 示例
上下文用于共享网站级别的信息,但它也可以在更窄的范围内非常有效地共享事物。在本节中,我们将看到这两种情况的示例。
主题上下文
一个涉及上下文的好用且常见的用法是主题化,它允许用户根据他们的偏好在浅色和深色主题之间切换。参见图 7.7:

图 7.7 – 使用浅色和深色选项制作的主题
要实现这个功能,让我们从ThemeContext开始:
const ThemeContext = React.createContext({
mode: 'light', // or 'dark'
})
我们可以使用支持mode属性的对象来设置默认主题,其中mode是一个字符串,可以包含“light”或“dark”。
设计主题上下文
任何需要主题的组件都可以从ThemeContext中读取设置。让我们构建一个具有主题感知能力的Button组件:
const Button = () => {
const theme = useContext(ThemeContext)
return (
<ButtonStyle mode={theme.mode}>
Ok
</ButtonStyle>
}
我们可以根据ButtonStyle组件内部的共享mode来为主题按钮的样式进行主题化:
const color = props =>
props.mode === 'light' : 'black' ? 'white'
const ButtonStyle = styled.button`
color: ${color};
`
ButtonStyle是一个应用于宿主button组件的样式化组件,它具有修改后的CSS。它接收mode属性,基于此,我们可以使用"white"或"black"来定义其color。如果您对使用StyledComponent感兴趣,请查看第十章的采用 CSS-in-JS 方法部分,使用 React 构建网站。
基于这种方法,我们可以构建许多其他具有主题感知能力的组件。想象一下,如果我们有App下的几个这样的组件;在模式改变的情况下,它们可以在单个更新中切换到相应的样式:
const App = ({ theme }) => {
return (
<ThemeContext.Provider value={theme}>
...
</ThemeContext.Provider>
}
现在让我们谈谈如何设计一个上下文。在前面的主题化案例中,我们使用了mode在主题之间切换,但我们也可以将确切的样式烘焙到其中:
const ThemeContext = React.createContext({
primaryColor: 'blue',
secondaryColor: 'red'
})
在前面的上下文中,定义了两种用于主要和次要目的的单独颜色。这样,我们就可以直接在 Button 中读取颜色:
const color = props => props.primaryColor
const ButtonStyle = styled.button`
color: ${color};
`
在设计上下文方面,没有对错之分。关于您应该使用哪个属性的决策,更多或更少地基于您想要如何消费这些属性并设计组件。在我们的案例中,决策更多关于组件是否应该基于 mode 或 primaryColor。
手动应用主题
使用主题为多个组件组工作得很好,尤其是在主题上下文中。然而,有时会有这样的需求,我们想要将主题应用于一个特定的组件,而不影响所有其他组件的主题。这通常适用于获得与网站其他部分不同对比色的导航标题组件。
通过在单独的作用域中应用上下文,可以解决这个问题:
const Header = () => {
return (
<ThemeContext.Provider value={{ mode: 'dark' }}>
<Button />
</ThemeContext.Provider>
)
}
在前面的 Header 组件中,还写了一个 ThemeContext.Provider 的另一个用途,提供不同的 mode 主题,而 App 已经提供了一个 mode 主题。假设 App 主题是 'light';当它进入 Header 时,它变为 'dark'。
当主题感知的 Button 组件被更新时,它会寻找定义提供者的最近祖先。由于找到的祖先是 Header 而不是 App,它读取 'dark' 模式。
我们可以自由地覆盖上下文值的部分,而不是覆盖整个值:
const Header = () => {
const theme = useContext(ThemeContext)
const value = { ...theme, primaryColor: 'blue' }
return (
<ThemeContext.Provider value={value}>
...
</ThemeContext.Provider>
)
}
在前面的上下文中,Header 从父作用域中获取当前主题,并覆盖了 primaryColor 属性为 'blue'。因此,所有在 Header 下的子消费者都看到了修改后的主题设置。
操场 – 主题上下文
您可以自由地在这个在线示例中玩耍:codepen.io/windmaomao/pen/xxLrwJy.
现在我们看到,上下文非常适合网站级别的主题,如主题。上下文不必总是像网站那样大;如果我们能识别一个区域,我们就应该能够将其应用于它。让我们看看一个例子,我们将上下文应用于一个目标区域,如表格。
表格上下文
上下文的另一个用途是将上下文应用于一个可以很小但足够大以包含许多相关组件的地方,例如表格。参见 图 7.8:

图 7.8 – 包含可定制单元格显示的表格
在现代 UI 中,一个表格可能相当复杂,因为它包含标题、主体、页脚、分页以及可定制的列设置,以驱动单元格的外观和感觉。此外,所有这些元素都可以即时混合匹配,以适应不同的业务目的。
自定义表格单元格
表格由单元格的行和列组成,其中每个单元格都可以由如 DefaultCell 这样的单元格组件控制:
const DefaultCell = ({ value }) => {
return <div>{value}</div>
}
属性value用于从一个行的属性中传递一个原始值,例如一个字符串或一个数字。在典型情况下,我们简单地将其显示为字符串格式,就像前面的代码所示。
给定一个跟踪水果列表的表格行:
const fruits = [
{ title: 'Apple', status: true },
{ title: 'Orange', },
{ title: 'Strawberry' },
{ title: 'Pineapple', status: true },
{ title: 'Watermelon' }
]
前面的title列是一个完美的例子,它应该显示水果名称的列表。然而,并非所有单元格都是纯字符串,因为我们可以将单元格显示为状态指示器、进度条、复选框等。在我们的例子中,我们有一个表示水果是否美味或只是 OK 的status列,这可以通过另一个单元格行为来处理:
const StatusCell = ({ value }) => {
const s = value ? 'Nice' : 'Ok'
return <div>{s}</div>
}
在前面的自定义StatusCell组件中,传入的值是一个布尔值,它显示为'Nice'或'Ok'。
这意味着我们需要一个通用的TableCell,我们可以传入某种类型的列信息来定制显示:
const TableCell = ({ col, row }) => {
const value = row[col.name]
const Component = col.Cell || DefaultCell
return <Component value={value} />
}
在前面的TableCell组件中,提供了一个col属性来描述单元格应该如何更新。让我们看看title和status两列的信息:
const cols = [
{ name: 'title' },
{ name: 'status', Cell: StatusCell }
]
对于第二列,Cell属性被指定为自定义的StatusCell,而第一列省略了它,因此使用了DefaultCell。通过这个col功能,我们可以为各种单元格设计自定义的外观和感觉。
设计表格上下文
到目前为止的例子是基于单元格内容显示单元格,即row[col.name]。只要列名与行数据中存储的数据匹配,我们就应该找到正确的单元格值。但有时,它们可能不匹配,或者更糟糕的是,有时我们需要根据多个列来显示单元格。例如,为了显示一个人的全名,我们需要这个人的名字和姓氏,它们分布在两个列中。只有row可以提供足够的信息。但我们只有一个单元格value。我们能在哪里得到row?
我们可以将row作为属性传递给每个TableCell。但如果我们想避免使用属性方法以保持单元格接口的灵活性,我们可以这样做。在这种情况下,我们可以创建一个TableContext:
const TableContext = React.createContext({
rows: [],
cols: [],
row: {}
})
export default TableContext
在前面的TableContext中,我们添加了row属性以及rows和cols,以便所有消费者都可以共享它们。然后我们可以将其提供给TableRow组件中的每一行:
import TableContext from './TableContext'
const TableRow = ({ row }) => {
const table = useContext(TableContext)
const value = { ...table, row }
const cols = table.cols
return (
<TableContext.Provider value={value}>
<TableRowStyle>
{cols.map(col => {
<TableCell
row={row}
col={col}
/>
})}
</TableRowStyle>
</TableContext.Provider>
)
}
TableRow组件负责更新表格中的一行。我们在这里使用TableContext有两个目的,一个是为了获取所有存储的cols,另一个是为了在将上下文返回给每一行之前,用之前提供的属性覆盖row属性。现在,当我们到达每个单元格组件时,我们应该能够获取当前的row。为了演示,我们可以为'combo'设置另一个自定义列:
const cols = [
{ name: 'title' },
{ name: 'status', Cell: StatusCell },
{ name: 'combo', Cell: ComboCell }
]
'combo'列的想法是在单个单元格中通过自定义的ComboCell组件将'title'和'status'结合起来:
const ComboCell = () => {
const { row } = useContext(TableContext)
const s = row.status ? 'Nice' : 'Ok'
return <div>{row.title} – {s}</div>
}
在前面的代码中,我们从TableContext中获取当前的row信息,并访问title和status属性。通过这种方法,我们可以从当前行组装任何信息。此外,请注意,没有将属性传递到这个组件中。这意味着,只要导入TableContext,你就可以在任何你想的地方设计一个自定义单元格。这种解耦,没有显式的依赖,应该使我们能够非常舒适地动态设计自定义单元格样式。
我们可以争论,如果我们直接将row提供给ComboCell,我们就可以在不设置表格上下文的情况下实现相同的功能。这可能是对的。你选择是否将row作为属性或从上下文中传递会影响你如何设计单元格。
在我们的例子中,从上下文中传递row确实使设计变得更加灵活。让我们继续我们的例子,看看在其他地方如何使用这个TableContext。假设这次一个单元格需要知道更多信息,比如cols设置,甚至与表格相关的一些操作,比如删除一行。
让我们看看一个例子,通过点击单元格来删除表格的一行。现在,为了管理表格的内容,我们可以向Table组件提供一个状态[rows, setRows]:
const Table = ({ cols, rows, setRows }) => {
const value = { cols, rows, setRows }
return (
<TableContext.Provider value={value}>
<TableStyle>
{rows.map(row => {
<TableRow row={row} />
})}
</TableStyle>
</TableContext.Provider>
)
}
在前面的代码中,Table组件负责显示表格的所有行。传递的rows和cols以及setRows分发函数都被发送到TableContext提供者以供消费。有了这个,我们可以定义一个自定义的DeleteCell组件:
const DeleteCell = () => {
const { row, rows, setRows } = useContext(TableContext)
const onClick = () => {
const newRows = rows.filter(r => r.title !== row.title)
setRows(newRows)
}
return (
<div>
<button onClick={onClick}>Remove</button>
</div>
)
}
在前面的代码中,DeleteCell包含一个newRows,通过过滤掉当前的row然后使用setRows分发函数来替换表格内容。一旦rows被更新,它应该触发提供者的更新,从而将更改传播到所有表格组件。
没有上下文提供setRows,它必须通过许多层传递才能到达DeleteCell。即使在简短的例子中,层数也不是显而易见的。此外,在这种情况下,我们事先并不知道组件的名称。它可能是DeleteCell或DefaultCell,或者任何尚未设计的单元格组件。因此,这里的上下文使用对于将事物传递到未知的确切位置非常有帮助。
操场 – 表格上下文
欢迎免费尝试这个在线示例:codepen.io/windmaomao/pen/VwzWeMa。
让我们通过在App组件下使用Table组件来总结这个表格例子:
const cols = [
{ name: 'combo', Cell: ComboCell },
{ name: 'action', Cell: DeleteCell }
]
const App = () => {
const [rows, setRows] = useState(fruits)
return <Table
rows={rows}
cols={cols}
setRows={setRows}
/>
}
概述
在本章中,我们学习了一个非常酷的概念,那就是上下文。首先,我们了解了在React中上下文是什么。然后,我们深入研究了上下文的设计以及如何使用useContext钩子来消费它。然后,我们通过管理一个网站的用户对象进行上下文测试驱动,进一步学习了将上下文应用于Theme和Table的两种更多应用。
在下一章中,我们将进入 React 家族的下一个钩子,并看看如何使用 ref 将私人事务隐藏在引擎之外。
问题与答案
以下是一些问题与答案,以帮助您巩固知识:
-
什么是React上下文?
React上下文是一个可以用来与消费者共享值的身份。它被设计成可以提供一个值给局部作用域,因此该作用域内的所有组件都可以从这个共享值中读取。
-
useContext钩子是什么?useContext钩子在消费者组件中使用,以便它可以读取由提供者给出的上下文。一个组件可以消费尽可能多的上下文。当提供者更新时,该特定上下文的所有消费者组件将同时更新。 -
useContext的常见用法是什么?当涉及到组件区域更新时,
useContext钩子是React钩子家族中的一个钩子。我们经常在User、Theme和Table中看到这种上下文用法,这需要在不通过组件层传递的情况下,在祖先组件下消费一个值。useContext钩子还使得向一个区域传递值成为可能,而不必确切知道目的地在哪里。
附录
附录 A – 传播上下文
React 包含不同类型的纤维。虽然函数组件是其中之一,但上下文提供者又是另一种。它为每次更新都有自己的更新逻辑:
function updateContextProvider(fiber) {
var providerType = fiber.type
var context = providerType._context
var newProps = fiber.pendingProps
var oldProps = fiber.memoizedProps
var newValue = newProps.value
if (oldProps !== null) { ... }
var children = newProps.children
reconcileChildren(fiber, children)
return fiber.child
}
在前面的代码中,updateContextProvider函数接收fiber,并通过oldProps !== null检查它是否已经挂载。如果是首次挂载,那么它就会做一些类似于函数组件的事情,将子组件协调到纤维中,然后返回第一个子组件以供后续处理。
如果不是首次挂载,例如在更新过程中,它就会比较value属性之间的oldValue和newValue:
if (oldProps !== null) {
var oldValue = oldProps.value
if (oldValue === newValue) {
if (oldProps.children === newProps.children) {
return bailoutOnAlreadyFinishedWork(fiber)
}
} else {
propagateContextChange(fiber, context)
}
}
如果从严格的相等比较===提供的值没有变化,并且子组件也没有变化,那么它就会退出纤维并跳过协调。否则,如果提供的值有变化,它将通过propagateContextChange函数将上下文变化传播到所有消费者纤维:
function propagateContextChange(work, context) {
var fiber = work.child;
while (fiber !== null) {
var nextFiber = void 0
var list = fiber.dependencies;
if (list !== null) {
nextFiber = fiber.child;
var dependency = list.firstContext;
while (dependency !== null) {
if (dependency.context === context) {
scheduleWorkOnParentPath(fiber.return)
break
}
dependency = dependency.next;
}
} else {
nextFiber = fiber.child;
}
...
fiber = nextFiber;
}
}
在fiber纤维下进行深度搜索的传播检查是否包含与firstContext相同的context。如果找到匹配项,它将调用scheduleWorkOnParentPath函数来安排从根到父级的路径更新。这个函数与我们在useState钩子中看到的scheduleUpdateOnFiber非常相似。但在这里,这个函数不是只执行一次,而是应用于找到的所有消费者。
简而言之,你可以看到提供者是一个特殊的组件,它为从同一上下文中读取的所有消费者安排更新。
附录 B – 上下文作用域和值
上下文值在不同的上下文作用域下发生变化。但这个陈述意味着什么呢?
Const provider1 = (value) => {
const provider2 = (value) => {
const provider3 = (value) => {
console.log(value)
}
provider3(3)
}
provider2(2)
}
provider1(1)
在前面的复杂示例中,如果我们调用provider1(1),你能猜到输出是什么吗?请花一分钟思考。答案是3。
因为每个函数都有一个作用域,在函数的作用域内,所有从输入参数传入的局部变量都有优先权。尽管外部的value是1,但由于作用域的改变,内部的value变成了3。
上下文提供者以类似的方式设计;在进入一个作用域之前,它使用堆栈将旧值推入,然后在离开相同的作用域后弹出旧值。这样,当我们在一个内部作用域中工作时,总是将外部作用域保持在内存中。
让我们仔细看看这个过程。假设cursor在一个外部作用域中持有值,并且它想要带着新的value进入内部作用域:
function push(cursor, value) {
index++
valuesStack[index] = cursor.current
cursor.current = value
}
在前面的代码中,cursor是一个全局变量,它持有current属性下的当前值。push操作将旧值放入堆栈,然后替换cursor的当前值为新值。
因此,一旦我们进入内部作用域,cursor的current就会更新为最新值,那么我们如何在完成这个内部作用域后恢复旧值呢?是的,我们执行一个pop操作:
function pop(cursor) {
if (index < 0) return
cursor.current = valueStack[index]
valueStack[index] = null
index--
}
在前面的代码中,我们将之前的堆叠值弹回到cursor。因此,当我们从内部作用域退出时,值会恢复到旧值。
这种机制正是应用于我们的上下文提供者:
function updateContextProvider(fiber) {
...
pushProvider(fiber, newValue)
if (oldProps !== null) { ... }
...
}
当它到达提供者的更新时,它使用pushProvider函数将旧值推入并替换为新值:
function pushProvider(fiber, nextValue) {
var context = fiber.type._context
push(valueCursor, context._currentValue)
context._currentValue = nextValue;
}
pushProvider的最后一行使用value属性的nextValue将上下文的_currentValue设置为上下文。这就是内部作用域中的消费者如何获得内部值。但在那之前,它将_currentValue的旧值推入堆栈,并将其存储在全局变量valueCursor中。
在我们离开提供者的作用域之前,调用popProvider:
function popProvider(fiber) {
var currentValue = valueCursor.current
pop(valueCursor)
var context = fiber.type._context
context._currentValue = currentValue
}
popProvider的最后一行将上下文的currentValue设置回从valueCursor保存的值。但在那之前,它从堆栈中弹出旧值。
因此,在所有时候,都有一个valueCursor来持有外部作用域的值,而context._currentValue则持有当前/内部作用域的值。这意味着存储在_currentValue中的上下文值在整个更新过程中并不是一个固定值。希望你现在可以明白,上下文具有“全局”的概念,并且根据作用域而变化。
第八章:使用 Ref 隐藏内容
在上一章中,我们学习了React上下文的设计以及如何使用useContext钩子来更新一个区域。在本章中,我们将介绍另一个React实体,即 ref。我们将学习如何通过 ref 访问DOM元素,并探讨useRef钩子背后的设计和源代码。我们还将描述如何在不触发更新的情况下与持久值一起工作。最后,我们将把 refs 应用到一些实际问题中,例如点击菜单外部、避免内存泄漏、设置中继和定位当前值。我们还会在附录部分揭示两个额外主题,即回调 ref 和 forward ref。
在本章中,我们将涵盖以下主题:
-
访问DOM元素
-
理解
useRef设计 -
未更新状态
-
测试
useRef -
useRef示例 -
问答环节
-
附录
访问 DOM 元素
在现代UI框架引入之前,为了改变屏幕,我们直接与DOM元素工作:
<body>
<h1 id="title"></h1>
</body>
<script>
const el = document.getElementById('#title')
el.textContent = "Hello World"
</script>
前面的HTML文件定义了一个带有特定id值的h1元素。因此,我们可以使用id值来找到el元素并更改其textContent。这就是DOM元素更新的方式:

图 8.1 – 显示 Hello World 标题的 HTML
使用 React,我们可以通过将元素包裹在组件中来实现前面的功能,例如函数组件:
const Title = () => {
const [title, setTitle] = useState("")
useEffect(() => { setTitle("Hello World") }, [])
return <h1>{title}</h1>
}
使用前面提到的函数式方法的好处是,它提供了一个在物理DOM之上的抽象,并允许我们专注于局部开发空间。这样,我们可以安全地放置我们的逻辑和设计,而不必担心其他代码可能会意外地触摸这个空间。这使得我们的开发更加高效。
这是一件好事,这里有一个问题。我们是否还能像以前一样使用id来获取DOM元素?因为React的工作并不是重新发明所有的DOM功能,有时我们确实需要直接与元素工作:
const Title = () => {
return <h1 id="title">{title}</h1>
}
在前面的代码中,我们给h1元素添加了id。但很快我们就遇到了一些问题。首先,Title组件被设计成可重用的。这意味着我们可以在当前屏幕上拥有多个<Title />实例。假设我们想要操作其中一个——我们如何通过id知道要找到哪个实例?
其次,更重要的是,假设我们找到了我们想要的元素。由于它现在被包裹在组件中,React管理其生命周期,那么我们如何精确地知道它在何时挂载和卸载?如果我们不确定这一点,我们如何安全地操作它?
这两个问题都很棘手,但在我们能够直接在组件下操作元素之前,我们需要解决这些问题。让我们看看React是如何解决它们的。
连接和断开连接
那么,组件中的元素何时挂载和卸载呢?要回答这个问题,我们首先需要仔细查看从组件返回的元素:
return <h1>{title}</h1>
前面的语句返回了什么?它是一个 DOM 元素,就像我们在 HTML 文件中放入的那样吗?虽然看起来很像,但编译器却给出了不同的说法:
return createElement('h1', null, title)
实际上,编译器看到的是一个带有 createElement 函数的 JavaScript 语句,它返回一个 React 元素。它接受三个输入参数,元素类型(h1),属性(null),和子元素(title),返回的元素通常被称为虚拟 DOM。如果您想了解更多关于 createElement 的使用方法,请查看 第十章 的 Adopting CSS-in-JS approach 部分,Building a Website with React。
当第一次更新开始时,React 会取这个元素,将其与纤维进行协调,并将其附加到树上。然后它继续处理其子元素。对于所有子元素,它将它们协调并作为子纤维附加。这个过程会一直持续到所有元素都被协调到树中。
在所有纤维都准备就绪后,React 执行一次提交操作,在屏幕上创建所有 DOM 元素。所以,本质上,物理 DOM 元素直到整个树在内存中更新完毕才被创建。因此,只有到那时,React 才会根据我们的请求提供元素实例:
return <h1 ref={ref}>Hello World</h1>
在前面的代码中,一个 ref 对象被作为属性传递给 h1 元素,并作为一个存储容器,要求 React 一旦可用就存储元素实例。这个 ref 容器采用特定的格式:
ref = { current: null }
在 DOM 元素实例创建后,前面的 ref 在 提交 阶段的 commitAttachRef 函数中被填充:
function commitAttachRef(fiber, instance) {
const ref = fiber.ref
if (ref !== null) {
ref.current = instance
}
}
在前面的 commitAttachRef 函数中,当 ref 被提供并初始化时,其 current 属性从 DOM 实例中分配。这是为了挂载。同样,当 DOM 元素即将被移除时,current 在 提交 阶段的 commitDetachRef 函数中重新分配回 null:
function commitDetachRef(fiber) {
var ref = fiber.ref
if (ref !== null) {
ref.current = null
}
}
使用这个功能,只要我们提供一个指向元素的 ref,React 就会在挂载和卸载时将元素的实例分配给 ref.current。我们可以使用 ref.current 来处理元素,就像它过去一样。这是 React 访问 DOM 元素的 React 方式。
这里有一个细微之处。请注意,当元素准备好时传递元素实例时,赋值是通过 ref.current = instance 而不是 ref = instance 来完成的。这是因为 React 将 ref 设计为一个在组件生命周期中可用的容器。简单来说,容器始终有效,而 current 属性下的值可能会在过程中发生变化。
我们迄今为止使用的 ref 属性是一个对象格式的 ref,这是最流行且易于使用的格式。除此之外,React 在其他情况下还支持一种其他格式。如果你感兴趣,可以查看本章末尾的 附录 A – 回调 ref。
现在我们知道了什么是 React ref,让我们看看如何在函数组件中使用 useRef 钩子创建一个。
理解 useRef 设计
React 提供了一个 useRef 钩子来创建 ref:
const Title = () => {
const ref = useRef(null)
return <h1 ref={ref}>Hello World</h1>
}
useRef 钩子只接受一个初始值作为其唯一的输入参数,并返回一个 ref 对象,将初始值放在 current 属性下。
除了基本的 fiber 钩子支持之外,不需要额外的数据结构用于 useRef:


图 8.2 – useRef 设计
就像 useState 和 useEffect 使用 state 来存储状态和副作用一样,useRef 使用 state 来存储 ref。接下来,让我们看看它是如何实现的。
useRef 钩子遵循典型的钩子设置,它根据 isFiberMounting 标志是否表示纤维正在挂载或通过更新进行,选择 mountRef 或 updateRef 的路径,如 第三章 中所述,Hooking into React:
function useRef(initialValue) {
if (isFiberMounting) {
return mountRef(initialValue)
} else {
return updateRef()
}
}
在挂载期间,它首先通过创建一个来获取钩子:
function mountRef(initialValue) {
const hook = mountHook()
const ref = { current: initialValue }
hook.state = ref
return ref
}
在返回 ref 对象之前,初始值被存储在 current 属性下,而 ref 被存储在钩子的 state 中。
组件挂载后,下次它被更新并到达 useRef 钩子时,它会通过克隆一个来获取钩子:
function updateRef() {
const hook = updateHook()
return hook.state
}
一旦我们有了钩子,我们就可以从 state 中获取 ref 并返回它。此外,请注意,钩子在挂载后不接受任何输入参数。
到目前为止,这是我们见过的最短的钩子实现。以下图表显示了工作流程:


图 8.3 – useRef 工作流程
简而言之,useRef 钩子提供了持久化 ref 的基本存储。存储在钩子中的 ref 在挂载后永远不会更新,其中 current 值被初始化。基本上,useRef 钩子本质上让我们能够开箱即用地管理状态。
如果 ref 被连接为一个元素的属性,当元素挂载或卸载时,其 DOM 实例会在 current 属性中更新。
现在我们知道了 useRef 钩子的设计。如果创建的 ref 用于持有值,它与 useState 钩子有何不同?由于它们都可以持有值,让我们花些时间比较它们,以更好地了解 useRef 钩子。
无更新的状态
通过 useRef 创建的 ref 可以用来持有不仅仅是 DOM 实例,还可以是任何值。在任何时候,我们都可以通过新的赋值来更改它的 current 属性:
ref.current = ...
赋值可以是一个JavaScript表达式。特别之处在于,引用赋值除了赋值之外不做任何其他事情。这意味着它不会触发更新。让我们看看当我们将它连接到用户操作时,它对 UI 的影响:
const Title = () => {
const ref = useRef(null)
const onClick = () => {
ref.current = 'white'
}
return <Child color={ref} onClick={onClick} />
}
在前面的代码中,一个事件处理器被连接到Child组件上,当用户点击时,它将一个颜色赋值给ref.current。这看起来与使用useState钩子的情况非常相似。如果我们使用useState钩子,代码将如下所示:
const Title = () => {
const [color, setColor] = useState('')
const onClick = () => {
setColor('white')
}
return <Child color={color} onClick={onClick} />
}
当比较这两个案例时,我们可以看到主要的不同之处在于以下这一行:
// ref version
ref.current = 'white'
// state version
setColor('white')
在引用的情况下,它是一个简单的赋值,而在状态的情况下,它调度了一个更新来安排状态变化。这意味着状态情况比引用情况要复杂得多。我们甚至可以用状态来模拟引用情况:
const Title = () => {
const [obj] = useState({ color: '' })
const onClick = () => {
obj.color = 'white'
}
return <Child color={obj} onClick={onClick} />
}
在前面的实验中,我们使状态成为一个具有color属性的obj。这个obj与useRef中的ref相当相似,因为obj.color =也是一个简单的赋值:
obj.color = 'white'
你可能会想知道,对于普通的赋值,与ref或obj连接的color属性会发生什么?有趣的是,在这两种情况下,都没有发生任何事情。因为对于普通的赋值,没有向Title组件发送任何消息,因此没有对Child组件进行更新。因此,即使内容发生了变化,React也不会对此做出响应。
因此,本质上,一个引用(ref)可以用来存储一个值,但没有更新能力。这也解释了为什么useRef的源代码如此紧凑,因为它除了返回一个持久值之外,没有做太多其他的事情。
既然我们已经了解了引用(ref)和useRef钩子的设计,让我们来实际测试一下。
测试驱动useRef
总是有可能React在控制DOM元素内部方面显得力不从心。比如说,有一个输入框和一个按钮,如图 8.4 所示。当按钮被点击时,我们希望手动将焦点放在输入框上:
![Figure 8.4 – 焦点输入
![Figure 8.04_B17963.jpg]
图 8.4 – 焦点输入
通常情况下,如果我们点击按钮,它会获得焦点,如果我们点击其他地方,它会失去焦点。但在这个例子中,在我们点击焦点按钮之后,我们希望将焦点放在输入框上而不是按钮上,这样用户就可以立即开始输入。
让我们看看我们如何应用useRef来实现这一点:
const Title = () => {
const ref = useRef()
const onClick = () => {
ref.current.focus()
}
return (
<>
<input ref={ref} />
<button onClick={onClick}>focus</button>
</>
)
}
在前面的例子中,当input被挂载后,它的实例被存储在ref.current中。当我们点击按钮时,事件处理器使用引用来调用原生的DOM focus方法,使输入框获得焦点。就是这样!
虽然引用对象始终是有效的,但current属性并不总是有效的。在挂载完成之前,它可以存储一个null值。在卸载之后,它也可以存储一个null值。因此,为了确保我们不遇到任何运行时错误,我们通常在使用它之前添加一个检查:
if (ref.current) ref.current.focus()
有时候,你会看到以下短路方式:
ref.current && ref.current.focus()
重要的是要注意,尽管我们可以使用ref来控制DOM元素,但 React 不会知道你代码的影响。例如,在我们的例子中,React无法判断输入是否被聚焦。为了使 React 知道这一点,我们仍然需要添加一个状态来跟踪这种变化:
const Title = () => {
const [focused, setFocused] = useState(false)
const ref = useRef()
const onClick = () => {
ref.current.focus()
setFocused(true)
}
...
}
从某种意义上说,获取原始 DOM 元素使我们能够绕过 React 来操作元素。
操场 – 聚焦输入
你可以自由地在这个在线示例中玩耍:codepen.io/windmaomao/pen/WNZwoje
控制子组件引用
由于引用基本上是一个对象,它可以作为属性传递给子组件。因此,传入的ref对象可以附加到子组件内部的 DOM 元素上:
const Child = ({ childRef }) => {
return <input ref={childRef} />
}
利用这个childRef,Child组件允许父组件对其操作:
const Title = () => {
const ref = useRef()
const onClick = () => {
ref.current.focus()
}
return (
<>
<Child childRef={ref} />
<button onClick={onClick}>focus</button>
</>
)
}
在前面的代码中,Title组件通过useRef创建了一个ref对象,并通过childRef属性将其传递给Child。当Child挂载时,它将input实例填充到ref.current中。当我们点击按钮时,它调用Child输入元素的focus方法。这允许父组件控制Child的DOM元素。
注意我们使用的属性名为childRef而不是ref,因为ref是一个保留的属性名,用于附加DOM实例,而childRef只是一个普通的属性,用于传递一个对象。尽管两者都是属性,但ref属性是特殊的。如果我们在这个例子中错误地使用了ref而不是childRef,那就错了:
<Child ref={ref} />
前面的行会要求Child函数组件将其实例分配给ref。但是,函数组件默认情况下没有引用。因此,在传递时避免使用引用名称。实际上,有一种方法可以将引用附加到函数组件上,如果你感兴趣,你可以在本章末尾的附录 B – 传递引用部分找到更多信息。
现在我们已经使用引用来控制组件中的元素,让我们看看更多使用useRef的例子。
useRef示例
引用非常强大。因为React使事物非常具有反应性,如果我们想禁用这种反应性或对其进行调整,引用就给我们提供了这样做的机会。在本节中,我们将探讨更多如何使用它来解决React中有趣问题的例子。
点击菜单外部
假设你有一个组件,并且你想知道当用户点击组件外部时。这是一个非常受欢迎的弹出菜单或模态窗口的功能。一旦菜单可见,我们希望在用户点击任何外部位置时将其关闭:

图 8.5 – 点击外部关闭
假设我们有一个显示菜单项列表的Menu组件:
const Menu = () => {
const [on, setOOn] = useState(true)
if (!on) return null
return (
<ul>
<li>Home</li>
<li>Price</li>
<li>Produce</li>
<li>Support</li>
<li>About</li>
</ul>
)
}
在前面的代码中,创建了一个 on 状态并将其初始值设置为 true,从而使菜单项可见。但当我们点击列表外部时,我们希望将 on 设置为 false 来隐藏它。为了简单起见,这里我们在 Menu 组件内部定义了 on 标志,但在实际应用中,它可能作为属性从父组件传递过来。
我们知道如何使用事件处理器找出用户何时点击了 Menu 组件,但我们如何知道用户何时点击了屏幕外的某个地方?我们需要知道整个屏幕上所有组件的位置吗?
这是我们可以将引用(ref)附加到 ul 元素上的地方:
const Menu = () => {
const [on, setOn] = useState(true)
const ref = useRef()
if (!on) return null
return (
<ul ref={ref}>
...
</ul>
)
}
我们可以监听一个 mousedown 窗口事件,而不是将点击事件处理器附加到一个元素上。这样,我们就能意识到任何用户点击,无论它是在 Menu 组件内部还是外部:
const Menu = () => {
const ref = useRef()
useEffect(() => {
const listener = e => { ... }
window.addEventListener('mousedown', listener)
return () => {
window.removeEventListener('mousedown', listener)
}
}, [])
...
}
在前面的代码中,我们为 mousedown 窗口事件注册了一个事件处理器,因此任何鼠标点击都会调用我们的 listener 函数。在卸载时,我们也确保通过一个 destroy 函数移除这个事件处理器。
当 mousedown 处理器被触发时,我们可以使用 ref 来找出鼠标位置是否包含在 ul 元素的边界内:
const listener = e => {
if (!ref.current) return
if (!ref.current.contains(e.target)) {
setOn(false)
}
}
在前面的 listener 处理程序中,每次 mousedown 事件发生时,我们通过 ref.current 检查元素是否已经挂载,然后通过 e.target 检查鼠标下的元素是否是 ul 元素的子元素。如果用户点击 ul 内的任何子元素,那么我们知道他们点击了内部。如果没有,我们知道用户点击了外部,然后我们可以分派以将 on 状态设置为 false,从而关闭菜单的显示。
操场 – 点击菜单外部
欢迎免费尝试这个在线示例:codepen.io/windmaomao/pen/XWaerGm。
简而言之,借助引用(ref),我们可以调用 contains 函数来找出一个元素是否在另一个元素内部。
避免内存泄漏
从历史的角度来看,引用(ref)最初是用来持有 DOM 元素的,但后来人们发现它在解决棘手问题方面非常有效。一个问题就是内存泄漏,它发生在执行异步操作时。关于异步操作,回调函数会在稍后调用。当回调被处理时,组件(或与组件相关的任何变量)可能已经不再有效。
假设我们获取一个 API 并将结果显示为 text:
const Title = () => {
const [text, setText] = useState("")
useEffect(() => {
fetch("https://google.com").then(res => {
setText(res.title)
})
}, [])
return <h1>{text}</h1>
}
前面的代码是一个常见的获取过程,但其中隐藏着一个内存泄漏。当它发生时,浏览器会输出以下信息:

图 8.6 – 内存泄漏警告信息
虽然在开发构建中,React足够友好地将其显示为警告信息,但实际上这是一个错误,因为它在信息中提到表明存在内存泄漏。这个泄漏的奇怪之处在于,大多数时候,即使在消息出现后,UI仍然可以继续工作。那么,我们应该忽略这个消息吗?绝对不行。
让我们构建犯罪现场,并尝试理解在这个消息下到底发生了什么:
const App = ({ flag }) => {
if (flag) return <Title />
return null
}
假设您有一个App父组件,它根据flag显示Title。对于一次更新,flag变为false,因此Title被卸载,屏幕变空白。这是有效的业务逻辑,那么为什么它是个问题呢?
问题出在Title组件内部,而不是App组件。确切地说,当Title组件挂载时,API 获取开始,但获取可能不会在卸载之前很快完成。flag和获取是两件独立的事情。因此,Title组件在卸载后可能会有未完成的事务。比如说,到了处理未完成事务的时间,比如回调函数——setText语句会发生什么?当组件已经消失时,它应该引发另一个更新吗?
技术上讲,如果组件被卸载,它就不再可以被更新了。此外,每个钩子都在纤维下注册,如果纤维被移除,那么其下注册的任何内容都不应该再被访问。否则,会出现不一致的情况,例如内存泄漏。
所以回到我们的案例,当一个异步调用在卸载后返回——这成为一个我们不能忽视的明确错误。这个错误在代码切换到另一个更新分支的情况中很常见,比如路由切换。大多数内存泄漏都很难调试,所以我们应不惜一切代价避免它们。
操场 - 内存泄漏
欢迎您在这个在线示例codepen.io/windmaomao/pen/VwzMYNL中尝试。
为了查看内存泄漏信息,您需要打开浏览器开发者面板并切换到控制台标签页。
为了解决这个问题,我们需要做的事情是,根据Title组件是否仍然挂载,仔细保护回调函数的内容:
const Title = () => {
const [text, setText] = useState("")
const mountedRef = useRef(true)
useEffect(() => {
fetch("https://google.com").then(res => {
if (!mountedRef.current) return
setText(res.title)
})
return () => {
mountedRef.current = false
}
}, [])
}
在前面的代码中,我们添加了mountedRef来指示Title是否挂载。我们最初将其设置为true,因为当组件被更新时,我们假设可以安全地分派更多更新。在通过useEffect卸载后,我们在destroy函数中将mountedRef标志设置为false。
现在,在获取的回调处理程序中,我们通过读取mountedRef来检查它是否仍然挂载。如果它是false,我们取消处理程序操作,而不前进到访问任何内部方法,比如setText。这意味着即使API成功,也不会有更新将这个值带到屏幕上。
操场 - 避免内存泄漏
欢迎尝试这个在线示例:codepen.io/windmaomao/pen/wvqraKP。
你可能会想知道为什么我们不能用状态而不是引用(ref)来为mountRef目的。让我们假设我们用mounted状态替换mountRef:
const Title = () => {
const [mounted, setMounted] = useState(true)
useEffect(() => {
...
return () => {
setMounted(false)
}
}, [])
}
虽然很有创意,但前面的代码不会工作。因为本质上,你在卸载后要求分发一个新的更新,这正是我们想要避免的内存泄漏。在第五章,“使用 Effect 处理副作用”中,我们学习了被动效果的destroy函数是在所有 DOM 元素稳定后最后被调用的,所以到那时,我们不应该被允许访问任何内部方法。
这个例子也告诉我们,从引用(ref)的变化不应该反映在用户界面(UI)中,而状态(state)的设计是为了始终与 UI 保持同步。
设置骡子
当我们设计一个 Web 应用程序时,我们往往不使用全局变量,因为我们知道它们的用法很容易导致一些难以管理的副作用。另一方面,如果我们有一些对整个站点都有效的全局信息,如果我们想在幕后与整个应用程序的其他部分共享它,那么这仍然很方便。那么,在这种情况下,我们可以有什么妥协呢?
在第七章,“使用上下文覆盖区域”,我们学习了如何创建上下文以共享站点的信息。我们可以在树的顶部提供信息,即App组件:
const App = () => {
const [value, setValue] = useState(0)
return (
<AppContext.Provider value={{ value, setValue }}>
...
</AppContext.Provider>
)
}
在前面的代码中使用状态的一个事实是,通过setValue分发函数更改值会导致整个站点更新,这可能是一个非常昂贵的操作。如果我们不需要通知用户这个更改,我们可以使用引用(ref)来代替:
const App = () => {
const value = useRef({
count: 1
})
const onIncrement = () => {
value.current.count++
}
return (
<AppContext.Provider value={value}>
<button onClick={onIncrement}>+<button>
<Title />
</AppContext.Provider>
)
}
在前面的代码中,使用useRef创建了一个引用(ref),用于在current属性下持有自定义的count值。我们可以通过onIncrement点击按钮来增加它。我们还添加了一个Title组件来消费这个count值:
const Title = () => {
const { current } = useContext(AppContext)
return <div>{current.count}</div>
}
在前面的Title组件中,它从AppContext中消费current,并显示存储的count值。在这种设置下,如果你在App组件中点击onIncrement,number值始终保持在0。看起来我们的count出了问题。
为了揭示发生了什么,让我们在Title组件中添加一个带有按钮的手动更新:
const Title = () => {
const { current } = useContext(AppContext)
const [number, setNumber] = useState(current.count)
const onClick = () => {
setNumber(current.count)
}
return <button onClick={onClick}>{number}</button>
}
在前面的代码中,我们将count值放入一个本地的number状态中,这样我们就可以使用setNumber来进行更新。现在,当你通过onClick点击number时,你将在屏幕上看到最新的current.count值,如图图 8.7所示:

图 8.7 – 具有单独更新的计数状态
在这里看到数字如何在屏幕上显示的过程有点引人入胜。首先,我们增加它,然后我们揭示它。因此,current.count并没有损坏;它只是没有与屏幕同步。
操场 – Mule 上下文
随意在这个在线示例中玩耍:codepen.io/windmaomao/pen/YzxrXQN。
在像AppContext这样的上下文中,我们可以存储值并自由地使用它,而不与显示解耦。从某种意义上说,ref 上下文变成了一个可以绕过React在组件之间移动任何数据(或功能)的“驮马”。实际上,当你需要引入一个第三方库,这个库不一定与React紧密连接,但你希望它能够与React一起运行时,这种上下文是一种有效的方法。
定位当前值
current属性是 ref 的独特之处。ref 下的current属性名称给出是有原因的,因为从技术上讲,在 React 中没有什么比 ref 更“当前”的了。
当我们使用useState钩子时,我们想知道状态的当前更新值。尽管我们使用了相同的词,但在某些情况下,状态并不一定是“当前”的。我们将通过一个例子来演示这一点。
假设我们有一个按钮用来增加计数,但不是立即增加,而是在点击后等待 3 秒钟:
function Title() {
const [count, setCount] = useState(0)
const onClick = () => {
setTimeout(() => {
console.log('clicked', count) ➀
setCount(count + 1)
}, 3000)
}
console.log('updated', count) ➁
return <button onClick={onClick}>+</button>
}
在前面的代码中,在事件处理程序中使用了setTimeout来故意延迟setCount函数 3 秒钟。我们期望看到的是每次点击都应该像延迟点击一样,屏幕上的count值在 3 秒后增加到1、2和3。
当我们运行代码时,它显示的结果不同,如下一个时间线所示:
|--------------0-0--0----> clicked ➀
0---------------1-1--1---> updated ➁
在连续点击按钮三次并等待 3 秒后,我们没有在屏幕上看到count增加到3。相反,我们看到它只增加到1。这很令人惊讶吗?
操场 – 当前值在哪里?
随意在这个在线示例中玩耍:codepen.io/windmaomao/pen/ZEJXbEG。
三个分发如何最终只导致一个分发?让我们通过在时间线上添加两个更多系列来调试,即"x"点击和"R"更新:
0.5s 3.5s
|-----x-x--x-------------> click
|--------------0-0--0----> clicked ➀
R---------------R-R------> update
0---------------1-1------> updated ➁
当我们第一次点击按钮(大约在t=0.5s时),事件处理程序中的count值是多少?它是0,我们的初始状态。然后,当我们第二次点击按钮时,事件处理程序中的count值是多少?你会说,它一定是1,对吧,因为我点击了它?但不幸的是,情况并非如此。
观察到"updated"序列,点击后并没有立即接收到第二次更新。如果没有新的更新,count将继续保持旧状态。由于新的更新直到三秒后(大约在t=3.5s)才到达,在这段时间内,任何事件处理程序都会携带相同的count。好吧,这就解释了为什么在每次点击时"clicked"序列打印了0。这几乎就像是所有的三次点击都执行了相同的派发语句:
const onClick = () => {
setTimeout(() => {
setCount(0 + 1) // count = 0
}, 3000)
}
实际上,我们派发了三次请求来更改1。在我们的案例中,所有点击都在第二次更新之前发生。这造成了一个不同步的时刻。这并不是设计缺陷,因为count只被设计用来指向当前更新中的一个副本。除非它是由指向相同内存空间的指针组成,否则它不能指向当前值。
一个常见的误解是将setState称为一个赋值操作。到现在为止,你应该看到这是不正确的,因为它实际上请求一个赋值而不是执行赋值。请求需要时间来处理和执行,而且,赋值可以被优化撤销。从setState来的这个赋值的命运并不明朗,而在引用案例中,赋值是明确、即时且不可错过的。
让我们应用一个引用来解决这个问题:
function Title() {
const [count, setCount] = useState(0)
const ref = useRef(0)
const onClick = () => {
setTimeout(() => {
ref.current++
setCount(ref.current)
console.log('clicked', ref.current) ➀
}, 3000)
}
console.log('updated', count) ➁
...
}
我们可以通过以下时间线草图来确认这一点:
|-----0.5s-----3.5s------> time
|-----x-x--x-------------> click
|--------------1-2--3----> clicked ➀
R---------------R-R--R---> update
0---------------1-2--3---> updated ➁
好吧,现在我们使用引用来存储数字和count状态后,一切工作正常。ref.count++语句增加了当前数字,并继续存储更新的数字。在这里,我们使用了状态和引用来跟踪一个单一的数字。这有点过度,我们在这里做这个演示只是为了展示解决方案。在第九章的使用自定义钩子重用逻辑部分,useCurrent 钩子,我们将把这个方法精炼成更实用的东西。
操场 – 定位当前值
随意在这个在线示例中玩耍:codepen.io/windmaomao/pen/eYEGpJJ。
对于这个问题有一个更简单的解决方案,而且它不涉及引用。记住,useState钩子支持另一种函数式格式:
const onClick = () => {
setTimeout(() => {
setCount(v => v + 1)
}, 3000)
}
在前面的代码中,使用了函数式格式设置器,这样我们就可以通过v读取当前状态,因为我们想在提交之前确切地知道当前状态是什么。v => v + 1语句变得至关重要,有时我们甚至可以在这个函数内部放置一些逻辑:
setCount(v => {
// perform some action
// based on the current v
return v
})
看起来前面的位置不是执行除了值更新之外的其他业务逻辑的正确地方;然而,根据useState的设计,这实际上是唯一一个你可以一致读取当前状态的位置。我们甚至返回了当前的v值,这意味着我们只想获取当前值,而不关心新的更新。你可以把这当作一个虚构的getCount访问函数来访问状态。
总的来说,这两种解决方案都揭示了这样一个事实:在当前的更新中,状态值可能会与其底层当前值不同步。
你可能现在会有一个疑问——如果引用如此强大且灵活,为什么我们不直接用它来替换状态?这个问题的答案在于这样一个困境:React希望开发者使用的状态是一个受管理的状态,它在任何状态变化时都会负责分发。然而,引用是一个原始状态,开发者仍然需要管理UI更新的其他各个方面。从某种意义上说,如果我们用引用来做所有的事情,那么我们甚至不需要使用React,因为引用的目的就是隐藏东西,而不引起引擎的注意。
摘要
在本章中,我们首先学习了什么是React引用。然后我们介绍了如何使用引用访问DOM元素,并探讨了useRef钩子的设计,以及如何持久化一个值而不触发UI更新。我们还通过一个输入焦点示例对useRef进行了测试。最后,我们通过展示更多使用示例,包括在菜单外点击、避免内存泄漏、设置中转站和定位当前值,来展示了它的特殊性。
在下一章中,我们将把迄今为止学到的所有钩子放在一起,看看如何最终创建你定制的钩子,以解决你自己的问题。
问题和答案
这里有一些问题和答案来刷新你的知识:
-
什么是引用?
React引用是一个用于持有持久值的容器。通常,你可以将值用作没有更新能力的原始状态。
-
什么是
useRef?useRef钩子可以在函数组件中创建一个引用。一旦创建,它就可以在组件的生命周期内用作持久容器。 -
useRef的常见用法有哪些?useRef的一个主要用途是持有可以用来调用原生DOM功能的DOM实例。另一个主要用途是绕过 React 做一些事情,而不会意外地启动引擎。
附录
附录 A – 回调引用
React 提供了两种通过ref属性接收元素实例的方式。最简单的一种是我们介绍的方式,即对象格式。但还有一种叫做回调引用,它采用函数格式:
function commitAttachRef(fiber, instance) {
var ref = fiber.ref;
if (ref !== null) {
if (typeof ref === 'function') {
ref(instance)
} else {
ref.current = instance
}
}
}
类似地,当DOM元素卸载时,这种功能格式也得到支持:
function commitDetachRef(fiber) {
var ref = fiber.ref
if (ref !== null) {
if (typeof ref === 'function') {
ref(null)
} else {
ref.current = null
}
}
}
在 DOM 附加或分离期间,如果它发现ref属性以函数格式提供,它将调用它并将实例传递给它。这里的使用方法:
const Title = () => {
const ref = useRef()
const onRef = (instance) => {
ref.current = instance
}
return <h1 ref={onRef}>...</h1>
}
在前面的代码中,一个onRef函数被连接到ref属性。设置引用的两种方式,无论是对象还是回调,都是可比较的。而且函数方式似乎涉及更多的工作。那么这种函数格式有什么更实用的地方?
尽管引用对象给我们分配了 DOM 元素,但它并没有告诉我们 DOM 元素何时被附加或移除。因此,为了捕捉这些时刻,我们可以使用引用回调:
const ref = useRef()
const setRef = (r) => {
if (...) {
ref.current = r
} else {
…
}
}
return <h1 ref={setRef}>...</h1>
在前面的代码中,基于一个条件,我们可以决定在哪里存储这个引用或存储哪个引用。示例只提供了一个非常简单的实现,但你可以看到这为我们提供了更多在管理 DOM 实例方面的自定义逻辑空间。
附录 B – Forward ref
引用用于存储类的实例,无论这个类是 DOM 元素还是类组件。但并非所有组件都是使用类编写的,例如函数组件:
function Title = () {
return ...
}
// Not valid declaration
const ATitle = new Title()
// Not valid operation
ATitle.doSomething()
在前面的代码中,我们将 Title 声明为一个函数组件。但由于它没有用类声明,我们不需要使用 new 来创建实例。相反,我们在更新时通过 Title() 调用它。同样,由于这个原因,我们无法通过实例方法(如 ATitle.dosomething())访问内部变量。
这就是为什么我们之前提到我们不能将引用附加到函数组件上;默认情况下,这不是函数组件可以提供的:
return <Title ref={ref} />
然而,从实际的角度来看,对于开发者来说,获取 Title 实例并对其实施一些操作是有意义的。因此,为了满足这一需求,并将引用的概念一致地应用于所有组件,React 提供了一个名为 forward ref 的选项:
const Title = React.forwardRef((props, ref) => {
return (
<h1 ref={ref}>
{props.children}
</h1>
)
})
在前面的设置中,通过使用 React 提供的 forwardRef 函数,我们可以将定义在 h1 元素上的引用提升为 Title 组件的引用。这意味着什么?让我们看看一个用法示例:
const App = () => {
const ref = useRef()
const onClick = () => {
ref.current.textContent = "Hello"
}
return <Title ref={ref} />
}
在前面的 App 组件中,我们现在可以将 Title 类似于一个 h1 元素来处理;当我们更改其内容时,它实际上会改变 Title 内部的 h1 文本内容。本质上,一个引用从子组件传递给了父组件。
函数组件与类组件不同,在 React 中没有实例方法,所以即使我们现在有了引用,如果我们想支持它,也需要设置一个自定义方法:
const Title = React.forwardRef((props, ref) => {
useImperativeHandler(ref, () => ({
go: () => { ref.current.focus() }
}))
return (
<h1 ref={ref}>
{props.children}
</h1>
)
})
在前面的代码中,React 提供了一个内置的钩子 useImperativeHandle,允许我们自定义实例值。在这个例子中,我们为引用添加了一个 go 自定义方法:
const App = () => {
const ref = useRef()
const onClick = () => {
ref.current.go()
}
return <Title ref={ref} />
}
这样,当我们要求 Title 实例移动时,它会聚焦到 h1 元素上。
因此,通过 forwardRef 和 useImperativeHandle,我们向函数组件添加了一个引用。这为开发者提供了更多手动控制函数组件的机会。然而,我们需要理解,从子组件传递给父组件的引用来自单个元素,所以从技术上讲,这种方式完成的引用仍然是该元素的引用,而不是函数组件的真实引用。
第九章:使用自定义钩子重用逻辑
在上一章中,我们学习了 useRef 钩子的设计和如何使用引用来更新状态而不刷新屏幕。在这一章中,我们将汇集到目前为止所学的所有钩子,并看看如何创建一个自定义钩子来满足我们的需求。我们将介绍什么是自定义钩子,然后逐步编写一些自定义钩子,包括 useToggle、useWindow、useAsync、useDebounced、useClickOutside、useCurrent 和 useProxy。
在这一章中,我们将涵盖以下主题:
-
复习 React 钩子
-
useToggle -
useWindow -
useAsync -
useDebounced -
useClickOutside -
useCurrent -
useProxy -
问题和答案
复习 React 钩子
我们已经看到了 React 提供的许多钩子。让我们花点时间回顾一下我们到目前为止学到了什么:
-
使用
useState钩子更新状态。 -
使用
useEffect钩子处理副作用。 -
使用
useMemo钩子重用最后一个值。 -
使用
useContext钩子更新区域。 -
使用
useRef钩子隐藏显示内容。
useState 钩子是最受欢迎的,用于定义状态并使其可触发 UI 更新。React 希望我们使用这个作为与屏幕同步的主要机制。使用它的一个心理图像是,只要状态发生变化,UI 就应该相应地产生结果。否则,UI 应保持不变。本质上,这意味着要在屏幕上发生某些事情,设计一个状态并将其与元素连接起来。这是 React 的方式。如果你以此为基准,可以帮助你理解其他任何东西。
useEffect 钩子允许我们监听状态变化,基于此,我们可以执行一个动作,例如副作用。因此,有了它,你就有两种方式在屏幕上产生某些事情,即监听事件或状态变化。这里的微妙之处在于,副作用不会在更新后所有 DOM 元素都稳定下来之前应用。另外,别忘了清理副作用,如果有的话。
useMemo 钩子作为一个优化,使我们能够使用在之前更新中评估的值。基本设置是,如果状态已更改,它应该驱动另一轮更新。在那个更新中,所有组件变量都应该得到更新。但如果我们有意重用旧值,值似乎会“跳过”更新。这样,我们可以抑制一些与组件更新无关的高频动作。
当涉及到区域更新时,useContext 钩子是必不可少的。一个 useState 钩子可以将状态发送到某个地方,但它缺少两件事。一是需要使用属性将状态进一步发送到子组件,二是需要知道它将属性发送给哪些子组件。一旦建立上下文,任何在其下的子组件都可以消费它,无论层级有多深。并且它支持按需使用——你消费时才使用它。
useRef 钩子是 React 支持的一种绕过 React 引擎的方式。默认情况下,React 会希望对所有的状态变化做出反应。useRef 钩子允许你继续持久化这个值,而不需要更新能力。因此,useRef 钩子可以在 React 无法或不允许我们触及的地方变得非常有用。
前面的钩子并不是 React 钩子集合的全部。实际上,React 有十几个内置的钩子;例如,useCallback、useLayoutEffect、useTransition 和 useDeferredValue,其中一些也处于 React 未来并发模式的实验阶段。
我们到目前为止所涵盖的钩子有一个独特之处。每个钩子都是独特的,并且每个钩子都是为了一个原子目的而设计的。它们之间没有太多的重叠。当我们想要在我们的应用程序中混合和匹配它们时,这为我们提供了一个坚实的基础,正如我们在前面的章节中已经看到的。
当涉及到构建网站时,可能会有时候你想创建一些可能不被这些内置钩子覆盖的自定义逻辑。你可能考虑扩展一些钩子,或者你可能甚至想重写一个或两个。在这种情况下,我们有什么选择?这个问题的答案在下一节中。
创建一个新的钩子
我们能否创建一个新的钩子?在每一章的前面,我们都要求你阅读源代码,所以到现在,你应该熟悉每个钩子是如何在底层实现的。那么,我们能否遵循同样的过程并创建一个呢?不幸的是,这并不那么容易,主要是因为这个过程并不允许即时扩展。
用游戏引擎作类比,它允许你处理动画、材质、灯光,甚至游戏逻辑,但它不允许你更改引擎。例如,你不能添加一个既不是动画也不是材质的自定义身份类型,并仍然期望游戏引擎能够识别它。你可能会问“为什么不能?”这是因为自定义身份类型需要引擎进行额外的实现以支持它。
注意
React 是一个开源项目,这意味着任何人都可以为引擎做出贡献。源代码也由 Facebook React 团队积极维护,他们一直在寻找新的提案和功能请求。
虽然引擎不容易扩展,但创建自定义钩子的门并没有关闭。大多数时候,我们不需要新的钩子类型,而是希望有一个具有扩展行为的钩子。让我们来看一个例子:
const aFunctionWrittenByOthers = () => {}
给定前面的函数,我们可以轻松地将其封装在一个新的函数中:
const aFunction = () => {
aFunctionWrittenByOthers()
...
}
钩子是一个函数。如果一个钩子被设计用来管理状态,每次我们需要状态时,我们就不需要重新发明它;同样,如果一个钩子被设计用来处理副作用,每次我们需要副作用时,我们可以通过调用它来采用这些功能。所以,只要我们设计的钩子足够有用和可用,我们就应该能够直接在我们的函数中使用它。这就是可重用性的基本思想。
让我们看看下面的例子:
const useUsername = (initialFirst, initialLast) {
const [firstName, setFirstname] = useState(initialFirst)
const [lastName, setLastname] = useState(initialLast)
const fullname = firstName + ' ' + lastName
return { fullname, setFirstname, setLastname }
}
在前面的函数中,useUsername使用了两次useState钩子,输出包括来自两个状态firstName和lastName的联合fullname,以及两个单独的派发函数来更新它们。
让我们更仔细地检查一下useUsername函数,因为useUsername是一个自定义钩子。
编写自定义钩子
我们现在可以使用我们刚刚创建的useUsername函数如下:
const Title = () => {
const { fullname } = useUsername('John', 'Doe')
...
}
将前面两个版本与或没有useUsername钩子的版本进行比较,我们可以看到useUsername函数基本上是通过代码重构提取的一个实用函数,其中新useUsername函数的接口以两个字符串作为输入参数,以及一个包含字符串和两个函数的对象作为返回值。
这正是我们最初创建新函数的方式。我们创建函数是因为我们需要它,或者因为我们看到代码中存在一些重复,我们可以通过一些重构来避免重复。这样,代码不仅会变得整洁,而且函数还可以在未来的其他地方使用。重构更像是一种一石二鸟的方法,只要有多只鸟要打。
这是计算机科学 101,我们迄今为止创建的自定义钩子是它的一个演示。好,让我们回顾一下自定义钩子的一些基本概念。
我们之所以称useUsername为自定义钩子,是因为它满足以下要求:
-
它是一个函数。
-
它以
use为前缀命名。 -
它至少消耗了一个内置钩子。
显然,我们可以编写一个函数并随意给它一个以use为前缀的名字,但这样的函数是否算作自定义钩子呢?让我们看看下面的函数:
const useNotAHook = (a) => {
return a
}
const Title = () => {
return useNotAHook()
}
在前面的设置中像useNotAHook这样的函数不是钩子!我们不是一直在说钩子是一个函数吗?是的,但并非所有函数都是钩子,即使它在功能组件内部被调用,比如在Title组件中。useNotAHook仅仅是一个普通函数。
你可能会想,“好吧,我们需要让函数变得更复杂才能算作自定义钩子。”让我们尝试以下函数:
const useValue = (v) => {
const [value] = useState(v)
return value
}
前面的函数中只有两行,所以它并不复杂。它只取useState钩子的第一部分并返回状态。就是这样。但它是自定义钩子吗?是的,它是。所以,自定义钩子不必很复杂!
希望到现在为止,你看这些令人费解的例子时不会感到头晕。简而言之,按照惯例,一个自定义钩子需要满足之前列出的所有要求。
从技术上讲,自定义钩子和常规函数之间唯一的区别是它至少使用了一次内置钩子。内置钩子有什么特别之处呢?内置钩子“钩入 React”并提供了一些你无法在不打开引擎的情况下实现的功能。这里的功能主要指的是管理持久状态。这就是“自定义”这个名字的由来,用来区分你创建的钩子和内置钩子。
在我们开始创建自定义钩子之前,还有另一个值得注意的独特之处。大多数自定义钩子都是没有附带任何视觉表示的。因此,自定义钩子本质上是一段可重用的算法。
这是 React 团队构想的钩子可以为我们带来的。钩子“让你可以在组件之间重用逻辑。”当你创建自定义钩子时,请记住这一点。如果你觉得逻辑足够通用,或者至少你认为还有其他地方可以应用相同的逻辑,那么是我们尝试使用自定义函数的时候了。如果它最终使用了内置钩子之一,那么你就可以创建一个自定义钩子了。
现在我们有了基本的概念,回顾一下,如果我们回顾这本书中迄今为止所写的代码,我们会发现我们可能在不自知的情况下上了这艘船。让我们重新审视它。
useToggle
在这个自定义钩子中使用的钩子:useState
以一个例子为例,我们已经有在true和false之间切换状态的想法有一段时间了。我们用它来处理可切换的情况,比如切换复选框、悬停在文本上、引发错误,或者任何模拟开关灯的行为。参见图 9.1中的一个用法:

图 9.1 – useToggle
我们能否将这个想法抽象化,提供这样的布尔状态以及切换功能?让我们开始重构:
const useToggle = (initialStatus = false) => {
const [status, setStatus] = useState(initialStatus)
const toggle = () => {
dispatch(status => !status)
)
return [status, toggle]
}
在前面的代码块中,useToggle自定义钩子接受一个initialStatus作为输入参数,默认值为false,并返回status和一个toggle函数。调用toggle函数会将status从false切换到true,或者从true切换到false。
useToggle钩子有一个设计得很好的函数,具有定义明确的输入参数和返回值,看起来非常适合支持布尔状态的切换。这里我们可以做一些小的改进。有时,我们还想切换到特定的状态,而不仅仅是翻转:
const toggle = (newStatus) => () => {
if (newStatus === undefined) {
setState(status => !status)
} else {
setState(newStatus)
}
}
在toggle的前一个修订版本中,当给出newStatus时,它会切换到该特定状态,否则,它会像旧的toggle一样翻转。注意我们使用了一行中的双箭头,如() => () => {}:
const toggle = (newStatus) => {
return () => {
…
}
}
如果我们慢慢写,我们可以使用前面的等效版本,其中可以清楚地看到从函数中返回了一个内联函数,因为在这种情况下,我们期望toggle返回的是一个事件处理器。
返回函数的函数在函数式编程(FP)中非常常见。尽管这本书并没有强烈要求使用 FP(或者可能已经做到了),但在React代码中使用 FP 是很自然的,尤其是在处理函数组件时。
好的,现在我们已经设计了自定义钩子,让我们来试一试。
用法
假设我们将这个useToggle钩子应用到可以切换error状态的Avatar组件中。Avatar组件在第四章中介绍,使用状态启动组件:
Const Avatar = ({ src, username }) => {
const [error, onError] = useToggle()
return (
<AvatarStyle>
{error ? (
<div>{username}</div>
) : (
<img
src={src}
alt={username}
onError={onError()}
/>
)}
</AvatarStyle>
)
}
很有趣!尽管在应用useToggle前后没有太大的差异,但在图像加载遇到错误后,error状态被切换的逻辑变得相当清晰。
操场 – 使用 useToggle 的 Avatar
欢迎自由地在这个在线示例中尝试codepen.io/windmaomao/pen/yLozOJQ。
让我们尝试在另一个地方应用useToggle,比如在也介绍在第四章中的Tooltip组件中,使用状态启动组件:
const Tooltip = ({ children, tooltip }) => {
const [entered, onEntered] = useToggle()
return (
<TooltipStyle>
<div
onMouseEnter={onEntered(true)}
onMouseLeave={onEntered(false)}
>
{children}
</div>
{entered && (
<div className="__tooltip">
{tooltip}
</div>
)}
</TooltipStyle>
)
在前面的代码中,我们使用了useToggle来返回一个entered状态和一个onEntered函数,这个函数可以很好地输入到onMouseEnter和onMouseLeave事件处理器中。
操场 – 使用 useToggle 的工具提示
欢迎自由地在这个在线示例中尝试codepen.io/windmaomao/pen/QWMqNKx。
通过useToggle钩子,切换机制的概念得到了生动的揭示。函数可以单独测试,并且可以相对容易地扩展。如果我们经常使用这个钩子,创建和维护它的成本甚至可以更低。
参考文献
在互联网上,有许多人编写了类似于useToggle的类似钩子。以下是一些供你参考的列表,以便你了解更多关于这个自定义钩子的信息:
-
useToggle:usehooks.com/useToggle/. -
react-use-toggle:github.com/bsonntag/react-use-toggle. 它有一个很好的测试。
useWindow
在此自定义钩子中使用的钩子:useState和useEffect
文本或图像可以根据当前浏览器窗口大小调整其外观。我们在第五章中实验了这个想法,使用 Effect 处理副作用。见图 9.2。

图 9.2 – useWindow 自定义钩子
现在的问题是:我们能否将这个想法抽象出来,并将其应用于屏幕上的任何地方,就像响应式设计一样?让我们稍微重构一下代码,以提出一个自定义的useWindow钩子:
const useWindow = (size = 0) => {
const [width, setWidth] = useState(0)
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth)
}
handleResize()
window.addEventListener("resize", handleResize)
return () => {
window.removeEventListener("resize", handleResize)
}
}, [setWidth])
return [width, width > size]
}
前面的 useWindow 钩子是从我们之前的代码中提取出来的,并返回屏幕的当前宽度。使用 useEffect 来处理浏览器 resize 事件上的初始化和清理系统事件。在每次屏幕调整大小时,窗口的 innerWidth 被存储在 width 状态中。
为了使其易于使用,我们可以向这个自定义钩子提供输入参数 size,这样它也可以告诉我们 width 是否超过了那个 size,从而告诉我们屏幕是否足够宽以支持大尺寸版本。
用法
让我们将这个自定义钩子应用到当屏幕尺寸达到 600 px 时可以调整为大版本的文本:
const Greeting = () => {
const [, wide] = useWindow(600)
return <h1>{wide ? "Hello World" : "Hello"}</h1>
}
这看起来非常容易使用,而且更重要的是,与检测窗口尺寸相关的功能完全被提取出来并外包给 useWindow 钩子,因此大大减少了代码量。
操场 – 使用 useWindow 响应式
欢迎自由地在这个在线示例中玩耍 codepen.io/windmaomao/pen/zYdEqog.
这个自定义钩子有一个独特之处。与 CSS 支持的经典 media-query 不同,useWindow 返回的 wide 标志用于完全更改布局。这意味着我们可以支持非常剧烈的屏幕变化以适应屏幕尺寸:
const Header = () => {
const [, wide] = useWindow(725)
return wide ? <HeaderWide /> : <HeaderMini />
}
在前面的代码中,HeaderWide 和 HeaderMini 是两个完全不同的布局组件,用于显示小于 725 和大于 725 的屏幕尺寸的标题。
从制作这个自定义钩子的过程中,我们学到的一点是,功能可以根据您的目的进行定制。在这里,我们导出一个标志,width > size,因为我们认为它对当前项目很有用。然而,响应性设置不是固定的,它可以因项目而异。这并不会阻止我们在当前时刻创建一些有用的东西。这正是代码重构的意义,即提高代码质量。
参考资料
我们版本的 useWindow 只监控窗口宽度,但我们也可以跟踪屏幕的宽度和高度,如下面的参考实现所示:
-
useWindowSize:usehooks.com/useWindowSize/ -
useWindowSize:github.com/jaredLunde/react-hook/tree/master/packages/window-size
useAsync
在此自定义钩子中使用的钩子:useState, useEffect, useRef, 和 useMemo
每个人都希望尝试的钩子之一是 useAsync,它用于获取异步资源,正如我们在第五章中介绍的,使用 Effect 处理副作用。参见 图 9.3:

图 9.3 – useAsync 钩子
虽然听起来很简单,但每个人对他们的项目都有不同的要求和实现。以下是我们希望拥有的几个功能:
-
支持加载指示器。
-
可按需执行。
-
支持错误处理。
-
可以取消异步调用。
-
可以缓存异步数据。
功能列表可以一直继续。在这本书中,我将提供一个支持前两项的基本版本。
在任何时刻,调用都应该知道loading状态,并且当资源解析完成后,data应该可用以供使用。此外,我们希望保留对execute函数的引用,以防我们想要再次获取资源。让我们通过自定义钩子来设计它:
const useAsync = (
asyncFunc,
initialParams = {},
immediate = true
) => {
...
return { execute, loading, data }
}
在先前的代码块中,useAsync钩子接受三个输入参数,asyncFunc、initialParams和immediate,并返回三个属性,execute、loading和data。asyncFunc函数是一个用户提供的异步函数,例如一个Promise,如下定义:
const fn = ({ id }) => {
return fetch('/anAPIResource/${id}')
.then(res => res.json())
}
在先前的fn承诺中,一个id作为输入参数发送。这就是useAsync的第二个输入参数变得有用的地方,它可以用来以键/值对的形式提供initialParams,例如{ id: 3 }。
useAsync钩子还支持一个可选的标志immediate,当设置为true时,它会在组件挂载后立即调用异步调用。尽管这是最常见的情况,但我们也可以将其设置为false,这样我们就可以稍后手动调用execute。
在钩子内部,我们使用状态来模拟loading和data:
const [loading, setLoading] = useState(immediate)
const [data, setData] = useState(null)
const mountedRef = useRef(true)
使用useRef来为mountedRef提供信息,以知道何时这个组件被卸载;我们已在第八章,“使用 Ref 隐藏内容”中解释了这一点。
为了提供按需获取资源的能力,我们创建了一个execute函数,它接受一个params对象:
const execute = params => {
setLoading(true)
return asyncFunc({
...initialParams,
...params
}).then(res => {
if (!mountedRef.current) return null
setData(res)
setLoading(false)
return res
})
}
在先前的execute函数中,将loading设置为true,这样如果我们将一个加载指示器与之连接,它就可以开始旋转。然后它使用params和initialParams的组合调用asyncFunc,这样params就可以覆盖initialParams设置的任何键。
当资源解析并返回时,我们首先通过mountedRef检查组件是否仍然挂载,如果不是,我们跳过以避免内存泄漏。否则,它将根据需要设置data,并通过将loading设置为false来关闭加载指示器。
这里还有一个细微之处,我们希望使用这个execute函数的一个版本,而不是在每次更新时使用一个新的实例,因此,我们可以在这里应用useMemo来实现这一点:
const execute = useMemo(() => params => {
setLoading(true)
return …
}, [asyncFunc, setData, setLoading])
当immediate设置为true时,我们希望在挂载后立即调用获取操作,这通过useEffect钩子得到支持:
useEffect(() => {
if (immediate) {
execute(initialParams)
}
}, [immediate, execute])
为了确保我们不遇到内存泄漏,我们还需要在组件卸载时将mountedRef设置为false。这是通过另一个useEffect来完成的:
useEffect(() => {
return () => {
mountedRef.current = false
}
}, [mountedRef])
通过这些更改,这个自定义的useAsync钩子对于一般资源获取来说功能丰富。让我们试一试。
用法
现在让我们将useAsync自定义钩子应用到Title组件上,看看我们如何可以预加载一些来自API的信息:
const fn = () => fetch("google.com")
const Title = () => {
const { data, loading } = useAsync(fn)
if (loading) return 'loading ...'
if (!data) return null
return <div>loaded</div>
}
在前面的代码中,异步函数立即被调用。在挂载后,它显示null,在获取过程中显示loading...,在获取成功后显示loaded。在这个简单的情况下,我们添加了两个短路路径:
if (loading) return 'loading ...'
在加载时,我们切换到加载状态;这是你可以安装一个漂亮的(内联)加载器或旋转器的位置:
const spinner = <Spinner >
…
if (loading) return spinner
无论加载是否尚未开始或获取失败,只要数据不可用,我们就在屏幕上不显示任何内容:
if (!data) return null
这种逻辑可以有效地防止用户看到任何不完整或错误的数据。
Playground – 使用 useAsync 获取
欢迎在线尝试这个示例codepen.io/windmaomao/pen/jOLaOxO。
好的,让我们尝试一个初始不进行获取的案例。相反,我们从用户交互中获取一个任意的id资源,例如表格行中的删除按钮:
const fn = ({ id }) => fetch('google.com/${id}')
const Title = () => {
const {
execute, data, loading
} = useAsync(fn, {}, false)
const onClick = id => () => {
execute({ id })
}
if (loading) return 'loading ...'
return data ? <h1>{data}</h1> : (
<button onClick={onClick(3)}>Load 3</button>
)
}
在前面的例子中,fetch承诺被修改为接受id作为输入。我们在事件处理程序onClick内部手动使用execute,其中使用给定的id获取资源。
Playground – 使用 useAsync 手动获取
欢迎在线尝试这个示例codepen.io/windmaomao/pen/GRvOgoa。
参考资料
我们提供的useAsync钩子作为学习目的的基本模板。如果你对你的项目需要更多功能,你可以在以下参考资料中找到更多:
-
useAsync:usehooks.com/useAsync/ -
Hooks Async:
github.com/dai-shi/react-hooks-async -
Vercel SWR:
swr.vercel.app/– 支持缓存和服务器集成 -
React Query:
github.com/tannerlinsley/react-query
useDebounced
在此自定义钩子中使用的钩子:useState、useEffect和useRef
在第六章,“使用 Memo 提升性能”中,我们遇到了一个非常有趣的实现,其中我们防抖了用户的按键,这样我们就不太频繁地调用重操作(如搜索)。

图 9.4 – useDebounced 钩子
出现的一个模式是,对于给定状态,无论我们通过分发来改变它,我们都需要等待一段时间,以确保这是采取行动的正确时机。因此,本质上我们想要设计一个新的状态,作为给定状态的防抖版本。让我们尝试在自定义的useDebounced钩子中捕捉这个模式:
const useDebounced = (oldState, duration) => {
const [state, dispatch] = useState(oldState)
const invokeRef = useRef(null)
useEffect(() => {
invokeRef.current = setTimeout(() => {
dispatch(oldState)
}, duration)
return () => {
clearTimeout(invokeRef.current)
}
}, [oldState, duration])
return state
}
在前面的代码块中,useDebounced 钩子被设计为接受两个输入参数,即 oldState 和防抖持续时间的 duration。钩子返回一个新的带有防抖值的 state。
实际上不能重用 Lodash 库中的 debounce 函数,因此在这里重新创建了 debounce 功能。新状态频率由 useEffect 控制。每当 oldState 发生变化时,它都会启动一个 setTimeout,要求在一定的 duration 后运行回调函数。
在这里,我们使用 useRef 确保我们可以在组件的生命周期内跟踪 setTimeout 的持久函数处理。在现在和持续时间结束之间,如果另一个变化发生,它将通过 clearTimeout 取消之前的 setTimeout,从而防止变化应用到 state 上。只有当其中一个 setTimeout 成功调用时,oldState 的变化才会应用到 state 上。
在某种程度上,oldState 和 state 之间会有一点滞后。让我们试一试,看看我们如何使用这个 useDebounced 钩子。
用法
让我们看看它如何在需要根据用户输入执行搜索的 Title 组件中使用:
const Title = () => {
const [text, setText] = useState('')
const query = useDebounced(text, 300)
const matched = useMemo(() => {
return fruites.filter(v => v.includes(query))
}, [query])
const onChange = e => {
const t = e.target.value
setText(t)
}
return (
<>
<input value={text} onChange={onChange} />
{matched.join(',')}
</>
)
}
在前面的代码中,一个 text 状态被发送到 useDebounced 以形成一个新的 query 状态:
const query = useDebounced(text, 300)
由于 query 状态更新频率较低,我们可以通过 useMemo 将其连接到 filter,因为否则 text 状态可以通过 onChange 非常快速地更新。从某种意义上说,我们通过 query 创建了一个状态事件,以便根据不同频率的两个数据流更新 UI。
操场 – 使用 useDebounced 进行搜索
欢迎您在此在线示例中自由尝试 codepen.io/windmaomao/pen/bGrYNmB。
从这个 useDebounced 自定义钩子中,我们可以看到一个由监听状态变化而创建的人工事件,其作用可以与物理事件一样有用。
参考文献
要了解更多关于 useDebounced 钩子的信息,这里为您提供了参考链接:
-
useDebounce:usehooks.com/useDebounce/. -
useDebounce:github.com/xnimorz/use-debounce. 该功能支持所有防抖选项。
useClickOutside
在此自定义钩子中使用的钩子:useEffect
在 第八章,使用 Ref 隐藏内容 中,我们了解了一个可以检测用户点击组件外部的案例。这个功能相当通用,我们想在项目的各个部分利用这个功能,例如关闭模态或工具提示 – 见 图 9.5。

图 9.5 – useClickOutside 钩子
让我们看看我们能否对旧代码进行一些重构,并将其转换为自定义的 useClickOutside 钩子:
function useClickOutside(ref, handler) {
useEffect(() => {
const evt = e => {
if (!ref.current) return
if (!ref.current.contains(e.target)) {
handler && handler()
}
}
window.addEventListener("mousedown", evt)
return () => {
window.removeEventListener("mousedown", evt)
}
}, [ref, handler])
}
useClickOutside钩子接受两个输入参数,第一个是一个元素的ref,第二个是在检测到外部点击后要调用的回调handler。注意这个钩子不返回任何值。
使用useEffect来管理一个mousedown事件,如果点击在组件内部,则阻止处理程序被调用。我们基本上将我们的旧代码放入一个单独的函数中。让我们试试看。
用法
我们可以在一个Menu组件上尝试useClickOutside:
const Menu = ({ on, dismiss }) => {
const ref= useRef()
useClickOutside(ref, toggle(false))
if (!on) return null
return (
<ul ref={ref}>
<li>Home</li>
<li>Price</li>
<li>Product</li>
<li>Support</li>
<li>About</li>
</ul>
)
}
这次我们设置了Menu以支持两个输入参数。一个是on标志,另一个是dismiss函数。这两个都通过 props 提供,以便Menu可以被父组件驱动:
const App = () => {
const [on, toggle] = useToggle(true)
return (
<Menu
on={on}
dismiss={toggle(false)}
</>
)
}
在前面的App组件中,我们使用了一个自定义钩子中之前构建的on状态,为我们提供了一个布尔值以及一个toggle函数。我们使用它们来驱动一个Menu。太棒了,我们立刻开始使用自己的自定义钩子。最初,on被设置为true,表示Menu被显示。点击其外部任何地方都会使其消失。
Playground – 使用 useClickOutside 的 Menu
欢迎在此在线示例codepen.io/windmaomao/pen/qBXVdOe中玩耍。
参考文献
要了解更多关于useClickOutside钩子的信息,这里有一些参考链接供您参考:
-
useOnClickOutside:usehooks.com/useOnClickOutside/ -
useClickOutside:github.com/ElForastero/use-click-outside
useCurrent
在此自定义钩子中使用的内置钩子:useState
当使用useState时,我们遇到了很多问题,阻止了新用户正确理解如何使用它,主要是由于继承的滞后行为,因为状态值在分发后不会立即改变。
const [state, dispatchState] = useState(0)
在上一行,如果我们理解dispatchState函数是用来分发和请求更改的,那么我们不需要做太多,因为这就是React设计useState的方式。然而,我们通常倾向于有不同的想法:
const [state, setState] = useState(0)
前面的setState名称是我们陷入麻烦的主要原因,因为在这里我们期望state在setState语句之后立即改变。
在第八章,“使用 Ref 隐藏内容”,我们使用了一个useRef来定位当前值。解决这个问题有两种不同的方法:一种是为保持指向当前值设计一个容器,另一种是为在需要时提供一个获取当前值的访问函数。这次让我们尝试第二种方法:
function useCurrent(initialState) {
const [obj, setObj] = useState({ state: initialState })
const dispatch = newState => {
if (obj.state !== newState) {
obj.state = newState
setObj({ …obj })
}
}
const getState = () => obj.state
return [getState, dispatch]
}
在前面的自定义useCurrent钩子中,它将状态存储在obj的state属性下。当你需要找出状态时,你可以调用getState函数,当你需要更新状态时,你执行dispatch,就像以前一样。在这里,我们必须手动管理obj,如果我们发现newState与当前的obj.state没有不同,我们就跳过这个分发。
用法
让我们为我们的 3 秒延迟点击示例试一试:
const Title = () => {
const [getCount, setCount] = useCurrent(0)
const onClick = () => {
setTimeout(() => {
setCount(getCount() + 1)
}, 3000)
}
return <button onClick={onClick}>{getCount()}</button>
}
上述代码显示,这次代码简化了一些,因为我们不需要引用(ref)来跟踪当前值。相反,我们使用自定义的useCurrent来管理状态。最大的不同之处在于,每次我们需要找出count时,我们需要调用从钩子返回的getCount。好处是我们不再需要总是想知道当前的count是什么了。
操场 – 使用useCurrent的当前状态
欢迎尝试这个在线示例:codepen.io/windmaomao/pen/VwzrvBX。
参考资料
查看这些链接,了解人们从不同角度如何解决这个问题:
)
-
useStateRef:github.com/Aminadav/react-useStateRef -
useRefState:github.com/alex-cory/urs. -
使用安全状态:
ahooks.js.org/hooks/advanced/use-safe-state/. 这实现了安全状态。
useProxy
在此自定义钩子中使用的内置钩子:useState、useEffect和useRef
要修复或改进React状态背后的思考永无止境。一个有趣的想法来自这个问题:“为什么我们不能直接对状态进行普通的赋值,而不是使用分发方法?”阻碍我们的一个技术问题是我们不能在没有对象或某些东西来保存状态的情况下进行赋值。因此,如果我们允许在类似以下方式的对象下存储属性:
const p = useProxy({ count: 0, text: '' })
然后,我们可以将一个分发(dispatch)转换为一个如下所示的赋值(assignment):
p.count++
p.text = 'Hello World'
让我们看看如何借助ES6引入的Proxy来设计这样的东西:
const useProxy = (initialObj) => {
const [,dispatch] = useState(initialObj)
const [obj] = useState(new Proxy(initialObj, {
get: function() {
return Reflect.get(...arguments)
},
set: function(obj, prop, value) {
if (obj[prop] !== value) {
obj[prop] = value
dispatch({ ...obj })
}
return true
}
}))
return obj
}
上述自定义useProxy钩子采用了与useCurrent钩子类似的方法,将initialObj存储到状态中,但同时也创建了一个带有 Proxy 的特殊对象。不深入探讨 Proxy 的使用细节,这个特殊对象基本上捕捉了读取和写入任何属性的读写时刻,通过两个函数调用,get和set。在这里,我们并不关心get,所以它回退到默认行为,而set则用新版本覆盖了默认行为:
set: function(obj, prop, value) {
if (obj[prop] !== value) {
obj[prop] = value
dispatch({ ...obj })
}
return true
}
前面的代码会在任何类似 obj.prop = value 的语句中被调用。其实现与 getCurrent 钩子非常相似,它会检查新的 value 是否与存储的 obj[prop] 不同,并在必要时进行分派。
由于我们正在跟踪对象属性下的几个状态,因此添加一个 mountRef 标志以防组件卸载后出现问题是有意义的:
const useProxy = (initialObj) => {
...
const mountRef = useRef(true)
useEffect(() => {
return () => {
mountRef.current = false
}
})
...
}
给定一个 mountRef,我们可以修改 set 来禁用它以避免内存泄漏:
set: function(obj, prop, value) {
if (!mountedRef.current) return false
...
}
好的,有了所有这些功能,让我们将它们组合起来并试一试。
用法
useProxy 钩子功能更强大,但它要求你将所有值放在一个对象下,这对于表单处理特别有用:
const Form = () => {
const form = useProxy({ count: 0, text: '' })
在前面的 Form 组件中,我们定义了一个 form 对象来保存两个状态,count 和 text。让我们先看看我们如何现在增加一个数字:
const onClick = () => { ++form.count }
return(
<div>
<h1>Count: { form.count }</h1>
<button onClick={onClick}>Increment</button>
</div>
)
在前面的代码中,计数是从 form.count 显示的,但在增加它时,我们只需简单地做 ++form.count。本质上,这相当于以下任何一个:
form.count += 1
from.count = form.count + 1
从使用体验来看,我们不需要记住什么是分派;我们只需要进行一个普通的赋值。自定义的 useProxy 钩子会为我们处理分派。
这个自定义钩子带来的另一个优点是,从现在起,set 和 get 都是通过同一个对象 form 完成的。这意味着如果我们需要让子组件处理一个表单元素,我们就不需要像通常那样发送两个部分。让我们看看另一个使用其他 text 状态的例子:
const Text = (({ form }) => {
const onChange = e => {
form.text = e.target.value
}
return (
<input
value={form.text}
onChange={onChange}
/>
)
})
const Form = () => {
const form = useProxy({ count: 0, text: '' })
return(
<div>
<Text form={form} />
</div>
)
}
在前面的例子中,我们定义了一个 Text 组件来处理文本输入。注意我们只需要通过属性传递 form。在 Text 组件内部,文本的显示和赋值都是通过 form.text 管理的。非常方便,不是吗?
游戏场 – 使用 useProxy
欢迎在 codepen.io/windmaomao/pen/eYEeZmL 上尝试这个在线示例。
useProxy 钩子确实要求我们使用一个对象来管理状态,但一旦你接受了这种方法,你可能会觉得像平常一样编码,而无需处理 React 状态的麻烦。
参考
-
Valtio:
github.com/pmndrs/valtio
摘要
在本章中,我们总结了迄今为止我们介绍的所有 React 内置钩子,然后继续介绍如何创建自定义钩子。然后,一旦我们理解了这个概念,我们就回顾了本书中编写的所有代码,并将其中一些转换成了自定义钩子,包括 useToggle、useWindow、useAsync、useDebounced、useClickOutside、useCurrent 和 useProxy。
在下一章中,我们将了解 React 如何将所有不同类型的网络资源整合起来,并协调它们来构建一个网站。
问答
这里有一些问答来帮助你巩固知识:
-
什么是 React 内置钩子?
React 内置钩子指的是由 React 设计的所有钩子,包括
useState、useEffect等等。你无法即时创建一个内置钩子,但你可以提出你的想法并向 React 核心团队发送拉取请求以供审查。 -
什么是自定义钩子?
我们可以通过消耗一个 React 内置钩子并给钩子命名时以
use为前缀来创建一个自定义钩子。自定义钩子可以像内置钩子一样强大。内置钩子的目的是解决原子核心能力,而自定义钩子通常是为了解决实际项目问题而创建的。互联网上有数百个自定义钩子可能对你有用或具有启发性。 -
创建钩子的最佳实践是什么?
自定义钩子可以是,并且大多数情况下是,在代码重构过程中自然产生的。只要你觉得一束代码可以包含钩子并被复用,那么你就可以提取这些功能并使其通用,以便其他项目部分可以引用。从某种意义上说,你可以将自定义钩子视为一个实用函数,只不过它涉及内置钩子。
第十章:使用 React 构建网站
在本书的最后一章,我们将一般性地讨论 React,特别是 React 在网页开发中所扮演的角色。我们将从三个角度来探讨这个问题,看看 React 如何将资源整合起来构建一个网站。首先,我们将看看 React 如何拥抱新的 JavaScript ES6 功能,如箭头函数和模板字符串。然后,我们将通过使用 styled-JSX 和 styled-components 等库的 CSS-in-JS 方法来展示如何对组件进行样式化。最后,但同样重要的是,我们还将了解 JSX 代码以及它是如何被用来将类似 HTML 的行转换为 JavaScript 表达式的。
在本章中,我们将涵盖以下主题:
-
React 的功能
-
拥抱 JavaScript ES6
-
采用 CSS-in-JS 方法
-
从 HTML 到 JSX
-
问题和答案
探索 React 的功能
在本节中,我们将从探索 React 在网站开发领域所起的作用开始。通过这样做,我们希望对 React 如何帮助我们有一个更准确的描述。
本书致力于介绍在 React 中将状态引入函数组件,特别是钩子机制。我们希望通过阅读本书,你能了解如何正确地设计钩子。
当将 React 与其他 UI 框架进行比较时,你是否听说过有时人们将其称为工具库而不是框架?对于什么应该被称为框架以及什么不应该被称为框架并没有明确的定义。根据 React 源代码的数量以及它与其它系统的连接方式,我们几乎不能将其视为一个工具库。但与此同时,我们也应该记住 React 在一开始就设定的目标,因为了解这一点将帮助你正确地使用这项技术。让我们看看用法:
const rootEl = document.getElementById('root')
ReactDOM.render(<App />, rootEl)
以下代码是我们开始所有 React 项目的步骤。它搜索一个 DOM 元素,并在其下渲染一个组件。此外,在该元素之后,该元素下的屏幕将由 React “管理”。这意味着 React 会覆盖该元素下的所有内容,并在组件内部任何状态变化时刷新。
因此,前面的 ReactDOM.render 方法是将 React 集成到你的项目的关键行。正如你所见,这一行非常强大。实际上,它可以在一个项目中多次使用。考虑以下 HTML 页面:
<div class="slider" value="3">loading…</div>
<div cla"s="sli"er" val"e""5">loading...</div>
我们不想控制整个网站,而是想用功能更丰富的组件替换每个 slider 实例:
const elements = document.querySelectorA'l('.sli'er')
elements.forEach(el => {
const value = el.getAttribu'e('va'ue')
ReactDOM.render(<Slider value={value} />, el)
})
我们可以在 HTML 中遍历与 slider 实例匹配的元素。对于找到的每个元素,我们取其 value 属性并发送到一个使用 ReactDOM.render 的组件。哇!所有滑块都由 React 管理。
虽然拥有多个 render 实例不是一种典型的方法,但我们构建了一个 单页应用程序 (SPA)。这确实给我们一个重要的信息;那就是从技术角度来看,React 的目标是替换和管理屏幕上的 DOM 元素。为了实现这一点,每个元素都会在内部创建一个新的根纤维,以便在接收到分发时,引擎知道从哪里开始更新。
到现在为止,我们已经知道了 React 的设计目的是什么。让我们快速看看完成这个任务需要多少努力——例如,调用 render 语句的所有依赖项是什么?
const { render } = require('react-dom')
要启动引擎,我们需要由 react-dom 包提供的 render 函数。组件可以设计在其他地方,这需要 react 包的实用工具,如 createElement 和 useState:
const { createElement, useState } = require('react')
const Slider = () => {
const [value] = useState(0)
...
}
除了 react-dom 和 react 包,我们还需要定位和操作 DOM 元素的 document 对象。但通常情况下,只要我们有一个浏览器会话,我们就假设 document 是可用的。
简而言之,有两个依赖包。react 包用于定义组件。使用它,组件定义可以输出为引擎可以理解的格式。react-dom 包用于将组件渲染和管理到屏幕上。这意味着在启动引擎之前,你不需要 react-dom。大部分的开发时间都花在用 react 包定义我们的组件上。从这个练习中,我们应该清楚地看到 React 作为实用库的功能。
要用 React 建立一个网站,我们需要做所有网络开发者需要做的事情,即用 HTML 设计网站布局,用 CSS 使网站看起来美观,并用 JavaScript 添加业务逻辑和交互来吸引用户。因此,问题变成了所有这些构建块如何适应新的组件生态系统。让我们逐一看看它们。
拥抱 JavaScript ES6
React 是用 JavaScript 语言编写的。JavaScript 在过去 20 年中一直在发展。随着在 jQuery、Angular、React 和 Node.js 等框架中的应用,JavaScript 成为了最受欢迎和最有影响力的编程语言之一。
最新的 JavaScript 基于 ECMAScript 2015,也称为 ES6。这给 JavaScript 带来了一些重要的特性,而 React 也迅速很好地采用了这些特性。以下只是其中的一些特性:
-
箭头函数
-
扩展和剩余参数
-
对象增强
-
模板字符串
-
解构
-
let和const -
模块
-
符号
如果你正在考虑将 JavaScript 作为你的主要语言,建议你在空闲时间回顾所有前面的材料,因为它们在 React 应用中很常见。我们将在接下来的章节中逐一介绍这些特性。
箭头函数
箭头函数是函数表达式的全新语法。一个典型的函数可以用 function 关键字定义:
function abc(v) {
}
ES6 引入了一种更紧凑的方式来定义前面的函数,使用箭头(=>):
const abc = v => {
}
之前的箭头版本省略了单词 function,使函数看起来更像一个对象。箭头函数通常用作回调:
arr.map(v => v * 2)
之前的代码返回一个新数组,每个元素都加倍。当我们进行 React 的调度时,我们也倾向于使用箭头语法:
setCount(v => v + 1)
注意,如果箭头函数返回一个单一的表达式,则可以省略 return 语句。我们可以将上一行与常规函数形式进行比较:
setCount(function (v) {
return v + 1
})
显然,箭头函数在作为对象工作时往往更紧凑。作为一个对象意味着我们可以通过函数输入参数自由地将其传递到任何地方。有时,你也会看到链式版本,如下所示:
const fn = a => b => a + b
上一行等价于以下行:
const fn = (a) => {
return (b) => {
return a + b
}
}
你可能会觉得箭头函数非常具有表现力。这本书基本上只使用这种格式。只有在一个情况下,这本书才会回到常规函数格式:
fn()
function fn() {
}
通常情况下,我们可以在声明之前调用函数,编译器不会抱怨。然而,这对于箭头函数来说是不行的:
fn()
const fn = () => {
}
这是因为 fn 需要先声明,然后才能被引用。
与常规函数相比,尽管箭头函数更紧凑、更具表现力,但也存在一些需要注意的问题:
-
它在创建时设置
this对象,而不是在调用时。 -
它在调用后不会设置
arguments对象。 -
它不能用作
constructor函数。
展开和剩余
对于 JavaScript 语言来说相当新颖,展开是一种表达一个项目所有部分的方式,而剩余则是表达除了前几个部分之外的项目剩余部分的方式。
ES6 引入了许多特性,使我们的代码更具表现力。其中一个特性是使用 ... 关键字,因为几乎没有人会费心去记住确切的特性名称。让我们来看一个例子:
const a = [1, 2, 3, 4, 5]
const b = [...a, 6]
在前面的代码中,变量 b 从数组 a 中获取所有元素,将它们展开成五个元素,然后添加新的元素,使得新的数组总共包含六个元素。你也可以用对象来做这件事:
const a = { first: 'John', last: 'Doe' }
const b = { ...a, age: 23 }
在前面的代码中,变量 b 从对象 a 中获取所有属性,展开它们,然后添加新的 age 属性,使得新的对象总共包含三个属性。
注意,这两种用法都创建了一个新的变量,要么是一个数组,要么是一个对象。当涉及到从现有变量快速创建另一个变量时,这种新语法变得相当流行。
React 在组件设计中经常使用这个特性:
const App = ({ title, ...props }) => {
return <div {...props}>Hello</div>
}j
之前组件使用 props 对象来捕获除 title 之外的其他属性列表。这有几个有用的原因:
-
我们不必关心列表中其他属性的数量。
-
我们可以将这个列表发送到子组件中。
另一个与 spread 操作符行为相似的 ES6 功能是 rest 操作符。这是当 ... 应用于函数的输入参数列表时:
function abc(a, b, ...rest) {
}
abc(a, b, 1) // rest = [1]
abc(a, b, 1, 2) // rest = [1, 2]
在前面的 abc 函数中,我们使用了 ... 剩余操作符来获取额外的参数。rest 变量包含一个数组,当我们用超过两个参数调用函数时,额外的参数会被填充到这个数组中。
对象增强
在 ES6 中,对象被扩展以支持相当多的功能。让我们看看其中的一些。其中一个功能是,在给对象属性赋值时,我们可以使用简写版本:
const first = "John"
const last = "Doe"
const name = { first, last }
如果我们用旧的方式编写前面的代码,它将等同于以下代码:
const name = { first: first, last: last }
如果属性名与赋值变量名匹配,ES6 允许我们只写一次属性名。这在实践中非常有用,因此,人们常常通过创建一个临时变量来匹配属性名来利用这一点:
const onClick = () => {
const first = "John"
const name = { first }
}
对象的另一个增强是支持将表达式用作属性名:
const name = e.target.name
const value = e.target.value
return { [name]: value }
前面的代码是我们用来支持表单提交的代码。不要将那段代码的最后一行与以下版本混淆:
return { name: value }
注意,这两个之间的区别在于 name 周围的括号。如果没有括号,name 将会将值赋给 "name" 属性。然而,在 [name] 版本中,它将会将值赋给一个名为 name 变量中存储的属性名。如果 name 包含了 "first" 这个词,那么 [name] 版本将变成以下这样:
return { first: value }
基本上,这使得我们可以将任何 JavaScript 表达式用作属性键。在此之前这是不可能的,所以我们通常编写以下代码来解决这个问题:
obj[name] = value
模板字符串
在 JavaScript 中构建长字符串而不会牺牲字符串的原始格式曾经很麻烦。为了解决这个问题,ES6 添加了模板字符串,允许字符串被反引号(')包围,这是我们处理 Markdown 文件时经常使用的奇怪字符。使用这种语法,字符串可以按照以下方式编写:
'This is a string'
模板字符串有趣的地方,除了使用 " 或 ' 的常规引号字符串之外,还支持跨多行编写的文本段落,同时保留换行符:
'
This
is
a
string
'
这使得在不需要格式化内容的情况下引入一段长文本变得很有用。模板字符串也支持任何 JavaScript 表达式:
const what = "This"
const str = '
${what}
is
a
string
'
在前面的代码中,我们将模板中的 ${what} 替换成了 "This" 字符串。本质上,${} 内部可以写任何 JavaScript 表达式。
模板字符串的一个常见用法是将另一种格式转换为字符串:
const n = 3
const str = '${n}'
const url = 'http://foo.org/bar?id=${n}'
你可以在前面的代码中看到,当我们需要组装一个如动态 url 这样的字符串时,这变得非常有用。
我们可以将前面的代码应用于更复杂的情况,例如CSS内容:
const color = 'red'
const Button = css'
display: inline-block;
color: ${color};
'
前面的代码使用了一种不同的形式,称为标签模板,css被称为标签函数。这里还有一个例子:
function image(strings) {
return '<img src="img/${strings[0]" />'
}
const s = image'
http://google.com
'
前面的代码定义了一个image标签函数,它将strings转换为另一种格式——在我们的例子中,是一个图像元素声明。从这一点可以看出,标签模板可以作为工具,减轻生成字符串的痛苦。在接下来的Adopting CSS-in-JS方法部分,我们将看到更多这样的例子。
解构赋值
“解构赋值”这个词听起来有些别扭,也许我们甚至找不到它在字典中的条目。
然而,你可能过去已经多次使用过这个特性:
const arr = [1, 2, 3]
const [a, b] = arr
实际上,解构赋值允许你解构一个对象,并将拆分的元素分配给变量。前面的代码是应用于数组的解构赋值用法,相当于以下代码:
const a = arr[0]
const b = arr[1]
你可以看到,在解构过程中,我们遵循给定的结构来选择所需的元素。同样,它也可以应用于对象:
const name = { first: 'John', last: 'Doe' }
const { first, last } = name
前面的声明可以翻译为以下内容:
const first = name.first
const last = name.last
虽然我们还可以继续使用旧方法,但解构赋值使用起来要容易得多,也快得多。请记住,你选择的元素必须存在;否则,你可能会得到一个undefined,如下面的例子所示:
const name = { first: 'John' }
const { second } = name
解构赋值的一个有用特性是,如果你想用另一个名字存储属性,你可以重命名它:
const { first: firstName } = name
前面的行可以翻译为以下内容:
const firstName = name.first
实际上,firstName变量用于赋值,而不是first,尽管first是对象下的属性名。
解构赋值经常与其他ES6特性结合使用,例如扩展运算符:
const { first } = { ...name, last }
我们在React代码中经常看到这个前面的声明,所以让我解释一下它的每个部分:
-
...name是将name对象的全部属性展开。 -
last是增强的对象语法,用于添加last属性。 -
{ ...name, last }是从现有对象中创建一个新的对象。 -
first是从新对象中解构出first属性。
哇,这里有很多事情在进行中!也许我们可以用旧方法写一个等效版本:
const temp = Object.assign({}, name)
temp.last = last
const first = temp.first
实际上,新语法只需要一行代码,而不是三行,但令人惊讶的是,结果要准确得多,表达也更丰富。
如果你对这个特性还不熟悉,不要犹豫,在编写每个部分时放慢一点速度。这样做的原因是,你不仅会了解新语法提供了什么知识,而且也不会不小心错过任何逻辑。
let 和 const
在过去使用JavaScript时,你可能对var、let和const关键字感到困惑。你应该使用哪一个来声明一个变量?
首先要明确的一点是,var 已经不再常用;它主要存在是为了向后兼容,因为它使用了一个奇怪的作用域规则。相反,我们应该都使用 let 和 const,因为它们基于块作用域,这是开发者更习惯使用的:
function abc() {
let a = 1
if (true) {
const b = 2
for (let i = 1; i < 3; i++) {
...
}
}
}
在前面的代码中,a 变量存在于函数的作用域中,而 b 变量存在于条件语句的作用域中。这两个引用在由 {} 父包围块指定的作用域内都是有效的。如果你试图在它们定义的作用域之外引用 a 或 b,编译器现在会抛出一个错误。同样适用于使用 let 在循环中定义的 i 变量。如果我们使用 var,它会使 i 在整个函数体中可访问。
正如名称所暗示的,你需要使用 let 来表示稍后需要改变的变量,而使用 const 来表示你预期不会改变的项。以下是一个例子:
let x = 5
x = 6 // valid
const n = "abc"
n = "def" // compiler error
在前面的代码中,如果你稍后更改 x 为 6,这是完全正常的。但是,如果你尝试将 n 更改为 "def",编译器会抛出一个错误。这适用于任何原始值,如数字或字符串。
当涉及到非原始值,如对象或数组时,事情会变得有些复杂:
let obj = { a: 1, b: 2 }
const obj = { a: 1, b: 2 }
你能区分前两行之间的区别吗?什么是变量对象,什么是常量对象?
如果一个对象是用 const 声明的,这意味着只能使用该对象本身来指向另一个内存空间:
const obj = {}
obj = {} // compiler error
obj.key = '' // valid
在前面的代码中,我们定义了一个 obj 常量。稍后,如果我们尝试重写 obj,编译器会抛出一个错误。然而,如果我们通过键修改其内容,它仍然是有效的。同样,我们可以期待数组有类似的行为:
const arr = []
arr = [] // compiler error
arr.push('A') // valid
你可能会觉得这种行为有点奇怪,但实际上,它在所有对象和数组初始化、比较等方面设计得相当一致。
如果你看到用 let 语句定义的数组或对象,它应该告诉你它们可以在稍后重写:
let obj = {}
let arr = []
这是 let 和 const 语句之间的主要区别。
模块
现在的开发者无法离开的一个东西就是从另一个文件导入的代码。然而,JavaScript 直到 ES6 才引入了一个类似于 AMD 或 CommonJS 这样的模块系统。其理念是我们可以从一个文件中导出一些内容以供重用,如下例所示:
const Title = () => <div>Hello World</div>
export default Title
前面的代码在 React 应用程序中相当常见。基本上,它在一个单独的文件中定义了一个 Title 组件,并将其作为默认值导出。从另一个文件中,我们可以通过 import 导入它并使用它,如下所示:
import Title from './Title'
const App = () => <Title />
这是将所有文件拉在一起并编译成单个 index.js 文件的主要机制,因为它实际上导入了所有这些文件。
有时,我们还想与 default 一起导出其他材料:
const Title = () => <div>Hello World</div>
export default Title
const TitleType = "Component"
export { TitleType }
在前面的代码中,我们将Title导出为default,同时将TitleType作为非默认导出。这意味着我们可以挑选事物并单独导入它们:
import Title, { TitleType } from './Title'
当我们在单个文件中存储大量相关实用功能并将它们逐个导出时,这种能力变得非常方便:
const fn1 = () => {}
const fn2 = () => {}
export { fn1, fn2 }
为了避免名称冲突,在导入时,我们可以使用as关键字进行别名导入:
Import { fn1 as aliasFn1 } from './fns'
// we can use aliasFn1
符号
符号是一个听起来像游戏术语的新JavaScript特性。实际上,它是一种特殊的原始类型。通常,我们可以将字符串作为属性键:
obj['name'] = 3
任何编写上述行的人都可以访问存储在name属性下的值。但如果我们只想限制只有知道钥匙的人才能访问,你会问:“你这是什么意思?"name"字符串不是对任何人都是公开的吗?”这正是我们要表达的意思——我们想要创建一个不是每个人都能轻易重新创建的钥匙。以下是我们将要做的:
const Name = Symbol('name')
obj[Name] = 3
在前面的代码中,创建了一个带有"name"字符串的Symbol作为钥匙。如果我们有确切的钥匙,我们仍然可以访问obj下的这个符号化属性。但如果你创建另一个类似的钥匙并尝试访问属性,那么它就不会起作用,如下所示:
const Name = Symbol('name')
obj[Name] = 4
嗯,我们不是又用同样的代码了吗?为什么我们无法访问相同的键?答案在于以下比较:
Symbol('name') !== Symbol('name')
两个Symbol('name')语句之间的比较返回false!这意味着你不能期望通过再次编写它来重新创建"name"键;你只能拥有最初创建的原始键,或者通过Symbol.for从系统中所有注册的键中查询它:
Symbol.for('name') === Symbol.for('name')
一旦我们在系统中找到该键,我们就可以访问该属性:
const Name = Symbol.for('name')
obj[Name] = 4
哎。这很有趣,不是吗?
符号是一个无法重新创建的原始类型,并且保证是唯一的!这意味着它可以作为一个独特的门钥匙。如果钥匙不存在,那么门就无法打开。
当我们不希望任何开发者意外访问某些内存或分配值时,这很有用。它还可以以独特的方式将此键转移到另一个项目或仓库中。我将在下一节中展示React如何使用它的一个例子。
到目前为止,我们已经讨论了ES6的一些主题,包括箭头函数、对象增强、模板字符串、解构、let和const、模块和符号。但实际上ES6还有更多,比如 promise、proxy、generators 和 weak map。当我们提到 API 时使用了 promise,在第九章中使用了 proxy,使用自定义 Hooks 来重用逻辑。
一件事可以肯定的是,React确实充分利用了最新的JavaScript语言。如果你经常练习,你会发现代码变得更加表达,同时不失其准确性。
在下一节中,我们将看到React如何使用来自非JavaScript语言的材料,例如CSS。
采用 CSS-in-JS 方法
当我们在 React 中构建应用程序时,在某个时候,我们需要找到一种方法来将 CSS 样式应用到我们的代码中,对吧?如果你过去有 CSS 经验,我们熟悉的一种方法是使用 CSS 类来设置样式。考虑以下存储在具有 .css 扩展名的文件中的 CSS 片段:
h1 {
color: red;
}
我们可以通过一个名为 className 的属性将此样式应用到 React 元素上:
const Title = () => {
return <h1 className="title">Hello</h1>
}
注意
class 是 JavaScript 的保留关键字,因此不能使用。相反,React 选择使用不同的单词,className。
虽然这个旧方法仍然有效,但关于 title 类的唯一性问题。CSS 可以应用到屏幕上所有的 title 元素,但它不能跳过我们不想被样式的组件内部的元素。从 CSS 的角度来看,title 类被暴露出来以进行全局样式化。
使用组件设计,我们自然会想要只将样式应用到这个组件上!例如,我们定义的 title 样式只能在 Title 组件内有效。信不信由你,有作用域是组件设计的基础。
使样式对组件独特的一种方法是将样式内联与元素一起使用 style 属性:
const Title = () => {
return <h1 style={{ color: "red" }}>Hello</h1>
}
但前面硬编码方法的问题也很明显。直接在内联中编写大量样式是不切实际的。这正是 CSS-in-JS 方法发挥作用的地方。想法是利用模板字符串使用旧方法编写 CSS。好吧,我们刚刚从 ES6 的一个特性中介绍了模板字符串:
const css = '
h1 { color: red; }
'
使用前面字符串的学习曲线几乎不存在。因此,这种方法很快被社区采用,并被几个库实现,例如 styled-JSX 和 styled-components。我们将在本节中介绍这两个库,让我们开始吧。
styled-SX
styled-JSX 是一个 CSS-in-JS 库,它允许我们为组件编写作用域内的 CSS,这样样式就不会影响到其他组件,从而允许我们修改样式而不用担心会影响到屏幕上的其他组件。
让我们用相同的 Title 组件来看看如何使用 styled-JSX 来实现:
const Title = () => (
<>
<h1>Hello</h1>
<style jsx>{'
h1 { color: red; }
'}</style>
</>
)
它引入了一个特殊的 style 标签,带有 jsx 属性,在其下方,样式可以写成 CSS 代码。即使 Title 组件被多次使用,样式也只注入一次。
当涉及到原型设计应用程序时,这种半内联的样式方法可以非常高效。我们不必将 CSS 与组件内联,我们可以将样式放在一个单独的文件中,如下所示:
import css from 'styled-jsx/css'
export default css'
h1 {
color: red;
}
'
我们可以将其导入到 Title 组件中:
import titleStyle from '../titleStyle'
const Title = () => (
<>
<h1>Hello</h1>
<style jsx>{titleStyle}</style>
</>
)
这给了我们一些自由来编写 CSS,无论是内联还是单独的文件。
有一个重要的事情需要注意,我们迄今为止编写的样式默认情况下不适用于子组件:
const Child = () => <span>World</span>
const Title = () => (
<>
<h1>Hello <Child /></h1>
<style jsx>{'
span { color: green; }
'}
</>
}
上述代码不会使任何 span 元素以绿色颜色显示。为了使其工作,我们可以使用一个 global 属性:
const Child = () => <span>World</span>
const Title = () => (
<>
<h1>Hello <Child /></h1>
<style jsx global>{'
h1 { color: red; }
span { color: green; }
'}
</style>
</>
)
styled-JSX 是一个相当独特的库,它使得在 React 中进行样式设计变得简单。此外,由于样式实际上是用 JavaScript 字符串编写的,因此可以在运行时进行操作。我们将在下一个包 styled-components 中展示这一功能,因为该功能适用于这两个包。
styled-components
另有一个名为 styled-components 的库实现了 CSS-in-JS 策略。本书为所有需要样式的示例采用了这种方法。它不是使用 style 标签,而是实际上允许我们在单独的组件中定义 CSS:
import styled from 'styled-components'
const TitleStyle = styled.h1'
color: red;
'
const Title = () => {
return (
<TitleStyle>
Hello World
</TitleStyle>
)
}
在上述代码中,styled.h1 是一个标签模板函数,它输出一个包含嵌入式 h1 元素的组件。
此外,styled-components 允许 CSS 默认应用于子元素,这与 styled-JSX 包不同:
const TitleStyle = styled.h1'
color: red;
span { color: green; }
'
const Child = () => <span>World</span>
const Title = () => {
return (
<TitleStyle>
Hello <Child />
</TitleStyle>
)
}
上述代码设置了 span 元素的绿色颜色。这让我们想起了 Sass 或 SCSS 代码。
CSS-in-JS 解决方案提供的一个有趣的好处是,JavaScript 字符串可以与其他 JavaScript 表达式混合,以支持运行时动态样式:
const fontSize = (props) =>
props.big ? '3em' : '1.5em'
const TitleStyle = styled.h1'
font-size: ${fontSize};
'
const Title = ({ big }) => {
return (
<TitleStyle big={big}>
Hello World!
</TitleStyle>
)
}
上述代码定义了一个 big 属性,可以发送到 TitleStyle 组件,根据 big 的值,fontSize 可以在 3em 和 1.5em 之间切换。
动态 CSS 支持使得在 React 中轻松创建可主题化的组件成为可能,同时也为需要随时间变化值以进行动画的组件打开了大门。
既然我们已经看到了 React 如何将 CSS 带入 JavaScript 以融合这两种技术,那么让我们继续探讨 HTML,看看它如何被引入 React。
从 HTML 到 JSX
对于一个开发者来说,采用 React 是一个障碍,尤其是如果他们习惯于使用其他网络技术的话。因为 HTML 已经不再被导入到项目中了。除了入口文件 index.html,没有其他带有 .html 扩展名的文件,而这个入口文件通常只包含一行 HTML,如下所示:
<div id="root">loading...</div>
但仅此而已。如果有一个项目经理喜欢审查 HTML 或甚至参与其中,他们现在不能再这样做。缺少的 HTML 文件可能是团队犹豫采用 React 的原因之一。
然而,HTML 与 CSS 有着类似的问题。它们在编程语言中拥有的作用域在这里并不存在。更糟糕的是,一旦写好一段 HTML,几乎会立即转换为 DOM 元素。因此,基于组件的系统必须想出一种方法在中间添加一层。
React是如何解决这个问题?它选择了接受HTML,这并不令人惊讶。但React所做的令人惊讶的工作之一是,它使编写这些语句的体验尽可能接近HTML。技术上,React使将一段HTML转换成一段JavaScript代码的平滑转换成为可能。有时,我们并没有注意到这一点,也无法区分它们:
return <h1 title="Title">Hello World</h1>
上述代码是我们在一个函数组件内部使用的。格式看起来像一段HTML。编译器看到的是以下内容:
return React.createElement(
'h1', { title: "Title" }, "Hello World"
)
在编译器转换之后,它变成了一个createElement函数的JavaScript表达式,该函数接受三个输入参数。
这种转换发生的魔法是通过一个带有Babel插件的编译器完成的。当编译器构建代码时,Babel 将代码转换成抽象语法树、标记格式,然后将它们重新拼接并放入JavaScript格式中。
注意
如果你对了解编译器做了什么感兴趣,可以访问babeljs.io/repl并亲自尝试。
我们现在将仔细研究这个createElement函数。createElement函数的第一个输入参数是元素的type,它来自元素标签h1。第二个是props,它是一个包含title属性的对象。最后但同样重要的是,第三个是children;在我们的例子中,它是一个包含"Hello World"的字符串。让我们更详细地了解一下这些输入参数,因为它们都是React的必要组成部分!
属性
props输入参数是React最重要的机制之一。我们一直在谈论属性,这就是它们接收第一手值的地方。这是因为createElement是一个JavaScript函数,我们可以将任何JavaScript表达式连接到属性上:
const Title = ({ title }) => {
const [count] = useState(0)
return React.createElement(
'h1', { title, count }, "Hello World"
)
}
在上述代码中,现在很明显,title属性和count状态是如何作为属性的一部分发送给createElement的。
这也解释了为什么我们不能使用class保留字作为属性来样式化组件:
// code below wouldn't work
const Title = () => {
return React.createElement(
'h1', { class: "title" }, "Hello World"
)
}
上述代码会因为class键而引发编译器错误。
子元素
children输入参数是我们可以在一个元素下嵌套另一个元素的原因:
return (
<h1>
<span>
Hello World
</span>
</h1>
)
编译器看到的是以下内容:
return React.createElement(
'h1', null, React.createElement(
'span', null, "Hello World"
)
)
注意从上述输出中可以看到,createElement有两种用法,一个用于h1元素,另一个用于子span元素。第二个createElement函数被用作第一个createElement函数的children参数。
现在,不难看到通过createElement函数从组件中返回的嵌套元素的长列表。技术上,即使没有编译器的帮助,你也可以手动使用这种嵌套编写代码。
尽管单个元素可以嵌套在下面,但有时可能需要将多个元素嵌套在另一个元素下面:
return (
<ul>
{['Apple', 'Orange'].map(v => <li>{v}</li>)}
</ul>
)
对于前述涉及数组的JavaScript表达式,该数组将被发送到createElement的children:
return React.createElement(
'ul',
null,
['apple', 'orange'].map((v) =>
React.createElement('li', null, v)
)
)
实际上,children输入参数可以支持以下格式:
-
一个
"Hello World"字符串 -
通过
createElement创建单个元素 -
以前述格式之一为格式的元素数组
React还允许你以另一种方式添加任意数量的子元素:
return React.createElement(
'ul',
null,
React.createElement('li', null, 'apple'),
React.createElement('li', null, 'orange')
)
在前述代码中,我们可以从第三个输入参数开始,在输入参数列表中堆叠子元素。
元素类型
元素类型,createElement的第一个输入参数,可以是一个简单的字符串,表示一个HTML标签,例如h1和span。然而,它也可以采用其他格式,例如一个函数:
const Child = () => <h1>Hello World</h1>
const Title = () => {
return <Child />
}
编译器看到的前述代码如下:
const Child = () =>
React.createElement("h1", null, "Hello World")
const Title = () => React.createElement(Child, null)
注意前述代码中的Child——函数本身被发送到createElement作为元素类型!这就是React允许你从自定义组件创建元素的方式。正因为如此,这意味着我们可以在运行时支持动态元素类型:
const Child1 = () => <h1>Hello</h1>
const Child2 = () => <h1>World</h1>
const Title = ({ flag }) => {
const Child = flag ? Child1 : Child2
return <Child />
}
在前述代码中,我们不是返回<Child1 />或<Child2 />,而是首先确定Child组件的类型,然后从这个<Child />组件返回实例。这一切都是因为组件类型是一个JavaScript变量:
const Child1 = () => React.createElement(
"h1", null, "Hello"
)
const Child2 = () => React.createElement(
"h1", null, "World"
)
const Title = ({ flag }) => {
const Child = flag ? Child1 : Child2
return React.createElement(Child, null)
}
前述代码确认了编译器看到的内容。Child组件类型是在运行时确定的。
现在我们已经看到了createElement函数及其三个输入参数,让我们仔细看看它返回的内容。
React 元素
如createElement的名称所示,该函数返回一个带有其下元素树的根元素。
这个元素是DOM元素吗?不,它被称为React元素。尽管React文档没有过多地谈论它,但我们将在这里简要地揭示它:
{
$$typeof: Symbol.for('react.element'),
type: type,
props: props,
}
前述代码是React元素定义的简短定义。正如你所见,它基本上是一个对象。我们已经介绍了type和props,但$$typeof是什么东西呢?显然,这不是React希望任何人都能篡改的东西。
事实证明,React支持各种元素类别。由于没有更好的词,让我们把$$typeof称为另一种类型,即内部类型。我们最常用的一个是react.element,它被定义为Symbol。我们之前已经介绍了Symbol。基本上,react.element是一个独特的原始值,一旦创建,就可以使用但不能更改。
你可能会问,“还有其他类型吗?”实际上,有二十多种;这里只是列出一些可能对你感兴趣的:
-
react.element -
react.portal -
react.fragment -
react.provider -
react.suspense -
react.memo -
react.lazy
为什么我们需要所有这些不同的类型?
大多数应用程序实现或多或少都是围绕更新react.element进行的,但当涉及到特殊情况时,它需要一个不同的更新算法。让我们看看以下示例:
import { memo } from 'react'
const App = memo(() => {
return (
<Provider value={3}>
<>
<div>One</div>
<div>Second</div>
</>
</Provider>
)
})
在前面的代码中,Provider 创建了一个具有 react.provider 类型的元素。在其下方,<> 创建了一个具有 react.fragment 类型的元素,再下面,div 创建了两个具有 react.element 类型的元素。整个组件被一个具有 react.memo 类型的 memo 元素包裹。这给你一个大致的概念,了解不同的 React 元素在哪里被使用。
简而言之,从 JSX 代码块返回的是 React 元素。这些 React 元素是输入到 React 引擎中的内容。
摘要
在本章中,我们从 React 在 Web 开发中的作用开始。我们探讨了它的三个方面。首先,我们了解了 JavaScript 的最新 ES6 功能,如箭头函数和模板字符串。接下来,我们学习了将 CSS 带入 JavaScript 的 CSS-in-JS 方法,使用如 styled-JSX 和 styled-components 这样的库。最后,但同样重要的是,我们学习了如何将类似 HTML 的 JSX 代码转换并返回为 React 元素。总的来说,我们看到了 React 如何将这些资源(包括 JavaScript、CSS 和 HTML)整合在一起,帮助我们构建网站。
问题和答案
以下是一些问题和答案,以帮助你巩固知识:
-
什么是 React?
这是一个允许我们使用渲染引擎设计和更新组件的工具。
-
什么是 JavaScript ES6?
JavaScript 包含了作为 ES6 发布的所有最新功能。React 利用这些功能,使用箭头函数、模板字符串和结构化等特性。在项目中使用它们可以使你的代码更高效、更易于表达和维护。
-
什么是 CSS-in-JS?
CSS-in-JS 指的是一种有见地但流行的将样式应用于 React 组件的方法。应用的样式仅限于组件,不会与其他组件冲突。此外,样式可以与任何 JavaScript 表达式连接,以支持运行时动态样式。
-
JSX 代码是什么?
React 允许我们使用 JSX 代码编写类似 HTML 的代码。实际上,它们看起来非常相似,只不过 JSX 允许我们将这些语句转换为接受元素类型、属性和子元素从输入参数的本地 JavaScript 表达式,并返回引擎可以生效的 React 元素。

订阅我们的在线数字图书馆,全面访问超过 7,000 本书和视频,以及领先的行业工具,帮助您规划个人发展并推进职业生涯。有关更多信息,请访问我们的网站。
第十一章:为什么订阅?
-
通过来自 4,000 多位行业专业人士的实用电子书和视频,节省学习时间,增加编码时间
-
通过为您量身定制的技能计划提高学习效果
-
每月免费获得一本电子书或视频
-
完全可搜索,便于快速访问关键信息
-
复制粘贴、打印和收藏内容
您知道 Packt 为每本书都提供电子书版本,并提供 PDF 和 ePub 文件吗?您可以在 packt.com 升级到电子书版本,并且作为印刷书客户,您有权获得电子书副本的折扣。有关更多信息,请联系我们 customercare@packtpub.com。
在 www.packt.com,您还可以阅读一系列免费的技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。
您可能还会喜欢的其他书籍
如果您喜欢这本书,您可能还会对 Packt 的其他书籍感兴趣:
[(https://www.packtpub.com/product/simplify-testing-with-react-testing-library/9781800564459)]
使用 React Testing Library 简化测试
Scottie Crump
ISBN: 978-1-80056-445-9
-
探索 React Testing Library 及其应用场景
-
掌握 RTL 生态系统
-
将 jest-dom 应用于使用 RTL 增强您的测试
-
通过使用 RTL 获得创建不因更改而中断的测试所需的信心
-
将 Cucumber 和 Cypress 集成到您的测试套件中
-
使用 TDD 驱动编写测试的过程
-
将您现有的 React 知识应用于使用 RTL
React 17 设计模式与最佳实践
Carlos Santana Roldán
ISBN: 978-1-80056-044-4
-
掌握样式化和优化 React 组件的技术
-
使用新的 React Hooks 创建组件
-
掌握新的 React Suspense 技术以及在项目中使用 GraphQL
-
使用服务器端渲染使应用程序加载更快
-
编写一套全面的测试以创建健壮且易于维护的代码
-
通过优化组件构建高性能应用程序
Packt 正在寻找像您这样的作者
如果您有兴趣成为 Packt 的作者,请访问 authors.packtpub.com 并今天申请。我们已与成千上万的开发人员和科技专业人士合作,就像您一样,帮助他们将见解分享给全球科技社区。您可以提交一般申请,申请我们正在招募作者的特定热门话题,或提交您自己的想法。
嗨!
我是方锦,正确设计 React Hooks一书的作者。我真心希望你喜欢阅读这本书,并觉得它有助于提高你在 React 中的生产力和效率。
如果你能在亚马逊上留下评论,分享你对正确设计 React Hooks的看法,那将真正帮助到我(以及其他潜在读者)!
前往以下链接留下你的评论:packt.link/r/1803235950/
你的评论将帮助我了解这本书中哪些内容做得好,哪些地方可以改进以供未来版本使用,所以这真的非常感谢。
祝好,
方锦

你可能还会喜欢以下书籍
你可能还会喜欢以下书籍
你可能还会喜欢以下书籍
你可能还会喜欢以下书籍


[(
浙公网安备 33010602011771号