第三章: 副作用与生命周期:useEffect 完全指南
第三章: 副作用与生命周期:useEffect 完全指南
Prorise第三章: 副作用与生命周期:useEffect 完全指南
摘要: 在上一章,我们掌握了如何使用 useState 来管理组件的内部状态,并驱动 UI 更新。然而,一个真实的组件不仅要渲染 UI,还需要与“外部世界”打交道。本章将通过一个具体的实战案例,引出 React 中用于处理这类“副作用”的统一解决方案——useEffect Hook。我们将彻底抛弃枯燥的语法讲解,从代码出发,精确地将 useEffect 的用法映射到您所熟知的 onMounted, watch 和 onUnmounted,真正打通从 Vue 到 React 在组件生命周期和响应式方面的核心认知。
在本章中,我们将循序渐进地完成一次从“有状态组件”到“有副作用组件”的升级:
- 首先,我们将从一个 简单的计数器 开始,并提出一个新需求:将计数器的值同步到浏览器的标题栏上。
- 接着,我们将引入 useEffect 来 解决这个“副作用”问题,并在此过程中解构其核心组成:效应函数与依赖数组。
- 然后,我们将深入探索 依赖数组 的三种核心用法,将它与 Vue 的 onMounted 和 watch 进行精确对标。
- 最后,我们将通过一个 新的定时器案例,学习 useEffect 的 清理机制,并将其与 Vue 的 onUnmounted 进行映射,形成完整的知识闭环
3.1. 第一次接触:当组件需要与外部世界对话
让我们从第二章结尾的 Counter 组件开始。它是一个纯粹的内部状态组件,只关心自己的 count 状态和 UI 渲染。
Counter.tsx (我们已有的知识)
1 | import { useState } from 'react'; |
新需求: 我们希望每当 count 变化时,浏览器的标签页标题也能同步更新为 “You clicked X times”。
思考一下: 这个操作应该放在组件的哪个部分?
- 不能 直接放在组件函数的主体里,因为那里的代码会在 每次渲染时 都执行,我们不希望在渲染过程中执行 DOM 操作。
- 不能 放在事件处理器里,因为我们希望在 组件加载完成时 也设置一次标题,而不仅仅是点击时。
这个“更新浏览器标题”的操作,就是一个典型的 副作用 (Side Effect)。它不直接计算和返回 JSX,而是去操作一个 React 组件之外的系统(在这里是浏览器 DOM)。
3.1.1. 解决方案:使用 useEffect 同步状态到外部
为了处理这种副作用,我们引入 useEffect。
CounterWithTitle.tsx (引入 useEffect)
1 | import { useState, useEffect } from 'react' |
现在,当你运行这个组件时,会发现:
- 组件首次加载时,标题会更新为 “你点击了 0 次”。
- 每次点击按钮,count 增加,标题也会同步更新。
useEffect 完美地解决了我们的问题。它就像一个我们安置在组件渲染流程之外的“特殊区域”,专门用来执行那些不方便在主函数体中进行的操作
3.1.2. 解构 useEffect:效应函数与依赖数组
上面的代码 useEffect(() => { … }); 是 useEffect 最基本的形式,但它并不完美(打开控制台,你会发现 “Effect function is running!” 在每次渲染时都会打印)。为了精确控制副作用的执行时机,我们需要理解它的完整结构:
- setup 函数: 第一个参数,一个函数。我们称之为“效应函数”。你的副作用逻辑就写在这里(例如,document.title = …)。
- dependencies 数组: 第二个参数,一个 可选的 数组。我们称之为“依赖数组”。这是 useEffect 的灵魂所在,它告诉 React:“只有当这个数组里的值发生变化时,才需要重新执行 setup 函数。”
现在,让我们用依赖数组来优化我们的组件,让 effect 只在 count 变化时执行:
1 | // 👇 使用 useEffect 来处理副作用 |
现在,useEffect 的行为变得更加智能:
- React 在每次渲染后,会比较 [count] 这次的值和上次渲染时的值。
- 如果 count 没变(例如,父组件的其他 state 变化导致本组件重渲),React 会跳过 setup 函数的执行。
- 只有当 count 的值确实发生了变化,setup 函数才会再次运行。
3.2. 精通依赖数组:从 onMounted 到 watch 的精确映射
通过控制依赖数组的内容,我们可以精确地模拟出 Vue 中几乎所有的生命周期和侦听行为。
用法一:空数组 [] —— 对标 onMounted
如果你提供一个 空的依赖数组 [],这意味着 setup 函数的依赖永远不会改变。因此,这个 setup 函数将 只在组件第一次渲染挂载后执行一次。
痛点背景: 在 Vue 中,我们需要在组件挂载后从服务器获取初始数据,我们会这样写:
1 | onMounted(async () => { |
在 React 中如何实现?
解决方案:
UserProfile.tsx
1 | import { useState, useEffect } from 'react'; |
用法二:包含值的数组 [dep1, dep2] —— 对标 watch
当你向依赖数组中提供一个或多个值时,useEffect 就会像 Vue 的 watch 一样工作:它会 “侦听”
这些值的变化,并在任何一个值改变后的下一次渲染完成后,执行 setup 函数。
痛点背景: 在 Vue 中,如果一个 prop (例如 userId) 变化了,我们需要重新获取数据。我们会使用 watch:
1 | watch(() => props.userId, (newUserId) => { |
这正是我们在 CounterWithTitle
示例中已经做过的事情。
1 | // 👇 使用 useEffect 来处理副作用 |
一个常见的陷阱: 如果你不提供依赖数组(useEffect(() => { ... })
),setup
函数会在 每一次渲染后 都执行。这等价于 Vue 的 onUpdated
加上 onMounted
,通常会导致性能问题或无限循环,是你应该极力避免的模式。
3.3. 清理机制:对标 onUnmounted
副作用通常需要“清理”。例如,如果你设置了一个定时器,或者添加了一个全局事件监听,你需要在组件被销毁时取消它们,以防止内存泄漏或 bug。
解决方案: useEffect
的 setup
函数可以 返回另一个函数。React 会将这个返回的函数保存下来,并在 下一次 effect 即将重新执行之前,或者 组件即将卸载时,自动调用它。这个返回的函数就是 清理函数。
痛点背景: 在 Vue 中,我们在 onUnmounted
钩子中执行清理工作。
1 | onMounted(() => { |
React 中的等价实现:
Timer.tsx
1 | import { useState, useEffect } from 'react'; |
通过将副作用的“创建”和“清理”逻辑放在同一个 useEffect 内部,React 让相关联的代码更加内聚,也更不容易忘记清理。
3.4. key
的深度应用:重置组件状态的艺术
在 2.1.4
节,我们已经知道 key
在列表渲染中是必不可少的,它帮助 React 识别哪些项被更改、添加或删除。然而,key
的作用远不止于此。它实际上是 React 用来标识一个组件 身份 (identity) 的核心线索。
本小节核心知识点:
- 当一个组件的
key
发生变化时,React 不会去更新(diff)现有的组件实例。 - 相反,React 会认为这是一个 全新的组件,从而 卸载 (unmount) 旧的组件实例(包括其所有内部 state),并 挂载 (mount) 一个全新的实例。
- 改变
key
是一种强大的、声明式的、用于完全重置一个组件及其子组件状态的策略
3.4.1. 原理速览:一个简单的 Switcher
示例
在深入探讨复杂的应用场景之前,让我们先通过一个极简的 Switcher
组件来直观地理解 key
是如何工作的。
文件路径: src/components/Switcher.tsx
1 | import { useState } from "react"; |
发生了什么?
<input>
元素是一个 非受控组件,它自己在内部管理着用户输入的值。- 当你在输入框里输入一些文字时,这些文字被保存在这个
<input>
实例的内部状态中。 - 当你点击“切换”按钮时,
sw
的值改变,导致<input>
的key
prop 从"light"
变成了"dark"
。 - React 发现
key
变了,它不会去更新旧的输入框,而是直接 销毁 旧的<input>
实例(连同它内部保存的输入文字),然后创建一个 全新的、状态为空的<input>
实例并挂载到 DOM 上。
🤔 思考一下
请亲自尝试一下这个效果:
- 在输入框中随意输入一些文字。
- 点击“切换”按钮。
- 观察输入框,你会发现里面的文字消失了!这正是因为
key
的改变导致了整个<input>
组件的重置。
3.4.2. 实战场景:优雅地重置复杂表单
现在我们理解了原理,让我们回到一个更真实的痛点:
痛点背景: 你有一个用户资料编辑表单组件 (UserProfileForm
),它接收一个 userId
作为 prop,内部有多个 useState
。当 userId
prop 变化时,我们期望整个表单被清空并重新加载新用户的数据。如果用 useEffect
,代码会很繁琐:
1 | // 繁琐的 useEffect 方案 |
解决方案:用 key
声明式地重置
基于我们从 Switcher
中学到的知识,我们可以用 key
来极大地简化这个流程。
1 | function App() { |
1 | // UserProfileForm 组件现在可以非常纯净, |
总结与最佳实践:
当一个组件的“身份”与其某个核心 prop
(通常是 ID)深度绑定时,将这个 prop
同时用作组件的 key
是一种极其强大且优雅的模式。它将“当 prop
变化时重置组件”这个命令式的逻辑,转换为了“这个 prop
就是组件的身份”这种声明式的表达,让父组件完全掌握了子组件的生命周期,代码更简洁,意图也更清晰。