JavaScript 原型继承全流程深度拆解:从 call/apply 到寄生组合式继承

41 阅读3分钟

就算现在大家都用 class 写继承,只要你打开 Vue 2/3、React、jQuery、Element-Plus、Ant Design 等任意主流框架源码,底层实现继承的地方,十有八九还是寄生组合式继承。

今天我们就用你自己的笔记和代码,一步一步把原型继承的所有坑和最优解全部讲透。

一、第一步:构造函数继承 —— 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;
}

const cat = new Cat('小白', 1, '黑色');
console.log(cat); 
// {name: '小白', age: 1, color: '黑色'}

优点:实例属性完美继承,互不干扰 缺点:只能拿到实例属性,Animal.prototype 上的方法完全拿不到

二、最大雷区:直接赋值原型(你第一个例子的大坑)

你第一个 HTML 里就是经典错误写法:

JavaScript

Cat.prototype = Animal.prototype;   // 直接指向同一个对象
Cat.prototype.constructor = Cat;

看起来 cat.species 能拿到了,但只要你在子类加个方法:

JavaScript

Cat.prototype.eat = function() { console.log('吃鱼'); };

灾难发生:

JavaScript

const animal = new Animal('大黄', 5);
animal.eat(); // 竟然也输出了 “吃鱼”!!

这就是原型污染,父类被你不小心改了,生产环境绝对不允许,面试直接红灯。

三、正确姿势:空对象做中介(你第二个和第四个例子已经非常接近了)

我们来把你第二个 HTML 里的代码稍作整理,就是业界经典写法:

JavaScript

function Animal(name, age) {
  this.name = name;
  this.age = age;
}
Animal.prototype.species = '动物';

function Cat(name, age, color) {
  Animal.call(this, name, age);
  this.color = color;
}

// 核心:用一个空函数做中介
function inheritPrototype(Child, Parent) {
  const F = function() {};           // 中介函数
  F.prototype = Parent.prototype;    // 借原型
  Child.prototype = new F();         // 实例化得到一个干净对象
  Child.prototype.constructor = Child; // 修复 constructor
}

// 执行继承
inheritPrototype(Cat, Animal);

// 现在可以安全地在子类原型上添加方法
Cat.prototype.eat = function() {
  console.log('eat fish');
};

const cat = new Cat('小白', 1, '黑色');
console.log(cat.species); // '动物'
cat.eat();                // 'eat fish'

// 验证父类原型没被污染
const animal = new Animal('大黄', 5);
console.log(animal.eat); // undefined → 安全!

这就是大名鼎鼎的寄生式继承 + 组合继承的核心实现,jQuery、Vue 2、Element 源码里都用过类似手法。

四、组合继承的唯一小缺陷(很多人忽略)

上面的 inheritPrototype 虽然完美,但有一个极小缺陷:

在 new F() 这一步,实际上又执行了一次 Parent 的构造函数(虽然里面没代码),所以整个继承过程父类构造函数被执行了两次:

  1. new F() 时一次(虽然没参数)
  2. Cat 构造函数里 Animal.call(this, ...) 一次

99.99% 的业务场景完全可以忽略,但在构造函数有副作用(比如发网络请求、修改全局状态)时就会出大问题。

五、终极最优解:寄生组合式继承(Holy Grail 圣杯模式)

JavaScript

function Animal(name, age) {
  this.name = name;
  this.age = age;
}
Animal.prototype.getName = function() {
  return this.name;
};

function Cat(name, age, color) {
  Animal.call(this, name, age);  // 只执行这一次
  this.color = color;
}

// 最完美的继承方式
Cat.prototype = Object.create(Animal.prototype);
Cat.prototype.constructor = Cat;   // 修复 constructor

Cat.prototype.getColor = function() {
  return this.color;
};

const cat = new Cat('小白', 1, '黑色');
console.log(cat.getName());     // 小白
console.log(cat.getColor());    // 黑色
console.log(cat instanceof Animal); // true

完美解决了所有问题:

  • 父类构造函数只执行一次
  • 原型链完全正确
  • 没有任何污染
  • 性能最高
  • 代码最简洁

六、JS 是动态语言,实例属性会遮蔽原型属性

JavaScript

function Cat() {}
Cat.prototype.species = '猫科动物';

const cat = new Cat();
cat.species = 'hello';     // 在实例上新增属性

console.log(cat.species);           // 'hello'      → 实例属性
console.log(Cat.prototype.species); // '猫科动物'  → 原型属性还在

这正是为什么我们要把方法放在原型上(节省内存),把个性化数据放在实例上。

七、ES6 class 底层到底干了什么?

JavaScript

class Animal {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }
  getName() { return this.name; }
}

class Cat extends Animal {
  constructor(name, age, color) {
    super(name, age);
    this.color = color;
  }
  getColor() { return this.color; }
}

你以为用 class 就摆脱原型了?Babel 转成 ES5 后,核心代码就是:

JavaScript

Cat.prototype = Object.create(Animal.prototype);
Cat.prototype.constructor = Cat;

所以 class extends 本质就是寄生组合式继承的语法糖!

八、完整终极工具函数(可直接拷贝到项目中使用)

JavaScript

// 一行搞定最完美的继承
function inherit(Child, Parent) {
  Child.prototype = Object.create(Parent.prototype);
  Child.prototype.constructor = Child;
  // 可选:方便调用父类方法
  Child.super = Parent;
}

// 使用示例
inherit(Cat, Animal);