Vue2/3:响应式陷阱、依赖追踪机制
系列文章目录
文章目录
一、vue 响应式原理
1. Object.defineProperty 的用法(Vue 2 模式)
它的特点是:必须预先知道要监听哪个属性。
const data = { name: 'Alice' }; // 原始数据
const observedData = {}; // 代理后的数据
// 必须循环遍历所有属性(递归执行)
Object.defineProperty(observedData, 'name', {
get() {
console.log('检测到读取 name');
return data.name;
},
set(newVal) {
console.log('检测到修改 name 为:', newVal);
data.name = newVal;
// 这里会触发视图更新逻辑(如通知 Watcher)
}
});
// 测试
console.log(observedData.name); // 输出: 检测到读取 name -> Alice
observedData.name = 'Bob'; // 输出: 检测到修改 name 为: Bob
// ❌ 陷阱演示:动态新增属性
observedData.age = 25;
// 结果:没有任何日志输出,响应式失效!因为初始化时没有劫持 'age'
2. Proxy 的用法(Vue 3 模式)
它的特点是:拦截整个对象,管家守在大门口,不管谁进出(新增或删除)都能抓到。
const data = { name: 'Alice' };
const proxyData = new Proxy(data, {
// target 是原对象,key 是属性名
get(target, key) {
console.log(`检测到读取属性: ${key}`);
return target[key];
},
set(target, key, newVal) {
console.log(`检测到修改属性: ${key},新值为: ${newVal}`);
target[key] = newVal;
return true; // 表示赋值成功
},
deleteProperty(target, key) {
console.log(`检测到删除属性: ${key}`);
delete target[key];
return true;
}
});
// 测试 1:读取与修改
console.log(proxyData.name); // 输出: 检测到读取属性: name
proxyData.name = 'Bob'; // 输出: 检测到修改属性: name
// ✅ 优势演示 1:动态新增属性
proxyData.age = 25; // 输出: 检测到修改属性: age(原生支持,无需 $set)
// ✅ 优势演示 2:删除属性
delete proxyData.name; // 输出: 检测到删除属性: name
// ✅ 优势演示 3:数组操作
const arrProxy = new Proxy([1, 2], {
set(target, key, val) {
console.log(`索引 ${key} 变更为 ${val}`);
target[key] = val;
return true;
}
});
arrProxy.push(3); // 输出: 索引 2 变更为 3 (以及更新 length 的日志)
二、Vue响应式陷阱
在 Vue 的开发过程中,所谓的“响应式陷阱”通常是指:你认为数据变了视图就会跳,但实际上 Vue 并没有追踪到这个变化。
由于 Vue 2 和 Vue 3 的底层实现(Object.defineProperty vs Proxy)不同,它们的陷阱也各不相同。
1. Vue 2 的经典陷阱:无法感知“新成员”
Vue 2 使用 Object.defineProperty 在组件初始化时对属性进行劫持。这导致了两个核心限制:
A. 对象新增或删除属性
如果你在 data 初始定义之外给对象添加新属性,Vue 是看不见的。
data() {
return {
user: { name: 'Alice' }
}
},
mounted() {
this.user.age = 25; // ❌ 视图不会更新,因为 age 没有被初始化劫持
this.$set(this.user, 'age', 25); // ✅ 正确做法
}
B. 数组的索引和长度修改
直接通过索引修改数组项,或者修改数组长度,Vue 2 也追踪不到。
this.items[0] = 'new'; // ❌ 无效
this.items.length = 0; // ❌ 无效
this.items.splice(0, 1, 'new'); // ✅ 正确做法,Vue 劫持了 push/splice 等方法
2. Vue 3 的新陷阱:响应式的“丢失”
Vue 3 改用 Proxy,完美解决了上述新增属性和数组的问题,但因为解构和赋值的特性,带来了新的陷阱:
A. 响应式解构陷阱 (Destructuring)
这是最常见的错误。如果你对 reactive 对象进行解构,得到的变量会失去响应性。
const state = reactive({ count: 0 });
let { count } = state; // ❌ count 变成了一个普通的数字 0,不再是响应式的
count++; // 视图不会跳
// ✅ 正确做法:使用 toRefs
const { count } = toRefs(state);
B. 整个对象直接赋值
如果你给一个 reactive 定义的对象重新赋值一个新对象,原本的响应式连接会断开。
let state = reactive({ list: [] });
state = { list: [1, 2, 3] }; // ❌ 之前的 Proxy 引用被覆盖了,响应式丢失
// ✅ 正确做法:修改属性或者使用 ref
const state = ref({ list: [] });
state.value = { list: [1, 2, 3] };
C. 原始引用 (Original Reference)
当你把一个原始对象赋值给响应式变量后,操作原始对象是不会触发更新的。
const raw = { a: 1 };
const state = reactive(raw);
raw.a = 2; // ❌ state.a 会变,但视图不一定会跳,因为没经过 Proxy 拦截
三、Vue2 依赖追踪机制 (Reactivity System) 深度解析
Vue 2 的核心逻辑是:数据劫持 + 发布订阅模式。
-
数据劫持:简单来说,数据劫持就是在访问或修改对象的属性时,拦截这一行为,并在其中触发额外的逻辑(如更新 DOM、收集依赖等)。
-
发布订阅模式(Publish-Subscribe Pattern,简称 Pub/Sub) :是一种异步的服务间通信模式。它就像一个“中间商”,让消息的发送者(发布者)和消息的接收者(订阅者)互不认识,也能完成信息的传递。
-
Observer (发布者):通过 Setter 触发消息。
-
Watcher(订阅者):负责具体的更新动作。
-
Dep(调度中心):每个属性都有一个 Dep 实例。它像一个中介,负责存储哪些 Watcher 订阅了自己,并在数据变动时统一分发通知。
-
Vue 2 的响应式系统是其核心灵魂,主要基于 Object.defineProperty 实现。我们可以将其工作机制拆解为“三个核心角色”和“三个关键阶段”。
1. 三个核心角色
-
Observer (发布者):利用 Object.defineProperty 将对象的属性转化为 getter 和 setter,实现 数据拦截。
-
Dep (依赖收集器):每个属性都有一个专属的 Dep 实例,负责记录“谁在用我”以及“通知他们更新”。
-
Watcher (订阅者):代表具体的任务(如渲染 Watcher、computed Watcher、user Watcher)。当数据变化时,它是最终执行代码的对象。
简易代码示例 (Vue 2)
// 1. Dep: 管理订阅者
class Dep {
constructor() {
this.subs = []; // 存储所有 Watcher
}
// 收集依赖
depend() {
if (Dep.target) {
this.subs.push(Dep.target);
}
}
// 通知更新
notify() {
this.subs.forEach(sub => sub.update());
}
}
Dep.target = null; // 全局静态属性,用于指向当前正在计算的 Watcher
// 2. Observer: 拦截数据
function observe(data) {
if (!data || typeof data !== 'object') return;
Object.keys(data).forEach(key => {
let value = data[key];
const dep = new Dep(); // 每个 key 闭包持有一个 Dep
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
dep.depend(); // 【重要】getter 阶段:收集当前正在运行的 Watcher
return value;
},
set(newValue) {
if (newValue === value) return;
value = newValue;
dep.notify(); // 【重要】setter 阶段:数据变了,通知 Dep 下的所有 Watcher 更新
}
});
});
}
// 3. Watcher: 执行任务
class Watcher {
constructor(exp, cb) {
this.cb = cb;
Dep.target = this; // 将自己设为当前目标
// 触发 getter,建立联系
exp();
Dep.target = null; // 释放
}
update() {
this.cb(); // 执行真正的更新逻辑
}
}
// 测试
const state = { count: 0 };
observe(state);
new Watcher(() => state.count, () => console.log('视图更新了!'));
state.count = 1; // 输出: 视图更新了!
1.1 Observer(观察者/发布者)
-
本质:一个构造函数。
-
职责:它是“装修师傅”。当你把一个普通的 JavaScript 对象传给 Vue 实例的 data 时,Observer 会遍历这个对象的所有属性,用 Object.defineProperty 把它们统统改装成带有 getter 和 setter 的形式。
1.2 Dep(Dependency - 依赖收集器/报社)
-
本质:一个简单的管理类。
-
职责:它是“中介”。每一个被 Observer 改装过的属性,内部都会持有一个 Dep 实例。
-
谁读了这个数据(触发 getter),Dep 就把谁记在小本本上(subs 数组)。
-
谁改了这个数据(触发 setter),Dep 就翻开小本本,挨个打电话通知。
-
1.3 Watcher(Watcher - 订阅者/读者)
-
本质:一个执行监听任务的类。
-
职责:它是“打工人”。当 Dep 发出通知时,Watcher 负责执行具体的动作(比如更新 DOM、运行你写的 watch 回调)。
2. 三个关键阶段
2.1 初始化阶段:数据劫持 (Data Hijacking)
当 Vue 实例化 时,它会遍历 data 中的所有属性,通过 Observer 进行递归包装。
-
Getter:当访问属性时(例如渲染页面),触发 Getter,此时 Dep 会把当前的 Watcher 收集起来(依赖收集)。
-
Setter:当修改属性时,触发 Setter,此时 Dep 会立即通知所有收集到的 Watcher(派发更新)。
2. 依赖收集阶段 (Dependency Collection)
这发生在 组件挂载(Mount) 期间:
-
Vue 创建一个 渲染 Watcher。
-
Watcher 触发组件的 render 函数。
-
在执行 render 时,代码会读取 data 中的属性,从而触发 Getter。
-
Getter 内部执行 dep.depend(),将当前的渲染 Watcher 存入该属性的 Dep 数组中。
3. 派发更新阶段 (Dispatching Updates)
当你执行 this.msg = ‘new value’ 时:
-
触发该属性的 Setter。
-
Setter 调用 dep.notify()。
-
Dep 遍历内部所有的 Watcher,并调用它们的 update() 方法。
-
异步队列优化:Vue 不会立即更新 DOM,而是将 Watcher 推入一个队列,在下一个 Tick(微任务)中统一执行,避免重复渲染。
四、Vue 3 依赖追踪机制 (Reactivity System) 深度解析
1. 核心设计思想:追踪 (Track) 与 触发 (Trigger)
Vue 3 的响应式是基于 Proxy 实现的。它的基本逻辑是:
- Track (追踪):当读取(Getter)数据时,记录当前是谁在用这个数据。
- Trigger (触发):当修改(Setter)数据时,通知所有记录过的人去更新。
2. 全局依赖管理中心:targetMap
Vue 3 使用了一个全局的 WeakMap 来存储所有的依赖关系,结构如下:
- targetMap (WeakMap): 键是
原始对象,值是depsMap。 - depsMap (Map): 键是对象的
属性名(key),值是dep。 - dep (Set): 存储所有依赖该属性的
Effect (副作用函数)。
为什么用 WeakMap? > 为了性能和内存优化。当原始对象不再被引用时,WeakMap 会自动让垃圾回收机制清理掉相关的依赖,防止内存泄漏。
// 核心数据结构示意
const targetMap = new WeakMap();
/* 结构展开如下:
targetMap (WeakMap): {
[rawObject]: Map { // 键是原始对象
"prop1": Set [effect1, effect2], // 键是属性名,值是副作用函数集合
"prop2": Set [effect3]
}
}
*/
-
第一层 WeakMap: 键是对象引用。使用 WeakMap 是为了防止内存泄漏,一旦对象被销毁,其相关的依赖映射也会自动被回收。
-
第二层 Map: 键是属性名 (Key)。一个对象有多个属性,每个属性都有自己的依赖池。
-
第三层 Set: 存储的是 ReactiveEffect 实例(副作用函数)。使用 Set 是为了去重,确保同一个 effect 不会被重复收集。
3. 运行流程图解
- 初始化:通过
reactive()或ref()将对象包装成 Proxy。 - 收集依赖:执行一个副作用函数(如渲染组件),触发 Proxy 的
get,调用track()将该函数存入对应的dep集合。 - 派发更新:修改属性触发 Proxy 的
set,调用trigger()找到对应的dep集合,循环执行里面的副作用函数。
4. Vue 2 vs Vue 3 依赖追踪对比
| 维度 | Vue 2 (defineProperty) | Vue 3 (Proxy) |
|---|---|---|
| 拦截方式 | 拦截属性 (Getter/Setter) | 拦截对象 (整个 Object) |
| 存储位置 | 闭包存储在每个属性中 | 全局统一管理 (targetMap) |
| 性能 | 初始化需要递归遍历所有属性 | 惰性监听:只有访问到深层时才代理 |
| 动态性 | 无法监听新增/删除属性 | 完美支持动态增删属性 |
5. 核心伪代码实现 (简易版)
// 存储所有依赖的“大地图”
// 结构为: WeakMap<target, Map<key, Set<effect>>>
const targetMap = new WeakMap();
let activeEffect = null; // 当前正在运行的副作用函数
/**
* 收集依赖:建立 属性 -> 副作用 的关联
*/
function track(target, key) {
if (!activeEffect) return; // 如果当前没有副作用运行,直接返回
let depsMap = targetMap.get(target);
if (!depsMap) {
targetMap.set(target, (depsMap = new Map())); // 为每个对象创建一个 Map
}
let dep = depsMap.get(key);
if (!dep) {
depsMap.set(key, (dep = new Set())); // 为每个属性创建一个存放 effect 的 Set
}
dep.add(activeEffect); // 将当前副作用添加到依赖集合中
}
/**
* 触发依赖:执行属性关联的所有副作用
*/
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
if (dep) {
dep.forEach(effect => effect()); // 运行所有收集到的副作用
}
}
/**
* 创建响应式对象
*/
function reactive(target) {
return new Proxy(target, {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver);
track(target, key); // 【核心】读取时收集依赖
return res;
},
set(target, key, value, receiver) {
const oldValue = target[key];
const result = Reflect.set(target, key, value, receiver);
if (oldValue !== value) {
trigger(target, key); // 【核心】修改时触发依赖
}
return result;
}
});
}
/**
* 模拟 Vue 的 watchEffect / mountComponent
*/
function effect(fn) {
activeEffect = fn;
fn(); // 立即执行一次,触发 getter 从而完成依赖收集
activeEffect = null; // 执行完后清空
}
// --- 测试代码 ---
const state = reactive({ count: 0 });
effect(() => {
console.log('当前计数是:', state.count);
});
state.count = 1; // 输出: 当前计数是: 1
五、vue2中,data中的每个属性都创建了一个dep实例,是如何将属性和dep实例对应上的?dep做的依赖收集是保存在哪里?
在 Vue 2 的响应式系统中,属性与 Dep(Dependency)实例的绑定主要通过 闭包(Closure) 实现,而依赖的存储则是由 Dep 实例内部的数组维护。
以下是深层机制的解析:
1. 属性如何与 Dep 实例对应?(闭包的力量)
在 Vue 2 初始化数据时,会调用 Object.defineProperty。这个过程发生在 defineReactive函数中。
核心逻辑:
- 每一个属性在执行 defineReactive 时,都会在函数作用域内创建一个私有的 Dep 实例。
- 由于 getter 和 setter 是在这个作用域内定义的函数,它们形成了闭包,可以一直访问到这个特定的 dep。
简易代码演示:
function defineReactive(obj, key, val) {
// 1. 为每个属性创建一个专属的 Dep 实例
// 这个 dep 变量被闭包捕获,与 key 形成一一对应关系
const dep = new Dep();
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
if (Dep.target) {
// 2. 依赖收集:将当前的 Watcher 存入该属性对应的 dep
dep.depend();
}
return val;
},
set: function reactiveSetter(newVal) {
if (newVal === val) return;
val = newVal;
// 3. 派发更新:通知该属性闭包里的 dep 去执行所有订阅者的 update
dep.notify();
}
});
}
对应关系: 并不是通过一个全局哈希表来对应的,而是通过函数作用域。当你访问 data.name,执行的是 name 闭包里的 getter,操作的就是 name 闭包里的那个 dep。
2. 依赖收集保存在哪里?
依赖(即 Watcher 实例)保存在 Dep 实例内部的 subs 数组中。
数据结构关系图
-
Dep 实例: 每一个 data 属性(或嵌套对象的属性)都有一个 Dep。
-
subs 数组: 它是 Dep 类的一个属性(this.subs = [])。
-
Watcher 实例: 当组件渲染、执行 computed 或 watch 时,会创建一个 Watcher。这个 Watcher 会被推入它所读取到的属性的 subs 数组里。
更多推荐



所有评论(0)