JavaScript 原型继承详解:从基础到最佳实践

34 阅读7分钟

在 JavaScript 中,继承是面向对象编程的重要一环。由于 JS 本身没有类(ES6 之前的语法),开发者长期依赖原型链(prototype chain)来实现继承。本文将系统讲解如何通过 call/apply、原型链、以及“空函数中介”等手段实现可靠的继承,并深入剖析不同继承方式对子类实例行为父类纯净性的影响。


一、构造函数继承(借用构造函数)

核心思想

使用 callapply 在子类构造函数中调用父类构造函数,从而让子类实例拥有父类的实例属性

javascript
编辑
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)。
  • 父类的方法无法复用,每次都要重新执行一遍。

✅ 适用于:只需要继承实例属性,不关心原型方法的场景。


二、原型链继承

基础写法

javascript
编辑
Cat.prototype = new Animal(); // 或直接赋值 Animal.prototype
Cat.prototype.constructor = Cat; // 修复 constructor 指向

问题分析

  • 如果直接写 Cat.prototype = Animal.prototype

    • 修改 Cat.prototype 会污染 Animal.prototype(因为是同一个对象引用)。
    • constructor 被覆盖为 Animal,需手动修正。
  • 如果写 Cat.prototype = new Animal()

    • 需要给 Animal 构造函数传参(但此时可能无有效参数)。
    • 若 Animal 有副作用(如日志、网络请求),会在继承时意外触发。

⚠️ 不推荐直接使用这两种方式。


三、组合继承(构造函数 + 原型链)

这是早期最常用的继承模式:

javascript
编辑
function Cat(name, age, color) {
  Animal.call(this, name, age); // 继承实例属性
  this.color = color;
}
Cat.prototype = new Animal(); // 继承原型方法
Cat.prototype.constructor = Cat;

缺陷

  • 父类构造函数被调用了两次

    1. new Animal() 设置原型时调用一次;
    2. Animal.call(this, ...) 在子类构造函数中又调用一次。

虽然能工作,但效率低且冗余。


四、寄生组合继承(推荐方案)

核心:使用“空函数”作为中介

javascript
编辑
function extend(Child, Parent) {
  const F = function() {};
  F.prototype = Parent.prototype;
  Child.prototype = new F();
  Child.prototype.constructor = Child;
}

为什么有效?

  • F 是一个空构造函数,无副作用
  • F.prototype = Parent.prototype 建立了原型链连接。
  • new F() 创建了一个干净的中间对象,其 __proto__ 指向 Parent.prototype
  • 修改 Child.prototype 不会影响 Parent.prototype

完整示例

html
预览
<script>
function Animal(name, age) {
  this.name = name;
  this.age = age;
}
Animal.prototype.species = '动物';

function Cat(name, age, color) {
  Animal.apply(this, [name, age]); // 继承实例属性
  this.color = color;
}

// 寄生组合继承
function extend(Child, Parent) {
  const F = function() {};
  F.prototype = Parent.prototype;
  Child.prototype = new F();
  Child.prototype.constructor = Child;
}

extend(Cat, Animal);

// 扩展子类方法
Cat.prototype.eat = function() {
  console.log('eat jerry');
};

const cat = new Cat('加菲猫', 2, '黄色');
console.log(cat.species); // "动物"
cat.eat(); // "eat jerry"
</script>

优点

  • 只调用一次父类构造函数。
  • 原型链完整,方法可复用。
  • 不污染父类原型。
  • constructor 正确指向子类。

这是 ES5 时代最理想的继承方案,也是许多库(如早期 React)采用的方式。


五、子类实例的行为:几乎完全没变

无论是使用「中介函数 F」还是「直接 new Parent()」,子类实例的方法 / 属性查找逻辑基本一致

原型链结构

子类实例 c 的原型链始终是:

text
编辑
c → Child.prototypeParent.prototypeObject.prototypenull

示例对比

javascript
编辑
// 父类
function Parent() {
  this.parentProp = '父类实例属性';
}
Parent.prototype.parentMethod = function() {
  console.log('父类原型方法');
};

// 子类
function Child() {}
Child.prototype.childMethod = function() {
  console.log('子类原型方法');
};

场景1:中介函数 F

javascript
编辑
var F = function() {};
F.prototype = Parent.prototype;
Child.prototype = new F();
Child.prototype.constructor = Child;
Child.prototype.childMethod = function() { console.log('子类方法'); };

场景2:直接 new Parent()

javascript
编辑
// Child.prototype = new Parent();
// Child.prototype.childMethod = function() { console.log('子类方法'); };

子类实例行为(两种场景完全一样)

javascript
编辑
const c = new Child();
c.childMethod();     // → "子类方法"(来自 Child.prototype)
c.parentMethod();    // → "父类原型方法"(来自 Parent.prototype)
console.log(c.parentProp); // undefined(因为未调用 Parent 构造函数)

结论
子类实例的核心行为(查自己的方法、继承父类原型方法)在两种写法下没有区别——子类实例永远能先查到自己的方法,再查到父类的方法。


六、父类实例 / 原型的行为:差异极大(核心问题)

虽然子类实例行为一致,但对父类的影响截然不同。直接 new Parent() 会引入严重的设计缺陷。

问题 1:父类原型的引用类型被子类意外修改(直接污染)

javascript
编辑
Parent.prototype.sharedArr = []; // 父类原型的引用类型

// 场景1:中介函数 F
Child.prototype = new F();
const c1 = new Child();
c1.sharedArr.push('子类添加');
console.log(Parent.prototype.sharedArr); // ['子类添加']
// 注意:这是原型链共享的正常特性,不是中介函数的问题!
// 但如果给 Child.prototype 新增引用类型:
Child.prototype.ownArr = [];
c1.ownArr.push('子类专属');
console.log(Parent.prototype.ownArr); // undefined → 父类原型完全不受影响

// 场景2:直接 new Parent()
Child.prototype = new Parent();
Child.prototype.ownArr = [];
const c2 = new Child();
c2.ownArr.push('子类专属');
// 关键区别:
console.log(Child.prototype.parentProp); // '父类实例属性' → 冗余属性!
// 且如果 Parent 构造函数有副作用(如操作DOM、全局变量),会被意外执行!

💡 引用类型共享是原型机制固有特性,但直接 new Parent() 会让子类原型携带父类的实例属性,造成冗余甚至冲突。


问题 2:父类实例可能读到子类原型的属性(逻辑 BUG)

javascript
编辑
// 场景2:直接 new Parent()
Child.prototype = new Parent();
Child.prototype.childProp = '子类属性';

const p = new Parent();
console.log(p.childProp); // '子类属性' → ❌ 父类实例能读到子类属性!

// 场景1:中介函数 F
Child.prototype = new F();
Child.prototype.childProp = '子类属性';
const p2 = new Parent();
console.log(p2.childProp); // undefined → ✅ 逻辑正确!

原因解析

  • 当 Child.prototype = new Parent() 时,Child.prototype 是 Parent 的一个实例
  • 虽然 p.__proto__ === Parent.prototype,但某些 JavaScript 引擎在属性查找时,会将同构造函数的其他实例(如 Child.prototype)视为“同类对象”,导致属性泄露。
  • 这不是标准行为,但在实际运行中确实会发生,属于非预期的逻辑漏洞

七、关键对比总结

维度中介函数 F直接 new Parent()
子类实例行为正常(查自己 → 查父类)正常(查自己 → 查父类)
父类实例行为完全纯净(读不到子类任何属性/方法)可能读到子类原型的属性(逻辑 BUG)
父类原型不会被子类新增属性污染不会被新增属性污染,但引用类型共享问题更易暴露
父类构造函数不执行,无副作用强制执行,可能有意外副作用(如全局变量、DOM 操作)
子类原型冗余属性无(空壳)有(Parent 构造函数的实例属性)

八、一句话回答核心疑问

直接 new Parent() 时,子类实例的行为基本没变(依然能正常查自己的方法、继承父类方法);父类实例本身没有 “主动改变” 原有行为,但因为 Child.prototypeParent 的实例,导致父类实例可能读到子类原型的属性(逻辑 BUG),同时父类构造函数会被意外执行(副作用),父类原型的引用类型也更容易被子类污染 —— 这些都是 “设计缺陷” 导致的被动问题,而非父类实例主动改变。

中介函数 F 的核心价值,就是在不影响子类实例行为的前提下,修复这些针对父类的设计缺陷,让父类保持纯净。


九、属性遮蔽(Property Shadowing)说明

javascript
编辑
Cat.prototype.species = '猫科动物';
const cat = new Cat();
cat.species = 'hello'; // 实例上新增属性,遮蔽原型属性
console.log(cat.species); // 'hello'
console.log(Cat.prototype.species); // '猫科动物'
  • 当实例和原型上有同名属性时,实例属性优先(遮蔽原型属性)。
  • 删除实例属性后,会重新访问到原型属性:delete cat.species

十、总结要点

方案是否继承实例属性是否继承原型方法是否污染父类调用父类构造次数推荐度
构造函数继承 (call/apply)1⭐⭐
原型链继承 (Cat.prototype = new Animal())❌(除非传参)✅(高风险)1(但有副作用)
组合继承2⭐⭐⭐
寄生组合继承(中介函数 F)1⭐⭐⭐⭐⭐

十一、拓展思考

1. ES6 的 class 和 extends 是什么原理?

ES6 的 class 本质上是语法糖,底层仍基于原型链和寄生组合继承。例如:

js
编辑
class Cat extends Animal {
  constructor(name, age, color) {
    super(name, age);
    this.color = color;
  }
}

Babel 会将其编译为类似寄生组合继承的代码。

2. 为什么不用 Object.create()

其实 Object.create(Parent.prototype) 是更现代的写法,效果等同于“空函数中介”:

js
编辑
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

它更简洁,且语义清晰,在支持 ES5+ 的环境中推荐使用


十二、注意事项

  • 永远不要直接赋值 Child.prototype = Parent.prototype,会导致原型污染。
  • 记得修复 constructor,否则 instance.constructor 会指向错误的构造函数。
  • 在严格模式下,未绑定的 this 会报错,确保 call/apply 正确使用。
  • 现代开发建议直接使用 ES6 class,除非需要兼容老旧环境。
  • 中介函数 F 的本质是隔离:让子类原型成为父类原型的“代理”,而非父类的“实例”。

📌 终极口诀
“子类行为看原型链,父类纯净靠中介;
构造属性用 apply,方法继承靠 F 函数。”