vue2 响应式原理
- Vue:框架构造函数
- Observer:执⾏数据响应化(分辨数据是对象还是数组)
- Compile:编译模板,初始化视图,收集依赖(更新函数、watcher 创建)
- Watcher:执⾏更新函数(更新 dom)
- Dep:管理多个 Watcher,批量更新
初始化的时候对 data 数据进行 Observer 响应式处理,还要 Compile 解析和编译模板,产生更新函数 updater,并在这个过程中产生 Watcher,将来对应数据变化时 Watcher 会调⽤更新函数,通过 Dep 和响应数据的 key 之间建立关系,将来 data 中数据⼀旦发⽣变化,会⾸先找到对应的 Dep,通知所有 Watcher 执⾏更新函数
vue3 响应式原理
Map、WeakMap 和 Set 的应用
对 targetMap 使用 WeakMap 类型是为了防止内存泄露
至于 keyMap 为什么不用 WeakMap 类型
- 一方面 key 值的类型一般是基本类型而不是对象,不能作为 WeakMap 的键
- 另一方面 Map 的查找和遍历性能也优于 WeakMap
deps 是 Set 结构:一个 Key 可能被多个 fn 调用,而且 fn 不用重复添加,需要去重,所以需要使用 Set 数据结构
Vue 3 中,依赖(即需要在数据变化时触发的副作用函数,如组件渲染函数、watch 回调等)的存储采用了三级映射结构,从顶层到底层依次为:
WeakMap<target, Map<key, Set<effect>>>- 第一层:
WeakMap的键是被代理的原始对象(target),值是一个Map(用于映射该对象的属性与依赖)。 - 第二层:
Map的键是对象的属性(key),值是一个Set(用于存储依赖该属性的所有副作用函数)。 - 第三层:
Set中存储的是具体的副作用函数(effect),即当target.key变化时需要执行的函数。
- 第一层:
为何选择 WeakMap + Map?
这个结构的设计与 JavaScript 引擎的特性深度结合,主要优化点体现在以下几个方面:
1. WeakMap 对原始对象的弱引用:自动垃圾回收
弱引用特性:
WeakMap的键(即被代理的原始对象target)是 “弱引用”,这意味着如果原始对象在外部没有其他强引用(即不再被使用),JavaScript 引擎的垃圾回收机制会自动回收该对象,同时WeakMap中对应的条目也会被移除。优化效果:
避免了 “数据已经失效但依赖仍被保留” 的内存泄漏问题。例如,当一个组件被销毁后,其关联的响应式数据对象若不再被引用,会被自动回收,无需手动清理依赖,减少了内存占用。
2. Map 对属性的精准映射:缩小更新范围
属性级别的依赖隔离:
Map以对象的属性(key)为键,将依赖与具体属性绑定。例如,target.a的依赖和target.b的依赖会被存储在Map的不同条目中。优化效果:
当某个属性(如
target.a)变化时,Vue 3 只需从Map中找到a对应的Set,触发其中的副作用函数,而不会影响其他属性(如target.b)的依赖。这种 “精准定位” 避免了 Vue 2 中可能出现的 “属性更新导致无关依赖触发” 的冗余操作,减少了不必要的更新开销。
3. Set 存储副作用函数:去重与高效操作
自动去重:
Set天然不允许重复值,因此同一个副作用函数不会被多次添加到依赖集合中(例如,组件渲染函数因嵌套访问同一属性时,不会重复收集)。高效增删:
Set的add、delete、clear等操作都是 O (1) 时间复杂度,比数组(Vue 2 中曾用数组存储依赖)的遍历去重(O (n))更高效。优化效果:
减少了依赖集合中的冗余函数,避免重复执行,同时提升了依赖添加 / 移除的效率
vue2 数组的响应式
在 Vue 2 中,由于 Object.defineProperty 无法直接监听数组通过索引修改元素或修改 length 的操作,Vue 采用了重写数组原型方法的方式来实现数组的响应式。其核心思路是:拦截数组的常用操作方法(如 push、pop 等),在执行原始方法的同时,触发依赖更新,从而保证视图与数据同步。
一、具体实现步骤
Vue 2 对数组的处理集中在 array.js 模块中,核心逻辑可分为以下几步:
1. 确定需要重写的数组方法
Vue 2 选择了数组原型中会改变数组自身内容的 7 个方法进行重写,因为这些方法是开发者最常用的修改数组的方式。这 7 个方法分别是:
push():向数组末尾添加元素pop():删除数组末尾元素shift():删除数组第一个元素unshift():向数组开头添加元素splice():添加/删除/替换数组元素(最常用,支持增删改)sort():排序数组reverse():反转数组
这些方法的共同特点是:调用后会改变原数组,而像 map、filter 等返回新数组的方法则无需重写(因为修改新数组不会影响原数组,若要响应式需手动替换原数组)。
2. 备份原始数组方法,创建拦截器
Vue 并没有直接修改 Array.prototype 上的方法(否则会污染全局数组),而是通过创建一个继承自数组原型的拦截器对象,在拦截器中重写目标方法,再让响应式数组的 __proto__ 指向这个拦截器。
具体代码逻辑如下(简化版):
// 1. 获取数组原型
const arrayProto = Array.prototype;
// 2. 创建拦截器对象,继承数组原型(避免污染原生方法)
const arrayMethods = Object.create(arrayProto);
// 3. 需要重写的 7 个方法
const methodsToPatch = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
// 4. 遍历方法,逐个重写
methodsToPatch.forEach(method => {
// 保存原始方法(如 Array.prototype.push)
const original = arrayProto[method];
// 重写拦截器上的方法
arrayMethods[method] = function (...args) {
// 步骤1:执行原始数组方法(保证功能不变)
const result = original.apply(this, args);
// 步骤2:获取当前数组的 Observer 实例(用于触发更新)
const ob = this.__ob__; // Vue 会为响应式对象添加 __ob__ 属性,指向其 Observer 实例
// 步骤3:处理新增元素(若有),将新增元素转为响应式
let inserted;
switch (method) {
case 'push':
case 'unshift':
inserted = args; // push/unshift 的参数就是新增元素
break;
case 'splice':
inserted = args.slice(2); // splice 的第三个参数及以后是新增元素(如 splice(0, 1, 'a') 中 'a' 是新增)
break;
}
// 若有新增元素,递归将其转为响应式
if (inserted) ob.observeArray(inserted);
// 步骤4:触发依赖更新(通知 Watcher 重新渲染)
ob.dep.notify();
// 返回原始方法的执行结果
return result;
};
});3. 让响应式数组指向拦截器
当 Vue 初始化数据时,若检测到属性是数组,会通过 Observer 类将其转为响应式数组。核心操作是:修改数组的 __proto__ 指向拦截器 arrayMethods,从而让数组调用方法时优先使用拦截器中重写的版本。
简化代码如下:
class Observer {
constructor(value) {
this.value = value;
this.dep = new Dep(); // 数组的依赖收集器
// 为响应式对象添加 __ob__ 属性,标记为已被观察
Object.defineProperty(value, '__ob__', {
value: this,
enumerable: false, // 不可枚举,避免被遍历到
writable: true,
configurable: true
});
if (Array.isArray(value)) {
// 若为数组,将其原型指向拦截器 arrayMethods
value.__proto__ = arrayMethods;
// 遍历数组元素,将嵌套对象转为响应式(如数组中的对象)
this.observeArray(value);
} else {
// 非数组对象,走 Object.defineProperty 逻辑
this.walk(value);
}
}
// 遍历数组元素,递归处理嵌套对象
observeArray(items) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i]); // observe 函数用于创建 Observer 实例
}
}
}通过这一步,响应式数组的方法调用会被拦截器捕获,而非直接调用原生数组方法。
二、如何避免影响其他场景下的数组使用?
Vue 2 重写数组方法的关键是仅对“响应式数组”生效,不会污染全局的 Array.prototype 或非响应式数组,具体通过以下方式保证:
1. 不直接修改原生数组原型
Vue 并没有修改 Array.prototype 本身,而是创建了一个继承自 Array.prototype 的拦截器 arrayMethods。只有被 Vue 处理为响应式的数组,其 __proto__ 才会指向 arrayMethods;普通数组的原型依然是原生 Array.prototype,因此不受影响。
例如:
// 普通数组(非响应式)
const normalArr = [1, 2, 3];
normalArr.push(4); // 调用原生 push 方法,无响应式逻辑
// Vue 响应式数组(在 data 中定义)
data() {
return { reactiveArr: [1, 2, 3] };
}
this.reactiveArr.push(4); // 调用拦截器中的 push 方法,触发更新2. 通过 __ob__ 属性区分响应式数组
Vue 为每个响应式对象(包括数组)添加了不可枚举的 __ob__ 属性,指向其 Observer 实例。只有带有 __ob__ 属性的数组,才会使用拦截器的方法;普通数组没有 __ob__,因此调用的是原生方法。
这一标记也用于避免重复处理:若数组已被转为响应式(有 __ob__),则不会再次创建 Observer 实例。
3. 拦截器方法内部调用原生方法
重写的方法(如 push)在执行时,会先通过 original.apply(this, args) 调用原生数组方法,保证数组操作的功能与原生一致,只是在其基础上增加了响应式逻辑(依赖更新、处理新增元素)。
因此,即使是响应式数组,其方法的基础功能(如 push 会添加元素)与原生数组完全一致,仅多了响应式副作用。
三、总结
Vue 2 对数组响应式的处理逻辑是:
- 重写 7 个会修改数组的原型方法,在拦截器中先执行原生方法,再触发更新并处理新增元素;
- 仅让响应式数组的原型指向拦截器,普通数组不受影响;
- 通过
__ob__属性标记响应式数组,避免重复处理和全局污染。
这种方式既解决了 Object.defineProperty 无法监听数组操作的问题,又保证了对其他场景下数组使用的无侵入性。不过,它依然存在局限性(如无法监听索引修改和 length 变化),这也是 Vue 3 改用 Proxy 彻底解决数组响应式问题的原因之一。
参考
4、带你一步步实现 vue3 源码之依赖收集&触发依赖 - 掘金
手写实现 vue2 和 vue3 的响应式原理和依赖收集、触发的对比 - 掘金