JavaScript继承:从原型链到组合设计

10 阅读5分钟

引言

继承是面向对象编程中的重要概念,它允许对象获取另一个对象的属性和方法。在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
  );
}

组合的优势

  1. 更灵活,可以精确选择需要的功能
  2. 避免复杂的继承层次
  3. 更容易修改和扩展
  4. 减少耦合,提高代码复用

5. 何时使用继承

尽管组合有诸多优势,继承仍然有其适用场景:

  1. 明显的"is-a"关系:当子类确实是父类的特殊类型时
  2. 需要多态性:当需要统一接口处理不同子类对象时
  3. 框架设计:某些框架或库要求使用继承
  4. 需要完整继承链:当确实需要完整的父类功能时

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. 性能考虑

不同的继承方式在性能上有所差异:

  1. 原型链查找:过深的原型链会影响属性查找速度
  2. 内存使用:组合继承会导致子类原型上有多余属性
  3. 初始化成本:构造函数继承每次实例化都要调用父类构造函数

在性能敏感的场景下,应该谨慎选择继承方式,必要时进行性能测试。

8. 最佳实践

  1. 优先使用ES6 class和extends语法
  2. 对于简单场景,考虑使用组合而非继承
  3. 避免创建过深的继承链(通常不超过3层)
  4. 遵循LSP(里氏替换原则)——子类应该能够替换父类
  5. 考虑使用设计模式如装饰器模式、策略模式等替代继承

结语

JavaScript提供了多种实现继承的方式,从早期的原型链到现代的class语法。理解这些继承方式的原理和优缺点对于编写可维护、高效的代码至关重要。同时,我们也应该认识到继承并非银弹,在某些场景下,组合可能是更好的选择。作为开发者,我们应该根据具体需求选择最合适的代码组织方式,而不是盲目遵循某一种范式。

参考文章