JavaScript 的继承机制建立在原型链之上,但在实际开发中,单纯的“原型链继承”并不能解决所有问题(如引用类型共享、无法向父类构造函数传参等)。为此,社区在原型继承的基础上演化出了一系列继承方案,每一种都是对前一方案的修补和改进。理解这些方案的演化过程,不仅能让你知道 ES6 Class 为什么那样设计,更能帮助你在面试、源码阅读和特殊场景下做出正确选择。
下面我们按演进顺序逐一拆解七种继承方案。
4.5.1 原型链继承
原理:将子类的原型对象指向父类的一个实例,通过原型链实现属性和方法的共享。
function Parent() {
this.name = 'Parent';
this.colors = ['red', 'blue'];
}
Parent.prototype.getName = function() {
return this.name;
};
function Child() {}
Child.prototype = new Parent(); // 核心:将子类原型指向父类实例
const child1 = new Child();
const child2 = new Child();
child1.colors.push('green');
console.log(child2.colors); // ['red', 'blue', 'green'] —— 共享了引用类型!
优点
- 实现简单,父类方法可以被所有子类实例共享。
缺点
- 引用类型的属性会被所有实例共享,一个实例修改会影响其他实例。
- 创建子类实例时无法向父类构造函数传递参数(
Child内部没有调用Parent,参数只能写死在new Parent()里)。
4.5.2 构造函数继承(借用构造函数)
原理:在子类构造函数内部使用 call 或 apply 调用父类构造函数,将父类的实例属性“拷贝”到子类实例上。
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
Parent.prototype.sayName = function() {
console.log(this.name);
};
function Child(name, age) {
Parent.call(this, name); // 核心:在子类环境中执行父类构造函数
this.age = age;
}
const child1 = new Child('Tom', 18);
const child2 = new Child('Jerry', 20);
child1.colors.push('green');
console.log(child2.colors); // ['red', 'blue'] —— 互不影响
console.log(child1.sayName); // undefined —— 继承不到原型上的方法
优点
- 解决了引用类型共享问题,每个实例都有独立的属性副本。
- 可以在子类构造函数中向父类传参。
缺点
- 方法只能定义在构造函数内部,每个实例都会创建一份方法副本,无法复用函数,浪费内存。
- 父类原型上定义的方法对子类不可见,违背了“继承方法”的初衷。
4.5.3 组合继承(原型链 + 构造函数)
原理:结合前两种方案,用原型链继承方法,用构造函数继承属性。这是 ES6 之前最常用的继承模式。
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
Parent.prototype.getName = function() {
return this.name;
};
function Child(name, age) {
Parent.call(this, name); // 第二次调用 Parent:继承属性
this.age = age;
}
Child.prototype = new Parent(); // 第一次调用 Parent:继承方法
Child.prototype.constructor = Child; // 修正构造器指向
const child1 = new Child('Tom', 18);
console.log(child1.getName()); // 'Tom'
child1.colors.push('green');
const child2 = new Child('Jerry', 20);
console.log(child2.colors); // ['red', 'blue'] —— 属性不共享
优点
- 融合了原型链和构造函数的优点:方法共享、属性独立、可传参。
缺点
- 父类构造函数被调用了两次:一次在创建子类原型时,一次在子类构造函数内部。这导致子类原型上存在一份多余的父类实例属性(
name、colors),又被实例自身属性所屏蔽,造成内存浪费。
4.5.4 原型式继承
原理:基于一个已有对象创建新对象,本质上是对 Object.create() 的模拟。ES5 正式将 Object.create() 标准化。
function createObject(o) {
function F() {}
F.prototype = o;
return new F();
}
const person = {
name: 'Default',
friends: ['Alice', 'Bob']
};
const p1 = createObject(person);
p1.friends.push('Charlie');
const p2 = createObject(person);
console.log(p2.friends); // ['Alice', 'Bob', 'Charlie'] —— 引用类型仍然共享
优点
- 不需要定义类型,直接从已有对象克隆出新对象,适合快速创建相似对象。
缺点
- 和原型链继承一样,引用类型属性会被所有克隆对象共享。
- 无法传递初始化参数,灵活性受限。
4.5.5 寄生式继承
原理:在原型式继承的基础上,用一个工厂函数封装新对象的创建过程,增强新对象的能力(添加属性或方法)。
function createAnother(original) {
const clone = Object.create(original); // 原型式继承
clone.sayHi = function() { // 添加新方法
console.log('Hi');
};
return clone;
}
const person = {
name: 'Default',
friends: ['Alice']
};
const another = createAnother(person);
another.sayHi(); // 'Hi'
优点
- 可以方便地对已有对象进行扩展,适合仅需扩展单个对象的场景。
缺点
- 方法和属性依然在对象层面,无法像构造函数那样实现函数复用(每次调用工厂函数都会创建新的
sayHi方法)。
4.5.6 寄生组合式继承(最优方案)
原理:解决组合继承中“父类构造函数被调用两次”的问题。核心思路:不让子类原型等于父类实例,而是让子类原型指向一个以父类原型为原型的干净对象。然后通过构造函数继承属性。
function inheritPrototype(Child, Parent) {
const prototype = Object.create(Parent.prototype); // 创建父类原型的副本
prototype.constructor = Child; // 修正 constructor
Child.prototype = prototype; // 将子类原型指向该副本
}
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
Parent.prototype.getName = function() {
return this.name;
};
function Child(name, age) {
Parent.call(this, name); // 只调用一次父类构造函数,继承属性
this.age = age;
}
inheritPrototype(Child, Parent); // 继承方法
const child = new Child('Tom', 18);
console.log(child.getName()); // 'Tom'
优点
- 只调用一次父类构造函数,避免了子类原型上出现多余的属性。
- 原型链保持正确,方法得到共享,属性保持独立。
- 是 ES6 Class
extends底层的核心实现模式。
缺点
- 实现步骤相对多,需要封装一个辅助函数。不过在工程中这通常被一次性封装好。
补充说明:寄生组合式继承是公认的 JavaScript 中最理想的继承范式,也是 Babel 等工具将 ES6 Class 转译为 ES5 时所使用的核心手法。
4.5.7 ES6 Class 继承
原理:class / extends / super 是 ES6 提供的语法糖,底层仍基于原型链。但它的实现比纯手写的寄生组合式更严格,加入了 super 调用顺序、不可枚举的方法定义等细节。
class Parent {
constructor(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
getName() {
return this.name;
}
}
class Child extends Parent {
constructor(name, age) {
super(name); // 必须先调用 super,才能访问 this
this.age = age;
}
}
const child1 = new Child('Tom', 18);
console.log(child1.getName()); // 'Tom'
console.log(child1 instanceof Parent); // true
Class 继承与寄生组合式的对应关系
| 步骤 | 寄生组合式实现 | ES6 Class 实现 |
|------|----------------|----------------|
| 建立原型链 | Child.prototype = Object.create(Parent.prototype) | class Child extends Parent 内部自动完成 |
| 构造函数中调用父类 | Parent.call(this, ...args) | super(...args) |
| 修正 constructor | 手动赋值 | 自动设置 |
| 静态属性/方法继承 | 需额外处理 Child.proto = Parent | 自动继承静态成员 |
优点
- 语法清晰易读,符合传统面向对象语言的思维习惯。
- 内置安全性,如
super前不能使用this。 - 静态方法和属性会被自动继承。
缺点 / 本质提醒
- 容易给新开发者造成“JavaScript 是基于类的语言”的错觉。一旦遇到原型链相关的怪异表现,调试起来会非常困惑。因此,理解其底层原型机制仍是必要的。
总结对比与选型建议
| 方案 | 属性共享问题 | 方法复用 | 可传参 | 调用父类次数 | 推荐度 |
|------|-------------|---------|--------|-------------|--------|
| 原型链继承 | ❌ 共享 | ✅ 复用 | ❌ 不可传参 | 1 | 不推荐 |
| 构造函数继承 | ✅ 独立 | ❌ 不复用 | ✅ 可传参 | 1 | 不推荐 |
| 组合继承 | ✅ 独立 | ✅ 复用 | ✅ 可传参 | 2(浪费) | 可用但已过时 |
| 原型式继承 | ❌ 共享 | ✅ 复用 | ❌ 不可传参 | 0 | 特殊场景 |
| 寄生式继承 | ❌ 共享 | ❌ 不复用 | ❌ 不可传参 | 0 | 特殊场景 |
| 寄生组合式继承 | ✅ 独立 | ✅ 复用 | ✅ 可传参 | 1 | 强烈推荐(ES5) |
| ES6 Class 继承 | ✅ 独立 | ✅ 复用 | ✅ 可传参 | 1(super) | 现代开发首选 |
实战建议:
- 新项目直接使用 ES6 Class 继承,代码简洁且不易出错。
- 理解寄生组合式继承的原理,有助于读懂旧代码、源码和 Babel 编译输出。
- 原型式继承和寄生式继承在实现部分设计模式(如组合模式扩展)时偶尔会用到,了解即可。
掌握这七种方案后,你将能从容应对任何与原型继承相关的场景,并为后续学习 Vue/React 组件继承、Mixin 混入等前端核心概念打下坚实基础。