跳到主要内容

JavaScript 组合继承

在JavaScript面向对象编程中,继承是一个核心概念。组合继承是JavaScript中最常用的继承模式之一,它结合了原型链继承和构造函数继承的优点,是一种既能够保持实例属性的独立,又能共享方法的继承模式。

什么是组合继承?

组合继承,也被称为伪经典继承,指的是将原型链继承和构造函数继承结合在一起的继承方式。它使用原型链实现对原型属性和方法的继承,而通过构造函数实现对实例属性的继承。

这种方式的核心思想是:

  1. 使用父类构造函数来初始化子类实例的属性
  2. 将父类的实例赋值给子类的原型,以实现方法的共享

为什么需要组合继承?

在学习组合继承之前,我们先回顾一下原型链继承和构造函数继承的优缺点:

原型链继承

优点: 方法可以复用(定义在原型上的方法被所有实例共享) 缺点:

  • 引用类型的属性被所有实例共享
  • 无法向父类构造函数传参

构造函数继承

优点:

  • 可以向父类构造函数传参
  • 每个实例有自己的属性副本

缺点:

  • 方法都在构造函数中定义,无法复用
  • 子类无法访问父类原型上定义的方法

组合继承正是为了解决这些问题而出现的,它结合了两者的优点,规避了它们的缺点。

组合继承的实现方法

让我们通过一个例子来学习组合继承:

javascript
// 父类构造函数
function Animal(name) {
this.name = name;
this.foods = ['meat', 'grass'];
}

// 在父类原型上定义方法
Animal.prototype.eat = function() {
console.log(`${this.name} is eating ${this.foods}`);
};

// 子类构造函数
function Dog(name, breed) {
// 调用父类构造函数,继承属性
Animal.call(this, name); // 第一次调用父类构造函数
this.breed = breed;
}

// 设置原型链,继承方法
Dog.prototype = new Animal(); // 第二次调用父类构造函数
// 修复constructor指向
Dog.prototype.constructor = Dog;

// 子类新增方法
Dog.prototype.bark = function() {
console.log(`${this.name} (${this.breed}) is barking!`);
};

// 创建子类实例
const dog1 = new Dog('Bobby', 'Labrador');
const dog2 = new Dog('Max', 'German Shepherd');

// 测试实例的独立性
dog1.foods.push('bone');
console.log(dog1.foods); // ['meat', 'grass', 'bone']
console.log(dog2.foods); // ['meat', 'grass'] - 不受影响

// 测试继承的方法
dog1.eat(); // Bobby is eating meat,grass,bone
dog1.bark(); // Bobby (Labrador) is barking!

运行结果:

['meat', 'grass', 'bone']
['meat', 'grass']
Bobby is eating meat,grass,bone
Bobby (Labrador) is barking!

组合继承的执行步骤解析

在上面的例子中,组合继承的实现包含以下关键步骤:

  1. 构造函数继承部分: 在子类构造函数中,通过 Animal.call(this, name) 调用父类构造函数,并传入子类实例作为 this 上下文。这确保了父类中定义的实例属性(如namefoods)被复制到子类实例上。

  2. 原型链继承部分: 通过 Dog.prototype = new Animal() 将子类的原型设置为父类的一个实例,从而建立了原型链。这使得所有 Dog 的实例都能访问 Animal 原型上的方法(如 eat 方法)。

  3. 修复构造函数指向: 设置 Dog.prototype.constructor = Dog 来修复子类原型的 constructor 属性,使其正确指向子类构造函数。

警告

注意,组合继承会导致父类构造函数被调用两次:

  1. 第一次是在子类构造函数内部通过 Animal.call(this, name)
  2. 第二次是在设置子类原型时通过 new Animal()

组合继承的优缺点

优点

  1. 每个实例都有自己的属性:通过构造函数继承确保每个实例都有自己的属性副本
  2. 方法可复用:通过原型链继承实现方法的共享
  3. 子类可以向父类构造函数传递参数
  4. instanceof 和 isPrototypeOf() 方法可用于识别实例

缺点

  1. 父类构造函数被调用两次:这导致了一些性能浪费
  2. 子类原型上会存在父类实例的冗余属性:虽然这些属性会被子类实例上的同名属性覆盖,但仍然存在于原型中

实际应用场景

组合继承在实际开发中非常有用,特别是在需要创建多个相似但又有区别的对象类型时。以下是一个电商系统中商品继承的例子:

javascript
// 基础商品类
function Product(id, name, price) {
this.id = id;
this.name = name;
this.price = price;
this.tags = [];
}

Product.prototype.display = function() {
return `${this.name} - $${this.price}`;
};

Product.prototype.addTag = function(tag) {
this.tags.push(tag);
};

// 电子产品子类
function ElectronicProduct(id, name, price, warranty) {
// 继承属性
Product.call(this, id, name, price);
this.warranty = warranty; // 保修期(月)
}

// 继承方法
ElectronicProduct.prototype = new Product();
ElectronicProduct.prototype.constructor = ElectronicProduct;

// 添加子类特有方法
ElectronicProduct.prototype.displayWithWarranty = function() {
return `${this.display()} (Warranty: ${this.warranty} months)`;
};

// 服装产品子类
function ClothingProduct(id, name, price, size, color) {
// 继承属性
Product.call(this, id, name, price);
this.size = size;
this.color = color;
}

// 继承方法
ClothingProduct.prototype = new Product();
ClothingProduct.prototype.constructor = ClothingProduct;

// 添加子类特有方法
ClothingProduct.prototype.displayWithDetails = function() {
return `${this.display()} (Size: ${this.size}, Color: ${this.color})`;
};

// 使用示例
const laptop = new ElectronicProduct(1, 'MacBook Pro', 1299, 12);
laptop.addTag('computer');
laptop.addTag('apple');

const shirt = new ClothingProduct(2, 'Cotton T-shirt', 29.99, 'M', 'Blue');
shirt.addTag('summer');

console.log(laptop.displayWithWarranty());
console.log(laptop.tags);
console.log(shirt.displayWithDetails());
console.log(shirt.tags);

运行结果:

MacBook Pro - $1299 (Warranty: 12 months)
['computer', 'apple']
Cotton T-shirt - $29.99 (Size: M, Color: Blue)
['summer']

在这个电商系统示例中,我们使用组合继承创建了不同类型的商品,它们共享基础商品的属性和方法,同时又拥有各自特有的属性和方法。

改进:寄生组合继承

虽然组合继承是JavaScript中最常用的继承模式,但它并不完美。为了解决组合继承调用两次父类构造函数的问题,开发者提出了一种更优化的方法——寄生组合继承:

javascript
function inheritPrototype(subType, superType) {
// 创建父类原型的一个副本
const prototype = Object.create(superType.prototype);
// 修复constructor指向
prototype.constructor = subType;
// 设置子类原型
subType.prototype = prototype;
}

function Animal(name) {
this.name = name;
this.foods = ['meat', 'grass'];
}

Animal.prototype.eat = function() {
console.log(`${this.name} is eating ${this.foods}`);
};

function Dog(name, breed) {
Animal.call(this, name);
this.breed = breed;
}

// 使用寄生组合继承
inheritPrototype(Dog, Animal);

Dog.prototype.bark = function() {
console.log(`${this.name} (${this.breed}) is barking!`);
};

// 测试
const dog = new Dog('Charlie', 'Beagle');
dog.eat();
dog.bark();

寄生组合继承避免了在子类原型上创建不必要的、多余的属性,同时还能保持原型链不变。这是目前JavaScript中最理想的继承范式。

提示

在现代JavaScript中,我们可以使用ES6的类语法(classextends)来实现继承,它的背后使用的正是寄生组合继承模式。

总结

组合继承是JavaScript中一种强大且常用的继承模式,它结合了原型链继承和构造函数继承的优点:

  • 通过构造函数继承实现实例属性的独立
  • 通过原型链继承实现方法的共享

尽管组合继承有调用两次父类构造函数的缺点,但它仍然是学习JavaScript面向对象编程的重要概念。在实际开发中,可以考虑使用寄生组合继承或ES6类语法来优化继承实现。

练习

  1. 创建一个Vehicle基类,包含brandyear属性,以及start()stop()方法。
  2. 使用组合继承创建CarMotorcycle子类,增加适合各自的特有属性和方法。
  3. 尝试将你的代码重构为使用寄生组合继承模式。
  4. 最后,使用ES6的类语法重写整个继承结构。

进一步阅读

  • MDN上的继承与原型链文档
  • JavaScript高级程序设计(第4版)中的继承章节
  • 探索ES6的类语法与传统原型继承的异同