第三章: 副作用与生命周期:useEffect 完全指南

第三章: 副作用与生命周期:useEffect 完全指南

摘要: 在上一章,我们掌握了如何使用 useState 来管理组件的内部状态,并驱动 UI 更新。然而,一个真实的组件不仅要渲染 UI,还需要与“外部世界”打交道。本章将通过一个具体的实战案例,引出 React 中用于处理这类“副作用”的统一解决方案——useEffect Hook。我们将彻底抛弃枯燥的语法讲解,从代码出发,精确地将 useEffect 的用法映射到您所熟知的 onMounted, watch 和 onUnmounted,真正打通从 Vue 到 React 在组件生命周期和响应式方面的核心认知。

在本章中,我们将循序渐进地完成一次从“有状态组件”到“有副作用组件”的升级:

  1. 首先,我们将从一个 简单的计数器 开始,并提出一个新需求:将计数器的值同步到浏览器的标题栏上。
  2. 接着,我们将引入 useEffect 来 解决这个“副作用”问题,并在此过程中解构其核心组成:效应函数与依赖数组。
  3. 然后,我们将深入探索 依赖数组 的三种核心用法,将它与 Vue 的 onMounted 和 watch 进行精确对标。
  4. 最后,我们将通过一个 新的定时器案例,学习 useEffect 的 清理机制,并将其与 Vue 的 onUnmounted 进行映射,形成完整的知识闭环

3.1. 第一次接触:当组件需要与外部世界对话

让我们从第二章结尾的 Counter 组件开始。它是一个纯粹的内部状态组件,只关心自己的 count 状态和 UI 渲染。

Counter.tsx (我们已有的知识)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { useState } from 'react';

function Counter() {
const [count, setCount] = useState(0);

return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}

新需求: 我们希望每当 count 变化时,浏览器的标签页标题也能同步更新为 “You clicked X times”。

思考一下: 这个操作应该放在组件的哪个部分?

  • 不能 直接放在组件函数的主体里,因为那里的代码会在 每次渲染时 都执行,我们不希望在渲染过程中执行 DOM 操作。
  • 不能 放在事件处理器里,因为我们希望在 组件加载完成时 也设置一次标题,而不仅仅是点击时。

这个“更新浏览器标题”的操作,就是一个典型的 副作用 (Side Effect)。它不直接计算和返回 JSX,而是去操作一个 React 组件之外的系统(在这里是浏览器 DOM)。

3.1.1. 解决方案:使用 useEffect 同步状态到外部

为了处理这种副作用,我们引入 useEffect。

CounterWithTitle.tsx (引入 useEffect)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { useState, useEffect } from 'react'

function CounterWithTitle() {
const [count, setCount] = useState(0)

// 👇 使用 useEffect 来处理副作用
useEffect(() => {
// 这里的代码会在每次组件渲染完成后执行
console.log('副作用函数正在运行...')
document.title = '你点击了' + count + '次'
}) // 暂时忽略掉第二个参数

return (
<div>
<p>你点击了 {count} 次</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
)
}

export default CounterWithTitle;

现在,当你运行这个组件时,会发现:

  1. 组件首次加载时,标题会更新为 “你点击了 0 次”。
  2. 每次点击按钮,count 增加,标题也会同步更新。

useEffect 完美地解决了我们的问题。它就像一个我们安置在组件渲染流程之外的“特殊区域”,专门用来执行那些不方便在主函数体中进行的操作


3.1.2. 解构 useEffect:效应函数与依赖数组

上面的代码 useEffect(() => { … }); 是 useEffect 最基本的形式,但它并不完美(打开控制台,你会发现 “Effect function is running!” 在每次渲染时都会打印)。为了精确控制副作用的执行时机,我们需要理解它的完整结构:

  1. setup 函数: 第一个参数,一个函数。我们称之为“效应函数”。你的副作用逻辑就写在这里(例如,document.title = …)。
  2. dependencies 数组: 第二个参数,一个 可选的 数组。我们称之为“依赖数组”。这是 useEffect 的灵魂所在,它告诉 React:“只有当这个数组里的值发生变化时,才需要重新执行 setup 函数。

现在,让我们用依赖数组来优化我们的组件,让 effect 只在 count 变化时执行:

1
2
3
4
5
6
// 👇 使用 useEffect 来处理副作用
useEffect(() => {
// 这里的代码会在每次组件渲染完成后执行
console.log('副作用函数正在运行...')
document.title = '你点击了' + count + '次'
},[count]) // 👈 关键:在这里传入依赖数组

现在,useEffect 的行为变得更加智能:

  • React 在每次渲染后,会比较 [count] 这次的值和上次渲染时的值。
  • 如果 count 没变(例如,父组件的其他 state 变化导致本组件重渲),React 会跳过 setup 函数的执行。
  • 只有当 count 的值确实发生了变化,setup 函数才会再次运行。

3.2. 精通依赖数组:从 onMounted 到 watch 的精确映射

通过控制依赖数组的内容,我们可以精确地模拟出 Vue 中几乎所有的生命周期和侦听行为。

用法一:空数组 [] —— 对标 onMounted

如果你提供一个 空的依赖数组 [],这意味着 setup 函数的依赖永远不会改变。因此,这个 setup 函数将 只在组件第一次渲染挂载后执行一次

痛点背景: 在 Vue 中,我们需要在组件挂载后从服务器获取初始数据,我们会这样写:

1
2
3
onMounted(async () => {
userData.value = await fetchUserData();
});

在 React 中如何实现?

解决方案:

UserProfile.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
const [user, setUser] = useState(null);

useEffect(() => {
console.log('组件被挂载..');
// 假设 fetchUserData 是一个获取数据的函数
fetchUserData(userId).then(data => {
setUser(data);
});
}, []); // 👈 空数组,意味着只在挂载时运行一次

if (!user) {
return <div>Loading...</div>;
}

return <div>Welcome, {user.name}</div>;
}

用法二:包含值的数组 [dep1, dep2] —— 对标 watch

当你向依赖数组中提供一个或多个值时,useEffect 就会像 Vue 的 watch 一样工作:它会 “侦听” 这些值的变化,并在任何一个值改变后的下一次渲染完成后,执行 setup 函数。

痛点背景: 在 Vue 中,如果一个 prop (例如 userId) 变化了,我们需要重新获取数据。我们会使用 watch:

1
2
3
watch(() => props.userId, (newUserId) => {
userData.value = await fetchUserData(newUserId);
});

这正是我们在 CounterWithTitle 示例中已经做过的事情。

1
2
3
4
5
6
// 👇 使用 useEffect 来处理副作用
useEffect(() => {
// 这里的代码会在每次组件渲染完成后执行
console.log('副作用函数正在运行...')
document.title = '你点击了' + count + '次'
},[count]) // 👈 关键:在这里传入依赖数组,表示当 count 变化时会自动执行 setup 函数

一个常见的陷阱: 如果你不提供依赖数组(useEffect(() => { ... })),setup 函数会在 每一次渲染后 都执行。这等价于 Vue 的 onUpdated 加上 onMounted,通常会导致性能问题或无限循环,是你应该极力避免的模式。


3.3. 清理机制:对标 onUnmounted

副作用通常需要“清理”。例如,如果你设置了一个定时器,或者添加了一个全局事件监听,你需要在组件被销毁时取消它们,以防止内存泄漏或 bug。

解决方案: useEffectsetup 函数可以 返回另一个函数。React 会将这个返回的函数保存下来,并在 下一次 effect 即将重新执行之前,或者 组件即将卸载时,自动调用它。这个返回的函数就是 清理函数

痛点背景: 在 Vue 中,我们在 onUnmounted 钩子中执行清理工作。

1
2
3
4
5
6
7
onMounted(() => {
const timerId = setInterval(() => { /*...*/ }, 1000);

onUnmounted(() => {
clearInterval(timerId);
});
});

React 中的等价实现:

Timer.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { useState, useEffect } from 'react';

function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
console.log('Effect is setting up a timer.');
const timerId = setInterval(() => {
// 使用函数式更新,避免依赖 seconds state
setSeconds(s => s + 1);
}, 1000);

// 👇 返回一个清理函数
return () => {
console.log('Cleanup function is running! Clearing timer.');
clearInterval(timerId);
};
}, []); // 👈 空数组,意味着 setup 只在挂载时运行一次,cleanup 只在卸载时运行一次

return <h1>{seconds} seconds have passed.</h1>;
}

通过将副作用的“创建”和“清理”逻辑放在同一个 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { useState } from "react";

function Switcher() {
const [sw, setSw] = useState(false);

return (
<div className="text-center p-8">
{sw ? (
<span className="bg-black text-white p-2 rounded m-2">暗黑模式</span>
) : (
<span className="bg-slate-300 text-black p-2 rounded m-2">明亮模式</span>
)}
<div className="mt-4">
<input
type="text"
placeholder="在这里输入..."
className="border-2 border-gray-400 p-2 rounded"
// 👇 这是本节的核心
// input 元素的 key 会根据 sw 的状态在 "dark" "light" 之间切换
key={sw ? "dark" : "light"}
/>
<button
className="ml-2 bg-blue-500 text-white p-2 rounded hover:bg-blue-700"
onClick={() => setSw((s) => !s)}
>
切换
</button>
</div>
</div>
);
};

export default Switcher;

发生了什么?

  1. <input> 元素是一个 非受控组件,它自己在内部管理着用户输入的值。
  2. 当你在输入框里输入一些文字时,这些文字被保存在这个 <input> 实例的内部状态中。
  3. 当你点击“切换”按钮时,sw 的值改变,导致 <input>key prop 从 "light" 变成了 "dark"
  4. React 发现 key 变了,它不会去更新旧的输入框,而是直接 销毁 旧的 <input> 实例(连同它内部保存的输入文字),然后创建一个 全新的、状态为空的 <input> 实例并挂载到 DOM 上。

🤔 思考一下
请亲自尝试一下这个效果:

  1. 在输入框中随意输入一些文字。
  2. 点击“切换”按钮。
  3. 观察输入框,你会发现里面的文字消失了!这正是因为 key 的改变导致了整个 <input> 组件的重置。

3.4.2. 实战场景:优雅地重置复杂表单

现在我们理解了原理,让我们回到一个更真实的痛点:

痛点背景: 你有一个用户资料编辑表单组件 (UserProfileForm),它接收一个 userId 作为 prop,内部有多个 useState。当 userId prop 变化时,我们期望整个表单被清空并重新加载新用户的数据。如果用 useEffect,代码会很繁琐:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 繁琐的 useEffect 方案
function UserProfileForm({ userId }) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
// ... 还有 5 个其他的 state

useEffect(() => {
// 每次 userId 变化,都需要手动重置所有 state
setName('');
setEmail('');
// ...
fetchUserData(userId).then(data => {
setName(data.name);
setEmail(data.email);
// ...
});
}, [userId]);
// ...
}

解决方案:用 key 声明式地重置
基于我们从 Switcher 中学到的知识,我们可以用 key 来极大地简化这个流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
function App() {
const [currentUserId, setCurrentUserId] = useState('user-1');

return (
<div>
<button onClick={() => setCurrentUserId('user-2')}>
切换到用户2
</button>
{/* 每次都需要组件内部的 useEffect 来处理重置 */}
<UserProfileForm userId={currentUserId} />
</div>
);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// UserProfileForm 组件现在可以非常纯净,
// 它不再需要复杂的 useEffect 来处理重置逻辑。
function UserProfileForm({ userId }) {
const [user, setUser] = useState(null);

// 只需要一个简单的 effect 来加载初始数据
useEffect(() => {
fetchUserData(userId).then(data => setUser(data));
}, [userId]); // 依赖 userId,但只在组件首次挂载时运行

// ... 表单的 JSX
}

function App() {
const [currentUserId, setCurrentUserId] = useState('user-1');

return (
<div>
<button onClick={() => setCurrentUserId('user-2')}>
切换到用户2
</button>
{/*
我们将 userId 同时作为 prop 和 key 传递进去。
当 currentUserId 变化时,key 随之变化,
React 会自动销毁旧的表单实例,创建一个全新的实例。
新实例挂载时,它内部的 useEffect 会自动运行一次,加载新用户数据。
*/}
<UserProfileForm key={currentUserId} userId={currentUserId} />
</div>
);
}

总结与最佳实践:
当一个组件的“身份”与其某个核心 prop(通常是 ID)深度绑定时,将这个 prop 同时用作组件的 key 是一种极其强大且优雅的模式。它将“当 prop 变化时重置组件”这个命令式的逻辑,转换为了“这个 prop 就是组件的身份”这种声明式的表达,让父组件完全掌握了子组件的生命周期,代码更简洁,意图也更清晰。