JavaScript 继承的那些血泪史:从 new Animal() 的万年大坑,到真正优雅的圣杯模式

47 阅读6分钟

JavaScript 继承的那些血泪史:从 new Animal() 的万年大坑,到真正优雅的圣杯模式

写这篇文章的初衷,是我发现 2025 年了,还有无数人(包括很多大厂面试题)依然在用 Child.prototype = new Parent() 这种超级大坑。
今天就一次性把原型继承的所有坑、所有最佳实践、所有底层原理全部讲透,让你看完这篇就再也不掉坑里。


7371e6c646ae0a7919845d85490c9946.jpg

一、一个让你当场崩溃的面试题(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;

两大致命问题

  1. 执行了两次父类构造函数(一次 call,一次 new)
  2. Cat.prototype 上多了 colorsnameage 等垃圾属性,所有实例共享!

这就是开头那个面试题的根源。


三、圣杯模式的诞生:玉伯用一个空函数拯救了世界

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 实例共享 nameage,改一个全改。

正宗圣杯模式(推荐!)
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:你真的会用吗?

方法传参方式适用场景
callfn.call(thisArg, a, b, c)参数明确时更清晰
applyfn.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 只是语法糖。


七、几个细节小问题

fc962ce0cd306c49bc54248e80437e81.jpg

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玉伯圣杯模式完美经典中的经典
2012Object.create 标准化官方实现圣杯现代标准
2015+class extends简洁优雅当前主流

记住这三句话,你就再也不会掉坑

  1. 永远不要在设置原型时执行父类构造函数(new Parent() 是毒)
  2. 永远使用中间对象(Object.create 或圣杯模式)
  3. 引用类型属性必须用 Parent.call(this) 继承到实例

JavaScript 的继承从来不是“类”的继承,而是“原型”的委托。
当你真正理解了圣杯模式背后的中间空对象思想,你就理解了 JavaScript 的灵魂。

愿你从此告别 new Parent() 的黑暗时代,拥抱真正优雅的原型继承!