JavaScript 中的继承机制:从构造函数到原型链
在 JavaScript 这门基于原型的语言中,实现“类式继承”并非像 Java 或 C++ 那样直接。开发者需要巧妙结合 构造函数 与 原型(prototype) 来模拟面向对象中的继承行为。本文将系统讲解如何通过 call/apply 实现属性继承、通过中介空函数实现原型继承,并深入剖析其背后的原理。
一、构造函数继承:使用 call 或 apply
JavaScript 的构造函数本质上是普通函数,但通过 new 调用时会创建一个新对象并绑定 this。要让子类继承父类的实例属性(如 name, age),可以在子类构造函数中调用父类构造函数,并显式指定 this 指向当前子类实例。
function Animal(name, age) {
this.name = name;
this.age = age;
}
function Cat(name, age, color) {
// 借用父类构造函数,初始化实例属性
Animal.apply(this, [name, age]); // 或 Animal.call(this, name, age);
this.color = color; // 子类特有属性
}
✅ 优点:
- 子类实例拥有父类的属性副本,避免引用共享问题。
- 可以向父类传递参数。
❌ 缺点:
- 无法继承父类原型上的方法或属性(如
Animal.prototype.species)。 - 每次创建子类实例都会执行一次父类构造函数,效率略低(但通常可接受)。
二、原型继承:让子类访问父类的原型成员
为了使子类能访问父类原型上的方法和属性(如 species、eat()),我们需要设置子类的 prototype 指向父类的原型链。
错误做法:直接赋值
Cat.prototype = Animal.prototype; // 危险!
这会导致 子类和父类共享同一个原型对象。一旦修改 Cat.prototype,Animal.prototype 也会被意外改变。
正确做法:引入“空中介函数”
为了解耦,我们引入一个空的构造函数 F 作为中介:
function extend(Parent, Child) {
const F = function() {}; // 空函数,不执行任何逻辑
F.prototype = Parent.prototype; // F 的原型指向父类原型
Child.prototype = new F(); // 子类原型 = F 的实例
Child.prototype.constructor = Child; // 修复 constructor 指向
}
然后调用:
extend(Animal, Cat);
🔍 原理图解:
Cat.prototype (new F())
↓ __proto__
F.prototype → Animal.prototype
↓
Object.prototype
这样:
Cat.prototype是一个独立对象,修改它不会影响Animal.prototype。cat instanceof Animal依然为true,因为原型链连通。constructor被正确修复为Cat。
三、完整继承示例
function Animal(name, age) {
this.name = name;
this.age = age;
}
Animal.prototype.species = '动物';
Animal.prototype.breathe = function() {
console.log('呼吸...');
};
function Cat(name, age, color) {
Animal.apply(this, [name, age]); // 继承实例属性
this.color = color;
}
// 原型继承
function extend(Parent, Child) {
const F = function() {};
F.prototype = Parent.prototype;
Child.prototype = new F();
Child.prototype.constructor = Child;
}
extend(Animal, Cat);
// 子类扩展方法
Cat.prototype.eat = function() {
console.log('吃鱼...');
};
// 测试
const cat = new Cat('胖猫', 1, '蓝色');
console.log(cat.name); // 胖猫
console.log(cat.species); // 动物(来自 Animal.prototype)
cat.eat(); // 吃鱼...
cat.breathe(); // 呼吸...
console.log(cat instanceof Animal); // true
四、关于 call 与 apply 的区别
两者都用于改变函数执行时的 this 上下文,并立即执行函数:
| 方法 | 参数形式 | 示例 |
|---|---|---|
call | 逐个传参 | Animal.call(this, name, age) |
apply | 第二个参数为数组 | Animal.apply(this, [name, age]) |
💡 小技巧:当参数数量不确定或已存在于数组中时,优先用
apply;否则用call更直观。
五、属性遮蔽(Shadowing)现象
注意:如果在实例上直接赋值同名属性,会遮蔽原型上的属性:
function Cat() {}
Cat.prototype.species = '猫科动物';
const cat = new Cat();
cat.species = 'hello Cat'; // 在实例上创建新属性
console.log(cat.species); // 'hello Cat'(实例属性)
console.log(Cat.prototype.species); // '猫科动物'(原型未变)
这是 JavaScript 属性查找机制的正常行为:先查实例,再沿原型链向上查找。
六、总结
| 继承方式 | 作用 | 是否推荐 |
|---|---|---|
构造函数继承(call/apply) | 继承实例属性 | ✅ 必须 |
| 原型直接赋值 | 共享原型(危险) | ❌ 避免 |
| 空函数中介继承 | 安全继承原型方法和属性 | ✅ 推荐 |
ES6 class extends | 语法糖,内部仍基于上述机制 | ✅ 现代项目首选 |
虽然现代开发多使用 class 语法,但理解底层的原型继承机制,有助于你更深入掌握 JavaScript 的面向对象本质,并在兼容旧代码或面试中游刃有余。
提示:ES6 的
class本质上仍是基于原型的语法糖,其extends机制正是对上述“组合继承”模式的封装。
掌握这些核心思想,你就能在 JavaScript 的继承世界中自由驰骋!