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

Vue 用 Proxy 把”数据变了就更新视图”做成响应式,React 则相反:它几乎不追踪依赖,而是靠 虚拟 DOM + Diff + 可中断调度 来决定”这次该更新哪”。本文把 React 的任督二脉——协调(Reconciliation)与 Fiber——彻底拆开,再看 Hooks 怎么把状态与副作用组织得既灵活又容易踩坑。

一、为什么是 React:声明式与组件化

传统 jQuery 时代,我们手写”查到 DOM → 改样式 → 绑事件”的命令式代码,状态一多就变成难以维护的”面条”。React 的核心承诺是:

  • 声明式:你描述”UI 应该长什么样”(基于当前 state),而不是”一步步怎么改”。
  • 组件化:把界面拆成可复用、自包含的函数/类,数据自顶向下流动。
1
2
3
4
function Counter() {
const [count, setCount] = React.useState(0);
return <button onClick={() => setCount(count + 1)}>点了 {count} 次</button>;
}

你只管声明 count 与视图的关系,count 变化时 React 自己决定怎么更新真实 DOM。

关键认知:React 并不”智能地”只更新那一小块。它先在内存里算出新的虚拟 DOM,再和旧的对比(Diff),最后把差异最小化地提交到真实 DOM。代价是 Diff 计算本身——这就是后面 Fiber 要解决的问题。

二、JSX 不是 HTML:编译与 createElement

<button> 这种写法叫 JSX,它不是浏览器能懂的 HTML,而是语法糖。Babel 会把它编译成 React.createElement 调用:

1
2
3
4
5
// 你写的
const el = <h1 className="title">Hello</h1>;

// 编译后等价于
const el = React.createElement('h1', { className: 'title' }, 'Hello');

createElement 返回的不是一个 DOM 节点,而是一个普通 JS 对象(虚拟 DOM 节点):

1
2
3
4
5
6
// 结构(简化)
{
type: 'h1',
props: { className: 'title', children: 'Hello' },
// ...React 内部字段
}

正因为 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
2
3
4
5
// ❌ 用 index 当 key:列表逆序/中间删除时,复用错位,状态串台
{list.map((item, i) => <li key={i}>{item}</li>)}

// ✅ 用稳定唯一 id
{list.map(item => <li key={item.id}>{item.text}</li>)}

没有稳定 key 时,React 按”位置”复用节点,这会导致输入框内容、组件内部 state 在错误元素上”串台”。

四、Fiber 架构:把渲染变成可中断的任务

4.1 Stack Reconciler 的痛点

React 15 及以前用栈式递归遍历虚拟 DOM:一旦开始 Diff,就必须一口气递归到叶子,中途无法暂停。如果组件树很大,这次渲染会长时间占用主线程,导致输入框卡顿、动画掉帧。

4.2 Fiber 是什么

Fiber 把”递归遍历”改成了”链表遍历 + 可中断调度”。每一个组件/节点对应一个 Fiber 节点,它们通过 child、sibling、return 三个指针串成链表:

1
2
3
4
5
      fiber (div)
/ | \
child sibling return
| | |
(h1) (p) (parent)

每个 Fiber 记录了自己的工作状态,React 可以算完一个节点就停下来,把控制权交还浏览器,等空闲了再继续——这就是”时间切片(Time Slicing)”。

4.3 双缓存树与 workInProgress

React 在内存里同时维护两棵树:

  • current 树:当前屏幕上正在显示的版本。
  • workInProgress 树:正在构建的下一个版本。
1
2
// 概念示意:构建新树时不直接改 current,完成后整体替换
// commit 阶段才把 wip 树一次性切换为 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
2
3
4
5
6
7
8
9
10
11
function Timer() {
const [n, setN] = React.useState(0);
React.useEffect(() => {
const id = setInterval(() => {
// ❌ 这里的 n 永远是 0:effect 闭包捕获的是首次渲染的 n
setN(n + 1);
}, 1000);
return () => clearInterval(id);
}, []); // 空依赖:effect 只跑一次
return <span>{n}</span>;
}
1
2
3
4
5
6
7
8
// ✅ 用函数式更新拿最新值
setN(prev => prev + 1);

// 或把 n 放进依赖(但会重启定时器,通常不如函数式更新)
React.useEffect(() => {
const id = setInterval(() => setN(p => p + 1), 1000);
return () => clearInterval(id);
}, []);

6.2 useState 的批量更新

React 18 起,所有场景(包括 Promise、setTimeout 内)默认自动批处理:

1
2
3
// 连续两次 setState 只会触发一次重渲染
setA(1);
setB(2);

6.3 useEffect:依赖与清理

1
2
3
4
React.useEffect(() => {
const ws = new WebSocket(url);
return () => ws.close(); // ✅ 清理函数:组件卸载/依赖变化前执行
}, [url]); // url 变了 → 先跑旧清理,再跑新 effect

useEffect(fn, deps) 的语义是:”当 deps 变化后,做点副作用,并在下次变化或卸载前清理”。漏写依赖、或在 effect 里读了没声明的变量,是 React 报错(react-hooks/exhaustive-deps)的主要来源。

6.4 useMemo / useCallback:避免无效重渲染

父组件重渲染时,默认子组件也会跟着重渲染。用 memo + 稳定引用掐断这条链路:

1
2
3
4
5
6
7
8
9
10
11
const Child = React.memo(function Child({ data, onClick }) {
return <li onClick={onClick}>{data.name}</li>;
});

function List({ items }) {
// ✅ 用 useCallback 让 onClick 引用稳定,否则 memo 失效
const handleClick = React.useCallback(id => {/* ... */}, []);
// ✅ 用 useMemo 缓存昂贵计算
const sorted = React.useMemo(() => items.slice().sort(byName), [items]);
return sorted.map(it => <Child key={it.id} data={it} onClick={handleClick} />);
}

6.5 自定义 Hook:逻辑复用范式

1
2
3
4
5
6
7
8
9
10
// 把"关注窗口宽度"这段逻辑抽成可复用的 Hook
function useWindowWidth() {
const [w, setW] = React.useState(window.innerWidth);
React.useEffect(() => {
const onResize = () => setW(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return w;
}

七、状态管理演进

当状态需要跨组件共享,组件树逐层透传 props 会迅速失控。方案随场景演进:

方案 适用场景 核心思想
Context 低频共享(主题、登录态) 跨层级直接注入,但值变化会让所有消费组件重渲染
Redux Toolkit 中大型、需要可预测/可回溯 单一 store + 不可变更新 + 中间件
Zustand 轻量、按需订阅 极简 store,组件只订阅自己关心的切片
useSyncExternalStore 接入外部可变数据源 官方桥接 API,保证并发模式下的数据一致性

经验法则:能用 useState + 局部状态解决的,别上全局状态库;全局状态里,高频变化的数据(如输入框内容)要单独切片,避免拖累整棵消费树。

八、并发特性与性能优化

React 18 把”并发”从实验特性变成正式能力,核心是让低优先级更新让路给高优先级交互。

能力 作用
React.memo 浅比较 props,阻止无关重渲染
React.lazy + Suspense 组件按需加载,避免首屏打一个巨大包
startTransition 把”非紧急更新”(如输入联想)标记为可打断
useDeferredValue 延迟一个值,让紧急渲染先完成
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Search() {
const [query, setQuery] = React.useState('');
const [results, setResults] = React.useState([]);
const deferred = React.useDeferredValue(query); // 紧急:输入框;非紧急:结果列表
React.useEffect(() => {
fetchResults(deferred).then(setResults);
}, [deferred]);
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<Results list={results} />
</>
);
}

九、React vs Vue3 速查对比

维度 React Vue 3
更新机制 虚拟 DOM + Diff(不追踪依赖) Proxy 响应式(精准追踪依赖)
状态模型 不可变 + setState 可变 + 响应式赋值
Diff 策略 Fiber 可中断协调 编译期 PatchFlag + Block Tree
组合逻辑 Hooks(闭包 + 调用顺序) Composition API(setup + 函数)
学习曲线 概念少但需懂闭包/Hooks 规则 上手快,但响应式细节需理解
生态风格 显式、自由度高 约定多、开箱集成度高

十、生产避坑清单

  1. 列表必须给稳定 key,别用 index 兜底,否则状态串台。
  2. 警惕 useEffect 闭包陷阱:读最新值用函数式更新,或把依赖写全。
  3. useEffect 依赖要诚实:漏写变量会读到旧值,多写变量会无意义重跑。
  4. memo 配 useCallback 才有意义:传给子组件的回调函数引用必须稳定。
  5. 别在 render 阶段写副作用(如直接改全局、发起请求),一律放 effect。
  6. useState 初始值只算一次:复杂初始化用惰性函数 useState(() => heavy())。
  7. StrictMode 双调用是故意的:effect 跑两次用来暴露漏写清理的 bug,别为了”安静”而关掉它。
  8. 状态不要无脑塞全局:高频变化的数据单独切片,避免整棵消费树重渲染。
  9. 大列表/大组件用 lazy + Suspense 拆包,减小首屏体积。
  10. setState 是异步批处理的:同一函数里多次调用只会触发一次渲染,别指望”立刻读到新值”。

React 的难点从来不在 API 多,而在心智模型:把”state → 视图”当成纯函数,把”副作用”严丝合缝地关进 effect,把”昂贵的协调”交给 Fiber 切片。想通这三点,闭包陷阱和重渲染问题都会迎刃而解。

结语

React 与 Vue 是前端两条并行且都正确的路线:一个用”可中断的虚拟 DOM 协调”换灵活性,一个用”精准响应式”换开发体验。理解 Fiber 与协调机制,不只是为了面试,更能在遇到”页面卡顿””状态不更新””列表错乱”时,知道根因在哪一环。