Notes

React StrictMode:为什么会“双调用”?(源码/机制视角)

一句话结论:React 在开发环境的 StrictMode 下会刻意制造“更苛刻的执行场景”——包括对某些 render 相关函数的重复调用,以及在 React 18 中对 Effects 做“模拟卸载再挂载”的双执行——用来提前暴露「不纯渲染 / 缺少清理 / 依赖时序」等问题;这些行为不会发生在生产环境

注意:本文描述的“双调用”主要指 React 18 开发环境 + StrictMode 的行为。

1. StrictMode 到底“严格”在哪里?

StrictMode 不是一个运行时特性开关(不会改变生产行为),它更像一组 DEV-only 的诊断器

从效果上看,它主要做两件事:

  1. 检测“不纯渲染”(impure render)
  • 通过重复调用一些 render 相关逻辑,让“带副作用的渲染”更容易暴露(例如渲染时写全局变量、请求、订阅、随机数等)
  1. 检测“副作用没有正确清理”(missing cleanup)(React 18 新加强)
  • 通过模拟「mount → unmount → mount」的生命周期,让缺少 cleanup 的 effect 更容易表现为:重复订阅、重复定时器、重复监听等

2. 哪些东西会被“重复执行”?(现象对照)

2.1 Render 相关:为了抓“不纯渲染”

在 DEV + StrictMode 下,React 会对一些函数做额外调用(典型包括):

  • 函数组件的组件函数本身(也就是 render 逻辑)
  • useState 的 lazy initializer:useState(() => init())
  • useReducer 的 initializer:useReducer(reducer, initArg, init)
  • useMemo 的 factory:useMemo(() => compute(), deps)

目的:如果这些函数不是“纯的”,重复调用会放大问题。

记法:凡是“在渲染阶段会运行的函数”,StrictMode 都倾向于让它们在 DEV 下更容易暴露副作用。

2.2 Commit 相关(Effects):为了抓“缺少清理”

React 18 DEV + StrictMode 下,会对 Effects(尤其是 useEffect/useLayoutEffect) 做“额外的 setup/cleanup 循环”。

你观察到的现象通常是:

  • 首次挂载时:
    • effect 执行一次
    • 然后立刻 cleanup 一次
    • 然后 effect 再执行一次

这不是 bug,是 React 在 DEV 下模拟了一次卸载与重新挂载

3. 机制:React 18 为什么要“模拟卸载再挂载”?

3.1 背景动机(与并发渲染的关系)

React 18 的并发能力让 UI 更新可能出现:

  • render 阶段可被中断/重做/丢弃
  • effect 的安装与清理更需要可重入(idempotent)

历史上很多代码默认“组件只 mount 一次”,effect 不写 cleanup 或 cleanup 不完整,在并发/重试场景下就更危险。

StrictMode 在 DEV 下把这种“危险代码”提前暴露出来:

  • 如果 effect 没有 cleanup:你会看到重复订阅、重复事件监听
  • 如果 cleanup 写错:你会看到卸载后仍残留副作用

3.2 Fiber/Flags 视角(发生在 commit 阶段的额外流程)

从架构上看:

  • render 阶段:生成 Fiber 树、计算变更
  • commit 阶段:把变更落到宿主环境(DOM),并在特定时机执行 layout/passive effects

StrictMode 的“effect 双执行”本质发生在 commit 附近,React 在 DEV 下会走一个额外的流程,概念上类似:

  1. 正常 commit:挂载 DOM、执行 layout effects、安排 passive effects
  2. DEV 严格检查:
    • 先执行一次“卸载路径”(cleanup layout/passive effects)
    • 再执行一次“挂载路径”(重新 setup layout/passive effects)

你可以把它理解为 React 在 DEV 下做了一个“快速的 mount/unmount/mount 回放”。

3.3 更贴近源码的关键词(帮助你读源码时定位)

不同版本代码命名会有差异,但在 React 18 源码里你会经常看到类似概念:

  • commitRoot:提交入口
  • DEV-only 的 double invoke:类似 commitDoubleInvokeEffectsInDEV 的逻辑
  • Layout/Passive 两类 effect 的 mount/unmount 函数族:
    • layout: commitHookEffectListMount/Unmount(或同类命名)
    • passive: commitPassiveMountEffects/commitPassiveUnmountEffects
  • 与 StrictMode 相关的 DEV flags:常见如 MountLayoutDev, MountPassiveDev(名字可能随版本演进)

阅读建议:你如果已经看过本仓库的 Fiber/commitRoot 文章,可以把 StrictMode 的 DEV 行为当作“commitRoot 的一个 DEV 分支”。

4. 它为什么只在开发环境出现?

因为这些检查会:

  • 增加额外的 mount/unmount 开销
  • 改变某些时序(比如 effect 的执行次数)

这类行为如果带到生产环境,既影响性能,也会改变业务逻辑。因此 React 把它限定为 DEV 工具。

5. 常见误区(从机制推导出的结论)

  1. “useEffect 怎么执行了两次?React 有 bug?”
  • 不是 bug,是 DEV + StrictMode 的诊断行为
  1. “我用 StrictMode 会不会让线上也执行两次?”
  • 不会。生产构建不会做这些 DEV double invoke
  1. “我只要关掉 StrictMode 就好了?”
  • 关掉只是“把烟雾报警器拆了”,问题本身(缺少清理/不纯渲染)仍然存在

6. 从架构角度的落地约束(最少但关键)

既然 StrictMode 的目标是逼你写出“可重入”的逻辑,那么最关键的工程约束只有两条:

  • 渲染必须是纯的:组件函数里只做计算,不做订阅/请求/写外部状态
  • effects 必须可逆:setup 与 cleanup 必须配对且完整(订阅/监听/定时器/外部实例)

关联阅读

cd ..