什么是原型链

一个新的对象例如:

1
let a = {}

通过打印可以发现,对象在生成后自带了一个__proto__属性。

例如当我们调用obj.toString时,JS引擎会一次做以下步骤:

  1. 看看obj本身是否有toString属性。
  2. 看看obj.__proto__是否有toString属性,如果有则返回此内容。
  3. 查看obj.__proto__.__proto__ …直到找到toString属性或者proto\为null

这样一个链式的搜索过程,即为原型链。

this的值到底是什么

一个简单的面试题:

1
2
3
4
5
6
7
8
9
var obj = {
foo: function(){
console.log(this)
}
}
var bar = obj.foo
obj.foo() // 打印出的 this 是 obj
bar() // 打印出的 this 是 window

从函数调用的角度来看,JS中有三种函数调用形式:

1
2
3
func(p1, p2)
obj.child.method(p1, p2)
func.call(context, p1, p2) // 先不讲 apply

不过前两类其实都可以归为第三类:

1
2
func(p1,p2) => func.call(undefined, p1, p2);
obj.child.method(p1, p2) => obj.child.method.call(obj.child, p1, p2)

这样,this就好定义了,就是第三类调用方法中的context

回到题目:

1
2
3
4
5
6
7
8
9
10
11
12
13
var obj = {
foo: function(){
console.log(this)
}
}
var bar = obj.foo
obj.foo() // 转换为 obj.foo.call(obj),this 就是 obj
bar()
// 转换为 bar.call()
// 由于没有传 context
// 所以 this 就是 undefined
// 最后浏览器给你一个默认的 this —— window 对象

小插曲:

1
2
3
4
arr[0]()
假想为 arr.0() // 虽然是错误的语法,但其实js数组的内部也差不多是这样排列的
然后转换为 arr.0.call(arr)
那么里面的 this 就是 arr 了

JS的new到底是干什么的

先来看看不用new来制造一个对象:

1
2
3
4
5
6
7
8
var obj = {
id: 1,
prop1: 'a',
prop2: 'b',
prop3: 'c',
prop4: function() {},
prop5: function() {},
}

如果我们要制造一百个这样的对象,循环一百次即可。

但是这样的方式存在内存大量浪费的问题,比如prop4和prop5,在一百个对象中其实都是同样的函数。

通过前面的原型链可以知道,我们可以通过原型链来解决重复创建的问题:我们可以先创建一个对象原型,然后让obj的proto指向对象原型。

1
2
3
4
5
var objProto = {
prop4: function() {},
prop5: function() {}
}
obj.prototype = objProto;

把创建一个对象的代码写在两个地方,可能不太优雅,于是我们可以封装一个方法:

1
2
3
4
5
6
7
8
function getNewObj() {
let obj = {}; // 创建一个临时对象
obj.prototype = objProto; // 绑定原型
obj.prop1 = 'a'; // 设置一些特有的属性
obj.prop2 = 'b';
obj.prop3 = 'c';
return obj; // 返回这个对象
}

所以js内置了new这个关键字,它做了以下几件事:

  1. 不用创建对象,因为new会帮你做。(使用this就可以访问到临时对象)
  2. 不用绑定原型,new为了知道原型在哪,所以固定了原型的名字为prototype。
  3. 不用return对象,new会自动帮你做。

综上所述,new是一个js中的语法糖,所做的就是上面的几件事,下面用new来完成刚刚的操作:

1
2
3
4
5
6
7
8
9
10
11
funtion Obj (id) {
this.id = id;
}
Obj.prototype = {
obj.prop1 = 'a';
obj.prop2 = 'b';
obj.prop3 = 'c';
prop4: function() {},
prop5: function() {}
}
let newObj = new Obj(1)

这里要注意一下Obj从一个对象变成了一个函数,这里其实是因为new关键字需要记录这个新对象是由哪个函数来创建,所以prototype里面其实有一个隐藏的属性constructor,它被默认的指定为了Obj;

1
Obj.prototype.constructor = Obj;

再谈继承

JS的继承,本质上就是原型链的传递。

1、借助构造函数实现继承
1
2
3
4
5
6
7
8
9
function Parent() {
this.name = 'parent';
}
Parent.prototype.say = function() {console.log(this.name)};
function Child() {
Parent.call(this);
this.type = 'child';
}

构造函数实现继承的原理是在子类的构造函数中调用父类的构造函数,这样父类的自有属性就可以继承给子类,但是缺点是子类没有父类prototype中的属性,比如上例中实例出来的Child子类就没有say方法。

2、借助原型链实现继承
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Parent() {
this.name = 'parent'
this.arr = [1,2,3]
}
function Child() {
this.type = 'child'
}
Child.prototype = new Parent(); // 这里能继承到Parent原型上的属性,其实是通过prototype.prototype,找到Parent.prototype
var child1 = new Child();
var child2 = new Child();
child1.name = 1;
console.log(child2.name) // 'parent'
child1.arr.push(4);
console.log(child2.arr) // [1,2,3,4]

这里有趣的一点是,child1给name赋值,没有影响child2.name原因是,当给一个对象本身的属性赋值的时候,是会覆盖掉原型上的属性,而不是修改原型上的属性。

而给child1.arr push一个值,却影响了child2.arr,原因是子类的原型都指向同一个对象(一个地址),而js是地址引用,所以child1.arr和child2.arr在没有被子类覆盖之前,其实是同一个地址,所以一个子类修改后其他子类也受影响。

3、组合方式继承
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Parent() {
this.name = 'parent'
this.arr = [1,2,3]
}
Parent.prototype.say = function() { console.log(this.name) }
function Child() {
Parent.call(this); // (1)
this.type = 'child'
}
Child.prototype = new Parent(); // (2)
let child1 = new Child();
let child2 = new Child();
child1.arr.push(4);
console.log(child1.say) // function
console.log(child2.arr) // [1,2,3]

用这种组合的方式,能够解决1、2种方法的缺点。(1)行代码在每次执行Child的构造函数的时候,都执行了一遍父类的构造函数,因此每一个子类都会复制一遍父类的私有属性(arr也有了新的地址),所以修改时相互之间不受影响。(2)行代码可以让child通过两次原型链上的查找获得say方法。

但是这个方法的缺点在于性能不高,因为每建立一个子类,Parent 都new了两次,一次是在Child构造函数种调用了Parent.call(this);一次是将子类原型指向 new Parent() 的时候。

4、组合继承的优化1
1
Child.prototype = new Parent(); = > Child.prototype = Parent.prototype;

将这一句修改后,能达到上述同样的效果,但随之而来的问题是:

1
2
3
child1 instanceOf Child // true
child1 instanceOf Parent // true
child1.constructor // Parent

我们无法区分一个对象到底是由谁实例化的,出现这种情况是因为我们说过,原型中会默认一个constructor的属性指向构造函数,因为我们将Child的原型指向了Parent的原型,所以出现了上述情况。

5、组合继承的优化2
1
2
3
4
5
6
Child.prototype = Parent.prototype = > Child.prototype = Object.create(Parent.prototype);
// Object.create(proto, [propertiesObject])
// 方法创建一个新对象,使用现有的对象来提供新创建的对象的proto。
// Object.create(null) 可以创造一个没有原型的干净对象。
// new Object(); 是有原型的

经过上述改良后,其实并没有实质性的改变,所以我们最终还是需要手动的给原型指定constructor

1
Child.prototype.constructor = Child

最终代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
function Parent() {
this.name = 'parent'
this.arr = [1,2,3]
}
Parent.prototype.say = function() { console.log(this.name) }
function Child() {
Parent.call(this); // (1)
this.type = 'child'
}
Child.prototype = Object.create(Parent.prototype); // (2)
Child.prototype.constructor = Child;
6、延申:多重继承
1
2
3
4
5
6
7
function Child() {
Parent1.call(this)
Parent2.call(this)
this.type = 'child';
}
Child.prototype = Object.create(Object.assign(Parent1.prototype, Parent2.prototype));
Child.prototype.constructor = Child;