🐛 React StrictMode 下 useEffect 执行两次:不是 Bug,是特性

问题场景

开发环境刚把项目升级到 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 是免费的"代码体检",它帮你抓出的竞态、内存泄漏、不可预测的渲染问题,远比"请求多打一次"严重得多。生产环境它零开销,留着百利而无一害。

要点总结

  1. StrictMode 双调用只发生在开发环境,生产环境无任何影响,不是 Bug 而是刻意设计。
  2. 双调用 = 挂载 → 卸载 → 重挂载,用来检测 Effect 的 cleanup 是否完备。
  3. 正确姿势:给副作用写 cleanup(AbortController 取消请求、clearInterval、removeEventListener),这也是 React 官方推荐写法。
  4. 副作用无法取消时,可用 useRef 标记跳过第二次执行,但这是治标方案。
  5. 不要为了"看着干净"移除 StrictMode,它是发现竞态和内存泄漏的最佳工具。
  6. 排查"请求发两次"问题时,先看是否包了 StrictMode,再看是否用了 React.StrictMode 的旧版 Fast Refresh 叠加效应------新版 React DevTools 已能直观看到双调用标记。
相关推荐
沙蒿同学9 分钟前
我把架构约定编译成了会变红的测试:Wails v2 + Go + Vue3 桌面脚手架实战
前端·后端·github
cpolar技术支持12 分钟前
本地 Playwright 测试报告怎么远程复盘?Trace Viewer 跑起来后,用 cpolar 分享失败现场
前端·自动化测试·测试工具·cpolar·playwright
wordbaby12 分钟前
企业级后台管理系统路由设计与最佳实践指南
前端
胡志辉的博客21 分钟前
【完全开源】IP 纯净度检测 可一键部署到自己的CF
前端·javascript·chrome·ip·chromium
Hilaku1 小时前
作为面试官,我最怕遇到什么样的候选人?
前端·javascript·程序员
TiDi1 小时前
Pinia优化重复请求
前端
web3d5201 小时前
01-用 Leafletjs 10 分钟搭一张水利一张图(Vue3 + Vite 实战)
前端·javascript
晚安日记wanna2 小时前
Vue2 的 defineProperty 差在哪四层追问筛掉九成候选人
前端·vue.js·面试
TiDi2 小时前
吸顶导航交互实现
前端
kisshyshy2 小时前
从Props透传到自定义Hook:系统梳理React跨层级通信与逻辑复用
前端·架构·代码规范