Vue 的响应式系统是其最核心的设计之一,也是它区别于传统模板引擎的根本特征。Vue 2 使用 Object.defineProperty 实现响应式,而 Vue 3 全面拥抱 Proxy,不仅解决了诸多历史痛点,还带来了更灵活的扩展能力。本文从源码级角度深入解析 Vue 3 响应式系统的完整链路。
一、Vue 2 的局限:Object.defineProperty 的边界
Vue 2 在初始化阶段遍历 data 对象的所有属性,对每个属性调用 Object.defineProperty 重新定义其 getter/setter,从而实现依赖收集与触发更新:
function defineReactive(obj, key, val) {
const dep = new Dep()
Object.defineProperty(obj, key, {
get() {
if (Dep.target) dep.depend()
return val
},
set(newVal) {
if (newVal === val) return
val = newVal
dep.notify()
}
})
}
然而,Object.defineProperty 存在三个无法绕过的限制:
- 无法检测新增属性:直接用
obj.newKey = 'value'添加的属性不是响应式的,必须使用Vue.set(obj, 'newKey', 'value')。 - 无法检测删除属性:
delete obj.key不会触发视图更新,必须使用Vue.delete(obj, 'key')。 - 数组索引变更无法响应:通过索引直接修改数组元素(
arr[0] = 1)不会触发更新,因为 Vue 2 只对数组的push、pop、splice等变异方法进行了包装劫持,而直接赋值绕过了拦截逻辑。
这些限制源自 Object.defineProperty 的设计本质——它只能拦截单个已有属性的读写,无法触及对象本身的整体语义操作。
二、Vue 3 Proxy:面向对象的拦截器
Vue 3 使用 Proxy 代理整个对象,统一拦截所有操作:
const proxy = new Proxy(target, {
get(target, key, receiver) { /* 依赖收集 */ },
set(target, key, value, receiver) { /* 触发更新 */ },
deleteProperty(target, key) { /* 删除响应 */ },
has(target, key) { /* in 运算符 */ },
ownKeys(target) { /* Object.keys / for...in */ }
})
Proxy 的优势显而易见:
- 属性增删即响应:新增属性时,
set拦截器自然触发;删除属性时,deleteProperty被调用,无需额外 API。 - 数组原生能力:数组索引修改直接命中
set,无需重写数组原型方法。 - 操作粒度更广:不仅拦截读写,还能拦截
in运算符、for...in遍历、Reflect.ownKeys等。 - 惰性代理:Vue 3 的
reactive()采用惰性深度代理——只在访问嵌套对象时才递归创建 Proxy,而 Vue 2 需在初始化时递归遍历整个对象树。
三、依赖收集与触发:从 targetMap 到 dep
Vue 3 的响应式核心模块 @vue/reactivity 独立为一个包,其依赖收集机制由三个层级构成:
// 全局依赖存储结构
type TargetMap = WeakMap<Target, Map<Key, Dep>>
const targetMap: TargetMap = new WeakMap()
具体结构分解如下:
targetMap:WeakMap<目标对象, 属性映射>。WeakMap的键是原始对象,当对象不再被引用时,WeakMap不会阻止垃圾回收,避免内存泄漏。depsMap:Map<属性键, Set<Effect>>。每个属性对应一个Set,存储所有订阅该属性的effect函数。dep:Set<Effect>。Set自动去重,确保同一effect不会被重复收集。
依赖收集发生在 get 拦截器中,触发更新发生在 set 拦截器中。以下是一个简化但完整的实现:
let activeEffect = null
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)
// 双向记录,用于 cleanup
activeEffect.deps.push(dep)
}
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const dep = depsMap.get(key)
if (dep) {
const effects = new Set(dep)
effects.forEach(effect => effect.run())
}
}
值得注意的是,Vue 3 使用 new Set(dep) 复制一份 effect 集合后再遍历执行。这是因为某些 effect 在执行过程中可能会再次触发依赖收集,修改原 Set 的结构,导致遍历出错。这一防御性拷贝是生产级框架对边界的精细处理。
四、ref 与 reactive:原始值的"装箱"策略
reactive() 只能对对象生效——因为 Proxy 需要一个对象作为目标。对于原始值(string、number、boolean 等),Vue 3 设计了 ref() 进行"装箱"处理:
function ref(value) {
return createRef(value, false)
}
function createRef(rawValue, shallow) {
if (isRef(rawValue)) return rawValue
return new RefImpl(rawValue, shallow)
}
class RefImpl {
constructor(value, __v_isShallow) {
this._value = __v_isShallow ? value : toReactive(value)
this.dep = new Set()
this.__v_isRef = true
}
get value() {
trackRefValue(this)
return this._value
}
set value(newVal) {
if (hasChanged(newVal, this._rawValue)) {
this._rawValue = newVal
this._value = __v_isShallow ? newVal : toReactive(newVal)
triggerRefValue(this)
}
}
}
ref 的内部并非通过 Proxy 实现,而是直接在实例的 value 属性上通过 getter/setter 拦截,底层同样调用 track 和 trigger,共享同一套依赖系统。
使用上的选择建议:
reactive:适用于对象/数组类型,在<script setup>中使用时变量名无需.value,访问更自然。ref:适用于原始值,或需要解构/重新赋值时(因为reactive对象的解构会失去响应性)。- 自动解包:
ref作为reactive对象的属性时,模板中访问会自动解包value;但在普通 JavaScript 表达式中仍需显式写.value。
五、Effect 与调度器:批量更新与 nextTick
Vue 3 中的 effect 是响应式的执行单元,对标 Vue 2 的 Watcher。
function effect(fn, options = {}) {
const _effect = new ReactiveEffect(fn)
if (options.scheduler) {
_effect.scheduler = options.scheduler
}
_effect.run()
const runner = _effect.run.bind(_effect)
runner.effect = _effect
return runner
}
class ReactiveEffect {
constructor(fn) {
this.fn = fn
this.deps = []
this.scheduler = null
}
run() {
try {
activeEffect = this
cleanupEffect(this)
return this.fn()
} finally {
activeEffect = null
}
}
}
核心设计亮点:调度器(Scheduler)。当你传入 scheduler 选项时,trigger 不再立即执行 effect.run(),而是将 effect 推入调度队列,由调度器决定何时执行。Vue 的 computed 和 watch 底层都依赖调度器实现各自语义:
const queue = new Set()
let isFlushing = false
function queueJob(job) {
queue.add(job)
if (!isFlushing) {
isFlushing = true
Promise.resolve().then(() => {
queue.forEach(job => job())
queue.clear()
isFlushing = false
})
}
}
这就是 nextTick 的本质——利用微任务队列将多次更新合并为一次 DOM 刷新。假设你在一次同步代码中连续修改了 count 三次,Vue 不会刷新三次 DOM,而是将三个 effect 收集到队列中,在下一轮微任务中统一执行,最终只渲染一次。
六、Computed 与 Watch:惰性求值与即时追踪
Computed:惰性的缓存
computed 的核心特征是惰性求值(lazy evaluation):只有当依赖发生变化且值被实际访问时,才重新计算。
function computed(getterOrOptions) {
let getter = getterOrOptions
const cRef = new ComputedRefImpl(getter)
return cRef
}
class ComputedRefImpl {
constructor(getter) {
this._getter = getter
this._dirty = true
this._value = undefined
this.dep = new Set()
// 创建内部 effect,挂 lazy: true
this.effect = new ReactiveEffect(getter, () => {
if (!this._dirty) {
this._dirty = true
triggerRefValue(this)
}
})
this.effect.computed = this
}
get value() {
trackRefValue(this)
if (this._dirty) {
this._dirty = false
this._value = this.effect.run()
}
return this._value
}
}
当依赖变化时,调度器将 _dirty 置为 true,但不立即执行计算函数。稍后如果用户读取 .value,才会真正执行 getter 并缓存结果。这种设计避免了不必要的计算开销。
Watch:即时追踪
watch 与 computed 相反,它的 effect 不设置 lazy,因此依赖变化时立即触发回调。它不要求返回值,而是通过回调函数暴露 newVal 和 oldVal:
function watch(source, callback, options) {
let getter = () => traverse(source)
let oldValue, newValue
const job = () => {
newValue = effect.run()
callback(newValue, oldValue)
oldValue = newValue
}
const effect = new ReactiveEffect(getter, job)
oldValue = effect.run()
}
traverse 函数递归访问响应式对象的每个属性,确保所有深层属性都被追踪——这正是 watch 能监听嵌套对象变化的原因。
七、Reactivity Transform 与演进
Vue 3.2 曾短暂实验过 Reactivity Transform,通过编译器宏让 .value 自动解包:
// Reactivity Transform(已废弃)
let count = $ref(0) // 自动编译为 ref,无需 .value
count++ // 编译后变为 count.value++
这一提案在 v3.3 被正式标记为废弃,原因在于:
- 心智负担复杂:编译器魔法的边界难以界定,开发者无法直观判断何时需要
.value、何时不需要。 - TypeScript 兼容性:宏转换增加了类型系统的推理复杂度,IDE 支持不够稳定。
- 生态碎片化:第三方库无法使用编译器宏,导致库与业务代码风格不一致。
现代 Vue 3 项目推荐的模式是明确的 ref() + .value,配合 ESLint 规则(vue/ref-macros)维持一致性。对于偏好无 .value 语法的场景,reactive() 结构化的对象仍然是最清晰的选择。
八、Vue 与 React:推模型 vs 拉模型
理解 Vue 和 React 响应式差异的关键在于它们对组件重新渲染的判断机制:
| 维度 | Vue(推模型,Push-based) | React(拉模型,Pull-based) |
|---|---|---|
| 触发时机 | 状态变更时,框架"推送"通知到精确的依赖组件 | 状态变更时,组件函数重新执行,React"拉取"新 VDOM |
| 依赖追踪 | 编译期+运行时自动追踪,无需声明依赖 | 依赖由开发者手动声明(useEffect deps),易遗漏 |
| 粒度 | 属性级精度,只更新相关的组件 | 组件级精度,默认重新执行整个组件函数 |
| 优化手段 | computed 缓存、v-once、v-memo | useMemo、React.memo、useCallback |
| 心智模型 | 自动化,隐式追踪,写更少优化代码 | 显式声明,可控性高,但需手动避免重复渲染 |
两种模型各有优劣。Vue 的推模型在中小型项目中开发效率高得多——你几乎不需要关心 memoization;但在超大型应用中,隐式追踪可能导致难以调试的依赖链。React 的显式模型虽然代码更冗长,却提供了更明确的优化途径。
总结
Vue 3 的响应式系统是一次彻底的重构:Proxy 取代了 Object.defineProperty,独立了 @vue/reactivity 包使其可用于非 Vue 场景,调度器统一了 computed/watch/nextTick 的更新语义。源码实现上,WeakMap → Map → Set 的三层结构、基于 effect 的执行单元、以及惰性求值与调度队列的精细配合,共同构成了 Vue 响应式系统的优雅设计。掌握这些原理,不仅能写出更高效的 Vue 应用,也为理解现代响应式编程的通用模式奠定了基础。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。