JavaScript 组合继承
在JavaScript面向对象编程中,继承是一个核心概念。组合继承是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!
组合继承的执行步骤解析
在上面的例子中,组合继承的实现包含以下关键步骤:
-
构造函数继承部分: 在子类构造函数中,通过
Animal.call(this, name)
调用父类构造函数,并传入子类实例作为this
上下文。这确保了父类中定义的实例属性(如name
和foods
)被复制到子类实例上。 -
原型链继承部分: 通过
Dog.prototype = new Animal()
将子类的原型设置为父类的一个实例,从而建立了原型链。这使得所有Dog
的实例都能访问Animal
原型上的方法(如eat
方法)。 -
修复构造函数指向: 设置
Dog.prototype.constructor = Dog
来修复子类原型的constructor
属性,使其正确指向子类构造函数。
注意,组合继承会导致父类构造函数被调用两次:
- 第一次是在子类构造函数内部通过
Animal.call(this, name)
- 第二次是在设置子类原型时通过
new Animal()
组合继承的优缺点
优点
- 每个实例都有自己的属性:通过构造函数继承确保每个实例都有自己的属性副本
- 方法可复用:通过原型链继承实现方法的共享
- 子类可以向父类构造函数传递参数
- instanceof 和 isPrototypeOf() 方法可用于识别实例
缺点
- 父类构造函数被调用两次:这导致了一些性能浪费
- 子类原型上会存在父类实例的冗余属性:虽然这些属性会被子类实例上的同名属性覆盖,但仍然存在于原型中
实际应用场景
组合继承在实际开发中非常有用,特别是在需要创建多个相似但又有区别的对象类型时。以下是一个电商系统中商品继承的例子:
// 基础商品类
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中最常用的继承模式,但它并不完美。为了解决组合继承调用两次父类构造函数的问题,开发者提出了一种更优化的方法——寄生组合继承:
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的类语法(class
和extends
)来实现继承,它的背后使用的正是寄生组合继承模式。
总结
组合继承是JavaScript中一种强大且常用的继承模式,它结合了原型链继承和构造函数继承的优点:
- 通过构造函数继承实现实例属性的独立
- 通过原型链继承实现方法的共享
尽管组合继承有调用两次父类构造函数的缺点,但它仍然是学习JavaScript面向对象编程的重要概念。在实际开发中,可以考虑使用寄生组合继承或ES6类语法来优化继承实现。
练习
- 创建一个
Vehicle
基类,包含brand
和year
属性,以及start()
和stop()
方法。 - 使用组合继承创建
Car
和Motorcycle
子类,增加适合各自的特有属性和方法。 - 尝试将你的代码重构为使用寄生组合继承模式。
- 最后,使用ES6的类语法重写整个继承结构。
进一步阅读
- MDN上的继承与原型链文档
- JavaScript高级程序设计(第4版)中的继承章节
- 探索ES6的类语法与传统原型继承的异同