JavaScript 中的继承机制:从构造函数到原型链

15 阅读3分钟

JavaScript 中的继承机制:从构造函数到原型链

在 JavaScript 这门基于原型的语言中,实现“类式继承”并非像 Java 或 C++ 那样直接。开发者需要巧妙结合 构造函数原型(prototype) 来模拟面向对象中的继承行为。本文将系统讲解如何通过 call/apply 实现属性继承、通过中介空函数实现原型继承,并深入剖析其背后的原理。


一、构造函数继承:使用 callapply

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)。
  • 每次创建子类实例都会执行一次父类构造函数,效率略低(但通常可接受)。

二、原型继承:让子类访问父类的原型成员

为了使子类能访问父类原型上的方法和属性(如 specieseat()),我们需要设置子类的 prototype 指向父类的原型链。

错误做法:直接赋值

Cat.prototype = Animal.prototype; // 危险!

这会导致 子类和父类共享同一个原型对象。一旦修改 Cat.prototypeAnimal.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.prototypeAnimal.prototypeObject.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

四、关于 callapply 的区别

两者都用于改变函数执行时的 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 的继承世界中自由驰骋!