引言
在 JavaScript 的世界中,继承是通过原型(prototype)和 原型链(prototype chain)机制实现的。与传统面向对象语言(如 Java、C++)不同,JavaScript 并没有真正意义上的"类继承"概念------即使 ES6 引入了 class 语法,它本质上也只是对原型继承的语法糖封装。JavaScript 的核心继承模型是基于对象的委托机制 ,即一个对象可以通过其内部的 [[Prototype]] 链访问另一个对象的属性和方法。
本文将系统、深入地介绍 JavaScript 中几种常见的基于原型和原型链的继承方式,并重点解析其中"使用空对象作为中介"的经典模式------寄生组合式继承,帮助你彻底掌握 JS 面向对象编程的底层逻辑。
一、基本概念回顾
1. 原型(Prototype)
在 JavaScript 中,每个函数(function)都有一个 prototype 属性 ,该属性指向一个对象。当这个函数被用作构造函数(通过 new 调用)时,所创建的实例对象会自动将其内部 [[Prototype]](可通过 __proto__ 访问)链接到该 prototype 对象上。
javascript
function Parent() {}
console.log(Parent.prototype); // { constructor: Parent }
const p = new Parent();
console.log(p.__proto__ === Parent.prototype); // true
注意:普通对象没有
prototype属性,只有函数才有。但所有对象(包括函数)都有__proto__(或可通过Object.getPrototypeOf()获取),用于构成原型链。
2. 原型链(Prototype Chain)
当访问一个对象的属性(如 obj.prop)时,JavaScript 引擎会执行以下查找过程:
- 先在对象自身查找;
- 如果找不到,则沿着
__proto__向上查找其原型; - 继续向上,直到找到该属性,或到达原型链顶端(
null)为止。
arduino
const obj = {};
console.log(obj.toString);
// obj 自身没有 toString,但:
// obj.__proto__ → Object.prototype → 找到 toString 方法
// 最终输出:function toString() { [native code] }
这种链式查找机制就是原型链,它是 JavaScript 实现继承的核心。
二、常见的基于原型的继承方式
1. 原型链继承(Prototype Chain Inheritance)
这是最基础的继承方式:让子类的 prototype 指向父类的一个实例。
javascript
function Parent() {
this.name = 'parent';
this.colors = ['red', 'blue']; // 引用类型属性
}
Parent.prototype.say = function() {
console.log('Hello from parent');
};
function Child() {}
// 关键:Child.prototype 指向 Parent 的一个实例
Child.prototype = new Parent();
const child1 = new Child();
const child2 = new Child();
child1.colors.push('green');
console.log(child2.colors); // ['red', 'blue', 'green'] 被污染!
child1.say(); // "Hello from parent"
问题分析:
- 引用属性共享 :
colors数组存在于Child.prototype上,所有子实例共享同一份数据。 - 无法传参 :创建
Child实例时无法向Parent构造函数传递参数。 - 语义不清晰 :
Child.prototype包含了本应属于实例的属性(如name),造成冗余。
此方式仅适用于无状态、纯方法复用的场景,实际开发中极少单独使用。
2. 构造函数继承(借用构造函数 / Classical Inheritance)
通过在子构造函数内部调用父构造函数(使用 .call() 或 .apply()),实现属性的"复制式"继承。
ini
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
function Child(name) {
Parent.call(this, name); // 借用父构造函数,this 指向新创建的 Child 实例
}
const child1 = new Child('Alice');
const child2 = new Child('Bob');
child1.colors.push('green');
console.log(child2.colors); // ['red', 'blue'] 独立副本
优点:
- 每个实例拥有独立的属性,避免引用类型污染;
- 支持向父类传参。
缺点:
- 无法继承父类原型上的方法 。例如
Parent.prototype.say对Child实例不可见; - 方法无法复用:若在构造函数内定义方法,每个实例都会创建一份新函数,浪费内存。
适合只关心属性继承、不依赖原型方法的场景。
3. 组合继承(Combination Inheritance)
结合前两种方式的优点:用构造函数继承属性,用原型链继承方法。
ini
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
Parent.prototype.say = function() {
console.log('Hi, I am ' + this.name);
};
function Child(name, age) {
Parent.call(this, name); // 继承属性(可传参,不共享)
this.age = age;
}
// 继承方法:设置 Child.prototype 为 Parent 实例
Child.prototype = new Parent(); // 问题:这里会无参调用 Parent()
Child.prototype.constructor = Child; // 修复 constructor 指向
const child = new Child('Tom', 10);
child.say(); // "Hi, I am Tom"
优点:
- 属性独立(不共享引用类型);
- 方法复用(通过原型);
- 支持传参;
instanceof和isPrototypeOf正常工作。
缺点:
-
父构造函数被调用了两次:
- 第一次:
Parent.call(this, name)------ 正确初始化实例属性; - 第二次:
new Parent()------ 在设置原型时无意义地创建了一个冗余的父实例,其属性(如name: undefined)被挂在Child.prototype上,造成内存浪费。
- 第一次:
尽管有缺陷,组合继承曾是 ES5 时代最常用的继承模式。
三、重点解析:空对象作为中介的继承方式(寄生组合式继承)
为了解决组合继承中父构造函数被调用两次 的问题,寄生组合式继承(Parasitic Combination Inheritance) 应运而生。这是《JavaScript 高级程序设计》作者 Nicholas C. Zakas 推荐的最高效、最理想的 ES5 继承方式。
核心思想
不通过
new Parent()创建子类原型,而是创建一个"干净"的空对象,让这个空对象的原型指向Parent.prototype。
这样既能建立正确的原型链,又避免执行 Parent 构造函数,从而消除冗余属性。
实现步骤详解
javascript
function inheritPrototype(Child, Parent) {
// Step 1: 创建一个空的构造函数 F(中介)
function F() {}
// Step 2: 将 F 的 prototype 指向 Parent.prototype
// 这样 F 的实例就能"继承" Parent.prototype 上的所有方法
F.prototype = Parent.prototype;
// Step 3: 将 Child.prototype 设置为 F 的一个实例
// new F() 是一个空对象,其 __proto__ 指向 Parent.prototype
// 它不包含 Parent 构造函数初始化的任何实例属性(如 name、colors)
Child.prototype = new F();
// Step 4: 修复 constructor,确保 Child.prototype.constructor 指向 Child
// 否则会错误地指向 Parent(因为 F.prototype = Parent.prototype)
Child.prototype.constructor = Child;
}
完整使用示例
javascript
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
Parent.prototype.say = function() {
console.log('Parent says:', this.name);
};
function Child(name, age) {
Parent.call(this, name); // 借用构造函数继承属性(仅调用一次!)
this.age = age;
}
// 使用空对象中介实现原型继承
inheritPrototype(Child, Parent);
const child = new Child('Lucy', 8);
child.say(); // "Parent says: Lucy"
// 验证继承关系
console.log(child instanceof Parent); // true
console.log(child instanceof Child); // true
console.log(child.constructor === Child); // true
console.log(child.colors); // ['red', 'blue'](来自实例,非原型)
// 检查原型链
console.log(child.__proto__ === Child.prototype); // true
console.log(Child.prototype.__proto__ === Parent.prototype); // true
为什么这种方式更优?
| 优势 | 说明 |
|---|---|
| ✅ 只调用一次父构造函数 | 仅在 Parent.call(this, name) 中执行,无冗余 |
| ✅ 子类原型干净 | Child.prototype 上没有 name、colors 等实例属性 |
| ✅ 完整保留原型链 | child → Child.prototype → Parent.prototype → Object.prototype → null |
| ✅ 内存高效 | 避免了组合继承中在原型上存储无用属性的问题 |
| ✅ 语义正确 | 属性归实例,方法归原型,职责分明 |
关键理解 :
new F()创建的对象是一个"空壳代理",它唯一的使命是作为桥梁,将Child.prototype的__proto__指向Parent.prototype,而不携带任何由Parent构造函数初始化的数据。
四、现代替代方案:Object.create()
ES5 标准引入了 Object.create(proto, [propertiesObject]) 方法,可以直接创建一个以指定对象为原型的新对象。这使得寄生组合式继承的实现更加简洁:
ini
function Child(name, age) {
Parent.call(this, name);
this.age = age;
}
// 使用 Object.create 替代中介函数
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;
Object.create(Parent.prototype)的效果等同于new F()(其中F.prototype = Parent.prototype),但由引擎原生实现,更安全、更高效。
此外,还可以封装一个通用继承函数:
ini
function extend(Child, Parent) {
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;
}
五、补充:原型式继承(Prototypal Inheritance)
虽然不属于"类式继承",但 Object.create() 也支持直接基于现有对象创建新对象,这体现了 JavaScript 真正的原型继承思想:
javascript
const person = {
name: 'Anonymous',
friends: ['Alice'],
greet() {
console.log(`Hi, I'm ${this.name}`);
}
};
const me = Object.create(person);
me.name = 'John';
me.friends.push('Bob');
console.log(me.name); // "John"
console.log(person.friends); // ['Alice', 'Bob'] 共享引用!
// 若需深拷贝属性,可配合属性描述符或后续赋值
此方式适用于无需构造函数、只需对象复用的场景,如配置模板、默认选项等。
六、总结对比表
| 继承方式 | 是否共享引用属性 | 能否传参 | 能否继承原型方法 | 父构造函数调用次数 | 是否推荐 |
|---|---|---|---|---|---|
| 原型链继承 | 是 | 否 | 是 | 1(设置原型时) | ❌ |
| 构造函数继承 | 否 | 是 | 否 | 1(子构造中) | ⚠️ 局限 |
| 组合继承 | 否 | 是 | 是 | 2 | ⚠️ 可用但非最优 |
| 寄生组合式继承(空对象中介) | 否 | 是 | 是 | 1 | ✅ 强烈推荐(ES5) |
原型式继承(Object.create) |
是 | 否 | 是(来自源对象) | 0 | ✅ 特定场景适用 |
七、最佳实践建议
-
在 ES5 环境中 :优先使用 寄生组合式继承 ,可通过
Object.create(Parent.prototype)简化实现。 -
在 ES6+ 环境中 :直接使用
class extends语法,它在底层正是基于寄生组合式继承实现的:scalaclass Parent { constructor(name) { this.name = name; } say() { console.log('Hi from', this.name); } } class Child extends Parent { constructor(name, age) { super(name); // 相当于 Parent.call(this, name) this.age = age; } } -
永远记住 :JavaScript 的继承不是"复制",而是"委托"。理解
[[Prototype]]链的查找机制,比死记语法更重要。
结语 :原型和原型链是 JavaScript 面向对象编程的基石。掌握"空对象作为中介"的寄生组合式继承,不仅能写出高效、健壮的代码,更能深入理解这门语言的设计哲学------万物皆对象,继承靠委托。