🚀 从生命周期到 useEffect:一次讲透 React 函数组件的"副作用哲学"
在 Hooks 时代,我们看似告别了
componentDidMount、componentDidUpdate、componentWillUnmount这些又长又拗口的名字,但 useEffect 真的只是它们的语法糖吗?这篇文章从一个真实的用户名编辑组件出发,带你重新理解"副作用"这两个字在 React 心智模型中的分量。
一、开篇:一个看似简单的需求
最近在做一个 React + TypeScript 的练手项目,需求非常朴素:
- 进入页面后,异步请求拿到用户名,展示一句
Hello, xxx。 - 提供一个输入框 + 按钮,允许用户修改名字并提交。
需求很简单,但它牵出了 React 函数组件里最容易被忽视、也最容易踩坑的两个概念:副作用和生命周期。
我们先看一下项目里 [App.tsx](file:///c:\Users\hu shu\Desktop\WROKSPACE-remote\fe\react\111\ts-demo\src\App.tsx) 的核心片段:
const App: React.FC = () => {
const [name, setName] = useState<string>("aaa");
const [editingName, setEditingName] = useState("defaultUserName");
const loadUserName = () => {
setTimeout(() => {
setName("name from async call");
setEditingName("editing name from async call");
}, 2000);
};
// 副作用
useEffect(() => {
// 组件挂载之后
// 组件第一要素是赶快显示出来,让用户觉得很快
loadUserName();
}, []);
// ...
};
注释里那句"组件第一要素是赶快显示出来"非常关键,它揭示了 useEffect 的设计哲学。我们慢慢展开。
二、什么是"副作用"?为什么要单独拎出来?
2.1 纯函数与 UI = f(props)
React 的核心信条之一是:
UI = f(props)
也就是说,组件应该像一个纯函数:给相同的 props,就渲染相同的 UI。这种"纯"让 React 可以放心地做 diff、做并发渲染、做时间旅行调试。
但真实业务里,组件不可能只做"算 UI"这一件事:
- 发起 HTTP 请求拿数据
- 订阅 WebSocket / EventBus
- 操作 DOM(测量宽高、聚焦输入框)
- 启动定时器、轮询
- 读写 localStorage
这些东西不直接参与本次渲染,却会"间接"地影响后续渲染(比如请求回来数据后 setState)。在函数式编程里,它们统称为 Side Effect(副作用)。
React 的态度很明确:渲染阶段必须纯净,副作用必须被隔离。所以 useEffect 应运而生——它是一个"逃生舱",专门把副作用推到渲染流程之外去执行。
2.2 为什么不能直接写在函数体里?
很多初学者会写:
const App: React.FC = () => {
const [name, setName] = useState("aaa");
// ❌ 错误:直接在函数体里发起异步
fetch("/api/user").then(r => r.json()).then(d => setName(d.name));
return <Hello username={name} />;
};
问题在于,函数组件每次渲染都会完整执行一遍函数体。你点个按钮触发了 setState,父组件传了新 props,父组件自己 setState 了……这些都会让组件重新执行,结果就是上面的 fetch 被疯狂触发,接口被打爆,还可能出现"请求回来后 setState 在已卸载组件上"的经典警告。
useEffect 的第一个承诺就是:保证你的副作用只在"渲染完成后"才跑一次(具体跑几次还看依赖数组,下面细讲)。
三、回到生命周期的视角
3.1 类组件的三大生命周期
在类组件时代,我们是这样组织副作用的:
| 阶段 | 钩子 | 典型用途 |
|---|---|---|
| 挂载后 | componentDidMount | 请求数据、订阅、启动定时器 |
| 更新后 | componentDidUpdate | 根据 props 变化发起新请求 |
| 卸载前 | componentWillUnmount | 清除定时器、取消订阅、abort 请求 |
类组件的一个痛点是:同一段逻辑被强行劈成三段。比如"订阅 + 取消订阅"本来是一对,却被写到了 DidMount 和 WillUnmount 两个方法里;DidUpdate 里还要手写 prevProps !== this.props 的比较,代码又长又容易漏。
3.2 useEffect 如何"合并"这三个钩子
useEffect 的签名非常简洁:
useEffect(setup: () => Teardown, deps?: DependencyList): void;
它用一个函数 + 一个依赖数组,把"挂载、更新、卸载"三件事统一表达:
- 挂载后:组件第一次渲染完成,执行
setup。 - 更新后:如果
deps中的某个值变了,先执行上一轮setup返回的清理函数,再执行新的setup。 - 卸载前:执行最后一次
setup返回的清理函数。
项目里的写法是典型的"只在挂载时执行一次":
useEffect(() => {
loadUserName();
}, []); // 空依赖数组 => 只在 mount 时跑
它等价于类组件的:
componentDidMount() {
this.loadUserName();
}
而"挂载时启动、卸载时清理"的经典模式则长这样:
useEffect(() => {
const timer = setInterval(() => console.log("tick"), 1000);
return () => clearInterval(timer); // cleanup
}, []);
这个 return 出去的清理函数,就是 componentWillUnmount 的等价物。
四、依赖数组:useEffect 的"灵魂开关"
useEffect 一共就三种用法,理解了依赖数组,你就掌握了 80% 的 useEffect。
4.1 三种形态
// 形态 A:不传依赖数组 —— 每次渲染后都执行
useEffect(() => {
console.log("render done");
});
// 形态 B:传空数组 —— 只在挂载后执行一次
useEffect(() => {
loadUserName();
}, []);
// 形态 C:传依赖数组 —— 挂载后执行 + 依赖变化时执行
useEffect(() => {
document.title = `Hello, ${name}`;
}, [name]);
| 形态 | 等价生命周期 | 触发时机 |
|---|---|---|
| 不传数组 | DidMount + 每次 DidUpdate | 每次渲染后 |
[] | 仅 DidMount | 仅挂载后 |
[a, b] | DidMount + "当 a 或 b 变化时"的 DidUpdate | 挂载后 + 依赖变化后 |
4.2 闭包陷阱:为什么"少写依赖"是个坑
来看一个看似合理但会埋雷的写法:
useEffect(() => {
const timer = setInterval(() => {
console.log(name); // ❌ 永远是初始值 "aaa"
}, 1000);
return () => clearInterval(timer);
}, []); // 空数组,闭包锁死了第一次渲染时的 name
这里的 name 是闭包捕获的"第一次渲染时的快照",永远是 "aaa",哪怕你后面 setName 了一百次也感知不到。这就是臭名昭著的 Stale Closure(闭包陈旧值) 问题。
解法有两个方向:
解法一:把依赖写全
useEffect(() => {
const timer = setInterval(() => {
console.log(name);
}, 1000);
return () => clearInterval(timer);
}, [name]); // name 变了就重启定时器
缺点:每次 name 变化都会清掉旧定时器、新建一个,频繁时性能不友好。
解法二:用 ref 持有最新值
const nameRef = useRef(name);
useEffect(() => {
nameRef.current = name; // 每次渲染同步最新值
}, [name]);
useEffect(() => {
const timer = setInterval(() => {
console.log(nameRef.current); // ✅ 始终是最新的
}, 1000);
return () => clearInterval(timer);
}, []);
经验法则:依赖数组该写什么,就写什么。能借力 eslint-plugin-react-hooks 的 exhaustive-deps 规则就开起来,它会帮你揪出缺失的依赖。
五、清理函数:被低估的"另一半"
很多人写 useEffect 只关心 setup,却忘了 cleanup。但在真实项目里,cleanup 往往比 setup 更重要——它直接决定了你会不会内存泄漏。
5.1 cleanup 的执行时机
cleanup 不是"只在卸载时执行",它执行得更频繁:
- 每次 setup 重新执行前(当依赖变化触发重新执行)
- 组件卸载时
也就是说,假设依赖是 [id],id 变了 3 次,组件最终卸载,cleanup 一共会执行 4 次:3 次是"上一次的清理",1 次是"卸载时的最终清理"。
这个设计非常优雅:保证每次 setup 都跑在一个干净的环境里。
5.2 典型场景:取消未完成的请求
useEffect(() => {
let cancelled = false;
fetch(`/api/user/${id}`)
.then(r => r.json())
.then(data => {
if (!cancelled) setUser(data); // ✅ 只在还活跃时更新
});
return () => { cancelled = true; }; // 切换 id 时忽略旧请求
}, [id]);
这种 cancelled 标志位是最朴素的"防竞态"写法。更现代的做法是用 AbortController:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/user/${id}`, { signal: controller.signal })
.then(r => r.json())
.then(setUser)
.catch(err => {
if (err.name !== "AbortError") throw err;
});
return () => controller.abort(); // 真正取消网络请求
}, [id]);
区别在于:标志位只是"忽略结果",请求还在跑;AbortController 是真正把请求掐断,更省带宽。
六、回到项目:状态提升带来的 useEffect 体验
项目里有一个非常精彩的设计演进,值得单独拎出来讲。它经历了三个版本。
版本一:子组件直接把 event 传给父组件
// 父组件
<NameEditComponent
username={username}
onChange={(e) => setUserName(e.target.value)} // 父组件处理事件
/>
问题:父组件被迫处理 React.ChangeEvent<HTMLInputElement>,关注点被污染了。父组件的使命应该是"持有状态 + 提供修改方法",不应该关心输入框的 DOM 细节。
版本二:子组件拥有私有状态
// NameEditComponent.tsx
const [editingName, setEditingName] = useState(props.initialUserName);
const onNameSubmit = () => {
props.onNameUpdated(editingName); // 只把"值"交给父组件
};
父组件变得清爽,但子组件有了私有状态,props.initialUserName 后续变化时,子组件不会响应(因为 useState 初始值只在挂载时取一次)。
版本三:状态完全提升,子组件纯展示
// App.tsx
const [editingName, setEditingName] = useState("defaultUserName");
<NameEditingComponent
editingName={editingName}
onNameUpdated={setUserNameState}
onEditingNameUpdated={setEditingName}
disabled={editingName === '' || editingName === name}
/>
// NameEditingComponent.tsx —— 子组件零状态
const onChange = (e) => onEditingNameUpdated(e.target.value);
这一刻,子组件真正变成了 UI = f(props),纯展示、可复用、易测试。而异步加载用户名这件事,被干净利落地留在了 App 的 useEffect 里:
useEffect(() => {
loadUserName(); // 一次性把 name 和 editingName 都填上
}, []);
因为状态都在父组件,useEffect 只需要在一个地方发请求、一次性更新两个状态,子组件自动响应。这就是**"状态提升 + useEffect 集中管理副作用"**带来的整洁——它不是 React 的语法糖,而是架构层面的胜利。
七、StrictMode 的"双重执行"陷阱
[main.tsx](file:///c:\Users\hu shu\Desktop\WROKSPACE-remote\fe\react\111\ts-demo\src\main.tsx) 里用了 <StrictMode>:
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
在开发环境下,StrictMode 会故意把 setup 执行两次(mount → unmount → mount),用来帮你发现副作用是否写得不纯净、是否漏了 cleanup。
很多人第一次遇到会慌:"我的接口怎么被请求了两次?" 别急,这只是开发期的行为,生产构建里不会出现。它的潜台词是:你的 useEffect 必须写成"可重复执行而不出错"的形态。
对照检查清单:
- ✅ setup 里启动了定时器 / 订阅 → cleanup 里有没有清掉?
- ✅ setup 里发起请求 → cleanup 里有没有 abort 或置 cancelled?
- ✅ setup 里改了全局状态(比如 document.title)→ cleanup 里有没有还原?
只要这三条满足,StrictMode 的双重执行对你就是透明的。
八、useEffect 的常见反模式
最后总结几个高频踩坑点,自检一下:
❌ 反模式 1:把 useEffect 当 watch 用
useEffect(() => {
if (name !== "") {
fetch(`/api/search?q=${name}`);
}
}, [name]);
听起来很合理,但这其实是"派生状态触发副作用"。如果 name 还能被别的逻辑改,链路会越来越乱。先问自己:这个计算能不能放在事件处理函数里?能就别放 useEffect。
❌ 反模式 2:在 useEffect 里同步更新 state
const [a, setA] = useState(1);
const [b, setB] = useState(2);
useEffect(() => {
setB(a * 2); // 多余的一次渲染
}, [a]);
如果 b 完全由 a 推导,直接 const b = a * 2 即可,不需要 state,也不需要 useEffect。这就是 React 官方反复强调的"优先用渲染期计算,而不是 effect 同步"。
❌ 反模式 3:依赖数组里塞对象/函数
useEffect(() => {
doSomething(options);
}, [options]); // options 每次渲染都是新对象引用 => 等于没写依赖
对象和函数每次渲染都是新引用,会让 effect 每次都跑。解法:要么把 options 用 useMemo 包起来,要么把依赖收敛到 primitive 字段(options.id 这种)。
✅ 正确姿势:effect 应该"自洽"
一个写得好的 effect,应该满足:
- 依赖数组完整(
eslint exhaustive-deps不报错)。 - setup 有对应的 cleanup。
- 多次执行不会产生副作用累积。
- 不在 effect 里做"本可以放在事件处理器里"的事。
九、收尾:useEffect 不是生命周期,而是一种"同步"
很多人把 useEffect 理解成"函数组件版的生命周期",这只对了一半。更准确的心智模型是:
useEffect 是把你组件里的某些东西(state),和组件外的某些东西(数据源、订阅、DOM、网络)做"同步"。
依赖数组就是"同步的触发条件"——当这些外部/内部依赖变了,就重新同步一次。
带着这个视角再看项目里的 loadUserName:
useEffect(() => {
loadUserName();
}, []);
它表达的是:组件挂载这件事发生后,我要去把外部的用户数据同步到本地 state。空数组表示"只同步一次,后续不关心"。一旦你把依赖写进去,意思就变成了"这些值一变,我就要重新同步"。
记住这句话,你会发现 React 文档里那些"don't use effects for derived state"的劝告,全都在说同一件事:能算的不用同步,能放事件里的不放 effect 里。useEffect 是逃生舱,不是万能锤。
📚 延伸阅读 & 参考资料
- React 官方文档:You Might Not Need an Effect
- React 官方文档:Using the Effect Hook
- Dan Abramov: A Complete Guide to useEffect
- 项目源码:[ts-demo/src/App.tsx](file:///c:\Users\hu shu\Desktop\WROKSPACE-remote\fe\react\111\ts-demo\src\App.tsx)
写到这里,我对 useEffect 的理解从"生命周期平替"升级成了"声明式同步"。希望这篇笔记能帮你少踩几个坑,也欢迎在评论区交流你的 useEffect 心得 🚀