在上一篇30K必考面试题:Vue响应式是如何实现的?我来带你手把手写一个 - 掘金中,还留了个坑没有填:Vue的数组响应式是如何实现的?
Vue的对象响应式实现还不理解的同学可以先去30K必考面试题:Vue响应式是如何实现的?我来带你手把手写一个 - 掘金补一下课,简单易懂看了就会。
为什么数组响应式和对象响应式不一样?
众所周知,在JS中“数组”也是一种对象,那既然对象是利用Object.defineProperty
来实现响应式的,按理说数组也一样可以调用这个方法才对!
让我们先来用Object.defineProperty
在数组上试试(如果看不懂这段代码,一定要先去30K必考面试题:Vue响应式是如何实现的?我来带你手把手写一个补个课):
<div id="arrayBox"></div>
<button id="btn">+1</button>
<script>
let array = [1];
for (const key in array) {
let value = array[key];
Object.defineProperty(array, key, {
set(newVal) {
value = newVal;
refreshDocument();
},
});
}
function refreshDocument() {
document.querySelector("#arrayBox").innerHTML = `[${array}]`;
}
refreshDocument();
document.querySelector("#btn").addEventListener("click", function () {
array[0] += 1;
});
</script>
运行代码,可以看到我们已经通过最基础的Object.defineProperty
实现了基础的响应式效果:
既然可以实现,那为什么Vue不是通过使用和普通对象一样的Object.defineProperty
方式来给数组实现响应式呢?
说到这里,我们就要说到使用Object.defineProperty
实现响应式的缺点了:
- 只能对现有属性进行监听,对新增属性或删除属性不能直接进行监听;
- 需要对监听对象进行属性遍历,性能消耗大;
先说第一点“只能对现有属性进行监听,对新增属性或删除属性不能直接进行监听”,很好理解:我们刚才遍历了array
中的所有属性,对array
中唯一的属性array[1]
进行了Object.defineProperty
设置,让array[1]
在变化后可以通过setter
调用刷新页面的方法refreshDocument
。但如果我们动态地给array
新增一个属性呢?比如这样:
document.querySelector("#btn").addEventListener("click", function () {
array.push(2);
});
在点击按钮后,我们不再是修改array[1]
的值了,而是通过push
方法新增一个数组元素2,我们再来看看效果:
响应式果不其然失效了,但这也很合理,毕竟我们只对数组中的第一项进行了Object.defineProperty
,而后面新增的值并没有被遍历设置,自然是没有响应式的。
所以在Vue2中,如果要给对象新增属性,直接设置是会让新属性丢失响应式的。比如这样:
data: {
message: "Hello Vue!"; // this.message是响应式的
}
data.a = 10; // this.a是非响应式的
这时的data.a
是没有响应式的。如果要增加新属性,我们必须要通过Vue.set
方法来新增,Vue才会对新属性设置响应式。
说完第一点,再来说第二点缺点:
Vue对于数组的响应式处理方式和对于对象的处理方式完全不同,熟悉Vue2的同学一定知道:在Vue2中如果直接修改数组元素,类似上面那样:array[0] += 1
,是不会生效的;而你想要让你的修改被响应式监听到,需要使用push
、pop
、splice
等数组原生方法才可以。
这是为什么呢?明明我们刚才用Object.defineProperty
来设置数组元素就可以实现监控所有现有属性的修改,为什么Vue反而没有实现这个功能呢?
这就是因为Object.defineProperty
的第二个缺点:性能太差。
试想一下,在日常工作中本就经常需要处理大量的数组,有时候一个数组就包含上千条数据,有可能在其中还有数组的嵌套。这时光是对这一个数组,Vue要实现响应式就至少要循环上千次,分别给每个数组元素都设置对应的Object.defineProperty
。而如果数组元素有新增或删除,又需要对整个数组重新进行上千次遍历进行设置。
这个性能消耗,想想就害怕!
所以在Vue2中,对于数组的响应式处理走了一条截然不同的路。
数组的响应式实现
在Vue2中,只有调用数组原型上的原型方法来修改数组才可以被响应式监听,比如:
let array = [1];
function refreshDocument() {
document.querySelector("#arrayBox").innerHTML = `[${array}]`;
}
refreshDocument();
document.querySelector("#btn").addEventListener("click", function () {
array.push(2);
});
我们要实现的效果就是,在这段基础代码的基础上,点击按钮后让页面更新显示新的数组,像是这样:
要怎么实现这个功能呢?我们先来梳理下思路:
用Object.defineProperty
来给对象元素添加响应式,是利用getter
和setter
的特性,在值被修改时通过setter
调用对应的回调方法,完成对页面的更新;
而数组只需要在数组原型方法被调用时才需要执行对应的回调来更新页面,所以也就是说我们只需要在push
方法被执行时同时执行更新页面的方法就可以实现了,类似这样:
document.querySelector("#btn").addEventListener("click", function () {
array.push(2);
refreshDocument();
});
这样确实实现了点击按钮后,页面元素被刷新。
但是,在Vue里我们可不需要在每次调用数组的push
方法后,都手动调用刷新页面的回调方法。我们需要的效果是每一次push
方法被调用后,回调方法都自动执行。要实现这个需求,我们要先来看看push
方法是怎么被调用的。
何为原型方法?
熟悉原型链的同学可以快速略读这个章节。
我们一直在说,Vue中的数组在被调用部分“原型方法”后会更新页面,那这个“原型方法”到底是什么?和“普通方法”有什么区别呢?
先来看个例子:
const arr = [0, 1, 2, 3];
arr.func = function() {
console.log('func被调用啦');
}
console.log(arr);
arr.func()
从输出结果可以看到,数组arr
中除了有最开始定义的[0, 1, 2, 3]
之外,还有后面新增的func
方法。而我们调用arr.func
也是可以正常运行func
方法的。
但如果这时我们运行一个不存在的方法,比如arr.f()
:
这时自然代码就报错:“arr.f
不是一个方法”,这也是自然,我们没有定义这个方法,当然运行不了。
但为什么我们这时候去运行arr.push()
就不会报错呢?明明在刚才的console.log(arr)
输出结果中我们也没有看到有这个push
方法的存在。
可以看到,push
方法就被定义在数组的prototype
属性中,除此以外还能看到我们熟悉的splice
、pop
、map
等数组方法。
这个prototype
就是数组的原型,而这些方法就是数组的原型方法。
原型和原型链不是这篇文章的重点,在这里我就简单说一下,如果有同学想看我聊聊原型和原型链的话记得留言告诉我。
简单来说,每一个JS对象都通过[[Prototype]]
指向一个自己的原型对象,比如每一个“原始数组”指向的“原型对象”都是“Array构造函数”的原型对象,原型对象中的方法就是原型方法;如果在数组上调用push
方法,而数组中本身不包含这个方法,就会顺着往[[Prototype]]
,也就是数组的原型对象里面找,而数组的原型对象里是包含这个push
方法的。
所以我们可以在数组中调用到数组本身里“不存在”的push
方法。
利用原型实现方法实现回调执行
第一步尝试
我们现在想要做到的,不过就是在每次push
方法被执行后,都调用对应的回调方法。知道了上述原型方法的执行逻辑后,如何做到这一步就有点思路了。
既然是push
执行后就执行回调,那我们就像对象响应式中一样,利用Object.defineProperty
给push
方法设置get
方法,每当push
方法被调用就会触发get
方法执行,然后在get
方法中调用回调不就好啦?说干就干,我们来试试:
let array = [1];
function refreshDocument() {
document.querySelector("#arrayBox").innerHTML = `[${array}]`;
}
refreshDocument();
document.querySelector("#btn").addEventListener("click", function () {
array.push(2);
});
const push = Array.prototype.push;
// 数组的push方法就在Array.prototype原型对象中
Object.defineProperty(Array.prototype, 'push', {
get() {
console.log('我是Array的push方法,我被执行了');
refreshDocument();
return push;
}
})
可以看到,这个效果被完美实现了:每一次点击按钮都会调用array.push(2)
,从而触发push
方法的get
方法中的回调方法refreshDocument
被调用。
但是真的就这么简单吗?我们再来看看另一种情况:array
现在是我们希望被响应式监听的对象,但我们实际的代码中会有一些“非响应式数据”,也就是一些就算“数据改变”也不会引起“页面改变”的数据,这些数据在被修改后不应该触发回调方法。
但我们看看现在的逻辑:我们直接修改了Array原型对象上的push
方法,只要是在这个页面上调用push
方法,不论调用者是不是响应式数据,都会引起回调方法refreshDocument
被调用。改改代码来看看是不是这样:
<button id="nonreactiveBtn">nonreactiveArray push(20)</button>
<script>
// 只对data中的值进行响应式监听
let data = {
array: [1],
};
// 非响应式数组对象
let nonreactiveArray = [10];
function refreshDocument() {
document.querySelector("#arrayBox").innerHTML = `[${data.array}]`;
}
refreshDocument();
// 点击按钮后更改响应式数组的内容
document.querySelector("#btn").addEventListener("click", function () {
data.array.push(2);
});
// 点击按钮后更改非响应式数组的内容
document.querySelector("#nonreactiveBtn").addEventListener("click", function () {
nonreactiveArray.push(20);
});
const push = Array.prototype.push;
Object.defineProperty(Array.prototype, "push", {
get() {
console.log(`我是Array的push方法,我被执行了`);
setTimeout(() => {
refreshDocument();
}, 0);
return push;
},
});
</script>
可以看到,在我们新增一个非响应式数组并用push
方法对其进行修改后,get
中的回调依然被执行了。虽然页面看起来没有变化,但其实每一次push
的调用都触发了refreshDocument
方法的执行,这无疑是没有意义的性能浪费。
第二步走向正轨
所以Vue中的数组响应式用的并不是这个方式,但排除了一个错误答案后我们离真相又近了一步。Vue2的数组响应式是如何实现的呢?
我们前面说了,如果在array
上调用push
方法,会调用到array.[[prototype]]
中的push
方法。既然我们不能直接修改push
,那我们可不可以在array.[[prototype]]
这个原型对象上动刀子呢?比如我们先把array.[[prototype]]
给改成自己写的一个对象。我们来试试:
let data = {
array: [1],
};
function refreshDocument() {
document.querySelector("#arrayBox").innerHTML = `[${data.array}]`;
}
refreshDocument();
document.querySelector("#btn").addEventListener("click", function () {
data.array.push(2);
});
// 通过array.__proto__来获取array.[[prototype]],对其进行修改
data.array.__proto__ = {
push: function () {
alert("push");
}
}
可以看到,我们已经通过重写array.[[prototype]]
原型对象,来篡改了push
方法。这已经完成了重要的第一步。
但现在又出现了新的问题:push方法原本的功能没有了,现在push
方法只会调用alert
,而不会去给数组新增一位元素。解决这个问题其实很简单,我们只需要在自己写的push方法中调用下Array原型上的push
方法就好了:
data.array.__proto__ = {
push: function (...args) {
// 使用call方法来调用Array.prototype.push方法,把this设置为当前this。不懂this的同学留言告诉我
Array.prototype.push.call(this, ...args);
console.log(data.array);
}
}
可以看到,我们已经实现了重写push
方法的同时,依然保留了push
方法原本的功能。这下离成功不就只剩一步之遥啦?现在只需要在我们自己写的push
方法中调用回调refreshDocument
,不就实现响应式了嘛:
// 只对data中的值进行响应式监听
let data = {
array: [1],
};
function refreshDocument() {
console.log('refreshDocument被调用啦,现在展示的数组是:', data.array);
document.querySelector("#arrayBox").innerHTML = `[${data.array}]`;
}
refreshDocument();
document.querySelector("#btn").addEventListener("click", function () {
data.array.push(2);
});
data.array.__proto__ = {
push: function (...args) {
Array.prototype.push.call(this, ...args);
refreshDocument();
},
};
奇怪的事情又发生了,从控制台的输出结果来看,refreshDocument
确实被调用了,数组的值也被正确修改了,但为什么页面上展示的内容变成了这奇怪的样子?
直接说结论,refreshDocument
方法会把数组array
转换成字符串显示在页面上,而将数组正确转换成字符串的toString
方法本身是在Array.prototype
原型对象中的,我们现在因为改写了array.[[Prototype]]
,并且我们写的原型对象中并没有toString
方法。所以当我们把array
转换为字符串时,顺着原型链找到了Object.toString
方法,我们看到的页面显示结果就是把数组传入Object.toString
方法后得到的结果。
这一段没完全看懂也没关系,你就需要知道我们现在改写的原型对象中只有push
方法,导致很多原本数组的方法都不可用了。比如这时我们写一个array.pop()
方法调用试试:
这时不出意外地报错了,毕竟我们只写了push
方法嘛,别的方法在我们改写的原型方法上就不存在了,自然就会报错“方法不存在”。
那这样岂不是我们要把Array
原型对象上所有的方法都重写一遍才行?这为了实现一个响应式付出的代价也太了吧,毕竟Array
原型对象中大多数的方法都不会改变数组,重写也没什么意义呀!这时该怎么办?
第三步走向完善
我们前面说过:“如果在array
上调用push
方法,会调用到array.[[prototype]]
中的push
方法”。
但如果我们调用一个array.[[prototype]]
上也不存在的方法呢?比如有个在array.[[prototype]]
上不存在的方法叫valueOf
,我们看看如果我们运行array.valueOf
会发生什么:
可以看到,这时并没有因为array.[[prototype]]
中没有valueOf
方法而报错,而是正常执行了。这是因为如果我们获取一个在对象本身(array
)和对象指向的原型对象(array.[[prototype]]
)中都不存在的属性时,JS并不会轻易放弃,而是会继续顺着array.[[prototype]]
向上找。
记得我们前面说过“每一个JS对象都通过[[Prototype]]
指向一个自己的原型对象”吗,而array.[[prototype]]
也是一个对象,所以它自然也有一个[[prototype]]
属性,指向更上一层的原型对象。
所以当我们执行array.valueOf()
时,其实是执行到了array.[[prototype]].[[prototype]].valueOf()
,这是不是就像一条链条把不同的方法都串联到了一起,这也就是所谓的“原型链”。
所以我们可以利用“原型链”的特点,解决上面“丢失Array原生方法”的问题。
其实代码非常简单,一行就能搞定:
data.array.__proto__ = {
push: function (...args) {
Array.prototype.push.call(this, ...args);
refreshDocument();
},
__proto__: Array.prototype // 将自定义对象的原型对象指向到 Array.prototype
};
此时我们再来看看页面效果:
这下不就完美实现了!这时虽然我们都自定义原型对象中依然不存在toString
方法,但我们把自定义原型对象的[[Prototype]]
指向到了Array.prototype
,所以我们其实是通过array.[[Prototype]].[[Prototype]].toString
找到了数组本身的toString
方法,完美解决了上面的问题。
并且这时也不会影响其它非响应式数组,毕竟我们的操作都是针对data.array
进行的!在现在这种实现方式中因为不会对数组元素进行遍历,也不会遇到前面说到的Object.defineProperty
定义响应式的“性能太差”的问题,可以说是一箭双雕了!
虽说我们看起来已经很好得实现了数组响应式的功能,但现在的代码只是一个实现数组响应式的最基础版本,有三个主要问题需要完善:
- 现在数组修改后调用的回调方法依然是写死固定的,但实际的源码中肯定不会如此简单粗暴,要解决这个问题就要用到上一篇Vue响应式是如何实现的?中“依赖收集”的知识;
- 现在只对
push
方法进行了响应式操作,但还需要对包括splice
、pop
、shift
在内的会对数组本身产生修改的方法都进行改写; - 现在是单独对
data.array
进行响应式监听,但我们需要对data
中所有的数据都进行响应式监听,包括其中的基础类型、对象类型和数组类型的属性值。
结合上一篇文章和这篇文章中的实例代码,尝试着解决这三个问题,写完可以再找一份源码来改一改看看区别,搞定后你对于“Vue2响应式”和“依赖收集”就不会有任何问题啦,但一定要自己动手试一试,最少也要按照顺序把上面的代码一步一步走一遍,一定会让你有所收获!
本章小节
Vue2的响应式原理内容到此就告一段落啦,Vue2的“对象响应式”和“数组响应式”是分别通过Object.defineProperty
和“改写原型对象”实现的,实现这些响应式功能的基础逻辑都并不复杂,不过都是在“值改变时调用对应的回调方法”这个逻辑,只是如何利用JS的特性来实现这样的逻辑是我们需要深入理解和学习的。
背了很多八股文却感觉无处可用的同学这下可以长舒一口气:八股文真是能用上的!什么闭包原型链,这下不都用上了。
当然实际的Vue源码比我们写的简易demo还是要复杂多了,但是核心逻辑依然一致,只是在核心逻辑的基础上增加了更多的性能优化、更多不同情况的逻辑判断。当你理解了这两篇文章的内容后再去阅读源码,一定会轻松很多!
有问题的前端知识的小伙伴还请多多评论留言,共同讨论相互学习!😁