JavaScript 原型继承全解析:从 call/apply 到寄生组合式继承
在 JavaScript 的世界中,继承是实现代码复用和构建复杂对象体系的基石。与 Java 或 C++ 等基于“类”的语言不同,JS 的继承核心在于原型(Prototype)。
本文将结合你提供的代码片段,深入浅出地讲解 JS 继承的演进过程,重点剖析 call/apply 的作用、直接继承原型的弊端,以及最终极的解决方案——寄生组合式继承。
一、基石:call 与 apply —— 借用构造函数
在讨论复杂的原型链之前,我们先看最基础的构造函数继承。
1. 核心概念
call 和 apply 是函数对象的方法,它们的核心作用是:改变函数运行时的 this 指向,并立即执行该函数。
- 第一个参数:指定
this要绑定的对象(在继承中,通常绑定为子类的实例this)。 - 后续参数:传递给被调用函数的参数。
call(this, arg1, arg2, ...):参数逐个传递。apply(this, [arg1, arg2, ...]):参数以数组形式传递。
2. 代码实战
在子类构造函数中,通过 call 或 apply 调用父类构造函数,可以将父类的实例属性(如 name, age)复制到子类实例上。
function Animal(name, age) {
this.name = name;
this.age = age;
}
function Cat(name, age, color) {
// 关键步骤:借用父类构造函数,将 Animal 的属性绑定到 Cat 的 this 上
// 此时 this 指向的是 new Cat() 创建的空对象
Animal.call(this, name, age);
// 或者使用 apply: Animal.apply(this, [name, age]);
this.color = color; // 子类特有属性
console.log(this);
}
const cat = new Cat('加菲猫', 2, '黄色');
console.log(cat.name); // '加菲猫' (继承自 Animal)
console.log(cat.color); // '黄色' (Cat 特有)
✅ 优点:解决了实例属性的继承问题,且可以向父类构造函数传参。
❌ 缺点:只能继承实例属性,无法继承父类原型(prototype)上的方法(如 Animal.prototype.sayHi)。
二、进阶:原型链继承与它的“坑”
为了继承父类原型上的方法(如 species),我们需要建立原型链。
1. 错误的尝试:直接赋值
很多初学者会直接让子类的原型等于父类的原型:
// ❌ 错误示范
Cat.prototype = Animal.prototype;
Cat.prototype.constructor = Cat;
const cat = new Cat('加菲猫', 2, '黄色');
cat.species = '猫科';
// 灾难发生了!
console.log(Animal.prototype.species); // '猫科'
// 修改子类原型,竟然影响了父类原型!因为它们指向同一个内存地址。
后果:子类对原型的任何修改(添加方法、修改属性)都会污染父类原型,导致所有 Animal 实例都受到影响。
2. 改进版:使用实例作为中介
为了解决引用共享问题,我们让子类的原型指向父类的一个实例:
// ✅ 改进:Cat.prototype 指向 Animal 的实例
Cat.prototype = new Animal();
Cat.prototype.constructor = Cat;
✅ 优点:
- 子类可以访问父类原型上的方法(
species)。 - 子类修改自己的原型属性,不会影响父类原型(因为
new Animal()创建了一个新对象)。
❌ 缺点:
- 执行了父类构造函数:在
new Animal()时,父类构造函数被执行了。如果父类构造函数有副作用(如发送网络请求、操作 DOM)或需要必填参数,这会很麻烦。 - 属性冗余:父类实例属性(
name,age)会被添加到Cat.prototype上,而不仅仅是cat实例上,造成内存浪费和逻辑混淆。
三、终极方案:寄生组合式继承
为了解决上述所有问题(既要继承实例属性,又要继承原型方法,还不能执行父类构造函数,还不能污染父类原型),JavaScript 社区总结出了寄生组合式继承(Parasitic Combination Inheritance)。
这是目前最成熟、性能最好的继承模式,也是 ES6 class 语法背后的实现原理。
1. 核心思想
- 借用构造函数:在子类构造函数中用
call/apply继承实例属性。 - 空函数中介:创建一个空函数
F,让F.prototype指向Parent.prototype。 - 实例化中介:让
Child.prototype = new F()。- 这样
Child.prototype的原型指向了Parent.prototype。 - 但
Child.prototype本身是一个干净的对象,没有执行Parent构造函数,也没有多余的实例属性。
- 这样
2. 完整代码实现
function Animal(name, age) {
this.name = name;
this.age = age;
this.hobbies = ['sleep']; // 引用类型属性测试
}
Animal.prototype.species = '动物';
Animal.prototype.sayName = function() {
console.log('My name is ' + this.name);
};
function Cat(name, age, color) {
// 1. 借用构造函数:继承实例属性
Animal.call(this, name, age);
this.color = color;
}
// 2. 核心工具函数:实现原型链的无损连接
function extend(Child, Parent) {
var F = function() {}; // 创建空函数作为中介
F.prototype = Parent.prototype; // 让 F 的原型指向父类原型
Child.prototype = new F(); // 关键点:Child 原型指向 F 的实例
// 这个实例的原型链指向 Parent.prototype
// 但实例本身不包含 Parent 的实例属性
Child.prototype.constructor = Child; // 修正构造函数指向
}
// 执行继承
extend(Cat, Animal);
// 3. 给子类原型添加特有方法
Cat.prototype.eat = function() {
console.log("eat jerry");
};
// --- 测试验证 ---
const cat = new Cat('加菲猫', 2, '黄色');
// 验证实例属性
console.log(cat.name); // '加菲猫'
console.log(cat.color); // '黄色'
console.log(cat.hobbies); // ['sleep']
// 验证原型方法
console.log(cat.species); // '动物' (来自 Animal.prototype)
cat.sayName(); // 'My name is 加菲猫'
cat.eat(); // 'eat jerry' (Cat 特有)
// 验证隔离性 (最重要的一点)
cat.hobbies.push('play');
const animal = new Animal('Generic', 5);
console.log(animal.hobbies); // ['sleep'] (不受 cat 影响,说明不是共享引用)
// 验证原型链结构
console.log(cat instanceof Cat); // true
console.log(cat instanceof Animal); // true
console.log(cat.constructor === Cat); // true
3. 为什么它是完美的?
| 特性 | 构造函数继承 | 原型链继承 (new Parent) | 寄生组合式继承 |
|---|---|---|---|
| 继承实例属性 | ✅ 支持 | ✅ 支持 (但在原型上) | ✅ 支持 (在实例上) |
| 继承原型方法 | ❌ 不支持 | ✅ 支持 | ✅ 支持 |
| 传参能力 | ✅ 支持 | ❌ 困难 | ✅ 支持 |
| 执行父类构造 | ✅ 1次 | ✅ 1次 (多余) | ✅ 1次 (仅必要) |
| 原型污染 | N/A | ❌ 风险 (若直接赋值) | ✅ 完全隔离 |
| instanceof | ❌ 失败 | ✅ 成功 | ✅ 成功 |
四、深入理解:原型链的层级
通过 extend 函数,我们构建了如下完美的原型链:
cat (实例)
│
└─ __proto__ ──> Cat.prototype (由 new F() 创建的空对象)
│
└─ __proto__ ──> Animal.prototype
│
└─ __proto__ ──> Object.prototype
│
└─ __proto__ ──> null
- 当访问
cat.species时:cat自身没有 -> 查找Cat.prototype-> 没有。- 查找
Cat.prototype.__proto__(即Animal.prototype) -> 找到!
- 当修改
Cat.prototype.eat时:- 只修改了
Cat.prototype对象,完全不会触碰Animal.prototype。
- 只修改了
五、总结
JavaScript 的继承机制虽然灵活,但也容易踩坑。
call/apply是继承实例属性的关键,区别仅在于参数传递形式(列表 vs 数组)。- 直接赋值原型 (
Child.prototype = Parent.prototype) 是大忌,会导致父子类原型共享,互相污染。 - 简单的原型链 (
Child.prototype = new Parent()) 会多余执行一次父类构造函数,且可能带来参数初始化的问题。 - 寄生组合式继承 是最佳实践:
- 利用
call继承属性。 - 利用空函数中介 (
F) 搭建原型链,既复用了原型方法,又避免了执行父类构造函数和原型污染。
- 利用
如今,虽然我们可以直接使用 ES6 的 class 和 extends 语法糖,但理解其背后的寄生组合式继承原理,对于深入掌握 JavaScript 语言特性、排查复杂 Bug 以及阅读框架源码依然至关重要。