前言
首先欢迎大家关注我的Github博客,也算是对我的一点鼓励,毕竟写东西没法获得变现,能坚持下去也是靠的是自己的热情和大家的鼓励。
从上一篇文章响应式数据与数据依赖基本原理开始,我就萌发了想要研究Vue源码的想法。最近看了youngwind的一篇文章如何监听一个数组的变化发现Vue早期实现监听数组的方式和我的实现稍有区别。并且在两年前作者对其中的一些代码的理解有误,在阅读完评论中@Ma63d的评论之后,感觉收益匪浅。
Vue实现数据监听的方式
在我们的上一篇文章中,我们想尝试监听数组变化,采用的是下面的思路:
function observifyArray(array){
//需要变异的函数名列表
var methods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
var arrayProto = Object.create(Array.prototype);
_.each(methods, function(method){
arrayProto[method] = function(...args){
// 劫持修改数据
var ret = Array.prototype[method].apply(this, args);
//可以在修改数据时触发其他的操作
console.log("newValue: ", this);
return ret;
}
});
Object.setPrototypeOf(array, arrayProto);
}
prototype
来实现,新的
prototype
重写了原生数组原型的部分方法。因此在调用上面的几个变异方法的时候我们会得到相应的通知。但其实
setPrototypeOf
方法是ECMAScript 6的方法,肯定不是Vue内部可选的实现方案。我们可以大致看看Vue的实现
思路。
function observifyArray(array){
var aryMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
var arrayAugmentations = Object.create(Array.prototype);
aryMethods.forEach((method)=> {
// 这里是原生Array的原型方法
let original = Array.prototype[method];
// 将push, pop等封装好的方法定义在对象arrayAugmentations的属性上
// 注意:是属性而非原型属性
arrayAugmentations[method] = function () {
console.log('我被改变啦!');
// 调用对应的原生方法并返回结果
return original.apply(this, arguments);
};
});
array.__proto__ = arrayAugmentations;
}
__proto__
是我们大家的非常熟悉的一个属性,其指向的是实例对象对应的原型对象。在ES5中,各个实例中存在一个内部属性[[Prototype]]
指向实例对象对应的原型对象,但是内部属性是没法访问的。浏览器各家厂商都支持非标准属性__proto__
。其实Vue的实现思路与我们的非常相似。唯一不同的是Vue使用了的非标准属性__proto__
。
其实阅读过《JavaScript高级程序设计》的同学应该还记得原型式继承。其重要思路就是借助原型可以基于已有的对象创建对象。比如说:
function object(o){
function F(){}
F.prototype = o;
return new F();
}
其实我们上面Vue的思路也是这样的,我们借助原型创建的基于arrayAugmentations的新实例,使得实例能够访问到我们自定义的变异方法。
上面一篇文章的作者youngwind写文章的时候就提出了,为什么不去采用更为常见的组合式继承去实现,比如:
function FakeArray() {
Array.apply(this,arguments);
}
FakeArray.prototype = [];
FakeArray.prototype.constructor = FakeArray;
FakeArray.prototype.push = function () {
console.log('我被改变啦');
return Array.prototype.push.apply(this,arguments);
};
let list = ['a','b','c'];
let fakeList = new FakeArray(list);
结果发现fakeList
并不是一个数组而是一个对象,作者当时这这样认为的:
构造函数默认返回的本来就是this对象,这是一个对象,而非数组。Array.apply(this,arguments);这个语句返回的才是数组
我们能不能将Array.apply(this,arguments);直接return出来呢?
如果我们return这个返回的数组,这个数组是由原生的Array构造出来的,所以它的push等方法依然是原生数组的方法,无法到达重写的目的。
首先我们知道采用new
操作符调用构造函数会依次经历以下四个步骤:
- 创建新对象
- 将构造函数的作用域给对象(因此构造函数中的this指向这个新对象)
- 执行构造函数的代码
- 返回新对象(如果没有显式返回的情况下)
在没有显式返回的时候,返回的是新对象,因此fakeList
是对象而不是数组。但是为什么不能强制返回Array.apply(this,arguments)
。其实下面有人说作者这句话有问题
这个数组是由原生的Array构造出来的,所以它的push等方法依然是原生数组的方法,无法到达重写的目的。
其实上面这句话本身确实没有错误,当我们给构造函数显式返回的时候,我们得到的fakeList
就是原生的数组。因此调用push
方法是没法观测到的。但是我们不能返回的Array.apply(this,arguments)
更深层的原因在于我们这边调用Array.apply(this,arguments)
的目的是为了借用原生的Array
的构造函数将Array
属性赋值到当前对象上。
举一个例子:
function Father(){
this.name = "Father";
}
Father.prototype.sayName = function(){
console.log("name: ", this.name);
}
function Son(){
Father.apply(this);
this.age = 100;
}
Son.prototype = new Father();
Son.prototype.constructor = Son;
Son.prototype.sayAge = function(){
console.log("age: ", this.age);
}
var instance = new Son();
instance.sayName(); //name: Father
instance.sayAge(); //age: 100
子类Son
为了继承父类Father
的属性和方法两次调用Father
的构造函数,Father.apply(this)
就是为了创建父类的属性,而Son.prototype = new Father();
目的就是为了通过原型链继承父类的方法。因此上面所说的才是为什么不能将Array.apply(this,arguments)
强制返回的原因,它的目的就是借用原生的Array
构造函数创建对应的属性。
但是问题来了,为什么无法借用原生的Array
构造函数创建对象呢?实际上不仅仅是Array
,String
、Number
、Regexp
、Object
等等JavaScript的内置类都不能通过借用构造函数的方式创建带有功能的属性(例如: length
)。JavaScript数组中有一个特殊的响应式属性length
,一方面如果数组数值类型下标的数据发生变化的时候会在length
上体现,另一方面,修改length
也会影响到数组的数值数据。因为无法通过借用构造函数的方式创建响应式length
属性(虽然属性可以被创建,但不具备响应式功能),因此在E55我们是没法继承数组的。比如:
function MyArray(){
Array.apply(this, arguments);
}
MyArray.prototype = Object.create(Array.prototype, {
constructor: {
value: MyArray,
writable: true,
configurable: true,
enumerable: true
}
});
var colors = new MyArray();
colors[0] = "red";
console.log(colors.length); // 0
colors.length = 0;
console.log(colors[0]); //"red"
好在我们迎来ES6的曙光,通过类class的extends,我们就可以实现继承原生的数组,例如:
class MyArray extends Array {
}
var colors = new MyArray();
colors[0] = "red";
console.log(colors.length); // 0
colors.length = 0;
cosole.log(colors[0]); // undefined
为什么ES6的extends可以做到ES5所不能实现的数组继承呢?这是由于二者的继承原理不同导致的。ES5的继承方式中,先是生成派生类型的this
(例如:MyArray),然后调用基类的构造函数(例如:Array.apply(this)),这也就是说this
首先指向的是派生类的实例,然后指向的是基类的实例。由于原生对象(例如: Array)通过借用的方式并不能给this
赋值length
类似的具有功能的属性,因此我们没法实现想要的结果。
但是ES6的extends
的继承方式却是与之相反的,首先是由基类(Array)创建this
的值,然后再由派生类的构造函数修改这个值,因此在上面的例子中,一开始就可以通过this
创建基类的所有內建功能并接受与之相关的功能(如length
),然后在此this
的基础上用派生类进行扩展,因此就可以达到我们的继承原生数组的目的。
不仅仅如此。ES6在扩展类似上面的原生对象时还提供了一个非常方便的属性: Symbol.species
。
Symbol.species
Symbol.species
的主要作用就是可以使得原本返回基类实例的继承方法返回派生类的实例,举个例子吧,比如Array.prototype.slice
返回的就是数组的实例,但是当MyArray
继承Array
时,我们也希望当使用MyArray
的实例调用slice
时也能返回MyArray
的实例。那我们该如何使用呢,其实Symbol.species
是一个静态访问器属性,只要在定义派生类时定义,就可以实现我们的目的。比如:
class MyArray extends Array {
static get [Symbol.species](){
return this;
}
}
var myArray = new MyArray(); // MyArray[]
myArray.slice(); // MyArray []
myArray
的
slice
方法时也会返回的是
MyArray
类型的实例。如果你喜欢尝试的话,你会发现即使去掉了静态访问器属性
get [Symbol.species]
,
myArray.slice()
也会仍然返回
MyArray
的实例,这是因为即使你不显式定义,默认的
Symbol.species
属性也会返回
this
。当然你也将
this
改变为其他值来改变对应方法的返回的实例类型。例如我希望实例
myArray
的
slice
方法返回的是原生数组类型
Array
,就可以采用如下的定义:
class MyArray extends Array {
static get [Symbol.species](){
return Array;
}
}
var myArray = new MyArray(); // []
myArray.slice(); // []
当然了,如果在上面的例子中,如果你希望在自定义的函数中返回的实例类型与
Symbol.species
的类型保持一致的话,可以如下定义:
class MyArray extends Array {
static get [Symbol.species](){
return Array;
}
constructor(value){
super();
this.value = value;
}
clone(){
return new this.constructor[Symbol.species](this.value)
}
}
var myArray = new MyArray();
myArray.clone(); //[]
通过上面的代码我们可以了解到,在实例方法中通过调用this.constructor[Symbol.species]
我们就可以获取到Symbol.species
继而可以创造对应类型的实例。
上面整个的文章都是基于监听数组响应的一个点想到的。这里仅仅是起到抛砖引玉的作用,希望能对大家有所帮助。如有不正确的地方,欢迎大家指出,愿共同学习。
原文作者:请叫我王磊同学
本文来源: 掘金 如需转载请联系原作者