-
Notifications
You must be signed in to change notification settings - Fork 384
vue早期源码学习系列之二:如何监听一个数组的变化 #85
New issue
Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? # to your account
Comments
ES2015的class extends语法是可以完美继承Array的,没有你提到的问题 另外, |
并没有吧?
|
@henryzp 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 刚刚翻了一下书,发现确实如此。以前看书看到最常用的组合继承之后,就忽略了其他少用到继承方式了。多谢指点! |
@youngwind 我们先不说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);
先说博主你说的第二个原因,这里的错误是博主搞混了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');//不能用数组来作为参数,那样的话数组就被包在数组里了。 在上述两处代码中博主都犯了这个错误。 然后再回到博主说的两个原因中的第一点:
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就不行了呢? 这也是那个著名的问题的来源:ES5及以下的JS无法完美继承数组。(博主可以随意google,文章非常多,git上有大量的程序员朋友用各种奇技淫巧来实现继承数组实现队列、栈等等子类,但都不是完美的)
因为响应式的length和[[class]]我们都无法在js层面实现,因此我们无法去用任何一个对象来“仿照”一个数组,这也就导致了你要想创造一个fakeArray,你必须在fakeArray里直接用Array构造函数,不能创造一个对象然后让对象继承Array.prototype,而 ES6解决了这个问题不管是class的extends,还是setPrototypeOf,但是对于Vue,这不是解决方案。 如果有 如果有 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直接修改 当然,这种形式来监听数组意味着Vue只能监听到那8个异化方法的执行,对于修改length和直接通过下标以及Array.prototype.push.apply(this.arr,[1,2,3])这种形式的使用都无法监听(上述情况确实无解,遍历下标执行defineProperty不可取也存在巨大bug)。只能采用this.$set/$delete等方法来让被异化的数组arr的 |
@Ma63d 非常感谢指出错误并给出详尽的解释。
|
@youngwind 你的文章也让我收获很多,你探寻的东西非常广,我应该多向你学习。 |
@Ma63d 看来有些东西就得深入的研究。这篇文章看了2天,因为刚接触js不久,所以google了好长时间。虽然还有些地方不太明白,es5及以下无法完美继承array应该是明白了。 |
@mygaochunming |
@Ma63d 非常感谢您的回答,但是关于第二点,我测试了一下,结果: |
@mygaochunming 当访问没有 既然已经在原型链上做了修正constructor的操作。这个操作对所有instance应该都生效了 另外 因此从fake array的本意上来看, 如果以上的内容有不正确的地方。欢迎斧正。 |
@mygaochunming @tommytroylin |
@Ma63d @youngwind 问一个问题,就是数组也是对象,明明defineProperty可以对对象进行循环遍历来监控每一个属性,为什么就不能对数组进行监控呢。对push等方法确实不适用,但是对取下标的方法改变数组是可以监控的呀。这个里面存在什么问题和bug呢? |
let a = []
Object.defineProperty(a, '0', {get: function(){console.log('getter'); return 1;}})
a.pop(); // 报错 |
@Ma63d |
let a = [{}]
Object.defineProperty(a, '0', {get: function(){console.log('getter'); return 1;}})
a.pop()
a.push(2)
console.log(a) // 之前的getter失效 |
@Ma63d 对于对象也是这样啊,比如对 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,你又得手动监控。 |
@Ma63d 我知道这个意思,肯定是需要通过包装这些数组方法来监听。我只是疑惑这一点: Vue 不能检测数组利用索引直接设置一个项。我纠结的是为什么不能defineProperty和这些方法一起用呢。或许我有点笨了,我该自己亲自去看看实现过程了。本来是有点害怕看不懂源码,就直接来看现成的博客的,看来还是不能偷懒。 |
@Zhangzirui 额, 不是, 哥们是我描述的不太清楚. 这样吧, 我再解释一遍. 其实你也说了 数组本质还是个对象, pop 本质是 delete, 你 delete 了当然就监听不到了. 再次添加就需要再次 set 。 但是这种需求是低频的。而数组元素增删则是极其高频的 你会去 set/delete data上的10个属性吗? 可能你100行代码里都没有一个this.$delete,但是对于数组而言, 你增/删10个、100个、1000个元素都是再平常不过的需求。几乎所有数组使用都伴随着高频的数组元素删除。 同时,数组元素增删的方法是多样化的。 举个例子, 回忆一下数组的那个"响应式" length 属性(在数组10000下标里填写元素, 数组 length 自动变成10001; 给数组length属性赋值为0, 会自动清空数组所有元素). 所以,现在的问题就出现了,你如果强行 hack , 去改造那些增、删的手段, 让用户每次增删元素, 你都能监听到, 并且在监听到之后使用 Vue.set / delete,那么因为增、删的高频性。会使得 Vue.set/ delete同样高频,但是 Vue.set / delete 会带来明显性能问题的呀。对于 data 的直接属性的 set 和 delete 会使得所有 同时,因为数组增删元素的多样性会带来代码实现上的极大复杂度,最关键的是即使代码量增加了,你也无法真正做到任何时候都能监听到他的增删操作,是的,没有方法。 所以,对每一个数组元素defineProperty带来代码本身的复杂度增加和代码执行效率的降低, 为什么不采用简单的改写数组8大方法来实现呢? |
@Ma63d 谢谢你耐心讲解,我知道了,谢啦! |
@Ma63d 关于你写的上面这段,实际换成Array是可以的,博主方式其实没问题(除了语法错误),他是直接new FakeArray(),实际添加了一个叫__proto__的属性,而且这个__proto__指向FakeArray()的prototype对象,其实和你后面直接函数调用在FakeArray里实现的一个意思。 |
@lulutia 换成Array是不行的。 |
@Ma63d ok,没注意是 构造函数 初始化的 问题,那Array是不行的,就算方法可以重写,但是初始化没法搞,值也是不对的 |
大致了解原理实现, 粘帖一段Vue源码片段,可以直接 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'等几个方法,但是当访问和改变数组中的数据就不能监听到,这个与上一篇遗留的问题有点不符。这个功能是不需要吗?但是如果数组中又有对象呢? |
直接继承Array,然后想修改什么就在MyArray里面覆盖掉Array的方法,不知道这样行不行 |
前言
继上一篇 #84 ,文末我们提到另一个问题如何监听数组的变化?,今天我们就来解决这个问题。我们先来看一眼vue官方说明文档?
出处:https://cn.vuejs.org/v2/guide/list.html#变异方法
出处:https://cn.vuejs.org/v2/guide/list.html#注意事项
为什么说明文档中提到只有某些特定方法才能触发视图更新呢?我们可以从vue的源码中找到答案。
奇技淫巧
这次checkout的版本更上次一样,都是这个位置。
相关的源码是这两个地方。
整体思路是什么呢? → 通过重新包装数据中数组的push、pop等常用方法。注意,这里重新包装的只是数据数组(也就是我们要监听的数组,也就是vue实例中拥有的data数据)的方法,而不是改变了js原生Array中的原型方法。
为什么不能修改原生Array的原型方法呢?这道理很显然,因为我们是在写一个框架,而非一个应用,我们不应该过多地影响全局。如果你真得采取了这种糟糕的方法,想象以下场景:”你在一个应用中使用了vue,但是你在vue实例以外定义了一些数组,你改变这些与vue无关的数组的时候,居然触发了vue的方法!!“这能忍??
代码实现
PS:如果不能理解这里的proto,请翻看《Javascript的高级程序设计》第148页,以及参看这个答案,多看几遍你就懂了。(吐槽:每次碰到js原型都不好描述.....)
======================= 分割线 ==========================
2017.3.8 更新:在下面这这一章节《作者写得有问题?》中,关于“为何这么写”的解析有误。
在此保留原文,正确的解析请参考 @Ma63d 的评论。#85 (comment)
======================= 分割线 ===========================
作者写得有问题?
ok,目前为止我们已经实现了如何监听数组的变化了。
但是,我们仔细回想一下,难道只能通过作者那样的方法来实现吗?不觉得直接重新定义proto指针有点奇怪吗?有其他实现的方法吗?
我们回到最开始的目标:
对于某些特定的数组(数据数组),他们的push等方法与原生Array的push方法不一样,但是其他的又都一样。
这不就是经典的继承问题吗? 子类和父类很像,但是呢,子类有点地方又跟父类不同
我们只需要继承父类,然后重写子类的prototype中的push方法不就可以了吗?红宝书告诉我们组合继承才是最常用的继承方法啊!(请参考红宝书第168页)难道是作者糊涂了?(想到这儿,我心里一阵窃喜,拜读了作者的代码这么久,终于让我发现一个bug了,不过好像也算不上是bug)
废话不多说,我赶紧自己用组合继承实现了一下。
结果如下图所示

虽然我成功地重新定义push方法,但是为什么fakeList是一个空对象呢?
原因是:构造函数默认返回的本来就是this对象,这是一个对象,而非数组。Array.call(this,arguments);这个语句返回的才是数组。
那么我们能不能将Array.call(this,arguments);直接return出来呢?
不能。原因有两个:
shit.....太麻烦了!看来还是没有办法通过组合继承的模式来实现一开始的目标。(写到这儿,我心里默念:还是老司机厉害啊!我还是太年轻了......)
后话
目前为止,我们已经知道如何监听对象和数组的变化了,下一步应该做什么呢?
答案是:实现一个watch库
什么是watch库?你看一下这个就知道了。
The text was updated successfully, but these errors were encountered: