React 核心原理与 Hooks 实战:虚拟 DOM、Fiber 架构、Diff 算法与状态管理

React 核心原理与 Hooks 实战:虚拟 DOM、Fiber 架构、Diff 算法与状态管理
神经蛙Vue 用 Proxy 把”数据变了就更新视图”做成响应式,React 则相反:它几乎不追踪依赖,而是靠 虚拟 DOM + Diff + 可中断调度 来决定”这次该更新哪”。本文把 React 的任督二脉——协调(Reconciliation)与 Fiber——彻底拆开,再看 Hooks 怎么把状态与副作用组织得既灵活又容易踩坑。
一、为什么是 React:声明式与组件化
传统 jQuery 时代,我们手写”查到 DOM → 改样式 → 绑事件”的命令式代码,状态一多就变成难以维护的”面条”。React 的核心承诺是:
- 声明式:你描述”UI 应该长什么样”(基于当前 state),而不是”一步步怎么改”。
- 组件化:把界面拆成可复用、自包含的函数/类,数据自顶向下流动。
1 | function Counter() { |
你只管声明 count 与视图的关系,count 变化时 React 自己决定怎么更新真实 DOM。
关键认知:React 并不”智能地”只更新那一小块。它先在内存里算出新的虚拟 DOM,再和旧的对比(Diff),最后把差异最小化地提交到真实 DOM。代价是 Diff 计算本身——这就是后面 Fiber 要解决的问题。
二、JSX 不是 HTML:编译与 createElement
<button> 这种写法叫 JSX,它不是浏览器能懂的 HTML,而是语法糖。Babel 会把它编译成 React.createElement 调用:
1 | // 你写的 |
createElement 返回的不是一个 DOM 节点,而是一个普通 JS 对象(虚拟 DOM 节点):
1 | // 结构(简化) |
正因为 JSX 最终是 JS 对象,所以你可以像写普通函数一样组合 UI:把 JSX 当返回值、用数组 map 出列表、用条件表达式控制显隐——这都是纯 JS 能力,不是模板语法的特权。
三、虚拟 DOM 与 Diff 算法
3.1 为什么要 Diff
理想情况下每次 state 变化都”删掉整棵树重画”最省心,但真实 DOM 操作昂贵。React 的策略是:用 JS 计算换 DOM 操作——先在内存里 Diff 新旧虚拟 DOM,只把必要的变更 patch 到真实 DOM。
3.2 三个”偷懒”但有效的假设
React 的 Diff 之所以快,靠的是三个工程化假设而非”精确比对”:
| 假设 | 含义 | 带来的优化 |
|---|---|---|
| 同层比较 | 只比较同一层级的节点,不跨层移动 | 把 O(n³) 降到 O(n) |
| 类型不同即重建 | 同位置 div 变 span,直接销毁重建整棵子树 |
避免无意义的逐属性 diff |
| key 标识身份 | 列表用 key 标记”谁是谁”,而非按位置 |
精准复用、移动节点而非重建 |
3.3 key 的作用(最常踩的坑)
1 | // ❌ 用 index 当 key:列表逆序/中间删除时,复用错位,状态串台 |
没有稳定 key 时,React 按”位置”复用节点,这会导致输入框内容、组件内部 state 在错误元素上”串台”。
四、Fiber 架构:把渲染变成可中断的任务
4.1 Stack Reconciler 的痛点
React 15 及以前用栈式递归遍历虚拟 DOM:一旦开始 Diff,就必须一口气递归到叶子,中途无法暂停。如果组件树很大,这次渲染会长时间占用主线程,导致输入框卡顿、动画掉帧。
4.2 Fiber 是什么
Fiber 把”递归遍历”改成了”链表遍历 + 可中断调度”。每一个组件/节点对应一个 Fiber 节点,它们通过 child、sibling、return 三个指针串成链表:
1 | fiber (div) |
每个 Fiber 记录了自己的工作状态,React 可以算完一个节点就停下来,把控制权交还浏览器,等空闲了再继续——这就是”时间切片(Time Slicing)”。
4.3 双缓存树与 workInProgress
React 在内存里同时维护两棵树:
- current 树:当前屏幕上正在显示的版本。
- workInProgress 树:正在构建的下一个版本。
1 | // 概念示意:构建新树时不直接改 current,完成后整体替换 |
为什么不直接在 current 上改?因为渲染过程可能被中断、被丢弃(比如有更高优先级更新插队)。先在 workInProgress 上安静地构建,全部就绪后一次性 commit,用户永远不会看到”半成品”界面。
五、渲染的两大阶段:render 与 commit
| 阶段 | 是否可中断 | 做什么 | 能否操作 DOM |
|---|---|---|---|
| Render(协调) | ✅ 可中断 | Diff、打上增删改标记(Effect 列表) | ❌ 不能 |
| Commit(提交) | ❌ 必须一气呵成 | 真正增删改 DOM、执行 ref、触发生命周期 | ✅ 能 |
Commit 阶段又分 mutation(改 DOM)和 layout(读布局、执行 useLayoutEffect)。因为 commit 不可中断,所以它必须足够短——这也解释了为什么重活要放在 render 阶段”可中断”地做。
六、Hooks 原理与实战
Hooks 让函数组件也能拥有 state 和副作用,但它是建立在闭包 + 调用顺序之上的,理解这点才能避开绝大多数坑。
6.1 闭包陷阱
1 | function Timer() { |
1 | // ✅ 用函数式更新拿最新值 |
6.2 useState 的批量更新
React 18 起,所有场景(包括 Promise、setTimeout 内)默认自动批处理:
1 | // 连续两次 setState 只会触发一次重渲染 |
6.3 useEffect:依赖与清理
1 | React.useEffect(() => { |
useEffect(fn, deps) 的语义是:”当 deps 变化后,做点副作用,并在下次变化或卸载前清理”。漏写依赖、或在 effect 里读了没声明的变量,是 React 报错(react-hooks/exhaustive-deps)的主要来源。
6.4 useMemo / useCallback:避免无效重渲染
父组件重渲染时,默认子组件也会跟着重渲染。用 memo + 稳定引用掐断这条链路:
1 | const Child = React.memo(function Child({ data, onClick }) { |
6.5 自定义 Hook:逻辑复用范式
1 | // 把"关注窗口宽度"这段逻辑抽成可复用的 Hook |
七、状态管理演进
当状态需要跨组件共享,组件树逐层透传 props 会迅速失控。方案随场景演进:
| 方案 | 适用场景 | 核心思想 |
|---|---|---|
| Context | 低频共享(主题、登录态) | 跨层级直接注入,但值变化会让所有消费组件重渲染 |
| Redux Toolkit | 中大型、需要可预测/可回溯 | 单一 store + 不可变更新 + 中间件 |
| Zustand | 轻量、按需订阅 | 极简 store,组件只订阅自己关心的切片 |
| useSyncExternalStore | 接入外部可变数据源 | 官方桥接 API,保证并发模式下的数据一致性 |
经验法则:能用 useState + 局部状态解决的,别上全局状态库;全局状态里,高频变化的数据(如输入框内容)要单独切片,避免拖累整棵消费树。
八、并发特性与性能优化
React 18 把”并发”从实验特性变成正式能力,核心是让低优先级更新让路给高优先级交互。
| 能力 | 作用 |
|---|---|
React.memo |
浅比较 props,阻止无关重渲染 |
React.lazy + Suspense |
组件按需加载,避免首屏打一个巨大包 |
startTransition |
把”非紧急更新”(如输入联想)标记为可打断 |
useDeferredValue |
延迟一个值,让紧急渲染先完成 |
1 | function Search() { |
九、React vs Vue3 速查对比
| 维度 | React | Vue 3 |
|---|---|---|
| 更新机制 | 虚拟 DOM + Diff(不追踪依赖) | Proxy 响应式(精准追踪依赖) |
| 状态模型 | 不可变 + setState | 可变 + 响应式赋值 |
| Diff 策略 | Fiber 可中断协调 | 编译期 PatchFlag + Block Tree |
| 组合逻辑 | Hooks(闭包 + 调用顺序) | Composition API(setup + 函数) |
| 学习曲线 | 概念少但需懂闭包/Hooks 规则 | 上手快,但响应式细节需理解 |
| 生态风格 | 显式、自由度高 | 约定多、开箱集成度高 |
十、生产避坑清单
- 列表必须给稳定
key,别用index兜底,否则状态串台。 - 警惕
useEffect闭包陷阱:读最新值用函数式更新,或把依赖写全。 useEffect依赖要诚实:漏写变量会读到旧值,多写变量会无意义重跑。memo配useCallback才有意义:传给子组件的回调函数引用必须稳定。- 别在 render 阶段写副作用(如直接改全局、发起请求),一律放 effect。
useState初始值只算一次:复杂初始化用惰性函数useState(() => heavy())。StrictMode双调用是故意的:effect 跑两次用来暴露漏写清理的 bug,别为了”安静”而关掉它。- 状态不要无脑塞全局:高频变化的数据单独切片,避免整棵消费树重渲染。
- 大列表/大组件用
lazy+Suspense拆包,减小首屏体积。 setState是异步批处理的:同一函数里多次调用只会触发一次渲染,别指望”立刻读到新值”。
React 的难点从来不在 API 多,而在心智模型:把”state → 视图”当成纯函数,把”副作用”严丝合缝地关进 effect,把”昂贵的协调”交给 Fiber 切片。想通这三点,闭包陷阱和重渲染问题都会迎刃而解。
结语
React 与 Vue 是前端两条并行且都正确的路线:一个用”可中断的虚拟 DOM 协调”换灵活性,一个用”精准响应式”换开发体验。理解 Fiber 与协调机制,不只是为了面试,更能在遇到”页面卡顿””状态不更新””列表错乱”时,知道根因在哪一环。















