Skip to content
Vue 与 React 工作原理:依赖追踪、虚拟 DOM 与协调
Vue 和 React 解决同一个问题:数据变了,界面自动跟着变。但在“怎么知道数据变了”和“怎么更新界面”这两步上,两套方案从根上就不一样。
Vue 走的是依赖追踪路线:数据被代理对象包裹,读的时候收集谁在用这个数据,写的时候通知这些订阅者重新执行。React 走的是状态快照路线:每次更新生成一棵新的 Virtual DOM 树,跟上一棵做比较,找出差异来操作真实 DOM。
这两条路线决定了后面所有设计——更新精度、调度策略、性能瓶颈的位置,全都不一样。
Vue 的依赖追踪:effect、track 与 trigger
Vue 3 的响应式系统由两部分拼成:一个是 Proxy 创建的代理对象,另一个是副作用订阅机制。Proxy 负责拦下属性的读和写,但它自己不知道“谁在用这个数据”——这件事由 track 和 trigger 配合 effect 来完成 [2]。
effect 是一个包装器,它做的事情很简单:把一个函数标记为“当前正在执行的副作用”,执行这个函数,然后取消标记。当这个函数内部去读取响应式对象的属性时,Proxy 的 get 拦截触发 track,track 一看,有一个活跃的副作用存在,就把它记录到这个属性的订阅列表里 [5]。
javascript
// effect 的简化行为:执行前设置 activeEffect,执行后清空
let activeEffect
function effect(fn) {
activeEffect = fn
fn() // 执行期间,内部读取操作会触发 track
activeEffect = null
}这里的关键是副作用函数必须先执行一次。不执行,就没有读取行为,也就无从建立依赖关系。这也是为什么 Vue 的 watchEffect 会立即执行回调——它需要这次执行来完成依赖收集。
track:读取时收集依赖
track 在属性被读取时触发。它的核心数据结构是一个三级存储:
WeakMap<target, Map<key, Set<effect>>>第一层用 WeakMap 是因为当响应式对象被销毁时,对应的依赖关系也应该被 GC 回收,不需要手动清理。第二层是每个属性到一个副作用集合的映射 [3]。
javascript
const targetMap = new WeakMap()
function track(target, key) {
if (!activeEffect) return // 没有正在执行的副作用,不追踪
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
deps.add(activeEffect)
}在 Vue 的实际实现里,track 还处理了很多边界情况:同一个副作用可能被多次收集(需要去重),effect 可能嵌套(需要栈来管理 activeEffect),有些 effect 被标记为 lazy 不会立即执行。但这些都建立在这个三级映射之上。
trigger:写入时触发更新
trigger 在属性被写入时触发。它做的事情就是把写入属性的所有订阅副作用找出来,重新执行 [4]。
javascript
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const deps = depsMap.get(key)
if (!deps) return
// 创建副本再遍历,防止执行过程中 deps Set 被修改导致死循环
const effectsToRun = new Set(deps)
effectsToRun.forEach(effect => effect())
}这里有个容易被忽略的细节:trigger 执行副作用时是同步的。如果在副作用里又写入了同一个属性,deps 集合在执行过程中可能被修改,所以需要先创建一个副本。Vue 实际实现中还有调度器介入——多次 trigger 可能不会立即执行,而是攒到下一个 tick 统一处理。
一个最小依赖图示例
把 track、trigger 和 effect 拼在一起,就是 Vue 响应式系统的最小工作模型:
javascript
// 创建响应式对象
function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, value) {
target[key] = value
trigger(target, key)
return true
}
})
}
// 使用
const state = reactive({ count: 0, name: 'Vue' })
effect(() => {
console.log(`count 是 ${state.count}`)
})
// 立即输出: count 是 0
state.count = 1
// 触发 trigger,输出: count 是 1
state.name = 'React'
// count 的副作用不会被触发,因为 name 不在它的依赖集合里运行这段代码可以看到两件事:state.count 被修改时,只有访问过 count 的副作用重新执行;修改 state.name 不会触发那个副作用,因为副作用内部没有读取 name,track 阶段没有建立依赖。这就是 Vue 所说的“精准更新”——不是把整个组件重跑一遍,而是只重跑依赖了变化属性的那些副作用。
Vue 的更新触发:从数据变化到组件重新渲染
组件渲染本身也是一个 effect。Vue 内部把组件的 render 函数包装在一个 effect 里,这样 render 过程中读取的所有响应式数据都会被自动追踪。数据变化时 trigger 触发这个 render effect,组件重新渲染。
但重新渲染不等于重新生成整个 DOM。Vue 在 render 之后还有一层 Virtual DOM diff——它和 React 一样,也会比对新旧虚拟节点树,找到最小差异后应用 DOM 操作。区别在于触发机制:Vue 是依赖追踪告诉它“这个组件需要重新渲染”,然后在这个组件内部做 diff;React 是整个组件树自上而下走一遍,靠 diff 来决定哪些地方需要更新。
Vue 3 的 render effect 队列还存在批处理机制。同一个同步代码块中多次修改同一个属性,或者修改多个相互关联的属性,trigger 不会立即执行副作用,而是把 effect 加入一个队列,等当前同步代码执行完毕后统一处理。这就是为什么在同一个事件循环里连续改三次 state.count,组件只会渲染一次:
javascript
state.count = 1
state.count = 2
state.count = 3
// 当前同步代码结束后才触发一次渲染这种行为由调度器控制,不是 track/trigger 本身的行为。如果需要在下一次 DOM 更新后执行回调,Vue 提供了 nextTick。
Virtual DOM 在 React 中的角色
React 没有依赖追踪。每次 setState 或状态更新,React 不知道具体是哪个组件用了哪个属性——它只知道状态变了,需要重新执行组件函数,生成新的 Virtual DOM 树 [10]。
Virtual DOM 本质上是一棵用普通对象描述的树。每个节点存储了类型(div、span、自定义组件)、属性、子节点这些信息。它不直接对应真实 DOM,只是一份 JavaScript 内存中的描述。
React 的渲染函数执行完后,产生一棵新的 Virtual DOM 树。然后 React 把这棵新树和内存中保存的旧树做比较——这个过程叫 reconciliation,协调。比较的结果是一系列 DOM 操作指令:哪个元素需要插入、哪个属性需要更新、哪个节点需要删除。最后 React 把这些指令批量应用到真实 DOM 上。
这里的关键点是:React 每次更新的起点是“状态变了”,它重跑整个组件函数,产生一棵新的虚拟树,然后和旧的比。它不知道哪个状态绑定到哪个元素,所以就走全量比较的路。把整个树的比较做高效,是 React 解决更新性能的路线。
Fiber 架构:工作单元、双缓存与可中断协调
Reconciliation 本身是纯 JavaScript 计算。如果组件树很大,一次全量 diff 可能耗时几十毫秒甚至更长——直接卡住主线程,浏览器无法响应用户输入,页面掉帧。
React 16 引入的 Fiber 架构就是解决这个问题的。核心思路是:把一次大范围的协调任务拆成一个个小的“工作单元”,每个单元执行完检查一下还剩多少时间,不够了就交还主线程让浏览器处理交互,等空闲了再继续。
工作单元与副作用链表
每个 Fiber 节点对应一个组件或 DOM 元素。Fiber 树的结构跟组件树一致,每个节点有 child(第一个子节点)、sibling(兄弟节点)、return(父节点)三个指针。这三个指针让 React 可以从任意节点开始,按深度优先顺序遍历整棵树,随时暂停随时恢复——因为下一个要处理的节点总是可以从当前节点的 return 或 sibling 找到。
协调过程中,React 给每个 Fiber 节点打上一个“副作用标签”:Placement(插入)、Update(更新)、Deletion(删除)等。这些带有副作用标签的节点会被串联成一条单向链表——nextEffect 指针逐个指向下一个有副作用的 Fiber。有了这条链表,后续提交阶段不需要再遍历整棵树,直接沿着链表走一遍就能完成所有 DOM 操作。
双缓存树的切换逻辑
React 同时维护两棵 Fiber 树。屏幕上当前显示的那棵叫 current 树,React 在后台构建的那棵叫 workInProgress 树。
每次更新开始,React 根据 current 树的每个节点创建一个对应的 workInProgress 节点,形成一棵新树。整个协调过程都是在这棵 workInProgress 树上进行的——比较新旧节点、打副作用标签、构建副作用链表。等到协调完成,React 把 workInProgress 树切换为新的 current 树,原来的 current 树被弃用,等待下次更新时复用节点。
这个双缓存机制有几个实际好处:一是协调过程中屏幕上的界面不受影响,两棵树互不干扰;二是协调可以被打断,workInProgress 树的状态可以被丢弃,下次再从头开始构建也不会影响 current 树;三是 Fiber 节点本身可以不断复用,切换时不用重新创建整棵树的节点对象。
React 的 render 与 commit:一次更新的完整流程
React 的一次更新分 render 和 commit 两个阶段。render 阶段就是前面说的协调过程——构建 workInProgress 树,逐节点比较,打副作用标签。render 阶段可以异步执行,可以被中断和恢复。
commit 阶段在 render 阶段完成后同步执行。commit 分为三个子阶段:before mutation(执行 getSnapshotBeforeUpdate 等生命周期)、mutation(把副作用链表上的 DOM 操作应用到真实 DOM)、layout(执行 componentDidMount/componentDidUpdate、useLayoutEffect 等)。commit 阶段不可中断,必须一口气执行完,因为此时开始操作真实 DOM,中断会导致界面状态不一致。
React 的“渲染”在官方术语中特指“调用组件函数生成 React Element 描述”这一步,也就是纯计算,不涉及 DOM。很多场合说的“组件重新渲染”其实是指这一步,真正修改 DOM 是在 commit 阶段。Vue 的“渲染”在类似语境下也指生成虚拟节点的过程,DOM 更新在后续的 patch 阶段完成。两个框架在这点上没有本质区别。
最小示例对比:Vue 精准更新 vs React 全量 diff
用一个具体的例子来展示两套更新机制的行为差异。
jsx
// Vue 3
const App = {
setup() {
const count = ref(0)
const name = ref('Vue')
return () => (
<div>
<span>{count.value}</span>
<span>{name.value}</span>
</div>
)
}
}
// React
function App() {
const [count, setCount] = useState(0)
const [name, setName] = useState('React')
return (
<div>
<span>{count}</span>
<span>{name}</span>
</div>
)
}当 count 更新时,Vue 的 render effect 重新执行组件渲染函数。这个渲染函数读取 count.value 和 name.value,所以组件重新生成了这两个 span 的虚拟节点。接着 Vue 的 diff 算法逐层比较:div 的 children 是两个 span,第一个 span 的文本变了,第二个没变。最终只更新第一个 span 的 textContent。
表面上看,Vue 也是走了一遍组件渲染加 diff,并没有“精准到只更新一个 span 而不触及其它”。但真正精准的地方在组件层:count 变了只触发 App 这一个组件的 render effect,不会去碰兄弟组件或父组件。
React 的处理是:setCount 触发 App 组件的重新渲染,生成新的 Virtual DOM 树,然后和旧树做完整的 diff。在这个简单例子里,diff 结果跟 Vue 一样——只更新第一个 span。区别在于到达 diff 这一步的路径不同:React 不知道 count 更新只影响第一个 span,它把整个组件树都走一遍 diff;Vue 用依赖追踪精确到了组件级,知道只有 App 需要重跑。
当组件树变得复杂——几十个组件嵌套,每个组件又有十几个状态——Vue 只重新渲染依赖了变化数据的那些组件,React 默认会重新渲染整个子树。React 用 React.memo、useMemo 等手动优化来解决这个问题,但本质上这是两条不同的优化路线。
更新优化的路径差异:运行时调度与手动优化
React 把优化精力放在运行时调度上。Fiber 的可中断协调让 diff 过程不影响交互流畅度,并发调度可以根据用户操作的优先级给更新分档。JSX 编译后就是普通的 JavaScript 函数调用,不包含任何关于“这个属性是静态”的附加信息,因此 React 不依赖编译阶段来获取优化线索。
Vue 的依赖追踪在运行时自动确定了组件的渲染边界,开发者不太需要关心“这个组件会不会重复渲染”。但依赖追踪本身也有成本——把深层嵌套的大对象直接扔进 reactive,会让每一层属性都变成响应式,对性能敏感的场合需要注意。Vue 也利用编译阶段做模板静态分析来辅助优化,但核心的更新边界判定仍然由运行时的依赖追踪完成。
这两条路径的差异带来不同的心智负担。Vue 的开发者通常不需要手动控制渲染传播,因为依赖追踪自动划定了边界。React 的开发者需要理解 React.memo、useCallback、useMemo 的作用和限制,手动控制渲染传播。这不是好坏之分,而是响应式系统和不可变数据模型本身的设计选择带来的连锁效应 [7]。
Vue 曾经考虑过在编译阶段做响应性语法糖,让 let count = $ref(0) 这样的写法直接编译成响应式代码,但由于改变了 JavaScript 语义且需要构建步骤,最终在 3.4 版本中放弃 [7]。这从侧面说明,Vue 的核心策略是在运行时做依赖追踪,编译优化是锦上添花;React 的核心策略是在运行时做调度优化,把 diff 的成本平摊到可以中断的时间片里。
注意点与易混淆点
调用栈里看不到依赖追踪。 Vue 的 track 和 trigger 是由 Proxy 的内部方法触发的,在浏览器开发者工具的调用栈中不会显式出现 track() 函数调用——它被代理层隐藏了。当调试“为啥这个组件没更新”时,通常是去检查数据是不是用 ref/reactive 声明的,而不是去翻调用栈。Vue 提供了 onRenderTracked 和 onRenderTriggered 两个调试钩子,可以在组件渲染时看到依赖收集和触发更新的具体信息 [11]。
React 的“渲染”和“DOM 更新”是两件事。 组件函数执行(render)产生 React Element 描述,不等于 DOM 被更新。很多性能优化策略的出发点是减少 render 次数,因为 render 越多产生的虚拟树越多,diff 要比较的节点越多。但如果 render 后的 diff 结果为空(子树没有实际变化),就不会操作 DOM。React.memo 的作用就是在 render 之前拦截:如果 props 没变,连 render 都不执行,直接复用上一次的渲染结果。
effect 不要和 React 的 useEffect 混淆。 Vue 的 effect 是响应式系统的底层概念,代表“依赖变化后需要重新执行的一段代码”。watchEffect 和 computed 都建立在 effect 之上。React 的 useEffect 是组件挂载/更新后执行的副作用,跟依赖追踪没有关系——React 没有依赖追踪这个机制。
Fiber 是可中断的,但 commit 阶段不是。 如果在 commit 阶段出问题,中断是不可能的,因为 DOM 已经被部分修改。这就是为什么 React 把生命周期和 effects 的执行放在 commit 的不同子阶段,而且严格限定了同步执行的要求。
Vue 的 shallowRef 和 triggerRef 的存在,说明依赖追踪不是全自动的。 某些场景下需要手动触发 [8][9]。一个典型的场景:shallowRef 包裹的对象,内部属性的修改不会触发响应,因为 shallowRef 只追踪 .value 这一层的改变。此时需要显式调用 triggerRef 或重新对 .value 赋值来触发更新。
