在 JavaScript 里,继承是一个核心概念,它能让代码复用更高效,结构更清晰。JavaScript 有多种继承方式,每种方式都有其原理、优缺点和适用场景。下面就来详细探讨这些继承方式。
1. 原型链继承
原理
把子类的原型指向父类的实例,子类实例就能通过原型链访问到父类的属性和方法。
在 JavaScript 的原型链继承机制里,核心操作是将子类构造函数的原型对象指向父类构造函数的一个实例,即执行Child.prototype = new Parent()
。在此之前,Child.prototype
指向的是Child
构造函数默认的原型对象,而执行这一赋值语句后,它被替换为Parent
构造函数新创建的实例。
如此一来,当创建Child
的实例时,该实例的原型链会发生相应变化。它的原型将是Parent
构造函数的实例,这意味着Child
实例在访问属性和方法时,首先会在自身查找;若未找到,便会沿着原型链进入到Parent
实例中查找;若在Parent
实例中也未找到,还会继续深入到Parent.prototype
中查找。通过这样的原型链查找机制,Child
实例得以访问到Parent
构造函数实例以及Parent.prototype
上定义的属性和方法 ,实现了子类对父类成员的继承。
代码示例
javascript
function Parent() {
this.name = 'parent';
this.colors = ['red', 'blue', 'green'];
}
Parent.prototype.sayName = function() {
console.log(this.name);
};
function Child() {}
Child.prototype = new Parent();
const child1 = new Child();
const child2 = new Child();
child1.colors.push('yellow');
console.log(child1.colors); // ["red", "blue", "green", "yellow"]
console.log(child2.colors); // ["red", "blue", "green", "yellow"]
缺点
1. 父类中的引用类型属性会被所有子类实例共享
javascript
// 定义父类构造函数
function Parent() {
// 父类中的引用类型属性,这里是一个数组
this.colors = ['red', 'blue', 'green'];
}
// 定义子类构造函数
function Child() {}
// 实现原型链继承
Child.prototype = new Parent();
// 创建两个子类实例
const child1 = new Child();
const child2 = new Child();
// 子类实例 child1 修改 colors 数组
child1.colors.push('yellow');
// 输出 child2 的 colors 数组
console.log(child2.colors); // 输出: ['red', 'blue', 'green', 'yellow']
- 当
child1
修改colors
数组时,由于child1
和child2
共享同一个Parent
实例作为原型,所以child2
的colors
数组也会受到影响。
2. 创建子类实例时,无法向父类的构造函数传递参数
js
// 定义父类构造函数
function Animal(name) {
this.name = name;
this.sayName = function () {
console.log(`My name is ${this.name}`);
};
}
// 定义子类构造函数
function Dog() {}
// 实现原型链继承,传递参数给 Animal 构造函数
Dog.prototype = new Animal('旺财');
// 创建 Dog 实例
const dog = new Dog();
// 尝试调用 sayName 方法
dog.sayName();
虽然传递参数后 name
属性能正确初始化,但这种方式存在局限性:
- 所有实例共享相同的属性值 :由于
Dog
的所有实例都继承自同一个Animal
实例(也就是Dog.prototype
),所以所有Dog
实例的name
属性都会是'旺财'
,没办法为每个Dog
实例单独设置不同的名字。 - 修改原型属性影响所有实例 :要是修改了
Dog.prototype
上的属性,所有Dog
实例都会受到影响。
2. 构造函数继承
原理
构造函数继承的核心机制是通过在子类构造函数中调用父类构造函数,利用call
、apply
或者bind
方法改变父类构造函数内部的this
指向,使其指向子类实例。这样,父类构造函数所定义的属性和方法便会被复制到子类实例中。同时,除了在子类构造函数内部进行调用,也可以在外部手动操作来实现类似继承效果。
调用方式
- 构造函数内部调用 :在子类构造函数内部使用
Parent.call(this, name)
这样的形式。每次创建子类实例时,该调用会自动执行,使得子类实例继承父类构造函数中的属性和方法。例如:
javascript
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue', 'green'];
}
function Child(name) {
Parent.call(this, name);
}
const child = new Child('example');
// 这里通过new Child()创建实例时,自动继承了Parent构造函数中的属性
- 构造函数外部调用 :先创建子类实例,之后手动调用
Parent.call(child, name)
来实现继承。这种方式更为灵活,开发者能够依据具体需求,有选择性地让特定实例继承父类构造函数中的属性和方法。示例如下:
javascript
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue', 'green'];
}
function Child() {}
const child = new Child();
// 手动决定让child实例继承Parent构造函数的属性
Parent.call(child, 'externalCall');
代码示例
javascript
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue', 'green'];
}
Parent.prototype.sayName = function() {
console.log(this.name);
};
function Child(name) {
Parent.call(this, name);
}
const child1 = new Child('child1');
const child2 = new Child('child2');
child1.colors.push('yellow');
console.log(child1.colors); // ["red", "blue", "green", "yellow"]
console.log(child2.colors); // ["red", "blue", "green"]
在上述代码中,Child
构造函数利用Parent.call(this, name)
调用Parent
构造函数,将this
指向Child
实例,使得Parent
构造函数中定义的name
和colors
属性被复制到每个Child
实例上。
创建child1
和child2
实例时,它们各自拥有独立的name
和colors
属性副本。当修改child1.colors
时,仅影响child1
实例,不会对child2
实例的colors
属性造成影响,清晰地体现了构造函数继承能使子类实例拥有独立属性的特点。
然而,单纯的构造函数继承方式只能继承父类构造函数内的属性和方法,无法继承父类原型上的方法。例如,Parent.prototype.sayName
方法,child1
和child2
就无法直接调用。
优点
- 解决了引用属性共享的问题,因为每个子类实例都有自己独立的属性副本。
- 支持在创建子类实例时向父类构造函数传递参数。
缺点
无法复用父类原型上的方法。因为这种继承方式只是在子类构造函数中调用父类构造函数,父类原型上的方法不会被继承,子类的方法需要在构造函数中重新定义,导致代码复用性差。
js
// 定义父类构造函数
function Animal(name) {
this.name = name;
}
// 在父类原型上定义方法
Animal.prototype.sayHello = function () {
console.log(`Hello, I'm ${this.name}`);
};
// 定义子类构造函数
function Dog(name) {
// 构造函数继承
Animal.call(this, name);
// 如果需要 sayHello 方法,只能在子类构造函数中重新定义
this.sayHello = function () {
console.log(`Woof! Hello, I'm ${this.name}`);
};
}
// 创建 Dog 实例
const dog = new Dog('Buddy');
// 调用 sayHello 方法
dog.sayHello();
// 检查父类原型方法是否被继承
console.log(dog instanceof Animal); // false
- 父类
Animal
接收name
参数并赋值给实例属性,同时在原型上定义sayHello
方法用于输出动物问候语。 - 子类
Dog
通过Animal.call(this, name)
实现构造函数继承,但无法复用父类原型方法,需在自身构造函数中重写sayHello
。 - 创建
dog
实例并调用sayHello
方法,能输出对应问候语。dog instanceof Animal
结果为false
,表明构造函数继承未继承父类原型方法。
适用场景
适用于需要独立属性副本,且不需要复用父类原型方法的情况。比如创建一次性配置对象,像数据库、API 的配置对象,各自有独立配置属性,操作简单,都符合该场景。
3. 组合继承(经典继承)
原理
组合继承巧妙地融合了原型链继承和构造函数继承的优势。
从原型链继承角度来说,子类实例可以通过原型链访问到父类实例以及父类原型上的属性和方法,解决了构造函数继承无法复用父类原型上方法的问题。
从构造函数继承角度来看,父类构造函数中定义的属性和方法会被复制到每个子类实例上,保证了每个子类实例都有自己独立的属性副本,避免了原型链继承中父类引用类型属性被所有子类实例共享的问题,同时也支持在创建子类实例时向父类构造函数传递参数。
代码示例
js
// 定义父类 Parent,接收 name 参数,设置 name 和 colors 属性
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue', 'green'];
}
// 在父类原型上添加 sayName 方法
Parent.prototype.sayName = function() {
console.log(this.name);
};
// 定义子类 Child,接收 name 参数
function Child(name) {
// 构造函数继承,使子类实例有独立属性
Parent.call(this, name);
}
// 原型链继承,让子类实例可访问父类原型方法
Child.prototype = new Parent();
// 修正子类原型的 constructor 指向
Child.prototype.constructor = Child;
// 创建子类实例
const child1 = new Child('child1');
const child2 = new Child('child2');
// 修改 child1 的 colors 属性
child1.colors.push('yellow');
console.log(child1.colors); // ["red", "blue", "green", "yellow"]
console.log(child2.colors); // ["red", "blue", "green"]
缺点
父类构造函数会被调用两次,一次是在子类构造函数中,一次是在设置子类原型时。这会导致子类原型中存在一些冗余的属性,增加了内存开销。
适用场景
在需要复用父类原型方法,同时又要独立属性副本的场景下使用,但要注意内存开销问题。
4. 原型式继承
原理
基于现有的对象创建新对象,通过 Object.create()
方法来模拟实现。该方法会创建一个新对象,并将新对象的原型设置为传入的现有对象,使得新对象能够继承原对象的属性和方法。
Object.create()
方法的使用及注意点
-
基本语法 :
Object.create(proto[, propertiesObject])
。proto
:必填参数,它指定了新创建对象的原型对象。可以传入null
,不过此时新对象将没有原型,也就无法通过原型链访问其他对象的属性和方法。一般情况下,传入一个已有的对象,让新对象继承该对象的属性和方法。propertiesObject
:可选参数,是一个对象。该对象的属性和值会被添加到新创建的对象上。并且每个属性都有各自的描述符,例如value
(属性值)、writable
(是否可写)、enumerable
(是否可枚举)、configurable
(是否可配置)等,开发者可以通过这些描述符精确设置属性的特性。
-
使用示例:
javascript
// 示例 1:基本使用,创建一个继承自指定对象的新对象
const animal = {
kind: 'mammal',
speak: function() {
console.log('I make a sound');
}
};
const dog = Object.create(animal);
dog.name = 'Buddy';
dog.speak(); // 输出: I make a sound
console.log(dog.kind); // 输出: mammal
// 示例 2:使用 propertiesObject 参数
const person = {
name: 'John'
};
const newPerson = Object.create(person, {
age: {
value: 30,
writable: true,
enumerable: true,
configurable: true
},
city: {
value: 'New York',
writable: true,
enumerable: true,
configurable: true
}
});
console.log(newPerson.name); // 输出: John
console.log(newPerson.age); // 输出: 30
console.log(newPerson.city); // 输出: New York
-
注意点:
- 属性共享问题:与原型链继承类似,新对象继承的原对象属性中,若存在引用类型属性,那么多个基于相同原对象创建的新对象会共享这些引用类型属性。例如:
javascript
const shared = {
data: []
};
const obj1 = Object.create(shared);
const obj2 = Object.create(shared);
obj1.data.push(1);
console.log(obj2.data); // 输出: [1]
- 没有构造函数 :原型式继承创建的对象没有明确的构造函数概念。虽然新对象的原型指向了原对象,但这与通过构造函数创建对象并设置原型的方式不同。
- 这意味着不能像使用构造函数那样,通过
new
关键字来创建具有特定初始化逻辑的对象。例如,无法为新对象在创建时传递参数进行初始化。
- 这意味着不能像使用构造函数那样,通过
js
const person = {
name: 'person',
sayName: function() {
console.log(this.name);
}
};
// 这里的参数不会被用于初始化新对象
const anotherPerson = Object.create(person, 'anotherPerson');
anotherPerson.sayName(); // 输出 'person',而不是预期的 'anotherPerson'
适用场景
适用于简单对象的继承,当不需要使用构造函数来创建对象时,可以使用这种方式。
- 比如在配置对象场景中,有一个基础的配置对象,其中包含一些通用的配置项,如超时时间、基础 URL 等。基于这个基础配置对象,通过
Object.create()
可以快速创建多个具有特定修改的配置对象,而无需定义复杂的构造函数。
5. 寄生式继承
原理
寄生式继承建立在原型式继承之上。
首先,通过原型式继承的方式,使用Object.create()
基于一个现有的对象创建一个新对象,这使得新对象继承了现有对象的属性和方法。然后,在这个新创建的对象基础上,通过为其添加额外的属性或方法来对其进行增强,从而满足特定的业务需求。
代码示例
javascript
function createAnother(original) {
// 使用Object.create()基于original对象创建一个新对象clone,实现原型式继承
const clone = Object.create(original);
// 为clone对象添加一个新的方法sayHi
clone.sayHi = function() {
console.log('Hi');
};
// 返回增强后的clone对象
return clone;
}
const person = {
name: 'person',
sayName: function() {
console.log(this.name);
}
};
const anotherPerson = createAnother(person);
anotherPerson.sayHi(); // Hi
缺点
- 与构造函数继承类似,寄生式继承的方法难以复用。
- 在寄生式继承中,新添加的方法(如上述代码中的
sayHi
方法)是直接添加到新创建的对象实例上的,而不是添加到原型上。这就导致每个通过这种方式创建的新对象都拥有自己独立的方法副本。 - 当有大量对象需要创建时,会造成内存的浪费,因为每个对象都重复存储了相同的方法代码,无法像将方法定义在原型上那样实现方法在多个对象间的共享。
适用场景
适用于需要对现有对象进行简单增强的场景。
- 例如,在一个小型项目中,已经有一个基础的工具对象,该对象包含了一些常用的工具函数,如字符串处理函数、数组操作函数等。随着项目的推进,某个特定模块需要在这些基础工具的基础上,额外添加一个特殊功能的函数。
- 通过寄生式继承,基于现有的工具对象创建一个新对象,然后为新对象添加特定模块所需的函数,而不会影响到原有的工具对象以及其他地方对该工具对象的使用。
javascript
// 假设已有一个基础工具对象
const basicUtils = {
sumArray: function(arr) {
return arr.reduce((acc, num) => acc + num, 0);
},
capitalize: function(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
};
function enhanceUtils(original) {
const enhanced = Object.create(original);
// 为特定模块添加一个新功能:计算数组平均值
enhanced.averageArray = function(arr) {
return this.sumArray(arr) / arr.length;
};
return enhanced;
}
const projectUtils = enhanceUtils(basicUtils);
const numbers = [1, 2, 3, 4, 5];
console.log(projectUtils.averageArray(numbers));
- 又如在一个游戏开发中,已经有一个通用的角色对象,包含了角色的基本属性和行为方法,如移动、跳跃等。现在某个关卡需要一种特殊的角色,除了具备通用角色的所有功能外,还需要有一个独特的技能。
- 利用寄生式继承,基于通用角色对象创建特殊角色对象,并为其添加独特技能方法,即可满足该关卡的需求,同时不会对其他关卡的通用角色造成影响。
6. 寄生组合式继承
原理
- 寄生组合式继承是为了解决组合继承的一些缺点而出现的。
- 在组合继承中,子类的原型被设置为父类的一个实例,这会导致父类构造函数被调用两次:一次是在创建子类原型时,另一次是在创建子类实例时。
- 而寄生组合式继承通过
Object.create(Parent.prototype)
创建一个中间对象,这个中间对象的原型指向父类的原型。然后将这个中间对象作为子类的原型,这样就避免了父类构造函数被调用两次的问题,同时还能让子类继承父类原型上的方法。
代码示例
js
// 定义父类构造函数 Parent,接收 name 参数
function Parent(name) {
this.name = name;
this.colors = ['red', 'blue', 'green'];
}
// 在父类原型上定义 sayName 方法
Parent.prototype.sayName = function() {
console.log(this.name);
};
// 定义子类构造函数 Child,接收 name 参数
function Child(name) {
// 调用父类构造函数,实现属性继承
Parent.call(this, name);
}
// 实现寄生组合式继承的函数
function inheritPrototype(Child, Parent) {
// 创建中间对象,其原型指向父类原型
const prototype = Object.create(Parent.prototype);
// 修正 constructor 属性
prototype.constructor = Child;
// 将子类原型设置为中间对象
Child.prototype = prototype;
}
// 调用函数实现继承
inheritPrototype(Child, Parent);
// 创建子类实例
const child1 = new Child('child1');
const child2 = new Child('child2');
// 修改 child1 的 colors 属性
child1.colors.push('yellow');
console.log(child1.colors); // ["red", "blue", "green", "yellow"]
console.log(child2.colors); // ["red", "blue", "green"]
优点
- 解决组合继承的冗余问题:在组合继承中,父类构造函数被调用两次,会导致一些不必要的开销,比如父类实例属性会被重复创建。而寄生组合式继承只在创建子类实例时调用一次父类构造函数,避免了这种冗余,提高了性能。
- 代码复用性高:通过将中间对象的原型指向父类的原型,子类可以继承父类原型上的方法,实现了方法的复用。同时,子类实例又拥有自己独立的属性,避免了原型链继承中引用类型属性共享的问题。
- ES6 中的
class
继承的基础 :ES6 中的class
继承实际上就是基于寄生组合式继承实现的。这说明寄生组合式继承是一种比较理想的继承方式,符合现代 JavaScript 开发的需求。
适用场景
适用于大多数需要继承的场景,特别是对性能和代码复用性有较高要求的场景,具体如下:
- 大型项目开发 :在大型项目中,可能会有大量的对象需要创建和继承。如果使用组合继承,父类构造函数被多次调用会带来明显的性能开销。而寄生组合式继承避免了这种冗余,能够提高项目的整体性能。
- 例如,在一个电商系统中,有商品类、服装类、电子产品类等,服装类和电子产品类都继承自商品类。使用寄生组合式继承可以更高效地实现这种继承关系,减少不必要的开销。
- 框架和库的开发 :框架和库通常需要被广泛使用,对性能和代码复用性要求较高。寄生组合式继承可以确保框架和库中的类能够高效地继承和复用代码。
- 比如,在一个前端框架中,有各种组件类,这些组件类可能会继承自一个基础组件类。使用寄生组合式继承可以让框架更加高效和稳定。
- 频繁创建对象的场景 :当需要频繁创建对象时,性能问题会更加突出。寄生组合式继承能够减少不必要的构造函数调用,提高对象创建的效率。
- 例如,在一个游戏开发中,可能会频繁创建各种角色对象,使用寄生组合式继承可以让角色对象的创建更加高效。
7. class 继承
原理
- 在 ES6 中引入的
class
关键字,为 JavaScript 提供了更接近传统面向对象编程语言的类和继承的语法糖。 - 其底层原理本质上还是基于原型和原型链,和寄生组合式继承紧密相关。当使用
class
定义一个类并通过extends
关键字实现继承时,JavaScript 引擎会自动创建一个继承体系。 - 子类会继承父类的属性和方法,并且可以重写或扩展这些属性和方法。在这个过程中,JavaScript 会创建一个中间对象来处理原型链关系,类似于寄生组合式继承中创建中间对象的操作,从而确保子类能够正确地继承父类的行为,同时保持属性和方法的正确访问与覆盖机制。
代码示例
javascript
// 定义父类
class Parent {
constructor(name) {
this.name = name;
this.colors = ['red', 'blue', 'green'];
}
sayName() {
console.log(this.name);
}
}
// 定义子类,继承自 Parent
class Child extends Parent {
constructor(name) {
// 调用父类构造函数,初始化继承自父类的属性
super(name);
}
}
// 创建子类实例
const child1 = new Child('child1');
const child2 = new Child('child2');
// 修改 child1 的 colors 属性
child1.colors.push('yellow');
console.log(child1.colors); // ["red", "blue", "green", "yellow"]
console.log(child2.colors); // ["red", "blue", "green"]
优点
- 语法简洁清晰 :
class
继承的语法更加直观和符合传统面向对象编程的习惯,代码结构更加清晰,易于理解和维护。 - 明确的继承关系 :使用
extends
关键字明确地表明了子类与父类之间的继承关系,这在大型项目中对于理解代码结构和类之间的层级关系非常有帮助。 - 更好的语义化 :
class
和extends
等关键字提供了更好的语义化表达,使得代码更具可读性,能够让开发者更容易地理解代码的意图和功能。
适用场景
适用于各种需要构建复杂对象体系和继承结构的场景,尤其是在大型应用程序开发、框架和库的构建中。
- 例如,在一个大型的企业级 JavaScript 应用中,可能会有一系列的用户相关类,如普通用户类、管理员用户类等,管理员用户类可以继承自普通用户类,通过
class
继承可以方便地定义和管理这些类之间的关系,并且清晰地实现功能的扩展和重写。 - 在前端框架如 Vue 和 React 的组件开发中,也经常使用
class
继承来构建组件的层级结构,方便管理组件的属性和方法的继承与扩展。