JavaScript 原型继承详解:从构造函数继承到空对象中介模式

37 阅读4分钟

在 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 构造函数,从而将 nameage 属性“复制”到子类实例上。这就是构造函数式继承,它解决了实例属性的继承问题。

小知识callapply 功能相同,区别仅在于参数传递方式——call 逐个传参,apply 以数组形式传参。

但注意:这种方式无法继承父类原型上的方法或属性(比如 Animal.prototype.species)。要解决这个问题,我们需要操作原型链。


二、直接赋值原型的陷阱

有人可能会想到直接赋值:

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

这看似实现了继承,但实际上 Cat.prototypeAnimal.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;

为什么这样做更安全?

让我们一步步拆解:

  1. F.prototype = Animal.prototype
    这一步确实是引用赋值,F.prototypeAnimal.prototype 指向同一对象。但这没关系,因为我们不会直接修改 F.prototype

  2. new F() 创建了一个新对象
    根据 new 操作符的底层机制:

    • 创建一个空对象 tempObj
    • tempObj.__proto__ = F.prototype → 即 Animal.prototype
    • 执行 F 函数体(为空,无操作)
    • 返回 tempObj

    所以 new F() 返回的对象本身是空的,但它的原型链指向 Animal.prototype

  3. Cat.prototype = new F()
    此时 Cat.prototype 是一个全新的对象,它继承自 Animal.prototype,但不是 Animal.prototype 本身

  4. 修正 constructor

    Cat.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 时代实现继承的最佳实践之一。

虽然现代开发中我们更多使用 classextends(ES6+),但理解这种底层机制,有助于我们深入掌握 JavaScript 的原型本质,也能在兼容旧环境或阅读老代码时游刃有余。


希望这篇文章能帮你理清 JavaScript 继承的脉络!欢迎在评论区交流你的理解或疑问~