各位列位看官,晚上好!
我是你们的编程导游,今天我们不聊那些花里胡哨的 Hook 新特性,也不讲怎么把 Redux 弄成更臃肿的 Zustand。今天,我们要揭开 React 开发模式中最“可怕”、最“强迫症”、也是最让人爱恨交织的一个工具——Strict Mode(严格模式) 的面纱。
你们肯定都用过 <React.StrictMode>。就像你们生活中的某些严厉的教导主任一样,它总是站在你代码的角落里,冷冷地看着你,一旦你写了一行“渣男代码”,它就会狠狠地抽你一巴掌。
今天,我们要深入探讨这个“教导主任”是如何通过双重挂载的把戏,暴力撕开组件中非确定性副作用的遮羞布。
准备好了吗?让我们把时钟拨回 16.8 版本,开始这场关于“副作用”的侦探游戏。
第一幕:当 React 决定变成“强迫症”
首先,我们要搞清楚一个核心概念:Strict Mode 什么时候会双倍快乐?
只有两个时刻。
- 初次挂载。
- 组件更新时。
在生产环境(NODE_ENV === 'production')里,React 是个勤恳的社畜,来了活儿干一次就完事了,绝不回头。但在开发环境(development)里,React 变成了一只狂躁的蜜蜂,它不仅仅要干活,它要双重确认。
它的逻辑大概是这样的(伪代码):
// React 内部伪逻辑
function renderWithDoubleMount(Component) {
// 第一次:挂载
const [instance1] = mount(Component);
// 第二次:卸载
unmount(instance1);
// 第三次:再次挂载
const [instance2] = mount(Component);
// 第四次:再次卸载
unmount(instance2);
return instance2;
}
这听起来很疯狂,对吧?为什么 React 要这么折腾?它闲得慌吗?
当然不是。这是 React 团队为了模拟快速卸载和重新挂载的场景。想象一下,在单页应用(SPA)中,你在一个路由切换时,React 应用被卸载然后立即挂载。如果这中间发生了什么乱七八糟的事情,那你就得去生产环境找 Bug,那太晚了。
所以,Strict Mode 在开发模式下,模拟了两次挂载和两次卸载。
重点来了:Strict Mode 仅仅针对 useEffect、useLayoutEffect、useState、useReducer 以及组件函数本身。 它对 useMemo、useCallback 以及普通变量(除了引用本身)通常不会重新运行,或者说是“幽灵运行”。
第二幕:非确定性副作用的“自杀式袭击”
现在,我们要引入今天的反派角色:非确定性副作用。
什么是“非确定性”?简单说,就是“今天我做了 A,结果产生 B;如果明天我再做一次 A,结果可能变成了 C”。或者是“我依赖于 Date.now(),但两次执行的时间差导致逻辑崩塌”。
非确定性副作用是 React 的天敌。React 的核心哲学是“声明式”:你告诉它“想要什么”,它去计算怎么得到。如果你在副作用里写了一堆“过程式”的乱七八糟的逻辑,React 就会崩溃。
Strict Mode 的双倍挂载,就是一把尖刀,专门用来捅这些副作用。
案例一:未清理的定时器——“僵尸虫”
这是最经典、最常见、也是最容易被忽视的错误。
假设你写了一个计数器,你想每秒加一次。
function BadTimer() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log("定时器启动,ID:", Date.now());
// 啥也没干,忘了返回清理函数!
const id = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
}, []);
return <div>{count}</div>;
}
在生产环境,这个代码是能跑的。但在 Strict Mode 下,剧情是这样的:
- 第一次挂载:
setInterval启动,ID 是1001。 - 第一次卸载:React 调用清理函数(空的),定时器被取消,ID
1001消失。 - 第二次挂载:
setInterval再次启动,新的 ID 是1002。 - 第二次卸载:React 再次调用清理函数,ID
1002消失。
结果: 没有任何副作用,一切正常。
但是!现在我们把逻辑改一下,加一点副作用。
function BadTimer() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log("定时器启动,ID:", Date.now());
// 我们用这个 ID 去保存状态,以为它不会变
const id = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
// 假设我们想在控制台看到 ID 变化
setTimeout(() => console.log("1秒后,ID 变了没?", id), 1100);
}, []);
return <div>{count}</div>;
}
运行一下,你会看到控制台出现了两次输出。
为什么?因为在第二次挂载时,ID 变了。
这时候,如果你依赖这个 id 去做一些有状态的脏活(比如把它存到一个全局 Map 里),或者你依赖它在两次渲染之间保持一致性,你就会出大问题。因为 Strict Mode 强行让你体验了“挂载 -> 卸载 -> 挂载”的过程,如果你的副作用不能处理“被销毁后重启”的逻辑,它就会产生双重计数或者内存泄漏。
Strict Mode 的暴力之处在于:它逼迫你必须写 return () => clearInterval(id)。 如果你不写,它会在控制台给你报个“警告”,虽然不致命,但那是它在说:“嘿,哥们,你忘了清理了,下次你在生产环境把内存撑爆了别怪我。”
案例二:直接修改状态——“叛徒”
这大概是 React 开发者最容易犯的错之一。你以为你只是在修个变量,结果你其实是在对 React 的状态管理系统造反。
function BadMutation() {
const [items, setItems] = useState(["Apple", "Banana"]);
useEffect(() => {
// 嘿,看这里!我直接修改了数组!
// 在 JS 里这是合法的,但在 React 里这是“谋杀”
items.push("Cherry");
console.log(items);
}, [items]);
return <div>{items.length}</div>;
}
在生产环境,这看起来好像还行,只是打印了一遍。
但在 Strict Mode 下:
- Mount:
items是["Apple", "Banana"]。你 push 了"Cherry"。现在items变成了["Apple", "Banana", "Cherry"]。Effect 运行。打印。 - Unmount: React 尝试恢复到上一次的状态。它深拷贝了旧数组?不,它只是销毁了组件实例。原来的
items引用被丢弃。 - Mount: React 重新挂载组件。
items再次被重置为["Apple", "Banana"]。 - Effect 运行: 你再次 push
"Cherry"。
如果你在组件里,仅仅是为了“看一看”而修改了 items,这没问题。但在 Effect 里修改了它,你就破坏了 React 的“单一数据源”。
更可怕的是: 如果你在 useEffect 之外直接修改了 state(不是用 setCount),然后在 useEffect 里又依赖这个 state 去做判断,Strict Mode 的双检会瞬间让你晕头转向。
function Confused() {
const [count, setCount] = useState(0);
// 依赖项数组里写了 count
useEffect(() => {
console.log("Effect 运行了,当前 count:", count);
}, [count]);
// 在某个回调里直接改了 count
const handleClick = () => {
count = 1; // 直接赋值,没有调用 setCount
console.log("点击了按钮,count 变成了:", count);
};
return <button onClick={handleClick}>Click Me</button>;
}
在生产环境,这看起来没什么问题。
但在 Strict Mode 下:
- Mount:
count是 0。Effect 打印 0。点击按钮,count 变成 1(但没有触发 Effect,因为 React 不会因为直接赋值而重新渲染)。 - Unmount: 组件销毁。
- Mount:
count重置为 0。Effect 打印 0。 - Effect 运行: 打印 0。
你会发现,你明明点了一下按钮,count 变成了 1,但 Effect 只运行了两次 0。这种数据不同步的混乱,是 Strict Mode 帮你抓出来的。
第三幕:随机数与 ID 的噩梦
这是非确定性副作用的另一个重灾区:随机数。
你说:“我代码写得这么严谨,没有副作用,纯函数,纯净得像刚洗完澡的猫。”
React 会嘲笑你。
function RandomOrder() {
const [items, setItems] = useState([]);
useEffect(() => {
// 每次挂载,我都生成一串随机 ID
const newItem = {
id: Math.random().toString(36).substr(2, 9),
text: "Hello"
};
setItems([newItem]);
console.log("生成了一个随机 ID:", newItem.id);
}, []);
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.text}</li>
))}
</ul>
);
}
在生产环境,你打开页面,得到一个 ID abc123。
在 Strict Mode 下:
- Mount: 生成 ID
abc123。渲染。 - Unmount: 销毁。
- Mount: 生成新的 ID
def456。渲染。
如果你把这个 ID 传给了后端 API,或者存到了 LocalStorage,或者是依赖 ID 做一些唯一性判断,后果不堪设想。
Strict Mode 的暴力暴露: 它强制你在开发阶段意识到,Math.random() 是不稳定的。在严格的开发模式下,每次渲染都生成新的随机数,会让你的测试变得毫无意义(因为同样的测试步骤,结果却不同)。
同样的问题也发生在 Date.now()、crypto.randomUUID() 等时间或随机相关的 API 上。
第四幕:看不见的“幽灵”与 DOM 操作
除了上面那些显眼的 Bug,还有一些副作用非常隐蔽,比如 DOM 操作。
假设你在 useEffect 里,利用 document.getElementById 找到一个输入框,然后给它加点样式。
function BadDOM() {
const inputRef = useRef(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.style.border = "2px solid red";
console.log("DOM 被修改了");
}
}, []);
return <input ref={inputRef} type="text" />;
}
在生产环境:输入框是红色的。
在 Strict Mode 下:
- Mount:
inputRef找到了 DOM 节点。边框变红。 - Unmount: React 卸载组件。DOM 节点被从文档树中移除。
- Mount:
inputRef再次被渲染。inputRef.current是null。
为什么是 null?因为 React 的 Strict Mode 在开发模式下,会在卸载和重新挂载之间插入一个“重置屏幕”(flushControlled)的过程,这期间,DOM 节点处于一个暂时的不可用状态。
所以,在第二次挂载时,你的代码尝试去修改一个不存在的节点。虽然现代浏览器通常会忽略对 null 的样式修改,不会报错,但这依然是逻辑错误。
真正的重灾区是:
如果你在 Effect 里直接操作了 DOM(比如查找元素、修改属性),而 React 又销毁了组件,然后重新挂载。你下一次操作 DOM 时,找到的是新元素,但你可能还在操作旧的引用。
function ConfusedDOM() {
useEffect(() => {
const el = document.getElementById("my-btn");
if (el) {
el.onclick = () => console.log("Clicked");
}
}, []);
// 如果这里重新挂载了,el 会指向新元素,
// 但 onclick 还绑定在旧元素上!
return <button id="my-btn">Click</button>;
}
这在生产环境中可能不会发生,因为组件通常不会这么快被销毁。但在 Strict Mode 下,它让你在眨眼之间体验了从“绑定旧元素”到“绑定新元素”的过程,从而暴露出这种事件监听器泄漏的问题。
第五幕:为什么我们需要这种“暴力”?
你可能会问:“老铁,能不能关掉 Strict Mode?它太吵了,而且有时候会让我觉得很烦。”
不能,也不要试图关掉它。
为什么?
因为 React 的设计初衷是UI 是状态的函数。UI = f(State)。这意味着,只要 State 相同,UI 就必须完全相同。
Strict Mode 的双检逻辑,本质上是在强行执行一次“状态一致性检查”。
- 模拟卸载:它模拟了真实应用中可能出现的“路由切换”、“弹窗关闭再打开”等场景。如果你的代码在双检下能跑通,它在真实场景下通常也能跑通。
- 模拟清理:它强迫你思考:如果我立刻离开这个页面,我的副作用会留下什么垃圾?定时器还在跑吗?网络请求还在发吗?数据存进去了吗?
那些在 Strict Mode 下歇斯底里的 Bug,才是真正的癌症。
如果你在 Strict Mode 下看到报错,那是 React 在救你的命。生产环境里,这些 Bug 会变成内存泄漏、莫名其妙的 UI 不同步、或者难以复现的崩溃。
第六幕:如何驯服这只野兽(最佳实践)
面对 Strict Mode 的“双重暴击”,我们该怎么办?我们要学会拥抱它,而不是逃避它。
1. 坚决执行“清理即真理”
这是 React 官方最推荐的副作用模式。写 Effect 时,默认它会被运行多次,所以你必须准备好“清理”逻辑。
useEffect(() => {
const subscription = props.source.subscribe();
// 返回清理函数,相当于组件卸载时的“善后工作”
return () => {
subscription.unsubscribe();
};
}, [props.source]);
如果 Strict Mode 调用了两次,它会执行:
subscription.unsubscribe()subscription.unsubscribe()(再次清理,防御性编程)subscription = props.source.subscribe()(重新订阅)subscription.unsubscribe()(再次清理)
这样设计,无论它怎么折腾,你的数据流始终是干净的。
2. 保持 Effect 的“纯度”
Effect 里面不要放那些“非确定性”的代码。
- 禁止:
Math.random()、Date.now()(除非是为了记录日志)。 - 禁止:在 Effect 之外修改 State。
- 禁止:依赖渲染过程中生成的 ID。
- 推荐:使用
useId(React 18+) 来获取稳定的 ID,或者在 Effect 外部生成 ID。
// 好的做法
function GoodId() {
const id = useId(); // 这个 ID 是稳定的
// ...
}
3. 审视你的 Effect 依赖项
如果你在 Strict Mode 下发现 Effect 运行了两次,或者报错说 xxx is not defined,检查你的依赖数组。
useEffect(() => {
function handleClick() {
console.log("Clicked");
}
document.addEventListener("click", handleClick);
return () => {
document.removeEventListener("click", handleClick);
};
}, []); // 依赖数组是空的,意味着它只在挂载和卸载时运行一次。
如果你在依赖数组里写了 count,但函数体里没有用到 count,React 会警告你。这是为了防止你在 Effect 里写了一堆“老皇历”,导致每次渲染都重新绑定事件监听器。
4. 理解 useLayoutEffect 的特殊性
这是 React 里最容易混淆的。useLayoutEffect 的副作用在屏幕绘制之前同步运行。
在 Strict Mode 下,useLayoutEffect 的行为和 useEffect 完全一致:挂载 -> 挂载 -> 卸载 -> 卸载。
这意味着,如果你在 useLayoutEffect 里读取了 DOM 的位置并计算了样式,那么在第二次挂载时,你会读到一个空的 DOM,因为 React 在挂载前执行了一次 useLayoutEffect(此时 DOM 为空),然后才渲染 DOM,再执行第二次 useLayoutEffect。
useLayoutEffect(() => {
const el = document.getElementById('test');
// 第一次挂载时,el 是 null!
// React 会报错或者行为异常
console.log(el);
}, []);
教训: 如果你需要在 useLayoutEffect 里操作 DOM,请务必做好防御性编程(检查 null)。
第七幕:总结与展望
各位,我们今天一起探索了 React 严格模式背后的“黑暗森林”。
React 严格模式并不是为了让你写代码更慢,也不是为了增加你的工作量。它是 React 社区(大神们)为了防止我们写出那种“一旦在生产环境跑起来就全是坑”的代码,而设计的一套自动化测试框架。
它的双重挂载机制,就像是一个苛刻的监工,拿着两个锤子,逼着你:
- 清理你的垃圾(Unmount 逻辑)。
- 验证你的稳定性(Random ID 不再随机,Direct Mutation 不再有效)。
它暴力地暴露了组件中的非确定性副作用。这些副作用之所以危险,是因为它们依赖于“执行一次”的假设,而 React 的哲学是“声明式”,声明式本质上就是“结果导向”的,它不在乎中间过程执行了多少次,只在乎最终结果是否正确。
如果你能在 Strict Mode 下通过测试,那么恭喜你,你的代码已经通过了 React 的“地狱难度”洗礼。
下次当你看到控制台里那一长串红红绿绿的 Warning 时,不要感到愤怒,不要感到厌烦。你应该感到庆幸。因为那是 React 在对你说:“嘿,这行代码如果不清理,服务器内存会被你撑爆的。”
所以,善待你的 Strict Mode,尊重你的副作用,写好你的清理函数。让我们一起,写出更健壮、更纯粹的 React 应用!
现在,去检查你的代码吧,看看有没有那个没清理的 setInterval,正在你的代码里“游荡”呢!