JavaScript 继承的那些血泪史:从 new Animal() 的万年大坑,到真正优雅的圣杯模式
写这篇文章的初衷,是我发现 2025 年了,还有无数人(包括很多大厂面试题)依然在用
Child.prototype = new Parent()这种超级大坑。
今天就一次性把原型继承的所有坑、所有最佳实践、所有底层原理全部讲透,让你看完这篇就再也不掉坑里。
一、一个让你当场崩溃的面试题(99% 的人都会错)
function Animal() {
this.colors = ['black', 'white'];
}
Animal.prototype.getColor = function() {
return this.colors;
};
function Cat() {}
Cat.prototype = new Animal(); // 经典错误写法!
Cat.prototype.constructor = Cat;
const cat1 = new Cat();
const cat2 = new Cat();
cat1.colors.push('orange');
console.log(cat1.colors); // ["black", "white", "orange"]
console.log(cat2.colors); // ["black", "white", "orange"] ← 卧槽?!
问:为什么 cat2 的颜色也被改了?
这就是 JavaScript 继承里最臭名昭著的坑:直接用 new Parent() 做原型,会把父类构造函数里的引用类型属性挂到子类原型上,导致所有子类实例共享同一份引用!
这篇文章的使命,就是让你彻底明白为什么会这样,以及怎么永远避免这个坑。
二、JavaScript 继承的 5 种姿势(从地狱到天堂)
| 方式 | 是否执行父构造函数 | 是否污染原型 | 引用类型是否共享 | 推荐指数 |
|---|---|---|---|---|
| 原型链继承(直接赋值原型) | 否 | 是 | 严重共享 | ★☆☆☆☆ |
| 构造函数继承(call/apply) | 是 | 否 | 完全独立 | ★★★☆☆ |
| 组合继承(call + new Parent()) | 是(两次!) | 是 | 共享 | ★★☆☆☆ |
| 寄生组合继承(圣杯模式) | 是(一次) | 否 | 完全独立 | ★★★★★ |
| ES6 class extends | 是(一次) | 否 | 完全独立 | ★★★★★ |
我们一个个来拆。
1. 最原始的原型链继承(别再用了!)
function Cat() {}
Cat.prototype = Animal.prototype; // 直接共享原型
const cat = new Cat();
cat.colors = ['orange'];
console.log(Animal.prototype.colors); // orange ← 连父类都被污染了!
后果:子类改原型 = 父类也被改,所有类共用一个原型,彻底崩盘。
2. 构造函数继承(call/apply)—— 解决了引用类型共享
function Cat(name, age, color) {
Animal.call(this, name, age); // 或者 apply(this, arguments)
this.color = color;
}
优点:每个实例的 this.colors 都是独立的
缺点:只能继承实例属性,getColor 这种原型方法拿不到
3. 组合继承(曾经的“标准答案”,现在是大坑)
function Cat(name, age, color) {
Animal.call(this, name, age);
this.color = color;
}
Cat.prototype = new Animal(); // ← 罪恶之源!
Cat.prototype.constructor = Cat;
两大致命问题:
- 执行了两次父类构造函数(一次 call,一次 new)
Cat.prototype上多了colors、name、age等垃圾属性,所有实例共享!
这就是开头那个面试题的根源。
三、圣杯模式的诞生:玉伯用一个空函数拯救了世界
2008 年,淘宝前端大佬玉伯(寿喜耀)看不下去了,写出了传说中的圣杯模式:
function inherit(Child, Parent) {
var F = function() {}; // 中间空函数,关键!
F.prototype = Parent.prototype; // 借用原型
Child.prototype = new F(); // 创建干净的中间实例
Child.prototype.constructor = Child;
Child.prototype.uber = Parent.prototype; // 可选:保留超类指针
}
为什么加一个空函数 F 就安全了?
| 操作 | 直接 new Animal() | new F()(圣杯模式) |
|---|---|---|
| 是否执行 Animal 构造函数 | 是 | 否 |
| Cat.prototype 上有 colors 吗 | 有(污染) | 没有(干净) |
| 原型链是否正确 | 正确 | 正确 |
| 引用类型是否共享 | 共享 | 完全独立 |
本质区别:new F() 创建的对象只继承了原型链,但根本没执行 Animal(),所以不会把 colors 数组挂到 Cat.prototype 上!
完整优化版(邱少羽改进)
Function.prototype.inherit = function(Parent) {
var F = function() {};
F.prototype = Parent.prototype;
this.prototype = new F();
this.prototype.constructor = this;
this.prototype.uber = Parent.prototype;
return this; // 支持链式调用
};
// 使用(优雅到爆)
Cat.inherit(Animal);
现代终极写法(2025 年推荐)
// 寄生组合继承(Object.create 版)
function inherit(Child, Parent) {
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;
}
// 或者直接用 ES6(底层就是这个原理)
class Cat extends Animal {
constructor(name, age, color) {
super(name, age);
this.color = color;
}
}
Object.create 就是圣杯模式的官方实现!
四、真实代码对比
典型的组合继承大坑
Cat.prototype = new Animal(); // 直接执行父构造函数 → 污染!
后果:所有 cat 实例共享 name、age,改一个全改。
正宗圣杯模式(推荐!)
function extend(Child, Parent) {
var F = function(){};
F.prototype = Parent.prototype;
Child.prototype = new F();
Child.prototype.constructor = Child;
}
extend(Cat, Animal);
完美解决所有问题,子类原型干净,引用类型独立。
实例属性会遮蔽原型属性(重要提醒!)
const cat = new Cat();
cat.species = 'hello';
console.log(cat.species); // hello(实例属性)
console.log(Cat.prototype.species); // 猫科动物(原型属性)
易错点:实例属性会“遮蔽”同名原型属性,但不会修改原型!
五、call vs apply:你真的会用吗?
| 方法 | 传参方式 | 适用场景 |
|---|---|---|
| call | fn.call(thisArg, a, b, c) | 参数明确时更清晰 |
| apply | fn.apply(thisArg, [a, b, c]) | 参数是数组时更方便 |
Animal.call(this, name, age);
Animal.apply(this, arguments); // 经典写法
Animal.apply(this, [name, age]); // 更安全(避免 arguments 陷阱)
提醒:arguments 是类数组,不是真数组,某些情况下会被改,建议显式传数组。
六、2025 年你应该怎么写继承?(终极答案)
// 方式1:最推荐(现代寄生组合继承)
function Cat(name, age, color) {
Animal.call(this, name, age);
this.color = color;
}
Cat.prototype = Object.create(Animal.prototype);
Cat.prototype.constructor = Cat;
// 方式2:最优雅(ES6 class)
class Cat extends Animal {
constructor(name, age, color) {
super(name, age);
this.color = color;
}
eat() { console.log('eat Jerry~'); }
}
这两者底层实现完全一致,class 只是语法糖。
七、几个细节小问题
1.组合继承里 Cat.prototype = new Animal() 为什么所有子类实例的引用类型属性会互相影响?
因为 new Animal() 会执行父构造函数,把引用类型(如 colors: [])挂到了 Cat.prototype 上,所有子类实例从同一个原型读取同一个引用 → 改一个全改。
2.圣杯模式和 new Animal() 到底有啥本质区别?
圣杯模式 new 的是一个故意写空的函数 F,执行了也什么属性都不加,产生的中间对象天生干净;new Animal() 会把父类所有实例属性(包括引用类型)污染到子类原型上。
3.圣杯模式的核心点到底是啥?
核心点只有一个:用一个「空构造函数」做中介,让 new 这步“有动作但没副作用”,从而制造出一个只继承原型方法、却不继承任何实例属性的干净对象。
4.两种继承的改动变化
- 组合继承
所有子类实例共享父类引用类型属性(兄弟共享)
改 cat1.colors,cat2.colors 也会变
- 原型链继承
子改引用类型时,所有父类实例 + 所有子类实例 + 父类原型都会改
子类改的根本就是父类原型本身
所有 Animal 实例、所有 Cat 实例、甚至 Animal.prototype 自己都共享同一份数据
八、总结:JavaScript 继承的血泪进化史
| 时代 | 主流写法 | 问题 | 现状 |
|---|---|---|---|
| 2008年前 | 原型链直接赋值 | 严重污染 | 彻底淘汰 |
| 2008-2015 | 组合继承(new Parent()) | 引用类型共享 | 还在害人 |
| 2008 | 玉伯圣杯模式 | 完美 | 经典中的经典 |
| 2012 | Object.create 标准化 | 官方实现圣杯 | 现代标准 |
| 2015+ | class extends | 简洁优雅 | 当前主流 |
记住这三句话,你就再也不会掉坑:
- 永远不要在设置原型时执行父类构造函数(
new Parent()是毒) - 永远使用中间对象(
Object.create或圣杯模式) - 引用类型属性必须用
Parent.call(this)继承到实例
JavaScript 的继承从来不是“类”的继承,而是“原型”的委托。
当你真正理解了圣杯模式背后的中间空对象思想,你就理解了 JavaScript 的灵魂。
愿你从此告别 new Parent() 的黑暗时代,拥抱真正优雅的原型继承!