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/err…并填写表格。
通过链接材料发送至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 标题:
![Figure 1.1 – 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 – 两次更新中的两个函数作用域
图 2.4 – 两次更新中的两个函数作用域
幸运的是,JavaScript以一种方式支持函数作用域,它可以访问定义在其定义作用域内的所有变量。在我们的情况下,如果一个变量在Title函数外部定义,我们可以在Title函数内部访问这个变量,因为这个值现在在多个Title函数之间是共享的。
分享的最简单方式是创建一个全局变量,因为全局变量位于JavaScript代码的最外层作用域,因此可以在任何函数内部访问。
注意
不要被本章中使用的全局变量吓倒。在第三章“Hooking into React”中,我们将完善这种方法,并看看React如何在一个更好的位置定义变量。
这样,每个局部count值都可以设置/获取这个全局count值,如图 2.5 所示:
![图 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 计数器
图 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 按钮
图 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
图 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
图 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
图 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 – 包含源纤维的纤维树
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 钩子工作流程
让我们来解释一下我们在 图 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 的头像组件
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中的所有灰色线条。