JavaScript继承方式详解

33 阅读14分钟

JavaScript继承方式详解

在JavaScript中,继承是面向对象编程的核心概念之一,它允许我们创建具有父类特性的新对象,实现代码复用和对象关系的构建 。尽管JavaScript是一种基于原型的语言,而非传统意义上的类式语言,但它提供了多种实现继承的方式。本文将详细讲解JavaScript中的几种主要继承方式,包括构造函数绑定继承、prototype模式继承以及组合继承,并通过代码示例和优缺点分析,帮助读者深入理解JavaScript的继承机制。

一、JavaScript的原型链机制

JavaScript的继承机制基于原型链,这是理解所有继承方式的基础 。在JavaScript中,每个对象都有一个内部属性[[Prototype]],指向其原型对象。原型对象本身也是一个对象,同样有自己的[[Prototype]],依此类推,直到原型是null的对象。这种链式结构被称为原型链。

当访问一个对象的属性时,JavaScript引擎会先在该对象自身查找,如果找不到,就会沿着原型链向上查找,直到找到或到达链尾(null) 。例如:

function Animal() {}
Animal.prototype物种 = '动物';

function Cat(name) {
  this.name = name;
}
Cat.prototype = new Animal(); // 原型链继承

const cat = new Cat('小黑');
console.log(cat.物种); // 输出 '动物'
console.log(cat.name); // 输出 '小黑'

在这个例子中,Cat的原型指向Animal的实例,因此cat实例可以访问Animal原型上的物种属性。

原型链的动态性是JavaScript的特性之一,可以运行时修改原型链的任何成员,甚至是换掉原型 。例如:

cat.__proto__物种 = '猫'; // 修改原型链上的属性
console.log(cat.物种); // 输出 '猫'

然而,需要注意的是,__proto__是非标准的访问器,虽然大多数JavaScript引擎实现了它,但更推荐使用Object.getPrototypeOf()Object.setPrototypeOf()函数来访问和修改原型链 。

二、构造函数绑定继承(call/apply方法)

构造函数绑定继承,也被称为借用构造函数继承或经典继承,是JavaScript中最基础的继承方式之一 。其核心思想是在子类构造函数中调用父类构造函数,将父类的属性直接绑定到子类实例上。

2.1 实现原理

构造函数绑定继承通过使用call()apply()方法,在子类构造函数中以子类实例为上下文执行父类构造函数 。这样,父类构造函数中的属性会被直接添加到子类实例上,而不是通过原型链继承。

function Animal(物种) {
  this.物种 = 物种 || '动物';
}

function Cat(name, color) {
  // 使用call方法绑定父类构造函数
  Animal.call(this, '猫');
  this.name = name;
  this.color = color;
}

const cat1 = new Cat('小黑', '黑色');
console.log(cat1.物种); // 输出 '猫'
console.log(cat1.name); // 输出 '小黑'

2.2 优点

  1. 避免引用属性共享:构造函数绑定继承将父类的属性直接添加到子类实例上,而非原型链上,因此引用类型的属性不会被所有实例共享。

  2. 支持传递参数:可以在子类构造函数中调用父类构造函数,并传递参数,实现更灵活的初始化。

  3. 简单直观:实现逻辑简单,易于理解。

2.3 缺点

  1. 无法继承原型方法:构造函数绑定继承只能继承父类构造函数中的属性,无法继承父类原型上的方法 。

  2. 方法重复:如果父类有原型方法,每个子类实例都需要单独定义这些方法,导致内存浪费。

  3. 缺乏共性:所有子类实例的方法都是独立的,无法共享,违背了面向对象编程的复用原则。

2.4 适用场景

构造函数绑定继承适用于以下场景:

  • 父类只有实例属性,没有原型方法。

  • 子类需要完全独立于父类,不共享任何方法。

  • 简单的继承需求,不需要复杂的原型链。

然而,由于无法继承原型方法的缺点,构造函数绑定继承很少单独使用,通常需要与其他继承方式结合。

三、prototype模式继承

prototype模式继承是另一种基本的继承方式,它通过将子类的原型指向父类的实例来实现继承 。这种方式可以继承父类的原型方法,但存在引用属性共享的问题。

3.1 基本实现

function Animal() {
  this.物种 = '动物'; // 在构造函数中定义属性
}

Animal.prototype.获取物种 = function() {
  return this.物种;
};

function Cat(name, color) {
  this.name = name;
  this.color = color;
}

// 将Cat的原型指向Animal的实例
Cat.prototype = new Animal();

// 修复 constructor 属性
Cat.prototype.constructor = Cat;

const cat1 = new Cat('小黑', '黑色');
console.log(cat1.获取物种()); // 输出 '动物'
console.log(cat1物种); // 输出 '动物'

3.2 优点

  1. 方法复用:父类原型上的方法可以被所有子类实例共享,节省内存。

  2. 动态继承:子类可以动态地添加、修改或删除原型方法,不影响现有实例。

  3. 支持多态:子类可以重写父类的原型方法,实现多态。

3.3 缺点

  1. 引用属性共享:在父类构造函数中定义的引用类型属性(如数组、对象)会被所有子类实例共享,修改一个实例会影响其他实例 。

  2. 无法传递参数:父类构造函数在子类原型设置时被调用,无法向父类构造函数传递参数。

  3. 构造函数指针丢失:将子类原型指向父类实例后,子类实例的constructor属性会指向父类,而非子类本身 。

3.4 修复步骤

为了解决以上问题,通常需要进行以下修复:

  1. 修复引用属性共享:将父类中需要独立的属性移到构造函数中,而非原型上。

  2. 修复构造函数指针:手动设置子类原型的constructor属性指向子类本身。

function Animal() {
  this.物种 = '动物'; // 将属性移到构造函数中
}

Animal.prototype.获取物种 = function() {
  return this.物种;
};

function Cat(name, color) {
  this.name = name;
  this.color = color;
}

Cat.prototype = new Animal(); // 原型链继承

// 修复 constructor 属性
Cat.prototype.constructor = Cat;

const cat1 = new Cat('小黑', '黑色');
const cat2 = new Cat('小白', '白色');

// 测试引用属性共享
cat1.物种 = '家猫';
console.log(cat2.物种); // 输出 '动物',因为物种是实例属性

3.5 原型链的工作原理

原型链的工作原理可以理解为:当访问一个对象的属性时,首先在对象自身查找,如果找不到,就去它的原型对象查找,依此类推,直到找到或到达链尾(null) 。

console.log(Cat.prototype === cat1.__proto__); // true
console.log(Animal.prototype === Cat.prototype.__proto__); // true
console.log(Object.prototype === Animal.prototype.__proto__); // true
console.log(Cat.prototype constructor === Cat); // true
console.log(Cat.prototype constructor === Animal); // false

这种链式结构使得子类实例可以访问父类原型上的方法,但无法直接访问父类构造函数中的属性,除非通过构造函数绑定继承。

四、组合继承(最常用的继承方式)

组合继承结合了构造函数绑定继承和prototype模式继承的优点,是JavaScript中最常用的继承方式 。它解决了引用属性共享的问题,同时支持传递参数,并能够继承父类的原型方法。

4.1 实现原理

组合继承通过以下两个步骤实现:

  1. 构造函数绑定继承:在子类构造函数中调用父类构造函数,传递参数,确保实例属性独立。

  2. 原型链继承:将子类原型指向父类实例,继承父类原型方法。

function Animal(物种) {
  this.物种 = 物种 || '动物';
}

Animal.prototype.获取物种 = function() {
  return this.物种;
};

function Cat(name, color) {
  // 构造函数绑定继承
  Animal.call(this, '猫');
  this.name = name;
  this.color = color;
}

// 原型链继承
Cat.prototype = new Animal();

// 修复 constructor 属性
Cat.prototype.constructor = Cat;

const cat1 = new Cat('小黑', '黑色');
const cat2 = new Cat('小白', '白色');

// 测试引用属性共享
cat1.物种 = '家猫';
console.log(cat1.物种); // 输出 '家猫'(实例属性)
console.log(cat2.物种); // 输出 '猫'(构造函数绑定继承的属性)

// 测试原型方法
console.log(cat1.获取物种()); // 输出 '家猫'
console.log(cat2.获取物种()); // 输出 '小白'

4.2 优点

  1. 属性独立:父类在构造函数中定义的属性会被添加到子类实例上,每个实例都有自己的属性副本,避免引用共享问题 。

  2. 方法复用:父类原型上的方法可以被所有子类实例共享,节省内存。

  3. 支持传递参数:可以在子类构造函数中调用父类构造函数,并传递参数。

  4. 修复 constructor 指针:手动设置子类原型的constructor属性,避免指向父类。

4.3 缺点

  1. 父类构造函数重复调用:父类构造函数会被调用两次(一次在子类实例创建时,一次在子类原型设置时),可能导致冗余初始化。

  2. 属性遮蔽:如果父类在构造函数和原型上都定义了同名属性,子类实例的属性会遮蔽原型属性,可能导致意外行为 。

4.4 示例代码分析

用户提供的代码示例:

function Animal() {
  this.物种 = '动物'; // 在构造函数中定义属性
}

function Cat(name, color) {
  // 使用call方法绑定父类构造函数
  Animal.call(this);
  this.name = name;
  this.color = color;
}

// 设置Cat的原型指向Animal的实例
Cat.prototype = new Animal();

// 修复 constructor 属性
Cat.prototype constructor = Cat;

const cat = new Cat('小黑', '黑色');
console.log(cat.物种); // 输出 '动物'
console.log(Animal.prototype constructor); // 输出 Animal
console.log(Cat.prototype constructor); // 输出 Cat

在这个例子中,Cat的实例通过Animal.call(this)继承了父类的物种属性,同时通过Cat.prototype = new Animal()继承了父类的原型方法。最后通过Cat.prototype constructor = Cat修复了constructor属性的指向。

然而,这个实现存在一个问题:父类Animal的构造函数被调用了两次,一次在Cat的构造函数中,一次在设置Cat的原型时。这可能导致不必要的初始化,尤其是当父类构造函数有副作用时。

4.5 寄生组合继承优化

为了解决父类构造函数重复调用的问题,可以使用寄生组合继承进行优化 :

function inheritPrototype(子类, 父类) {
  const 原型 = Object.create(父类.prototype);
  原型 constructor = 子类;
  子类.prototype = 原型;
}

function Animal(物种) {
  this.物种 = 物种 || '动物';
}

Animal.prototype.获取物种 = function() {
  return this.物种;
};

function Cat(name, color) {
  Animal.call(this, '猫');
  this.name = name;
  this.color = color;
}

// 使用寄生组合继承优化
inheritPrototype(Cat, Animal);

const cat1 = new Cat('小黑', '黑色');
const cat2 = new Cat('小白', '白色');

// 测试引用属性共享
cat1.物种 = '家猫';
console.log(cat1.物种); // 输出 '家猫'
console.log(cat2.物种); // 输出 '猫'

// 测试原型方法
console.log(cat1.获取物种()); // 输出 '家猫'
console.log(cat2.获取物种()); // 输出 '小白'

通过使用Object.create(父类.prototype),可以创建父类原型的一个副本,避免调用父类构造函数,从而解决重复调用的问题。

五、ES6 class继承

ES6引入了class语法,简化了JavaScript的继承机制 。它本质上是原型链继承的语法糖,但提供了更接近传统面向对象语言的语法。

5.1 基本语法

class Animal {
  constructor(物种) {
    this.物种 = 物种 || '动物';
  }

  获取物种() {
    return this.物种;
  }
}

class Cat extends Animal {
  constructor(name, color) {
    // 必须先调用 super()
    super('猫');
    this.name = name;
    this.color = color;
  }

  获取信息() {
    return `名字:${this.name},颜色:${this.color},物种:${this.物种}`;
  }
}

const cat = new Cat('小黑', '黑色');
console.log(cat.获取物种()); // 输出 '猫'
console.log(cat.获取信息()); // 输出 '名字:小黑,颜色:黑色,物种:猫'

5.2 特点

  1. 语法简洁:提供了接近Java、C++等传统面向对象语言的语法,更易于理解。

  2. 自动修复 constructor 指针:不需要手动修复constructor属性,子类原型的constructor会自动指向子类本身。

  3. 支持 super 关键字:可以在子类构造函数和方法中使用super()调用父类构造函数,使用super.方法名()调用父类方法。

  4. 支持静态方法:可以通过static关键字定义静态方法,这些方法直接属于类本身,而非实例。

  5. 支持继承内置对象:可以继承内置对象(如ArrayError等),扩展其功能。

5.3 底层实现

ES6的class继承本质上是寄生组合继承的语法糖 。当使用extends关键字时,JavaScript引擎会自动执行以下操作:

  1. 创建父类原型的一个副本。

  2. 将子类原型指向这个副本。

  3. 设置子类原型的constructor指向子类本身。

  4. 在子类构造函数中自动调用super()(如果未显式调用)。

因此,ES6的class继承避免了父类构造函数重复调用的问题,是目前JavaScript中最推荐的继承方式。

六、继承方式的比较与选择

以下是JavaScript几种主要继承方式的比较:

继承方式实例属性原型方法参数传递constructor 指针内存效率适用场景
原型链继承原型原型不支持需要手动修复学习原型链原理,已不推荐生产使用
构造函数绑定继承实例不支持支持需要手动修复简单继承,父类只有实例属性
prototype模式继承原型原型不支持需要手动修复需要继承原型方法,但属性不需要共享
组合继承实例原型支持需要手动修复最常用的继承方式,平衡了属性独立和方法复用
寄生组合继承实例原型支持自动修复优化后的组合继承,避免父类构造函数重复调用
ES6 class继承实例原型支持自动修复现代JavaScript项目的默认选择

根据不同的需求和场景,可以选择合适的继承方式:

  • 简单继承:如果只需要继承实例属性,没有原型方法,可以选择构造函数绑定继承。

  • 方法共享:如果需要共享原型方法,可以选择prototype模式继承或组合继承。

  • 全面继承:如果需要同时继承实例属性和原型方法,并且希望语法简洁,可以选择ES6 class继承。

  • 性能优化:如果需要避免父类构造函数重复调用,可以选择寄生组合继承。

七、继承的常见问题与解决方案

在使用JavaScript继承时,可能会遇到以下常见问题:

7.1 引用属性共享问题

在原型链继承中,父类原型上的引用类型属性会被所有子类实例共享,修改一个实例会影响其他实例 。

解决方案:将父类中需要独立的属性移到构造函数中,而非原型上。

7.2 constructor 指针丢失问题

将子类原型指向父类实例后,子类实例的constructor属性会指向父类,而非子类本身 。

解决方案:手动设置子类原型的constructor属性指向子类。

7.3 父类构造函数重复调用问题

在组合继承中,父类构造函数会被调用两次,可能导致冗余初始化 。

解决方案:使用寄生组合继承优化,避免调用父类构造函数。

7.4 属性遮蔽问题

如果父类在构造函数和原型上都定义了同名属性,子类实例的属性会遮蔽原型属性,可能导致意外行为 。

解决方案:避免在构造函数和原型上定义同名属性,或者明确使用this.属性名 = 值来覆盖原型属性。

八、总结与最佳实践

JavaScript的继承机制虽然复杂,但通过理解原型链、构造函数绑定继承和prototype模式继承的基本原理,可以灵活运用组合继承或ES6 class继承来实现全面的继承。

最佳实践

  1. 优先使用ES6 class继承:语法简洁,功能完善,是现代JavaScript项目的默认选择。

  2. 理解继承的本质:无论使用哪种语法,JavaScript的继承本质上都是基于原型链的。

  3. 合理设计属性和方法:将需要独立的属性定义在构造函数中,将可以共享的方法定义在原型上。

  4. 避免过度继承:继承虽然强大,但可能导致代码复杂度增加,应谨慎使用。

  5. 测试继承关系:使用instanceofconstructor属性测试继承关系,确保继承正确。

通过掌握这些继承方式和最佳实践,可以更有效地利用JavaScript的面向对象特性,构建复杂的应用程序。