React useEffect 详解:从执行时机、依赖数组到副作用清理
在 React 函数组件中,我们经常会遇到这样的需求:
- 组件显示出来以后,再异步加载数据;
- 某个状态变化以后,把最新数据保存到浏览器;
- 组件进入页面后启动定时器,离开页面时关闭定时器;
- 观察组件何时挂载、何时更新、何时卸载。
这些事情都不是在"计算这次界面应该长什么样",而是在渲染之外附带完成的工作,也就是常说的副作用 。useEffect 正是用来处理这类工作的 Hook。
本文只围绕 useEffect 展开,通过几个连续的例子,讲清楚它的执行时机、依赖数组和清理函数。
一、什么是副作用
先看一段最简单的组件代码:
tsx
import { useEffect, useState } from "react";
function App() {
const [status, setStatus] = useState<"loading" | "ready" | null>(null);
useEffect(() => {
console.log("组件挂载完成");
setTimeout(() => {
setStatus("ready");
}, 2000);
}, []);
console.log("组件函数执行,准备渲染");
return <div>当前状态:{status}</div>;
}
export default App;
这段代码做了两类不同的事情:
- 组件函数根据当前的
status计算并返回 JSX; - 页面完成本次渲染以后,
useEffect启动一个定时任务,2 秒后修改状态。
第二类工作就是副作用。它不是本次 JSX 计算的一部分,却会在渲染完成以后产生后续影响。
将日志放在组件函数和 Effect 中,可以直观看到它们的先后关系:
text
组件函数执行,准备渲染
组件挂载完成
也就是说,组件函数会先执行并返回界面,Effect 随后再执行。它适合"组件先尽快显示,再附带完成其他工作"的场景。
二、为什么异步加载适合放进 useEffect
假设用户名需要通过异步操作加载,可以这样写:
tsx
import * as React from "react";
const App = () => {
const [name, setName] = React.useState<string>("defaultUserName");
const [editingName, setEditingName] =
React.useState<string>("defaultUserName");
const loadUsername = () => {
setTimeout(() => {
setName("name from async call");
setEditingName("name from async call");
}, 3000);
};
React.useEffect(() => {
loadUsername();
}, []);
return (
<>
<p>名字:{name}</p>
<p>正在编辑:{editingName}</p>
</>
);
};
export default App;
整个过程可以拆成两步。
第一步:先使用初始状态完成渲染
组件第一次执行时,两个状态都是:
text
defaultUserName
React 不需要等待异步操作结束,就能先把组件显示出来。
第二步:挂载后开始异步加载
本次渲染完成以后,useEffect 调用 loadUsername。3 秒后,定时器中的代码更新两个状态:
tsx
setName("name from async call");
setEditingName("name from async call");
状态更新会触发组件重新执行,于是界面显示异步得到的新名字。
这里最重要的不是 setTimeout 本身,而是 useEffect 带来的执行顺序:
text
使用初始状态渲染组件
↓
组件挂载后执行 Effect
↓
异步操作结束并更新状态
↓
组件使用新状态重新渲染
因此,"挂载后请求数据,再通过状态更新界面"是 useEffect 的典型使用方式。
三、useEffect 的基本结构
useEffect 最常见的结构如下:
tsx
useEffect(() => {
// Effect 要执行的副作用
return () => {
// 可选:清理副作用
};
}, [依赖项]);
这里包含三个部分:
- 第一个参数是 Effect 函数,用来执行副作用;
- Effect 函数可以返回一个清理函数;
- 第二个参数是依赖数组,它决定 Effect 需要在哪些更新之后重新执行。
真正决定执行频率的关键,就是第二个参数。
四、依赖数组的三种写法
下面用 count 和 todos 两个状态,分别观察三种写法。
jsx
const [count, setCount] = useState(0);
const [todos, setTodos] = useState([]);
1. 空依赖数组:[]
jsx
useEffect(() => {
console.log("组件挂载后执行");
}, []);
空数组表示这个 Effect 不依赖组件中的任何响应式状态。因此,它用于只需要在组件挂载后启动一次的工作,例如开始一次初始数据加载:
tsx
useEffect(() => {
loadUsername();
}, []);
需要注意,[] 表达的是"没有需要跟踪的依赖项",不是"Effect 完全脱离组件生命周期"。组件卸载时,如果 Effect 返回了清理函数,清理函数仍然会执行。
2. 指定依赖项:[count]
jsx
useEffect(() => {
console.log("挂载后执行");
console.log("count 改变后也会执行");
}, [count]);
这个 Effect 会在以下时机执行:
- 组件挂载后的第一次渲染完成时;
count相比上一次发生变化并完成渲染后。
例如点击按钮修改 count:
jsx
const countBy = () => {
setCount(count + 1);
};
return (
<button onClick={countBy}>
count++:{count}
</button>
);
每次 count 改变,组件都会重新渲染,依赖 count 的 Effect 随后重新执行。
同理,如果副作用与 Todo 列表有关,就应该依赖 todos:
jsx
useEffect(() => {
console.log("挂载后执行");
console.log("todos 更新后执行");
}, [todos]);
count 的变化不会让这个 Effect 因依赖变化而重新执行;todos 的变化才是它关心的更新。
依赖数组可以理解为一句声明:
这个副作用使用并跟随哪些状态。
3. 不传第二个参数
jsx
useEffect(() => {
console.log("每次渲染完成后都执行");
});
没有依赖数组时,Effect 会在组件挂载及后续每次渲染完成后执行。
例如组件中既有 count,又有 todos,修改其中任何一个状态都会让组件重新渲染,这个 Effect 也会跟着执行。
三种写法可以放在一起对比:
| 写法 | 挂载后 | 后续更新后 |
|---|---|---|
useEffect(fn, []) |
执行 | 不因状态更新而重新执行 |
useEffect(fn, [count]) |
执行 | count 改变后执行 |
useEffect(fn) |
执行 | 每次渲染完成后都执行 |
五、实战:todos 变化后保存到 localStorage
Todo 列表不仅要在页面中展示,还希望刷新页面以后仍能恢复。可以用 localStorage 保存它。
先读取已经保存的数据,作为 todos 的初始值:
jsx
const [todos, setTodos] = useState(() => {
return JSON.parse(localStorage.getItem("todos")) || [];
});
接着,用 useEffect 监听 todos:
jsx
useEffect(() => {
localStorage.setItem("todos", JSON.stringify(todos));
}, [todos]);
这段 Effect 的执行过程是:
- 组件挂载并完成第一次渲染;
- Effect 将当前
todos写入localStorage; - 添加、切换、删除或者清理 Todo 时,
todos发生变化; - 组件使用新列表重新渲染;
- Effect 再把新列表保存到
localStorage。
例如添加 Todo 时,会生成一个全新的数组:
jsx
const addTodo = (text) => {
if (text.trim() === "") return;
setTodos([
{
id: +Date.now(),
text,
completed: false,
},
...todos,
]);
};
setTodos 触发更新,随后 [todos] 对应的 Effect 执行,完成持久化。
为什么要使用 JSON.stringify
localStorage 按字符串形式保存键和值,而 todos 是一个对象数组,所以写入前要转换成 JSON 字符串:
jsx
localStorage.setItem("todos", JSON.stringify(todos));
读取时再用 JSON.parse 还原:
jsx
JSON.parse(localStorage.getItem("todos"))
这里可以看到 useEffect 的职责非常明确:列表的增删改负责产生新状态,而"状态改变后顺便保存一下"交给 Effect。副作用与它所依赖的数据通过 [todos] 对应起来。
六、清理函数:组件卸载前收尾
副作用有时会持续占用资源。最直观的例子是定时器:
jsx
const Demo = () => {
useEffect(() => {
const interval = setInterval(() => {
console.log("interval is here");
}, 1000);
return () => {
console.log("组件卸载前执行,进行清理");
clearInterval(interval);
};
}, []);
return <div>demo</div>;
};
Effect 在 Demo 挂载后创建定时器,每隔 1 秒打印一次日志。Effect 返回的函数则负责清理:
jsx
return () => {
clearInterval(interval);
};
注意,这里返回的是一个函数,而不是直接执行 clearInterval。React 会在需要清理这个 Effect 时调用它。
用条件渲染观察组件卸载
父组件可以根据 count 的奇偶性决定是否显示 Demo:
jsx
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>count++</button>
{count % 2 === 0 && <Demo />}
</div>
);
当 count 为偶数时,Demo 存在,定时器不断执行;当 count 变成奇数时,条件不成立,Demo 从组件树中卸载,清理函数随之执行并关闭定时器。
执行顺序可以概括为:
text
Demo 挂载
↓
Effect 创建 interval
↓
interval 每秒执行
↓
Demo 卸载
↓
清理函数调用 clearInterval
如果组件已经卸载,定时器却仍在运行,它所占用的资源就不能及时释放,也可能继续执行已经没有意义的工作。为持续性的副作用提供对应的清理逻辑,是 useEffect 非常重要的一部分。
七、把执行、重新执行和清理连起来理解
到这里,可以从组件生命周期的角度总结 useEffect:
挂载后
组件第一次渲染完成后执行 Effect。
jsx
useEffect(() => {
console.log("mounted");
}, []);
更新后
组件重新渲染完成后,React 根据依赖数组判断是否需要再次执行 Effect。
jsx
useEffect(() => {
console.log("todos updated");
}, [todos]);
卸载时
如果 Effect 返回清理函数,组件卸载时会执行清理。
jsx
useEffect(() => {
const interval = setInterval(() => {
console.log("working");
}, 1000);
return () => {
clearInterval(interval);
};
}, []);
因此,useEffect 并不只是"组件挂载时执行一个函数"。更完整的理解应该是:
在渲染完成后执行副作用,根据依赖决定是否重新执行,并在副作用结束前完成必要的清理。
八、一个完整示例
下面把本文涉及的几种用法组合到一个 Todo 应用中:
jsx
import { useEffect, useState } from "react";
const Demo = () => {
useEffect(() => {
const interval = setInterval(() => {
console.log("interval is here");
}, 1000);
return () => {
console.log("组件卸载前执行,清理定时器");
clearInterval(interval);
};
}, []);
return <div>demo</div>;
};
const App = () => {
const [count, setCount] = useState(0);
const [todos, setTodos] = useState(() => {
return JSON.parse(localStorage.getItem("todos")) || [];
});
const addTodo = (text) => {
if (text.trim() === "") return;
setTodos([
{
id: +Date.now(),
text,
completed: false,
},
...todos,
]);
};
useEffect(() => {
console.log("count 挂载或更新后执行");
}, [count]);
useEffect(() => {
console.log("每次渲染完成后执行");
});
useEffect(() => {
localStorage.setItem("todos", JSON.stringify(todos));
}, [todos]);
return (
<div>
<p>Count:{count}</p>
<button onClick={() => setCount(count + 1)}>count++</button>
{count % 2 === 0 && <Demo />}
<button onClick={() => addTodo("学习 useEffect")}>
添加 Todo
</button>
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</div>
);
};
export default App;
这个例子里同时存在四类 Effect 行为:
Demo挂载后创建定时器,卸载时清理定时器;[count]只跟随count的变化;- 不传依赖数组的 Effect 在每次渲染后执行;
[todos]在列表变化后把数据写入localStorage。
只要先判断副作用与哪些状态有关,再选择正确的依赖数组,代码的执行时机就会变得清晰。
九、总结
学习 useEffect,可以牢牢记住下面四点:
useEffect用来处理渲染之外的副作用,例如异步加载、本地存储和定时器。- Effect 在渲染完成后执行,所以组件可以先展示,再通过异步结果更新状态。
- 依赖数组决定 Effect 的执行频率:
[]用于挂载后的初始化工作,[state]跟随指定状态,不传则每次渲染后都执行。 - 持续占用资源的副作用需要返回清理函数,例如使用
clearInterval移除定时器。
最后,可以用一句话串起它的完整逻辑:
组件先根据状态完成渲染,
useEffect随后执行副作用;依赖变化时重新执行,组件卸载时完成清理。