【面试系列】JS实现继承的几种方式

2,804 阅读10分钟

许多面向对象语言都支持两种继承方式:接口继承和实现继承。接口继承只继承方法签名,而实现继承则继承实际的方法。

由于函数没有签名,在ECMAScript中无法实现接口继承,只支持实现继承,而且实现继承主要是依靠原型链来实现的。

原型链的查找规则是:当查找一个对象中的某个属性/方法时,如果对象本身没有这个属性/方法,那么会去他的内置属性__proto__中查找,即他的构造函数的原型对象prototype中查找,如果构造函数的原型中没有,就沿着构造函数原型中的__proto__属性,依次一层层往上查找。

ES5继承的6种实现方式

  1. 原型链
  2. 借用构造函数
  3. 组合继承
  4. 原型式继承
  5. 寄生式继承
  6. 组合寄生式继承

下面我们详细介绍一下这6种继承的实现。

1. 原型链

其基本思想:

利用原型链,让一个引用类型继承另一个引用类型的属性和方法。

构造函数、原型和实例的关系:

  • 所有的构造函数都是函数,所有的函数都有prototype属性
  • 每个原型对象,都包含一个指针constructor指向构造函数
  • 所有的引用类型都有一个内置属性__proto__指向其构造函数的原型对象

⚠️ 请牢牢记住以上三条! 请牢牢记住以上三条! 请牢牢记住以上三条!

实现原型链继承的基本模式如下:

function One(x = 0) {
    this.numOne = 1;
    this.numX = x
}
One.prototype.getNumX = function() {
    return this.numX;
}
// 原型上定义引用类型的数据,会被所有实例共享
One.prototype.numList = [1, 2, 3]

function Two(x = 0) {
    this.numTwo = 2;
    this.numTwoX = x;
}
// Two继承了One
// Two的原型对象prototype是One的实例,其内部指针__proto__指向One的原型对象
Two.prototype = new One(6);
Two.prototype.getNumTwo = function() {
    return this.numTwo;
}

const two = new Two();
const two2 = new Two()
two.getNumX(); // 6
two2.getNumX(); // 6
console.log(two.numList) // [1 ,2, 3]
two.numList.push(4);
console.log(two.numList) // [1, 2, 3, 4]
two2.numList.push(5);
console.log(two2.numList) // [1, 2, 3, 4, 5],和two共享了numList

实例以及构造函数和原型之间的关系如图所示:

原型链继承的问题:

  1. 原型属性是包含引用类型的值时,会被所有实例共享。所以,一般属性在构造函数中定义,不在原型链中定义。
  2. 没有办法在不影响所有对象实例的情况下,给父类的构造函数传递参数。 鉴于以上两点,实践中很少会单独使用原型链。

注意事项:

  1. 所有的引用类型都默认继承了Object,这个继承也是通过原型链实现的,而Object的原型对象指向null,null是原型链的终极
  2. 在使用原型链实现继承时,不能使用字面量创建原型方法,因为这样会重写原型链。
  3. 给原型添加方法的代码一定要放在替换原型的语句之后。

2. 借用构造函数

其基本思想是:

在子类型构造函数的内部调用父类构造函数。通过使用apply()和call()方法在新创建的对象上执行构造函数。

基本模式:

function One(num) {
    this.numOne = num;
    this.numList = [1, 2];
}
// One原型中定义的方法对于Two的实例是不可见的
One.prototype.getNumOne = function () {
    return this.numOne;
};
function Two() {
    // 继承了One
    One.call(this, 3);
    this.numTwo = 2;
}
Two.prototype.getNumTwo = function () {
    return this.numTwo;
};
const two = new Two();
two.numList.push(3);
console.log(two.numTwo); // 2
console.log(two.numOne); // 3
console.log(two.numList); // [1, 2, 3]
two.getNumOne(); // 报错:two.getNumOne is not a function
two.getNumTwo(); // 2

const two2 = new Two();
two2.numList.push(4); 
console.log(two2.numList); // [1, 2, 4]
two2.getNumOne(); // 报错:two2.getNumOne is not a function
two2.getNumTwo(); // 2

借用构造函数实现继承的问题:

方法在构造函数中定义,无法复用。在父类型的原型中定义的方法,对子类型而言是不可见的,所有类型都只能使用构造函数模式。

所以,借用构造函数的技术也是很少单独使用的。

3. 组合继承

组合继承是将原型链和构造函数结合,发挥二者之长的一种继承模式。

其基本思想是:

使用原型链实现对原型属性和方法的继承,而通过构造函数实现对实例属性的继承。

基本模式如下:

function One(num) {
    this.numOne = num;
    this.numList = [1, 2];
}
One.prototype.getNumOne = function() {
    return this.numOne;
};
function Two(num1, num2) {
    // 第二次调用了One(),实例对象继承了One的两个属性,会屏蔽掉原型Two.prototype中的两个同名属性
    One.call(this, num1);
    this.numTwo = num2;
}
// 第一次调用了One(),继承了One的原型方法, Two.prototype会得到One的两个属性
Two.prototype = new One();
Two.prototype.constructor = Two;
Two.prototype.getNumTwo = function() {
    return this.numTwo;
}
var two1 = new Two(3, 5);
two1.numList.push(6); 
console.log(two1.numList); // [1, 2, 6]
two1.getNumOne(); // 3
two1.getNumTwo(); // 5

var two2 = new Two(4, 6);
two2.numList.push(7);
console.log(two2.numList); // [1, 2, 7]
two2.getNumOne(); // 4
two2.getNumTwo(); // 6

组合继承的特点

组合继承避免了原型链和构造函数的缺陷,融合了两者的有点,是javascript中最常用的继承模式。但是这个方式也有一个问题,那就是无论在什么情况下,都会调用两次父类型的构造函数:一次是在创建子类型原型的时候,另一次是在构造函数内部。

4. 原型式继承

其基本思想是:

必须有一个对象作为另一个对象的基础,然后把这个对象传给object函数,再根据具体需求,修改得到的对象。 object函数如下:

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

可以看出,返回的对象是构造函数F的实例,其内置对象__proto__指向F的原型对象,而F.prototype = o;即返回的对象的__proto__指向传入的对象o,到这里,是不是觉得这个思路有一丝丝的似曾相识,对了,就是Object.create()。

Object.cerate()这个方法与object函数的行为相同。这个方法接收两个参数:一个是用作新对象原型的对象,和一个为新对象定义额外属性的对象,第二个参数是可选项。

基本模式:

var person = {
    name: "MMZ",
    course: ["Math", "English", "History"]
};

var person1 = Object.create(person);
person1.course.push('Music');
console.log(person1.name); // MMZ
console.log(person1.course); // ["Math", "English", "History", "Music"]

var person2 = Object.create(person, {
    name: {
        value: "MinMin"
    }
});
person2.course.push('PE');
console.log(person2.name); // MinMin
// 对于引用类型的值的属性,会共享
console.log(person2.course); // ["Math", "English", "History", "Music", "PE"]

console.log(person.course); // ["Math", "English", "History", "Music", "PE"]

原型式继承的特点:

  1. Object.create()有兼容性问题,IE9以下不支持。
  2. 适用于没必要创建构造函数,只是想让一个对象和另一个对象保持类似的情况。
  3. 对于包含引用类型值的属性始终都会共享相应的值。

5. 寄生式继承

这种方式是与原型式继承紧密相关的一种思路。其继承的思路与寄生构造函数和工厂模式类似。

其基本思路是:

创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后再返回对象。

示例:

function createObject(origin) {
    const clone = Object.create(origin);
    clone.sayHi = function() {
        return 'Hi';
    }
    return clone;
}

const person = {
    name: "MMZ",
    course: ["Math", "English", "History"]
};

const person1 = createObject(person);
person1.sayHi(); // Hi

寄生式继承的问题

使用寄生式函数来对对象添加函数,无法做到函数的复用。

6. 组合寄生式继承

其基本思路是:

使用寄生式继承来继承父类型的原型,然后再将结果指定给子类型的原型。

基本模式:

// inheritPrototype接收两个参数:子类型的构造函数和父类型的构造函数
function inheritPrototype(child, parent) {
    // 创建父类型原型的副本
    const prototype = Object.create(parent.prototype); 
    // 为创建的副本添加constructor属性,从而弥补因重写原型而失去的默认的constructor属性
    prototype.constructor = child;
    // 将创建的副本赋值给子类型的原型
    child.prototype = prototype;
}

function One(num1) {
    this.numOne = num1;
    this.numList = [1,2];
}
One.prototype.getNumOne = function() {
    return this.numOne;
}

function Two(num1, num2) {
    One.call(this, num1)
    this.numTwo = num2;
}
inheritPrototype(Two, One);
Two.prototype.getNumTwo = function() {
    return this.numTwo;
}

var two = new Two(3, 5);
two.numList.push(6); 
console.log(two.numList); // [1, 2, 6]
two.getNumOne(); // 3
two.getNumTwo(); // 5

组合寄生式继承构造函数、原型以及实例的关系图如下:

组合寄生式的特点

组合寄生式继承,只调用了一次父类的构造函数,避免了在子类型原型对象prototype上创建不必要的、多余的属性,同时保持原型链不变,是引用类型最理想的继承范式。

ES6的继承实现方式

Class 通过extends关键字来实现继承。

示例

class One{
    constructor(name, age) {
        this.name = name;
        this.age = age;
        this.hobby = "Coding"
    }
    getOneInfo() {
        return `姓名:${this.name},年龄:${this.age}`;
    }
}
// Two这个类通过extends关键字,继承了One这个类的所有属性和方法。
class Two extends One {
    constructor(x, y, gender) {
        // 调用父类的constructor
        super(x, y);
        this.gender = gender;
    }
    getTwoInfo() {
        // 调用父类原型上的方法 getOneInfo(),父类的属性会被子类继承
        return `${super.getOneInfo()},性别:${this.gender},爱好:${this.hobby}`;
    }
}

const two = new Two("MMZ", "18", "girl");
two.getTwoInfo(); 
// "姓名:MMZ,年龄:18,性别:girl,爱好:Coding"

⚠️ 子类必须在constructor方法中调用super方法,否则新建实例时会报错,如下所示。这是因为,子类型自己的this对象,必须先通过调用父类的构造函数来塑造,得到和父类实例同样的属性和方法,然后再对其进行加工,加上子类自己的实例属性和方法。只有super方法才能调用父类实例,不调用super(),子类就没有自己的this对象。只有调用super之后,才可以使用this关键字,否则会报错。

class One {}
class Two extends One {
    // constructor中没有调用super方法
    constructor() {}
}

const two = new Two();

super关键字

super这个关键字,既可以当作函数使用,也可以当作对象使用。

  1. super作为函数调用时,代表父类的构造函数。ES6 要求,子类的构造函数必须执行一次super函数。
class A {
  constructor() {
    // 返回new命令作用于的那个构造函数
    console.log(new.target.name);
  }
}
class B extends A {
  constructor() {
    // super() 调用父类A的构造函数,但是返回的是子类B的实例,即super内部的this指的是B的实例,super()在这里相当于A.prototype.constructor.call(this)。
    super();
  }
}
// 判断B是否继承了A
Object.getPrototypeOf(B) === A; // true
// 子类的__proto__属性,总是指向父类
B.__proto__ === A; // true
// 子类prototype对象的__proto__属性,总是指向父类的prototype属性
B.prototype.__proto__ === A.prototype; // true
B.prototype.constructor === B; // true

const a = new A() // A
const b = new B() // B
// 实例的__proto__属性,指向构造函数的原型对象
a.constructor === A; // true
a.__proto__ === A.prototype; // true
b.constructor === B; // true
b.__proto__ === B.prototype; // true

作为函数时,super()只能用在子类的构造函数之中,用在其他地方就会报错。

class A {}

class B extends A {
  m() {
    super(); // 报错
  }
}

  1. super作为对象时,在普通方法中,指向父类的原型对象;在静态方法中,指向父类。
class A {
    constructor() {
        this.m = 3;
    }
    p() {
        return 2;
    }
    static q() {
        return 7;
    }
    q() {
        return 8;
    }
}
A.prototype.s = 4;

class B extends A {
    constructor() {
        super();
        // super在普通方法之中,指向A.prototype,所以super.p()就相当于A.prototype.p()。
        console.log(super.p()); // 2
        console.log(super.m); // undefined,super指向父类的原型对象,所以定义在父类实例上的方法或属性,是无法通过super调用的
        console.log(super.s); // 4,s定义在父类的原型对象上,super就可以取到。
        console.log(super.q());
    }
    getP() {
        // ES6 规定,在子类普通方法中通过super调用父类的方法时,方法内部的this指向当前的子类实例。等价于super.p.call(this)
        return super.p();
    }
    static getQ() {
        // super在静态方法之中指向父类,而不是父类的原型对象
        return super.q();
    }
    getQ() {
        // 在普通方法之中指向父类的原型对象。
        return super.q();
    }
}

let b = new B();
b.getP(); // 2
b.getQ(); // 8
B.getQ(); // 7

总结:ES5和ES6继承的区别

  • ES5 的继承,实质是先创造子类的实例对象this,然后再将父类的属性和方法添加到子类实例对象的this上面(Parent.call(this))。即“实例在前,继承在后”。

  • ES6 的继承机制完全不同,实质是先创建父类的实例对象this(所以必须先调用super方法),然后再用子类的构造函数修改this实现继承。先将父类的属性和方法,加到一个空的对象上面,然后再将该对象作为子类的实例,即“继承在前,实例在后”。这就是为什么 ES6 的继承必须先调用super()方法,因为这一步会生成一个继承父类的this对象,没有这一步就无法继承父类。

  • 大多数浏览器的 ES5 实现之中,每一个对象都有__proto__属性,指向对应的构造函数的prototype属性。

  • Class 作为构造函数的语法糖,同时有prototype属性和__proto__属性,因此同时存在两条继承链。

    1. 子类的__proto__属性,表示构造函数的继承,总是指向父类。
    2. 子类prototype属性的__proto__属性,表示方法的继承,总是指向父类的prototype属性。