Published on

JavaScript 实现继承的多种方式

Authors
  • avatar
    Name
    青雲
    Twitter

继承是面向对象编程中的一个核心概念,通过继承,可以让一个类继承另一个类的属性和方法,在不重新编写相同代码的情况下进行功能扩展。JavaScript 作为一种基于原型的动态语言,提供了多种实现继承的方式。本文将详细介绍几种常见的继承方式,并讨论它们的优缺点。

原型链继承

实现方式

原型链继承是通过将子类的原型设置为父类的实例来实现的。这意味着子类的实例可以访问父类的属性和方法。

function Parent() {
  this.parentProperty = 'Parent Property';
}

Parent.prototype.parentMethod = function() {
  console.log('This is a parent method');
};

function Child() {
  this.childProperty = 'Child Property';
}

// 原型继承
Child.prototype = new Parent();
Child.prototype.constructor = Child;

const childInstance = new Child();
console.log(childInstance.parentProperty);  // 输出: Parent Property
childInstance.parentMethod();  // 输出: This is a parent method

优点

  • 简单易懂的实现方式。
  • 子类实例可以共享父类实例的属性和方法。

缺点

  • 所有子类实例共享父类实例的引用类型属性,可能导致修改一个实例中的引用类型属性会影响其他实例。
  • 无法在子类构造函数中向父类构造函数传递参数。

借用构造函数继承

实现方式

借用构造函数继承通过在子类构造函数中调用父类构造函数来实现。这种方式避免了原型链继承的引用类型共享问题。

function Parent(name) {
  this.name = name;
  this.parentProperty = 'Parent Property';
}

function Child(name, age) {
  Parent.call(this, name);  // 借用构造函数
  this.age = age;
}

const childInstance = new Child('Alice', 10);
console.log(childInstance.name);  // 输出: Alice
console.log(childInstance.age);   // 输出: 10
console.log(childInstance.parentProperty);  // 输出: Parent Property

优点

  • 每个子类实例拥有自己的父类属性副本,避免了引用类型共享问题。
  • 可以在子类构造函数中向父类构造函数传递参数。

缺点

  • 父类原型上的方法不会被子类继承,这是因为借用构造函数继承仅在构造函数内部进行,不涉及原型链。

组合式继承

实现方式

组合式继承结合了原型链继承和借用构造函数继承的优点,使用借用构造函数继承实例属性,使用原型链继承方法。

function Parent(name) {
  this.name = name;
  this.parentProperty = 'Parent Property';
}

Parent.prototype.parentMethod = function() {
  console.log('This is a parent method');
};

function Child(name, age) {
  Parent.call(this, name);  // 借用构造函数继承实例属性
  this.age = age;
}

// 原型链继承方法
Child.prototype = new Parent();
Child.prototype.constructor = Child;

const childInstance = new Child('Alice', 10);
console.log(childInstance.name);  // 输出: Alice
console.log(childInstance.age);   // 输出: 10
console.log(childInstance.parentProperty);  // 输出: Parent Property
childInstance.parentMethod();  // 输出: This is a parent method

优点

  • 每个子类实例有自己的父类属性副本,避免了引用类型共享问题。
  • 子类可以继承父类原型上的方法。

缺点

  • 父类构造函数会被调用两次,第一次是在 Child.prototype 被赋值为 new Parent() 时,第二次是在 Parent.call(this, name) 时。这会造成冗余的性能开销。

原型式继承

实现方式

原型式继承是创建一个全新的对象,并将这个新对象的原型指向一个已存在的对象。

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

const parentObject = {
  parentProperty: 'Parent Property',
  parentMethod: function() {
    console.log('This is a parent method');
  }
};

const childObject = createObject(parentObject);
console.log(childObject.parentProperty);  // 输出: Parent Property
childObject.parentMethod();  // 输出: This is a parent method

优点

  • 实现简单,创建新对象并直接指定其原型。

缺点

  • 所有子对象共享原型上的引用类型属性,可能导致改动一个子对象中的引用类型属性会影响其他子对象。

寄生式继承

实现方式

寄生式继承是对原型式继承的一种拓展,通过创建一个封装对象并对其扩展。

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

function createChildObject(parent) {
  const child = createObject(parent);
  child.childProperty = 'Child Property';
  return child;
}

const parentObject = {
  parentProperty: 'Parent Property',
  parentMethod: function() {
    console.log('This is a parent method');
  }
};

const childObject = createChildObject(parentObject);
console.log(childObject.parentProperty);  // 输出: Parent Property
console.log(childObject.childProperty);   // 输出: Child Property
childObject.parentMethod();  // 输出: This is a parent method

优点

  • 可以直接扩展对象,简单直观。

缺点

  • 与原型式继承一样,共享原型上的引用类型属性。

寄生组合式继承

实现方式

寄生组合式继承是组合式继承的优化版本,不会多次调用父类构造函数。

function Parent(name) {
  this.name = name;
  this.parentProperty = 'Parent Property';
}

Parent.prototype.parentMethod = function() {
  console.log('This is a parent method');
};

function Child(name, age) {
  Parent.call(this, name);  // 借用构造函数继承实例属性
  this.age = age;
}

// 优化版:只继承父类的原型
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

const childInstance = new Child('Alice', 10);
console.log(childInstance.name);  // 输出: Alice
console.log(childInstance.age);   // 输出: 10
console.log(childInstance.parentProperty);  // 输出: Parent Property
childInstance.parentMethod();  // 输出: This is a parent method

优点

  • 解决了组合式继承多次调用父类构造函数的问题。
  • 子类可以继承父类原型上的方法。
  • 每个子类实例有自己的父类属性副本,避免了引用类型共享问题。

缺点

  • 相对其他方法有一些复杂性,但优点显著。

ES6 class 继承

实现方式

ES6 引入了类(Class)语法糖,使得继承变得更加简洁和易读。

class Parent {
  constructor(name) {
    this.name = name;
    this.parentProperty = 'Parent Property';
  }

  parentMethod() {
    console.log('This is a parent method');
  }
}

class Child extends Parent {
  constructor(name, age) {
    super(name);  // 调用父类构造函数
    this.age = age;
  }

  childMethod() {
    console.log('This is a child method');
  }
}

const childInstance = new Child('Alice', 10);
console.log(childInstance.name);  // 输出: Alice
console.log(childInstance.age);   // 输出: 10
console.log(childInstance.parentProperty);  // 输出: Parent Property
childInstance.parentMethod();  // 输出: This is a parent method
childInstance.childMethod();  // 输出: This is a child method

优点

  • 语法简洁易懂,类似于其他面向对象语言。
  • 使用 super 关键字调用父类构造函数和父类方法。
  • 不存在组合式继承多次调用父类构造函数的问题。

缺点

  • 本质上仍是基于原型链工作,依赖较新的 JavaScript 引擎支持。
  • 有些兼容性问题存在于较老的浏览器中,但可以通过 Babel 等工具编译为 ES5 代码。

面试实战

题目一:描述JavaScript的原型继承是如何工作的。

答案:在JavaScript中,每个对象都有一个指向其原型对象的内部链接。当试图访问一个对象的属性时,如果该对象本身没有这个属性,解释器就会在其原型对象中查找这个属性,这个原型对象自己也可能有原型,以此类推,这样就形成了一个原型链。如果属性最终都没有被找到,那么其值就是undefined。这种继承机制称为原型继承。

题目二:用构造函数创建对象有什么弊端?

答案:使用构造函数创建对象的弊端包括:

  • 每个方法都会在每个实例上重新创建一次,导致资源浪费。
  • 方法是无法共享的,不能利用原型链的优势。
  • 继承实现起来较复杂,需要操作原型和构造函数,代码可读性较差。

题目三:描述如何使用函数和原型来模拟经典的类继承。

答案:在JavaScript中,类继承通常是通过构造函数和原型链模拟的,具体步骤如下:

  1. 创建一个构造函数,定义作为实例基础的属性。
  2. 为该构造函数的原型对象添加方法,供所有实例共享。
  3. 创建另一个构造函数,准备作为子类。
  4. 将子类的原型设置为父类的实例来实现继承。
  5. 必要时调整子类原型的构造器指向,确保实例化时能正确识别类型。
function Parent(name) {
    this.name = name;
}

Parent.prototype.sayName = function () {
    console.log(this.name);
};

function Child(name, age) {
    Parent.call(this, name);
    this.age = age;
}

Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

Child.prototype.sayAge = function () {
    console.log(this.age);
};

题目四:ES6类的继承是如何工作的?

答案:ES6引入了class和extends关键字来实现继承。在内部,它们仍然使用原型链,但提供了更清晰的语法和模型。继承可以用下面的代码简单地实现:

class Parent {
    constructor(name) {
        this.name = name;
    }

    sayName() {
        console.log(this.name);
    }
}

class Child extends Parent {
    constructor(name, age) {
        super(name); // 调用父类的constructor
        this.age = age;
    }

    sayAge() {
        console.log(this.age);
    }
}

题目五:解释一下在JavaScript中实现多重继承的可能性及其潜在问题。

答案:JavaScript自身不直接支持多重继承,因为每个对象只能有一个原型,但可以间接实现混入(mixin)模式,在混入模式中,一个对象可以获得(继承)多个对象的属性和方法。这可以通过扩展多个对象并将它们的属性和方法复制到一个新对象中来实现。然而,多重继承或混入可能导致命名冲突和不确定的继承顺序,影响代码的可维护性。

题目六:如何在不使用构造函数的情况下实现继承?

答案:在不使用构造函数的情况下实现继承,可以使用 Object.create() 方法。这种方法直接创建了一个新对象,并将其内部的[[Prototype]]链接到指定的对象,实现了基于原型的继承。

const parent = {
    sayName: function () {
        console.log(this.name);
    }
};

const child = Object.create(parent);
child.name = 'Child';
child.sayName(); // 输出 'Child'