在 JavaScript 的面向对象编程中,继承是一个核心概念。由于 JS 本身基于原型(prototype)而非类(class)来实现对象之间的关系,因此“如何优雅地实现继承”一直是开发者需要掌握的重要技能。本文将结合实际代码和清晰的逻辑,重点讲解一种经典且实用的继承方式——利用空对象作为中介的原型继承,并解释其背后的原理与优势。
一、构造函数式继承的问题
我们先来看一个最基础的继承尝试:
function Animal(name, age) {
this.name = name;
this.age = age;
}
Animal.prototype.species = '动物';
function Cat(name, age, color) {
Animal.call(this, name, age);
this.color = color;
}
const cat = new Cat('加菲猫', 2, '黄色'); // 实际参数不匹配
这样,通过 call(或 apply),我们在 Cat 实例的上下文中执行了 Animal 构造函数,从而将 name 和 age 属性“复制”到子类实例上。这就是构造函数式继承,它解决了实例属性的继承问题。
✅ 小知识:
call和apply功能相同,区别仅在于参数传递方式——call逐个传参,apply以数组形式传参。
但注意:这种方式无法继承父类原型上的方法或属性(比如 Animal.prototype.species)。要解决这个问题,我们需要操作原型链。
二、直接赋值原型的陷阱
有人可能会想到直接赋值:
Cat.prototype = Animal.prototype; // ❌ 危险!
这看似实现了继承,但实际上 Cat.prototype 和 Animal.prototype 指向同一块内存地址。任何对 Cat.prototype 的修改(比如添加方法或重置 constructor)都会直接影响 Animal.prototype,破坏封装性,造成意料之外的副作用。
例如:
Cat.prototype.constructor = Cat;
console.log(Animal.prototype.constructor); // 输出 Cat!错误!
显然,这不是我们想要的“继承”,而是“共享”。
三、空对象中介:安全实现原型继承
为了解决上述问题,业界提出了一种经典方案——使用一个空的构造函数作为中介。这也是你在 3.html 中看到的核心思想。
核心代码如下:
function Animal(name, age) {
this.name = name;
this.age = age;
}
Animal.prototype.species = '动物';
function Cat(name, age, color) {
Animal.call(this, name, age); // 继承实例属性
this.color = color;
}
// 空对象中介
var F = function() {};
F.prototype = Animal.prototype;
Cat.prototype = new F();
Cat.prototype.constructor = Cat;
为什么这样做更安全?
让我们一步步拆解:
-
F.prototype = Animal.prototype
这一步确实是引用赋值,F.prototype和Animal.prototype指向同一对象。但这没关系,因为我们不会直接修改F.prototype。 -
new F()创建了一个新对象
根据new操作符的底层机制:- 创建一个空对象
tempObj tempObj.__proto__ = F.prototype→ 即Animal.prototype- 执行
F函数体(为空,无操作) - 返回
tempObj
所以
new F()返回的对象本身是空的,但它的原型链指向Animal.prototype。 - 创建一个空对象
-
Cat.prototype = new F()
此时Cat.prototype是一个全新的对象,它继承自Animal.prototype,但不是Animal.prototype本身。 -
修正
constructorCat.prototype.constructor = Cat;由于
new F()返回的对象默认继承Animal.prototype.constructor(即Animal),我们需要手动将其指回Cat。
关键点:这个修改只影响Cat.prototype对象自身,不会污染Animal.prototype,因为两者不再是同一个对象!
验证继承链
const cat = new Cat('加菲猫', 2, '黄色');
console.log(cat instanceof Cat); // true
console.log(cat instanceof Animal); // true
console.log(cat.species); // '动物' —— 成功继承原型属性!
console.log(cat.constructor === Cat); // true
完美!既继承了实例属性,又安全地继承了原型属性,且 constructor 指向正确。
四、封装成通用继承函数
我们可以将上述逻辑封装成一个可复用的 extend 函数(如 4.html 所示):
function extend(Child, Parent) {
var F = function() {};
F.prototype = Parent.prototype;
Child.prototype = new F();
Child.prototype.constructor = Child;
}
使用方式:
extend(Cat, Animal);
这样,任何子类都可以通过一行代码安全继承父类的原型。
💡 为什么用
function(){}而不是new Object()?
因为new Object()的__proto__永远指向Object.prototype,无法通过F.prototype = ...改变其原型链。只有通过new 函数(),才能让新对象的__proto__指向指定的prototype。
五、总结:空对象中介的优势
| 方式 | 是否继承实例属性 | 是否继承原型属性 | 是否安全(不污染父类) |
|---|---|---|---|
构造函数继承(call) | ✅ | ❌ | ✅ |
直接赋值 prototype | ❌ | ✅ | ❌ |
| 空对象中介 | ✅ | ✅ | ✅ |
空对象中介模式完美结合了构造函数继承和原型链继承的优点,同时规避了直接赋值带来的副作用,是 ES5 时代实现继承的最佳实践之一。
虽然现代开发中我们更多使用 class 和 extends(ES6+),但理解这种底层机制,有助于我们深入掌握 JavaScript 的原型本质,也能在兼容旧环境或阅读老代码时游刃有余。
希望这篇文章能帮你理清 JavaScript 继承的脉络!欢迎在评论区交流你的理解或疑问~