🐛 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 已能直观看到双调用标记。
相关推荐
钛态4 小时前
Vite 中的 CSS 工程化:从 CSS Modules 到 UnoCSS 的渐进式迁移
前端·vue·react·web
Csvn6 小时前
原型链、this 与闭包内存:三座深水区一次打通(含内存泄漏排查实战)
前端·javascript
葡萄城技术团队6 小时前
如何通过 JSON 数据源 / Web API 快速构建轻量级报表(Report)?
前端·json·原型模式
浪兎兎7 小时前
【Java Web】Servlet + JSP 笔记
java·前端·servlet
Patrick_Wilson7 小时前
iOS Safari Clipboard API 权限弹窗问题
前端·ios·safari
OpenTiny社区8 小时前
Naive UI × GenUI SDK:自定义物料库搭建实战
前端·ai编程
kyriewen8 小时前
我拿 4 个真实前端任务试了 GLM-5.3 Flash:代码一遍跑通,账单 4 分钱
前端·程序员·ai编程
IT_陈寒8 小时前
Vue的响应式更新有时候真的不听话
前端·人工智能·后端
风骏时光牛马9 小时前
AI编程常见问题梳理与要点解析
前端
前端snow9 小时前
ai agent -- LCEL 汇总
前端