React StrictMode:为什么会“双调用”?(源码/机制视角)
一句话结论:React 在开发环境的
StrictMode下会刻意制造“更苛刻的执行场景”——包括对某些 render 相关函数的重复调用,以及在 React 18 中对 Effects 做“模拟卸载再挂载”的双执行——用来提前暴露「不纯渲染 / 缺少清理 / 依赖时序」等问题;这些行为不会发生在生产环境。
注意:本文描述的“双调用”主要指 React 18 开发环境 + StrictMode 的行为。
1. StrictMode 到底“严格”在哪里?
StrictMode 不是一个运行时特性开关(不会改变生产行为),它更像一组 DEV-only 的诊断器。
从效果上看,它主要做两件事:
- 检测“不纯渲染”(impure render)
- 通过重复调用一些 render 相关逻辑,让“带副作用的渲染”更容易暴露(例如渲染时写全局变量、请求、订阅、随机数等)
- 检测“副作用没有正确清理”(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 下会走一个额外的流程,概念上类似:
- 正常 commit:挂载 DOM、执行 layout effects、安排 passive effects
- 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
- layout:
- 与 StrictMode 相关的 DEV flags:常见如
MountLayoutDev,MountPassiveDev(名字可能随版本演进)
阅读建议:你如果已经看过本仓库的 Fiber/commitRoot 文章,可以把 StrictMode 的 DEV 行为当作“commitRoot 的一个 DEV 分支”。
4. 它为什么只在开发环境出现?
因为这些检查会:
- 增加额外的 mount/unmount 开销
- 改变某些时序(比如 effect 的执行次数)
这类行为如果带到生产环境,既影响性能,也会改变业务逻辑。因此 React 把它限定为 DEV 工具。
5. 常见误区(从机制推导出的结论)
- “useEffect 怎么执行了两次?React 有 bug?”
- 不是 bug,是 DEV + StrictMode 的诊断行为
- “我用 StrictMode 会不会让线上也执行两次?”
- 不会。生产构建不会做这些 DEV double invoke
- “我只要关掉 StrictMode 就好了?”
- 关掉只是“把烟雾报警器拆了”,问题本身(缺少清理/不纯渲染)仍然存在
6. 从架构角度的落地约束(最少但关键)
既然 StrictMode 的目标是逼你写出“可重入”的逻辑,那么最关键的工程约束只有两条:
- 渲染必须是纯的:组件函数里只做计算,不做订阅/请求/写外部状态
- effects 必须可逆:setup 与 cleanup 必须配对且完整(订阅/监听/定时器/外部实例)
关联阅读
- 并发模式概念:
notes/03-concurrent-mode/01-concept.md - commitRoot(你已有):
notes/05-hooks/14-commitRoot.md - useEffect 实现原理(你已有):
notes/08-interview/17-useEffect-impl.md