既然 Vue 可以通过响应式探测数据变化,为什么还需要 Virtual DOM Diff?
原文只有一个面试题链接。这里补充完整回答:响应式系统负责知道“哪些状态依赖发生变化”,Virtual DOM/renderer 负责知道“声明式 UI 应该如何变成新的宿主节点”。两者解决的是不同问题,不能相互替代。
一、先修正一个前提:Vue 不一定知道“具体 DOM 节点”变了
Vue 的响应式系统可以比较精确地知道:
哪个对象的哪个属性被读取
哪个属性后来被写入
哪些组件渲染 effect / watcher 依赖这个属性
但这不等于它天然知道某个属性对应 DOM 中的哪一个具体节点。例如:
<template>
<section :data-count="count">
<h1>{{ count }}</h1>
<p v-if="count > 0">当前有数据</p>
</section>
</template>
当 count 改变时,它影响 attribute、文本内容和条件分支。响应式系统主要知道当前组件的 render effect 读取了 count,并触发这个 effect 重新运行;“哪些 DOM 节点需要新增、修改、移动或删除”仍然属于渲染器和 patch 的工作。
更准确的模型是:
响应式系统:状态 → 依赖 / effect
渲染函数:依赖 → 新的 VNode 树
renderer:旧 VNode + 新 VNode → DOM 操作
Vue 2 的渲染 watcher 通常以组件渲染为粒度;Vue 3 使用 ReactiveEffect 和编译器提示进一步缩小工作范围,但仍不能把状态属性直接等同为单个 DOM 节点。
二、如果已经知道数据变了,为什么不能直接改 DOM
对于极简单的页面,当然可以手写:
import { ref, watch } from 'vue'
const count = ref(0)
const el = document.querySelector('#count')
watch(count, value => {
el.textContent = String(value)
})
但真实页面很快会遇到问题:
- 一个状态被多个文本、class、style、attribute 和组件同时使用;
v-if会新增或删除整棵子树;v-for会插入、删除、排序和移动节点;- 子组件、slot、Transition、Teleport 有自己的渲染边界;
- 表单 property 和 HTML attribute 的更新规则不同;
- 异步组件、KeepAlive、SSR hydration 需要统一生命周期;
- 多次状态修改需要批量调度,避免重复读写 DOM;
- 业务代码如果直接保存 DOM 引用,状态和视图会强耦合。
因此,响应式系统只给出“需要重新计算某个渲染结果”的信号,不能替代一个通用的 UI reconciliation 层。
三、Virtual DOM Diff 解决的是什么问题
Virtual DOM 是对期望 UI 的可组合描述:
const oldVNode = {
type: 'button',
props: { class: 'normal' },
children: '保存',
}
const newVNode = {
type: 'button',
props: { class: 'primary' },
children: '保存中',
}
Diff/patch 可以把这两个描述转换为:
更新 button.class
更新 button.textContent
保留原有 button 节点、焦点和事件
如果节点类型和 key 不同,renderer 可能需要卸载旧节点并创建新节点;如果 children 是 keyed list,则需要匹配、移动、创建和删除。这个过程把 DOM 操作集中在框架内部,而不是散落在业务 watcher 中。
function patch(oldVNode, newVNode) {
if (!sameVNode(oldVNode, newVNode)) {
replace(oldVNode, newVNode)
return
}
patchProps(oldVNode.el, oldVNode.props, newVNode.props)
patchChildren(oldVNode.el, oldVNode.children, newVNode.children)
}
这段代码只是概念示意。真正的 Vue renderer 还要处理文本、注释、Fragment、组件、指令、Transition、Teleport、Suspense、SVG、SSR hydration 等类型。
四、响应式和 Diff 的配合关系
Vue 2.7
Object.defineProperty
↓
Dep 收集 render Watcher
↓
setter / 数组方法调用
↓
queueWatcher + nextTick
↓
vm._render() 生成新 VNode
↓
vm._update() / patch
Vue 2 的 getter/setter 能精确定位属性依赖,但渲染 watcher 通常重新执行组件 render,再由 patch 比较新旧 VNode。
Vue 3.5
Proxy / ref getter
↓
ReactiveEffect track
↓
trigger + scheduler
↓
组件 render effect 重新运行
↓
VNode patch
Vue 3 compiler 还会提供:
- 静态节点提升;
- Patch Flags;
- Block Tree;
- keyed children 的专门路径;
- 动态后代扁平化。
因此 Vue 3 并不是“只要使用 Proxy 就不需要 Virtual DOM”,而是响应式、编译器和 renderer 一起工作,避免遍历和更新不需要变化的部分。
五、一个状态影响多个节点的例子
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<button :disabled="count >= 3">
点击次数:{{ count }}
</button>
<p v-if="count >= 3">已经达到上限</p>
</template>
count 从 2 变成 3 时,至少有这些变化:
- button 的
disabledproperty 从 false 变成 true; - button 文本从“点击次数:2”变成“点击次数:3”;
- 新增提示 p 节点;
- 事件、组件生命周期和后续状态仍需保持正确。
响应式系统负责触发这个组件的更新;VNode/renderer 负责根据前后 UI 描述提交这些变化。把三处 DOM 更新全部写在一个 watcher 中当然可以,但那实际上是在手写一个局部 renderer。
六、Diff 并不意味着每次都全量比较
“使用 Virtual DOM 就一定要完整遍历整棵树”也不准确。现代 Vue 会结合:
- 组件更新边界;
- 静态提升;
- Patch Flags;
- Block Tree;
- key;
- scheduler 批处理。
但也不能反过来说 Diff 一定产生理论上的最小 DOM 操作。不同类型节点、复杂动态 key、未优化 render function、组件边界和宿主平台都会影响实际工作量。
七、什么时候可以不用 Virtual DOM
Virtual DOM 是一种取舍,不是所有界面都必须使用:
- 静态 HTML 可以直接由服务器输出;
- Canvas/WebGL 通常有自己的场景树和绘制模型;
- 专门的细粒度响应式系统可以直接订阅 DOM 绑定;
- 小型交互用原生 DOM 可能更简单;
- Vue 自定义 renderer 可以把同样的 VNode 协调逻辑适配到其他平台。
选择框架时应看更新模式、团队维护成本、SSR/可访问性要求和性能测量,而不是只比较“有没有 Virtual DOM”。
八、面试中的推荐回答
可以这样总结:
Vue 的响应式系统和 Virtual DOM Diff 解决的是两个层面的问题。响应式系统负责追踪状态被哪些渲染 effect 读取,并在状态变化后调度相关组件更新;render function 会生成新的 VNode。Virtual DOM renderer 再比较新旧 VNode,决定哪些属性、文本、子节点需要更新、插入、移动或删除,并统一处理组件、指令、slot 和不同宿主环境。Vue 2 依赖
defineProperty + Dep/Watcher,Vue 3 主要使用Proxy + ReactiveEffect,但两者都仍需要 renderer。Vue 3 还通过静态提升、Patch Flags 和 Block Tree 减少 Diff 成本。响应式知道“状态依赖变了”,不等于直接知道“哪个 DOM 节点应该怎样改”。
参考资料
原文只有问题标题和讨论链接,本文补充了响应式、渲染 effect、VNode、Diff、编译器优化和直接 DOM 更新之间的关系。