blog
blog copied to clipboard
vue早期源码学习系列之二:如何监听一个数组的变化
前言
继上一篇 #84 ,文末我们提到另一个问题如何监听数组的变化?,今天我们就来解决这个问题。我们先来看一眼vue官方说明文档?
Vue.js 包装了被观察数组的变异方法,故它们能触发视图更新。被包装的方法有:
- push()
- pop()
- shift()
- unshift()
- splice()
- sort()
- reverse()
出处:https://cn.vuejs.org/v2/guide/list.html#变异方法
Vue.js 不能检测到下面数组变化:
- 直接用索引设置元素,如 vm.items[0] = {};
- 修改数据的长度,如 vm.items.length = 0。
出处:https://cn.vuejs.org/v2/guide/list.html#注意事项
为什么说明文档中提到只有某些特定方法才能触发视图更新呢?我们可以从vue的源码中找到答案。
奇技淫巧
这次checkout的版本更上次一样,都是这个位置。 相关的源码是这两个地方。
- observe/array-augmentations.js
- observe/observer.js // line 38
整体思路是什么呢? → 通过重新包装数据中数组的push、pop等常用方法。注意,这里重新包装的只是数据数组(也就是我们要监听的数组,也就是vue实例中拥有的data数据)的方法,而不是改变了js原生Array中的原型方法。
为什么不能修改原生Array的原型方法呢?这道理很显然,因为我们是在写一个框架,而非一个应用,我们不应该过多地影响全局。如果你真得采取了这种糟糕的方法,想象以下场景:”你在一个应用中使用了vue,但是你在vue实例以外定义了一些数组,你改变这些与vue无关的数组的时候,居然触发了vue的方法!!“这能忍??
代码实现
const aryMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
const arrayAugmentations = [];
aryMethods.forEach((method)=> {
// 这里是原生Array的原型方法
let original = Array.prototype[method];
// 将push, pop等封装好的方法定义在对象arrayAugmentations的属性上
// 注意:是属性而非原型属性
arrayAugmentations[method] = function () {
console.log('我被改变啦!');
// 调用对应的原生方法并返回结果
return original.apply(this, arguments);
};
});
let list = ['a', 'b', 'c'];
// 将我们要监听的数组的原型指针指向上面定义的空数组对象
// 别忘了这个空数组的属性上定义了我们封装好的push等方法
list.__proto__ = arrayAugmentations;
list.push('d'); // 我被改变啦! 4
// 这里的list2没有被重新定义原型指针,所以就正常输出
let list2 = ['a', 'b', 'c'];
list2.push('d'); // 4
PS:如果不能理解这里的proto,请翻看《Javascript的高级程序设计》第148页,以及参看这个答案,多看几遍你就懂了。(吐槽:每次碰到js原型都不好描述.....)
======================= 分割线 ========================== 2017.3.8 更新:在下面这这一章节《作者写得有问题?》中,关于“为何这么写”的解析有误。 在此保留原文,正确的解析请参考 @Ma63d 的评论。https://github.com/youngwind/blog/issues/85#issuecomment-284974136 ======================= 分割线 ===========================
作者写得有问题?
ok,目前为止我们已经实现了如何监听数组的变化了。 但是,我们仔细回想一下,难道只能通过作者那样的方法来实现吗?不觉得直接重新定义proto指针有点奇怪吗?有其他实现的方法吗? 我们回到最开始的目标: 对于某些特定的数组(数据数组),他们的push等方法与原生Array的push方法不一样,但是其他的又都一样。 这不就是经典的继承问题吗? 子类和父类很像,但是呢,子类有点地方又跟父类不同 我们只需要继承父类,然后重写子类的prototype中的push方法不就可以了吗?红宝书告诉我们组合继承才是最常用的继承方法啊!(请参考红宝书第168页)难道是作者糊涂了?(想到这儿,我心里一阵窃喜,拜读了作者的代码这么久,终于让我发现一个bug了,不过好像也算不上是bug) 废话不多说,我赶紧自己用组合继承实现了一下。
function FakeArray() {
Array.call(this,arguments);
}
FakeArray.prototype = [];
FakeArray.prototype.constructor = FakeArray;
FakeArray.prototype.push = function () {
console.log('我被改变啦');
return Array.prototype.push.call(this,arguments);
};
let list = ['a','b','c'];
let fakeList = new FakeArray(list);
结果如下图所示
虽然我成功地重新定义push方法,但是为什么fakeList是一个空对象呢? 原因是:构造函数默认返回的本来就是this对象,这是一个对象,而非数组。Array.call(this,arguments);这个语句返回的才是数组。
那么我们能不能将Array.call(this,arguments);直接return出来呢? 不能。原因有两个:
- 如果我们return这个返回的数组,这个数组是由原生的Array构造出来的,所以它的push等方法依然是原生数组的方法,无法到达重写的目的。
- 如果我们return这个返回的数组,其实最后fakeList === [[['a','b','c']]],它变成了一个数组的数组的数组,因为list本身是一个数组,arguments用封装了一层数组,new Array本身接收数组作为参数的时候本来就会返回包裹这个数组的数组,new Array(['a', 'b']) === [['a', 'b']],所以就变成三层数组了。
shit.....太麻烦了!看来还是没有办法通过组合继承的模式来实现一开始的目标。(写到这儿,我心里默念:还是老司机厉害啊!我还是太年轻了......)
后话
目前为止,我们已经知道如何监听对象和数组的变化了,下一步应该做什么呢? 答案是:实现一个watch库 什么是watch库?你看一下这个就知道了。
ES2015的class extends语法是可以完美继承Array的,没有你提到的问题
另外, let fakeList = new FakeArray(list);
写法有误, 应该写成
et fakeList = new FakeArray(...list);
@renaesop
并没有吧?
class FakeArray {
constructor() {
Array.call(this,arguments);
}
push(){
console.log('我被改变啦');
return Array.prototype.push.call(this,arguments);
}
}
var list = [1, 2, 3];
var arr = new FakeArray(...list);
console.log(arr.length)
arr.push(3);
console.log(arr)
@henryzp Array.call(this,arguments)
不等价于extends的语义
另外,push里面应该用apply吧
class FakeArray extends Array{
push(...args){
console.log('我被改变啦');
return super.push(...args);
}
}
var list = [1, 2, 3];
var arr = new FakeArray(...list);
console.log(arr.length)
arr.push(3);
console.log(arr)
@renaesop ,受教。。
thx
作者那个proto应该是原型式继承,《高程》中紧随的那一节,实际上就是Object.create
@lingxufeng2014 刚刚翻了一下书,发现确实如此。以前看书看到最常用的组合继承之后,就忽略了其他少用到继承方式了。多谢指点!
https://vuejs.org.cn/guide/list.html#u53D8_u5F02_u65B9_u6CD5 https://vuejs.org.cn/guide/list.html#问题 出处链接挂了,Vuejs 的中文网址换 URL 了,随手补上新的链接 https://cn.vuejs.org/v2/guide/list.html#数组更新检测 https://cn.vuejs.org/v2/guide/list.html#注意事项
@youngwind ~~半年过去了,博主这个博客的star也由我最开始看这篇文章的100多变成了现在的700多,但是到现在博主依然没有改掉文中的很明显的错误,额。。。。。为了不误导后续的读者,我说一下把。~~
我们先不说Vue不采用继承数组来实现数组监听的问题。 先说说博主给的代码和文章原文:
function FakeArray() {
Array.call(this,arguments);
}
FakeArray.prototype = [];
FakeArray.prototype.constructor = FakeArray;
FakeArray.prototype.push = function () {
console.log('我被改变啦');
return Array.prototype.push.call(this,arguments);
};
let list = ['a','b','c'];
let fakeList = new FakeArray(list);
虽然我成功地重新定义push方法,但是为什么fakeList是一个空对象呢? 原因是:构造函数默认返回的本来就是this对象,这是一个对象,而非数组。Array.call(this,arguments);这个语句返回的才是数组。 那么我们能不能将Array.call(this,arguments);直接return出来呢? 不能。原因有两个: 如果我们return这个返回的数组,这个数组是由原生的Array构造出来的,所以它的push等方法依然是原生数组的方法,无法到达重写的目的。 如果我们return这个返回的数组,其实最后fakeList === [[['a','b','c']]],它变成了一个数组的数组的数组,因为list本身是一个数组,arguments用封装了一层数组,new Array本身接收数组作为参数的时候本来就会返回包裹这个数组的数组,new Array(['a', 'b']) === [['a', 'b']],所以就变成三层数组了。
先说博主你说的第二个原因,这里的错误是博主搞混了call和apply。博主在:
function FakeArray() {
Array.call(this,arguments);//应该用apply
}
以及:
FakeArray.prototype.push = function () {
console.log('我被改变啦');
return Array.prototype.push.call(this,arguments); //应该用apply
};
...
let fakeList = new FakeArray('a','b','c');//不能用数组来作为参数,那样的话数组就被包在数组里了。
在上述两处代码中博主都犯了这个错误。
然后再回到博主说的两个原因中的第一点:
如果我们return这个返回的数组,这个数组是由原生的Array构造出来的,所以它的push等方法依然是原生数组的方法,无法到达重写的目的。
Nonono,博主你写的代码是对的,常说的组合寄生式继承就是你写的这段代码,除了刚刚说的那个apply和call的小错误,其他的一点没错。因此要想达到重写的目的的话,就用你的写法是完完全全可以的,错误的不是你的写法,而是数组这东西很特殊。那好,我们假设,我们继承的不是数组,而是一个其他正常一点的东西,比如是一个我自己写的类Father:
function Father(){
}
Father.prototype.push = function(){
console.log('我是父类方法')
}
// 下面的代码是博主你的代码,我只不过fix了一下call方法和最后构造函数调用的传参
// 同时由继承Array变成了继承Father
function FakeArray() {
return Father.apply(this,arguments);
}
FakeArray.prototype = new Father;
FakeArray.prototype.constructor = FakeArray;
FakeArray.prototype.push = function () {
console.log('我被改变啦');
return Father.prototype.push.apply(this,arguments);
};
let fakeList = new FakeArray('a','b','c');
这段简单的继承代码真心不用我说。
那为什么把Father换成Array就不行了呢?
因为Array构造函数执行时不会对传进去的this做任何处理。不止Array,String,Number,Regexp,Object等等JS的内置类都不行。
Object.apply({a:'1'})
跟你执行Object()
得到的对象一模一样。而我们自己写的Father却不会。
这也是那个著名的问题的来源:ES5及以下的JS无法完美继承数组。(博主可以随意google,文章非常多,git上有大量的程序员朋友用各种奇技淫巧来实现继承数组实现队列、栈等等子类,但都不是完美的) 为什么无法完美继承?
-
因为数组有个响应式的
length
:他会自动根据你填入元素的下标进行增长,同时你把他改小的话,他一次删除把中间的元素给删除。a = [1];a[10]=1;a.length===11
以及a = [1,2,3,4];a.length=1//此时元素2,3,4被删除了
-
数组内部的[[class]]属性,这个属性就是我们用
Array.isArray(someArray)
和Object.prototype.toString.call(someArray)
来判断someArray是否是数组的根源,这是引擎内部实现,用任何JS方法无法改变。而为什么一定要用这两种方法来判断是否是数组大家应该都看过相关文章把,比如someArray instanceof Array
就无法正确判断,在someArray是来自一个iframe而不是当前window的情况下(因为instanceof原型链上逐个比对)。
因为响应式的length和[[class]]我们都无法在js层面实现,因此我们无法去用任何一个对象来“仿照”一个数组,这也就导致了你要想创造一个fakeArray,你必须在fakeArray里直接用Array构造函数,不能创造一个对象然后让对象继承Array.prototype,而Array构造函数执行时不会对传进去的this做任何处理
,所以,这就无解了,你根本没办法继承他,很多人就只能选择暴力的去修改Array.prototype。
ES6解决了这个问题不管是class的extends,还是setPrototypeOf,但是对于Vue,这不是解决方案。
如果有__proto__
这个非标准属性的话,这个问题也可以得以解决。__proto__
你我都懂的,可能是实现最广的非标准属性了。除了部分安卓机型。
如果有__proto__
,那我可以直接在fakeArray的构造函数里返回一个真正的数组,然后设置这个数组的__proto__
为一个继承自数组原型的新对象:
function fakeArray(){
let a = Array.apply(null,arguments)
a.__proto__ = fakeArray.prototype
a.constructor = fakeArray
return a
}
original = Array.prototype
fakeArray.prototype = Object.create(original);
fakeArray.prototype.constructor = fakeArray
fakeArray.prototype.push = function(){
console.log('苟利国家生死已')
original.push.apply(this,arguments)
}
var words = fakeArray()
words.push('岂','因','祸','福','避','趋','之')
console.log(words.join(""))
上述代码基本就是Vue源码一个小变种,思路是一样的,Vue没有必要真正创建一个子类哈,所以Vue直接修改__proto__
为一个继承自数组的对象即可。当然还有Vue先判断了一下是否能使用__proto__
,不能的话最后采用直接给实例数组上挂异化后的push方法的形式来完成。
当然,这种形式来监听数组意味着Vue只能监听到那8个异化方法的执行,对于修改length和直接通过下标以及Array.prototype.push.apply(this.arr,[1,2,3])这种形式的使用都无法监听(上述情况确实无解,遍历下标执行defineProperty不可取也存在巨大bug)。只能采用this.$set/$delete等方法来让被异化的数组arr的arr.__ob__.dep
属性上存放的dep实例收到数组修改事件,从而让所有订阅到这个数组的watcher都得到通知,当然,他们收到的通知是这个数组修改了,至于是哪个元素修改了并不知道。 所以才会有启发式diff算法的介入。这部分博主你写过相关文章,我就不多说了。
@Ma63d 非常感谢指出错误并给出详尽的解释。
- call 和 apply 的问题,是我当时糊涂了。
- 我确实是:“用错误的代码得出的错误结果来证明继承数组无法实现”,这是不对的。
- 关于“ES5及以下的JS无法完美继承数组”的深层次原因,的确是未曾深究,你的解析(及提供的资料)让我收获很大,谢谢!
@youngwind 你的文章也让我收获很多,你探寻的东西非常广,我应该多向你学习。
@Ma63d 看来有些东西就得深入的研究。这篇文章看了2天,因为刚接触js不久,所以google了好长时间。虽然还有些地方不太明白,es5及以下无法完美继承array应该是明白了。 不过,在测试的过程中,我发现你例子中的几个问题: 1、function FakeArray() { return Father.apply(this,arguments); } Father.apply本身就返回undefined,即使return了对new操作符也不起作用,为什么还要加return,有我还不了解的地方吗? 2、 function fakeArray(){ let a = Array.apply(null,arguments) a.proto = fakeArray.prototype a.constructor = fakeArray return a } 这个地方为什么还有a.constructor=fakeArray这句,起到什么作用? 请不吝赐教,谢谢!
@mygaochunming
1.这里不应该加return。不过在这段代码里加不加对结果不产生影响。不用过度解读。我是因为要证明博主的代码只需要一个小的改动即可实现继承,所以除了fix了我提到的那两个小问题,其他代码一概没动。
2.这里是因为此时a的constructor是指向Array的(这是个继承属性,继承自Array.prototype.constructor),但是a是fakeArray的实例。故把a.constructor指向真正的构造函数。
@Ma63d 非常感谢您的回答,但是关于第二点,我测试了一下,结果:
因为有了:fakeArray.prototype.constructor = fakeArray和a.proto = fakeArray.prototype,是不是就已经完成了将a的constructor指向fakeArray了?
a.constructor = fakeArray,貌似是在a上增加了个constructor,而不是在__proto__上。
是我哪里还没有理解透彻吗?
@mygaochunming 我认为是 @Ma63d 疏忽了。
当访问没有constructor
属性的对象的constructor
属性,既 obj.hasOwnProperty('constructor') === false
时执行obj.constructor
,此时return的是原型上的constructor属性。
既然已经在原型链上做了修正constructor的操作。这个操作对所有instance应该都生效了
因此 a.constructor = fakeArray
是多余的。
另外a.constructor = fakeArray
是属性赋值,它使用对象属性shadowing了原型上的constructor而使得从对象处貌似能够获取到'正确的' constructor。也是偏离了本意的。
因此从fake array的本意上来看,a.constructor = fakeArray
是多余的。
如果以上的内容有不正确的地方。欢迎斧正。
@mygaochunming @tommytroylin 我认真看了一下,确实不用在构造函数里写a.constructor = fakeArray 我当时只看了@mygaochunming 你提供的代码,没看我原来写的。就下意识的觉得这个a是用数组构造出来的,得改改constructor,没看我原来的代码 原来的代码里既然在后面写了: fakeArray.prototype.constructor = fakeArray 那肯定是不用重写a.constructor了。
@Ma63d @youngwind 问一个问题,就是数组也是对象,明明defineProperty可以对对象进行循环遍历来监控每一个属性,为什么就不能对数组进行监控呢。对push等方法确实不适用,但是对取下标的方法改变数组是可以监控的呀。这个里面存在什么问题和bug呢?
@Zhangzirui
let a = []
Object.defineProperty(a, '0', {get: function(){console.log('getter'); return 1;}})
a.pop(); // 报错
@Ma63d 额... a.pop();报错的原因是在Obejct.definePorperty时没有定义configurable为true吧,定义一下就好了。
@Zhangzirui
let a = [{}]
Object.defineProperty(a, '0', {get: function(){console.log('getter'); return 1;}})
a.pop()
a.push(2)
console.log(a) // 之前的getter失效
@Ma63d 对呀,之前的getter失效是正常的,因为数组如果不为空,第一个属性名就是'0',这个时候将它删掉了,那么这个属性的监控绑定就消失了。即使这个数组再添加一个值,虽然这个值的属性名仍然是‘0’,但是也是不起作用的。
对于对象也是这样啊,比如对 b = {'age': 24} 这个对象做监控,然后delete掉age属性,即使之后再次添加一个age属性,那么也是监控不到的呀。这也是为什么Vue针对对象新添加的属性要使用Vue.set方法的原因。
我不觉得这样是取下标的方法改变数组不能监控的原因。
@Zhangzirui 可是数组你想往里面添加任何数据的话用户肯定是arr.push(1,2,3,4)啊, 你难道想让他这样去手动添加元素?
this.$set(arr, '0', 1)
this.$set(arr, '1', 2)
this.$set(arr, '2', 3)
this.$set(arr, '3', 4)
这也就是数组使用defineProperty的问题, 你在init阶段监控一次后, 任何时刻把元素pop/splice出去了, 你的getter就失效了. 你再push的时候你就必须得让Vue手动监控一次。一旦用户又pop/splice,你又得手动监控。 同理执行 任何push\splice\pop行为 都会产生相似效果. 你就永远监控不到用户对数组的操作。
@Ma63d 我知道这个意思,肯定是需要通过包装这些数组方法来监听。我只是疑惑这一点: Vue 不能检测数组利用索引直接设置一个项。我纠结的是为什么不能defineProperty和这些方法一起用呢。或许我有点笨了,我该自己亲自去看看实现过程了。本来是有点害怕看不懂源码,就直接来看现成的博客的,看来还是不能偷懒。
@Zhangzirui 额, 不是, 哥们是我描述的不太清楚. 这样吧, 我再解释一遍.
其实你也说了 数组本质还是个对象, pop 本质是 delete, 你 delete 了当然就监听不到了. 再次添加就需要再次 set 。 首先 Vue 是反对你用 Vue.set/delete的。 但是增删属性的需求确实是有的,并且不少,所以他俩存在的意义是保证这种需求至少可以得到满足。
但是这种需求是低频的。而数组元素增删则是极其高频的
你会去 set/delete data上的10个属性吗? 可能你100行代码里都没有一个this.$delete,但是对于数组而言, 你增/删10个、100个、1000个元素都是再平常不过的需求。几乎所有数组使用都伴随着高频的数组元素删除。
同时,数组元素增删的方法是多样化的。
举个例子, 回忆一下数组的那个"响应式" length 属性(在数组10000下标里填写元素, 数组 length 自动变成10001; 给数组length属性赋值为0, 会自动清空数组所有元素). 这里单单举了length的例子, 但是光是使用length删除属性这种方法你就没法监听:如果你强行用defineProperty去强行改造length,不用多想,必然造成其响应式特性的丢失。
所以,现在的问题就出现了,你如果强行 hack , 去改造那些增、删的手段, 让用户每次增删元素, 你都能监听到, 并且在监听到之后使用 Vue.set / delete,那么因为增、删的高频性。会使得 Vue.set/ delete同样高频,但是 Vue.set / delete 会带来明显性能问题的呀。对于 data 的直接属性的 set 和 delete 会使得所有 watcher 重新计算 value,退订、重订依赖( _digest()方法 )。 每次 set / delete 都是 O(number of watcher) 级别。对于 n个数组元素的增删就是 O(n * number of watcher)。
同时,因为数组增删元素的多样性会带来代码实现上的极大复杂度,最关键的是即使代码量增加了,你也无法真正做到任何时候都能监听到他的增删操作,是的,没有方法。
所以,对每一个数组元素defineProperty带来代码本身的复杂度增加和代码执行效率的降低, 为什么不采用简单的改写数组8大方法来实现呢?
@Ma63d 谢谢你耐心讲解,我知道了,谢啦!
@Ma63d 那为什么把Father换成Array就不行了呢? 因为Array构造函数执行时不会对传进去的this做任何处理。
关于你写的上面这段,实际换成Array是可以的,博主方式其实没问题(除了语法错误),他是直接new FakeArray(),实际添加了一个叫__proto__的属性,而且这个__proto__指向FakeArray()的prototype对象,其实和你后面直接函数调用在FakeArray里实现的一个意思。
@lulutia 换成Array是不行的。 "因为Array构造函数执行时不会对传进去的this做任何处理。"请再理解这句话。
@Ma63d ok,没注意是 构造函数 初始化的 问题,那Array是不行的,就算方法可以重写,但是初始化没法搞,值也是不对的
大致了解原理实现, 粘帖一段Vue源码片段,可以直接Chrome
执行
function def (obj, key, val, enumerable) {
Object.defineProperty(obj, key, {
value: val,
enumerable: !!enumerable,
writable: true,
configurable: true
})
}
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
/**
* Intercept mutating methods and emit events
*/
methodsToPatch.forEach(function (method) {
// cache original method
const original = arrayProto[method]
def(arrayMethods, method, function mutator (...args) {
const result = original.apply(this, args)
const ob = this.__ob__
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
if (inserted) ob.observeArray(inserted)
// notify change
ob.dep.notify()
return result
})
})
// 正常方式
let normal = [];
normal.push(2);
console.log(normal);
// 方法一
let a = [];
a.__proto__ = arrayMethods;
a.push(2);
console.log(a);
// 方法二
let b = [];
const arrayKeys = Object.getOwnPropertyNames(arrayMethods)
for (let i = 0, l = arrayKeys.length; i < l; i++) {
const key = arrayKeys[i]
def(b, key, arrayMethods[key])
}
b.push(2)
console.log(b);
想问一下,这个是能监听到调用数组的'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'等几个方法,但是当访问和改变数组中的数据就不能监听到,这个与上一篇遗留的问题有点不符。这个功能是不需要吗?但是如果数组中又有对象呢?
class MyArray extends Array{
push(...args){
console.log(args[0])
}
}
let myArr = new MyArray(...list)
myArr.push(4)
myArr.reverse()
console.log(myArr)
直接继承Array,然后想修改什么就在MyArray里面覆盖掉Array的方法,不知道这样行不行