JS 是一门面向对象的语言,面向对象的三个特性:封装、继承、多态。虽然 JS 没有多态,但是继承还是有的,但是 JS 的继承只支持实现继承,实现的方式就是通过原型链。原型链我们上篇已经讲过了,所以本篇主要 JS 中几种继承的方式。

1、借用构造函数实现继承

这种继承方法最原始的继承,实现的非常简单就是在子类型构造函数的内部实现超类型的构造函数。可以通过 call 和 apply 来实现。

1
2
3
4
5
6
7
8
9
10
function Father() {
this.money = "$1";
}
function Son() {
Father.call(this);
}
var son1 = new Son();
console.log(son1.money); // $1
var son2 = new Son();
console.log(son2.money); // $1

上述可见,这完全实现了继承,儿子继承了老子的钱,虽然只有 1 美元,但也是爱啊!

但是这样有一个问题,我们来看一下:

1
2
3
4
5
6
7
8
9
10
11
12
function Father() {
this.money = "$1";
}
Father.prototype.makeMoney = function () {
console.log("努力工作");
};
function Son() {
Father.call(this);
}
var son1 = new Son();
console.log(son1.money); // $1
son1.makeMoney(); //Uncaught TypeError: son1.makeMoney is not a function

发现了吧,这是一个大问题啊,虽然儿子继承了老子的钱,但是没有继承老子的赚钱的能力,虽然 1 美元是一笔大款,但是没有赚钱的能力,早晚也会花光的啊!这是因为方法都在构造函数中定义,因此函数复用就无从谈起了,而且在父类的原型上定义的方法,对子类型而言也是不可见的,结果所有类型都只能使用构造函数模式。因为这个问题,借助构造函数的技术通常都不是单独使用的。那如何实现继承父类原型上的方法呢?这就要讲到另外一个实现继承的方式了,用原型链继承。

2、原型链实现继承

原型链在 JS 中是一种特殊的存在,那如何实现在原型链上的继承呢?是否想到了 new 这个关键词?new 干了哪些事情呢?这是一个经典面试题,简单的说是:

  1. 创建一个新对象
  2. 将新对象的__proto__指向构造函数的 prototype 对象
  3. 将构造函数的作用域赋值给新对象 (也就是 this 指向新对象)
  4. 执行构造函数中的代码(为这个新对象添加属性)
  5. 返回新的对象

第二条就能满足咱们的要求。
所以继承父类原型链上的方法我们可以这样写:

1
2
3
4
5
6
7
8
9
10
11
function Father() {
this.money = "$1";
}
Father.prototype.makeMoney = function () {
console.log("努力工作");
};
function Son() {}
Son.prototype = new Father();
var son1 = new Son(); // $1
console.log(son1.money);
son1.makeMoney(); // 努力工作

简直完美啊,儿子即继承了 1 美元又继承了努力赚钱的方法。但是好像忽略了一点,这个父亲好像有两个儿子,现在这个父亲不止只有钱还有很多东西,那我们将它们写成一个数组,此时小儿子偷摸的想要老父亲的自行车,老父亲允许了,让他自己拿于是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Father() {
this.goods = ["$1", "手电筒", "冰箱"];
}
Father.prototype.makeMoney = function () {
console.log("努力工作");
};
function Son() {}
Son.prototype = new Father();
var son1 = new Son();
var son2 = new Son();
son2.goods.push("自行车");
console.log(son1.goods); // ["$1", "手电筒", "冰箱", "自行车"]
son1.makeMoney(); // 努力工作
console.log(son2.goods); // ["$1", "手电筒", "冰箱", "自行车"]
son2.makeMoney(); // 努力工作

貌似有些尴尬,因为小儿子继承自行车这个事情一下子就被大儿子发现了。这是因为包含引用类型值的原型属性会被所有实例共享。这也是为什么要使用构造函数来定义属性的原因。在通过原型来实现继承时,原型实际上会变成另一个原型的实例,于是原先的实例属性也就变成了现在的原型属性。

以这个例子来说,父类的 goods 是个数组(引用数据类型),每个父类的实例都会有一个 goods 的属性,在子类通过原型链继承了父类的原型时,子类的原型就是父类的实例,那么每个子类也同样拥有了 goods 这个原型属性,就相当于在 son.prototype.goods 一样,但是由于包含引用类型的原型属性会被所有实例共享,所以当 son1 对 goods 进行修改时,son2 也会被修改。

3、组合继承

组合继承就是把构造函数继承和原型链继承组合在一起,结合两者的长处。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Father() {
this.goods = ["$1", "手电筒", "冰箱"];
}
Father.prototype.makeMoney = function () {
console.log("努力工作");
};
function Son() {
Father.call(this);
}
Son.prototype = new Father();
var son1 = new Son();
var son2 = new Son();
son2.goods.push("自行车");
console.log(son1.goods); // ['$1', '手电筒', '冰箱']
son1.makeMoney(); // 努力工作
console.log(son2.goods); // ['$1', '手电筒', '冰箱', '自行车']
son2.makeMoney(); // 努力工作

终于满足了小儿子独自继承自行车的梦想。

4、组合继承的优化

组合继承是 JS 中最经典的继承方式,但是上述中还有些许的缺点,会发现在创建一个子类型的实例时,会创建两次父类的实例,接下来就对这一点进行优化。
上述借用原型链继承中,应用 new 关键词的目的,就是希望把父类的原型赋值给子类,那可以直接将父类的原型赋值过去,优化 1 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Father() {
this.goods = ["$1", "手电筒", "冰箱"];
}
Father.prototype.makeMoney = function () {
console.log("努力工作");
};
function Son() {
Father.call(this);
}
Son.prototype = Father.prototype;
var son1 = new Son();
var son2 = new Son();
son2.goods.push("自行车");
console.log(son1.goods); // ['$1', '手电筒', '冰箱']
son1.makeMoney(); // 努力工作
console.log(son2.goods); // ['$1', '手电筒', '冰箱', '自行车']
son2.makeMoney(); // 努力工作

结果没有任何问题,也解决了调用两次父类实例的问题,但是:

1
2
3
4
son1.constructor
ƒ Father() {
this.goods = ["$1", "手电筒", "冰箱"];
}

子类虽然是继承父类的,但是它的实例也是一个个体啊,儿子 1 和儿子 2 都是儿子啊,怎么一下子成为老子了,这肯定不行。
所以我们再次优化,优化 2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Father() {
this.goods = ["$1", "手电筒", "冰箱"];
}
Father.prototype.makeMoney = function () {
console.log("努力工作");
};
function Son() {
Father.call(this);
}
Son.prototype = Object.create(Father.prototype);
Son.prototype.constuctor = Son;
var son1 = new Son();
var son2 = new Son();
son2.goods.push("自行车");
console.log(son1.goods); // ['$1', '手电筒', '冰箱']
son1.makeMoney(); // 努力工作
console.log(son2.goods); // ['$1', '手电筒', '冰箱', '自行车']
son2.makeMoney(); // 努力工作

这样才算是真正优化完成。

5、原型式继承

本来觉得写到上一条的时候就结束了,但是看到了《高程》里对继承的讲述,还有 ES6 继承特性的底层实现原理,觉得还是有必要好好的了解这一块的知识。

如高程所讲,提出这种继承方式的人叫道格拉斯·克罗克福德,他在 2006 年写了一篇名为《Prototypal Inheritance in Javascript》(Javascript 中原型式继承),在此书中他实现了一种新型的继承方式,这个继承方式没有使用严格意义上的构造函数,而是建造一个基准的对象,通过原型将已有的基准对象再创建一个新的对象,同时还不必因此创建自定义类型,为了实现这个目的,他创建了如下函数:

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

他在里面创建了一个临时的构造函数,然后将传入的对象赋值给这个临时构造函数的原型,最后返回这个临时构造函数的新实例。看下面这个例子:

1
2
3
4
5
6
7
8
var father = {
money: "$1",
goods: ["手电筒", "冰箱"],
};
var son1 = object(father);
var son2 = object(father);
son2.goods.push("自行车");
console.log(father.goods); // ['手电筒', '冰箱', 'John', '自行车']

毕竟是通过原型继承,所以终究还是会有原型继承应有的问题,引用类型的属性会被共享,但是有没有感觉这个方法和 Object.create()很像,没错 object.create()就是基于这个方法建立的。

1
2
3
4
5
6
7
8
var father = {
money: "$1",
goods: ["手电筒", "冰箱"],
};
var son1 = Object.create(father);
var son2 = Object.create(father);
son2.goods.push("自行车");
console.log(father.friends); // ['手电筒', '冰箱', 'John', '自行车']

但 Object.create()不同的是,它还有第二个参数,第二个参数是可选值,设置第二个参数时,可以指定任何属性都会覆盖原型上的同名属性。

6、寄生式继承

这种继承方式同样是道格拉斯·克罗克福德提出的,有些类似于借用构造函数继承,但是同样的他利用了一个临时构造函数的思想,即所说的寄生思想,将一个对象作为基准,然后寄生在这个对象上,这样就可以拥有这个对象的所有属性和方法,封装成一个函数,在函数内部给这个对象的副本添加新的属性和方法。

1
2
3
4
5
6
7
function createAnother(o) {
var clone = object(o);
clone.sayHi = function () {
console.log("hi");
};
return clone;
}

但是这种呢,无法做到函数的复用大大降低效率。

7、寄生组合式继承

前面提到的组合继承时最经典的继承方式,但是有个很重要的问题,会执行两次父类的构造函数。没事我们可以用寄生组合式继承来解决这个问题。

我们引用《高程》对寄生组合式继承解释:即通过借用构造函数来继承属性,通过原型链的混用形式来继承方法。其背后的思路是:不必为了子类型的原型而调用超类型的构造函数,我们所需要的的无非就是超类型原型的一个副本而已。本质上,就是使用寄生式继承来继承超类型的原型,然后再将结果指定给子类型的原型。如下所示:

1
2
3
4
5
function inheritPrototype(father, son) {
var prototype = object(father.prototype);
prototype.constructor = son;
son.prototype = prototype;
}

这个示例实现了最简单的寄生组合式继承。它接受了两个参数,分别是父类的构造函数和子类的构造函数。在函数内部第一步是创建一个父类型原型副本,第二步是将新的副本添加 constructor,弥补因重构原型而失去的默认 constuctor 属性,第三步是将新的副本赋值给子类型的原型上。

这样我们就可以保证只执行一次父类的构造函数。

1
2
3
4
5
6
7
8
9
10
function Father() {
this.money = "$1";
}
Father.prototype.makeMoney = function () {
console.log("努力工作");
};
function Son() {
Father.call(this);
}
inheritPrototype(Father, Son);

8、ES6 的继承 class…extends…

以前说到继承的时候只是觉得是个语法糖,用起来很是方便,但是通过写这篇文章查阅资料时看到了其底层实现的原理。这也是写了原型式继承、寄生继承、寄生组合式继承的原因。

我们先来看一下 ES6 继承:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Father {
constructor() {
this.money = "$1";
this.goods = ["手电筒", "冰箱"];
}
makeMoney = function () {
console.log("努力工作");
};
}

class Son extends Father {
constructor() {
super();
}
}

用 Bable 转成 ES5 后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
"use strict";

function _possibleConstructorReturn(self, call) {
if (!self) {
throw new ReferenceError(
"this hasn't been initialised - super() hasn't been called"
);
}
return call && (typeof call === "object" || typeof call === "function")
? call
: self;
}

function _inherits(subClass, superClass) {
if (typeof superClass !== "function" && superClass !== null) {
throw new TypeError(
"Super expression must either be null or a function, not " +
typeof superClass
);
}
subClass.prototype = Object.create(superClass && superClass.prototype, {
constructor: {
value: subClass,
enumerable: false,
writable: true,
configurable: true,
},
});
if (superClass)
Object.setPrototypeOf
? Object.setPrototypeOf(subClass, superClass)
: (subClass.__proto__ = superClass);
}

function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}

var Father = function Father() {
_classCallCheck(this, Father);

this.makeMoney = function () {
console.log("努力工作");
};

this.money = "$1";
this.goods = ["手电筒", "冰箱"];
};

var Son = (function (_Father) {
_inherits(Son, _Father);

function Son() {
_classCallCheck(this, Son);

return _possibleConstructorReturn(
this,
(Son.__proto__ || Object.getPrototypeOf(Son)).call(this)
);
}

return Son;
})(Father);

我们可以看到当创建类的时候,ES6 转换为 ES5 时,跟以前创建类会多一个_classCallCheck 函数,其实看名称就知道是一个检测,它接受两个参数,第一个是 this,第二个是构造函数,判断 this 是否是这个这个构造函数的实例,即 instanceof 检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上,说白了就是判断调用前是否有 new 关键字,不是则抛出 Cannot call a class as a function 异常,所以能进一步可知 class 就是一个语法糖。

ES6 的继承转换为 ES5 后发现子类是一个自执行的函数,将父类作为参数传递过去。首先是_inherits 函数,同样接受两个参数,一个是子类构造函数,一个是父类构造函数。看其内部结构:

  1. 首先判断父类的类型
  2. 基于父类的原型创建一个新的对象。并通过 Object.create 的第二个参数,将新对象的构造函数指向子类,再将其赋值给子类的原型。
  3. 将子类的__proto__指向父类的构造函数

第二步是不是很熟悉,没错就是寄生组合式继承的简单版。
之后是个闭包,保存父类的引用,闭包内部的实现步骤:

  1. 判断调用前是否有 new 关键字
  2. 因为_inherits 函数执行后,Son.__proto__ || Object.getPrototypeOf(Son)实际上指的就是父类的构造函数,通过 call 方法将其调用改为当前的 this
  3. _possibleConstructorReturn 函数中,首先校验 this 是否被初始化,super 是否调用,并返回父类已经赋值完的 this。
  4. 然后进行子类构造函数中的逻辑

当我们没有写子类构造函数时:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Son extends Father {}
// 转为ES5
var Son = (function (_Father) {
_inherits(Son, _Father);
function Son() {
_classCallCheck(this, Son);
return _possibleConstructorReturn(
this,
(Son.__proto__ || Object.getPrototypeOf(Son)).apply(this, arguments)
);
}
return Son;
})(Father);

可见默认的构造函数中会主动调用父类构造函数,并默认把当前 constructor 传递的参数传给了父类。
所以当我们声明了 constructor 后必须主动调用 super(),否则无法调用父构造函数,无法完成继承。
本篇就先写到这吧,欢迎留言讨论。

参考资料:

JavaScript 高级程序设计(第 3 版)