JavaScript 中的组合继承与寄生组合继承:大厂面试必问的最优继承方案

在JavaScript的世界里,继承是一个永恒的话题,也是衡量一个开发者对语言底层机制理解深度的重要标准。尤其是在大厂面试中,面试官往往会通过对继承模式的探讨,来洞察你对原型链、构造函数、this绑定以及性能优化的认知。今天,我们将深入探讨JavaScript中最常见的两种继承模式:组合继承(Combination Inheritance)与寄生组合继承(Parasitic Combination Inheritance),并揭示后者为何被称为“最优”继承方案。


JavaScript继承的基石:原型链与构造函数

在深入探讨具体的继承模式之前,我们必须先巩固JavaScript继承的基石:原型链(Prototype Chain)和构造函数(Constructor Function)。JavaScript是一种基于原型的语言,它没有传统意义上的类(ES6引入的class关键字只是语法糖,其底层依然是原型继承)。

构造函数

构造函数是用于创建特定类型对象的函数。当使用new关键字调用一个函数时,这个函数就成为了构造函数。

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

const person1 = new Person("Alice", 30);
console.log(person1.name); // Alice

new Person()执行时,会发生以下几件事情:

  1. 创建一个新的空对象。
  2. 将这个新对象的[[Prototype]](内部属性,在旧版浏览器中可通过__proto__访问)链接到Person.prototype对象。
  3. Person函数的作用域(this)绑定到这个新对象上。
  4. 执行Person函数,为新对象添加属性。
  5. 如果Person函数没有显式返回其他对象,则默认返回这个新对象。

原型对象 (prototype)

每个函数都有一个prototype属性,它是一个对象,包含所有实例共享的属性和方法。

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

Person.prototype.sayHello = function() {
    console.log(`Hello, my name is ${this.name}`);
};

const person1 = new Person("Alice", 30);
const person2 = new Person("Bob", 25);

person1.sayHello(); // Hello, my name is Alice
person2.sayHello(); // Hello, my name is Bob

console.log(person1.sayHello === person2.sayHello); // true (方法被所有实例共享)

原型链 (__proto__ / Object.getPrototypeOf())

当访问一个对象的属性或方法时,JavaScript会首先在对象自身上查找。如果找不到,它会沿着对象的[[Prototype]]链接向上查找,直到找到该属性或到达原型链的顶端(Object.prototype)。这就是原型链。

function Person(name) {
    this.name = name;
}
Person.prototype.sayName = function() {
    console.log(this.name);
};

const person = new Person("Charlie");
console.log(person.__proto__ === Person.prototype); // true
console.log(Object.getPrototypeOf(person) === Person.prototype); // true
console.log(Person.prototype.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__ === null); // true,原型链的顶端

理解这些基础是理解后续继承模式的关键。


继承模式的演进:从缺陷到优化

JavaScript的继承模式经历了一个从简单到复杂,从有缺陷到优化的演进过程。每一种新模式的出现,都是为了解决前一种模式的局限性。

1. 原型链继承 (Prototype Chaining Inheritance)

这是最基本的继承方式,通过让子类的原型指向父类的实例来实现。

// 父类构造函数
function SuperType(name) {
    this.name = name;
    this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function() {
    console.log(this.name);
};

// 子类构造函数
function SubType(name, age) {
    this.age = age;
}

// 核心:让 SubType 的原型指向 SuperType 的一个实例
SubType.prototype = new SuperType(); // 关键一步
SubType.prototype.constructor = SubType; // 修复 constructor 指向

SubType.prototype.sayAge = function() {
    console.log(this.age);
};

const instance1 = new SubType("Alice", 30);
instance1.colors.push("black");
console.log(instance1.colors); // ["red", "blue", "green", "black"]
instance1.sayName();           // Alice
instance1.sayAge();            // 30

const instance2 = new SubType("Bob", 25);
console.log(instance2.colors); // ["red", "blue", "green", "black"] - 注意:实例2也受到了影响!
instance2.sayName();           // undefined (SuperType 构造函数没有被调用来初始化 instance2 的 name 属性)
instance2.sayAge();            // 25

问题分析:

  1. 共享引用类型属性: SubType.prototype = new SuperType(); 这一步,SubType的所有实例都共享了父类实例的引用类型属性(如colors数组)。改变其中一个实例的colors会影响所有其他实例。这是它最大的缺陷。
  2. 无法向父类构造函数传递参数: 在创建SubType.prototype时调用new SuperType(),无法向SuperType传递参数。这意味着所有子类实例的父类属性都是一样的,或者说,父类构造函数中的参数无法针对每个子类实例进行定制。
  3. 创建子类实例时不能初始化父类属性: 比如instance2.nameundefined,因为SuperType构造函数只在创建SubType.prototype时执行了一次,而不是在创建instance2时执行。

优点:

  • 方法可以被子类实例共享(通过原型链)。

2. 借用构造函数继承 (Constructor Stealing / Call Inheritance)

为了解决原型链继承中引用类型共享和无法传参的问题,我们可以借用构造函数。

// 父类构造函数
function SuperType(name) {
    this.name = name;
    this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function() {
    console.log(this.name);
}; // 这个方法实际上不会被子类实例继承

// 子类构造函数
function SubType(name, age) {
    // 核心:借用父类构造函数,通过 call 或 apply 改变 this 的指向
    SuperType.call(this, name); // 关键一步:传递参数并绑定 this
    this.age = age;
}

const instance1 = new SubType("Alice", 30);
instance1.colors.push("black");
console.log(instance1.colors); // ["red", "blue", "green", "black"]
console.log(instance1.name);   // Alice
// instance1.sayName();         // 报错:instance1.sayName is not a function

const instance2 = new SubType("Bob", 25);
console.log(instance2.colors); // ["red", "blue", "green"] - 实例2的 colors 不受影响
console.log(instance2.name);   // Bob

问题分析:

  1. 方法无法共享: SuperType.prototype上的方法(如sayName)不会被子类实例继承。如果父类有很多方法,每次创建子类实例时,这些方法都会在子类构造函数中重新创建一遍,导致内存浪费。
  2. 只能继承父类的实例属性和方法: 无法继承父类原型上的属性和方法。
  3. 父类构造函数会被调用两次: (在后续的组合继承中会暴露此问题,这里暂时不明显)

优点:

  • 解决了引用类型属性共享的问题,每个子类实例都有独立的父类属性副本。
  • 可以向父类构造函数传递参数。

3. 组合继承 (Combination Inheritance) – “第一次最佳”方案

组合继承结合了原型链继承和借用构造函数继承的优点。它通过借用构造函数来继承实例属性(解决引用类型共享和传参问题),通过原型链来继承原型属性和方法(解决方法共享问题)。

// 父类构造函数
function SuperType(name) {
    this.name = name;
    this.colors = ["red", "blue", "green"];
    console.log("SuperType constructor called (instance properties)");
}

SuperType.prototype.sayName = function() {
    console.log(`My name is ${this.name}.`);
};

// 子类构造函数
function SubType(name, age) {
    // 第一次调用 SuperType 构造函数:继承实例属性
    SuperType.call(this, name); // 为每个子类实例初始化 name 和 colors
    this.age = age;
    console.log("SubType constructor called");
}

// 核心:第二次调用 SuperType 构造函数:继承原型属性和方法
// 让 SubType 的原型指向 SuperType 的一个实例
SubType.prototype = new SuperType("dummy"); // 此处参数其实无关紧要,但会执行父类构造函数
SubType.prototype.constructor = SubType; // 修复 constructor 指向

SubType.prototype.sayAge = function() {
    console.log(`My age is ${this.age}.`);
};

console.log("--- Creating instance1 ---");
const instance1 = new SubType("Alice", 30);
instance1.colors.push("black");
console.log(instance1.colors); // ["red", "blue", "green", "black"]
instance1.sayName();           // My name is Alice.
instance1.sayAge();            // My age is 30.

console.log("--- Creating instance2 ---");
const instance2 = new SubType("Bob", 25);
console.log(instance2.colors); // ["red", "blue", "green"] (不受 instance1 影响)
instance2.sayName();           // My name is Bob.
instance2.sayAge();            // My age is 25.

console.log("--- Checking prototype chain ---");
console.log(instance1.__proto__ === SubType.prototype);        // true
console.log(SubType.prototype.__proto__ === SuperType.prototype); // true

组合继承的执行流程解析:

  1. 定义SuperTypeSubType构造函数。
  2. 设置SubType.prototype SubType.prototype = new SuperType("dummy");
    • 此时,new SuperType("dummy")会执行SuperType构造函数一次。它会创建一个SuperType的实例,并将name设置为"dummy"colors设置为["red", "blue", "green"]
    • 这个SuperType实例成为了SubType.prototype的值。因此,SubType.prototype上现在有了一个name属性和一个colors属性。
    • 同时,SubType.prototype[[Prototype]]链接到了SuperType.prototype。这样,SubType的实例就能通过原型链访问SuperType.prototype上的方法(如sayName)。
    • 问题所在: SuperType构造函数在此处被调用了一次,它为SubType.prototype添加了namecolors属性。这些属性对SubType的实例来说是多余的,因为实例自身的namecolors会在构造函数中通过SuperType.call(this, name)再次初始化。
  3. 修复constructor SubType.prototype.constructor = SubType; 确保instance1.constructor指向SubType
  4. 创建instance1 const instance1 = new SubType("Alice", 30);
    • SubType.call(this, "Alice"); 会再次调用SuperType构造函数,将name设置为"Alice"colors设置为["red", "blue", "green"]。这些属性直接添加到instance1实例上。
    • this.age = 30; 添加age属性到instance1
    • 此时,instance1自身拥有namecolorsage属性。
    • instance1[[Prototype]]指向SubType.prototypeSubType.prototype上也有namecolors属性(来自第2步),但由于instance1自身有同名属性,原型上的属性会被“遮蔽”。

优缺点总结:

特性 优点 缺点
实例属性 独立,互不影响(通过 call 解决)
原型方法 共享,节省内存(通过原型链解决)
传参 可向父类构造函数传参
父类构造函数 被调用了两次:一次是设置 SubType.prototype 时,另一次是在 SubType 构造函数内部调用 call 时。
原型冗余 SubType.prototype 上会包含父类构造函数生成的实例属性副本,这些副本是多余的,因为子类实例自身会有同名属性。

组合继承在大多数情况下表现良好,解决了之前两种模式的主要问题。然而,它最大的缺点是父类构造函数会被调用两次,导致SubType.prototype上会有一份多余的父类实例属性。虽然这些属性会被子类实例上的同名属性遮蔽,但它们确实存在,浪费了内存,并且在某些场景下可能导致不必要的计算。


最优方案:寄生组合继承 (Parasitic Combination Inheritance)

寄生组合继承是为了解决组合继承中父类构造函数被调用两次和原型上存在冗余属性的问题而诞生的。它通过寄生式继承(Parasitic Inheritance)来继承父类的原型,并结合借用构造函数来继承父类的实例属性。

核心思想是:不通过调用父类构造函数来为子类原型赋值,而是通过创建父类原型的一个副本,并将其赋值给子类的原型。

// 辅助函数:创建一个对象的浅拷贝,并将其 constructor 指向指定构造函数
function inheritPrototype(subType, superType) {
    // 1. 创建父类原型的一个副本 (Object.create 是核心)
    // 这一步等价于 let prototype = Object.create(superType.prototype);
    // 这样做的目的是为了避免直接调用 superType 构造函数,而又能继承其原型链
    const prototype = Object.create(superType.prototype);

    // 2. 增强对象:将新创建的副本的 constructor 属性指向子类构造函数
    // 确保子类实例的 constructor 属性是正确的
    prototype.constructor = subType;

    // 3. 将新创建的副本赋值给子类的原型
    subType.prototype = prototype;
}

// 父类构造函数
function SuperType(name) {
    this.name = name;
    this.colors = ["red", "blue", "green"];
    console.log("SuperType constructor called (instance properties)");
}

SuperType.prototype.sayName = function() {
    console.log(`My name is ${this.name}.`);
};

// 子类构造函数
function SubType(name, age) {
    // 借用构造函数:继承实例属性
    SuperType.call(this, name); // 第一次调用 SuperType 构造函数
    this.age = age;
    console.log("SubType constructor called");
}

// 核心:调用辅助函数,实现原型继承
// 此时不会调用 SuperType 构造函数
inheritPrototype(SubType, SuperType);

// 为子类原型添加方法
SubType.prototype.sayAge = function() {
    console.log(`My age is ${this.age}.`);
};

console.log("--- Creating instance1 ---");
const instance1 = new SubType("Alice", 30);
instance1.colors.push("black");
console.log(instance1.colors); // ["red", "blue", "green", "black"]
instance1.sayName();           // My name is Alice.
instance1.sayAge();            // My age is 30.

console.log("--- Creating instance2 ---");
const instance2 = new SubType("Bob", 25);
console.log(instance2.colors); // ["red", "blue", "green"] (不受 instance1 影响)
instance2.sayName();           // My name is Bob.
instance2.sayAge();            // My age is 25.

console.log("--- Checking prototype chain ---");
console.log(instance1.__proto__ === SubType.prototype);        // true
console.log(SubType.prototype.__proto__ === SuperType.prototype); // true
console.log(instance1.constructor === SubType);                // true
console.log(instance2.constructor === SubType);                // true

寄生组合继承的执行流程解析:

  1. 定义SuperTypeSubType构造函数。
  2. 调用inheritPrototype(SubType, SuperType)
    • const prototype = Object.create(superType.prototype);:这一步是关键!它创建了一个新的空对象,并将这个新对象的[[Prototype]]链接到SuperType.prototype注意:这里没有调用SuperType构造函数! 因此,SuperType构造函数中定义的所有实例属性(如name, colors)都不会被添加到prototype这个新对象上。
    • prototype.constructor = subType;:将新对象的constructor属性设置为SubType,确保SubType实例的constructor属性是正确的。
    • subType.prototype = prototype;:将这个经过“寄生”处理的prototype对象赋值给SubType.prototype。现在,SubType.prototype[[Prototype]]指向SuperType.prototype,完美地建立了原型链,同时又避免了SuperType构造函数的冗余调用。
  3. SubType.prototype添加自己的方法。
  4. 创建instance1 const instance1 = new SubType("Alice", 30);
    • SuperType.call(this, "Alice");这里是SuperType构造函数的唯一一次调用。它为instance1实例自身添加了namecolors属性。
    • this.age = 30;:添加age属性到instance1
    • instance1[[Prototype]]指向SubType.prototype。通过SubType.prototypeinstance1可以访问到SuperType.prototype上的方法。

核心优势:

  • 只调用一次父类构造函数: 仅在子类构造函数中通过SuperType.call(this, ...)调用一次,用于初始化子类实例的自身属性。
  • 子类原型上没有冗余属性: SubType.prototype上不会有父类构造函数创建的实例属性副本,因为它直接继承的是父类的原型,而不是父类的实例。这使得原型链保持整洁高效。
  • 能够向父类构造函数传参。
  • 子类实例和原型方法互不干扰。

优缺点总结:

特性 优点 缺点
实例属性 独立,互不影响(通过 call 解决)
原型方法 共享,节省内存(通过 Object.create 建立原型链)
传参 可向父类构造函数传参
父类构造函数 只被调用一次
原型冗余 ,原型链清晰高效 相对组合继承更复杂一些

正是由于这些优点,寄生组合继承被认为是JavaScript中最理想的继承方案,因为它解决了之前所有继承模式的主要问题,并提供了高效、干净的继承结构。


ES6 Class 的继承:语法糖下的寄生组合继承

ES6(ECMAScript 2015)引入了class关键字,为JavaScript带来了更接近传统面向对象语言的语法糖。但其底层机制仍然是基于原型链和寄生组合继承的。

class SuperType {
    constructor(name) {
        this.name = name;
        this.colors = ["red", "blue", "green"];
        console.log("SuperType constructor called (ES6)");
    }

    sayName() {
        console.log(`My name is ${this.name}.`);
    }
}

class SubType extends SuperType {
    constructor(name, age) {
        super(name); // 调用父类构造函数
        this.age = age;
        console.log("SubType constructor called (ES6)");
    }

    sayAge() {
        console.log(`My age is ${this.age}.`);
    }
}

console.log("--- Creating instance1 (ES6) ---");
const instance1_es6 = new SubType("Alice", 30);
instance1_es6.colors.push("black");
console.log(instance1_es6.colors); // ["red", "blue", "green", "black"]
instance1_es6.sayName();           // My name is Alice.
instance1_es6.sayAge();            // My age is 30.

console.log("--- Creating instance2 (ES6) ---");
const instance2_es6 = new SubType("Bob", 25);
console.log(instance2_es6.colors); // ["red", "blue", "green"]
instance2_es6.sayName();           // My name is Bob.
instance2_es6.sayAge();            // My age is 25.

console.log("--- Checking prototype chain (ES6) ---");
console.log(Object.getPrototypeOf(SubType) === SuperType);            // true (子类构造函数的原型指向父类构造函数)
console.log(Object.getPrototypeOf(SubType.prototype) === SuperType.prototype); // true (子类原型的原型指向父类原型)

extendssuper()的工作原理:

  1. extends关键字:
    • 它将SubType.prototype[[Prototype]]链接到SuperType.prototype
    • 它还做了另一件重要的事情:将SubType构造函数本身的[[Prototype]]链接到SuperType构造函数。这使得SubType可以继承SuperType的静态方法。
    • 这与寄生组合继承中的Object.create(superType.prototype)作用类似,都是为了建立原型链。
  2. super()关键字:
    • 在子类构造函数中,super()必须在this关键字被使用之前调用。
    • super()实际上等价于SuperType.call(this, ...)。它调用父类构造函数,将父类的实例属性绑定到子类实例上。
    • 如果没有super(),子类实例将无法正确初始化父类的实例属性,并且会报错。

ES6的class语法糖,在底层完美地实现了寄生组合继承的所有优点:父类构造函数只被调用一次,子类原型链干净整洁,实例属性独立,原型方法共享。它以更简洁、更易读的方式封装了这些复杂性,使得开发者可以更专注于业务逻辑而非继承机制的细节。


继承与组合:何时选择?

在现代JavaScript开发中,除了继承,组合(Composition)也是一种强大的代码复用模式。

  • 继承(Inheritance): 强调“is-a”关系(子类是一种父类)。适用于当子类和父类之间存在强烈的类型层级关系时。例如,Dog is a Animal
  • 组合(Composition): 强调“has-a”关系(一个对象包含另一个对象)。适用于当一个对象需要另一个对象的功能,但两者之间没有直接的类型层级关系时。例如,Car has an Engine

通常,“优先使用组合而不是继承”(Prefer composition over inheritance) 是一个被广泛接受的软件设计原则。继承会带来紧耦合,使得代码难以维护和扩展,尤其是在多层继承和复杂的类结构中。组合则更加灵活,通过将功能封装在不同的对象中,并在需要时将它们组合起来,可以实现更松散的耦合。

然而,对于UI组件、错误处理类等场景,继承仍然是简洁有效的方案。关键在于理解两者的优缺点,并根据具体需求做出明智的选择。


总结与展望

从原型链继承的缺陷,到借用构造函数继承的局限,再到组合继承的“第一次最佳”,最终发展到寄生组合继承的“最优”实践,我们看到了JavaScript继承模式的不断演进。ES6的class语法糖则进一步抽象和简化了这一过程,它在底层正是寄生组合继承的优雅实现。

理解这些继承模式的演变过程及其背后的原理,对于深入掌握JavaScript的面向对象特性至关重要。这不仅能让你在面试中脱颖而出,更能帮助你在日常开发中写出更健壮、更高效的代码。无论你选择哪种方式,清晰的原型链、恰当的this绑定以及高效的资源利用始终是构建优秀JavaScript应用的核心。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注