React19 学习笔记
写在开头
本文的定位
本文的主要目标,是帮助你建立对 React 核心机制的系统性心智模型,而非提供大量手把手的实操示例。 如果你希望在理解原理的同时配合更多实操练习,推荐结合 React 简明教程(2026 版) 一起学习,效果会更好。
文章状态说明
目前这篇学习笔记已经将 React 的核心内容基本上都覆盖到了。后续会结合评论区的反馈,对内容进行动态调整和优化。不得不说,这是我写过的最棒的一篇笔记。
关于正确性与讨论
受限于个人理解,文中内容难免存在表述不够严谨或理解偏差的地方。如果你发现任何问题,或有不同看法,欢迎在评论区指出和讨论。每一条反馈我都会认真阅读,也欢迎提出自己的疑问,大家一起交流学习。
阅读前的基础假设
在阅读本文之前,我默认你已经具备基本的前端知识,主要包括 HTML 和 JavaScript(对 JavaScript 的理解要求相对较高),CSS 并非必须。 是否学过 Vue 并不影响 React 的学习。即便你有 Vue 的使用经验,也不建议在阅读时将其相关概念直接套用到 React 中;本文将从新手视角出发,由浅入深地展开 React 的核心概念。
React 版本说明
本文内容基于 React 19 进行学习与整理。
初识 React 组件
React 组件是一段可复用的 UI 代码单元。它就像积木一样,界面可以被拆分为许多小块,每一块都是一个组件,这些组件组合在一起,构成完整的应用。
在现代 React 中,主流的组件写法是函数组件。也就是说用 JS 函数来定义组件。只不过这个函数和普通 JS 函数相比需要注意以下两点:
- 组件名称(也就是函数名)必须以大写字母开头。
- 函数的返回值是 JSX(看起来像 HTML 的语法),用于定义这个组件对应的界面结构。
例如:
▼jsx复制代码function App() { return <h1>Hello World!</h1> }
初识 JSX
JSX 是 JavaScript 的一种语法扩展,允许你在 JavaScript 文件中书写类似 HTML 的标签语法。从语法层面看,JSX 本质上是一个 JavaScript 表达式,用来表示界面结构。
例如,在刚刚的App组件中,<h1>Hello World!</h1>就是一个 JSX 表达式。
浏览器本身不支持 JSX ,因此其需要先被编译为普通的JavaScript 才能被浏览器执行。
那么 JSX 表达式的编译结果长什么样?
其实它会被编译为一个函数调用:
例如:
▼jsx复制代码function App() { return React.createElement("h1", null, "Hello World!"); }
这个函数的参数为:
源自 React 官方文档,了解即可。
type:type参数必须是一个有效的 React 组件类型,例如一个字符串标签名(如'div'或'span'),或一个 React 组件(一个函数式组件、一个类式组件,或者是一个特殊的组件如Fragment)。props:props参数必须是一个对象或null。如果你传入null,它会被当作一个空对象。创建的 React 元素的props与这个参数相同。注意,props对象中的ref和key比较特殊,它们 不会 作为element.props.ref和element.props.key出现在创建的元素element上,而是作为element.ref和element.key出现。- 可选
...children:零个或多个子节点。它们可以是任何 React 节点,包括 React 元素、字符串、数字、portal、空节点(null、undefined、true和false),以及 React 节点数组。
这个函数会返回一个 React 元素(本质上是一个 JS 对象)。你不需要关心它的具体结构,只需要知道:它代表了当前组件对应的界面结构。
尽管 JSX 的写法很像你熟悉的 HTML,但 JSX 不是 HTML;而且它的语法更严格,需要遵守更多约束。
① 组件的返回值必须只有一个根元素
组件一次只能返回一个“根元素”。也就是说,在 return (...) 里面,最外层不能并列写两个标签。
以下是错误示例:
▼jsx复制代码function App() { return ( <h1>A</h1> <h1>B</h1> ) }
解决方案:仅需让一个标签作为它们的父元素就好。
例如:
▼jsx复制代码function App() { return ( <div> <h1>A</h1> <h1>B</h1> </div> ) }
但这样会在最终的 DOM 中多出一个用于包裹的 <div>。
不过 React 提供了一个特殊的标签:<></> (Fragment),你可以这样用:
▼jsx复制代码function App() { return ( <> <h1>A</h1> <h1>B</h1> </> ) }
Fragment 只用于分组,不会被渲染成真实 DOM 节点。
② 所有标签都必须闭合
JSX 要求标签必须正确闭合:
- 成对标签必须有结束标签:
<li>oranges</li> - 没有内容的元素必须写成自闭合:
<img />、<input />
③ 属性名通常用 camelCase
JSX 最终会被编译为对 React.createElement 的函数调用,其中写在 JSX 上的属性,会被 React 收集起来,组成一个对象,并在组件渲染时传入组件。这个对象在 React 中被称为 props,它是组件接收外部数据的主要方式,后文会专门介绍。
因此,React/DOM 中的大多数属性名采用 camelCase(例如 onClick、tabIndex)。也存在少数的例外情况(例如以 data-、aria- 开头的属性),它们由于历史原因仍保持 kebab-case 的写法。
另外,class 和 for 在 JSX 中分别写成 className 和 htmlFor,这是为了避免与 JS 关键字冲突。
深入 JSX
在对 JSX 建立了一个初步的认知之后,接下来就可以进一步看看 JSX 在实际编写 UI 的几种常见使用方式。
插值语法
在 JSX 中,使用插值语法{} 可以在标签结构中嵌入 JavaScript 表达式,用来动态生成 UI 内容。{} 中只能书写表达式,其计算结果会在渲染时被插入到对应的位置。
例如:
▼jsx复制代码function App() { const text = "Hello World!" return <h1>{text}</h1> }
在这个例子中,text 是一个普通的 JavaScript 变量,JSX 会在渲染时读取它的值,并将结果显示在 <h1> 标签中。
当 JS 表达式的结果为 null、undefined 或 false 时,这些值不会被渲染到页面中。
属性绑定
在 JSX 中,标签属性既可以使用字符串字面量,也可以通过 {} 绑定 JavaScript 表达式。
例如:
▼jsx复制代码function App() { const ikunImgUrl = "https://pic4.zhimg.com/v2-d3e48d94ac7a156c058bb1ed651be1a9_1440w.jpg" return <img src={ikunImgUrl} /> }
事件绑定
在 JSX 中,可以通过事件处理属性响应用户的交互操作。这些属性以 onXxx 的形式命名,其中 Xxx 表示具体的事件类型。
仅需将事件处理函数与相应的事件处理属性绑定起来,即可为元素绑定对应的事件。
例如:
▼jsx复制代码function App() { return <button onClick={() => alert("恭喜你点击我了!")}>点我试试?</button> }
通过前面三个小节的内容,可以发现
{}在 JSX 中具有统一的作用。无论是内容、属性还是事件处理,它们都没有语法上的差异,只是 JS 表达式生效的位置不同而已。
此外,在 JSX 中编写注释时,同样需要借助
{}。例如:
▼jsx复制代码function App() { return ( <button onClick={() => alert("恭喜你点击我了!")}> 点我试试? {/* 我是一个注释 */} </button> ) }
条件渲染
在 React 中,可以通过 JavaScript 中用于条件判断的语法,来选择性地渲染 JSX。
其中较为常见的一种方式是使用三元运算符,下面将以这种方式为例。
例如:
▼jsx复制代码function App() { const isLogin = true return <>{isLogin ? <span>已登录</span> : <span>未登录</span>}</> }
列表渲染
现在你有如下数据需要渲染到页面上:
▼js复制代码const todos = [ { id: 1, content: "学React", }, { id: 2, content: "学Vue", }, { id: 3, content: "学Java", }, ];
接下来你需要将其渲染成如下结构:
▼html复制代码<ul> <li>学React</li> <li>学Vue</li> <li>学Java</li> </ul>
对于这类列表数据,通常会通过 map 方法,将每一项数据映射为一个结构相同的 JSX 元素,然后再在 JSX 中渲染。
例如:
▼jsx复制代码function App() { const todos = [ { id: 1, content: "学React", }, { id: 2, content: "学Vue", }, { id: 3, content: "学Java", }, ] return ( <ul> {todos.map((todo) => ( <li key={todo.id}>{todo.content}</li> ))} </ul> ) }
上述代码将列表数据映射为一组 JSX 元素并渲染到页面中。
在渲染列表时,React 要求为每一项 JSX 元素提供一个唯一的 key,可使用数据自身的 id。
状态
何为状态
在 React 中,组件的 UI 不是通过命令式操作来更新的,而是由数据计算得到的结果。也就是说,界面长什么样,取决于数据是什么样。这种方式正是数据驱动视图的设计思想的体现。
而这类由 React 管理、会影响组件的渲染结果,并在变化时自动触发组件重新渲染的数据称为状态(State)。
定义状态
在 React 中,我们使用useState(初始值)这个函数来在组件中定义状态。
在函数组件中,应在组件函数的最外层作用域中调用 useState 来声明状态。
状态是组件私有的,只存在于定义它的组件中。
提示:
在 React 中,以
use开头的函数称为 Hook 函数。关于 Hook 函数的细节将在后续内容中介绍。
useState 返回一个长度为 2 的数组:
- 第一个元素是当前的状态值
- 第二个元素是用于更新该状态的 setter 函数
在 React 中,通常使用 JavaScript 的数组解构语法来方便地获取它们。
按照约定,状态值通常命名为 something,其对应的更新函数命名为 setSomething。
例如:
▼jsx复制代码import { useState } from "react" function App() { const [count, setCount] = useState(0) return <h1>{count}</h1> }
更新状态
在 React 中,状态只允许通过调用其配套的 setter 函数来更新。
下面以一个“功德木鱼”组件为例,说明 setter 函数的两种常见使用场景。
该组件包含以下交互和展示内容:
- 一组按钮,分别用于“功德 +1”、“功德 -1”、“功德清零”
- 渲染当前功德的值
场景一:新状态值不依赖于旧值
当新状态值与旧值无关时,可以直接向 setter 函数传入新的值,例如:
▼jsx复制代码const reset = () => setCount(0)
场景二:新状态值依赖于旧值
当新状态值需要基于旧值计算时,应向 setter 函数传入一个更新函数,该函数接收旧值作为参数,并返回新的状态值。这种方式也称之为函数式更新。
更新函数的签名为
(oldVal) => newVal。
例如:
▼jsx复制代码const increment = () => setCount((oldVal) => oldVal + 1) const decrement = () => setCount((oldVal) => oldVal - 1)
最终的“功德木鱼”组件的完整代码如下:
▼jsx复制代码function App() { const [count, setCount] = useState(0) const reset = () => setCount(0) const increment = () => setCount((oldVal) => oldVal + 1) const decrement = () => setCount((oldVal) => oldVal - 1) return ( <> <h1>{count}</h1> <button onClick={decrement}>功德-1</button> <button onClick={reset}>功德清零</button> <button onClick={increment}>功德+1</button> </> ) }
setter 函数的注意事项
请看下面这个组件:
▼jsx复制代码function App() { const [count, setCount] = useState(0) const reset = () => { setCount(0) console.log(count) } const increment = () => { setCount((oldVal) => oldVal + 1) console.log(count) } const decrement = () => { setCount((oldVal) => oldVal - 1) console.log(count) } return ( <> <h1>{count}</h1> <button onClick={decrement}>功德-1</button> <button onClick={reset}>功德清零</button> <button onClick={increment}>功德+1</button> </> ) }
上述代码中,在调用 setter 函数后紧接着输出了状态 count 的值。
当你亲自调试上述代码时会发现,console.log 打印的仍然是更新前的状态值。
这说明,在当前执行流程中,调用 setter 并不会立即改变状态值。
调用 setter 只是告知 React 需要更新状态,而不是立刻修改当前可读取到的状态值,
因此 console.log(count) 读取到的仍然是旧值。
基于这一结论,可以进一步回答一个常见问题:当新状态值依赖于旧值时,是否可以直接使用 count + 1 的写法?
例如:
▼jsx复制代码const increment = () => { setCount(count + 1) }
在单次调用的场景下,这样的写法是可行的。
但如果需要在同一次执行流程中多次更新状态,例如实现“功德 +3”,则会出现问题:
▼jsx复制代码const addThree = () => { setCount(count + 1) setCount(count + 1) setCount(count + 1) }
原因在于,每一次更新读取到的仍然是同一个旧值。
如果使用函数式更新,则不会出现这一问题:
▼jsx复制代码const addThree = () => { setCount((oldVal) => oldVal + 1) setCount((oldVal) => oldVal + 1) setCount((oldVal) => oldVal + 1) }
简单来说,直接使用 count + 1 时,每一次更新读取到的都是同一个旧值,而函数式更新会在每一次更新时重新获取最新的状态值。
关于状态更新的具体细节,可以参考 React 官方文档的相关说明:Queueing a Series of State Updates – React
不可变更新原则
在 React 中,状态应被视为只读值。当状态的值是对象或数组等复杂类型时,不应直接修改它们的内容,而应创建一个新的对象或数组来替换原有的状态值,这称为“不可变更新原则”。
这是因为,React 在判断状态是否发生变化时,关注的是状态值本身是否发生了变化(对于对象或数组来说,即是否是同一个引用),而不会追踪对象或数组内部字段的修改。
因此,直接修改原有对象或数组,可能导致 React 无法正确识别状态已经更新。
对象更新示例
【简单对象】
考虑这样一个状态:
▼jsx复制代码const [me, setMe] = useState({ name: "ikun", age: 18 })
若需要更新me的age为19,此时该如何调用setMe?
当状态是一个普通对象时,通过创建一个新对象来完成不可变更新。
例如:
▼jsx复制代码setMe({ ...me, age: 19 })
这里通过创建一个新的对象来整体替换原有状态,而不是直接修改原有的状态对象。
展开运算符用于将原有字段拷贝到新对象中,并用新的 age 值覆盖对应字段,从而遵循不可变更新原则。
【嵌套对象】
考虑这样的一个状态:
▼jsx复制代码const [me, setMe] = useState({ name: "ikun", age: 18, school: { name: "Guizhou University", location: "Guiyang Guizhou" } })
若需要更新school的name为"GZU",此时该如何调用setMe?
当状态对象存在嵌套结构时,为了遵循不可变更新原则,需要为被修改的每一层对象创建新的副本,例如:
▼jsx复制代码setMe({ ...me, school: { ...me.school, name: "GZU" } })
可以预见的是,随着嵌套层级的增加,为遵循不可变更新原则而编写的模板代码会迅速增多。
数组更新示例
考虑这样的状态:
▼jsx复制代码const [todos, setTodos] = useState([ { id: 1, content: "学React", }, { id: 2, content: "学Vue", }, { id: 3, content: "学Java", }, ])
往todos中新增一个元素:
▼jsx复制代码setTodos([...todos,{ id: 4, content: "学CS Go", }])
修改id为2这个元素的内容为"学霸题":
▼jsx复制代码setTodos(todos.map((todo)=>todo.id !== 2 ? todo : { ...todo, content: "学霸题", }))
删除id为3的元素:
▼jsx复制代码setTodos(todos.filter((todo)=>todo.id !== 3))
上述示例中,每一次更新都返回了一个新的数组,并且在修改元素时为被修改的项创建了新的对象,从而遵循不可变更新原则。
工程化方案:使用 immer
可见,当状态是对象或数组等复杂类型时,为了遵循不可变更新原则,往往需要编写较多的模板代码。
为此,React 官方推荐使用第三方库 immer,以简化不可变更新的编写过程。
由于这并不属于 React 的核心内容,这里不再展开介绍,感兴趣的读者可以参考官方文档中的相关说明:
组件之间的协作
为什么需要拆分组件?
当应用规模逐渐变大时,把所有 UI 和逻辑都写在一个组件中,会迅速带来可维护性和复用性的问题。React 通过“组件拆分”的方式,将界面划分为职责明确的独立单元,从而降低复杂度,并提高复用能力。
在前面的示例中,我们刻意将所有内容写在同一个组件中,以便集中关注单个组件的基本用法。
在实际开发中,我们很少从零开始实现这些组件。更多时候,我们会直接使用一些成熟的 React 组件库,例如 Ant Design,通过组合现成的组件来快速搭建页面结构。只有在组件库未提供所需组件时,才需要自行实现。
因此,在组合多个组件之前,需要先理解组件之间的数据传递与交互方式。
接下来,我们将从最基础的方式开始,理解多个组件之间是如何协同工作的。
组件的层级关系
在拆分出多个组件之后,下一步需要明确的,是这些组件在结构上的组织方式。
先定义一个简单的组件:
▼jsx复制代码function Header() { return <h1>我是头部</h1> }
定义好的组件,可以像普通 JSX 标签一样在其他组件中使用。
例如:
▼jsx复制代码function App() { return ( <> <Header /> <p>我是页面内容</p> </> ) }
这里在 App 组件中使用了 Header 组件。
当一个组件在另一个组件中被使用时,外层组件称为父组件,内层组件称为子组件。父子组件并不是组件的固定身份,而只是用于描述当前的使用关系。以刚刚的例子为例,App 组件是 Header 组件的父组件。
理解组件之间的父子关系,是后续学习组件数据传递和协作的基石。
使用 props 传递数据
当组件需要在不同场景下展示不同的内容时,就不能再把数据写死在组件内部。
例如,下面是一个用于展示文章信息的 Article 组件,它包含文章的标题和正文:
▼jsx复制代码function Article() { return ( <> <h2>标题</h2> <p>正文</p> </> ) }
目前,我们将标题和正文的内容直接写在了组件内部。
在实际使用中,我们希望在不同的地方使用 Article 组件时,它能够展示不同的标题和正文。那么,这个组件应该如何声明它所需要的数据呢?
在 React 中,组件会接收一个名为 props 的对象,用于存放外部传入的数据。这个对象会作为组件函数的第一个参数传入,组件通过读取其中的字段来渲染对应的内容。
例如:
▼jsx复制代码function Article(props) { return ( <> <h2>{props.title}</h2> <p>{props.content}</p> </> ) }
由于 props 是一个对象,实际开发中更常见的写法是使用对象解构来简化代码:
▼jsx复制代码function Article({ title, content }) { return ( <> <h2>{title}</h2> <p>{content}</p> </> ) }
在工程实践中,通常会结合 TypeScript 对 props 进行类型约束,以减少传参错误。但这些偏工程化细节,不是理解 React 核心概念所必需的内容,这里不展开;感兴趣的读者可自行查阅相关文档。
那么,这些传入组件的 props,究竟是从哪里来的?
在 JSX 中,自定义组件也可以像 HTML 标签一样接收属性。不同的是,这些属性不会直接作用于 DOM,而是会被 React 收集起来,作为 props 传入组件。
▼jsx复制代码function App() { const articles = [ { id: "l-001", title: "雨夜书简:写给未寄出的那封信", content: "窗外的雨把路灯磨成一团暖黄。我把句子写得很慢,好像每个字都要先穿过胸口再落到纸上。你不必回信——我只是想让这段沉默,有一个干净的去处。", }, { id: "l-002", title: "诗意片段:冬天的河", content: "河面薄得像一层犹豫的玻璃,风一吹就起细小的纹。岸边的芦苇把影子交给水,又把声音交给夜。你站在桥上,像站在一句未完的诗里。", }, ] return ( <> {articles.map((article) => ( <Article key={article.id} title={article.title} content={article.content} /> ))} </> ) }
可见,我们通过 title、content 这两个属性把数据传入 Article;组件内部再从 props 中读取它们。
children
有些组件只负责提供外观或布局结构,而不关心具体显示的内容。具体内容由使用者在使用组件时,通过写在组件标签中间的内容传入。
在 React 中,写在组件标签中间的内容会被收集起来,作为 props.children 传入组件。组件内部只需要渲染 children,就可以把由使用者决定的内容显示在指定的位置。
例如:
▼jsx复制代码function App() { return ( <> <Card> <p>随便什么内容</p> </Card> <Card> <div>哈哈哈</div> </Card> </> ) } function Card({ children }) { return <div className="card">{children}</div> }
单向数据流
通过前面的示例可以发现,组件之间的数据传递始终有一个明确的方向。
在 React 中,组件之间的数据流动遵循单向数据流的原则:父组件通过 props 将数据传递给子组件,子组件只能读取这些数据,而不能直接修改它们。
为什么 React 会如此强调这个原则呢?
如果子组件可以直接修改从父组件接收到的数据,那么数据变化的来源将变得难以追踪,组件之间的依赖关系也会变得难以理清。在单向数据流原则的约束下,数据的来源始终是明确的:由哪个组件提供数据,就由哪个组件负责维护它。
子组件仍然可以参与数据变化,但数据的修改逻辑始终由父组件负责。子组件只是在合适的时机发出“修改请求”,由父组件决定如何更新数据。这通常通过父组件向子组件传递回调函数,在子组件中于合适的时机调用来实现。
输入框是一个非常典型的场景: 用户在子组件中输入内容,但输入的结果往往需要由父组件统一维护和使用。
从表现上看,输入框的值会随着用户的输入实时变化,看起来像是组件内部在不断修改数据。但在 React 中,即便是这样的交互场景,数据的流动方向依然遵循单向数据流的原则。
下面通过一个简单的示例来说明这一点:
▼jsx复制代码function MyTextInput({ value, onChange }) { return ( <> <input type="text" value={value} onChange={(e) => onChange(e.target.value)} /> </> ) }
使用这个组件:
▼jsx复制代码function App() { const [inputText, setInputText] = useState("") return ( <> <h1>{inputText !== "" ? inputText : "请输入文本:"}</h1> <MyTextInput value={inputText} onChange={(newVal) => setInputText(newVal)} /> </> ) }
效果:

在这个例子中,MyTextInput 组件本身并不维护任何状态。
虽然输入框的值会随着用户输入而变化,但数据的修改始终发生在父组件中,MyTextInput 只是通过调用onChange 回调,将新的输入值传递给父组件,再由父组件决定如何更新状态,并将新的值重新传递下来。
因此,输入框的交互效果看起来像是“双向绑定”,但数据的实际流动方向依然是清晰且单向的。
Context
在前面的示例中,我们通过 props 将数据从父组件传递给子组件。这种方式在组件层级较浅时非常直观,但当组件嵌套层级逐渐变深时,逐层传递 props 会变得冗长且难以维护。
假设应用中需要在多个组件中使用同一份主题配置(例如深色或浅色模式),而真正使用该配置的组件位于较深的组件层级中。
例如:
▼jsx复制代码function App() { return <Layout theme="dark" /> } function Layout({ theme }) { return <Page theme={theme} /> } function Page({ theme }) { return <Button theme={theme} /> } function Button({ theme }) { return <button>当前主题: {theme}</button> }
在这个例子中,Layout 和 Page 本身并不关心 theme, 却不得不接收并继续向下传递它。
为了解决组件层级加深时逐层传递 props 的问题,React 提供了 Context 机制,用于在组件树中跨层级地共享数据。
Context 的使用包含以下三个步骤:
- 创建 Context,定义需要共享的数据
- 提供 Context,确定数据在组件树中的作用范围
- 使用 Context,读取当前作用域内的数据
在 React 中,通过 createContext(默认值)函数创建一个 Context 对象。
例如:
▼jsx复制代码const ThemeContext = createContext("")
创建好 Context 之后,可以通过 Context.Provider 组件的 value 属性向包裹的组件树提供数据。
其使用方式如下:
▼jsx复制代码function App() { return ( <ThemeContext.Provider value="dark"> <Layout /> </ThemeContext.Provider> ) }
在这个例子中,ThemeContext.Provider 将 "dark" 这个值提供给其包裹的组件树。
这里的 theme 不再通过 props 传递,因此 Layout 组件也不再需要接收这个属性,后续将给出完整的代码。
在组件中,可以通过 useContext 读取 Context 中提供的数据。
useContext 的使用方式如下:
▼js复制代码const value = useContext(Context对象)
例如,在 Button 组件中,应这样消费 ThemeContext 中提供的数据:
▼jsx复制代码function Button() { const theme = useContext(ThemeContext) return <button>当前主题: {theme}</button> }
这部分示例的完整代码为:
▼jsx复制代码const ThemeContext = createContext("") function App() { return ( <ThemeContext.Provider value="dark"> <Layout /> </ThemeContext.Provider> ) } function Layout() { return <Page /> } function Page() { return <Button /> } function Button() { const theme = useContext(ThemeContext) return <button>当前主题: {theme}</button> }
至此,我们已学完了 React 中的 Context 机制。
Context 并不是所有场景下都必须使用的工具,但它是 React 生态中非常基础的一部分。后续在使用各种基于 React 构建的工具库时,你很可能会频繁遇到 Context 的身影。
理解 Context 的目的,并不是记住它的具体用法,而是在看到相关代码或工具时,能够意识到:这里使用的是 React 的 Context 机制,从而自然地理解和使用它。
副作用
何为副作用
在实际开发中,组件不仅负责渲染界面,它们还可能需要进行一些操作,如请求数据、操作 DOM、注册事件或与浏览器提供的 API 交互。
这些操作并不会直接影响组件根据当前的 props 和 state 计算出的渲染结果,但它们的执行会对组件作用域之外的环境产生影响。在 React 中,这类操作被统一称为 副作用(Effect)。
为何需要 useEffect
在理解了什么是副作用之后,接下来需要回到 React 组件的渲染过程本身。
当 React 渲染或重新渲染组件时,会 从上到下执行组件函数,并根据当前的 props 和 state 计算出界面结构。React 可能会多次执行组件函数来重新计算组件的 UI,但并非每次计算都会导致真实的页面更新,页面最终只会反映其中一次计算的结果。
因此,在组件函数的渲染过程中,不应该执行副作用,因为这些副作用逻辑可能在多次渲染过程中被重复执行。
这也正是 React 要求不能在组件渲染过程内执行副作用 的原因。
然而,在实际开发中,组件不可避免地需要执行副作用逻辑,例如请求数据。为了避免副作用在渲染过程中执行,React 提供了 useEffect Hook,使得副作用能够在组件渲染完成后执行。
初识 useEffect
useEffect 是 React 提供的一个 Hook,用于注册组件中需要在渲染完成之后执行的副作用逻辑。
它使得组件函数只负责计算 UI,而副作用的执行则由 React 在渲染完成之后处理。
useEffect 接收两个参数:
setup函数:定义副作用的具体逻辑。- 依赖项数组(
dependencies):可选,用来控制副作用何时重新执行。
useEffect 在组件渲染完成后执行 setup 函数,且可以返回一个 清理函数(cleanup),该函数会在组件卸载或副作用重新执行之前被调用,用来清理之前的副作用,后续会具体介绍二者的实际执行顺序。
依赖项数组是一个由状态组成的数组。React 会比较数组中每个依赖项的前后值变化,决定副作用是否需要重新执行。
副作用重新执行的时机
useEffect 的副作用并非每次组件渲染后都执行,而是根据传递的dependencies 数组的内容来决定何时重新执行。
① 无 dependencies 时
如果没有传递 dependencies,useEffect 会在 每次组件渲染后 执行副作用逻辑。
例如:
▼jsx复制代码function App() { const [count, setCount] = useState(0) useEffect(() => { console.log("副作用执行了") }) return ( <> <h1>{count}</h1> <button onClick={() => setCount(count + 1)}>+1</button> <button onClick={() => setCount(count - 1)}>-1</button> </> ) }
在这个例子中,每次 count 发生变化,App 组件就会重新渲染,副作用逻辑也会被触发。
② dependencies为空数组时
如果 dependencies 是一个空数组 ([]),副作用仅在 组件首次渲染后 执行一次,之后的重新渲染将不会再执行。
例如:
▼jsx复制代码function App() { const [show, setShow] = useState(true) return ( <> <button onClick={() => setShow((s) => !s)}> {show ? "隐藏 Component" : "显示 Component"} </button> <hr /> {show ? <Component /> : "Component 已卸载"} </> ) } function Component() { const [count, setCount] = useState(0) useEffect(() => { console.log("副作用执行了") }, []) return ( <> <h3>{count}</h3> <button onClick={() => setCount(count + 1)}>+1</button> <button onClick={() => setCount(count - 1)}>-1</button> </> ) }
在这个例子中,副作用逻辑只会在 Component 组件 首次渲染后 执行一次。即使 count 状态发生变化导致Component 重新渲染,副作用也不会重新执行;副作用只会在 Component 组件被移除后再重新渲染时再次执行。
③ dependencies中有具体依赖项时
当 dependencies 包含一个或多个具体的依赖项时,副作用会在依赖项变化时重新执行。
例如:
▼jsx复制代码function App() { const [count, setCount] = useState(0) const [text, setText] = useState("") useEffect(() => { console.log("副作用执行了") }, [count]) return ( <> <h3>{count}</h3> <button onClick={() => setCount((c) => c + 1)}>+1</button> <button onClick={() => setCount((c) => c - 1)}>-1</button> <hr /> <label> text: <input value={text} onChange={(e) => { setText(e.target.value) }} /> </label> <p>当前 text:{text || "(空)"}</p> </> ) }
在这个例子中,副作用只会在 count 状态变化时重新执行。即使 text 状态发生变化导致 App组件重新渲染,副作用也不会执行,因为 此时的useEffect 仅依赖于 count。
setup/cleanup 的执行顺序
在 useEffect 中,副作用逻辑由 setup 函数和 cleanup 函数组成。React 会在合适的时机自动调用这两个函数,确保副作用正确地清理和重新执行。
setup 和 cleanup的执行顺序如下:
- 组件首次渲染后
React 会执行
setup函数,初始化副作用。 - 依赖项变化触发组件重新渲染时
- 在组件重新渲染之前,React 会先执行
cleanup函数,清理上一次的副作用。此时cleanup中访问到的状态是旧值。 - 在组件重新渲染完成后,React 会重新执行
setup函数,此时setup中访问到的状态是新值。
- 在组件重新渲染之前,React 会先执行
- 组件即将卸载时
在组件卸载前,React 会最后一次执行
cleanup函数,以彻底清理副作用。
例如:
▼jsx复制代码function App() { const [showChild, setShowChild] = useState(true) return ( <> <button onClick={() => setShowChild((v) => !v)}> {showChild ? "卸载 Child" : "挂载 Child"} </button> <hr /> {showChild ? <Child /> : <div>Child 已卸载</div>} </> ) } function Child() { const [count, setCount] = useState(0) useEffect(() => { console.log(`[Child setup] count=${count}`) return () => { console.log(`[Child cleanup] count=${count}`) } }, [count]) return ( <> <h3>{count}</h3> <button onClick={() => setCount((c) => c + 1)}>+1</button> <button onClick={() => setCount((c) => c - 1)}>-1</button> </> ) }
在这个例子中,你可以点击按钮切换 showChild,来观察 setup 和 cleanup 的执行顺序。每次 count 变化时,Child 组件会重新渲染,并执行相应的副作用逻辑。
写到最后
有的教程可能会将上述内容描述成所谓的“生命周期”,或者将 Vue 中的生命周期概念类比到 React 中。
然而,在现代 React(尤其是函数式组件)中,生命周期 这一概念已经不再适用,最新的 React 文档中甚至不再提及“生命周期”这个词。
我想提醒你的是,副作用的所有相关逻辑,不需要再依赖生命周期的概念,而是通过 useEffect 来处理。useEffect 作为现代函数式组件中的核心 Hook,负责灵活地管理副作用。
自定义 Hook
自定义 Hook 是 React 中的一种机制,它允许你将组件中可复用的逻辑提取到一个函数中,并将其封装,这样多个组件可以共享这段逻辑,而不需要重复代码。而这些函数被称为 自定义 Hook 函数。
React 提供了内置的 Hook(例如 useState、useEffect),自定义 Hook 可以利用这些内置的 Hook 来实现自己的功能逻辑。
自定义 Hook 必须遵循与 React 内置 Hook 相同的规范:
- 命名规则:Hook 函数的名称必须以
use开头(例如:useState)。 - 调用规则:Hook 函数只能在组件的顶层作用域内调用。
为了说明如何将逻辑提取到自定义 Hook,我们先在 App 组件中实现一个简单的功能:追踪鼠标在屏幕上的 x 和 y 坐标。
▼jsx复制代码function App() { const [position, setPosition] = useState({ x: 0, y: 0 }) useEffect(() => { // 订阅鼠标移动事件,实时更新当前鼠标坐标 const handleMouseMove = (event) => { setPosition({ x: event.clientX, y: event.clientY }) }; // 组件挂载时绑定监听,组件卸载时移除监听,避免内存泄漏 window.addEventListener("mousemove", handleMouseMove, { passive: true }) return () => { window.removeEventListener("mousemove", handleMouseMove) }; }, []); return ( <div> x: {position.x}, y: {position.y} </div> ); }
上面实现的鼠标追踪功能是一个常见的需求,假设以后我们在其他地方也需要用到这段逻辑,重复代码会增加维护成本,而且会导致逻辑分散。为了避免这种问题,我们可以将这段逻辑提取到一个自定义 Hook 中,这样以后需要使用的时候,只需简单调用该 Hook 就能复用这份逻辑。
现在,我们将追踪鼠标坐标的逻辑封装到一个自定义 Hook 中。这个 Hook 只需要返回 x 和 y 的坐标值,其它逻辑都封装在 Hook 内部,例如:
▼jsx复制代码function useMousePosition() { const [position, setPosition] = useState({ x: 0, y: 0 }) useEffect(() => { const handleMouseMove = (event) => { setPosition({ x: event.clientX, y: event.clientY }) } window.addEventListener("mousemove", handleMouseMove, { passive: true }) return () => { window.removeEventListener("mousemove", handleMouseMove) }; }, []) return { x: position.x, y: position.y } }
一旦我们将逻辑封装成 useMousePosition,在 App 组件中使用这个 Hook 就非常简单了:
▼jsx复制代码function App() { // 调用自定义 Hook 获取鼠标坐标 const { x, y } = useMousePosition(); return ( <div> x: {x}, y: {y} </div> ); }
这样,App 组件变得更加简洁,鼠标位置的追踪逻辑也得到了复用。如果以后需要在其他地方使用这个功能,只需要调用 useMousePosition 即可。
自定义 Hook 的好处在于,它能够提高代码的复用性,通过将公共逻辑提取到 Hook 中,我们可以在多个组件之间共享这段逻辑,避免重复编写。它还可以简化组件的代码,将副作用、状态管理等逻辑封装在自定义 Hook 中,使组件只专注于渲染和 UI,从而提高了代码的清晰度。此外,多个组件共享同一份逻辑,更新时只需修改自定义 Hook,避免了逻辑分散带来的维护难度,提高了代码的可维护性。
在实际开发过程中,我们可以使用一些成熟的 Hooks 工具库来帮助我们快速实现常见功能。例如,Ahooks 是一个非常优秀的 React Hooks 工具库,提供了超过 40 个常用的 Hook,包括数据请求、表单处理、缓存管理等,能够显著提高开发效率。通过学习并使用第三方库,我们不仅可以加速开发进程,还能逐步学习如何编写更加优雅、符合规范的自定义 Hook。
