
如果你用过Vue,你一定对它那响应式系统印象深刻。你只需要在<script>
里修改一个数据,template
里对应的视图就会自动更新。
JavaScript
// 你只做了这个
this.message = 'Hello, World!';
整个过程很丝滑流畅。
但作为工程师,我们都知道,它正是Vue框架的精髓所在,也是Vue 2和Vue 3在底层实现上最核心的区别。
这篇文章,就让我们一起深入底层,不仅要对比Vue 2的Object.defineProperty
和Vue 3的Proxy
这两种方案的优劣,更要亲手用几十行代码,分别实现它们的最简原型。
Vue 2 的 Object.defineProperty
Vue 2的响应式系统,是基于Object.defineProperty
这个ECMAScript 5的API。
它的核心思想是:劫持对象属性的getter
和setter
来实现。
你可以把它想象成,我们给一个对象的每个"属性" 都派驻了一个"对象守卫"。
- 当有人要读取这个属性时(
get
),并悄悄记下"谁"(我们称之为依赖)来过。 - 当有人要修改这个属性时(
set
),立刻去通知所有之前的依赖:"嘿嘿数据更新了!"
1. 动手实现一个迷你版
我们来用代码模拟这个过程。首先,我们需要一个"依赖管理器"(Dep
)和一个"依赖"(Watcher
)。
JavaScript
// 依赖管理器:每个属性都有一个,用来存放所有依赖它的Watcher
class Dep {
constructor() {
this.subscribers = new Set(); // 用Set来存放Watcher,可以自动去重
}
// 添加依赖
depend() {
if (Dep.target) {
this.subscribers.add(Dep.target);
}
}
// 通知更新
notify() {
this.subscribers.forEach(sub => sub.update());
}
}
// 全局的临时变量,用来存放当前正在计算的Watcher
Dep.target = null;
// 依赖:一个需要响应数据变化的对象(比如一个渲染函数)
class Watcher {
constructor(updateFn) {
this.updateFn = updateFn;
}
// 包装一下,设置Dep.target,然后执行更新函数
// 执行更新函数时,就会触发属性的get,从而被Dep收集起来
watch() {
Dep.target = this;
this.updateFn();
Dep.target = null;
}
update() {
this.updateFn();
}
}
现在,我们来写核心的"劫持"函数:
JavaScript
// 核心函数:为对象的单个属性添加响应式
function defineReactive(obj, key, val) {
const dep = new Dep(); // 每个属性都有一个自己的依赖管理器
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
// 依赖收集
dep.depend();
return val;
},
set: function reactiveSetter(newVal) {
if (newVal === val) return;
val = newVal;
// 通知依赖更新
dep.notify();
}
});
}
// 遍历对象,对所有属性进行劫持
function observe(obj) {
if (typeof obj !== 'object' || obj === null) {
return;
}
Object.keys(obj).forEach(key => {
defineReactive(obj, key, obj[key]);
});
}
2. Object.defineProperty
的"先天不足"
这个方案很巧妙,但在实际使用中暴露了几个无法根治的问题:
- 无法监听对象属性的新增和删除 :
Object.defineProperty
只能劫持一个对象已有的 属性。如果你在初始化之后,给对象动态添加一个新属性(obj.newProp = '...'
),Vue 2是无法检测到这个变化的。为此,Vue 2不得不提供一个特殊的API------Vue.set
来解决这个问题。 - 无法原生监听数组的变化 :它不能直接劫持数组的索引和
length
属性。Vue 2的解决方案是"魔改"了数组的原型方法,比如push
、pop
、splice
等,在这些方法被调用时,手动去触发更新。这既不优雅,也增加了额外的开销。 - 必须在初始化时就递归遍历所有属性 :
observe
函数需要在最开始就把一个对象(可能非常深、非常大)的所有属性都用Object.defineProperty
包装一遍,这对性能是有一定影响的。
Vue 3 的 Proxy
为了解决Object.defineProperty
的这些"先天不足",Vue 3毅然决然地拥抱了ES6的新特性------Proxy
。
Proxy
的核心思想是:在目标对象之前架设一层"代理",对对象的 所有操作 (不只是get
和set
)进行拦截。
(概念说的烂透了,不想继续写了🤭)
1. 动手实现一个迷你版
使用Proxy
,代码会变得更简洁、更强大。我们只需要一个reactive
函数。
JavaScript
// (这里的Dep和Watcher可以复用上面的)
function reactive(obj) {
// 用一个Map来存储每个原始对象和它的依赖管理器
const targetMap = new WeakMap();
return new Proxy(obj, {
get(target, key, receiver) {
// 依赖收集
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
let dep = depsMap.get(key);
if (!dep) {
dep = new Dep();
depsMap.set(key, dep);
}
dep.depend();
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver);
// 通知更新
let depsMap = targetMap.get(target);
if (depsMap) {
let dep = depsMap.get(key);
if (dep) {
dep.notify();
}
}
return result;
}
// deleteProperty, has等其他trap可以类似实现
});
}
(注:这是一个极简实现,真实的Vue实现会更复杂,会用track
和trigger
函数来封装依赖收集和通知逻辑)
2. Proxy
的"降维打击"优势
- 全方位拦截 :
Proxy
可以拦截多达13种操作,包括属性的增、删、查等,完美解决了Object.defineProperty
无法监听属性新增/删除的问题。再也不需要Vue.set
了。 - 原生支持数组 :对数组的操作(包括通过索引修改、修改
length
)都能被Proxy
的get
/set
trap捕获,不再需要去"魔改"数组方法。 - 懒处理,性能更佳 :
Proxy
是对整个对象的代理,它不需要在初始化时就去递归遍历所有属性。只有当你真正访问到某个深层属性时,才会触发对应的get
操作,这是一种"懒代理"模式,在某些场景下性能更好。
没有对比 没有伤害👇
特性 | Object.defineProperty (Vue 2) |
Proxy (Vue 3) |
---|---|---|
拦截目标 | 对象的单个属性 | 整个对象 |
新增/删除属性 | 不支持 (需Vue.set/delete ) |
支持 |
数组操作 | 不支持 (需重写原型方法) | 支持 |
初始化 | 需递归遍历所有属性 | 只代理顶层对象,懒处理 |
浏览器兼容性 | 兼容到IE9 | ES6+ (不兼容IE) |
从Object.defineProperty
到Proxy
,这不仅仅是一次简单的API替换,更是一次 Vue响应式系统的进化 。Proxy
让Vue的响应式系统摆脱了历史的束缚,变得更加健壮、完整和高效。
虽然Proxy
放弃了对IE的兼容,但这无疑是一个完全正确和值得的决策。
希望通过这次的对比和动手实现,能给你更深刻的理解。
分享完毕😊