JavaScript 原型继承全解析:从 call/apply 到寄生组合式继承

3 阅读6分钟

JavaScript 原型继承全解析:从 call/apply 到寄生组合式继承

在 JavaScript 的世界中,继承是实现代码复用和构建复杂对象体系的基石。与 Java 或 C++ 等基于“类”的语言不同,JS 的继承核心在于原型(Prototype)

本文将结合你提供的代码片段,深入浅出地讲解 JS 继承的演进过程,重点剖析 call/apply 的作用、直接继承原型的弊端,以及最终极的解决方案——寄生组合式继承


一、基石:call 与 apply —— 借用构造函数

在讨论复杂的原型链之前,我们先看最基础的构造函数继承

1. 核心概念

callapply 是函数对象的方法,它们的核心作用是:改变函数运行时的 this 指向,并立即执行该函数。

  • 第一个参数:指定 this 要绑定的对象(在继承中,通常绑定为子类的实例 this)。
  • 后续参数:传递给被调用函数的参数。
    • call(this, arg1, arg2, ...):参数逐个传递。
    • apply(this, [arg1, arg2, ...]):参数以数组形式传递。

2. 代码实战

在子类构造函数中,通过 callapply 调用父类构造函数,可以将父类的实例属性(如 name, age)复制到子类实例上。

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

function Cat(name, age, color) {
    // 关键步骤:借用父类构造函数,将 Animal 的属性绑定到 Cat 的 this 上
    // 此时 this 指向的是 new Cat() 创建的空对象
    Animal.call(this, name, age); 
    // 或者使用 apply: Animal.apply(this, [name, age]);
    
    this.color = color; // 子类特有属性
    console.log(this); 
}

const cat = new Cat('加菲猫', 2, '黄色');
console.log(cat.name); // '加菲猫' (继承自 Animal)
console.log(cat.color); // '黄色' (Cat 特有)

✅ 优点:解决了实例属性的继承问题,且可以向父类构造函数传参。 ❌ 缺点:只能继承实例属性,无法继承父类原型(prototype)上的方法(如 Animal.prototype.sayHi)。


二、进阶:原型链继承与它的“坑”

为了继承父类原型上的方法(如 species),我们需要建立原型链。

1. 错误的尝试:直接赋值

很多初学者会直接让子类的原型等于父类的原型:

// ❌ 错误示范
Cat.prototype = Animal.prototype;
Cat.prototype.constructor = Cat;

const cat = new Cat('加菲猫', 2, '黄色');
cat.species = '猫科'; 

// 灾难发生了!
console.log(Animal.prototype.species); // '猫科' 
// 修改子类原型,竟然影响了父类原型!因为它们指向同一个内存地址。

后果:子类对原型的任何修改(添加方法、修改属性)都会污染父类原型,导致所有 Animal 实例都受到影响。

2. 改进版:使用实例作为中介

为了解决引用共享问题,我们让子类的原型指向父类的一个实例

// ✅ 改进:Cat.prototype 指向 Animal 的实例
Cat.prototype = new Animal(); 
Cat.prototype.constructor = Cat;

✅ 优点

  1. 子类可以访问父类原型上的方法(species)。
  2. 子类修改自己的原型属性,不会影响父类原型(因为 new Animal() 创建了一个新对象)。

❌ 缺点

  1. 执行了父类构造函数:在 new Animal() 时,父类构造函数被执行了。如果父类构造函数有副作用(如发送网络请求、操作 DOM)或需要必填参数,这会很麻烦。
  2. 属性冗余:父类实例属性(name, age)会被添加到 Cat.prototype 上,而不仅仅是 cat 实例上,造成内存浪费和逻辑混淆。

三、终极方案:寄生组合式继承

为了解决上述所有问题(既要继承实例属性,又要继承原型方法,还不能执行父类构造函数,还不能污染父类原型),JavaScript 社区总结出了寄生组合式继承(Parasitic Combination Inheritance)

这是目前最成熟、性能最好的继承模式,也是 ES6 class 语法背后的实现原理。

1. 核心思想

  1. 借用构造函数:在子类构造函数中用 call/apply 继承实例属性。
  2. 空函数中介:创建一个空函数 F,让 F.prototype 指向 Parent.prototype
  3. 实例化中介:让 Child.prototype = new F()
    • 这样 Child.prototype 的原型指向了 Parent.prototype
    • Child.prototype 本身是一个干净的对象,没有执行 Parent 构造函数,也没有多余的实例属性。

2. 完整代码实现

function Animal(name, age) {
    this.name = name;
    this.age = age;
    this.hobbies = ['sleep']; // 引用类型属性测试
}
Animal.prototype.species = '动物';
Animal.prototype.sayName = function() {
    console.log('My name is ' + this.name);
};

function Cat(name, age, color) {
    // 1. 借用构造函数:继承实例属性
    Animal.call(this, name, age);
    this.color = color;
}

// 2. 核心工具函数:实现原型链的无损连接
function extend(Child, Parent) {
    var F = function() {};      // 创建空函数作为中介
    F.prototype = Parent.prototype; // 让 F 的原型指向父类原型
    
    Child.prototype = new F();  // 关键点:Child 原型指向 F 的实例
                                // 这个实例的原型链指向 Parent.prototype
                                // 但实例本身不包含 Parent 的实例属性
    
    Child.prototype.constructor = Child; // 修正构造函数指向
}

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

// 3. 给子类原型添加特有方法
Cat.prototype.eat = function() {
    console.log("eat jerry");
};

// --- 测试验证 ---
const cat = new Cat('加菲猫', 2, '黄色');

// 验证实例属性
console.log(cat.name);    // '加菲猫'
console.log(cat.color);   // '黄色'
console.log(cat.hobbies); // ['sleep']

// 验证原型方法
console.log(cat.species); // '动物' (来自 Animal.prototype)
cat.sayName();            // 'My name is 加菲猫'
cat.eat();                // 'eat jerry' (Cat 特有)

// 验证隔离性 (最重要的一点)
cat.hobbies.push('play');
const animal = new Animal('Generic', 5);
console.log(animal.hobbies); // ['sleep'] (不受 cat 影响,说明不是共享引用)

// 验证原型链结构
console.log(cat instanceof Cat);    // true
console.log(cat instanceof Animal); // true
console.log(cat.constructor === Cat); // true

3. 为什么它是完美的?

特性构造函数继承原型链继承 (new Parent)寄生组合式继承
继承实例属性✅ 支持✅ 支持 (但在原型上)✅ 支持 (在实例上)
继承原型方法❌ 不支持✅ 支持✅ 支持
传参能力✅ 支持❌ 困难✅ 支持
执行父类构造✅ 1次✅ 1次 (多余)✅ 1次 (仅必要)
原型污染N/A❌ 风险 (若直接赋值)完全隔离
instanceof❌ 失败✅ 成功✅ 成功

四、深入理解:原型链的层级

通过 extend 函数,我们构建了如下完美的原型链:

cat (实例)
  │
  └─ __proto__ ──> Cat.prototype (由 new F() 创建的空对象)
                      │
                      └─ __proto__ ──> Animal.prototype
                                          │
                                          └─ __proto__ ──> Object.prototype
                                                              │
                                                              └─ __proto__ ──> null
  • 当访问 cat.species 时:
    1. cat 自身没有 -> 查找 Cat.prototype -> 没有。
    2. 查找 Cat.prototype.__proto__ (即 Animal.prototype) -> 找到!
  • 当修改 Cat.prototype.eat 时:
    • 只修改了 Cat.prototype 对象,完全不会触碰 Animal.prototype

五、总结

JavaScript 的继承机制虽然灵活,但也容易踩坑。

  1. call / apply 是继承实例属性的关键,区别仅在于参数传递形式(列表 vs 数组)。
  2. 直接赋值原型 (Child.prototype = Parent.prototype) 是大忌,会导致父子类原型共享,互相污染。
  3. 简单的原型链 (Child.prototype = new Parent()) 会多余执行一次父类构造函数,且可能带来参数初始化的问题。
  4. 寄生组合式继承 是最佳实践:
    • 利用 call 继承属性。
    • 利用空函数中介 (F) 搭建原型链,既复用了原型方法,又避免了执行父类构造函数和原型污染。

如今,虽然我们可以直接使用 ES6 的 classextends 语法糖,但理解其背后的寄生组合式继承原理,对于深入掌握 JavaScript 语言特性、排查复杂 Bug 以及阅读框架源码依然至关重要。