问题场景
开发环境刚把项目升级到 React 18,突然发现一个诡异现象:所有页面的接口请求都发了两遍。
tsx
// 一个再普通不过的组件
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
console.log('fetch user', userId);
fetch(`/api/users/${userId}`).then(res => res.json()).then(setUser);
}, [userId]);
return <div>{user?.name ?? 'loading...'}</div>;
}
控制台输出:
sql
fetch user 123
fetch user 123
日志打印了两次、请求发了两遍,排查半天发现业务代码没任何问题------罪魁祸首是 main.tsx 里包着组件的 <StrictMode>。
更让人困惑的是:上线到生产环境后,这个现象又消失了。为什么只在开发环境出现?这是 React 的 Bug 吗?
原因分析
1. StrictMode 是「开发期体检工具」,不是运行时功能
<StrictMode> 只在**开发模式(development)**下生效,生产构建(production)中它什么都不做。这正是"线上正常、本地异常"的原因。
它的设计初衷是帮你提前暴露组件中不纯的写法 和不安全的副作用 ,手段就是故意让组件渲染两次、让 Effect 挂载/卸载/再挂载。
2. 双调用的具体机制(React 18+)
在 React 18 的 StrictMode 下,组件挂载时会经历:
挂载 → 卸载(模拟)→ 重新挂载
对应的 Effect 生命周期是:
arduino
setup 执行(第一次)→ cleanup 执行 → setup 再次执行(第二次)
所以 useEffect 里的请求发出两次、日志打印两次,是 React 故意模拟"组件被卸载再重挂"的场景,用来检查你的 cleanup 是否写得正确。
同理,以下内容在 StrictMode 下也会双调用:
- 组件函数体本身(render 阶段)
useState/useReducer的初始化函数(lazy initializer)useMemo/useCallback的计算函数useInsertionEffect/useLayoutEffect
3. 为什么 React 要这么做?
它想逼你回答一个问题:如果组件被卸载了,你留下的"烂摊子"(定时器、订阅、全局监听)清理干净了吗?
如果你的 cleanup 写得正确,双调用不会产生任何 bug;如果 cleanup 漏写了,StrictMode 就会把这个隐患放大暴露出来------这正是它存在的意义。
解决方案
方案一:正确编写 cleanup(推荐,治本)
把"发起请求"和"取消请求"成对写好,让第二次 setup 能正确接管:
tsx
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const controller = new AbortController();
console.log('fetch user', userId);
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(setUser)
.catch(err => {
// AbortError 是主动取消,不是真错误,直接忽略
if (err.name !== 'AbortError') console.error(err);
});
// cleanup:卸载时取消请求,避免竞态和重复请求
return () => controller.abort();
}, [userId]);
return <div>{user?.name ?? 'loading...'}</div>;
}
加了 AbortController 后,第一次 setup 的请求会被 cleanup 立刻 abort,实际只有第二次请求真正生效------网络面板里只会看到一个请求,日志也只剩一条。
同理处理定时器和事件监听:
tsx
useEffect(() => {
const timer = setInterval(() => setCount(c => c + 1), 1000);
window.addEventListener('resize', onResize);
return () => {
clearInterval(timer);
window.removeEventListener('resize', onResize);
};
}, []);
方案二:用 ref 标记跳过首次重复(治标,慎用)
如果请求本身无法取消(比如第三方 SDK、埋点上报),可以用 ref 挡住第二次执行:
tsx
const fetchedRef = useRef(false);
useEffect(() => {
if (fetchedRef.current) return;
fetchedRef.current = true;
// 这里只会在 StrictMode 下执行一次
trackEvent('page_view');
}, []);
⚠️ 注意:这只是"骗过" StrictMode 的检查,会掩盖 cleanup 缺失的问题,不建议无脑全局使用,只适合确实无法取消副作用的场景。
方案三:实在不想要 StrictMode,可以移除(下策)
tsx
// main.tsx
createRoot(document.getElementById('root')!).render(
// <StrictMode> ← 注释掉
<App />
);
强烈不建议。StrictMode 是免费的"代码体检",它帮你抓出的竞态、内存泄漏、不可预测的渲染问题,远比"请求多打一次"严重得多。生产环境它零开销,留着百利而无一害。
要点总结
- StrictMode 双调用只发生在开发环境,生产环境无任何影响,不是 Bug 而是刻意设计。
- 双调用 = 挂载 → 卸载 → 重挂载,用来检测 Effect 的 cleanup 是否完备。
- 正确姿势:给副作用写 cleanup(AbortController 取消请求、clearInterval、removeEventListener),这也是 React 官方推荐写法。
- 副作用无法取消时,可用
useRef标记跳过第二次执行,但这是治标方案。 - 不要为了"看着干净"移除 StrictMode,它是发现竞态和内存泄漏的最佳工具。
- 排查"请求发两次"问题时,先看是否包了 StrictMode,再看是否用了
React.StrictMode的旧版 Fast Refresh 叠加效应------新版 React DevTools 已能直观看到双调用标记。