JavaScript继承探秘:从原型链到ES6 Class
引言
在JavaScript的世界里,继承是一个核心概念,也是面试中的高频考点。它允许我们创建"子"对象,继承"父"对象的属性和方法,从而实现代码的复用和逻辑的扩展。然而,JavaScript的继承机制与其他面向对象语言(如Java或C++)有所不同,它并非基于传统的类,而是基于原型(Prototype)。
这导致了JavaScript中多种多样的继承实现方式,每种方式都有其独特的优缺点和适用场景。对于新手来说,理解这些继承方式之间的差异,并能够在合适的场景下选择最优的方案,是进阶路上的重要一步。
本文将带你深入探索JavaScript中8种主流的继承方式,从最基础的原型链继承,到ES6引入的class
语法糖,通过通俗易懂的讲解和代码示例,帮助你彻底搞懂JavaScript的继承,构建清晰的知识体系。
1. 原型链继承
原型链继承是JavaScript中最基本的继承方式,也是所有继承方式的基础。它的核心思想是利用原型(prototype
)的特性,让子类型的原型指向父类型的实例。
工作原理
每个函数都有一个prototype
属性,它是一个对象,包含所有实例共享的属性和方法。当我们创建一个对象实例时,该实例会有一个内部属性[[Prototype]]
(在ES5中通常通过__proto__
访问,在ES6及以后通过Object.getPrototypeOf()
访问),指向其构造函数的prototype
对象。通过将子类型的prototype
指向父类型的实例,就建立了一条原型链。
当访问一个对象的属性或方法时,如果该对象本身没有这个属性或方法,JavaScript会沿着原型链向上查找,直到找到该属性或方法,或者到达原型链的顶端(Object.prototype
)。
代码示例
javascript
// 父类型
function Parent() {
this.name = 'Parent';
this.colors = ['red', 'blue', 'green'];
}
Parent.prototype.sayName = function() {
console.log(this.name);
};
// 子类型
function Child() {
this.age = 10;
}
// 核心:将子类型的原型指向父类型的实例
Child.prototype = new Parent();
Child.prototype.sayAge = function() {
console.log(this.age);
};
const child1 = new Child();
child1.sayName(); // 输出: Parent
console.log(child1.age); // 输出: 10
console.log(child1.name); // 输出: Parent
child1.colors.push('yellow');
console.log(child1.colors); // 输出: ['red', 'blue', 'green', 'yellow']
const child2 = new Child();
console.log(child2.colors); // 输出: ['red', 'blue', 'green', 'yellow'] // 问题所在!
优点
- 简单易实现:代码量少,理解起来相对直观。
- 继承父类方法:子类实例可以访问父类原型上的方法。
缺点
- 引用类型共享问题 :父类实例的引用类型属性会被所有子类实例共享。这意味着一个子类实例修改了父类引用类型属性,其他子类实例也会受到影响,如上述
colors
数组的例子。 - 无法向父类构造函数传参:在创建子类实例时,无法向父类构造函数传递参数,导致父类实例的属性无法个性化。
- 创建子类实例时不能初始化父类属性:子类实例在创建时,父类的属性已经固定,无法在子类构造函数中对父类属性进行初始化。
适用场景
原型链继承适用于那些不需要向父类构造函数传递参数,并且父类没有引用类型属性需要独立维护的简单继承场景。在实际开发中,由于其明显的缺点,单独使用原型链继承的情况较少,更多是作为其他继承方式的基础。
2. 借用构造函数继承
借用构造函数继承,也称为"伪造对象"或"经典继承",其核心思想是在子类型构造函数内部调用父类型构造函数,从而在子类型实例上复制父类型的属性。
工作原理
这种方式通过使用call()
或apply()
方法,在子类型构造函数中改变父类型构造函数的this
指向,使其指向子类型的实例。这样,父类型构造函数中定义的属性就会被添加到子类型实例上。
代码示例
javascript
// 父类型
function Parent(name) {
this.name = name;
this.colors = ["red", "blue", "green"];
}
Parent.prototype.sayName = function() {
console.log(this.name);
};
// 子类型
function Child(name, age) {
// 核心:借用父类构造函数,并传递参数
Parent.call(this, name);
this.age = age;
}
const child1 = new Child("Child1", 10);
console.log(child1.name); // 输出: Child1
console.log(child1.age); // 输出: 10
console.log(child1.colors); // 输出: ["red", "blue", "green"]
child1.colors.push("yellow");
console.log(child1.colors); // 输出: ["red", "blue", "green", "yellow"]
const child2 = new Child("Child2", 20);
console.log(child2.name); // 输出: Child2
console.log(child2.age); // 输出: 20
console.log(child2.colors); // 输出: ["red", "blue", "green"] // 解决了引用类型共享问题!
// child1.sayName(); // 报错:child1.sayName is not a function
优点
- 解决了引用类型共享问题:父类的引用类型属性在每个子类实例中都是独立的副本,互不影响。
- 可以向父类构造函数传参:在创建子类实例时,可以向父类构造函数传递参数,实现属性的个性化。
缺点
- 方法无法复用:父类原型上定义的方法无法被子类继承。如果父类有很多方法,每个子类实例都会创建这些方法的副本,造成内存浪费。
- 只能继承父类的实例属性和方法:无法继承父类原型上的属性和方法。
- 每次创建实例都会调用父类构造函数:这会增加不必要的开销。
适用场景
借用构造函数继承主要用于解决原型链继承中引用类型共享的问题,并且允许向父类构造函数传递参数。然而,由于其无法继承父类原型上的方法,导致方法无法复用,因此通常不单独使用,而是与其他继承方式结合使用,形成更完善的继承模式。
3. 组合式继承
组合式继承是JavaScript中最常用的继承模式,它结合了原型链继承和借用构造函数继承的优点,解决了它们各自的缺点。其核心思想是:使用原型链继承原型上的属性和方法,而通过借用构造函数继承实例属性。
工作原理
- 在子类型构造函数中调用父类型构造函数:这确保了子类实例拥有父类实例的属性,并且每个子类实例的引用类型属性都是独立的副本,解决了引用类型共享问题,同时允许向父类构造函数传参。
- 将子类型的原型指向父类型的实例:这使得子类能够继承父类原型上的方法,实现了方法的复用。
代码示例
javascript
// 父类型
function Parent(name) {
this.name = name;
this.colors = ["red", "blue", "green"];
}
Parent.prototype.sayName = function() {
console.log(this.name);
};
// 子类型
function Child(name, age) {
// 第一次调用父类构造函数:继承实例属性
Parent.call(this, name);
this.age = age;
}
// 核心:将子类型的原型指向父类型的实例(第二次调用父类构造函数)
Child.prototype = new Parent();
// 修复constructor指向问题
Child.prototype.constructor = Child;
Child.prototype.sayAge = function() {
console.log(this.age);
};
const child1 = new Child("Child1", 10);
child1.sayName(); // 输出: Child1
console.log(child1.age); // 输出: 10
console.log(child1.colors); // 输出: ["red", "blue", "green"]
child1.colors.push("yellow");
console.log(child1.colors); // 输出: ["red", "blue", "green", "yellow"]
const child2 = new Child("Child2", 20);
child2.sayName(); // 输出: Child2
console.log(child2.age); // 输出: 20
console.log(child2.colors); // 输出: ["red", "blue", "green"] // 引用类型独立
优点
- 解决了引用类型共享问题:每个子类实例都有独立的父类引用类型属性。
- 可以向父类构造函数传参:允许在创建子类实例时个性化父类属性。
- 方法可以复用:父类原型上的方法可以被所有子类实例共享,避免了内存浪费。
- 是最常用的继承模式:兼顾了实例属性和原型属性的继承,是实践中广泛采用的模式。
缺点
- 调用了两次父类构造函数 :第一次是在
Child.call(this, name)
中,第二次是在Child.prototype = new Parent()
中。这会导致父类构造函数中的代码执行两次,虽然通常不会造成太大问题,但在某些情况下可能会有性能或逻辑上的冗余。
适用场景
组合式继承是JavaScript中最常用和推荐的继承模式,因为它解决了原型链继承和借用构造函数继承的各自缺点,并且能够很好地实现属性和方法的继承与复用。在ES6的class
语法糖出现之前,它是实现继承的最佳实践。
4. 原型式继承
原型式继承是道格拉斯·克罗克福德(Douglas Crockford)提出的一种继承方式,它基于一个已有的对象创建新对象,而不需要构造函数。ES5中新增的Object.create()
方法就是对原型式继承的规范化实现。
工作原理
原型式继承的核心是创建一个临时性的构造函数,将传入的对象作为这个构造函数的原型,然后返回这个临时类型的一个新实例。这样,新创建的对象就继承了传入对象的属性和方法。
代码示例
javascript
// 父对象
const parent = {
name: 'Parent',
colors: ['red', 'blue', 'green'],
sayName: function() {
console.log(this.name);
}
};
// 模拟Object.create()的实现
function object(o) {
function F() {}
F.prototype = o;
return new F();
}
// 使用object函数创建新对象
const child1 = object(parent);
child1.name = 'Child1';
child1.sayName(); // 输出: Child1
child1.colors.push('yellow');
console.log(child1.colors); // 输出: ['red', 'blue', 'green', 'yellow']
const child2 = object(parent);
child2.name = 'Child2';
child2.sayName(); // 输出: Child2
console.log(child2.colors); // 输出: ['red', 'blue', 'green', 'yellow'] // 引用类型共享问题!
// 使用Object.create()创建新对象
const child3 = Object.create(parent);
child3.name = 'Child3';
child3.sayName(); // 输出: Child3
console.log(child3.colors); // 输出: ['red', 'blue', 'green', 'yellow'] // 引用类型共享问题!
优点
- 简单:不需要定义构造函数,直接基于现有对象创建新对象。
- 适用于只需要继承对象属性和方法的场景:当不需要创建自定义类型,只想简单地继承一个对象的属性和方法时,这种方式非常方便。
缺点
- 引用类型共享问题:与原型链继承一样,父对象中的引用类型属性会被所有子对象共享,一个子对象修改了该属性,其他子对象也会受到影响。
- 无法传递参数:无法像构造函数那样在创建时传递参数来初始化属性。
适用场景
原型式继承适用于你有一个现成的对象,并希望在此基础上创建一个新对象,而不需要定义新的类型。例如,当你想创建一个与现有对象几乎完全相同的新对象,只是在某些属性上有所不同时,原型式继承是一个简洁的选择。但需要特别注意引用类型属性的共享问题。
5. 寄生式继承
寄生式继承是原型式继承的增强版,它在原型式继承的基础上,通过一个函数来封装创建对象的逻辑,并在函数内部对新创建的对象进行增强。这种方式类似于工厂模式。
工作原理
寄生式继承首先使用原型式继承创建一个新对象,然后在这个新对象上添加新的属性和方法,最后返回这个增强后的对象。这样,新对象不仅继承了父对象的属性和方法,还拥有了自己独有的特性。
代码示例
javascript
// 父对象
const parent = {
name: 'Parent',
colors: ['red', 'blue', 'green'],
sayName: function() {
console.log(this.name);
}
};
// 模拟Object.create()的实现
function object(o) {
function F() {}
F.prototype = o;
return new F();
}
// 寄生式继承函数
function createAnother(original) {
const clone = object(original); // 通过调用函数创建一个新对象
clone.sayHi = function() { // 以某种方式增强这个对象
console.log('hi');
};
return clone; // 返回这个对象
}
const child1 = createAnother(parent);
child1.sayName(); // 输出: Parent
child1.sayHi(); // 输出: hi
child1.colors.push('yellow');
console.log(child1.colors); // 输出: ["red", "blue", "green", "yellow"]
const child2 = createAnother(parent);
child2.sayName(); // 输出: Parent
child2.sayHi(); // 输出: hi
console.log(child2.colors); // 输出: ["red", "blue", "green", "yellow"] // 引用类型共享问题!
优点
- 简单易实现:在原型式继承的基础上,通过函数封装和增强,使得创建过程更加灵活。
- 可以为对象添加额外功能:在继承现有对象的基础上,可以方便地添加新的属性和方法。
缺点
- 引用类型共享问题:与原型式继承一样,父对象中的引用类型属性会被所有子对象共享。
- 方法无法复用 :每次调用
createAnother
函数都会创建新的方法,导致方法无法复用,造成内存浪费。
适用场景
寄生式继承适用于那些你有一个现成的对象,并且希望在不修改原有对象的基础上,为新创建的对象添加一些额外的功能。它提供了一种灵活的方式来增强对象,但由于其无法解决引用类型共享和方法复用问题,因此在实际开发中,单独使用的场景较少,更多是作为其他继承模式的补充。
6. 寄生组合式继承
寄生组合式继承是目前公认的、最理想的继承方式。它通过结合借用构造函数模式和原型链的优化,解决了组合式继承中调用两次父类构造函数的问题,同时保留了其优点。
工作原理
寄生组合式继承的核心是:
- 借用构造函数:在子类型构造函数中调用父类型构造函数,继承实例属性,并解决引用类型共享问题和传参问题。
- 寄生式继承原型 :不再直接将子类型的
prototype
指向父类型的实例,而是通过创建一个父类型原型的副本,然后将这个副本赋值给子类型的prototype
。这样,子类型的原型就继承了父类型原型上的方法,而避免了创建父类型实例时多余的开销。
代码示例
javascript
// 父类型
function Parent(name) {
this.name = name;
this.colors = ["red", "blue", "green"];
}
Parent.prototype.sayName = function() {
console.log(this.name);
};
// 核心函数:寄生式继承原型
function inheritPrototype(subType, superType) {
const prototype = Object.create(superType.prototype); // 创建父类型原型的一个副本
prototype.constructor = subType; // 修复constructor指向问题
subType.prototype = prototype; // 将副本赋值给子类型的原型
}
// 子类型
function Child(name, age) {
Parent.call(this, name); // 借用构造函数继承实例属性
this.age = age;
}
// 核心:实现原型继承
inheritPrototype(Child, Parent);
Child.prototype.sayAge = function() {
console.log(this.age);
};
const child1 = new Child("Child1", 10);
child1.sayName(); // 输出: Child1
console.log(child1.age); // 输出: 10
console.log(child1.colors); // 输出: ["red", "blue", "green"]
child1.colors.push("yellow");
console.log(child1.colors); // 输出: ["red", "blue", "green", "yellow"]
const child2 = new Child("Child2", 20);
child2.sayName(); // 输出: Child2
console.log(child2.age); // 输出: 20
console.log(child2.colors); // 输出: ["red", "blue", "green"] // 引用类型独立
优点
- 只调用一次父类构造函数:避免了组合式继承中两次调用父类构造函数的问题,提高了效率。
- 解决了引用类型共享问题:每个子类实例都有独立的父类引用类型属性。
- 可以向父类构造函数传参:允许在创建子类实例时个性化父类属性。
- 方法可以复用:父类原型上的方法可以被所有子类实例共享。
- 是实现继承的最佳实践 :在ES6
class
出现之前,这是最完美的继承方案。
缺点
- 代码相对复杂 :需要一个额外的
inheritPrototype
函数来处理原型链的构建。
适用场景
寄生组合式继承是JavaScript中实现继承的"黄金法则"。它解决了之前所有继承模式的缺点,是实现健壮、高效继承的最佳选择。在ES6 class
语法糖出现之前,几乎所有的JavaScript库和框架都采用这种模式来实现继承。
7. 混入方法继承多个对象
JavaScript作为一门灵活的语言,虽然没有像其他面向对象语言那样直接支持多重继承,但可以通过"混入"(Mixin)的方式实现类似的功能,即从多个源对象中"混入"属性和方法到一个目标对象或原型中。
工作原理
混入的本质是将一个或多个对象的属性和方法复制到另一个对象上。这通常通过遍历源对象的属性,并将其复制到目标对象来实现。ES6引入的Object.assign()
方法是实现混入的常用工具。
代码示例
javascript
// 源对象1
const CanWalk = {
walk: function() {
console.log("I can walk.");
}
};
// 源对象2
const CanSwim = {
swim: function() {
console.log("I can swim.");
}
};
// 目标对象
function Person(name) {
this.name = name;
}
// 混入方法到原型
Object.assign(Person.prototype, CanWalk, CanSwim);
const person1 = new Person("Alice");
person1.walk(); // 输出: I can walk.
person1.swim(); // 输出: I can swim.
console.log(person1.name); // 输出: Alice
// 也可以混入到实例
const dog = {};
Object.assign(dog, CanWalk);
dog.walk(); // 输出: I can walk.
优点
- 实现多功能组合:可以方便地将多个不相关的行为组合到一个对象中,实现代码的模块化和复用。
- 避免单继承限制:在JavaScript的单继承模型下,混入提供了一种实现多重继承思想的灵活方式。
- 轻量级:不需要复杂的原型链操作,直接复制属性和方法。
缺点
- 命名冲突:如果多个混入对象有同名属性或方法,后面的混入会覆盖前面的,可能导致意外的行为。
- 来源不明确:混入的属性和方法来源不清晰,可能增加代码的理解难度。
- 无法追踪继承链:混入是简单的属性复制,没有建立原型链,因此无法通过原型链进行类型检查或向上查找。
适用场景
混入模式适用于需要将多个独立的功能或行为添加到现有对象或类中的场景。例如,为某个对象添加日志记录、事件处理或数据验证等通用功能。它是一种非常实用的代码复用模式,尤其在构建可组合的组件时非常有用。
8. ES6 类继承 extends
ES6(ECMAScript 2015)引入了class
关键字,为JavaScript带来了更接近传统面向对象语言的类语法。虽然class
本质上仍然是基于原型的语法糖,但它提供了一种更清晰、更简洁的方式来定义类和实现继承。
工作原理
extends
关键字用于实现类的继承。子类通过extends
关键字继承父类,并可以使用super
关键字调用父类的构造函数和方法。class
继承的底层实现仍然是寄生组合式继承,但语法上更加直观。
代码示例
javascript
// 父类
class Parent {
constructor(name) {
this.name = name;
this.colors = ["red", "blue", "green"];
}
sayName() {
console.log(this.name);
}
}
// 子类
class Child extends Parent {
constructor(name, age) {
super(name); // 调用父类的构造函数,并传递参数
this.age = age;
}
sayAge() {
console.log(this.age);
}
}
const child1 = new Child("Child1", 10);
child1.sayName(); // 输出: Child1
console.log(child1.age); // 输出: 10
console.log(child1.colors); // 输出: ["red", "blue", "green"]
child1.colors.push("yellow");
console.log(child1.colors); // 输出: ["red", "blue", "green", "yellow"]
const child2 = new Child("Child2", 20);
child2.sayName(); // 输出: Child2
console.log(child2.age); // 输出: 20
console.log(child2.colors); // 输出: ["red", "blue", "green"] // 引用类型独立
优点
- 语法更清晰、更简洁 :
class
语法更符合传统面向对象编程的习惯,降低了学习成本。 - 解决了所有继承问题 :
extends
内部实现了寄生组合式继承的逻辑,完美解决了引用类型共享、方法复用和传参等问题。 - 更易于理解和维护:代码结构更清晰,更符合直觉。
缺点
- 本质仍是原型继承 :
class
只是语法糖,其底层机制仍然是基于原型的,对于不理解原型链的开发者来说,可能会产生误解。 - 不支持多重继承 :与传统面向对象语言一样,JavaScript的
class
也不直接支持多重继承(但可以通过混入模式实现类似功能)。
适用场景
ES6 class
继承是现代JavaScript开发中实现继承的首选方式。它提供了简洁、直观的语法,同时解决了传统继承模式中的各种问题。在新的项目中,应优先考虑使用class
来组织代码和实现继承。
总结与对比
继承方式 | 实例属性继承方式 | 原型属性/方法继承方式 | 引用类型共享问题 | 可传参 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|---|---|---|
原型链继承 | 否 | 是 | 是 | 否 | 简单易实现 | 引用类型共享,无法传参,无法初始化父类属性 | 简单继承,父类无引用类型属性 |
借用构造函数继承 | 是 | 否 | 否 | 是 | 解决了引用类型共享,可传参 | 方法无法复用,只能继承实例属性 | 解决引用类型共享,需传参,通常不单独使用 |
组合式继承 | 是 | 是 | 否 | 是 | 解决了所有问题,最常用 | 调用两次父类构造函数 | ES6前最常用且推荐的继承模式 |
原型式继承 | 否 | 是 | 是 | 否 | 简单,基于现有对象创建 | 引用类型共享,无法传参 | 基于现有对象创建新对象,无需定义新类型 |
寄生式继承 | 否 | 是 | 是 | 否 | 简单,可增强对象 | 引用类型共享,方法无法复用 | 增强现有对象,添加额外功能 |
寄生组合式继承 | 是 | 是 | 否 | 是 | 完美解决了所有问题,效率高 | 代码相对复杂 | ES6前最理想的继承方案 |
混入方法继承 | 是 | 是 | 否 | 否 | 实现多功能组合,避免单继承限制 | 命名冲突,来源不明确,无法追踪继承链 | 组合多个独立功能或行为到对象中 |
ES6 类继承 | 是 | 是 | 否 | 是 | 语法清晰简洁,解决了所有问题,易理解维护 | 本质仍是原型继承,不支持多重继承 | 现代JavaScript开发首选,简洁直观的继承方式 |
从上表可以看出,每种继承方式都有其独特的特点和适用场景。在ES6之前,寄生组合式继承是公认的最佳实践,因为它在效率和功能上都达到了平衡。而ES6引入的class
语法糖,则在底层实现了寄生组合式继承的逻辑,为开发者提供了更简洁、更符合直觉的语法。
结语
通过本文的深入探讨,相信你对JavaScript的8种继承方式有了更全面、更深入的理解。从最初的原型链继承,到后来为了解决各种问题而演变出的借用构造函数继承、组合式继承、原型式继承、寄生式继承、寄生组合式继承,再到ES6的class
语法糖和灵活的混入模式,每一种方式都代表了JavaScript在不同发展阶段对继承机制的探索和优化。
理解这些继承方式的原理和差异,不仅能帮助你更好地阅读和理解现有的JavaScript代码,也能让你在实际开发中,根据具体需求选择最合适的继承方案,写出更健壮、更高效、更易于维护的代码。记住,虽然class
语法糖让继承变得更加简单,但其底层仍然是基于原型的,深入理解原型链是掌握JavaScript继承的关键。
希望本文能成为你JavaScript学习旅程中的一份有益参考,祝你在编程的道路上越走越远!