JavaScript 继承进化史:从原型链的迷雾到完美的寄生组合
前言
在 JavaScript 的世界里,继承是一个既迷人又令人头疼的话题。它不像 Java 或 C++ 那样有着清晰的“类”与“子类”的界限,而是基于一种独特的**原型(Prototype)**机制。
很多开发者在初学时会陷入这样的困惑:“为什么我的子类修改了方法,父类也变了?”、“constructor 到底指向谁?”、“组合继承为什么调用了两次构造函数?”
本文将通过层层递进的方式,带你从零开始,彻底拆解 JavaScript 继承的六种演变形态。我们将结合真实的代码陷阱,揭示那些看似可行实则致命的“伪继承”,并最终抵达现代 JavaScript 继承的最佳实践。
第一章:基石——理解 prototype 与 constructor 的爱恨情仇
在谈论继承之前,我们必须先厘清两个核心概念:原型对象(prototype)和构造函数(constructor)。这是所有继承模式的 DNA。
1.1 默认的甜蜜关系
当你定义一个函数时,JavaScript 引擎会自动为其创建一个 prototype 对象。这个对象上默认有一个 constructor 属性,指回函数本身。
function Cat(name) {
this.name = name;
}
// 此时关系完美:
// 1. Cat.prototype.constructor === Cat (true)
// 2. 实例 cat 通过原型链访问到的 constructor 也是 Cat
const cat = new Cat('Mimi');
console.log(cat.constructor === Cat); // true
关键点:实例本身没有 constructor 属性,它是通过 __proto__ 链条找到 Cat.prototype.constructor 的。
1.2 致命的断裂:直接替换原型
这是新手最容易踩的坑。当你为了添加方法而直接用一个对象字面量替换 prototype 时,原本的 constructor 链接就断了。
Cat.prototype = {
sayHello: function() { console.log('Meow'); }
};
const badCat = new Cat('Tom');
// 灾难发生:
console.log(badCat.constructor === Cat); // false!
console.log(badCat.constructor === Object); // true!
// 因为对象字面量 {} 是由 Object 构造的,constructor 默认指向 Object
教训:任何手动操作原型的继承方案,第一步必须是修复 constructor,否则类型判断和新实例创建都会出错。
第二章:继承的六个阶段——从粗糙到完美
JavaScript 的继承发展史,就是一部不断修补漏洞、追求性能与隔离性的进化史。
第一阶段:原型链继承 (Prototypal Inheritance)
核心思想:让子类的原型对象,等于父类的一个实例。
function Animal() {
this.species = '动物';
}
Animal.prototype.eat = function() { console.log('eating'); };
function Cat() {}
// 关键代码:子类原型 = 父类实例
Cat.prototype = new Animal();
Cat.prototype.constructor = Cat; // 记得修复!
const cat = new Cat();
console.log(cat.species); // '动物' (成功继承)
cat.eat(); // 'eating' (成功继承)
- 优点:简单,易于实现,父类原型上的方法都能被继承。
- 缺点:
- 引用类型共享:如果父类属性是引用类型(如数组),所有子类实例共享同一个数组。修改一个,全部变。
- 无法传参:创建子类实例时,无法向父类构造函数传递参数。
第二阶段:构造函数继承 (Constructor Inheritance / 借用构造函数)
核心思想:在子类构造函数中,使用 call 或 apply 调用父类构造函数。
function Animal(name) {
this.name = name;
this.colors = ['red', 'blue']; // 引用类型
}
function Cat(name, color) {
// 关键代码:借用父类构造函数,绑定 this
Animal.call(this, name);
this.color = color;
}
const cat1 = new Cat('Mimi', 'white');
const cat2 = new Cat('Tom', 'black');
cat1.colors.push('green');
console.log(cat1.colors); // ['red', 'blue', 'green']
console.log(cat2.colors); // ['red', 'blue'] (互不影响!解决了引用共享问题)
- 优点:解决了引用类型共享问题;可以向父类传参。
- 缺点:
- 方法无法复用:父类原型上的方法(如
eat)子类无法继承。每次创建实例,方法都在实例内部重新定义(如果定义在构造函数内),或者根本找不到(如果定义在原型上)。 - 不是真正的继承:子类只是调用了父类函数,原型链上没有连接。
- 方法无法复用:父类原型上的方法(如
第三阶段:组合继承 (Combination Inheritance)
核心思想:取长补短。既用原型链继承方法,又用构造函数继承属性。
function Animal(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
Animal.prototype.eat = function() { console.log('eating'); };
function Cat(name, color) {
// 1. 借用构造函数继承属性 (第二次调用 Animal)
Animal.call(this, name);
this.color = color;
}
// 2. 原型链继承方法 (第一次调用 Animal)
Cat.prototype = new Animal();
Cat.prototype.constructor = Cat;
Cat.prototype.sayHello = function() { console.log('hello'); };
- 优点:融合了前两者的优点,既能传参、隔离引用,又能复用原型方法。是 ES5 中最常用的模式。
- 缺点:效率低。父类构造函数
Animal被调用了两次(一次在Cat.prototype = new Animal(),一次在Cat内部)。这会导致父类实例属性在原型上也存在一份冗余数据。
第四阶段:原型式继承 (Prototypal Inheritance - Object.create)
核心思想:直接基于一个对象创建新对象。这是 Object.create 的本质。
function createObj(o) {
function F() {}
F.prototype = o;
return new F();
}
// 等价于:const newObj = Object.create(oldObj);
- 适用场景:当你只是想浅拷贝一个对象,而不是进行严格的“类”继承时。它本质上还是原型链继承,同样存在引用共享的问题。
第五阶段:寄生式继承 (Parasitic Inheritance)
核心思想:在原型式继承的基础上,增强对象功能。
function createAnother(original) {
const clone = Object.create(original); // 原型式继承
clone.sayHi = function() { console.log('hi'); }; // 增强
return clone;
}
- 缺点:同样存在方法重复定义的问题,类似构造函数继承。
第六阶段:寄生组合式继承 (Parasitic Combination Inheritance) —— 终极形态
核心思想:解决组合继承中“调用两次父类构造函数”的痛点。
秘诀:使用 Object.create 来建立原型链,而不是 new Animal()。这样只链接原型,不执行构造函数。
function Animal(name) {
this.name = name;
this.colors = ['red', 'blue'];
}
Animal.prototype.eat = function() { console.log('eating'); };
function Cat(name, color) {
// 只调用一次:继承属性
Animal.call(this, name);
this.color = color;
}
// 【核心魔法】
// 1. 创建一个空对象,其 __proto__ 指向 Animal.prototype
// 2. 不执行 Animal 构造函数,避免冗余属性和副作用
Cat.prototype = Object.create(Animal.prototype);
// 3. 必须修复 constructor,否则指向 Object
Cat.prototype.constructor = Cat;
Cat.prototype.sayHello = function() { console.log('hello'); };
- 优点:
- 最高效:父类构造函数只调用一次。
- 最纯净:原型链上不会有父类的实例属性。
- 功能完整:完美支持传参、引用隔离、方法复用。
- 地位:这是 ES5 环境下最完美的继承方案。现代 Babel 转译 ES6
class语法时,底层使用的正是这种模式。
第三章:避坑指南——那个致命的“引用赋值”
在之前的对话中,我们提到了一种错误的写法,它看起来像组合继承,实则是“原型共享”的灾难。让我们再次审视这个反例,以此作为警戒。
❌ 错误示范:直接赋值原型
// 危险代码
Cat.prototype = Animal.prototype;
Cat.prototype.constructor = Cat;
Cat.prototype.sayHello = function() { ... };
为什么这是错的?
在 JavaScript 中,对象赋值是引用传递。
Cat.prototype = Animal.prototype 意味着这两个变量指向内存中的同一个对象。
后果演示:
- 污染父类:当你给
Cat.prototype添加sayHello时,Animal.prototype也瞬间拥有了sayHello。所有的动物(包括狗、鸟)都会说 "Hello",这违反了逻辑隔离。 - Constructor 错乱:当你执行
Cat.prototype.constructor = Cat时,你实际上修改了Animal.prototype.constructor。从此,new Animal().constructor竟然返回Cat!类型判断全面崩塌。 - 无法重写:如果猫想重写
species属性,直接修改Cat.prototype.species会导致所有动物的species都变成“猫”。
正确做法:
永远使用 Object.create(Animal.prototype)。它会创建一个新对象,这个新对象的“爸爸”是 Animal.prototype,但它自己是一个独立的个体。修改它,不会影响父类。
第四章:ES6 Class 语法——糖衣下的真相
到了 ES6,JavaScript 引入了 class 关键字,让继承看起来像传统面向对象语言一样优雅。
class Animal {
constructor(name) {
this.name = name;
}
eat() {
console.log('eating');
}
}
class Cat extends Animal {
constructor(name, color) {
super(name); // 等价于 Animal.call(this, name)
this.color = color;
}
sayHello() {
console.log('hello');
}
}
本质揭秘:
class 只是语法糖(Syntactic Sugar)。如果你查看 Babel 转译后的代码,会发现 extends 关键字底层依然在使用寄生组合式继承的逻辑:
Object.create(Animal.prototype)建立原型链。super()调用父类构造函数。- 自动处理
constructor的修复。
建议:
在现代开发中(2026年及以后),优先使用 ES6 class 语法。它不仅可读性强,而且浏览器和构建工具已经帮我们处理好了所有复杂的原型链细节和边界情况。但在阅读旧代码、编写底层库或面试时,理解底层的“寄生组合式继承”依然是区分初级与高级开发者的分水岭。
结语:继承的灵魂是“委托”
回顾 JavaScript 的继承进化史,我们发现其核心并非“复制”,而是**“委托”(Delegation)**。
- 当我们访问
cat.eat()时,cat自己没有,它委托给Cat.prototype。 Cat.prototype也没有,它委托给Animal.prototype。- 这条委托链就是原型链。
无论是古老的原型链继承,还是完美的寄生组合式继承,亦或是现代的 class,它们的目标都是一致的:
- 代码复用:方法只需定义一次。
- 内存高效:避免每个实例都拷贝一份方法。
- 逻辑隔离:子类能自由扩展而不污染父类。
理解了 prototype.constructor 的微妙关系,避开了“引用赋值”的深坑,掌握了 Object.create 的精髓,你就真正掌握了 JavaScript 面向对象的灵魂。
最后的一句话建议: 写代码时,请用
class保持优雅; 读源码时,请用“寄生组合式继承”的思维去洞察本质; 永远不要直接让Sub.prototype = Super.prototype,除非你想制造一场灾难。