前言
关于Vue.js的数据响应式,vue2采用Object.defineProperty()来实现,vue3采用Proxy来实现。最近有在源码层面深入理解Vue,在尝试用代码简单实现一个MVVC框架的过程中,一些割裂的原理终于在脑海里形成了闭环,这篇文章因此诞生,希望能让大家有所收获
阅读本文前,你最好了解:
- Object.defineProperty()和Proxy
- vue响应式原理 当然,文章一开始也会简单介绍一下,帮助后续的阅读
读前回顾
Object.defineProperty()
在JS中,对象的每个属性会具有属性描述符(Property Descriptor),它是一个对象,用来描述这个属性的信息。例如value、configurable、enumerable、writable。相信你能回忆起它们的含义~
如果需要为某个对象添加属性或修改属性,配置其属性描述符,可以使用Object.defineProperty(对象, 属性名, 描述符)
属性描述符中,如果配置了get和set中的任何一个,则该属性,不再是一个普通属性,而变成了存取器属性
get和set配置均为函数,如果一个属性是存取器属性,则读取该属性时,会运行get方法,将get方法得到的返回值作为属性值。如果给该属性赋值,则会运行set方法
const obj = {
b: 2
}
Object.defineProperty(obj, "a", {
get() {
console.log("运行了属性a的get函数")
},
set(val){
console.log("运行了属性a的set函数", val)
}
})
console.log(obj);
我们可以看到一开始a属性值看不到,点击...后看到值,并发现运行了a的get函数,修改属性值运行set函数
Proxy
代理(Proxy)是ES6新增内容。感兴趣的朋友在回忆它的时候不妨顺便复习下反射(Reflext),这里就不多赘述。代理就像打官司自己请的律师,希望下面的例子可以帮你回忆起来些什么
const obj = {
a: 1,
b: 2
}
const proxy = new Proxy(obj, {
set(target, propertyKey, value) {
// console.log(target, propertyKey, value);
// target[propertyKey] = value;
console.log("设置完成")
Reflect.set(target, propertyKey, value); // 使用一下反射,当然可以使用target[propertyKey] = value
},
get(target, propertyKey) {
if (Reflect.has(target, propertyKey)) {
return Reflect.get(target, propertyKey);
} else {
return "没有找到诶orz";
}
},
has(target, propertyKey) {
return false;
}
});
proxy.a = 10;
console.log(proxy.a);
console.log(proxy.d);
console.log("a" in proxy);
vue响应式原理
我们来看官方的图片,简单的阐述一下vue响应式原理
Object.defineProperty方法遍历对象每一个属性,将其变成getter和setter(这样我们就有机会在属性被赋值和更新的时候做些什么)。render函数运行的时候用到响应式数据,收集了依赖,数据发生变化会通知Watcher,重新运行render函数
关于vue数据响应式原理,在具体实现上,vue用到了几个核心部件:Observer、 Dep、Watcher、Scheduler。与本文联系密切的就是Observer部件。对其余部件感兴趣的朋友可以阅读其他人的文章
简单实现
vue响应式原理我们大致也了解了,我们简单用代码写一下(只考虑数据改变我们能有所操作,不考虑vue实例之类的)
使用defineProperty
function observeObj(target) {
const obj = {};
for (const prop in target) {
Object.defineProperty(obj, prop, {
set(val) {
console.log('set', val); // 有机会操作
target[prop] = val;
},
get() {
console.log('get'); // 有机会操作
return target[prop];
}
});
if (target[prop] instanceof Object) {
target[prop] = observeObj(target[prop]); // 递归,深度遍历每一个属性
}
}
return obj;
}
看到注释了吗,数据赋值和变化,会运行set和get函数,从而我们可以有所操作,在vue中简单来说就是重新运行render函数,重新渲染页面,这就是响应式
使用Proxy
function observeObjByProxy(target) {
const p = new Proxy(target, {
set(target, prop, value) {
console.log(target, prop, value); // 有机会操作
target[prop] = value;
},
get(target, prop) {
console.log(target, prop); // 有机会操作
return target[prop];
}
});
return p;
}
观察效果
我们可以
const ob = observerObjByProxy({ a: 1, b: 2, c: { d: 3 }, e: [8, 9] });`
然后在控制台输出ob,并且试着赋值观察控制台输出,感兴趣的朋友快动起小手,这里就不放图片了
两者区别
那么可能有人好奇,这两种方法有什么区别?我先把主要的列出来,然后再慢慢解释
| 区别 | defineProperty | Proxy |
|---|---|---|
| 是否可监听数组变化 | 否 | 是 |
| 是否可以劫持整个对象 | 否 | 是 |
| 兼容性 | 支持主流浏览器(IE8及以上) | 不支持IE |
兼容性? 这个就不多扯了,Proxy出现于ES6,是晚于defineProperty的。不带IE玩才是真快乐,作为现在不用怎么考虑兼容的前端学习人真happy!
劫持整个对象? 从代码量我们就能看出来了,defineProperty要遍历到每个属性,在属性上设置。而Proxy直接传入一个待处理的对象,返回proxy对象。这样对数据的访问是动态的,当访问某个属性的时候,动态的获取和设置
我们思考一下,假如我们将响应式的对象再增加一个属性,那个新属性是响应式的吗?如果是defineProperty获得的,答案是否定的。因为我们只提前遍历了一开始有的所有属性
。如果是Proxy获得的,答案则是肯定的,我们从这个现象,可以感受到Proxy的强大,代理整个对象,同时,对动态二字也有了更深的体会
劫持数组? 这个是本篇文章最想聊的
重点就是理解这句话。还记得刚才我们的两个简单实现吗,你会发现操作defineProperty处理后的对象中的数组,下标访问与重新赋值,控制台的输出显示它仍是响应式的,那为什么说它不能劫持数组?
其实这句话针对的是数组原型上的方法!不信你可以试试push()。defineProperty得到的对象就没有响应式了,而Proxy得到的对象响应式依然在!
问题来了!!先附上vue2深入响应式原理的官方文档链接:cn.vuejs.org/v2/guide/re…
看文档的关于数组部分,再顺便回忆一下自己平时的开发。你可能会发现vue2中不同的地方:
- 我们使用数组的方法仍具有响应式!
- 我们的下标访问居然不行了!
让我们走向本文的最后一段!
再探vue响应式
特殊的数组
function observeObj(target) {
const obj = {};
for (const prop in target) {
Object.defineProperty(obj, prop, {
set(val) {...},
get() {...}
});
if (target[prop] instanceof Object) {
target[prop] = observeObj(target[prop]); // 递归,深度遍历每一个属性
}
}
return obj;
}
再看一遍我们之前的代码。首先我们要明确一个概念,数组本质上是对象。代码中,属性值是数组,这个数组会进入递归接着处理。而我们所谓的数组下标访问,例如arr[0],可以看成arr["0"],换成对象访问属性值就是obj["prop"],当然对象可以obj.prop
vue2中我们对象可以obj["prop"],为什么数组不可以了呢?实际上,vue2在实现响应式的时候,多了一层判断,假如属性值是数组,我们单独处理
function constructObjectProxy(vm, obj) {...} // 像我们之前写的方法,只代理对象,不代理数组
// 对数组处理部分
const arrayProto = Array.prototype; // 数组原型
// 重写数组方法,给我们操作的机会,从而实现响应式
function defArrayFunc(obj, func, vm) {
Object.defineProperty(obj, func, {
enumerable: true,
configurable: true,
value: function (...args) {
let original = arrayProto[func];
const result = original.apply(this, args); // 调用数组原型上的方法
// 我们可以在这个方法里进行操作,实现响应式
return result;
}
})
}
// 代理数组
function proxyArr(vm, arr, namespace) {
let obj = { // vue自定义对象
push() { },
pop() { },
}
defArrayFunc.call(vm, obj, "push", vm); // 调用函数重写方法
defArrayFunc.call(vm, obj, "pop", vm);
arr.__proto__ = obj; // 数组的原型指向我们自定义的对象
obj.__proto__ = arrayProto; // 我们对象的原型指向Array原型
return arr;
}
// 将对象变成响应式的总方法
export function constructProxy(vm, obj) {
let proxyObj = null;
if (obj instanceof Array) { // 这个对象是数组
proxyObj = new Array(obj.length);
for (let i = 0; i < obj.length; i++) { // 数组每一项深入去代理
proxyObj[i] = constructProxy(vm, obj[i]);
}
proxyObj = proxyArr(vm, obj); // 代理数组的方法
}
else if (obj instanceof Object) {
proxyObj = constructObjectProxy(vm, obj);
}
else {
}
return proxyObj;
}
不要被这段伪代码吓到,重在理解原理。建议从最下面的constructorProxy方法读起,其中vm是vue实例,不用管它
vue2对数组的处理重在方法的重写上,然后遍历数组每一项,再对值进行深入代理,判断值是不是对象。而数组的下标0、1、2等,并没有变成存取器属性
对方法的处理也不难理解,如下图(第一次在掘金上写文章,用ppt画图了):
这下可以结合代码阅读了吧。arr.__proto__ === vue自定义对象。vue自定义对象.__proto__ === Array.prototype
动态的增删
最后再聊聊我们刚才提到的 “动态” 吧
在vue2中,我们有两个API:vm.$set()和vm.$delete()。借助它们,我们就可以对已有响应式对象添加或删除属性,新的属性仍是响应式的
vm.$set()和vm.$delete()不要滥用,很有可能造成性能上的影响,具体原因,我可能会在之后可能出现的响应式原理文章上出现
vue3中我们使用了Proxy,它能监控到属性的新增和删除。这就方便多了。我们看vue3的API文档也会发现少了不少内容,“罪魁祸首”就是Proxy了。原先过分的暴露一些内部的方法,这是很不好的,vue3里将这个问题解决了
关于vue2与vue3的区别,在这里再挖个坑,嘿嘿
后记
感谢你看到了这里,这是我在掘金发表的第一篇博客。希望能对大家有所帮助
我的期盼就是我写的每一篇文章,尽可能的通俗易懂、循序渐进,无论是新加入前端的朋友,还是前辈大牛,读完后都能有所思考