JS原型与继承

ECMAScript 2015 中引入的 JavaScript 类实质上是 JavaScript 现有的基于原型的继承的语法糖。类语法不会为JavaScript引入新的面向对象的继承模型。

MDN Web doc

在设计之初,JS本身只是单纯的作为一个用于网页设计的脚本语言,所以其并没有设计得很复杂。而对于继承,JS采用了原型的形式。

prototype与__proto__

JS中万物皆对象,函数也是一种对象。

每个函数对象都有一个prototype属性,而普通的对象没有。下面是一个Foo函数对象:

1
2
3
function Foo(attr) {
this.attr = attr;
}

对于每一个函数对象,都拥有一个prototype原型。在默认情况下,所有的原型都会自动获得一个constructor属性,这个属性指向 prototype 属性所在的函数对象。在上面这个例子中,Foo的prototype中的constructor属性指向的就是Foo这个函数对象。

下面我们将Foo函数当作构造函数来实例化两个对象:

1
2
var f1 = new Foo('A');
var f2 = new Foo('B');

在每一个实例中也会有一个constructor属性,其指向实例的构造函数。除此之外,每个对象(是任何对象,不止函数对象)还都会有一个__proto__属性,这个属性用来指向它们的原型对象prototype。

用一张图来总结一下:
prototype-1.png

对此,我们可以用一段代码来验证一下:

1
2
3
4
Foo.prototype.constructor === Foo     //true
f1.constructor === Foo //true
f1.__proto__ === Foo.prototype //true
f2.__proto__ === f1.__proto__ //true

原型链

先举个例子,我们使用上面实例化的f1并在控制台打印valueOf的结果:

1
console.log(f1.valueOf());

此时我们在浏览器控制台查看一下f1:
prototype-2.png
我们会发现,在我们的Foo构造器中,并没有声明valueOf这个函数。通过控制台查看f1中的__proto__属性,也就是Foo的原型,发现原型中也没有valueOf这个函数。但是我们发现在Foo的原型中依然有__proto__这个属性,前文说过,原型也是一个对象,它自然也有__proto__属性。现在我们再查看prototype的__proto__属性:
prototype-3.png
我们发现,Foo的prototype的prototype是object的prototype,而valueOf正是object的prototype里的方法。
prototype-4.png
所以,valueOf函数的查找过程大致可以描述为:

  1. 当前obj为f1,在实例f1中查找,若找直接返回。
  2. 在f1中没有找到,根据f1.__proto__查找f1的原型,若找到则返回。
  3. 在f1的原型中没有找到,将Foo.prototype作为obj重复1。
  4. 若一直没有找到,会发现Object.prototype.__proto__为null,停止查找。
1
2
3
4
5
6
f1.__proto__ === Foo.prototype
f1.__proto__.__proto__ === Object.prototype
Foo.prototype.__proto__ === Object.prototype
Object.prototype.__proto__ === null
f1.__proto__.__proto__.__proto__ === null
Foo.prototype.__proto__.__proto__ === null

上面这个链式关系就可以称作原型链。对于查找f1的方法,原型链大致如下:
f1 –> Foo.prototype –> Object.prototype –> null

继承

现在来说大头,JS中的继承。JS的继承有很多很多花样。

原型链继承

核心很简单,将子类的原型设置为父类的实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Parent(attr) {
this.attr = attr;
this.foo = 1;
}

function Child(attr) {
this.attr = attr;
this.bar = 2;
}

Child.prototype = new Parent();

var child = new Child('A');
console.log(child.attr); //A, 子类覆写父类属性
console.log(child.foo); //1, 父类属性
console.log(child.bar); //2, 子类属性

若要继承方法,只需正常在prototype里声明就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Parent(attr) {
this.attr = attr;
this.foo = 1;
}

Parent.prototype.doSomething = function() {
alert('DO SOMETHING!');
}

function Child(attr) {
this.attr = attr;
this.bar = 2;
}

Child.prototype = new Parent();

var child = new Child('A');
child.doSomething();

这里说一点题外话,就是函数为什么要写在原型里而非构造器里。其实就结果来讲,写在构造器里和函数里的效果是一样的,也就是说上面的代码效果等同于下面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Parent(attr) {
this.attr = attr;
this.foo = 1;
this.doSomething = function() {
alert('DO SOMETHING!');
}
}

function Child(attr) {
this.attr = attr;
this.bar = 2;
}

Child.prototype = new Parent();

var child = new Child('A');
child.doSomething();

但两者牵扯到内存的问题。若是函数写在了构造器里,假设我们现在实例了两个parent, attr分别为p1和p2,他们内部是这样的(下图省去了一些无关的属性或方法):
prototype-5.png
但如果是写在了prototype里:
prototype-6.png
可见prototype更像是一个公共容器,如果要实例化很多对象并且函数不会对私有属性进行访问或更改的话,函数声明建议写在prototype里。但若此方法需要访问私有变量的话,写在prototype里就显然没有用了。

回到正题,这种继承方法很好理解,但他有一些问题:若父类中有引用类型成员变量,子类继承的是引用本身,若现在有多个实例化的子类,一个子类对引用类型的成员变量进行更改的话,另一个中相应的变量也会被更改。

1
2
3
4
5
6
7
8
9
10
11
12
function Parent() {
this.arr = [1, 2];
}

function Child() {}

Child.prototype = new Parent();

var c1 = new Child();
var c2 = new Child();
c1.arr.push(3);
console.log(c2.arr); //[1, 2, 3]

注意!我们在开头讲到,原型的constructor应当是指向构造函数的。但在继承之后,子类的原型的constructor是指向父类的,因为我们调用了Child.prototype = new Parent();。所以我们最好手动设置一下Child.prototype.constructor = Child

构造器继承

这种继承方法就跟原型没多大关系了,但它能解决上面的问题,同时,父类还可以向子类传递参数。构造器继承的方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
function Parent() {
this.arr = [1, 2];
}

function Child() {
Parent.call(this);
}

var c1 = new Child();
var c2 = new Child();
c1.arr.push(3);
console.log(c1.arr); //[1, 2, 3]
console.log(c2.arr); //[1, 2]

此种方法通过在子类中调用父类的构造函数来实现继承,因为每new一个子类都会执行一遍父类的构造函数,多个子类之间的引用类型成员变量并不会共享。

但是这也有一个问题,就是函数也是引用类型变量,若采用此种继承方式,父类的函数会分别复制一份至各个子类中,就像上文讲到的将方法写在构造器中一样,造成了不少浪费。

组合继承

从上面两部分可以看到,原型链继承和构造器继承是互相对立的,你的优点是我的缺点,我的缺点是你的优点。那有没有什么方法可以兼得鱼和熊掌?我们的组合继承就诞生了。

在此,我们将两者混合在一起:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Parent() {
this.arr = [1, 2];
}

Parent.prototype.doSomething = function() {
alert('DO SOMETHING!');
}

function Child() {
Parent.call(this);
}

Child.prototype = new Parent();

var c1 = new Child();
var c2 = new Child();
c1.arr.push(3);
console.log(c1.arr); //[1, 2, 3]
console.log(c2.arr); //[1, 2]
console.log(c1.doSomething === c2.doSomething); //true

组合继承也有缺点,因为我们调用了两次父类的构造函数,一定程度上造成了性能的浪费。

原型式继承

1
2
3
4
5
function object(obj) {
function F() {}
F.prototype = obj;
return new F();
}

上面是原型式继承的代码,在object函数中,我们先构造了一个空的构造器F,然后将F的prototype指向obj,再实例化并返回。它和第一个讲的原型链继承长得非常像,只不过就是外面多了一层壳。所以它和原型链继承有一样的缺点,例如没法传递参数,会有引用问题等等。

那么它和原型链继承的区别在于obj这个参数,obj这个变量不一定非要是构造函数,也可以是任何的对象。这样整个过程下来我们就相当于深复制了一个obj对象。ES5中的Object.create()函数就是采用了此种方式。

寄生式继承

名字很nb,但实质上就是对原型式继承做了一些增进,原型式继承理解了,这个就没什么困难的了。

1
2
3
4
5
6
7
function clone(obj) {
var f = object(obj)
f.doSomething = function() {
alert('DO SOMETHING!');
}
return f;
}

其改进之处就是在浅复制obj的基础上为对象添加了函数,但它同构造器继承一样仍有函数无法复用的缺点。

寄生组合式继承

终极版来了。由于组合继承需要调用两次构造函数,一次在Child.prototype = new Parent()处,一次在Parent.call(this)处,所以推出了寄生组合式继承来弥补这个缺陷。

下面的inherit函数实现了简单的寄生组合式继承。

1
2
3
4
5
function inherit(child,parent) {
var prototype = object(parent.prototype); //浅复制一份父类的原型
prototype.constructor = child; //调整constructor的指向
child.prototype = prototype; //将子类的prototype指向我们的副本
}
1
2
3
4
5
6
7
8
9
10
11
12
13
function Parent() {
this.arr = [1, 2];
}

Parent.prototype.doSomething = function() {
alert('DO SOMETHING!');
}

function Child() {
Parent.call(this);
}

inherit(Child, Parent);

在整个继承代码中,父类的构造函数只在call部分调用了一次,从而避免了组合继承中的重复调用。

在我第一个看到这个寄生组合式继承的代码的时候,一直有一个疑惑,这个继承形式就是将Child.prototype = new Parent()改为了inherit(Child, Parent)。那我可否将组合继承中的Child.prototype = new Parent()改为Child.prototype = Parent.prototype?这样不也就避免了重复调用父类构造器了吗?但其实这样会引入一个问题,就是此时Child.prototype和Parent.prototype指向了同一个原型对象。当我在Child.prototype中添加一个函数时,Parent.prototype也增加了。这也是为什么在寄生组合式继承中一直强调创建父类原型对象的深复制的副本。

Object与Function

在此谈点题外的东西。在研究原型的时候,发现了一个有趣的东西。

众所周知,Object和Function是JS的两个内置对象。在原型链部分也有提到,一切对象的原型链最终都会回到Object.prototype –> null。也就是说,一切对象都包含Object原型对象中的属性方法。

同时,一切函数对象的原型链又源自Function.prototype。例如String,Array或者Function本身,他们都是函数对象,所以他们的__proto__属性都指向Function.prototype。最有意思的是,Object本身也显然是个函数对象,所以Object的__proto__自然也指向Function.prototype。

1
2
3
4
String.__proto__ === Function.prototype
Array.__proto__ === Function.prototype
Function.__proto__ === Function.prototype
Object.__proto__ === Function.prototype

那么,也就是说Object的__proto__指向Function的prototype,Funtion的prototype的__proto__又指向Object的prototype:
套娃原型链
非常的amazing啊。总结来说,一切普通对象都继承自Object.prototype,而对于函数对象,在中间还继承了Function.prototype,Function.prototype同样也是个普通对象,也继承自Object.prototype。

其实‘继承’这个讲法不准确,因为JS并没有直接的继承,所有的继承都基于原型链,更准确的说法应该是一切对象的原型链在最后都会回归到–>Object.prototype–>null,而函数对象在此之前会先回到Function的prototype:–>Function.protype–>Object.prototype–>null。

参考链接

JavaScript 中的继承
Object.prototype.__proto__
继承与原型链

Author

s.x.

Posted on

2020-04-10

Updated on

2022-01-29

Licensed under

Comments