引言
继承是面向对象编程中的重要概念,它允许对象获取另一个对象的属性和方法。在JavaScript中,继承的实现方式与其他传统面向对象语言有所不同,主要基于原型链机制。本文将全面介绍JavaScript中的各种继承方式,分析它们的优缺点,并探讨面向对象设计中的一些深层思考。
1. JavaScript继承的基本方式
1.1 借助call实现继承(构造函数继承)
function Parent1() {
this.name = 'parent1';
}
function Child1() {
Parent1.call(this); // 关键步骤
this.type = 'child1';
}
console.log(new Child1());
不懂call
的可以参考:手撕JavaScript的call方法:深入理解this绑定与函数调用
优点:
- 简单直接,子类实例不会共享父类引用属性
- 可以向父类构造函数传参
缺点:
- 无法继承父类原型上的方法和属性
- 每次创建实例都要调用父类构造函数,影响性能
1.2 借助原型链实现继承
function Parent2() {
this.name = 'parent2';
this.play = [1, 2, 3];
}
function Child2() {
this.type = 'child2';
}
Child2.prototype = new Parent2(); // 关键步骤
var s1 = new Child2();
var s2 = new Child2();
s1.play.push(4);
console.log(s1.play, s2.play); // 都输出[1,2,3,4]
优点:
- 可以继承父类实例和原型上的属性和方法
缺点:
- 所有子类实例共享父类引用属性,修改一个会影响所有实例
- 创建子类实例时无法向父类构造函数传参
1.3 组合继承(构造函数+原型链)
function Parent3() {
this.name = 'parent3';
this.play = [1, 2, 3];
}
function Child3() {
Parent3.call(this); // 第一次调用父类构造函数
this.type = 'child3';
}
Child3.prototype = new Parent3(); // 第二次调用父类构造函数
var s3 = new Child3();
var s4 = new Child3();
s3.play.push(4);
console.log(s3.play, s4.play); // [1,2,3,4] 和 [1,2,3]
优点:
- 解决了前两种方式的问题
- 既是子类的实例,也是父类的实例
缺点:
- 父类构造函数被调用了两次,影响性能
- 子类原型上有多余的父类实例属性
2. 优化后的继承方式
2.1 组合继承的优化1
function Parent4() {
this.name = 'parent4';
this.play = [1, 2, 3];
}
function Child4() {
Parent4.call(this);
this.type = 'child4';
}
Child4.prototype = Parent4.prototype; // 直接引用父类原型
var s3 = new Child4();
console.log(s3.constructor); // 输出Parent4而不是Child4
问题:
- 子类实例的构造函数指向错误
- 子类修改原型会影响父类
2.2 寄生组合继承(推荐方式)
function Parent5() {
this.name = 'parent5';
this.play = [1, 2, 3];
}
function Child5() {
Parent5.call(this); // 继承实例属性
this.type = 'child5';
}
// 使用Object.create创建父类原型的副本作为子类原型
Child5.prototype = Object.create(Parent5.prototype);
// 修复构造函数指向
Child5.prototype.constructor = Child5;
var s5 = new Child5();
console.log(s5 instanceof Child5, s5 instanceof Parent5); // true, true
优点:
- 只调用一次父类构造函数
- 原型链保持不变
- 能够正常使用instanceof和isPrototypeOf
这是目前最理想的JavaScript继承方式,也是ES6 class extends的实现基础。
3. ES6中的class继承
ES6引入了class语法糖,使继承更加直观:
class Parent {
constructor(name) {
this.name = name;
}
sayHello() {
console.log(`Hello, I'm ${this.name}`);
}
}
class Child extends Parent {
constructor(name, age) {
super(name); // 调用父类构造函数
this.age = age;
}
sayAge() {
console.log(`I'm ${this.age} years old`);
}
}
const child = new Child('Alice', 10);
child.sayHello(); // Hello, I'm Alice
child.sayAge(); // I'm 10 years old
通过Babel等工具编译后,ES6的class继承实际上就是转换为寄生组合继承的方式。
4. 继承与组合的思考
虽然继承是面向对象的重要特性,但它并非总是最佳选择。考虑以下汽车例子:
class Car {
constructor(id) {
this.id = id;
}
drive() {
console.log("Driving!");
}
music() {
console.log("Playing music!");
}
addOil() {
console.log("Adding oil!");
}
}
class ElectricCar extends Car {
// 电动车不需要加油,但继承了addOil方法
}
这就是著名的"大猩猩和香蕉"问题——我们只需要香蕉(某些方法),但却得到了整个大猩猩(整个父类)。
4.1 组合优于继承
组合是一种替代继承的设计模式,它通过将功能分解为更小的部分,然后在需要时组合它们:
const canDrive = () => ({
drive: () => console.log("Driving!")
});
const canPlayMusic = () => ({
playMusic: () => console.log("Playing music!")
});
const canAddOil = () => ({
addOil: () => console.log("Adding oil!")
});
function createGasCar(id) {
const car = { id };
return Object.assign(
car,
canDrive(),
canPlayMusic(),
canAddOil()
);
}
function createElectricCar(id) {
const car = { id };
return Object.assign(
car,
canDrive(),
canPlayMusic()
// 不添加canAddOil
);
}
组合的优势:
- 更灵活,可以精确选择需要的功能
- 避免复杂的继承层次
- 更容易修改和扩展
- 减少耦合,提高代码复用
5. 何时使用继承
尽管组合有诸多优势,继承仍然有其适用场景:
- 明显的"is-a"关系:当子类确实是父类的特殊类型时
- 需要多态性:当需要统一接口处理不同子类对象时
- 框架设计:某些框架或库要求使用继承
- 需要完整继承链:当确实需要完整的父类功能时
6. 其他继承相关技术
6.1 原型式继承
const parent = {
name: 'parent',
sayName() {
console.log(this.name);
}
};
const child = Object.create(parent);
child.name = 'child';
child.sayName(); // 输出"child"
6.2 混入模式(Mixin)
const Flyable = {
fly() {
console.log(`${this.name} is flying!`);
}
};
class Bird {
constructor(name) {
this.name = name;
}
}
Object.assign(Bird.prototype, Flyable);
const bird = new Bird('Sparrow');
bird.fly(); // Sparrow is flying!
Object.assign
可以参考:Object.assign() - JavaScript | MDN,它将一个或者多个源对象中所有可枚举的自有属性复制到目标对象,并返回修改后的目标对象。
7. 性能考虑
不同的继承方式在性能上有所差异:
- 原型链查找:过深的原型链会影响属性查找速度
- 内存使用:组合继承会导致子类原型上有多余属性
- 初始化成本:构造函数继承每次实例化都要调用父类构造函数
在性能敏感的场景下,应该谨慎选择继承方式,必要时进行性能测试。
8. 最佳实践
- 优先使用ES6 class和extends语法
- 对于简单场景,考虑使用组合而非继承
- 避免创建过深的继承链(通常不超过3层)
- 遵循LSP(里氏替换原则)——子类应该能够替换父类
- 考虑使用设计模式如装饰器模式、策略模式等替代继承
结语
JavaScript提供了多种实现继承的方式,从早期的原型链到现代的class语法。理解这些继承方式的原理和优缺点对于编写可维护、高效的代码至关重要。同时,我们也应该认识到继承并非银弹,在某些场景下,组合可能是更好的选择。作为开发者,我们应该根据具体需求选择最合适的代码组织方式,而不是盲目遵循某一种范式。