Description
前言
继上一篇 #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 的评论。#85 (comment)
======================= 分割线 ===========================
作者写得有问题?
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库?你看一下这个就知道了。
Activity
renaesop commentedon Sep 8, 2016
ES2015的class extends语法是可以完美继承Array的,没有你提到的问题
另外,
let fakeList = new FakeArray(list);
写法有误, 应该写成et fakeList = new FakeArray(...list);
henryzp commentedon Oct 5, 2016
@renaesop
并没有吧?
renaesop commentedon Oct 7, 2016
@henryzp
Array.call(this,arguments)
不等价于extends的语义另外,push里面应该用apply吧
henryzp commentedon Oct 7, 2016
@renaesop ,受教。。
thx
liekkas9 commentedon Oct 23, 2016
作者那个proto应该是原型式继承,《高程》中紧随的那一节,实际上就是Object.create
youngwind commentedon Oct 24, 2016
@lingxufeng2014 刚刚翻了一下书,发现确实如此。以前看书看到最常用的组合继承之后,就忽略了其他少用到继承方式了。多谢指点!
StevenYuysy commentedon Mar 1, 2017
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#注意事项
Ma63d commentedon Mar 8, 2017
@youngwind
半年过去了,博主这个博客的star也由我最开始看这篇文章的100多变成了现在的700多,但是到现在博主依然没有改掉文中的很明显的错误,额。。。。。为了不误导后续的读者,我说一下把。我们先不说Vue不采用继承数组来实现数组监听的问题。
先说说博主给的代码和文章原文:
先说博主你说的第二个原因,这里的错误是博主搞混了call和apply。博主在:
以及:
在上述两处代码中博主都犯了这个错误。
然后再回到博主说的两个原因中的第一点:
Nonono,博主你写的代码是对的,常说的组合寄生式继承就是你写的这段代码,除了刚刚说的那个apply和call的小错误,其他的一点没错。因此要想达到重写的目的的话,就用你的写法是完完全全可以的,错误的不是你的写法,而是数组这东西很特殊。那好,我们假设,我们继承的不是数组,而是一个其他正常一点的东西,比如是一个我自己写的类Father:
这段简单的继承代码真心不用我说。
那为什么把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__
为一个继承自数组原型的新对象:上述代码基本就是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算法的介入。这部分博主你写过相关文章,我就不多说了。youngwind commentedon Mar 8, 2017
@Ma63d 非常感谢指出错误并给出详尽的解释。
Ma63d commentedon Mar 8, 2017
@youngwind 你的文章也让我收获很多,你探寻的东西非常广,我应该多向你学习。
mygaochunming commentedon Mar 21, 2017
@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这句,起到什么作用?
请不吝赐教,谢谢!
Ma63d commentedon Mar 21, 2017
@mygaochunming
1.这里不应该加return。不过在这段代码里加不加对结果不产生影响。不用过度解读。我是因为要证明博主的代码只需要一个小的改动即可实现继承,所以除了fix了我提到的那两个小问题,其他代码一概没动。
2.这里是因为此时a的constructor是指向Array的(这是个继承属性,继承自Array.prototype.constructor),但是a是fakeArray的实例。故把a.constructor指向真正的构造函数。
mygaochunming commentedon Mar 21, 2017
@Ma63d 非常感谢您的回答,但是关于第二点,我测试了一下,结果:

因为有了:fakeArray.prototype.constructor = fakeArray和a.proto = fakeArray.prototype,是不是就已经完成了将a的constructor指向fakeArray了?
a.constructor = fakeArray,貌似是在a上增加了个constructor,而不是在__proto__上。
是我哪里还没有理解透彻吗?
20 remaining items