在 JavaScript 中,继承是面向对象编程的重要一环。由于 JS 本身没有类(ES6 之前的语法),开发者长期依赖原型链(prototype chain)来实现继承。本文将系统讲解如何通过 call/apply、原型链、以及“空函数中介”等手段实现可靠的继承,并深入剖析不同继承方式对子类实例行为和父类纯净性的影响。
一、构造函数继承(借用构造函数)
核心思想
使用 call 或 apply 在子类构造函数中调用父类构造函数,从而让子类实例拥有父类的实例属性。
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;
缺陷
-
父类构造函数被调用了两次:
new Animal()设置原型时调用一次;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.prototype → Parent.prototype → Object.prototype → null
示例对比
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.prototype是Parent的实例,导致父类实例可能读到子类原型的属性(逻辑 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 函数。”