React 运行时全揭秘

ReactDOM.render 做了什么 → Fiber 架构 → setState 怎么批处理 → Hooks 到底存在哪 → diff 真相

Fiber 架构 Reconciliation Hooks 原理 Commit 阶段
周五晚上9点,线上监控报警——首页白屏率飙升到15%。

排查发现:一个 useEffectaddEventListener 了窗口resize事件,但没有写 cleanup 函数。每次组件重新渲染,都注册一个新的事件监听器——2小时后,每个用户浏览器里堆了500+个resize监听器,一resize就触发500次回调,CPU直接拉满。

根因:useEffect 缺少 return cleanup → 事件监听器无限堆积
修复:加上 return () => window.removeEventListener('resize', handler)
教训:useEffect 里有订阅/监听/定时器,必须写 cleanup,否则内存泄漏只是时间问题
🗺️ 学习路线图:从"懂启动"到"避坑实战" ① 懂启动 createRoot做了什么 Fiber=链表结构 Render vs Commit ② 明机制 setState批处理 Hooks链表存储 diff三假设+key ③ 会避坑 useEffect必须cleanup 闭包陷阱(stale closure) state更新无限循环 📌 三个层次,逐步深入 第①层搞懂"React内部怎么跑" → 第②层搞懂"状态更新怎么流转" → 第③层搞懂"怎么避免踩坑" 很多React线上事故都出在第③层——useEffect没cleanup、闭包拿不到最新值
学习路线:先懂启动 → 再明机制 → 最终会避坑实战
① ReactDOM.render → createRoot:应用启动
React 18 用 createRoot,底层创建 Fiber 根节点,开始协调
React 18 应用启动流程 ① createRoot(document.getElementById('root')) 创建 FiberRoot(整个应用的根) ② root.render(<App />) 开始协调 → 构建 Fiber 树 ③ 执行 App() → 返回 JSX JSX 编译成 React.createElement → 虚拟DOM ④ Commit → 创建真实DOM → 挂载 页面出现内容 此时页面空白 #root 空 div 页面出现! 用户看到内容 step1 step2 step3 step4
createRoot 创建 Fiber 根节点,render 启动协调流程
📁 React 项目
📁 src
📄 main.tsx ←
📄 App.tsx
📁 components
📄 Counter.tsx
📄 index.html
// main.tsx — 应用入口 import { createRoot } from 'react-dom/client' import App from './App' const root = createRoot(document.getElementById('root')!) root.render(<App />) // root.render 内部做了什么: // 1. 创建 FiberRootNode(整个应用的 Fiber 根) // 2. 创建 RootFiber(第一层组件的 Fiber) // 3. 进入 Render 阶段(可中断): // - 执行 App() → 返回 JSX → 编译为 React.createElement 调用 // - 递归处理子组件 → 每个 return 的 JSX → 创建对应的 Fiber 节点 // - 对比新旧 Fiber → 标记需要更新的节点(副作用 flags) // 4. 进入 Commit 阶段(不可中断): // - 根据标记的 flags → 操作真实 DOM(插入/更新/删除) // - 执行 useEffect/useLayoutEffect 回调 // 5. 页面出现内容
🏗️ 类比:建筑设计与施工

📐 JSX/虚拟DOM = 建筑设计图纸(描述了界面长什么样)

📋 Fiber 节点 = 施工任务清单(每个组件/元素对应一个 Fiber,记录"我要怎么更新")

🔨 Commit 阶段 = 实际施工(按图纸操作真实DOM)

React 和 Vue 最大区别:React 把"设计图"和"施工"分成了两个阶段——先在内存中完成所有计算(Render),再一次性操作 DOM(Commit)。这样可以在 Render 阶段被中断/恢复,不阻塞浏览器。

✅ 搞懂了React应用启动——createRoot + render 启动协调流程
→ 接下来深入Fiber:React 16的核心架构为什么比15好?
② Fiber:React 16+ 的核心架构
为什么 React 需要 Fiber?因为旧的递归渲染会阻塞浏览器——大组件树卡死主线程
Fiber 树:链表结构的组件树 React 15:递归调用栈 App → 递归进入子组件 → 递归进入更深层... → 一路到底,无法中断 组件树很大时 → 主线程被占用数秒 → 卡死 React 16+:Fiber 链表 App Header Main List Logo Card 链表结构 → 可随时暂停/恢复 → 时间分片 一个 Fiber 节点的内部结构 child → 第一个子 sibling → 兄弟 return → 父节点 alternate → 旧副本 stateNode → 真实 DOM 引用 指向对应的 DOM 元素 Commit 阶段通过它操作 DOM memorizedState → Hooks 链表 useState/useEffect 的数据 以链表形式存在 Fiber 上
Fiber 是链表结构的虚拟节点树——通过 child/sibling/return 指针连接,可以遍历/暂停/恢复
🚦 类比:高速公路服务区

React 15(递归) = 乡间小路,只有一条道,进去了必须走到底才能回来。如果路太长(组件树太深),后面的车(浏览器渲染、用户交互)全部被堵住。

React 16+(Fiber) = 高速公路服务区。每开一段路(处理一个 Fiber 节点),检查时间——如果快到 5ms 了,就进服务区让路(交还控制权给浏览器),等浏览器处理完用户交互/渲染后再继续。

这就是"可中断渲染"的本质——Fiber 的链表结构让 React 可以记住"渲染到哪了",暂停后从断点继续。

核心认知 React 为什么比 Vue 复杂? 因为 React 选择了"可中断异步渲染"(Concurrent Mode)——它需要在内存中先构建完整的 Fiber 树(Render 阶段),再一次性提交到 DOM(Commit 阶段)。这个设计让 React 能够处理大型应用不卡顿,但代价是概念更多(Fiber、Reconciliation、Commit、Scheduler)。Vue 选择了更简单的同步渲染 + 细粒度响应式。
✅ 搞懂了Fiber——链表结构让渲染可中断、可恢复
→ 接下来看渲染三阶段:状态更新后React内部到底做了什么?
③ 渲染三阶段:Render → Reconciliation → Commit
一次状态更新触发三个阶段,每个阶段做什么必须分清楚
阶段1:触发更新(Trigger)

用户点击 → setCount(1) → React 标记 App 组件为"需要更新"

→ 把更新任务放入调度队列

阶段2a:Render(协调/Reconciliation)

在内存中进行,不碰真实 DOM!

→ 重新执行 App() 及子组件的函数体 → 返回新的 JSX

→ 对比新旧 Fiber 树 → 标记需要更新的节点(打 flags)

这个阶段可以被中断/恢复(时间片到了就暂停,让浏览器处理交互)

⚠️ 注意:函数组件在这个阶段被多次调用是正常的!不要在这里写副作用。

阶段2b:Diff(对比新旧 Fiber)

逐节点对比 current Fiber(旧)和 workInProgress Fiber(新)

规则:同 type 同 key → 复用(标记 Update);不同 type → 销毁+创建(标记 Placement/Deletion)

阶段3:Commit(提交,不可中断)

这个阶段才操作真实 DOM!

→ 根据 Render 阶段标记的 flags,执行 DOM 操作

→ 分三个子阶段:beforeMutation(读取DOM信息)→ mutation(执行DOM操作)→ layout(执行 useLayoutEffect)

→ 最后异步执行 useEffect

阶段4:浏览器渲染

DOM 更新后,浏览器重新计算样式、布局、绘制 → 用户看到变化

⚠️ 为什么不能在组件函数体中写副作用?

因为组件函数在 Render 阶段被调用,而这个阶段可以被中断和重复执行。React 可能因为时间片到了而暂停,之后从头重新调用你的组件函数。

如果你在函数体中写了 document.title = count → 可能被执行多次 → 行为不可预测。

正确做法:副作用放到 useEffect 里——它在 Commit 阶段之后执行,保证只执行一次。

📁 React 组件
📁 src
📄 Counter.tsx ←
📄 App.tsx
function Counter() { // ===== Render 阶段(可多次执行)===== const [count, setCount] = useState(0) // ❌ 错误:在函数体中直接修改DOM // document.title = `Count: ${count}` // 可能被执行多次! // ✅ 正确:副作用放到 useEffect useEffect(() => { document.title = `Count: ${count}` // Commit后只执行一次 }, [count]) return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(count + 1)}>+1</button> </div> ) // return 的 JSX 在 Render 阶段生成 // DOM 操作在 Commit 阶段执行 // useEffect 在 Commit 后执行 }
✅ 搞懂了渲染三阶段——Trigger → Render(可中断) → Commit(不可中断)
→ 接下来看批处理:多次setState为什么不会渲染多次?
④ setState 批处理:多次更新合并成一次
React 不是每次 setState 都立即更新——它把多次调用收集起来,一起处理
📁 批处理原理
📁 执行顺序
setCount(1)
setCount(2)
setCount(3)
合并为一次 ←
function Demo() { const [count, setCount] = useState(0) const [name, setName] = useState('Tom') function handleClick() { // ===== React 18 自动批处理 ===== setCount(1) // ① 入队,不立即渲染 setCount(2) // ② 入队,覆盖①,最终count=2 setName('Jerry') // ③ 入队,name更新 setCount(3) // ④ 入队,覆盖②,最终count=3 // 同步代码结束后 → React 只重新渲染一次! // count = 3, name = 'Jerry' // 而不是渲染4次 } // ===== 如果需要基于前一个值更新 ===== function addThree() { setCount(count + 1) // ❌ 最终count=1,不是3 // ✅ 正确:用函数式更新 setCount(prev => prev + 1) // prev=0 → 1 setCount(prev => prev + 1) // prev=1 → 2 setCount(prev => prev + 1) // prev=2 → 3 ✅ } return <button onClick={handleClick}>Click</button> }
批处理:多次 setState → 一次渲染 setCount(1) setCount(2) setCount(3) 更新队列 count: 多次更新合并 取最后一次:count=3 name: 更新为 'Jerry' 只渲染一次 重新执行组件函数 count=3, name='Jerry' → Commit → DOM更新 React 18 所有场景都自动批处理(同步/异步/事件/Promise/定时器) React 17 只在事件处理函数中批处理,异步回调(setTimeout/Promise)中不批处理
React 18 自动批处理:无论在哪里调用 setState,都收集到队列,最后只渲染一次
📝 类比:提交审批

你改了3个文件(3次 setState)→ 不需要每改一个文件就提交一次审批(渲染)。

而是把所有修改攒着 → 一起提交审批 → 审批一次(渲染一次)。

这就是批处理——性能优化。如果不批处理,改3次就渲染3次,浏览器可能卡顿。

✅ 搞懂了批处理——多次setState合并成一次渲染
→ 接下来看Hooks:useState的数据到底存在哪?
⑤ Hooks 到底存在哪:useState 的数据住在 Fiber 上
Hooks 不是魔法——它的数据以链表形式存在组件对应的 Fiber 节点上
Hooks 链表:存在 Fiber 节点的 memorizedState 上 Counter 组件的 Fiber 节点 memorizedState → Hook #1 useState(0) value: 3 queue: [更新队列] Hook #2 useState('Tom') value: 'Jerry' queue: [更新队列] Hook #3 useEffect(fn, []) effect: fn() deps: [] ⚠️ 为什么 Hooks 必须在顶层调用(不能在 if/for 里)? 因为 Hooks 靠"调用顺序"与 Fiber 链表中的位置匹配 如果第一次:Hook#1=count, Hook#2=name, Hook#3=effect 第二次因为 if 条件跳过了 Hook#1 → Hook#2 被匹配到 count 的位置 → 错乱!
Hooks 以链表形式存在 Fiber 上,React 靠调用顺序匹配——这就是 Hooks 规则的根本原因
📁 Hooks 原理
📁 关键认知
Hooks = 链表节点
顺序 = 位置匹配
函数组件每次调用 ←
遍历链表
function Counter() { // 每次组件函数执行时,React 按调用顺序遍历 Fiber 的 Hooks 链表 const [count, setCount] = useState(0) // → 读取链表第1个节点 const [name, setName] = useState('Tom') // → 读取链表第2个节点 useEffect(() => { ... }, []) // → 读取链表第3个节点 // ❌ 绝对不能这样写: if (count > 0) { const [x, setX] = useState(0) // 条件可能不满足 → 链表顺序错乱 } // 如果某次 count<=0 跳过了这个 useState // → 后面的 useEffect 被匹配到 useState 的链表位置 // → 数据类型不匹配 → Bug 或崩溃 } // useState 内部简化逻辑: let currentHook = fiber.memorizedState // 链表头 function useState(initial) { const hook = currentHook // 取当前节点 currentHook = currentHook.next // 指向下一个 return [hook.value, hook.setter] }
核心原理 Hooks 的"魔法"本质:函数组件每次执行,React 都按调用顺序遍历 Fiber 上的 Hooks 链表。第一个 useState 对应链表第1个节点,第二个对应第2个……只要顺序不变,每次渲染都能正确匹配。这就是为什么 Hooks 不能放在条件语句/循环里——会打乱顺序,导致匹配错位。
✅ 搞懂了Hooks存储——链表结构+调用顺序匹配
→ 接下来看diff和Vue对比,然后进入实战避坑
⑥ diff 真相:React 怎么对比新旧子节点
和 Vue 类似但实现不同——React 用 key 优化列表更新

React diff 的三个假设

1. 不同类型的元素产生不同的树。如果从 <div> 变成 <span>,React 销毁旧的整棵子树,创建全新的。不会尝试复用。

2. 同类型的元素,保留 DOM 节点,只更新属性。<div className="a"> 变成 <div className="b"> → 复用同一个 div,只改 className。

3. 通过 key 标识子元素。列表中的元素如果有 key,React 可以识别"这是同一个元素,只是位置变了"→ 移动而不是销毁重建。

⚠️ 为什么 React 的 key 不能用 index?

假设列表 [A, B, C],key 分别是 [0, 1, 2]。删掉 A 后变成 [B, C],key 变成 [0, 1]。

React 看到:key=0 从 A 变成 B(更新内容),key=1 从 B 变成 C(更新内容),key=2 消失了(删除C)。

但实际上 B 和 C 并没有变,只是位置移动了!React 错误地认为它们的内容变了 → 不必要的更新。更严重的是:如果列表元素有内部状态(如 input 的输入值),状态会绑到错误的元素上。

正确做法:用数据的唯一 id 做 key:key={item.id}

⑦ React vs Vue:运行时对比
同样都是"数据驱动DOM",但实现路径完全不同
对比维度 React Vue 3
响应式实现 无细粒度响应式,靠 setState 触发整个组件重新执行 Proxy + track/trigger,精确知道哪个属性变了
组件更新粒度 整个组件函数重新执行(除非 memo 优化) 只有依赖变化的组件重新渲染
渲染方式 异步可中断(Fiber + 时间分片) 同步(Vue 3 推出后也较快)
DOM 更新时机 Commit 阶段(Render 之后) nextTick 微任务中
状态管理 Hooks(useState/useEffect),链表存储 ref/reactive,Proxy 代理
模板 vs JSX JSX = JS 写法,更灵活 模板编译,静态优化更强
类比 推土机——大力出奇迹,整个组件重跑 手术刀——精准定位变化的属性

🤔 哪种更好?

没有绝对的更好,只有更适合的场景。

React 的"整体重跑"思路更简单直接(心智负担小),但在大型应用中可能需要手动优化(React.memo/useMemo/useCallback)。Vue 的"精准追踪"性能更好(自动优化),但需要理解响应式原理(proxy、track、trigger)。

✅ 搞懂了React全链路——启动→Fiber→渲染→批处理→Hooks→diff
→ 接下来进入实战:React最常见的三大坑,每个都是血泪教训
⑧ React三大陷阱 ❌✅对比
开头的事件监听器泄漏事故不是个例。以下三个坑,几乎每个React开发者都踩过。

陷阱1:useEffect 没 cleanup → 事件监听器/定时器无限堆积

📁 ❌ 没有cleanup
📁 src
📄 BadResize.tsx ←
function BadResize() { const [width, setWidth] = useState(window.innerWidth) useEffect(() => { // ❌ 没有return cleanup! window.addEventListener('resize', () => { setWidth(window.innerWidth) }) // 每次组件重新渲染: // 1. 上一个监听器还在 // 2. 又注册了一个新的监听器 // 3. 10次渲染 = 10个监听器 // 4. 一resize触发10次回调 → CPU飙升 }, [width]) // width变了就重新执行effect return <div>Window: {width}px</div> }
📁 ✅ 正确cleanup
📁 src
📄 GoodResize.tsx ←
function GoodResize() { const [width, setWidth] = useState(window.innerWidth) useEffect(() => { // ✅ 定义handler const handler = () => setWidth(window.innerWidth) window.addEventListener('resize', handler) // ✅ 返回cleanup函数 return () => { window.removeEventListener('resize', handler) // React在下一次effect执行前自动调用 // 保证任何时刻只有1个监听器 } }, []) // 空依赖 → 只注册一次 return <div>Window: {width}px</div> }
规则 useEffect cleanup 口诀:有 addEventListener → 必须有 removeEventListener;有 setInterval → 必须有 clearInterval;有订阅 → 必须有取消订阅。问自己:这个 effect 注册了什么?清理语句就是它的"反操作"。

陷阱2:闭包陷阱(Stale Closure)→ useEffect里拿到的不是最新值

📁 ❌ Stale Closure
📁 src
📄 BadTimer.tsx ←
function BadTimer() { const [count, setCount] = useState(0) useEffect(() => { const id = setInterval(() => { // ❌ count永远是0! // 这个effect只在mount时执行了一次 // 回调函数捕获了创建时的count=0(闭包) // 即使外部count已经变了,这里还是0 setCount(count + 1) // 永远是 0+1=1 }, 1000) return () => clearInterval(id) }, []) // 空依赖 → effect只执行一次 → 闭包捕获的count=0 return <div>{count}</div> }
📁 ✅ 正确方案
📁 src
📄 GoodTimer.tsx ←
function GoodTimer() { const [count, setCount] = useState(0) useEffect(() => { const id = setInterval(() => { // ✅ 方案1:用函数式更新,不需要依赖count setCount(prev => prev + 1) // prev始终是最新的 }, 1000) return () => clearInterval(id) }, []) // ✅ 方案2(如果需要用到count做其他事):把count加到依赖数组 // useEffect(() => { // const id = setInterval(() => { // setCount(count + 1) // 依赖里有count,每次count变effect重新执行 // }, 1000) // return () => clearInterval(id) // }, [count]) // ← 加上count依赖 return <div>{count}</div> }
核心原因 闭包陷阱的本质:useEffect 的回调函数在创建时"捕获"了当时的变量值。如果依赖数组里没有这个变量,回调永远用的是旧值。解决方式两种:① 函数式更新 setCount(prev => prev+1)——不需要依赖count;② 把变量加到依赖数组 [count]——变量变了就重新创建回调。

陷阱3:useEffect 里直接 setState → 无限渲染循环

📁 ❌ 无限循环
📁 src
📄 BadInfinite.tsx ←
function BadInfinite() { const [data, setData] = useState([]) const [count, setCount] = useState(0) // ❌ 无限渲染循环! useEffect(() => { fetch('/api/data').then(res => res.json()).then(d => { setData(d) // → 触发渲染 setCount(1) // → 又触发渲染 }) }) // ← 没有依赖数组!每次渲染都执行effect // 渲染→effect→setData→渲染→effect→setData→... // 浏览器卡死,控制台疯狂报错 // ❌ 另一种常见错误 useEffect(() => { setCount(count + 1) // count变 → effect重执行 → count又变 → ... }, [count]) // 依赖了count,setCount又改count → 无限循环 return <div>{count}</div> }
📁 ✅ 正确做法
📁 src
📄 GoodFetch.tsx ←
function GoodFetch() { const [data, setData] = useState([]) const [count, setCount] = useState(0) // ✅ 方案1:空依赖数组 → 只在mount时执行一次 useEffect(() => { fetch('/api/data').then(res => res.json()).then(d => { setData(d) }) }, []) // ← 空依赖,只在mount时fetch一次 // ✅ 方案2:如果需要依赖某个变量来决定是否重新fetch const [userId, setUserId] = useState(1) useEffect(() => { fetch(`/api/users/${userId}`).then(...) }, [userId]) // ← 只在userId变化时才重新fetch return <div>{count}</div> }
判断规则 useEffect 无限循环诊断三步:
1. effect 里调了 setState?→ 会触发重新渲染
2. 重新渲染后 effect 会再执行吗?→ 看依赖数组
3. 如果依赖数组里有被 setState 修改的变量 → 无限循环
解法:问自己"这个 effect 应该在什么时候执行?"只mount → [];依赖变量变 → [var];但不要依赖被自己修改的变量。
⚠️ React三大陷阱速查表
陷阱现象根因修复
陷阱1内存泄漏/CPU飙升useEffect 没写 cleanupreturn cleanup(removeEventListener/clearInterval)
陷阱2useEffect 里变量值是旧的闭包捕获了创建时的值(stale closure)函数式更新 setCount(prev=>) 或加依赖数组
陷阱3页面卡死/控制台疯狂报错effect里setState → 依赖变量 → 又触发effect空依赖[] 或 确保依赖不被自己修改
✅ 搞懂了React三大陷阱——cleanup/闭包/无限循环
→ 最后用面试速答检验一下,看看是否真的理解了
⑨ 高频疑问
Q1:useEffect 和 useLayoutEffect 有什么区别?

useEffect:在 Commit 阶段之后,异步执行(不阻塞浏览器渲染)。适合大多数副作用:数据获取、事件监听、日志。

useLayoutEffect:在 DOM 更新后、浏览器 paint 之前同步执行。适合需要读取 DOM 布局信息并立即修改的场景(如测量元素大小后调整位置)。因为它会阻塞渲染,尽量少用。

执行顺序:DOM更新 → useLayoutEffect → 浏览器paint → useEffect

Q2:为什么 useEffect 的依赖数组很重要?

useEffect(fn, deps) 的含义:"当 deps 中的值变化时,执行 fn"

[] → 只在挂载时执行一次(相当于 componentDidMount)

[count] → count 变化时执行

无依赖数组 → 每次渲染都执行(几乎一定不是你想要的)

漏掉依赖 → useEffect 里用的变量不是最新的(闭包陷阱)。这就是为什么 ESLint 有 react-hooks/exhaustive-deps 规则。

Q3:React.memo、useMemo、useCallback 什么时候用?

React.memo:包裹组件 → 当 props 没变时跳过重新渲染。适合"纯展示组件"。

useMemo:缓存一个计算结果 → 当依赖没变时返回缓存值。适合开销大的计算。

useCallback:缓存一个函数 → 当依赖没变时返回同一个引用。配合 React.memo 使用——如果父组件传函数给 memo 的子组件,不用 useCallback 每次渲染函数都是新引用 → memo 失效。

不要过度优化——只在性能确实有问题时用。大多数情况下 React 的默认行为已经足够快。

Q4:React 的虚拟 DOM 真的比直接操作 DOM 快吗?

不是!虚拟 DOM 本身有开销(创建 VNode、diff 对比)。如果和精心优化的原生 DOM 操作比,虚拟 DOM 更慢。

虚拟 DOM 的价值在于开发体验跨平台:你写声明式的 JSX,React 帮你算出最小 DOM 操作。而且同一套虚拟 DOM 可以渲染到浏览器、移动端(React Native)、SSR。

Vue 的编译时优化(静态提升、补丁标记)比 React 的纯运行时 diff 更快——这也是 Vue 在 benchmark 中经常胜出的原因。

Q5:useState 的初始值是函数时有什么区别?

useState(0) → 每次渲染都计算 0(无所谓,开销极小)

useState(() => expensiveCompute())惰性初始化,只在第一次渲染时执行函数

如果初始值需要经过复杂计算(如从 localStorage 读取并解析),用函数形式避免每次渲染都重复计算。

Q6:React 18 的 Concurrent Mode 到底解决了什么问题?

Concurrent Mode 让 React 的渲染变成可中断的。之前(React 17),一旦开始渲染就不能停,如果组件树很大,主线程被占住,用户交互(点击/输入)会感觉到延迟。

React 18 的 Concurrent Mode 让 React 在渲染过程中可以暂停,让浏览器先处理用户交互,然后再继续渲染。具体API:useTransition(标记低优先级更新)、useDeferredValue(延迟值)。

类比:React 17 = 单行道,车一上路就得到终点;React 18 = 高速公路,有应急车道让行。

Q7:useRef 和 useState 的区别?什么时候用 useRef?

核心区别:useState 变化 → 触发重新渲染;useRef 变化 → 不触发渲染。

useRef 的典型场景:
1. 获取 DOM 引用:const ref = useRef(); <div ref={ref}>
2. 存储不需要触发渲染的值:定时器ID、上一次的值(previousValue)
3. 不触发重渲染的"实例变量"

陷阱:不要用 ref 代替 state 来存储需要驱动UI更新的值——改了 ref 页面不会变!

Q8:React 严格模式(StrictMode)下 useEffect 为什么执行两次?

React 18 的 StrictMode 在开发环境下会故意两次调用 useEffect(mount → unmount → 再 mount)。

目的是帮你发现副作用代码的不安全问题——如果你的 effect 有正确的 cleanup,两次执行应该和一次执行结果一样。如果没有 cleanup(如事件监听器泄漏),两次执行就会暴露问题。

只在开发环境生效,生产环境不会两次执行。不要为了"解决"这个问题而关闭 StrictMode,而应该确保每个 effect 都有正确的 cleanup。

🎯 React 运行时速记三原则

1. 渲染链路三阶段:Trigger → Render(可中断) → Commit(不可中断)

2. Hooks本质:链表存储+调用顺序匹配 → 不能放if/for里

3. 避坑三件套:cleanup必写 + 闭包用函数式更新 + 依赖别被自己改

📁 运行原理图解 / 05-React运行时 — 配套阅读:④ Vue3运行时 · ① Java请求全链路
核心三原则速记

React三原则:① 状态更新不可变,永远不要直接修改 state → ② useEffect 依赖项必须完整,否则会闭包陷阱 → ③ key 帮助 diff 算法识别节点身份,不要用数组索引当 key