JavaScript面向对象编程(二):继承

Author Avatar
Klein 3月 17, 2019

JavaScript中常见的继承:

构造函数的继承

构造函数绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Person(){
this.species = "人类";
}

Person.prototype.a = 1
Person.prototype.eat = function(){
console.log('eat');
}

function Chinese(name, age){
Person.apply(this, arguments)
this.name = name;
this.age = age;
}

var ch1 = new Chinese('小明', 12)
ch1.species // 人类
ch1.a // undefinde
ch1.eat() // undefinde

核心代码是 SuperType.call(this),创建子类实例的时候调用父类构造函数,所以子类的每个实例都会把父类的属性和方法复制到自身。

不过只能复制父类自身的属性和方法,无法复制父类原型的属性和方法

缺点:

  • 只能继承父类的实例属性和方法,不能继承原型属性和方法
  • 无法实现复用,每个子类都有父类实例函数的副本,影响性能

原型链继承(prototype模式)

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
function Person(){
this.species = "人类";
this.list = [1,2,3];
}

Person.prototype.a = 1
Person.prototype.eat = function(){
console.log('eat');
}

function Chinese(name, age){
this.name = name;
this.age = age;
}

Chinese.prototype = new Person();
Chinese.prototype.constructor = Chinese

var ch1 = new Chinese('小明', 12)
ch1.species // 人类
ch1.a // 1
ch1.eat() // eat
ch1.list // [1, 2, 3]

// 实例对原型链上的引用类型进行操作
ch1.list.push(4)
ch1.list // [1, 2, 3, 4]

var ch2 = new Chinese('老周'21);
ch2.list // [1, 2, 3, 4]

Chinese.prototype = new Person();相当于完全删除了prototype 对象原先的值,然后赋予一个新值。

Chinese.prototype.constructor = Chinese又是什么?

任何一个 prototype 对象都有一个 constructor 属性,指向它的构造函数。如果没有Chinese.prototype.constructor = new Person() 这一行,Chinese.prototype.constructor 会指向 Chinese 的;加了这一行以后,Chinese.prototype.constructor 指向 Person

1
2
// 如果没有 Chinese.prototype.constructor = Chinese 
Chinese.prototype.constructor === Person // true

这显然会导致继承链的紊乱( ch1 明明是用构造函数 Chinese 生成的),因此我们必须手动纠正。

1
ch1.__proto__.constructor === Person // true

缺点:

多个实例对原型链上的引用类型的操作会导致原型对象被篡改。

组合继承

组合上述两种方法就是组合继承。用原型链实现对原型属性和方法的继承,用借用构造函数技术来实现实例属性的继承。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function Person(name){
this.species = "人类";
this.name = name;
}

Person.prototype.a = 1
Person.prototype.eat = function(){
console.log('eat');
}

function Chinese(name, age){
Person.apply(this, [name])
// Person.call(this, name)
this.age = age;
}

Chinese.prototype = new Person();
Chinese.prototype.constructor = Chinese

var ch1 = new Chinese('小明', 12)
ch1.species // 人类
ch1.a // 1
ch1.eat() // eat

当第一次调用 Person() :重写 Chinese.prototype,将 Chinese.prototype 指向 一个 Person 实例,包括两个属性 namespecies

当第二次调用Person():给 ch1 附加两个属性namecolor。再加上 Chinese 构造器添加的 age 属性,ch1总共有三个属性 namecolorage

缺点:

使用子类创建实例对象时,其原型中会存在与父类构造器自身相同数量的属性/方法。

直接继承 prototype

该方法是对 原型链继承(prototype 继承) 的改进。

将所有不变的方法/属性都写入 prototype 属性中。

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
function Person(name){}

Person.prototype.species = 1
Person.prototype.list = [1,2,3];

Person.prototype.eat = function(){
console.log('eat');
}

function Chinese(name, age){
this.name = name;
this.age = age;
}

Chinese.prototype = Person.prototype;
Chinese.prototype.constructor = Chinese

var ch1 = new Chinese('小明', 12)
var ch2 = new Chinese('老周', 21)
ch1.species // 人类
ch1.a // 1
ch1.eat() // eat

ch1.list.push(4)
ch1.list // [1,2,3,4]
ch2.list // [1,2,3,4]
ch1.list === ch2.list // true

缺点:

Chinese.prototypePerson.prototype 现在指向了同一个对象,那么任何对 Chinese.prototype 的修改,都会反映到 Person.prototype

所以,上面这一段代码其实是有问题的。

1
Person.prototype.constructor === Chinese // true

而且多个实例对原型链上的引用类型的操作会导致原型对象被篡改。

利用空对象作为中介

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function Person(name){}

Person.prototype.species = '人类'
Person.prototype.eat = function(){
console.log('eat');
}

function Chinese(name, age){
this.name = name;
this.age = age;
}

function F(){}
F.prototype = Person.prototype
Chinese.prototype = new F();
Chinese.prototype.constructor = Chinese

var ch1 = new Chinese('小明', 12)
var ch2 = new Chinese('老周', 21)
ch1.species // 人类
ch1.a // 1
ch1.eat() // eat

常将上面的方法,封装成一个函数,便于使用。

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
function Person(name){}

Person.prototype.species = '人类';
Person.prototype.list = [1,2,3];
Person.prototype.eat = function(){
console.log('eat');
}

function Chinese(name, age){
this.name = name;
this.age = age;
}

function extend(Child, Parent){
var F = function(){};
F.prototype = Parent.prototype;
Child.prototype = new F();
Child.prototype.constructor = Child.constructor;
Child.prototype.uber = Parent.prototype;
}

extend(Chinese, Person);

var ch1 = new Chinese('小明', 12)
var ch2 = new Chinese('老周', 21)
ch1.species // 人类
ch1.eat() // eat

ch1.list.push(4)
ch1.list // [1,2,3,4]
ch2.list // [1,2,3,4]
ch1.list === ch2.list // true

extend函数的最后一行意思是为子对象设一个 uber 属性,这个属性直接指向父对象的 prototype 属性。这等于在子对象上打开一条通道,可以直接调用父对象的方法。这一行放在这里,只是为了实现继承的完备性,纯属备用性质。

这个extend函数,就是YUI库如何实现继承的方法。

而且该函数还有另外一种写法:

1
2
3
4
5
6
function extend(Child, Parent){
var obj = Object.create(Parent.prototype);
Child.prototype = obj;
Child.prototype.constructor = Child;
Child.prototype.uber = Parent.prototype;
}

这个方法虽然改进了Chinese.prototypePerson.prototype 现在指向了 Chinese 的问题。但是依然有多个实例对原型链上的引用类型的操作会导致原型对象被篡改的问题

缺点:

多个实例对原型链上的引用类型的操作会导致原型对象被篡改。

寄生组合式继承

在上面一种方式的基础上加入构造函数绑定继承来传递参数来实现继承

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
function Person(name){
this.name = name;
this.list = [11,22,33];
}

Person.a = 'a';

// 在父类构造器的原型属性上放共用属性/方法
Person.prototype.species = '人类';
Person.prototype.listOnPrototype = [1,2,3];
Person.prototype.eat = function(){
console.log('eat');
}

function Chinese(name, age){
Person.apply(this, [name]);
// Person.call(this, name);
this.age = age;
}

function extend(Child, Parent){
let obj = Object.create(Parent.prototype);
Child.prototype = obj;
Child.prototype.constructor = Child;
Child.prototype.uber = Parent.prototype;
}

extend(Chinese, Person);

var ch1 = new Chinese('小明', 12);
var ch2 = new Chinese('老周', 21);

ch1.species // 人类
ch1.name // 小明
ch1.age // 12
ch1.eat() // eat

ch2.species // 人类
ch2.name // 老周
ch2.age // 21
ch2.eat() // eat

ch1.list.push(4)
ch1.list // [1,2,3,4]
ch2.list // [1,2,3,4]
ch1.list === ch2.list // true

这个例子的高效率体现在它只调用了一次父类构造器,并且因此避免了在子类构造器的原型上创建不必要的、多余的属性。与此同时,原型链还能保持不变;因此,还能够正常使用 instanceofisPrototypeOf()

这是最成熟的方法,也是现在库实现的方法

混入方式继承多个对象

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
function Parent1(){
this.a = 'a';
this.alist = [1,2,3];
}

Parent1.prototype.a1 = 'a1';
Parent1.prototype.a1list = [4,5,6];

function Parent2(){
this.b = 'b';
this.blist = [10,11,12];
}

Parent2.prototype.b1 = 'b1';
Parent2.prototype.b1list = [13,14,15];

function Child(c){
Parent1.call(this);
Parent2.call(this);
this.c = c;
}

Child.prototype = Object.create(Parent1.prototype);
Object.assign(Child.prototype, Parent2.prototype);
Child.prototype.constructor = Child;

Child.prototype.eat = function() {
console.log('eat')
}

var ch1 = new Child('c1');
var ch2 = new Child('c2');

ch1.a // a
ch1.alist // [1,2,3]
ch1.a1list // [4,5,6]

ch2.a // a
ch2.alist // [1,2,3]
ch2.a1list // [4,5,6]

ch1.alist.push(4)
ch1.alist // [1,2,3,4]
ch2.alist // [1,2,3]

ch1.a1list.push(4)
ch1.a1list // [4,5,6,4]
ch2.a1list // [4,5,6,4]

ch1.a1list === ch2.a1list // true
ch1.b1list === ch2.b1list // true

是在寄生组合继承的基础上使用 Object.assign 方法在子类构造器的原型上添加属性/方法的方式来继承多个对象。

所有子类实例通过原型链继承并共享子类构造器的原型上的使用 Object.assign 方法添加的属性/方法。

1
2
ch1.__proto__.b1list === Child.prototype.b1list // true
ch1.b1list === ch2.b1list // true

所有子类实例通过原型链继承并共享父类构造器的原型上的使用 Object.create 方法添加的属性/方法。

1
2
ch1.__proto__.__proto__a1list === Parent1.prototype.a1list // true
ch1.a1list === ch2.a1list // true

所以

在子类构造器自身的属性/方法不但会被继承而且会被共享。
在父类构造器自身的属性/方法会被继承但是不共享。

浅拷贝继承

把父类的所有不变属性,都放到它的 prototype 对象上。

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
function Person(){}
Person.prototype.species = '人类'
Person.prototype.list = [1, 2, 3];

function Chinese(name, age){
this.name = name;
this.age = age;
}

function copyExtend(Child, Parent) {
let p = Parent.prototype
let c = Child.prototype
for (const i in p) {
c[i] = p[i];
}
c.uber = p
}

copyExtend(Chinese, Person)

var ch1 = new Chinese('小明', 12);
var ch2 = new Chinese('老周', 21);

ch1.species // 人类
ch1.list //[1,2,3]
ch1.list === Chinese.prototype.list // true
ch2.list === Chinese.prototype.list // true
ch1.list === ch2.list // true

Chinese.prototype.list === Person.prototype.list // true

ch1.list.push(4)
ch1.list // [1,2,3,4]
ch2.list // [1,2,3,4]
Person.prototype.list // [1,2,3,4]

Chinese.prototype.constructor === Chinese //true

原理是将所有父构造器原型上的属性/方法浅复制到自构造器的原型上。

实例通过原型链继承到子类构造器原型上的属性/方法。

缺点:

子类实例对原型链上的引用类型的操作会导致原型对象被篡改,而且不仅是子类构造器上的原型对象会被篡改,父类构造器上的原型对象也会被篡改。

ES6类继承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
30
31
32
33
34
35
36
37
38
39
40
41
class Rectangle {
// constructor
constructor(height, width) {
this.height = height;
this.width = width;
}

// Getter
get area() {
return this.calcArea()
}

// Method
calcArea() {
return this.height * this.width;
}
}

const rectangle = new Rectangle(10, 20);
console.log(rectangle.area);
// 输出 200

-----------------------------------------------------------------
// 继承
class Square extends Rectangle {

constructor(length) {
super(length, length);

// 如果子类中存在构造函数,则需要在使用“this”之前首先调用 super()。
this.name = 'Square';
}

get area() {
return this.height * this.width;
}
}

const square = new Square(10);
console.log(square.area);
// 输出 100

extends 继承的核心代码如下,其实现和上述的寄生组合式继承方式一样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function _inherits(Child, Parent) {
// 创建对象,创建父类原型的一个副本
// 增强对象,弥补因重写原型而失去的默认的constructor 属性
// 指定对象,将新创建的对象赋值给子类的原型
Child.prototype = Object.create(Parent && Parent.prototype, {
'constructor': {
value: 'Child',
enumerable: false,
configurable: true,
writable: true
}
});

if (superType) {
// 如果浏览器支持 Object.setPrototypeOf
Object.setPrototypeOf
? Object.setPrototypeOf(subType, superType)
: subType.__proto__ = superType;
}
}
  1. 函数声明和类声明的区别:

函数声明会提升,类声明不会。

1
2
3
4
let p = new Rectangle(); 
// ReferenceError

class Rectangle {}

首先需要声明你的类,然后才能访问它,否则抛出一个ReferenceError异常。

  1. ES5继承和ES6继承的区别
  • ES5的继承实质上是先创建子类的实例对象,然后再将父类的方法添加到this上(Parent.call(this)).

  • ES6的继承有所不同,实质上是先创建父类的实例对象this,然后再用子类的构造函数修改this。因为子类没有自己的this对象,所以必须先调用父类的super()方法,否则新建实例报错

ES6 extends 继承,主要就是:

  1. 将子类构造函数(Child)的原型(proto)指向了父类构造函数(Parent)。即:

    1
    Child.__proto__ ==== Parent
  2. 将子类实例child的原型对象(Child.prototype) 的原型(proto)指向了父类parent的原型对象(Parent.prototype)。

    1
    Child.prototype.__proto__ ==== Parent.prototype
  3. 子类构造函数Child继承了父类构造函数Preant的里的属性。使用super调用的(ES5则用call或者apply调用传参)。

    1
    2
    3
    4
    function Child(name, age) {
    Person.call(this, name);
    this.age = age;
    }

寄生组合式继承的原理就是以上的第二和第三点,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
var Person = {
species: "人类",
list: [1,2,3]
}

function object(obj){
function F(){}
F.prototype = obj;
return new F();
}

var Chinese = object(Person);
var Japanese = object(Person);
Chinese.Nationality = '中国人';
Chinese.list // [1,2,3]
Japanese.Nationality = '日本人';
Japanese.list // [1,2,3]
Chinese.list.push
Chinese.list // [1,2,3,4]
Japanese.list // [1,2,3,4]

Japanese.list === Chinese.list // true
Chinese.__proto__ === Person // true
Chinese.__proto__ === Japanese.__proto__ // true

object() 对传入其中的对象执行了一次浅复制,将构造函数F的原型直接指向传入的对象。

缺点:

  • 原型链继承多个实例的引用类型属性指向相同,存在篡改的可能。
  • 无法传递参数

另外,ES5中存在Object.create()的方法,能够代替上面的object方法。

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
var Person = {
species: "人类",
list: [1,2,3]
}

var Chinese = Object.create(Person, {
'Nationality': {
value: '中国人'
}
});
var Japanese = Object.create(Person, {
'Nationality': {
value: '日本人'
}
});

Chinese.Nationality // 中国人
Japanese.Nationality // 日本人
Chinese.list // [1,2,3]
Japanese.list // [1,2,3]
Chinese.list.push(4);
Chinese.list // [1,2,3,4]
Japanese.list // [1,2,3,4]

Japanese.list === Chinese.list // true
Chinese.__proto__ === Person // true
Chinese.__proto__ === Japanese.__proto__ // true

寄生式继承

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
var Person = {
species: "人类",
list: [1,2,3]
}

function object(obj){
function F(){}
F.prototype = obj;
return new F();
}


function createAnother(original){
var clone = object(original)
clone.sayHi = function(){ // 以某种方式来增强对象
console.log("hi");
};
return clone; // 返回这个对象
}

var Chinese = object(Person);
var Japanese = object(Person);
Chinese.Nationality = '中国人';
Japanese.Nationality = '日本人';

Chinese.list // [1,2,3]
Chinese.sayHi() // hi
Japanese.list // [1,2,3]
Japanese.sayHi() // hi
Chinese.list.push
Chinese.list // [1,2,3,4]
Japanese.list // [1,2,3,4]

Japanese.list === Chinese.list // true
Chinese.__proto__ === Person // true
Chinese.__proto__ === Japanese.__proto__ // true

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
29
30
31
32
33
34
35
var Person = {
species: "人类",
list: [1,2,3]
}

function createAnother(original, propertiesObject){
var clone = Object.create(original, propertiesObject)
clone.sayHi = function(){ // 以某种方式来增强对象
console.log("hi");
};
return clone; // 返回这个对象
}

var Chinese = createAnother(Person, {
'Nationality': {
value: '中国人'
}
});
var Japanese = createAnother(Person, {
'Nationality': {
value: '日本人'
}
});

Chinese.list // [1,2,3]
Chinese.sayHi() // hi
Japanese.list // [1,2,3]
Japanese.sayHi() // hi
Chinese.list.push
Chinese.list // [1,2,3,4]
Japanese.list // [1,2,3,4]

Japanese.list === Chinese.list // true
Chinese.__proto__ === Person // true
Chinese.__proto__ === Japanese.__proto__ // true

其实和原型式继承是一样的。只是在其基础上进行封装,为构造函数新增属性和方法。

缺点(同原型式继承):

  • 原型链继承多个实例的引用类型属性指向相同,存在篡改的可能。
  • 无法传递参数

浅拷贝

除了使用”prototype链”实现继承以外,还有另一种思路:把父对象的属性,全部拷贝给子对象,也能实现继承。

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
function extendCopy(p) {
var c = {};
for (var i in p) {
  c[i] = p[i];
}
c.uber = p;
return c;
}

var Person = {
species: "人类",
list: [1,2,3]
}

var Chinese = extendCopy(Person);
Chinese.Nationality = '中国人'
var Japanese = extendCopy(Person);
Japanese.Nationality = '日本人'


Chinese.Nationality // 中国人
Japanese.Nationality // 日本人
Chinese.list // [1,2,3]
Japanese.list // [1,2,3]
Chinese.list.push(4);
Chinese.list // [1,2,3,4]
Japanese.list // [1,2,3,4]

Japanese.list === Chinese.list // true
Chinese.__proto__ === Japanese.__proto__ // true

extendCopy()只是拷贝基本类型的数据,我们把这种拷贝叫做”浅拷贝”。这是早期jQuery实现继承的方式。

深拷贝

深拷贝能够实现真正意义上的数组和对象的拷贝只要递归调用”浅拷贝”即可。

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
function deepCopy(p, c) {
var c = c || {};
for (var i in p) {
   if (typeof p[i] === 'object') {
c[i] = (p[i].constructor === Array) ? [] : {};
deepCopy(p[i], c[i]);
} else {
c[i] = p[i];
}
}
return c;
}

var Person = {
species: "人类",
list: [1,2,3]
}

var Chinese = deepCopy(Person);
var Japanese = deepCopy(Person);


Chinese.list // [1,2,3]
Japanese.list // [1,2,3]
Chinese.list.push(4);
Chinese.list // [1,2,3,4]
Japanese.list // [1,2,3]

小结

继承对于JS来说就是父类拥有的方法和属性、静态方法等,子类也要拥有。子类中可以利用原型链查找,也可以在子类调用父类,或者从父类拷贝一份到子类等方案。 继承方法有很多,重点在于必须理解这些对象、原型以及构造器的工作方式。

回顾ES6的 extends。主要原理就是三点:

  1. 子类构造函数的proto指向父类构造器,继承父类的静态方法
  2. 子类构造函数的prototype的proto指向父类构造器的prototype,继承父类的方法。
  3. 子类构造器里调用父类构造器,继承父类的属性。

参考

Javascript面向对象编程(二):构造函数的继承
Javascript面向对象编程(三):非构造函数的继承
面试官问:JS的继承
JavaScript常用八种继承方案