React19 学习笔记

写在开头

本文的定位

本文的主要目标,是帮助你建立对 React 核心机制的系统性心智模型,而非提供大量手把手的实操示例。 如果你希望在理解原理的同时配合更多实操练习,推荐结合 React 简明教程(2026 版) 一起学习,效果会更好。

文章状态说明

目前这篇学习笔记已经将 React 的核心内容基本上都覆盖到了。后续会结合评论区的反馈,对内容进行动态调整和优化。不得不说,这是我写过的最棒的一篇笔记。

关于正确性与讨论

受限于个人理解,文中内容难免存在表述不够严谨或理解偏差的地方。如果你发现任何问题,或有不同看法,欢迎在评论区指出和讨论。每一条反馈我都会认真阅读,也欢迎提出自己的疑问,大家一起交流学习。

阅读前的基础假设

在阅读本文之前,我默认你已经具备基本的前端知识,主要包括 HTMLJavaScript(对 JavaScript 的理解要求相对较高),CSS 并非必须。 是否学过 Vue 并不影响 React 的学习。即便你有 Vue 的使用经验,也不建议在阅读时将其相关概念直接套用到 React 中;本文将从新手视角出发,由浅入深地展开 React 的核心概念。

React 版本说明

本文内容基于 React 19 进行学习与整理。

初识 React 组件

React 组件是一段可复用的 UI 代码单元。它就像积木一样,界面可以被拆分为许多小块,每一块都是一个组件,这些组件组合在一起,构成完整的应用。

在现代 React 中,主流的组件写法是函数组件。也就是说用 JS 函数来定义组件。只不过这个函数和普通 JS 函数相比需要注意以下两点:

  1. 组件名称(也就是函数名)必须以大写字母开头
  2. 函数的返回值是 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 官方文档,了解即可。

  • typetype 参数必须是一个有效的 React 组件类型,例如一个字符串标签名(如 'div''span'),或一个 React 组件(一个函数式组件、一个类式组件,或者是一个特殊的组件如 Fragment)。
  • propsprops 参数必须是一个对象或 null。如果你传入 null,它会被当作一个空对象。创建的 React 元素的 props 与这个参数相同。注意,props 对象中的 refkey 比较特殊,它们 不会 作为 element.props.refelement.props.key 出现在创建的元素 element 上,而是作为 element.refelement.key 出现。
  • 可选 ...children:零个或多个子节点。它们可以是任何 React 节点,包括 React 元素、字符串、数字、portal、空节点(nullundefinedtruefalse),以及 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(例如 onClicktabIndex)。也存在少数的例外情况(例如以 data-aria- 开头的属性),它们由于历史原因仍保持 kebab-case 的写法。

另外,classfor 在 JSX 中分别写成 classNamehtmlFor,这是为了避免与 JS 关键字冲突。

深入 JSX

在对 JSX 建立了一个初步的认知之后,接下来就可以进一步看看 JSX 在实际编写 UI 的几种常见使用方式。

插值语法

在 JSX 中,使用插值语法{} 可以在标签结构中嵌入 JavaScript 表达式,用来动态生成 UI 内容。{} 中只能书写表达式,其计算结果会在渲染时被插入到对应的位置。

例如:

jsx
复制代码
function App() { const text = "Hello World!" return <h1>{text}</h1> }

在这个例子中,text 是一个普通的 JavaScript 变量,JSX 会在渲染时读取它的值,并将结果显示在 <h1> 标签中。

当 JS 表达式的结果为 nullundefinedfalse 时,这些值不会被渲染到页面中。

属性绑定

在 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”、“功德 -1”、“功德清零”
  2. 渲染当前功德的值

场景一:新状态值不依赖于旧值

当新状态值与旧值无关时,可以直接向 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 })

若需要更新meage为19,此时该如何调用setMe

当状态是一个普通对象时,通过创建一个新对象来完成不可变更新。

例如:

jsx
复制代码
setMe({ ...me, age: 19 })

这里通过创建一个新的对象来整体替换原有状态,而不是直接修改原有的状态对象。

展开运算符用于将原有字段拷贝到新对象中,并用新的 age 值覆盖对应字段,从而遵循不可变更新原则。

【嵌套对象】

考虑这样的一个状态:

jsx
复制代码
const [me, setMe] = useState({ name: "ikun", age: 18, school: { name: "Guizhou University", location: "Guiyang Guizhou" } })

若需要更新schoolname为"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} /> ))} </> ) }

可见,我们通过 titlecontent 这两个属性把数据传入 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)} /> </> ) }

效果:

PixPin_2026-01-17_13-08-15.webp

在这个例子中,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> }

在这个例子中,LayoutPage 本身并不关心 theme, 却不得不接收并继续向下传递它。

为了解决组件层级加深时逐层传递 props 的问题,React 提供了 Context 机制,用于在组件树中跨层级地共享数据。

Context 的使用包含以下三个步骤:

  1. 创建 Context,定义需要共享的数据
  2. 提供 Context,确定数据在组件树中的作用范围
  3. 使用 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 交互。

这些操作并不会直接影响组件根据当前的 propsstate 计算出的渲染结果,但它们的执行会对组件作用域之外的环境产生影响。在 React 中,这类操作被统一称为 副作用(Effect)

为何需要 useEffect

在理解了什么是副作用之后,接下来需要回到 React 组件的渲染过程本身。

当 React 渲染或重新渲染组件时,会 从上到下执行组件函数,并根据当前的 propsstate 计算出界面结构。React 可能会多次执行组件函数来重新计算组件的 UI,但并非每次计算都会导致真实的页面更新,页面最终只会反映其中一次计算的结果。

因此,在组件函数的渲染过程中,不应该执行副作用,因为这些副作用逻辑可能在多次渲染过程中被重复执行。

这也正是 React 要求不能在组件渲染过程内执行副作用 的原因。

然而,在实际开发中,组件不可避免地需要执行副作用逻辑,例如请求数据。为了避免副作用在渲染过程中执行,React 提供了 useEffect Hook,使得副作用能够在组件渲染完成后执行

初识 useEffect

useEffect 是 React 提供的一个 Hook,用于注册组件中需要在渲染完成之后执行的副作用逻辑

它使得组件函数只负责计算 UI,而副作用的执行则由 React 在渲染完成之后处理。

useEffect 接收两个参数:

  • setup 函数:定义副作用的具体逻辑。
  • 依赖项数组(dependencies):可选,用来控制副作用何时重新执行。

useEffect 在组件渲染完成后执行 setup 函数,且可以返回一个 清理函数(cleanup),该函数会在组件卸载或副作用重新执行之前被调用,用来清理之前的副作用,后续会具体介绍二者的实际执行顺序。

依赖项数组是一个由状态组成的数组。React 会比较数组中每个依赖项的前后值变化,决定副作用是否需要重新执行。

副作用重新执行的时机

useEffect 的副作用并非每次组件渲染后都执行,而是根据传递的dependencies 数组的内容来决定何时重新执行。

① 无 dependencies

如果没有传递 dependenciesuseEffect 会在 每次组件渲染后 执行副作用逻辑。

例如:

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 会在合适的时机自动调用这两个函数,确保副作用正确地清理和重新执行。

setupcleanup的执行顺序如下:

  1. 组件首次渲染后 React 会执行 setup 函数,初始化副作用。
  2. 依赖项变化触发组件重新渲染时
    • 在组件重新渲染之前,React 会先执行 cleanup 函数,清理上一次的副作用。此时cleanup中访问到的状态是旧值。
    • 在组件重新渲染完成后,React 会重新执行 setup 函数,此时 setup 中访问到的状态是新值。
  3. 组件即将卸载时 在组件卸载前,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,来观察 setupcleanup 的执行顺序。每次 count 变化时,Child 组件会重新渲染,并执行相应的副作用逻辑。

写到最后

有的教程可能会将上述内容描述成所谓的“生命周期”,或者将 Vue 中的生命周期概念类比到 React 中。

然而,在现代 React(尤其是函数式组件)中,生命周期 这一概念已经不再适用,最新的 React 文档中甚至不再提及“生命周期”这个词。

我想提醒你的是,副作用的所有相关逻辑,不需要再依赖生命周期的概念,而是通过 useEffect 来处理。useEffect 作为现代函数式组件中的核心 Hook,负责灵活地管理副作用。

自定义 Hook

自定义 Hook 是 React 中的一种机制,它允许你将组件中可复用的逻辑提取到一个函数中,并将其封装,这样多个组件可以共享这段逻辑,而不需要重复代码。而这些函数被称为 自定义 Hook 函数。

React 提供了内置的 Hook(例如 useStateuseEffect),自定义 Hook 可以利用这些内置的 Hook 来实现自己的功能逻辑。

自定义 Hook 必须遵循与 React 内置 Hook 相同的规范:

  1. 命名规则:Hook 函数的名称必须以 use 开头(例如:useState)。
  2. 调用规则: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 只需要返回 xy 的坐标值,其它逻辑都封装在 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。

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
坤坤
下载 APP