Skip to content
React 工作原理:Virtual DOM 与协调
概述
一个 UI 界面的最终形态始终是 DOM 节点。想让界面跟随数据变化,最直接的做法是找到受影响的节点,调用 .textContent 或 .appendChild 之类的命令式操作。在逻辑简单的场景中完全够用,但随着界面复杂度上升,应用要管理的状态越来越多,开发者需要在两套思维模型之间切换:先拿到最新数据,再推算界面差量,最后手动执行 DOM 更新。出错的概率也随之攀升。
React 的做法是把“UI 应该长什么样”描述成一份轻量的 JavaScript 对象树,放在内存里。每次状态发生变化,就重新生成一棵新的对象树,再用框架比较两棵树的差异,只把真正需要改变的部分写入真实 DOM。这份内存中的 UI 描述就是 Virtual DOM。
除了开发模型从“一步步操作 DOM”转变为“声明界面,框架负责同步”之外,Virtual DOM 还能减少因多次手动 DOM 操作带来的额外布局计算与重绘。多次分散的 DOM 修改,特别是那些会影响元素几何属性的操作,会让浏览器反复计算样式、布局和绘制,增加主线程负载,容易造成帧率下降。把多次差异先收集到内存对象里,最后一次性应用到真实 DOM,可以减少这类无谓开销。不过 Virtual DOM 的核心价值不在于这种批量更新(浏览器本身也会做一定程度的合并),而在于开发体验:界面成为状态的映射,开发者不再需要亲自维护两套数据的同步关系。
基本概念:React 元素与 Virtual DOM 树
在 React 中,JSX 编写的标签经编译后会变成 React.createElement() 调用,返回一个普通对象。这个对象就是 React 元素。
javascript
// <div className="container">hello</div>
const element = React.createElement(
'div',
{ className: 'container' },
'hello'
);调用返回的对象结构大致如下:
javascript
{
type: 'div',
props: {
className: 'container',
children: 'hello'
}
}一个组件的 render 输出的不是字符串,而是一层嵌套的 React 元素对象。这些对象以树的形式组织起来,就形成了 Virtual DOM 树。例如一个简单的列表组件:
javascript
function ItemList({ items }) {
return (
<ul>
{items.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
}会被转化为类似这样的对象树:
javascript
{
type: 'ul',
props: {
children: [
{ type: 'li', props: { children: 'Apple' }, key: '1' },
{ type: 'li', props: { children: 'Banana' }, key: '2' }
]
}
}React 元素仅仅是描述界面的数据,并不直接参与渲染。开发者只和 Virtual DOM 打交道,渲染器负责把这份数据从虚拟形态同步到真实 DOM 上。
协调的工作原理
Virtual DOM 提供了变更前后的两棵树,框架接下来的任务就是找出它们之间的差异。如果对任意两棵树计算最小编辑距离,通用算法的时间复杂度会达到 O(n³),在高频更新场景下并不可行。React 采用一套启发式策略,将复杂度控制在 O(n)。
同层比较与类型判断
协调过程遵循三个核心前提:
- 只对同一层级的节点进行比较。如果节点跨层级移动,React 不会尝试复用,而是直接认为是删除旧节点、创建新节点。
- 两个元素的
type不同(例如<div>变为<span>),则该节点连同其全部子节点都会被销毁重建,不会尝试保留内部的 DOM 片段。 type相同时,只更新发生变化的属性(如className、style等),保留现有 DOM 节点。
javascript
// 旧树
<div className="old">
<p>text</p>
</div>
// 新树
<span className="new">
<p>text</p>
</span>在这个例子中,<div> 与 <span> 的类型不同。React 会销毁旧的 <div> 及其子 <p>,然后创建全新的 <span> 和新的 <p>,即使 <p> 内部的文本完全没有改变。这种“宁可重建也不深层比较”的策略虽然有时会浪费一些 DOM 节点,但换来了稳定的 O(n) 比较开销。
列表 Diff 与 Key
遇到子节点为列表(如 [<li/>, <li/>, ...])时,仅凭类型和属性比较很难高效匹配。假设向列表头部插入一项:
javascript
// 旧
<ul>
<li>1</li>
<li>2</li>
</ul>
// 新
<ul>
<li>0</li>
<li>1</li>
<li>2</li>
</ul>在没有额外信息的情况下,React 会按顺序比较第一个子节点:旧的 <li>1</li> 与新的 <li>0</li> 类型相同但文本不同,于是标记为文本更新;第二个子节点同样需更新;末尾还需要新增一个 <li>2</li>。实际上只插入了一个节点,原来的两个节点可以完全复用,最优方案是把第一个 <li>1</li> 往下移一位,再在最前面插入 <li>0</li>。要实现这种优化,就需要 Key。
Key 是配给每个列表子节点的稳定标识。React 在协调时通过 key 把新树中的元素与旧树中具有相同 key 的元素关联起来,从而判断哪些节点需要移动、哪些可以被复用、哪些需要新增或删除。
不使用 Key 时的默认行为
如果列表中的每一项都没有 key,React 会按索引顺序逐一比较:第一个旧子节点与第一个新子节点比较,第二个与第二个比较,以此类推。这种默认行为在列表的顺序或长度发生变化时很难稳定地复用节点,可能导致不必要的重建,甚至出现组件内部状态丢失。
Key 错误使用:索引作为 Key 的状态错位
直接用数组索引作为 key 是一个常见误区。表面上消除了警告,实际行为跟不写 key 几乎一样——当列表重新排序或增删项时,同样位置的子节点仍按索引匹配。
下面是一个暴露问题的场景:每个列表项内部维护一个不受 React 控制的输入框,我们期望删除第一项后,剩下的输入框内容能跟着对应的项一起保留。
javascript
function ItemList() {
const [items, setItems] = useState([
{ id: 'a', text: 'Apple' },
{ id: 'b', text: 'Banana' },
{ id: 'c', text: 'Cherry' }
]);
const deleteFirst = () => setItems(prev => prev.slice(1));
return (
<div>
<button onClick={deleteFirst}>删除第一项</button>
<ul>
{items.map((item, index) => (
<li key={index}>
{item.text}
<input type="text" />
</li>
))}
</ul>
</div>
);
}初始渲染时,三个 <li> 的 key 分别为 0、1、2。用户在三个输入框里分别输入了 “A‑content”、“B‑content”、“C‑content”。点击按钮删除第一项后,列表变成两项,key 重新计算为 0 和 1(对应 Banana 和 Cherry):
- 旧
key=1(Banana,输入框值为 “B‑content”) ➜ 新key=0的位置。React 发现key相同,于是复用该 DOM 节点,但把文本更新为 “Apple”(新数据 Apple 占据了索引 0 的位置),而输入框 DOM 被保留,值依然是 “B‑content”。 - 旧
key=2(Cherry,输入框值为 “C‑content”) ➜ 新key=1的位置。复用,文本更新为 “Banana”,输入框值仍是 “C‑content”。
最终输入框里的内容与列表项文本完全错位。如果改用稳定的 key={item.id},React 就能根据 id 准确识别每个节点,仅删除 a 对应的那一项,其他节点保持不动,输入框的状态也会跟着节点正确迁移。
Key 只需要在同一父节点下的兄弟节点之间保持唯一,不必全局唯一。同时,Key 只在同一层兄弟间比较,跨层级的节点即使 key 相同也不会被关联。
Render 阶段与 Commit 阶段
一次状态更新从调用 setState(或 useState 的更新函数)到最终反映在界面上,要经过两个主要阶段。
Render 阶段:计算变更
setState 的调用被标记后,React 会在后续的调度中重新执行函数组件。组件重新 return 的 React 元素组成一棵新的 Virtual DOM 树。随后协调过程启动,将新树与当前界面对应的旧 Fiber 树进行比较,输出一系列操作指令:插入、更新、移动或删除节点。整个 Render 阶段是可中断的——React 16 引入的 Fiber 架构让渲染过程可以被打断,把控制权交还给浏览器处理更高优先级的任务(如用户输入);但从概念上说,这一步的结果始终是“需要应用到 DOM 的一串变更”。
Commit 阶段:应用变更
Render 阶段计算出的变更指令在 Commit 阶段被一次性执行到真实 DOM。这个过程不可中断,确保界面不会出现半成品状态。DOM 更新完成后,React 还会执行相关的副作用(例如 useEffect 的回调)以及更新 ref 的最新值。
一个简化的更新路径如下:
javascript
// 初始渲染
root.render(<App />);
// 某个动作触发状态更新
setCount(c => c + 1);
// → 调度重新渲染
// → Render 阶段:执行 App(),得到新元素树,与旧树协调,产出变更指令
// → Commit 阶段:将差异批量应用到真实 DOM注意点
- Virtual DOM 不会直接消除所有 DOM 操作开销。它只是在每次状态变更时尽量少地修改 DOM,并将多次修改合并为一次批量操作。静态内容较多的页面,Virtual DOM 仍然会在每次渲染时构建新旧两棵树并进行遍历比较,带来额外计算成本。
- 协调算法不保证找到理论上的最小更新集合,而是用启发式策略在 O(n) 时间内给出一个可接受的方案。跨层级移动元素会被直接销毁重建,因此应避免频繁改变 DOM 的层级结构。
- 使用索引作为
key不仅会在顺序变动时造成状态错位,在列表头部频繁插入或删除时还会降低性能:React 会原地更新后续每一项的文本与属性,而不是只移动少数几个节点。 - 首次渲染大量 Virtual DOM 节点时,因为多了对象创建和遍历组装的过程,会比直接操作真实 DOM 稍慢。这个差异通常只在数万节点级别才变得明显。
- Render 阶段可能被多次执行(高优先级任务打断),因此不要在其中放置副作用操作(如直接修改 DOM、发送请求)。副作用应放入
useEffect中。 - Virtual DOM 是纯运行时的机制,即便树的某一部分从未发生变化,每次重渲染仍会为这些部分创建新的 vnode,带来一定的内存分配压力。
