大家好,欢迎来到今天的讲座。
今天我们不谈那些花里胡哨的 Hooks 新特性,也不聊 Next.js 的部署姿势。我们要聊一个听起来像是个“强迫症诊所”的玩意儿——React 严格模式。
我知道,很多同学在开发中看到 <React.StrictMode> 这玩意儿,第一反应是:“这货是干嘛的?是不是嫌我代码写得太烂,特意来嘲讽我的?”
别急,别急。今天我们就把 React 严格模式像剥洋葱一样剥开,看看它那看似神经质的行为背后——也就是所谓的“双重挂载检测”——到底能给我们的工程化带来什么实实在在的价值。
我们要聊的核心问题是:为什么 React 要在开发环境下把你的组件“杀掉”再“复活”?这到底是恶作剧,还是为了救你的命?
好,让我们直接切入正题。
一、 严格模式:React 的“严厉老师”
首先,我们要纠正一个观念。React 严格模式(Strict Mode)不是一个错误检查器,它不像 ESLint 那样会直接指着你的鼻子说“这里有个未定义的变量”。
严格模式更像是一个严厉的体育老师。它不会因为你跑得慢就罚你跑圈,但它会要求你把动作做两遍。
它的主要作用是:
- 检测不安全的生命周期方法。
- 检测过时的 API 使用。
- 检测副作用(Side Effects)。
而我们要重点讲的,就是最后这一点。为了让你理解这个“副作用检测”,我们需要先搞清楚 React 到底在搞什么鬼。
二、 “双重挂载”:当你的组件被“凌迟”
在 React 的世界里,组件的生命周期通常是这样的:
- 挂载:组件进入 DOM,
useEffect执行。 - 更新:props 变了,组件重新渲染,
useEffect再次执行。 - 卸载:组件离开 DOM,
useEffect执行清理函数。
这听起来很顺理成章,对吧?就像你签了一纸婚约,领了证,然后过日子。
但是,React 严格模式在开发环境下,会强制把你的这个流程改成这样:
- 挂载:组件进入 DOM,
useEffect执行。 - 卸载:组件立刻被踢出 DOM,
useEffect的清理函数执行。 - 再次挂载:组件重新进入 DOM,
useEffect再次执行。 - 再次卸载:组件再次被踢出,清理函数再次执行。
等等,啥?
你可能会问:“这不是精神分裂吗?这就像你刚结婚,民政局刚给你盖章,下一秒离婚证就下来了,然后再领一次证?这日子还过不过了?”
这就是严格模式的“双重挂载”。它模拟了组件被意外销毁并重建的场景。它为什么要这么做?为了干什么?为了检测你的副作用是否具有“幂等性”。
三、 什么是副作用?为什么它是个坑?
在 React 中,渲染函数通常被认为是“纯函数”。输入 props,输出 JSX。它不应该有副作用。就像做数学题,输入数字,输出结果,不能一边算一边去厨房煮面。
但是,现实世界是复杂的。我们的组件往往需要做点“杂事”:
- 发送网络请求(
fetch、axios)。 - 订阅外部事件(
socket.on、setInterval)。 - 操作 DOM(
document.title、localStorage)。 - 调用第三方库。
这些就是副作用。它们发生在渲染之后,并且可能依赖于渲染的结果。
为什么 React 要管你这些杂事?因为 React 的核心哲学是可预测性。如果你在渲染过程中偷偷干了坏事,React 就很难追踪数据流,很难优化性能,更难调试。
四、 代码示例:当副作用遇到严格模式
为了证明严格模式的价值,我们来写点代码。
场景一:一个“单相思”的 API 请求
假设你写了一个 UserList 组件,你想在组件挂载的时候去获取用户数据。
import React, { useState, useEffect } from 'react';
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// 模拟 API 请求
console.log("开始请求用户数据...");
fetch('/api/users')
.then(res => res.json())
.then(data => {
setUsers(data);
setLoading(false);
});
}, []); // 依赖数组为空,只执行一次
if (loading) return <div>加载中...</div>;
return <ul>{users.map(user => <li key={user.id}>{user.name}</li>)}</ul>;
}
这段代码看起来很完美,对吧?组件挂载,请求发送,数据回来,渲染。
现在,我们在父组件里加上 <React.StrictMode>,然后运行。
发生了什么?
你会发现控制台输出了两行:
开始请求用户数据...
开始请求用户数据...
你的浏览器 Network 面板里,/api/users 请求了两次!
这很糟糕吗?
在开发环境下,这很烦人。但在生产环境下,如果你的 setUsers 是一个非幂等操作(比如增加计数器,或者更新数据库),那生产环境就会出大问题。你可能不小心给用户发了两封欢迎邮件。
严格模式的价值就在这里:它提前暴露了你的“懒惰”。
它强迫你思考:如果我的组件被卸载了,我正在进行的请求怎么办?我还需要发送第二次请求吗?
场景二:修正——学会说“再见”
为了解决这个问题,我们需要在 useEffect 里写清理函数。
useEffect(() => {
console.log("开始请求用户数据...");
const controller = new AbortController(); // 现代 Fetch API 的取消机制
const signal = controller.signal;
fetch('/api/users', { signal })
.then(res => res.json())
.then(data => {
if (!signal.aborted) { // 再次检查,防止在清理函数执行后数据才回来
setUsers(data);
setLoading(false);
}
})
.catch(err => {
if (err.name !== 'AbortError') console.error(err);
});
// 清理函数:组件卸载时调用
return () => {
console.log("组件卸载,取消请求");
controller.abort();
};
}, []);
现在,当 React 严格模式触发“双重挂载”时,流程是这样的:
- 组件挂载,发起请求 A。
- 组件卸载,触发清理函数,请求 A 被取消。
- 组件再次挂载,发起请求 B。
- 组件再次卸载,触发清理函数,请求 B 被取消。
结果: 请求被取消,没有数据被错误地 setState。控制台虽然会有两行日志,但网络请求是干净利落的。
这就是严格模式的工程价值:它通过模拟极端情况(瞬间销毁重建),强制你编写健壮的清理逻辑。
五、 定时器与内存泄漏:被遗忘的幽灵
除了网络请求,副作用中还有一个著名的坑:setTimeout 和 setInterval。
如果你在 useEffect 里启动了一个定时器,却忘了在组件卸载时 clearTimeout,那这个定时器就会变成一个幽灵,继续在后台运行,继续更新已经销毁的组件状态。这就是经典的内存泄漏。
严格模式对定时器的检测尤为“狠辣”。
function BadTimer() {
useEffect(() => {
const id = setInterval(() => {
console.log("Tick tock...");
}, 1000);
// 忘了写清理函数!
}, []);
return <div>我是个定时器</div>;
}
加上严格模式后,你会看到控制台疯狂输出:
Tick tock...
Tick tock...
Tick tock...
每隔 0.5 秒(因为卸载再挂载),定时器就会重新启动。而且,由于没有清理,旧的定时器还在跑,新的定时器还在跑。你的控制台会瞬间爆满,CPU 占用飙升。
如果这发生在生产环境,你的后台可能会被你的定时器刷屏。
通过严格模式,我们被迫养成一个好习惯:在 useEffect 的返回值里写清理逻辑。 这不仅是为了 React,更是为了你的代码卫生。
六、 工程价值深度解析:为什么这很重要?
讲了这么多技术细节,我们到底在图什么?为什么作为一个资深的工程师,我们要重视这个“双重挂载检测”?
1. 破除“侥幸心理”
很多初级开发者写代码,是靠运气和经验主义的。
“嗯,这个组件看起来不会销毁,我就不写清理函数了。”
“嗯,这个 API 请求很快,就算重复发一次也没事。”
React 严格模式就是那个打破你“侥幸心理”的锤子。它通过强制重复执行,让你直观地看到如果不写清理函数会发生什么。这种“视觉上的恐怖”能让你深刻记住:副作用必须清理。
2. 提升生产环境的稳定性
这是最核心的工程价值。
React 的设计哲学之一是“一致性”。开发环境模拟生产环境的行为。
如果严格模式能帮你发现并修复一个潜在的内存泄漏,那你就在生产环境中救了你的服务器。想象一下,一个复杂的后台管理系统,如果每个页面都泄漏一个 1MB 的定时器,运行一周后,内存占用直接爆表,系统卡死。
严格模式是你在上线前的最后一道防线。
3. 帮助你理解组件的生命周期
很多时候,我们对组件的理解是模糊的。我们只知道“渲染”,不知道“卸载”。
严格模式通过 useEffect 的清理函数调用,让你亲身体验了“卸载”这一刻。你会开始思考:
- 这个数据是在哪里产生的?
- 它会在什么时候失效?
- 如果组件突然销毁,我需要做什么来“收尾”?
这种思维模式的转变,是从“写脚本”到“写架构”的重要一步。
4. 优化代码结构
严格模式还能帮你发现那些不清晰的依赖关系。
如果你在 useEffect 里写了一些代码,但是没有把它们放在 useEffect 里,而是放在了组件的顶部,或者在条件语句里。严格模式虽然不能直接报错,但如果你在 useEffect 里用了未定义的变量,它会报错。
更重要的是,它迫使你思考:这个副作用到底应该依赖什么? 是空数组 [](只执行一次)?还是依赖某些 props?还是依赖 state?
这种对依赖关系的审视,能极大减少 Bug。
七、 进阶:严格模式下的“副作用陷阱”
让我们再深入一点。副作用不仅仅是 API 请求。有些副作用非常隐蔽,连严格模式都难以完全避免,这反而暴露了 React 设计的局限性。
场景:第三方库的副作用
假设你引入了一个旧的第三方库,这个库在初始化时会自动播放一段背景音乐。
useEffect(() => {
// 这个库初始化时自动播放音乐
oldLibrary.init({ autoPlay: true });
}, []);
在严格模式下,这个库会被初始化两次,音乐会播放两次。而且,当组件卸载时,这个库可能并没有提供清理函数来停止音乐。
这时候,你会非常抓狂。你可能会想:“React 严格模式真是个坑货,把我的音乐都搞乱了。”
这时候,你的工程价值体现出来了:你有能力去修改这个库,或者封装它。
你可以写一个 Wrapper 组件,在初始化前判断是否已经初始化过;或者在组件卸载时,强制调用库的销毁方法。这迫使你从“盲目集成”转变为“负责任的集成”。
场景:状态的不一致性
这是最棘手的情况。
假设你在 useEffect 里做了一个复杂的计算,这个计算依赖于 props,并且更新了 state。
function ComplexComponent({ data }) {
const [result, setResult] = useState(null);
useEffect(() => {
// 复杂计算
const res = heavyComputation(data);
setResult(res);
}, [data]); // 依赖 data
return <div>{result}</div>;
}
在严格模式下,data 不变,所以 useEffect 不会执行。但是,组件会先卸载再挂载。这会导致 result state 被重置为 null。
虽然这通常不会导致 Bug(因为渲染结果是一样的),但它破坏了 React 的“状态保持”特性。
这教会了我们什么?不要把逻辑放在 useEffect 里。 useEffect 是为了处理那些副作用,而不是为了处理业务逻辑。业务逻辑应该放在渲染函数里(如果依赖很少),或者放在 useReducer 里。
严格模式在这里充当了一个“反向导师”,告诉你:你的代码结构可能有问题。
八、 总结与实战建议
好了,讲了这么多,我们总结一下 React 严格模式的双重挂载检测到底给我们带来了什么工程价值。
- 它是你的“压力测试仪”:它模拟了组件的意外销毁和重建,检测你的清理函数是否健壮。
- 它是你的“Bug 捕手”:它能帮你发现内存泄漏、重复的 API 请求、未清理的定时器等隐形杀手。
- 它是你的“架构向导”:它强迫你区分渲染逻辑和副作用逻辑,让你写出更纯粹、更可预测的代码。
那么,作为资深工程师,我们应该怎么做?
- 拥抱它:在开发环境下,永远不要移除
<React.StrictMode>。把它当成你的护身符。 - 写好清理函数:这是基本功。记住
useEffect返回的函数就是你的“分手信”。 - 区分逻辑与副作用:尽量把纯逻辑放在渲染函数或自定义 Hook 中,把脏活累活(API、定时器)放在
useEffect并做好清理。 - 处理第三方库:对于不兼容严格模式的库,不要只是抱怨,要去封装它,解决它。
最后,我想说,React 严格模式虽然看起来有点“神经质”,有点“强迫症”,但它的初衷是好的。它试图在一个充满不确定性的开发环境中,给你提供一点点确定性。
代码不是写完就结束了,而是要维护、要演进、要健壮。而 React 严格模式,就是那个在背后默默帮你检查作业的严厉老师。虽然有时候会骂你,但最终,它会让你成为一个更优秀的程序员。
希望今天的讲座能让你对 React 严格模式有一个全新的认识。下次看到它触发双重挂载时,别慌,别骂,看看你的清理函数写对了没有。
谢谢大家。