副作用的双面人生:React 严格模式下的“双检”玄机
大家好,欢迎来到今天的代码“审讯室”。
我是你们的向导。今天我们不聊高深莫测的架构,也不聊那些让你头秃的并发模式。我们要聊一个藏在 React 皮肤底下,平时不显山不露水,但一旦开启就会让你瑟瑟发抖的“监工”——React Strict Mode(严格模式)。
你们大概都见过这个标签吧?就像个拿着手电筒的保安,站在你的组件树最顶层,冷冷地盯着你写下的每一行代码。
<React.StrictMode>
<App />
</React.StrictMode>
但你们真的懂他在干什么吗?为什么每次我开启这个模式,我的 setTimeout 就会像打了鸡血一样跑两遍?为什么我的 console.log 会莫名其妙地出现两行?难道 React 有了多重人格?
别慌,今天我们就来扒开 React 的裤衩(比喻),看看它在严格模式下是如何进行“双重渲染”的。这不仅仅是个 Bug,这是 React 为了让你写出更稳健代码而设下的重重陷阱。
第一部分:渲染,不是“死”,是“复生”
首先,我们要纠正一个概念。在 React 的世界里,render 函数被调用了两次,并不意味着组件“死”了一次,也不意味着它“挂了”。
在 React 的调度器里,有一个非常优雅的机制,叫做 “卸载与重新挂载”。
想象一下,你在玩一个角色扮演游戏。正常情况下,你进入游戏(挂载),开始打怪(渲染),最后退出游戏(卸载)。一切都很正常。
但在 React 严格模式(开发环境)下,剧情是这样的:
- 你进入游戏,开始打怪(第一次 Render)。
- 突然,系统提示“服务器连接断开”,你被踢出了游戏(第一次 Unmount)。
- 你重新连接服务器,再次进入游戏,开始打怪(第二次 Render)。
两次渲染之间,组件的状态被清空了,副作用被清理了,一切从零开始。这就像是一个完美的镜像世界。
React 为什么要这么干?这就像是一个强迫症医生,他在检查你的心脏时,会先按一下,再按一下,确保没有杂音。React 在开发环境下,通过“渲染 -> 卸载 -> 渲染”这个循环,来模拟一个极端的边界情况:如果你的代码在“重新挂载”时依然健壮,那它在生产环境里绝对无敌。
第二部分:什么是“非幂等”副作用?
现在,让我们进入核心议题:为什么双检逻辑能暴露非幂等副作用?
要理解这个,我们需要先搞清楚什么是“副作用”。
在数学和编程里,函数通常分为两类:
- 纯函数:输入一样,输出永远一样。比如
Math.add(2, 2),不管你调用多少次,结果永远是 4。它不关心外部世界,也不改变外部世界。 - 副作用函数:它做的事情不仅仅是计算,还可能涉及 DOM 操作、网络请求、定时器、修改全局变量等等。比如
fetch('/api'),它不仅返回数据,还改变了网络状态。
在 React 里,我们希望副作用是“可控”的。如果 useEffect 里的代码执行了两次,你应该怎么处理?
这里就引出了一个数学概念:幂等性。
- 幂等操作:执行一次和执行两次,结果是一样的。比如
fetch,如果两次请求同一个 URL,通常服务器会返回相同的数据(除非有缓存机制)。再比如Math.sqrt(4)。 - 非幂等操作:执行一次和执行两次,结果完全不同,甚至产生灾难。
React 严格模式就是那个拿着放大镜找“非幂等操作”的侦探。
第三部分:代码示例——那个会“双倍打击”的定时器
让我们来做一个实验。假设你写了一个 CountDown 组件,你想在组件挂载后开始倒数。
import React, { useState, useEffect } from 'react';
const BadTimer = () => {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('🚀 定时器启动了!');
const timer = setInterval(() => {
console.log('⏰ 倒计时:', count);
setCount(prev => prev + 1);
}, 1000);
// 清理函数:组件卸载时执行
return () => {
console.log('🧹 定时器被清理了!');
clearInterval(timer);
};
}, []); // 空依赖数组
return <div>Count: {count}</div>;
};
正常情况下,这个组件挂载,启动一个定时器,每秒打印一次。
在 React 严格模式下,发生了什么?
- 第一次渲染:
useEffect执行。你看到🚀 定时器启动了!。 - 卸载阶段:React 模拟组件卸载。你看到
🧹 定时器被清理了!。此时定时器被销毁,count变回 0。 - 第二次渲染:React 模拟组件重新挂载。你看到
🚀 定时器启动了!。
结果:
你的控制台里,你会看到两条 🚀,两条 🧹。
如果在这个组件里,你不仅打印日志,还做了更有破坏性的事情呢?比如,你把数据存到了 localStorage,或者向服务器发送了一个请求。
如果在严格模式下,你的 useEffect 没有写好清理函数,或者清理函数没生效:
- 第一次请求:服务器接收到了。
- 卸载:你以为请求取消了,其实没取消,或者服务器还在处理。
- 第二次请求:服务器又接收到了。
这就是非幂等副作用的恐怖之处:它会导致数据重复、内存泄漏、或者不可预知的业务逻辑混乱。
React 严格模式通过强制执行“卸载 -> 挂载”的循环,强行把那些“一次就够,不能再来一次”的操作暴露出来。它就像一个严厉的教导主任,在考试结束前提醒你:“喂,那道题你写了两次,虽然都对了,但你知道这意味着什么吗?意味着你考试的时候手抖了!”
第四部分:源码透视——Fiber 树的“生死轮回”
光说不练假把式。我们来看看 React 源码层面是怎么实现这个“双检”的。
在 React 的内部,有一个核心概念叫 Fiber。你可以把 Fiber 理解为 React 的“工作单元”。每一个组件、每一个 DOM 节点都是一个 Fiber 节点。
当我们开启严格模式时,React 的渲染调度器会变得“神经质”。
在 ReactStrictMode.js 中,并没有什么魔法咒语,它只是在开发环境下,对渲染逻辑做了一些手脚。
源码逻辑大致是这样的(伪代码示意):
function renderWithHooks() {
// 1. 准备阶段
const fiber = createFiberRoot();
// 2. 执行第一次渲染
updateFunctionComponent(fiber);
// 3. 【关键步骤】提交阶段
commitRoot(fiber);
// 4. 【关键步骤】卸载阶段
// React 这里会强制执行 cleanup
commitUnmount(fiber);
// 5. 执行第二次渲染
updateFunctionComponent(fiber);
// 6. 再次提交
commitRoot(fiber);
}
注意第 3 步和第 4 步。在开发环境下,React 会在渲染完成后,立即触发一次卸载。
这意味着什么?意味着你的组件的 useLayoutEffect 里的清理函数会被调用,useEffect 里的清理函数会被调用。然后,一切重置,再次渲染。
这种机制在源码中被称为 “双重调用”。它不仅仅是函数调用了两次,它是生命周期被完整地过了一遍。
如果你在 useLayoutEffect 里写了类似这样的代码:
useLayoutEffect(() => {
document.title = `Count: ${count}`;
return () => {
document.title = `Reset`;
};
}, [count]);
在严格模式下,你会看到标题先变成 Count: 0,然后变成 Reset,然后变成 Count: 0。这种视觉上的“闪烁”和状态重置,就是 React 在测试你的组件是否能承受这种剧烈的变动。
第五部分:事件监听器——双倍的快乐,双倍的麻烦
除了定时器,另一个经典的“非幂等”受害者是事件监听器。
很多新手(甚至老手)会犯一个错误:在 useEffect 里直接给 window 或 document 绑定事件,而不是在组件挂载时绑定,卸载时解绑。
function BadListener() {
useEffect(() => {
console.log('我在给窗户贴反光膜');
window.addEventListener('resize', handleResize);
}, []);
const handleResize = () => {
console.log('窗口大小变了!');
};
return <div>监听器组件</div>;
}
问题出在哪?
React 严格模式下,这个组件被卸载了,然后重新挂载。
- 挂载:
window上多了一个resize事件监听器。现在点击窗口,打印一次。 - 卸载:理论上应该移除监听器。但如果你的清理函数没写,或者写错了,
window上的监听器还在。 - 再次挂载:
window上又多了一个resize事件监听器。
后果:
现在 window 上有两个监听器。当你调整窗口大小时,handleResize 函数会被调用两次!
如果你在 handleResize 里更新了状态,React 会触发两次渲染。这会导致性能下降,甚至逻辑错误(比如计算出的尺寸是错的)。
React 严格模式通过这种“双检”,让你在还没上线前就发现这种“幽灵监听器”。它就像是在你耳边低语:“嘿,你有没有发现,调整一下浏览器窗口,日志怎么多了一行?是不是有人偷偷多贴了一张反光膜?”
第六部分:为什么生产环境不这么做?
这就好比你问教练:“为什么你在训练时让我跑两圈,但在决赛时不让我跑?”
因为性能。
在开发环境下,React 想要尽可能早地发现 Bug。牺牲一点性能(多跑两圈),换取代码的稳定性,是非常划算的买卖。
在生产环境下,React 会完全跳过 ReactStrictMode 的那些“神经质”操作。它只会执行一次渲染,一次挂载,不会去管你的清理函数有没有被调用。因为生产环境假设你的代码是经过严格测试的。
所以,如果你在生产环境上线后,发现某个按钮点击了两次,或者某个请求发了两次,不要立刻怪罪 React StrictMode。那可能是你的代码逻辑本身就有问题,只是平时没暴露出来。
第七部分:如何“过审”——编写副作用的艺术
既然 React 严格模式这么严,那我们怎么写代码才能通过它的“双检”呢?秘诀只有两个字:清理。
规则一:永远不要相信副作用会自动停止。
React 的 useEffect 返回的函数,就是你的“辞职信”。当组件卸载时,React 会强制阅读这封信,执行里面的清理逻辑。
function GoodTimer() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('🚀 定时器启动了!');
const timer = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
// 这里的 return 就是清理函数
return () => {
console.log('🧹 定时器被清理了!');
clearInterval(timer); // 必须手动停止!
};
}, []);
return <div>Count: {count}</div>;
}
在这个例子中,无论 React 是否触发卸载,clearInterval 都会被执行。这样,即使 React 模拟了双重渲染,你的定时器也不会变成双倍定时器。
规则二:清理函数本身要是幂等的。
有时候,清理函数内部也有副作用。比如,你在一个 useEffect 里启动了一个 WebSocket 连接。
useEffect(() => {
const ws = new WebSocket('ws://example.com');
return () => {
ws.close(); // 关闭连接
};
}, []);
在严格模式下,React 会执行清理函数两次吗?
答案是:不会。
React 有一个优化机制。如果它检测到组件正在卸载(第一次清理),它就不会再执行第二次清理(第二次挂载时的清理)。它认为你已经走人了,不需要再给你发辞职信了。
但是,为了保险起见,你的清理函数本身应该是幂等的。比如 ws.close() 调用两次通常不会报错(如果连接已经关闭的话)。但如果你在清理函数里写了 document.body.removeChild(element),而你第二次调用时元素已经被删了,那也没问题,removeChild 会优雅地处理这种情况。
第八部分:进阶案例——状态丢失与“假”清理
为了进一步加深理解,我们来看一个稍微复杂点的场景:状态重置与数据同步。
假设你有一个组件,它从 URL 参数中获取用户 ID,然后请求用户信息。
function UserProfile() {
const [user, setUser] = useState(null);
useEffect(() => {
console.log('正在请求用户数据...');
fetchUser().then(setUser);
return () => {
console.log('组件卸载,取消请求...');
// 这里你可能会想取消 fetch,但 fetch 不支持 AbortController
// 或者你可能忘了写 cancel logic
};
}, []);
return <div>{user?.name}</div>;
}
在 React 严格模式下:
- 挂载:发起请求 A,
user变为{name: 'Alice'}。 - 卸载:清理函数执行。请求 A 可能还在进行中(网络延迟),或者被标记为取消。
- 挂载:发起请求 B,
user变为{name: 'Bob'}。
问题来了:
如果请求 A 比请求 B 快(比如在本地模拟),请求 A 返回的数据会更新 user 状态吗?
答案是:不会。
因为 React 的调度机制。当请求 A 返回时,组件实际上处于“卸载后、挂载前”的中间状态。React 会丢弃这个状态更新,因为它认为组件已经不存在了。
这就是严格模式暴露的另一个深层问题:异步副作用与组件生命周期的不匹配。
如果你的清理函数没有正确处理“请求取消”,或者你的状态更新逻辑依赖于组件一直存在,那么在严格模式下,你会遇到非常诡异的数据不一致问题。
如何修复?
你需要使用 AbortController 来真正地取消请求。
useEffect(() => {
const controller = new AbortController();
fetchUser(controller.signal).then(setUser);
return () => {
// 在这里,真正地切断连接
controller.abort();
};
}, []);
这样,当组件卸载时,请求会被物理中断。当组件重新挂载时,会发起新的请求。严格模式通过这种方式,强迫你处理异步操作的取消逻辑。
第九部分:总结——为什么我们要感谢这个“双检”
好了,讲了这么多,我们来总结一下 React 严格模式下的“双检”逻辑到底好在哪里。
它不仅仅是一个简单的“渲染两次”。它是一个压力测试。
- 暴露非幂等副作用:它像照妖镜一样,让你看到那些隐形的定时器、监听器和未取消的网络请求。
- 验证清理函数:它强迫你写出健壮的
cleanup逻辑,确保组件在销毁时能“干干净净”地离开。 - 测试状态重置:它模拟了极端的状态变化,防止你在组件卸载后还在更新状态。
React 之父 Dan Abramov 曾经说过:“严格模式不是一个 Bug,它是一个特性。”
在生产环境中,React 不敢这么玩,因为它怕拖慢用户的加载速度。但在开发环境中,React 毫不犹豫地牺牲一点性能,来换取代码的纯净。
所以,当你看到控制台里疯狂输出两行日志,或者定时器被清除了又启动时,请不要生气。请拥抱这个“神经质”的 React。它是在告诉你:“嘿,哥们,你的代码有点脏,洗干净点再上线吧!”
记住,幂等性是后端设计的基石,而严格的清理是前端组件的尊严。 在 React 严格模式下,这两者都被检验得淋漓尽致。
下次再写 useEffect 的时候,记得问自己一个问题:“如果这个函数被调用了两次,我的程序会崩溃吗?”
如果答案是“不会”,恭喜你,你通过了 React 的双检测试。
附录:代码实战演练
为了彻底搞懂,我们来看一段终极代码。这段代码故意包含所有可能被双检逻辑抓到的错误。
import React, { useState, useEffect } from 'react';
const UltimateTrap = () => {
const [count, setCount] = useState(0);
const [logs, setLogs] = useState([]);
// 错误 1: 没有清理定时器
useEffect(() => {
const timer = setInterval(() => {
console.log('Tick');
setCount(c => c + 1);
}, 1000);
// 缺少 return () => clearInterval(timer);
}, []);
// 错误 2: 没有清理事件监听
useEffect(() => {
console.log('Add listener');
window.addEventListener('click', handleClick);
return () => {
console.log('Remove listener');
// 缺少 window.removeEventListener
};
}, []);
// 错误 3: 异步副作用没有取消机制
useEffect(() => {
console.log('Fetch data');
fetch('/api/data')
.then(res => res.json())
.then(data => setCount(data.id));
// 缺少 AbortController
}, []);
const handleClick = () => {
console.log('Clicked');
setCount(c => c + 1);
};
return (
<div>
<h1>Count: {count}</h1>
<div className="logs">
{logs.map((log, i) => <div key={i}>{log}</div>)}
</div>
</div>
);
};
在 React 严格模式下的行为预测:
- 控制台输出:
Add listener(挂载)Remove listener(卸载)Add listener(挂载)Tick(第一次定时器触发)Tick(第二次定时器触发)Fetch data(挂载)Fetch data(挂载)Clicked(点击窗口,触发两次,因为有两个监听器)Remove listener(卸载)Remove listener(卸载)Add listener(挂载)Tick(第三次触发…等等,第三次?是的,因为组件挂载了第三次)Fetch data(第三次)
后果:
- 性能:定时器会一直跑,直到页面关闭。
- 内存:事件监听器会叠加,内存占用增加。
- 逻辑:点击一次,
count增加两次。API 请求发了三次(虽然通常会被 React 合并,或者因为 AbortController 不存在而堆积)。
修复方案:
// 修复版
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
const handler = handleClick;
window.addEventListener('click', handler);
return () => window.removeEventListener('click', handler);
}, []);
useEffect(() => {
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(res => res.json())
.then(data => setCount(data.id));
return () => controller.abort();
}, []);
修复后的行为:
无论 React 怎么挂载卸载,clearInterval 和 removeEventListener 都会正确执行。AbortController 会切断旧的请求。组件始终处于一个干净、稳定的状态。
这就是 React 严格模式的魅力。它不只是一个工具,它更像是一个严师,用“双检”逻辑,帮你把代码里的隐患一个个揪出来。希望这篇讲座能让你在未来的开发中,对副作用多一份敬畏,对清理函数多一份执着。谢谢大家!