在 ES6 class 语法糖普及之前,JavaScript 的继承机制一直是前端面试与架构设计中的"深水区"。理解这一演进过程,不仅是为了应对面试,更是为了理解 JS 引擎如何处理对象之间的内存关系与原型链(Prototype Chain)。
本文将结合具体代码实例,剖析从"构造函数窃取"到"原型直接赋值",再到"圣杯模式(Holy Grail Pattern)"的演进之路,最后到达ES6的终极解法。
一、 起点:构造函数窃取(借用构造函数)
在实现继承的第一步,我们需要解决的是实例属性(Instance Properties)的继承问题。
通过 call 或 apply 来"借用"父类构造函数的做法。
JavaScript
// 父类
function Animal(name, age) {
this.name = name;
this.age = age;
}
// 子类
function Cat(name, age, color){
// 核心:将 Animal 的 this 指向当前 Cat 的实例
// Animal.call(this, name, age);
Animal.apply(this, [name, age]);
this.color = color;
}
底层解析
- 原理 :
apply和call改变了函数运行时的this指向。当new Cat()执行时,Animal内部的this.name = name实际上是执行在了新创建的Cat实例上。 - 局限性 :这仅仅解决了"属性"的继承。父类原型对象(
Animal.prototype)上的方法并没有被继承过来。cat实例无法访问Animal.prototype上的内容。
二、 迷途:原型链连接的"坑"
为了继承父类的方法,我们需要操作原型链。这里有两个常见的"错误"或"有缺陷"的尝试。
尝试 1:直接赋值(引用污染)
最简单的想法是将父类的原型直接赋值给子类:
JavaScript
Cat.prototype = Animal.prototype;
- 致命缺陷 :
Cat.prototype和Animal.prototype指向了内存中的同一个对象。 - 后果 :如果你给
Cat添加一个方法Cat.prototype.meow = ...,那么所有的Animal实例也会拥有这个方法。这破坏了封装性,且会导致constructor指向混乱,子类实例无法通过instanceof正确区分是Cat还是Animal。
尝试 2:实例化父类作为原型
这是 ES5 早期常见的做法:
JavaScript
Cat.prototype = new Animal();
- 缺陷 1:副作用(Side Effects) 。
new Animal()会执行父类构造函数。如果Animal构造函数里有复杂的逻辑(如 HTTP 请求、DOM 操作或繁重的计算),在定义Cat类时就会被提前触发,且此时往往无法传递正确的参数。 - 缺陷 2:属性冗余 。父类的实例属性(如
name)会被写入Cat.prototype中。虽然子类实例自身的name会遮蔽(Shadowing)它,但原型链上残留了一份多余的数据。
三、 手写extend:利用空对象作为中介(圣杯模式)
为了解决上述继承父类方法问题,我们需要一种方式:既能继承原型上的方法,又不执行父类构造函数,且断开引用联系。
利用一个空函数 F 作为中介的方案,这通常被称为"圣杯模式"或"寄生组合式继承"。
核心实现原理
JavaScript
function extend(Parent, Child) {
var F = function(){}; // 1. 创建一个空函数(中介)
F.prototype = Parent.prototype; // 2. 中介的原型指向父类原型
Child.prototype = new F(); // 3. 子类原型指向中介的实例
Child.prototype.constructor = Child; // 4. 修正构造器指向
}
深度剖析
-
为什么用空函数
F?new F()的开销极小。因为它内部没有任何代码,不会像new Animal()那样产生副作用或多余的属性。- 它充当了缓冲区(Buffer)。
-
原型链图解
textCat 实例 -> Cat.prototype (即 F 的实例) -> F.prototype -> Animal.prototype当我们修改
Cat.prototype时,实际修改的是那个F的实例。因为F的实例与F.prototype是两个独立的对象(通过__proto__链接),所以不会污染Animal的原型。 -
Constructor 的修正
Child.prototype = new F()这一步会导致Child.prototype.constructor丢失(顺着原型链找到了Animal)。- 必须执行
Child.prototype.constructor = Child来修正。这只修改了F实例上的属性,保证了Animal.prototype的纯洁性。
四、 手写extend完整代码演示
一个健壮的 ES5 继承实现如下:
JavaScript
// 封装继承函数
function extend(Parent, Child){
var F = function(){};
F.prototype = Parent.prototype;
Child.prototype = new F();
Child.prototype.constructor = Child;
}
// 1. 父类
function Animal(name, age) {
this.name = name;
this.age = age;
}
Animal.prototype.species = '动物';
// 2. 子类
function Cat(name, age, color){
// 构造函数借用:继承属性
Animal.apply(this, [name, age]);
this.color = color;
}
// 3. 实施继承
extend(Animal, Cat);
// 4. 子类扩展方法(安全,不影响父类)
Cat.prototype.eat = function() {
console.log("eat jerry");
}
const cat = new Cat('加菲猫', 2, '黄色');
console.log(cat.species); // "动物" (来自原型链)
console.log(cat.name); // "加菲猫" (来自实例)
五、终极解法
目前为止,我们深入剖析了如何利用"空函数中介(圣杯模式)"来解决 JavaScript 继承中的痛点。但这毕竟是"手写"的底层代码。随着 JavaScript 语言标准的发展,我们有了更优雅、更官方的替代方案。
六、 官方标准化:ES5 的 Object.create()
在"圣杯模式"中,我们为了创建一个"干净"的对象(即不执行父类构造函数,但原型指向父类原型),不得不手动定义一个空函数 F。
ES5 标准化了一个新方法 Object.create(),它完美地替代了那个空函数 F 的角色。
核心原理
Object.create(proto) 会创建一个新对象,并将这个新对象的 __proto__ 指向传入的 proto 参数。
这就意味着,我们不再需要手动写 function F(){} 了。
代码实现
使用 Object.create() 优化后的继承代码如下:
JavaScript
function Animal(name, age) {
this.name = name;
this.age = age;
}
Animal.prototype.species = '动物';
function Cat(name, age, color) {
Animal.call(this, name, age); // 1. 继承实例属性
this.color = color;
}
// 2. 继承原型方法 (替代了复杂的 extend 函数)
// 创建一个新对象,其原型指向 Animal.prototype,且不执行 Animal 构造函数
Cat.prototype = Object.create(Animal.prototype);
// 3. 修正 constructor
Cat.prototype.constructor = Cat;
// 测试
var cat = new Cat('汤姆', 3, '蓝色');
console.log(cat instanceof Cat); // true
console.log(cat instanceof Animal); // true
优点:
- 语义清晰:代码更少,意图更明确。
- 标准化:引擎层面的支持,无需引入第三方工具函数。
- 纯净 :完全避免了调用
new Animal()带来的副作用。
七、 现代终极方案:ES6 class 与 extends
虽然 Object.create 解决了原型链连接的问题,但写法上依然是"构造函数 + 原型赋值"的分离写法,这与 Java、C++ 等面向对象语言的直观感受相去甚远。
ES6 引入了 class 关键字,但这不仅仅是语法糖,它让继承变得极其简单且不易出错。
代码对比
让我们看看用 ES6 重写上面的逻辑是多么简洁:
JavaScript
// 定义父类
class Animal {
constructor(name, age) {
this.name = name;
this.age = age;
}
// 定义在原型上的方法 (相当于 Animal.prototype.eat)
eat() {
console.log('Nom nom nom');
}
}
// 定义子类
class Cat extends Animal {
constructor(name, age, color) {
// super 相当于 Animal.call(this, name, age)
// 注意:在 ES6 中,必须先调用 super() 才能使用 this
super(name, age);
this.color = color;
}
// 子类独有方法
meow() {
console.log('Meow~');
}
}
const cat = new Cat('加菲', 5, '橙色');
cat.eat(); // 调用父类方法
深度解析:extends 到底做了什么?
虽然写法变了,但 底层依然是原型链 。class 语法糖背后帮我们自动处理了以下细节:
- 构造函数借用 :
super(name)自动调用了父类的constructor并绑定了this。 - 原型链连接 :
extends自动设置了Cat.prototype的原型为Animal.prototype(相当于Object.create)。 - 静态属性继承 :ES6 的
class甚至允许子类继承父类的静态方法(static methods),这是 ES5 手写继承很难模拟的特性。 - Constructor 修正 :你不再需要手动修复
Cat.prototype.constructor,引擎自动搞定。
六、 总结:JS 继承的完整进化史
JavaScript 的继承本质是对**原型链(Prototype Chain)**的操控。
-
构造函数借用 (
call/apply) 解决了数据(实例属性)的独立性。 -
圣杯模式 (空函数中介)解决了行为(原型方法)的继承,同时避免了性能浪费和引用污染。
回顾整个历程,我们可以清晰地看到 JavaScript 在复用代码这条路上的探索:
-
原型直接赋值 (
Child.prototype = Parent.prototype)- ❌ 错误:引用共享,子类修改污染父类。
-
父类实例赋值 (
Child.prototype = new Parent())- ❌ 缺陷:父类构造函数被多余执行,属性冗余。
-
圣杯模式 / 寄生组合式 (空函数中介
F)- ✅ ES5 最佳实践(手动版):完美解决引用和副作用问题。
-
Object.create()- ✅ ES5 最佳实践(API版):简化了空函数中介的写法。
-
class/extends- 🚀 现代标准:语法糖封装了所有细节,语义化最强,开发效率最高。
虽然现代 ES6 提供了 class 和 extends 关键字,底层引擎依然是在做类似的"圣杯模式"操作。理解这一过程,能让你在面对复杂的对象关系和内存问题时,拥有"透视"代码的能力。
技术建议:在现代项目开发中(React/Vue/Node.js),请直接使用 class 和 extends。但理解前几步的原理,能让你深刻理解"JS 对象只是键值对的集合"以及"原型链指针"的本质,这是区分"API 调用者"与"工程师"的关键。