Vue 为什么选择异步更新 DOM?

85 阅读3分钟

刚接触 Vue 的时候,你可能会好奇:我明明刚改了数据,为什么 DOM 没有立刻变?Vue 为什么不直接同步更新?

现在从浏览器渲染原理出发,讲一下 Vue 做这个设计决策背后的理由。

1、DOM 操作为什么"贵"

要理解异步更新的价值,先得知道:操作 DOM 并不像改一个 JS 变量那么“便宜”。

每次你修改 DOM,浏览器都要做两件很重的事:

1.1 Reflow(重新布局)

元素的尺寸或位置变了,浏览器需要重新计算整个页面的布局:这个元素变大了,旁边的元素要不要移位?父容器要不要撑高?等等。

这是一个从上到下、牵一发动全身的计算过程,页面越复杂,越耗时。

1.2 Repaint(重新绘制)

布局算完了,浏览器把新的画面重新绘制到屏幕上。

即使你只改了一个小元素的颜色(不影响布局),也会触发 Repaint。

打个比方:DOM 操作就像建筑工地改图纸。改一处,不管改动大小,都得把整张图重新核对一遍、重新打印——这个过程是固定成本,改 1 次和改 10 次,每次的代价是一样的。

2、如果 Vue 选择同步更新,会发生什么

假设你在一个方法里连续修改了 3 个数据:

// 用户点击了「刷新」按钮
refresh() {
  this.isLoading = true     // 改数据 → 同步触发 DOM 更新 ①
  this.list = []            // 改数据 → 同步触发 DOM 更新 ②
  this.isLoading = false    // 改数据 → 同步触发 DOM 更新 ③
}

同步更新意味着:

  • 改 isLoading = true → 浏览器重新布局+绘制一次
  • 改 list = [] → 浏览器重新布局+绘制一次
  • 改 isLoading = false → 浏览器重新布局+绘制一次

共触发 3 次 Reflow + 3 次 Repaint。

但用户最终只能看到第 ③ 步之后的结果——前两次的渲染,对用户来说完全是浪费,用户甚至感知不到那两个中间状态(因为 JS 是单线程的,浏览器在 JS 执行期间不会渲染屏幕)。

3、Vue 的异步批处理策略

Vue 的实际做法是:

refresh() {
  this.isLoading = true     // 只记入队列,不动 DOM
  this.list = []            // 只记入队列,不动 DOM
  this.isLoading = false    // 只记入队列,不动 DOM
}
// 当前 JS 执行完毕 → Vue 把队列里所有变化合并 → 只更新 DOM 一次

只触发 1 次 Reflow + 1 次 Repaint,结果和同步更新完全一样,但省掉了 2/3 的渲染开销。

这就是异步批处理的核心价值:把同一个 tick 内所有的数据变化合并,只做一次 DOM 更新。

4、真实场景下差多少

简单页面(改 2~3 个数据):差距不明显,现代浏览器处理得很快。

但在以下场景,差距会非常显著:

场景 1:大列表渲染

// 假设 list 有 100 条数据,每条数据单独更新某个字段
this.list.forEach(item => {
  item.selected = false  // 如果是同步更新,触发 100 次 DOM 更新
})
// Vue 异步批处理:100 次数据变化 → 合并 → 只更新 1 次 DOM

场景 2:用户高频交互

用户快速滑动进度条,每移动一下触发一次数据更新。

  • 同步:每次滑动都重绘,60fps 下每秒 60 次 Repaint,页面容易卡顿
  • 异步:同一帧内的多次更新合并成 1 次,流畅很多

场景 3:父子组件连锁更新

父组件数据变化导致 10 个子组件都需要更新:

  • 同步:10 个子组件各自重绘一次 = 10 次 Repaint
  • 异步:统一在一次 tick 内完成所有子组件更新 = 1 次 Repaint

在复杂应用中,异步批处理带来的性能提升可以是数倍到数十倍。

5、代价是什么?也就是:$nextTick 的由来

这个设计唯一的代价,就是:改完数据后,不能立刻读取 DOM。

this.showPanel = true
// ❌ 此刻 DOM 还没更新,panel 可能还不在页面上
const height = this.$refs.panel.offsetHeight // 拿到旧值或报错

解决方案就是 $nextTick,它让你在 Vue 完成这一轮 DOM 更新后,再执行需要读取 DOM 的逻辑:

this.showPanel = true
this.$nextTick(() => {
  // ✅ DOM 已更新
  const height = this.$refs.panel.offsetHeight // 正确
})

这是一个经典的用代码复杂度换运行性能的设计取舍——大多数情况下,你根本不需要在改完数据后立刻读 DOM,所以这个代价很小;而性能收益在复杂应用中非常可观。

6、总结

同步更新Vue 异步批处理
每次数据变化立刻触发 DOM 更新记入队列,等 JS 执行完再统一更新
一个方法里改 N 次数据触发 N 次 Reflow/Repaint只触发 1 次
代码复杂度低(改完数据可立刻读 DOM)需要 $nextTick 协调
渲染性能差(大量冗余渲染)好(只渲染最终状态)

Vue 选择异步更新,本质上是在说:用户只关心最终结果,中间状态的渲染是浪费,我替你省掉它。