
一句话:StrictMode 在开发环境故意「双调用组件函数」与「Effect 挂载 → 卸载 → 再挂载」——目的是暴露「渲染不纯」与「清理缺失」,是并发渲染对代码的硬性要求,不是 bug。
一、StrictMode 做了什么
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);
| 检查 | 内容 | 目的 |
|---|---|---|
| 组件函数双调用 | 每次渲染调用组件函数两次(结果丢弃一次) | 暴露「渲染不纯」(有副作用) |
| Effect 双执行 | 挂载 → 卸载 → 再挂载(Effect 执行 2 次、清理 1 次) | 暴露「清理缺失」 |
| 检测废弃 API | 字符串 ref、findDOMNode、defaultProps(函数组件)等 |
提示迁移 |
| 检测可疑用法 | 渲染中调用 setState、在 useMemo 里做副作用 |
提示修正 |
// 观察:StrictMode 下会打印两次
function Component() {
console.log('渲染'); // ⚠️ 开发环境打印两次
return <div />;
}
// 观察:Effect 会执行两次(中间夹一次清理)
useEffect(() => {
console.log('effect 执行');
return () => console.log('effect 清理');
}, []);
// 输出:effect 执行 → effect 清理 → effect 执行
⚠️ 三个必须记住的事实:
① 这些检查【只在开发环境】生效(生产构建没有)
② 只影响「开发体验」,不影响「生产行为」
③ 它们暴露的是「真的问题」——如果代码正确,双调用不会造成任何异常
二、为什么 React 要这么做
因为【并发渲染】要求「render 可以被执行多次」:
① 渲染可以被【中断】→ 之后「重放」(重新执行未完成的部分)
② 渲染可以被【丢弃】→ 换优先级更高的任务先做
③ 未来 React 可能支持「保留状态地卸载/重挂载组件」
(如「离屏渲染」「预渲染」优化)
所以「render 必须是纯函数」从「最佳实践」升级为【正确性要求】。
StrictMode 的双调用就是【提前】把这个场景制造出来,
让你在开发阶段就发现「不纯的代码」,而不是在生产环境出诡异 bug。
一句话类比:
React 在说:「你的组件可能在任意时刻被重复执行、被反复挂载卸载,
请确保它在这种情况下依然正确。」
StrictMode 就是在替你「模拟」这种压力场景。
三、会暴露的四类问题
1) 渲染不纯
// ❌ 在渲染中修改外部变量
let renderCount = 0;
function Impure() {
renderCount++; // ⚠️ StrictMode 下会变成 2
return <div>{renderCount}</div>;
}
// ❌ 在渲染中修改 props / state 的对象
function Impure2({ items }: { items: Item[] }) {
items.push(newItem); // ⚠️ 修改了 props(引用也不变 → 不更新)
return <div>{items.length}</div>;
}
// ✅ 用不可变方式
function Pure({ items }: { items: Item[] }) {
const next = [...items, newItem];
return <div>{next.length}</div>;
}
// ❌ 在渲染中产生「不确定的值」
function Impure3() {
const id = Math.random(); // ⚠️ 两次调用结果不同
const now = Date.now(); // ⚠️ 同上(SSR 下还会 mismatch)
return <div id={id}>{now}</div>;
}
// ❌ 在渲染中调用 setState(无限循环风险)
function Impure4() {
const [n, setN] = useState(0);
setN(n + 1); // ❌ 渲染 → setState → 渲染 → ...
return <div>{n}</div>;
}
// ✅ 需要「派生」→ 直接算;需要「响应 props 变化」→ 在事件处理或用 key
2) 清理缺失
// ❌ 订阅没清理 → StrictMode 下建立两个,旧的一直存在
useEffect(() => {
const ws = new WebSocket(url);
ws.onmessage = handle;
// ⚠️ 没有 return () => ws.close()
}, [url]);
// ❌ 事件监听没清理
useEffect(() => {
window.addEventListener('scroll', onScroll); // ⚠️
}, []);
// ❌ 定时器没清理
useEffect(() => {
setInterval(tick, 1000); // ⚠️
}, []);
// ✅ 清理完整后,双执行不再有任何影响
useEffect(() => {
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
// 输出:添加 → 移除 → 添加(最终只有一个监听,正确)
3) 依赖了「只执行一次」的假设
// ❌ 认为 Effect 只会跑一次
const initializedRef = useRef(false);
useEffect(() => {
if (initializedRef.current) return;
initializedRef.current = true;
registerGlobalHandler(); // ⚠️ 这只是「绕过」,不是「修复」
}, []);
⚠️ 这里的陷阱:
① 用 ref 标志位能让「初始化只跑一次」→ 看起来「修好了」
② 但如果「初始化」本身「不该只跑一次」(比如它需要响应 props),
那这个写法只是「掩盖了真正的问题」
③ 更常见的问题是:这个「初始化」其实「忘了清理」(应该写清理函数)
✅ 正确的思考方式是:「这个副作用【应该】执行几次?【应该】怎么撤销?」
· 应该只执行一次且无需撤销 → 用 ref 标志位是可以的(但要注释说明)
· 应该在依赖变化时重新执行 → 写对依赖 + 清理
4) 使用已废弃的 API
// ❌ StrictMode 会告警
<input ref="inputRef" /> // 字符串 ref
this.refs.inputRef;
// ❌ StrictMode 会告警(函数组件上)
Component.defaultProps = { ... }; // 用默认参数替代
Component.propTypes = { ... }; // 用 TypeScript 替代
四、正确应对方式
✅ 三条原则:
① 让「渲染」纯:
· 只做「计算」——不修改外部变量、不改 props/state、不用随机值/时间
· 派生数据直接算(或用 useMemo)
· 副作用放到 Effect 或事件处理里
② 让「Effect」可执行多次:
· 每个 Effect 都写清理(建立了什么就撤销什么)
· 依赖写全(不要靠删依赖消警告)
· 需要的「最新值」用 ref 保存
③ 对「确实只该执行一次」的初始化,用「幂等 + 注释说明」的处理:
· 如「第三方库的全局初始化」:用 ref 标志位 + 注释
· 更好的方式:把初始化放到「模块作用域」或「应用入口」
// ✅ 完整示例:既有「只初始化一次」又「清理干净」
function MapView({ center }: { center: LatLng }) {
const mapRef = useRef<MapInstance | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// ① 只初始化一次(地图实例创建很贵,不该反复创建)
useEffect(() => {
if (!containerRef.current) return;
const map = createMap(containerRef.current, { center });
mapRef.current = map;
return () => {
map.destroy(); // ⭐ 清理(StrictMode 下会「创建 → 销毁 → 创建」)
mapRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- 只初始化一次,center 由下面的 effect 更新
}, []);
// ② center 变化时「只更新」,不重建
useEffect(() => {
mapRef.current?.setCenter(center);
}, [center]);
return <div ref={containerRef} />;
}
五、生产环境不生效
⚠️ 关键事实:
① StrictMode 的所有检查【只在开发构建中执行】
→ 生产构建会「编译掉」这些检查(不会有双调用、不会有额外的挂载卸载)
② 所以「开发环境报错、生产正常」不代表「生产没问题」——
它说明「你的代码依赖了『只执行一次』的假设」,
而这个假设在「并发渲染的某些场景」下是【不成立】的
③ 常见的「生产才出问题」的现实场景:
· 路由快速切换(组件被快速卸载重挂载)
· 条件渲染导致组件重建
· 并发特性(useTransition)导致渲染被中断重放
→ 这些场景与 StrictMode 的双调用【本质相同】
// 一个「开发环境被 StrictMode 暴露、生产环境才炸」的例子
useEffect(() => {
const ws = new WebSocket(url);
ws.onmessage = (e) => setMessages((prev) => [...prev, JSON.parse(e.data)]);
// ⚠️ 没写清理
}, [url]);
// 开发环境(StrictMode):立刻看到「两个 WebSocket」→ 提示你补清理
// 生产环境:用户快速切换频道 → 旧的 WebSocket 没关闭 →
// 两个连接同时推消息 → 消息重复、内存泄漏、服务端连接数暴涨
六、常见误判
❌ 误判一:「React 18 有 bug,组件渲染两次」
→ 不是 bug,是 StrictMode 的开发检查
❌ 误判二:「我的代码没问题,只是 StrictMode 太严格」
→ 双调用暴露的问题在「生产环境的并发/快速切换场景」下同样存在
❌ 误判三:「用 ref 标志位绕过就好了」
→ 这只是掩盖「清理缺失」或「渲染不纯」
❌ 误判四:「把 StrictMode 删掉就行了」
→ 你只是「关掉了体检」,问题依然存在(而且失去了提前发现的能力)
❌ 误判五:「双调用会消耗两倍性能,生产也会」
→ 生产环境没有双调用(只有开发环境)
// ✅ 一个「看起来像 StrictMode 问题、其实是代码问题」的例子
function usePolling(url: string, interval: number) {
const [data, setData] = useState(null);
useEffect(() => {
const id = setInterval(() => {
fetch(url).then((r) => r.json()).then(setData);
}, interval);
// ⚠️ 缺清理 → StrictMode 下「两个定时器」(请求量翻倍)
}, [url, interval]);
return data;
}
// ✅ 修复
useEffect(() => {
const ac = new AbortController();
const id = setInterval(() => {
fetch(url, { signal: ac.signal }).then((r) => r.json()).then(setData).catch(() => {});
}, interval);
return () => {
clearInterval(id);
ac.abort();
};
}, [url, interval]);
七、什么时候「合理地」保留「只执行一次」
// ✅ 场景:全局/昂贵的初始化(模块级或应用入口更合适,但有时只能在组件里)
function App() {
const initializedRef = useRef(false);
useEffect(() => {
if (initializedRef.current) return; // 幂等保护
initializedRef.current = true;
initAnalytics();
initErrorReporting();
// ⭐ 没有清理,因为这些是【应用级】的全局副作用,不需要按组件生命周期撤销
// ⭐ 必须加注释说明「为什么只做一次、为什么不清理」
}, []);
return <RouterProvider router={router} />;
}
⚠️ 判断标准:
① 这个副作用「属于组件」还是「属于应用」?
· 属于组件 → 必须写清理(它会被反复挂载卸载)
· 属于应用(全局)→ 可以考虑「只执行一次 + 不清理」,但更该放到「入口/模块」
② 「不清理的后果」是什么?
· 如果「重复执行」会导致「重复初始化、重复上报、重复连接」→ 必须幂等或清理
· 如果「重复执行」完全无害(如「设置一个全局变量」)→ 可以不处理
③ 优先选择「把全局初始化放到模块作用域或应用入口」——
那里本来就「只执行一次」,不需要任何 hack
八、常见坑
// ① 在渲染中修改外部变量(结果不可预测)
let count = 0;
function Comp() { count++; return <div>{count}</div>; } // ❌
// ② 在渲染中修改 props/state 对象
items.push(x); // ❌
// ③ 用 Math.random() / Date.now() 产生「渲染结果相关的值」
const id = Math.random(); // ❌
// ④ Effect 缺清理(订阅/定时器/监听/Observer/WebSocket)
// ⑤ 用 ref 标志位「绕过」双调用而不修问题
// ⑥ 认为「双调用会加倍请求 = 生产环境也是」
// ⑦ 把 StrictMode 删掉来「解决」问题
// ⑧ 在「初始化一次」的 Effect 里依赖了会变的 props(导致「用了过期的值」)
useEffect(() => {
initThirdParty(options); // ⚠️ options 变化后不会重新初始化
// eslint-disable-next-line
}, []);
// ⑨ 用「全局标志位」(模块级变量)做幂等(多实例时互相干扰)
let inited = false; // ⚠️ 应该用 useRef
// ⑩ 在 useMemo 里做副作用(StrictMode 下可能执行两次)
// ⑪ 认为「渲染两次会让 useRef 的计数器翻倍是正常的」(说明渲染不纯)
// ⑫ 在「已经挂载的组件」上看到「两次网络请求」却不检查清理
// ⑬ 用 `Date.now()` 作为 key(每次渲染都变 → 全量重建)
// ⑭ 在 Effect 里「先 setState 再读它」(拿不到新值)
// ⑮ 把「双调用导致的告警」当成「可以忽略的噪音」
// ⑧ 的正确处理:区分「初始化一次」与「响应变化」
// ① 初始化一次(不依赖 props)
useEffect(() => {
const inst = createInstance(containerRef.current!);
instanceRef.current = inst;
return () => inst.destroy();
}, []);
// ② 响应 props 变化(只更新,不重建)
useEffect(() => {
instanceRef.current?.update(options);
}, [options]);
九、面试延伸
- 「StrictMode 做了什么?」
它在开发环境额外执行三类检查:① 组件函数被调用两次——暴露「渲染不纯」(在渲染中改外部变量、改 props/state、用随机值);② Effect 执行「挂载 → 卸载 → 再挂载」——暴露「清理缺失」(订阅、定时器、监听、WebSocket 没关闭);③ 检测已废弃的 API 与可疑用法(字符串 ref、findDOMNode、函数组件的defaultProps、渲染中setState)。这些检查只在开发构建中生效。 -
「为什么 StrictMode 要让组件渲染两次?」
因为 React 18 的并发渲染要求「render 可以被执行多次」——渲染可能被中断后重放(重新执行未完成部分)、可能被整体丢弃(换更高优先级任务),未来 React 还可能支持「保留状态地卸载/重挂载组件」。所以「渲染必须是纯函数」从「好习惯」变成了「正确性要求」。StrictMode 的双调用就是提前制造这个压力场景,让你在开发阶段发现不纯的代码,而不是在生产环境的「快速切换路由」「并发特性」场景下出诡异 bug。 -
「双调用会导致请求发两次,是不是性能问题?」
不会——生产环境没有双调用(StrictMode 的检查只在开发构建中)。但在开发环境看到「请求两次」是有价值的信号:它说明你的 Effect 缺少取消/清理逻辑——而在生产环境的「路由快速切换」「参数快速变化」「组件被条件渲染重建」等场景下,同样的代码会导致真实的竞态与资源泄漏。所以正确应对是「补齐取消逻辑」(加AbortController,第一次请求会被立刻取消)。 -
「看到『渲染两次』应该怎么办?」
按「暴露的是什么问题」分两类处理:① 如果是「渲染不纯」——把渲染里的一切副作用(改外部变量、改 props、调用 API、读写 DOM)移到 Effect 或事件处理里;派生数据直接算;不要用Math.random()/Date.now()参与「影响渲染结果」的计算。② 如果是「清理缺失」——给 Effect 补清理(订阅unsubscribe、定时器clear、监听remove、Observerdisconnect、请求abort)。这两类修复本身就是「让代码更正确」,与 StrictMode 无关。 -
「可以用
ref标志位绕过双调用吗?」
在特定场景下可以,但必须说明理由:如果某段逻辑确实应该只执行一次、并且不需要清理(如「应用级的全局初始化」——初始化埋点、错误上报),那么用useRef(false)做幂等保护是合理的,但要加注释说明「为什么只做一次、为什么不清理」。不合理的用法是「用标志位掩盖『清理缺失』」——那只是让问题隐藏起来,在生产环境的真实场景下依然会出现。更好的做法是把「全局初始化」放到模块作用域或应用入口(那里本来就只执行一次)。 -
「为什么说『开发环境报错、生产正常』很危险?」
因为它说明「你的代码依赖了『只执行一次』的假设」——而 StrictMode 的双调用模拟的正是生产环境真实存在的场景:① 路由快速切换(组件被快速卸载重挂载);② 条件渲染导致组件重建;③ 并发特性(useTransition)导致渲染被中断重放;④ 未来的「离屏渲染/预渲染」优化。所以「生产环境暂时正常」只是因为这个场景还没被触发(用户操作不够快、或者恰好在单线程下没暴露),一旦触发就是线上问题(数据错乱、请求翻倍、内存泄漏)。 -
「
StrictMode能关掉吗?」
能(不包裹<StrictMode>就行),但强烈不建议:① 它只在开发环境生效,没有生产开销;② 它帮你提前发现并发渲染下的隐患(不纯的渲染、缺失的清理);③ 它还能检测废弃 API(升级 React 时很有价值)。关掉它相当于「关掉体检」——问题依然在那里,只是你更晚才发现。合理的做法是「保留 StrictMode,并修掉它暴露的问题」。 -
「怎么判断一个『只执行一次』的 ref 标志位是『合理』还是『掩盖问题』?」
问三个问题:① 「这段副作用属于组件还是应用?」——属于组件(依赖 props/state)→ 必须能响应变化 + 有清理;属于应用(全局初始化)→ 可以考虑一次性。② 「如果不清理,重复执行会造成什么?」——重复连接/重复上报/重复订阅 → 必须幂等或清理(用标志位也行,但要幂等)。③ 「能不能把它移到模块作用域或应用入口?」——能移就移(那里天然只执行一次,不需要任何 hack)。三个问题的答案是「属于应用 + 重复无害 + 移不了」时,ref 标志位才合理。
一句话速记
StrictMode 在开发环境做三类检查——「组件函数双调用(暴露渲染不纯)」「Effect「挂载→卸载→再挂载」(暴露清理缺失)」「检测废弃 API 与可疑用法」,生产构建不生效;它的背景是「并发渲染要求 render 可以被执行多次」(纯函数是正确性要求而非最佳实践);正确应对是「让渲染纯 + 给 Effect 补清理」,不要用
ref标志位掩盖问题(那是把 bug 藏到生产环境的「路由快速切换 / 并发特性」场景里);不要删 StrictMode(它零生产开销且能提前发现隐患);只有「应用级、重复执行无害、无法移到入口」的初始化用 ref 幂等才合理(并写注释)。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!