mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5
2402 字
12 分钟
Vue 3 响应式原理

一句话摘要#

Vue 3 的响应式核心,是通过 Proxy 拦截读写操作,再借助 effecttracktrigger 和依赖映射表,把数据读取和副作用函数重新执行关联起来。

目录#

核心数据结构#

三个表:targetMapWeakMap)、depsMapMap)、depSet)。

Vue 内部维护了一个全局的弱引用映射字典(WeakMap),数据结构大概是:

target(被代理对象) -> key(对象属性) -> Set<effect>

对应关系如下:

  • targetMapkey 是对象,value 是属性表。
  • depsMapkey 是属性,valueeffect 表。
  • depvalueeffect

核心函数#

三个函数:effecttracktrigger

1. effect:副作用函数#

effect 接收一个函数 fn。当你调用 effect(fn) 时:

  1. 它会立即执行一次 fn
  2. 在执行 fn 的过程中,如果读取了响应式数据(比如 reactive 生成的 Proxy 对象属性),就会触发该属性的读取拦截器(getter)。
  3. 建立起被访问数据与这个 fn 之间的联系,以便将来数据变化时,这个 fn 能够被再次执行。

2. track:依赖收集#

track 一般在响应式对象(Proxy)的 get(读取)操作拦截器中被内部调用。

其核心作用是把当前正在运行的 effect 记录下来。

3. trigger:派发更新#

trigger 一般在响应式对象(Proxy)的 set(修改)操作拦截器中被内部调用。

其核心作用是取出对应的 effect 并执行。

响应式更新流程#

effect(fn) -> 执行 fn()
读取数据触发 Proxy getter -> 调用 track 收集 activeEffect
修改数据触发 Proxy setter -> 调用 trigger 取出 effect -> 重新执行 effect(fn)

如何拦截 set 和 get#

  • Vue 2:ES5 Object.defineProperty()
  • Vue 3:ES6 Proxy

反射和代理#

代理(Proxy)是一个对象的占位符,默认情况下对该对象进行委托。

使用 Proxy

Proxy() 的第二个参数叫处理函数(Handler),可以传递一个诱捕器(trap),用于拦截各种基础操作,比如属性查找、枚举,我们就可以用来拦截 getset 了。

function reactive(target) {
const handler = {
get(target, key, receiver) {
console.log('触发了 Get,key = ' + key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
console.log('触发了 Set,key = ' + key + ',value = ' + value);
return Reflect.set(target, key, value, receiver);
}
};
// 返回一个代理对象,我们可以像使用原对象一样使用它。
return new Proxy(target, handler);
}
let product = reactive({ price: 5, quantity: 2 });

注意:receiver(接收器)的作用是保证当我的对象有继承自其他对象的值或函数时,this 指针能正确指向使用的对象。

加入 track 和 trigger#

再加入 tracktrigger

function reactive(target) {
const handler = {
get(target, key, receiver) {
let result = Reflect.get(target, key, receiver);
track(target, key); // 收集依赖
return result;
},
set(target, key, value, receiver) {
let oldValue = target[key];
let result = Reflect.set(target, key, value, receiver);
// 只有当值发生改变时才触发更新
if (oldValue !== value) {
trigger(target, key); // 派发更新
}
return result;
}
};
return new Proxy(target, handler);
}
let product = reactive({ price: 5, quantity: 2 });

然后我们尝试创建一个对象,并且使用上面的 reactive 方法:

let product = reactive({ price: 5, quantity: 2 });
let total = 0;
let effect = () => {
total = product.price * product.quantity;
};
effect(); // 初始执行,触发 getter 依赖收集
console.log(total); // 输出 10
product.quantity = 3; // 触发 setter 派发更新
console.log(total); // 输出 15

详细运行过程#

1. 建立响应式对象#

我们通过 reactive({ price: 5, quantity: 2 }) 创建对象,它返回了一个 Proxy 代理对象 product。此时 product 的读取(get)和修改(set)操作都已经分别被拦截处理。

2. 初次执行 effect 并收集依赖(track)#

手动调用执行 effect() 时,它内部读取了 product.priceproduct.quantity。此时触发了代理对象 productget 拦截器。

get 拦截器中,内部调用了 track(target, key)

  • track 把当前的 effect 副作用函数收集到全局依赖字典(targetMap -> depsMap -> dep)中,分别与 pricequantity 这被访问的属性建立联系。
  • total 计算结果为 5 * 2 = 10

3. 修改数据并自动派发更新(trigger)#

当代码走到 product.quantity = 3 时,是对 product 属性赋值,此操作触发了代理对象的 set 操作拦截器。

set 拦截器中:

  • 我们传入的新值 3value)和原来的值 2oldValue)不相等,即判定 oldValue !== value 成立。
  • 由于值发生了变化,拦截器继续调用 trigger(target, 'quantity') 发出更新派发信号。
  • trigger 去全局依赖字典找到订阅在当前对象 'quantity' 属性上的所有 effect 函数集合并执行它们。
  • 这样我们原本的 effect 函数就会重新隐式运行一遍(total = 5 * 3),从而使 total 更新为 15
  • 所以最后一句 console.log(total) 时,得到的是最新的结果 15

本章小结#

这个例子完整串起了初始化执行、读取收集、赋值触发和副作用重跑四个环节。最终 total10 更新为 15,正是 trigger 派发更新后的结果。

第三步的延伸:如果在外部再加入一个独立的 get 操作,会有什么问题吗?#

假如我们在最下面直接加上一句:

console.log('Updated quantity to = ' + product.quantity);

答案是:有可能会导致错误收集。

当你执行 product.quantity 这一读取操作时,自然会再次触发 Proxy 代理对象的 get 拦截器。而在 get 拦截器内,会不可避免地自动执行 track(target, key) 去收集依赖。

问题在于这里所处的上下文:这段 console.log 代码是在全局下直接运行的,它并没有被包裹在任何副作用 effect 函数内。

如果没有进行安全判断:我们的 track 函数可能会去收集那个根本不存在的依赖(可能是 undefined,也可能是上次残留还没被清除的旧 effect),将一个无效的依赖强行塞进我们的 dep 集合里,这会导致内存泄漏,甚至是调用报错。

这就引出了真正完善的依赖收集机制里非常关键的一环:在真正的 Vue 3 源码中,内部会维护一个全局变量(通常命名为 activeEffect)来时刻保存当前正在执行的副作用函数。在进入 effect 执行时赋值给它,出 effect 时清空。

track 在尝试收集依赖之前,做的第一件事就是检查 activeEffect 是否存在。只有存在才会把它添加进弱映射字典,以防止脱离副作用的“越界收集”。

本章小结#

依赖收集必须建立在当前确实存在 activeEffect 的前提下。脱离副作用函数的读取操作不能被随意收集,否则可能产生无效依赖、内存泄漏或调用报错。

ref 的实现#

对于基本数据类型(如字符串、数字等),由于它们不是对象,我们无法给它们应用 Proxy 代理。因此,Vue 的方案是用一个对象包裹它,这就是 ref

ref 的核心是利用了对象访问器(Getter / Setter 计算属性)来获取或设置 .value 的值:

function ref(raw) {
const r = {
get value() {
track(r, 'value'); // 收集依赖
return raw;
},
set value(newVal) {
if (raw !== newVal) {
raw = newVal;
trigger(r, 'value'); // 派发更新
}
}
};
return r;
}

computed 的实现#

function computed(getter) {
let result = ref();
// 每次 getter 里的依赖数据变化时,effect 都会重新执行,给 result.value 赋予新计算的值
effect(() => {
result.value = getter();
});
return result;
}
let product = reactive({ price: 5, quantity: 2 });
let salePrice = computed(() => {
return product.price * 0.9;
});
let total = computed(() => {
return salePrice.value * product.quantity;
});
console.log(total.value); // 此时由于依赖响应式数据改变,总价会自动重算

注意:源码实际上的处理方式(基于 ComputedRefImpl)是真正的 computed 是一个带有懒计算标志位(_dirty)的对象。

依赖发生改变时,它并不立即运算求新值,而是通过传入 scheduler 调度器把 _dirty 标记为 true(脏数据),这相当于告诉 Vue:“我的值已经过期了”。

只有当你再次读取 salePrice.value 触发访问时,Vue 发现 _dirtytrue,才会重新调用 getter() 执行真正的计算,算出新值后存起来并将 _dirty 置为 false

这期间如果没有依赖发生变更(_dirty 依然是 false),它会一直直接返回上次计算缓存的值,绝不重复运算。

本章小结#

简化版 computed 可以用 refeffect 拼出来;源码里的 computed 还会通过 _dirtyscheduler 做懒计算与缓存,避免每次读取都重复执行 getter()

总结#

Vue 3 响应式系统可以按一条主线理解:用 Proxy 拦截对象的读取和修改,在读取时通过 track 收集当前 activeEffect,在修改时通过 trigger 找到并重新执行对应的副作用函数。

ref 解决的是基本数据类型不能直接被 Proxy 代理的问题;computed 则是在响应式依赖之上加入计算、缓存和懒更新机制。理解这几层关系后,再看 reactiverefcomputed 的行为,就能更容易串起 Vue 3 响应式的整体流程。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Vue 3 响应式原理
https://blog.moxiaoshuai.fun/2026-05-vue-3响应式原理-vue-3响应式原理/
作者
莫莫
发布于
2026-05-27
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

封面
示例歌曲
示例艺人
封面
示例歌曲
示例艺人
0:00 / 0:00