React 运行时全揭秘
ReactDOM.render 做了什么 → Fiber 架构 → setState 怎么批处理 → Hooks 到底存在哪 → diff 真相
Fiber 架构
Reconciliation
Hooks 原理
Commit 阶段
周五晚上9点,线上监控报警——首页白屏率飙升到15%。
排查发现:一个 useEffect 里 addEventListener 了窗口resize事件,但没有写 cleanup 函数。每次组件重新渲染,都注册一个新的事件监听器——2小时后,每个用户浏览器里堆了500+个resize监听器,一resize就触发500次回调,CPU直接拉满。
根因:useEffect 缺少 return cleanup → 事件监听器无限堆积
修复:加上 return () => window.removeEventListener('resize', handler)
教训:useEffect 里有订阅/监听/定时器,必须写 cleanup,否则内存泄漏只是时间问题
学习路线:先懂启动 → 再明机制 → 最终会避坑实战
① ReactDOM.render → createRoot:应用启动
React 18 用 createRoot,底层创建 Fiber 根节点,开始协调
createRoot 创建 Fiber 根节点,render 启动协调流程
// 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 是链表结构的虚拟节点树——通过 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 阶段之后执行,保证只执行一次。
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 都立即更新——它把多次调用收集起来,一起处理
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>
}
React 18 自动批处理:无论在哪里调用 setState,都收集到队列,最后只渲染一次
📝 类比:提交审批
你改了3个文件(3次 setState)→ 不需要每改一个文件就提交一次审批(渲染)。
而是把所有修改攒着 → 一起提交审批 → 审批一次(渲染一次)。
这就是批处理——性能优化。如果不批处理,改3次就渲染3次,浏览器可能卡顿。
✅ 搞懂了批处理——多次setState合并成一次渲染
→ 接下来看Hooks:useState的数据到底存在哪?
⑤ Hooks 到底存在哪:useState 的数据住在 Fiber 上
Hooks 不是魔法——它的数据以链表形式存在组件对应的 Fiber 节点上
Hooks 以链表形式存在 Fiber 上,React 靠调用顺序匹配——这就是 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 → 事件监听器/定时器无限堆积
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>
}
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里拿到的不是最新值
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>
}
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 → 无限渲染循环
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>
}
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 没写 cleanup | return cleanup(removeEventListener/clearInterval) |
| 陷阱2 | useEffect 里变量值是旧的 | 闭包捕获了创建时的值(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必写 + 闭包用函数式更新 + 依赖别被自己改
核心三原则速记
React三原则:① 状态更新不可变,永远不要直接修改 state → ② useEffect 依赖项必须完整,否则会闭包陷阱 → ③ key 帮助 diff 算法识别节点身份,不要用数组索引当 key