React 严格模式:你的代码的严厉教练与“精神分裂”测试
大家好,欢迎来到今天的讲座。我是你们的编程向导。今天我们要聊一个在 React 开发中听起来有点“神经质”,但实际上是救命稻草的功能——React Strict Mode(严格模式)。
大家可能都在 App.js 的最外层见过它:
import React, { StrictMode } from 'react';
function App() {
return (
<StrictMode>
<YourComponents />
</StrictMode>
);
}
你可能会想:“这玩意儿到底是干嘛的?是让我写代码的时候更小心吗?还是说 React 的开发者觉得自己不够严格,非要加个‘超级严格模式’来吓唬我?”
答案是:它确实有点吓人,但它更像是一个严厉的代码健身教练。它不仅要求你肌肉发达(代码健壮),还要求你时刻注意呼吸(副作用管理)。
今天,我们将深入骨髓,扒开 React 严格模式的双重挂载逻辑,看看它到底是如何像个强迫症患者一样,把你的组件拿出来,狠狠摔在地上,再捡起来,再摔一次的。
第一部分:为什么我们需要“精神分裂”测试?
在 React 的世界里,有两种函数:一种是纯函数,一种是副作用函数。
纯函数就像数学公式:输入 A,输出 B,中间绝不产生任何“副作用”(不修改外部变量,不打印日志,不调用 API)。纯函数很可爱,但纯函数做不出网站。
副作用函数,比如 useEffect、setTimeout、fetch,就像是你生活中的“麻烦事”。你订阅了一个新闻推送,这会消耗内存;你设置了一个定时器,这会占用 CPU;你修改了 DOM 的样式,这可能会影响其他组件。
React 的核心理念是“声明式”:你告诉 React 你想发生什么,React 负责去实现。但问题是,当 React 在后台疯狂地重新渲染组件、卸载组件、挂载组件时,那些副作用函数很容易被遗忘,或者被重复执行,从而导致内存泄漏、重复请求、控制台刷屏等一系列令人头秃的问题。
为了解决这个问题,React 严格模式登场了。它的核心任务就是:模拟一种极端的“精神分裂”环境,检测你的副作用管理能力。
第二部分:双重挂载的魔法——它是怎么工作的?
让我们来揭开它的面纱。严格模式在开发环境下,会对组件树执行以下操作:
- 第一次挂载:React 创建组件实例,执行
render函数。 - 卸载:React 销毁组件实例,执行所有
useEffect、useLayoutEffect的清理函数。 - 第二次挂载:React 重新创建组件实例,再次执行
render函数。
注意,这不仅仅是渲染两次。它还会卸载! 这意味着你的组件会被彻底销毁,然后再重新创建。
为了让你直观地感受到这种“精神分裂”,我们来写一段代码。
示例 1:基础渲染日志
import React, { useState, useEffect, useLayoutEffect } from 'react';
function StrictModeDemo() {
const [count, setCount] = useState(0);
// 我们在三个不同的生命周期钩子上都加上日志
useEffect(() => {
console.log('🔵 [useEffect] 组件已挂载 (第一次)');
return () => console.log('🟢 [useEffect] 组件即将卸载');
}, []);
useLayoutEffect(() => {
console.log('🟣 [useLayoutEffect] 布局已更新 (第一次)');
return () => console.log('🟡 [useLayoutEffect] 布局即将卸载');
}, []);
console.log('🔴 [Render] 函数正在执行 (第一次)');
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>增加</button>
</div>
);
}
当你把这个组件放入 <StrictMode> 中时,控制台的输出会是这样:
🔴 [Render] 函数正在执行 (第一次)
🟣 [useLayoutEffect] 布局已更新 (第一次)
🔵 [useEffect] 组件已挂载 (第一次)
🔴 [Render] 函数正在执行 (第二次)
🟣 [useLayoutEffect] 布局已更新 (第二次)
🟢 [useEffect] 组件即将卸载
🟡 [useLayoutEffect] 布局即将卸载
🟢 [useEffect] 组件即将卸载
🟡 [useLayoutEffect] 布局即将卸载
看到了吗?这就是双重挂载。React 先把你造出来,然后又把你拆了,最后再把你造出来。这就像是一个演员上台演戏,演完第一遍,导演喊“卡!重演!把刚才的戏词再念一遍,然后退场,再上台!”。
这种机制是为了检测什么?
- 重复订阅:如果你在
useEffect里订阅了某个服务,但没有在清理函数里取消订阅,那么在严格模式下,你会有两个订阅者同时存在! - 重复定时器:如果你创建了
setTimeout,清理函数里没有清除它,那么你会得到两个定时器在后台同时运行,两个定时器同时触发,疯狂地更新你的状态。 - 内存泄漏:如果你在
useEffect里保存了外部引用,而没有清理,那么组件卸载后,这个引用依然存在,指向一个已经销毁的组件实例,这叫内存泄漏。
第三部分:实战演练——那些年我们踩过的坑
光说不练假把式。让我们看看在实际开发中,严格模式是如何像侦探一样发现 Bug 的。
场景 1:忘记清理的定时器
这是一个经典的错误。新手经常觉得:“反正组件卸载了,定时器也没人看,就不管了。”
错误代码:
function TimerComponent() {
useEffect(() => {
const id = setInterval(() => {
console.log('滴答滴答');
}, 1000);
// 忘了写 return 清理函数!
}, []);
return <div>定时器组件</div>;
}
在普通模式下,这个定时器会一直运行,直到你刷新页面。但在严格模式下:
- 组件挂载,定时器启动。
- 组件卸载,定时器没有清理,依然在后台运行!
- 组件重新挂载,又启动了一个定时器!
现在你有两个定时器在后台疯狂 console.log。控制台会瞬间刷屏,CPU 飙升。严格模式会立刻尖叫:“喂!你到底有几个定时器在跑?你这是在搞 DDoS 攻击吗?”
正确写法:
function TimerComponent() {
useEffect(() => {
const id = setInterval(() => {
console.log('滴答滴答');
}, 1000);
// 必须清理!
return () => {
console.log('🧹 清理定时器');
clearInterval(id);
};
}, []);
return <div>定时器组件</div>;
}
在严格模式下,你会看到:
滴答滴答
🧹 清理定时器
滴答滴答
🧹 清理定时器
这就强迫你养成了良好的习惯:凡事必清理。
场景 2:副作用中的 API 调用
这是最令人头疼的。假设你有一个登录组件,每次进入页面都要获取用户信息。
错误代码:
function Profile() {
useEffect(() => {
// 获取用户数据
fetch('/api/user').then(res => res.json()).then(data => {
console.log('用户数据:', data);
});
}, []);
return <div>个人中心</div>;
}
在普通模式下,这看起来没问题。但在严格模式下,组件会卸载再挂载。
- 发起请求 A。
- 组件卸载,请求 A 还在跑。
- 组件重新挂载。
- 发起请求 B。
现在你的网络面板里有两个相同的请求,而且请求 A 的回调函数依然在执行(虽然没人看它了,但它依然在消耗带宽和 CPU)。
如何解决?
不要在 useEffect 里直接写 API 调用逻辑(除非你极其小心)。更常见的做法是使用状态来控制数据获取,或者使用第三方库如 SWR、React Query,它们内部已经处理好了重复请求的问题。
或者,你可以利用 useRef 来标记是否已经加载过:
function Profile() {
const hasLoaded = useRef(false);
useEffect(() => {
if (hasLoaded.current) return; // 如果已经加载过,直接返回
hasLoaded.current = true;
fetch('/api/user').then(res => res.json()).then(data => {
console.log('用户数据:', data);
});
}, []);
return <div>个人中心</div>;
}
这样,即使严格模式让你重跑一遍,它也会因为 hasLoaded.current 的存在而乖乖躺平。
第四部分:useLayoutEffect 与 useEffect 的区别
既然提到了清理,就不得不提 useLayoutEffect。它是 useEffect 的“同步兄弟”。
- useEffect:在浏览器绘制完成后运行(异步)。你可以在这里做网络请求、设置定时器、记录日志。
- useLayoutEffect:在浏览器绘制之前运行(同步)。它阻塞了浏览器的绘制,直到清理函数执行完毕。
为什么严格模式对它们都“下手”?
因为 useLayoutEffect 是同步的,它直接操作 DOM。如果在 useLayoutEffect 里没有正确清理 DOM 引用,或者没有正确处理卸载逻辑,会导致严重的布局抖动或者 DOM 节点重复。
代码示例:
function LayoutDemo() {
const [width, setWidth] = useState(0);
// 同步执行,在页面还没画出来之前就执行了
useLayoutEffect(() => {
console.log('🚀 [useLayoutEffect] 正在操作 DOM...');
const element = document.getElementById('my-box');
if (element) {
element.style.backgroundColor = 'red';
// 假设这里我们要做一个复杂的布局计算
setWidth(element.offsetWidth);
}
return () => {
console.log('🛑 [useLayoutEffect] 清理:把背景色还原');
const element = document.getElementById('my-box');
if (element) {
element.style.backgroundColor = '';
}
};
}, []);
return <div id="my-box" style={{ width: '100px', height: '100px', background: 'yellow' }} />;
}
在严格模式下,你会看到 🚀 出现,然后 🛑 出现,然后 🚀 又出现。这确保了你的 DOM 操作是幂等的(可重复执行的)。
第五部分:React 18 的新特性与并发模式
如果你现在用的是 React 18 或更高版本,那么严格模式的含义更深了。它不仅仅是为了检测副作用,更是为了模拟并发模式。
并发模式是 React 的未来,它允许 React“暂停”当前的渲染任务,去处理更高优先级的任务(比如用户输入),然后再回来继续渲染。
在并发模式下,React 可能会这样做:
- 开始渲染组件 A。
- 暂停渲染 A,去处理用户的点击事件(渲染组件 B)。
- 回来继续渲染 A。
- 关键点:在渲染 A 的过程中,React 可能会“抛弃”这次渲染结果,重新开始一次新的渲染(卸载 -> 挂载)。
严格模式就是在开发环境下,提前模拟这种“抛弃渲染”的行为。
这意味着,如果你的代码在严格模式下能经受住“卸载 -> 重新挂载”的考验,那么在未来的并发模式下,你的组件也能更稳定,不会因为 React 的渲染中断和恢复而崩溃。
useInsertionEffect:新来的“三弟”
React 还有一个新的钩子 useInsertionEffect。它的位置介于 useLayoutEffect 和 useEffect 之间。
- 它在 DOM 更新之前运行(类似
useLayoutEffect)。 - 它不会阻塞浏览器的绘制(类似
useEffect)。
这主要是为了解决 CSS-in-JS 库的性能问题。在严格模式下,useInsertionEffect 也会运行两次,但它的设计初衷就是为了让你在并发模式下也能安全地操作样式。
第六部分:深度剖析——为什么 useRef 会重置?
很多开发者对严格模式有个误解:“是不是所有东西都重置了?”
答案是:只有组件实例重置了。
这意味着:
- useState:重置。
- useRef:重置。
- Context:重置。
- Props:重置。
- 全局变量:不重置。
为什么 useRef 会重置?
因为 useRef 返回的对象是保存在组件实例里的。当组件被卸载(StrictMode 卸载组件)时,整个实例被销毁了,里面的 useRef 对象自然也就没了。
代码示例:
function Counter() {
const countRef = useRef(0);
useEffect(() => {
console.log('当前 Ref 值:', countRef.current);
}, []);
return (
<div>
<button onClick={() => countRef.current++}>点我增加 Ref</button>
<p>Ref: {countRef.current}</p>
</div>
);
}
在严格模式下:
- 组件挂载,
countRef.current是 0。 - 点击按钮,变成 1。
- 组件卸载,
countRef销毁,重置为 0。 - 组件重新挂载,
countRef.current又是 0 了。
这会让很多依赖 useRef 来保存“上一次的状态”或“临时缓存”的逻辑失效。如果你在 useRef 里存了什么东西,你必须在 useEffect 的清理函数里把它取出来,存到别的地方(比如 useState 或全局状态),否则它就丢了。
第七部分:生产环境 vs 开发环境
最后,也是最重要的一点:React 严格模式只在开发环境下生效!
在 npm run build 之后,或者在 create-react-app 的生产构建中,严格模式会被完全移除。你的代码运行速度会更快,副作用只会执行一次。
这是为了性能考虑的。虽然双重挂载能帮你发现 Bug,但它确实增加了 CPU 的负担。在生产环境,我们不需要这种“精神分裂”式的测试,我们需要的是极致的流畅。
验证方法:
你可以写一个 console.log('生产环境'),看看在 npm start 和 npm run build 后的表现。你会发现,npm run build 后只打印一次。
总结与实战建议
通过今天的讲座,我们了解了 React 严格模式的双重挂载逻辑。它不是一个简单的包装器,而是一个强大的开发工具。
给你的行动清单:
- 开启它:永远在根组件包裹
<StrictMode>。不要觉得它麻烦,它是你的免费 QA。 - 清理一切:养成习惯,在
useEffect、useLayoutEffect里写return () => { ...清理逻辑... }。这是 React 社区的铁律。 - 警惕副作用:不要在
useEffect里直接做 API 调用,除非你有防重机制。 - 理解销毁:当你看到控制台里打印了两次日志,不要慌,那是 React 在帮你检查代码。它是你的朋友,不是你的敌人。
React 严格模式就像是代码界的“魔鬼教练”。它把你推向悬崖,逼你学会如何刹车,如何平衡。当你习惯了这种“精神分裂”式的测试,当你习惯了凡事必清理,那么在真正的生产战场上,你的代码将坚如磐石,无懈可击。
好了,今天的讲座就到这里。希望大家都能写出没有副作用、没有泄漏、逻辑清晰的 React 组件。下课!