深入响应式原理:从 Vue 2 的 getter/setter 到 Vue 3 的 effect
原文主要讨论“修改数据后,模板为什么会自动更新”。本文保留原文从数据渲染、用户交互、依赖收集到更新视图的主线,并补充 Vue 2.7、Vue 3.5 的实现差异。下面的源码是帮助理解的最小模型,不等同于 Vue 仓库中的完整生产代码。
一、响应式解决了什么问题
前面的渲染过程可以概括为:把数据交给模板,得到 DOM。实际应用还需要处理另一条链路:用户交互或其他业务逻辑修改数据后,视图也要同步变化。
考虑这个 Vue 2 示例:
<div id="app" @click="changeMsg">
{{ message }}
</div>
<script>
new Vue({
el: '#app',
data: {
message: 'Hello Vue!',
},
methods: {
changeMsg() {
this.message = 'Hello World!'
},
},
})
</script>
点击元素时只修改了 this.message,并没有手动查找插值对应的 DOM,也没有调用 textContent。Vue 会完成:
- 知道当前渲染过程读取了哪些响应式数据;
- 在这些数据变化时通知相关的渲染任务;
- 重新执行渲染函数,得到新的虚拟 DOM;
- 通过 patch/diff 将必要的变化应用到真实 DOM;
- 将同一轮事件中的多次变化合并,避免重复更新。
如果不用框架,我们当然也可以监听点击事件、修改数据、手动操作 DOM。但随着页面复杂度增加,就必须自己回答:要修改哪块 DOM?多个数据同时改变时是否重复更新?条件渲染和列表变化如何处理?Vue 的响应式系统和渲染器就是为这些问题服务的。
二、响应式的基本模型:依赖与副作用
可以用电子表格理解响应式:单元格 C1 = A1 + B1,当 A1 或 B1 变化时,C1 会重新计算。
在 JavaScript 中,普通变量不会自动建立这种关系:
let a = 1
let b = 2
let result = a + b
b = 3
console.log(result) // 仍然是 3
如果把计算过程包在一个函数里,就可以把它称为一个 effect(副作用/响应式副作用):
let result
function update() {
result = a + b
}
这里:
update是需要重新执行的 effect;a和b是 effect 使用的依赖;- 首次执行
update时,需要记录“当前 effect 读过 a 和 b”; - 之后写入
a或b时,找到并触发相关 effect。
JavaScript 没有拦截普通局部变量读取的通用机制,但可以拦截对象属性的读取和写入。Vue 2 主要使用 getter/setter,Vue 3 的 reactive() 使用 Proxy,ref() 仍然通过 .value 的 getter/setter 建立追踪。
三、Vue 2.7:Observer、Dep 和 Watcher
1. 版本范围
Vue 2.0 到 Vue 2.7 的核心响应式思路都是 Object.defineProperty、Dep 和 Watcher。Vue 2.7 增加了部分 Composition API,但没有把对象响应式改成 Vue 3 的 Proxy。
Vue 2 已经结束官方维护,下面内容主要用于维护旧项目和理解历史源码;新项目应优先使用 Vue 3。
2. defineReactive:为属性安装 getter/setter
简化后的实现如下:
function defineReactive(object, key, initialValue) {
const dep = new Dep()
let value = initialValue
Object.defineProperty(object, key, {
enumerable: true,
configurable: true,
get() {
if (Dep.target) {
dep.depend()
}
return value
},
set(nextValue) {
if (Object.is(nextValue, value)) return
value = nextValue
dep.notify()
},
})
}
dep 被 getter/setter 的闭包保存,因此每个属性都有自己的依赖容器。真实 Vue 2 源码还会:
- 保留原有 getter/setter;
- 递归观测嵌套对象;
- 收集数组和嵌套对象的依赖;
- 处理只读、不可配置属性、SSR 等边界;
- 在开发环境提供依赖调试信息。
3. Dep:依赖容器
let uid = 0
class Dep {
static target = null
constructor() {
this.id = uid++
this.subs = []
}
addSub(watcher) {
this.subs.push(watcher)
}
depend() {
if (Dep.target) {
Dep.target.addDep(this)
}
}
notify() {
// 复制一份,避免通知过程中订阅列表发生变化影响本轮遍历。
const subs = this.subs.slice()
for (const watcher of subs) {
watcher.update()
}
}
}
原文把 Dep.target 描述为当前执行的观察者,这个方向是对的,但真实源码并不是只简单赋值一个变量:Vue 2 使用 target stack,通过 pushTarget()/popTarget() 支持渲染 watcher 嵌套计算属性 watcher 的场景。
4. Watcher:把函数包装成可更新任务
渲染函数、计算属性和用户 watch 都可以由 watcher 负责,但它们的选项不同:
- 渲染 watcher:读取模板使用的数据,更新组件视图;
- 计算属性 watcher:通常是 lazy 的,依赖变化时先标记 dirty,访问计算属性时再求值;
- 用户 watcher:执行开发者提供的回调,可配置
deep、immediate等; - 同步 watcher:立即执行,但大量使用可能绕过批处理,通常不作为默认选择。
最小模型:
class Watcher {
constructor(getter, callback) {
this.getter = getter
this.callback = callback
this.deps = new Set()
this.value = this.get()
}
get() {
const previous = Dep.target
Dep.target = this
try {
return this.getter()
} finally {
Dep.target = previous
}
}
addDep(dep) {
if (this.deps.has(dep)) return
this.deps.add(dep)
dep.addSub(this)
}
update() {
const oldValue = this.value
const newValue = this.get()
this.value = newValue
this.callback?.(newValue, oldValue)
}
}
真实 Vue 2 的 watcher 会维护 deps、depIds、newDeps、newDepIds 两组集合:重新执行 getter 后,新增依赖会加入,已经不再访问的旧依赖会被清理。这样可以正确处理条件分支:
watchEffect(() => {
// Vue 2 中可类比为 render/computed watcher 的 getter
return enabled ? state.a : state.b
})
当 enabled 从 true 变为 false 后,watcher 应该停止订阅 state.a,转而订阅 state.b;依赖收集不是“一次绑定,永远不变”。
5. 初次渲染和更新流程
Vue 2 的典型流程可以抽象为:
observe(data)
↓
把对象属性转换为 getter/setter,创建 Dep
↓
创建组件渲染 Watcher
↓
执行 render
↓
getter 发现 Dep.target,收集依赖
↓
生成 vnode 并首次 patch
数据 setter 被调用
↓
dep.notify()
↓
watcher.update()
↓
进入 scheduler 队列并去重
↓
重新执行 render,生成新 vnode
↓
patch(oldVnode, newVnode)
notify() 不等于“此刻立刻完整地重绘 DOM”。默认情况下,Vue 2 会把 watcher 放进异步更新队列,同一事件循环内的重复触发会去重,之后统一刷新。需要在 DOM 更新完成后读取 DOM 时,应使用 $nextTick/Vue.nextTick。
6. Vue 2 对数组的处理
Object.defineProperty 可以拦截 obj.key,但不能直接拦截 arr[index] = value 和 arr.length = value。Vue 2 因此改写了七个变异方法:
const methods = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse',
]
这些方法会先调用原生实现,再观测 push、unshift、splice 新增的元素,最后调用数组 observer 上的 dep.notify()。
因此 Vue 2 中下面两种写法不要混淆:
// Vue 2 中不能可靠触发响应式更新
vm.items[index] = nextValue
vm.items.length = 0
// 推荐
vm.$set(vm.items, index, nextValue)
vm.items.splice(index, 1, nextValue)
vm.items.splice(0)
对嵌套对象新增属性也需要:
this.$set(this.user, 'age', 18)
// 或 Vue.set(this.user, 'age', 18)
Vue 3 不需要 Vue.set/this.$set,Proxy 可以拦截新增和删除,但仍然应保持状态结构清晰。
四、Vue 3:Proxy、ReactiveEffect 和依赖图
1. reactive() 和 ref()
Vue 3 的概念模型可以简化为:
function reactive(target) {
return new Proxy(target, {
get(object, key, receiver) {
track(object, key)
return Reflect.get(object, key, receiver)
},
set(object, key, value, receiver) {
const oldValue = object[key]
const result = Reflect.set(object, key, value, receiver)
if (!Object.is(oldValue, value)) {
trigger(object, key)
}
return result
},
})
}
function ref(initialValue) {
const refObject = {
get value() {
track(refObject, 'value')
return initialValue
},
set value(nextValue) {
if (Object.is(nextValue, initialValue)) return
initialValue = nextValue
trigger(refObject, 'value')
},
}
return refObject
}
这是概念代码,真实实现还要处理原型链、只读代理、浅层代理、数组、Map/Set、迭代器、ref 嵌套、调度器和开发调试事件。
2. track 和 trigger
从概念上看,依赖关系类似:
WeakMap<原始对象, Map<属性 key, 依赖集合>>
let activeEffect
const targetMap = new WeakMap()
function track(target, key) {
if (!activeEffect) return
let depsMap = targetMap.get(target)
if (!depsMap) {
depsMap = new Map()
targetMap.set(target, depsMap)
}
let dep = depsMap.get(key)
if (!dep) {
dep = new Set()
depsMap.set(key, dep)
}
dep.add(activeEffect)
}
function trigger(target, key) {
const depsMap = targetMap.get(target)
depsMap?.get(key)?.forEach(effect => effect.run())
}
function effect(fn) {
const runner = {
run() {
activeEffect = runner
try {
return fn()
} finally {
activeEffect = undefined
}
},
}
runner.run()
return runner
}
需要强调:这是便于理解的抽象,并不是 Vue 3.5 的逐行源码。Vue 3.5 的响应式内部进一步采用了版本计数和双向依赖链接等优化结构;官方文档用 WeakMap<target, Map<key, Set<effect>>> 解释语义,源码实现可以随版本变化。阅读源码时应区分“依赖模型”和“具体数据结构”。
触发类型也不只有普通 set:
- 读取属性通常是
get; in操作需要追踪has;Object.keys()、数组迭代、Map/Set 迭代需要追踪iterate;- 新增属性是
add; - 删除属性是
delete; - 清空 Map/Set 是
clear。
3. watchEffect、computed 和组件渲染
import { computed, ref, watchEffect } from 'vue'
const firstName = ref('Ada')
const lastName = ref('Lovelace')
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`
})
watchEffect(() => {
console.log(fullName.value)
})
watchEffect立即执行一次,自动收集回调中同步读取的依赖;computed也使用响应式 effect,但默认惰性求值并缓存结果,依赖变化时先失效;watch(source, callback)更强调明确的 source 和变化回调,适合异步副作用、旧值/新值和深度监听;- 组件渲染本质上也由一个响应式渲染 effect 驱动,只是它会交给 renderer 做 vnode patch,而不是直接用
innerHTML。
调度器会决定 effect 何时运行。watch/watchEffect 支持 flush: 'pre' | 'post' | 'sync' 等选项;nextTick() 适合等待 Vue 的 DOM 更新队列完成。不要把“依赖触发”误解为每次赋值都会同步执行完整组件渲染。
五、Vue 2 与 Vue 3 响应式对比
| 对比项 | Vue 2.7 | Vue 3.5 |
|---|---|---|
| 对象拦截 | Object.defineProperty | Proxy |
| 基本依赖角色 | Dep + Watcher | ReactiveEffect/依赖链接 |
| 数组下标赋值 | 不能直接可靠检测 | 可由 Proxy 拦截 |
| 数组 length | 有限制 | 可追踪并触发相关依赖 |
| 新增/删除属性 | $set/$delete 等辅助 | 直接 obj.newKey = value、delete obj.key |
| Map/Set | 不作为普通响应式集合完整支持 | 有专门 collection handlers |
ref | Vue 2.7 提供兼容实现 | .value getter/setter |
| 更新调度 | watcher scheduler | effect/job scheduler |
| 根数据结构 | 需要提前声明根属性 | Proxy 可处理新增,但仍推荐明确 state schema |
Proxy 并不是“所有地方都更快”或“自动深度监听一切”。它消除了 Vue 2 的一些检测限制,但仍然受 JavaScript 语义和使用方式影响。
六、常见响应式陷阱
1. 解构会丢失对象属性的响应式连接
const state = reactive({ count: 0 })
const { count } = state
// count 是普通局部变量,不再通过 state.count 的 Proxy getter 读取。
需要保留连接时,可以使用 toRef/toRefs:
const count = toRef(state, 'count')
2. reactive 返回的是代理对象
const raw = {}
const proxy = reactive(raw)
console.log(proxy === raw) // false
一般应持续使用代理对象,不要在业务中混用原始对象和代理对象作为身份标识。需要取原始对象时可使用 toRaw,但不应把它当作日常修改入口。
3. 不是所有外部对象都适合深度代理
接入第三方状态库、不可变数据或大型外部对象时,可以考虑 shallowRef、shallowReactive、markRaw。使用浅层 API 后,内部变化通常需要替换 .value 或手动 triggerRef,不能再假定深层属性会自动触发更新。
4. 依赖是按访问路径收集的
如果 effect 没有读取某个属性,修改该属性不会因为“它属于同一个对象”而自动让这个 effect 更新。另一方面,读取整个对象、遍历 key、读取数组长度和读取 Map 的迭代器,可能会建立不同粒度的依赖。
七、如何调试响应式更新
Vue 3 在开发环境提供组件调试钩子:
<script setup>
import { onRenderTracked, onRenderTriggered } from 'vue'
onRenderTracked(event => {
debugger
console.log('render 追踪了:', event)
})
onRenderTriggered(event => {
debugger
console.log('render 被什么触发:', event)
})
</script>
computed 和 watch 也支持 onTrack、onTrigger 调试选项。排查“为什么组件更新”时,可以先确认:
- effect 是否真的读取了该属性;
- 依赖是否在条件分支中被清理;
- 是否错误地解构或使用了 raw 对象;
- 是否是父组件 props、slot、key 或列表变化导致的更新;
- 数据更新后是否只是进入队列,尚未到 DOM 提交阶段。
总结
- 响应式的核心不是“数据变化后扫描整个页面”,而是读取时建立依赖、写入时通知相关 effect;
- Vue 2 使用
Object.defineProperty、Dep、Watcher和数组方法改写; - Vue 3 的
reactive使用 Proxy,ref通过.valuegetter/setter 追踪,ReactiveEffect负责执行和重新收集依赖; - Vue 2 的数组下标、length 和对象新增属性有检测限制,Vue 3 通过 Proxy 改善了这些场景;
notify/trigger通常会进入调度队列,不代表同步完成 DOM 更新;- 官方文档展示的 Set 依赖图是概念模型,Vue 3.5 内部数据结构已经进一步优化;
- 理解依赖收集后,再结合 renderer 的 vnode patch,才能完整解释“数据为什么会变成视图”。
参考资料
- Vue 3:深入响应式原理
- Vue 3:响应式基础
- Vue 2:深入响应式原理
- Vue 2.7
defineReactive源码 - Vue 2.7
Dep源码 - Vue 2.7
Watcher源码 - Vue 3 响应式源码目录
原文作者:一只小考拉。原文关于数据变更、观察者模式、Dep、Watcher、Observer、数组方法和 defineReactive 的主线予以保留;抓取产生的无效代码围栏、重复宣传、过时的 Vue 2 绝对化表述和“Vue 3 只是 Set 结构”的说法已整理或修正。