JS6种继承方式

继承实现

继承的概念:继承是面向对象的,使用这种方式我们可以更好地复用之前开发的代码,缩短开发周期,提升开发效率。

经典例子:定义一个经典的Class叫汽车,属性包括:颜色、轮胎、品牌、速度、排气量等,由汽车派生出两个类“轿车”和“货车”。在汽车的基础属性上为轿车增加一个后备箱,给货车添加大货箱,他们同属汽车这一类,但是不一样,这就是一个继承的关系。

继承可以使子类具有父类的各种属性和方法,也可以重新定义子类的某些属性,并重写或覆盖某些属性和方法,使其获得与父类不同的属性和方法。

JS实现继承的几种方式

第一种:原型链继承

原型链继承涉及到的构造函数、原型和实例三者之间含有一定的关系:每一个构造函数都有一个原型对象,原型对象又包含一个指向构造函数的指针,而实例则包含一个原型对象的指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  function Parent1() {
this.name = 'parent1';
this.play = [1, 2, 3]
}

function Child1() {
this.type = 'child2';
}

Child1.prototype = new Parent1();
console.log(new Child1());

var s1 = new Child1();
var s2 = new Child1();
s1.play.push(4);
console.log(s1.play, s2.play);

代码看似没有问题,虽然父类的方法和属性都能够访问,但其实有一个潜在的问题

1
2
3
4
var s1 = new Child1();
var s2 = new Child1();
s1.play.push(4);
console.log(s1.play, s2.play);

控制台执行:

明明只改变了 s1 的 play 属性,为什么 s2 也跟着变了呢?原因是因为两个实例使用的是同一个原型对象。它们的内存空间是共享的,当一个发生变化的时候,另外一个也随之进行了变化,这就是使用原型链继承方式的一个缺点。

要解决这个问题的话,我们就得再看看其他的继承方式来解决原型属性共享问题。

第二种:构造函数继承(借助Call)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Parent1(){
this.name = 'parent1';
}

Parent1.prototype.getName = function () {
return this.name;
}

function Child1(){
Parent1.call(this);
this.type = 'child1'
}

let child = new Child1();
console.log(child); // 没问题
console.log(child.getName()); // 会报错

打印的child 在控制台显示,除了 Child1 的属性 type 之外,也继承了 Parent1 的属性 name。这样写的时候子类虽然能够拿到父类的属性值,解决了第一种继承方式的弊端,但问题是,父类原型对象中一旦存在父类之前自己定义的方法,那么子类将无法继承这些方法。

构造函数实现继承的优缺点,它使父类的引用属性不会被共享,优化了第一种继承方式的弊端;但是随之而来的缺点也比较明显——只能继承父类的实例属性和方法,不能继承原型属性或者方法。

第三种:组合继承(前两种结合)

这种方式结合了前两种继承方式的优缺点,结合起来的继承,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function Parent3 () {
this.name = 'parent3';
this.play = [1, 2, 3];
}

Parent3.prototype.getName = function () {
return this.name;
}
function Child3() {
// 第二次调用 Parent3()
Parent3.call(this);
this.type = 'child3';
}

// 第一次调用 Parent3()
Child3.prototype = new Parent3();
// 手动挂上构造器,指向自己的构造函数
Child3.prototype.constructor = Child3;
var s3 = new Child3();
var s4 = new Child3();
s3.play.push(4);
console.log(s3.play, s4.play); // 不互相影响
console.log(s3.getName()); // 正常输出'parent3'
console.log(s4.getName()); // 正常输出'parent3'

执行上面的代码,可以看到控制台的输出结果,之前方法一和方法二的问题都得以解决。

但是这里又增加了一个新问题:通过注释我们可以看到 Parent3 执行了两次,第一次是改变Child3 的 prototype 的时候,第二次是通过 call 方法调用 Parent3 的时候,那么 Parent3 多构造一次就多进行了一次性能开销,这是我们不愿看到的。

那么是否有更好的办法解决这个问题呢?第六种继承方式可以更好地解决这里的问题。

上面的更多是围绕着构造函数的方式,那么对于 JavaScript 的普通对象

第四种:原型式继承

这里不得不提到的就是 ES5 里面的 Object.create 方法,这个方法接收两个参数:一是用作新对象原型的对象、二是为新对象定义额外属性的对象(可选参数)。

通过这段代码看看普通对象是怎么实现的继承。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let parent4 = {
name: "parent4",
friends: ["p1", "p2", "p3"],
getName: function() {
return this.name;
}
};

let person4 = Object.create(parent4);
person4.name = "tom";
person4.friends.push("jerry");

let person5 = Object.create(parent4);
person5.friends.push("lucy");

console.log(person4.name);
console.log(person4.name === person4.getName());
console.log(person5.name);
console.log(person4.friends);
console.log(person5.friends);

从上面的代码中可以看到,通过 Object.create 这个方法可以实现普通对象的继承,不仅仅能继承属性,同样也可以继承 getName 的方法。

第一个结果“tom”,person4 继承了 parent4 的 name 属性,但是在这个基础上又进行了自定义。

第二个是继承过来的 getName 方法检查自己的 name 是否和属性里面的值一样,答案是 true。

第三个结果“parent4”,person5 继承了 parent4 的 name 属性,没有进行覆盖,因此输出父对象的属性。

最后两个输出结果是一样,这里可以联想到 浅拷贝的知识点,关于引用数据类型“共享”的问题,其实 Object.create 方法是可以为一些对象实现浅拷贝的。

这种继承方式的缺点也很明显,多个实例的引用类型属性指向相同的内存,存在篡改的可能。另一种继承方式——寄生式继承在这个继承基础上进行优化。

第五种:寄生式继承

使用原型式继承可以获得一份目标对象的浅拷贝,然后利用这个浅拷贝的能力再进行增强,添加一些方法,这样的继承方式就叫作寄生式继承。

虽然其优缺点和原型式继承一样,但是对于普通对象的继承方式来说,寄生式继承相比于原型式继承,还是在父类基础上添加了更多的方法。看一下代码是怎么实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
 let parent5 = {
name: "parent5",
friends: ["p1", "p2", "p3"],
getName: function() {
return this.name;
}
};

function clone(original) {
let clone = Object.create(original);
clone.getFriends = function() {
return this.friends;
};
return clone;
}

let person5 = clone(parent5);

console.log(person5.getName());
console.log(person5.getFriends());

通过上面这段代码,我们可以看到 person5 是通过寄生式继承生成的实例,它不仅仅有 getName 的方法,而且可以看到它最后也拥有了 getFriends 的方法,结果如下图所示。

从最后的输出结果中可以看到,person5 通过 clone 的方法,增加了 getFriends 的方法,从而使 person5 这个普通对象在继承过程中又增加了一个方法,这样的继承方式就是寄生式继承。但是存在弊端两次调用父类的构造函数造成浪费,寄生组合继承可以解决这个问题。

第六种:寄生组合式继承

结合第四种中提及的继承方式,解决普通对象的继承问题的 Object.create 方法,在前面这几种继承方式的优缺点基础上进行改造,得出了寄生组合式的继承方式,这也是所有继承方式里面相对最优的继承方式,代码如下。

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
function clone (parent, child) {
// 这里改用 Object.create 就可以减少组合继承中多进行一次构造的过程
child.prototype = Object.create(parent.prototype);
child.prototype.constructor = child;
}

function Parent6() {
this.name = 'parent6';
this.play = [1, 2, 3];
}
Parent6.prototype.getName = function () {
return this.name;
}
function Child6() {
Parent6.call(this);
this.friends = 'child5';
}

clone(Parent6, Child6);

Child6.prototype.getFriends = function () {
return this.friends;
}

let person6 = new Child6();
console.log(person6);
console.log(person6.getName());
console.log(person6.getFriends());

通过这段代码可以看出来,这种寄生组合式继承方式,基本可以解决前几种继承方式的缺点,较好地实现了继承想要的结果,同时也减少了构造次数,减少了性能的开销,我们来看一下上面这一段代码的执行结果。

可以看到 person6 打印出来的结果,属性都得到了继承,方法也没问题,可以输出预期的结果。

综上,六种继承方式中寄生组合式继承是最优的继承方式。另外,ES6 还提供了继承的关键字 extends,再看下 extends 的底层实现继承的逻辑。

ES6 的 extends 关键字实现逻辑

可以利用 ES6 里的 extends 的语法糖,使用关键词很容易直接实现 JavaScript 的继承,

先看下用利用 extends 如何直接实现继承,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Person {
constructor(name) {
this.name = name
}
// 原型方法
// 即 Person.prototype.getName = function() { }
// 下面可以简写为 getName() {...}
getName = function () {
console.log('Person:', this.name)
}
}
class Gamer extends Person {
constructor(name, age) {
// 子类中存在构造函数,则需要在使用“this”之前首先调用 super()。
super(name)
this.age = age
}
}
const asuna = new Gamer('Asuna', 20)
asuna.getName() // 成功访问到父类的方法

由于浏览器的兼容性问题,如果遇到不支持 ES6 的浏览器,就得利用 babel 编译工具,将 ES6 的代码编译成 ES5,让一些不支持新语法的浏览器也能运行。

那么最后 extends 编译成了什么样子呢?看一下转译之后的代码片段。

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
function _possibleConstructorReturn (self, call) { 
// ...
return call && (typeof call === 'object' || typeof call === 'function') ? call : self;
}
function _inherits (subClass, 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;
}

var Parent = function Parent () {
// 验证是否是 Parent 构造出来的 this
_classCallCheck(this, Parent);
};
var Child = (function (_Parent) {
_inherits(Child, _Parent);
function Child () {
_classCallCheck(this, Child);
return _possibleConstructorReturn(this, (Child.__proto__ || Object.getPrototypeOf(Child)).apply(this, arguments));
}
return Child;
}(Parent));

从上面编译完成的源码中可以看到,它采用的也是寄生组合继承方式,因此也证明了这种方式是较优的解决继承的方式。

总结

JavaScript 的继承方式:

通过 Object.create 来划分不同的继承方式,最后的寄生式组合继承方式是通过组合继承改造之后的最优继承方式,而 extends 的语法糖和寄生组合继承的方式基本类似。

综上,我们可以看到不同的继承方式有不同的优缺点,选择最适合当前场景的继承方式,需要深入了解各种方式的优缺点。

参考:

廖雪峰JavaScript教程

红宝书JavaScript高级程序设计百度网盘链接:https://pan.baidu.com/s/1UjMmvMmbo6eU0-inVKecbA
提取码:pru8

W3Cschool JavaScript教程


JS6种继承方式
https://www.prime.org.cn/2021/03/26/JS6种继承方式/
Author
emroy
Posted on
March 26, 2021
Licensed under