React 为啥老是渲染两次?------聊聊 Strict Mode 的那些事
看控制台的时候,有没有怀疑人生?
写 React 的时候,你有没有遇到过这种场景:
明明只写了一行 console.log,结果控制台"刷刷"给你印出来两条一模一样的。或者发送网络请求,明明只调用了一次,Network 里却躺着两个请求。
第一反应通常是:"完了,我是不是哪里写出 Bug 了?组件是不是在哪里被意外卸载又挂载了?"
别慌,大概率不是你的锅,而是 React 故意的。
罪魁祸首:Strict Mode
赶紧去你的入口文件(通常是 main.tsx 或 index.tsx)看一眼,是不是长这样:
tsx
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
那个 <React.StrictMode> 就是"幕后黑手"。
它的中文名叫"严格模式"。这玩意儿只在 开发环境(Development) 下生效,到了 生产环境(Production) 就会自动隐身,不会对用户产生任何影响。
为什么要搞这么个"恶作剧"?
React 团队并不是闲着没事干,非要让你的控制台变脏。这么做的核心目的是:帮你揪出不纯的函数和有副作用的代码。
在 React 的设计哲学里,组件的渲染过程(Render Phase)应该是 纯粹(Pure) 的。
所谓的"纯",就是说:
- 给定相同的输入(Props 和 State),必须返回相同的输出(JSX)。
- 不能改变作用域之外的变量,不能有副作用(Side Effects)。
如果你的组件不纯,比如你在渲染函数里偷偷修改了一个全局变量:
tsx
let count = 0;
function BadComponent() {
count++; // ❌ 这是一个副作用!
return <div>Count: {count}</div>;
}
在单次渲染下,你可能看不出问题。但如果 React 决定并发渲染、或者为了优化跳过某些渲染,这个全局 count 就会变得不可预测。
为了让你在开发阶段就发现这种隐患,React 采取了最简单粗暴的办法:把你的组件渲染两次。
如果你的组件是纯的,渲染一次和渲染两次,对外部世界的影响应该是一样的(零影响),返回的结果也是一致的。但如果你在里面搞了小动作(比如上面的 count++),两次渲染就会导致 count 加了 2,结果就不对劲了,你立马就能发现问题。
具体哪些东西会执行两次?
在严格模式下,React 会特意重复调用以下内容:
- 函数组件体(Function Component body)
useState,useMemo,useReducer传递的初始化函数- 类组件的
constructor,render,shouldComponentUpdate等生命周期
注意,这仅仅是 "调用" 两次,并不是把你的组件在 DOM 上真的挂载两次。它主要是在内存里跑两遍逻辑,看看有没有奇奇怪怪的副作用发生。
useEffect 的"挂载 -> 卸载 -> 挂载"
除了渲染过程,从 React 18 开始,Strict Mode 还加了一个更狠的检查机制,针对 useEffect。
你可能会发现,组件初始化时,useEffect 里的代码也跑了两次。
严格来说,它的执行顺序是这样的:
- Mount(挂载) -> 执行 Effect
- Unmount(卸载) -> 执行 Cleanup(清除函数)
- Remount(挂载) -> 执行 Effect
这又是为了啥?
这是为了帮你检查 Cleanup 函数写没写对。
很多时候我们写了订阅(subscribe),却忘了取消订阅(unsubscribe);写了 setInterval,却忘了 clearInterval。这种内存泄漏在单次挂载中很难发现,但在页面快速切换时就会爆雷。
通过强制来一次"挂载->卸载->挂载"的演习,React 逼着你必须把 Cleanup 逻辑写好。如果你的 Effect 写得没问题,那么"执行->清除->再执行"的结果,应该和"只执行一次"在逻辑上是闭环的。
比如一个聊天室连接:
connect()(连接)disconnect()(断开)connect()(连接)
用户最终还是连接上了,中间的断开重连不应该导致程序崩溃或产生两个连接。
怎么解决?
1. 接受它,不要关掉它
最好的办法是适应它。既然 React 告诉你这里有副作用,那就去修复代码,而不是解决提出问题的人。
- 把副作用挪到
useEffect里去,别放在渲染函数体里。 - 确保
useEffect有正确的 Cleanup 函数。
2. 使用 useRef 解决数据重复请求
经常有人问:"我的请求在 useEffect 里发了两次,导致服务器存了两条数据,怎么办?"
如果你无法把后端接口改成幂等(Idempotent)的,可以使用 useRef 来标记请求状态:
tsx
import { useEffect, useRef } from 'react';
function DataFetcher() {
const hasFetched = useRef(false);
useEffect(() => {
if (hasFetched.current) return; // 如果已经请求过,直接返回
hasFetched.current = true;
fetchData();
}, []);
return <div>Loading...</div>;
}
不过 React 官方更推荐使用像 React Query (TanStack Query) 或 SWR 这样的库来管理数据请求,它们内部已经处理好了这些去重逻辑。
对于Strict Mode,我的理解是:
原理层面:
- 渲染双倍:为了检测渲染逻辑是否纯粹。
- Effect 挂载-卸载-挂载:为了检测 Effect 的清除逻辑是否正确。
- 仅限开发环境:生产环境完全无副作用。
实用层面:
- 它是 React 自带的"代码质量检测员"。
- 看到日志打印两次不要慌,先想想是不是 Strict Mode 的锅。
- 千万别在渲染函数里写副作用(比如修改外部变量、直接发请求)。
使用建议:
- 调试时:如果在排查 Bug,可以留意一下是不是因为两次渲染导致的逻辑错误。
- 写 Effect 时:脑子里模拟一下"连上-断开-连上"的过程,看看代码能不能扛得住。
- 请求处理:尽量用成熟的请求库(React Query/SWR),或者确保接口幂等。
写在最后
Strict Mode 就像一个严格的健身教练,刚开始你会觉得它很烦,总是挑你的刺,让你做重复动作。但长远来看,它能帮你练就一身"健壮"的代码体格,避免在未来复杂的并发渲染中受内伤。
下次看到控制台的双重日志,别再骂 React 了,那是它在默默守护你的代码质量。