🐛 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 已能直观看到双调用标记。
相关推荐
田威AI3 小时前
图片内文字翻译的规格:输入输出、保真、自动化、时间与费用
前端·计算机视觉
不可能片场3 小时前
命令行中文变问号 我用环境变量救了场
前端·electron
lerhxx4 小时前
AI 应用中的上下文管理 —— 从"上下文窗口"到"分层压缩"的工程实践
前端·javascript
骑着蜗牛撵大象3274 小时前
多 Agent 分治协作:MapReduce 模式下的并行扇出与结果归并
前端
用户55318297324 小时前
Flutter iOS 热更新深入 Dart VM:读懂函数入口、解释执行与调用桥接
前端
前端snow4 小时前
为什么大厂要用Postgres + mogodb的框架?
前端
运维有小邓14 小时前
2026年主流AD域管理软件有哪些?
前端
Mickey同学4 小时前
提示词注入:AI 时代的「SQL 注入」
前端
deli0074 小时前
GROUP BY 先别想当然:我把 SQL 分组语义做成了沙盘,8 个实验 + 27 条自检全绿
前端
WayneX4 小时前
开源 Vue 3 组件库 Morya UI:把组件、文档、AI 工具链一起做进一个包
前端·vue.js·前端框架