Skip to content

vue早期源码学习系列之二:如何监听一个数组的变化 #85

Open
@youngwind

Description

@youngwind
Owner

前言

继上一篇 #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的版本更上次一样,都是这个位置
相关的源码是这两个地方。

  1. observe/array-augmentations.js
  2. 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);

结果如下图所示
2016-08-26 7 28 43

虽然我成功地重新定义push方法,但是为什么fakeList是一个空对象呢?
原因是:构造函数默认返回的本来就是this对象,这是一个对象,而非数组。Array.call(this,arguments);这个语句返回的才是数组。

那么我们能不能将Array.call(this,arguments);直接return出来呢?
不能。原因有两个:

  1. 如果我们return这个返回的数组,这个数组是由原生的Array构造出来的,所以它的push等方法依然是原生数组的方法,无法到达重写的目的。
  2. 如果我们return这个返回的数组,其实最后fakeList === [[['a','b','c']]],它变成了一个数组的数组的数组,因为list本身是一个数组,arguments用封装了一层数组,new Array本身接收数组作为参数的时候本来就会返回包裹这个数组的数组,new Array(['a', 'b']) === [['a', 'b']],所以就变成三层数组了。

shit.....太麻烦了!看来还是没有办法通过组合继承的模式来实现一开始的目标。(写到这儿,我心里默念:还是老司机厉害啊!我还是太年轻了......)

后话

目前为止,我们已经知道如何监听对象和数组的变化了,下一步应该做什么呢?
答案是:实现一个watch库
什么是watch库?你看一下这个就知道了。

Activity

renaesop

renaesop commented on Sep 8, 2016

@renaesop

ES2015的class extends语法是可以完美继承Array的,没有你提到的问题

另外, let fakeList = new FakeArray(list);写法有误, 应该写成
et fakeList = new FakeArray(...list);

henryzp

henryzp commented on Oct 5, 2016

@henryzp

@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)
renaesop

renaesop commented on Oct 7, 2016

@renaesop

@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)
henryzp

henryzp commented on Oct 7, 2016

@henryzp

@renaesop ,受教。。

thx

liekkas9

liekkas9 commented on Oct 23, 2016

@liekkas9

作者那个proto应该是原型式继承,《高程》中紧随的那一节,实际上就是Object.create

youngwind

youngwind commented on Oct 24, 2016

@youngwind
OwnerAuthor

@lingxufeng2014 刚刚翻了一下书,发现确实如此。以前看书看到最常用的组合继承之后,就忽略了其他少用到继承方式了。多谢指点!

Ma63d

Ma63d commented on Mar 8, 2017

@Ma63d

@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上有大量的程序员朋友用各种奇技淫巧来实现继承数组实现队列、栈等等子类,但都不是完美的)
为什么无法完美继承?

  1. 因为数组有个响应式的length:他会自动根据你填入元素的下标进行增长,同时你把他改小的话,他一次删除把中间的元素给删除。
    a = [1];a[10]=1;a.length===11以及a = [1,2,3,4];a.length=1//此时元素2,3,4被删除了

  2. 数组内部的[[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算法的介入。这部分博主你写过相关文章,我就不多说了。

youngwind

youngwind commented on Mar 8, 2017

@youngwind
OwnerAuthor

@Ma63d 非常感谢指出错误并给出详尽的解释。

  1. call 和 apply 的问题,是我当时糊涂了。
  2. 我确实是:“用错误的代码得出的错误结果来证明继承数组无法实现”,这是不对的。
  3. 关于“ES5及以下的JS无法完美继承数组”的深层次原因,的确是未曾深究,你的解析(及提供的资料)让我收获很大,谢谢!
Ma63d

Ma63d commented on Mar 8, 2017

@Ma63d

@youngwind 你的文章也让我收获很多,你探寻的东西非常广,我应该多向你学习。

mygaochunming

mygaochunming commented on Mar 21, 2017

@mygaochunming

@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

Ma63d commented on Mar 21, 2017

@Ma63d

@mygaochunming
1.这里不应该加return。不过在这段代码里加不加对结果不产生影响。不用过度解读。我是因为要证明博主的代码只需要一个小的改动即可实现继承,所以除了fix了我提到的那两个小问题,其他代码一概没动。
2.这里是因为此时a的constructor是指向Array的(这是个继承属性,继承自Array.prototype.constructor),但是a是fakeArray的实例。故把a.constructor指向真正的构造函数。

mygaochunming

mygaochunming commented on Mar 21, 2017

@mygaochunming

@Ma63d 非常感谢您的回答,但是关于第二点,我测试了一下,结果:
image
因为有了:fakeArray.prototype.constructor = fakeArray和a.proto = fakeArray.prototype,是不是就已经完成了将a的constructor指向fakeArray了?
a.constructor = fakeArray,貌似是在a上增加了个constructor,而不是在__proto__上。
是我哪里还没有理解透彻吗?

20 remaining items

Loading
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

      Development

      No branches or pull requests

        Participants

        @tommytroylin@henryzp@renaesop@Ma63d@youngwind

        Issue actions

          vue早期源码学习系列之二:如何监听一个数组的变化 · Issue #85 · youngwind/blog