JavaScript继承:从原型到Class的转变
引言
作为前端开发者,面向对象编程(OOP)是我们必须掌握的核心概念之一,而继承则是OOP中实现代码复用和扩展的重要机制。与传统的基于类(Class-based)的编程语言(如Java、C++)不同,JavaScript采用了一种独特的基于原型(Prototype-based)的继承方式。
这种差异导致很多开发者在学习JavaScript继承时感到困惑:为什么JavaScript没有class关键字(ES6之前)?prototype和__proto__有什么区别?如何实现真正的继承?
接下来,还是面试官开始提问:请说一下javascrpt 如何实现继承?有哪些方式?都有哪些优缺点?
一、继承的基本概念
1. 什么是继承?
继承是面向对象编程中的一种机制,它允许一个对象(子类)继承另一个对象(父类)的属性(数据) 和 方法(函数),从而实现代码复用和扩展。
继承的主要优势:
- 代码复用:避免重复编写相同的代码
- 扩展性:可以在不修改原有代码的基础上添加新功能
- 维护性:集中管理共享的属性和方法,便于维护
虽然大多数业务场景不需要你专门去实现一个继承,常见封装通用功能,可还得用(写一个父类),绕不过去;
2. JavaScript继承的特殊性
JavaScript是一种基于原型的编程语言,而不是传统的基于类的编程语言。这意味着:
- 在JavaScript中,对象直接从其他对象继承,而不是通过类定义
- 每个对象都有一个原型对象,可以从中继承属性和方法
- 原型对象也可以有自己的原型,形成原型链
3. 核心概念回顾
在深入学习继承之前,我们需要先回顾几个与JavaScript继承密切相关的核心概念:
可以参考我的上篇文章 juejin.cn/post/757883…
3.1 原型(Prototype)
每个JavaScript对象都有一个原型对象,对象可以从原型中继承属性和方法。
3.2 __proto__(隐式原型)
每个对象都有一个__proto__属性,指向它的原型对象。这是一个非标准属性,推荐使用Object.getPrototypeOf()和Object.setPrototypeOf()代替。
3.3 prototype(显式原型)
只有函数才有prototype属性,当函数作为构造函数使用时,新创建的对象会将这个prototype作为自己的__proto__。
3.4 原型链(Prototype Chain)
对象通过__proto__形成的链式结构,用于属性和方法的查找。
二、JavaScript中的继承方式
JavaScript中有多种实现继承的方式,每种方式都有其优缺点:
1. 原型链继承
原型链继承是JavaScript中最基本的继承方式,它通过将子类的原型设置为父类的实例来实现继承。
实现原理
- 创建一个父类构造函数
- 创建一个子类构造函数
- 将子类的原型设置为父类的实例
- 修复子类原型的
constructor指向
代码示例
// 父类:Animal
function Animal(type) {
this.type = type;
this.eating = true;
}
// 在父类原型上添加方法
Animal.prototype.eat = function() {
console.log('进食中...');
};
// 子类:Dog
function Dog(name, breed) {
this.name = name;
this.breed = breed;
}
// 实现原型链继承:将Dog的原型设置为Animal的实例
Dog.prototype = new Animal('dog');
// 修复constructor指向
Dog.prototype.constructor = Dog;
// 在子类原型上添加方法
Dog.prototype.bark = function() {
console.log('汪汪汪!');
};
// 创建实例
const myDog = new Dog('Buddy', 'Golden Retriever');
// 访问自身属性
console.log(myDog.name); // 输出:Buddy
console.log(myDog.breed); // 输出:Golden Retriever
// 访问继承的属性
console.log(myDog.type); // 输出:dog
console.log(myDog.eating); // 输出:true
// 调用继承的方法
myDog.eat(); // 输出:进食中...
// 调用子类方法
myDog.bark(); // 输出:汪汪汪!
原型链可视化
myDog (Dog实例)
└── __proto__ → Dog.prototype (Animal实例)
├── type: "dog"
├── eating: true
├── __proto__ → Animal.prototype
├── eat()方法
└── __proto__ → Object.prototype
└── __proto__ → null
优缺点
优点:
- 实现简单,易于理解
- 可以继承父类的属性和方法
缺点:
- 父类的引用类型属性会被所有子类实例共享
- 创建子类实例时,无法向父类构造函数传递参数
2. 构造函数继承
构造函数继承通过在子类构造函数中调用父类构造函数来实现继承,主要解决了原型链继承中引用类型属性共享的问题。
实现原理
- 创建一个父类构造函数
- 创建一个子类构造函数
- 在子类构造函数中使用
call()或apply()方法调用父类构造函数
代码示例
// 父类:Animal
function Animal(type) {
this.type = type;
this.skills = ['run', 'jump'];
}
// 子类:Dog
function Dog(name, breed, type) {
// 使用call()调用父类构造函数,实现属性继承
Animal.call(this, type);
this.name = name;
this.breed = breed;
}
// 创建实例
const dog1 = new Dog('Buddy', 'Golden Retriever', 'dog');
const dog2 = new Dog('Max', 'German Shepherd', 'dog');
// 修改dog1的skills数组
dog1.skills.push('swim');
console.log(dog1.skills); // 输出:["run", "jump", "swim"]
console.log(dog2.skills); // 输出:["run", "jump"](未受影响)
优缺点
优点:
- 解决了原型链继承中引用类型属性共享的问题
- 创建子类实例时,可以向父类构造函数传递参数
缺点:
- 无法继承父类原型上的方法
- 每个子类实例都会创建父类方法的副本,造成内存浪费
3. 组合继承
组合继承(也称为伪经典继承)结合了原型链继承和构造函数继承的优点,是JavaScript中最常用的继承模式。
实现原理
- 使用构造函数继承继承父类的属性
- 使用原型链继承继承父类的方法
代码示例
// 父类:Animal
function Animal(type) {
this.type = type;
this.skills = ['run', 'jump'];
}
// 在父类原型上添加方法
Animal.prototype.eat = function() {
console.log('进食中...');
};
// 子类:Dog
function Dog(name, breed) {
// 使用构造函数继承继承属性
Animal.call(this, 'dog');
this.name = name;
this.breed = breed;
}
// 使用原型链继承继承方法
Dog.prototype = new Animal();
// 修复constructor指向
Dog.prototype.constructor = Dog;
// 在子类原型上添加方法
Dog.prototype.bark = function() {
console.log('汪汪汪!');
};
// 创建实例
const myDog = new Dog('Buddy', 'Golden Retriever');
// 访问属性
console.log(myDog.name); // 输出:Buddy
console.log(myDog.type); // 输出:dog
// 调用方法
myDog.eat(); // 输出:进食中...
myDog.bark(); // 输出:汪汪汪!
// 验证引用类型属性不共享
const dog2 = new Dog('Max', 'German Shepherd');
myDog.skills.push('swim');
console.log(myDog.skills); // 输出:["run", "jump", "swim"]
console.log(dog2.skills); // 输出:["run", "jump"]
优缺点
优点:
- 既可以继承父类的属性,又可以继承父类原型上的方法
- 解决了引用类型属性共享的问题
- 可以向父类构造函数传递参数
缺点:
- 父类构造函数会被调用两次(一次在创建子类原型时,一次在子类构造函数中)
- 子类原型上会存在父类构造函数创建的不必要的属性
4. 原型式继承
原型式继承是由道格拉斯·克罗克福德(Douglas Crockford)提出的一种继承方式,它基于Object.create()方法实现,主要用于创建一个对象的浅拷贝。
实现原理
- 创建一个临时构造函数
- 将该构造函数的原型设置为要继承的对象
- 返回临时构造函数的实例
代码示例
// 要继承的对象
const animal = {
type: 'animal',
skills: ['run', 'jump'],
eat: function() {
console.log('进食中...');
}
};
// 原型式继承函数
function createObject(proto) {
function F() {} // 临时构造函数
F.prototype = proto; // 设置原型
return new F(); // 返回实例
}
// 使用原型式继承创建新对象
const dog = createObject(animal);
dog.name = 'Buddy';
dog.breed = 'Golden Retriever';
// 使用ES6的Object.create()方法(更简洁)
const cat = Object.create(animal);
cat.name = 'Kitty';
cat.breed = 'Persian';
console.log(dog.name); // 输出:Buddy
console.log(dog.type); // 输出:animal(继承自animal)
dog.eat(); // 输出:进食中...(继承自animal)
// 注意:引用类型属性仍然共享
dog.skills.push('swim');
console.log(dog.skills); // 输出:["run", "jump", "swim"]
console.log(cat.skills); // 输出:["run", "jump", "swim"](也被修改了)
优缺点
优点:
- 实现简单,适合创建对象的浅拷贝
- 不需要创建构造函数
缺点:
- 引用类型属性会被所有实例共享
- 无法传递参数
5. 寄生式继承
寄生式继承是在原型式继承的基础上,增强对象的一种继承方式。它通过创建一个仅用于封装继承过程的函数,在内部增强对象,然后返回该对象。
实现原理
- 使用原型式继承创建一个新对象
- 增强新对象(添加属性和方法)
- 返回增强后的对象
代码示例
// 原型式继承函数
function createObject(proto) {
function F() {};
F.prototype = proto;
return new F();
}
// 寄生式继承函数
function createDog(proto, name, breed) {
// 使用原型式继承创建对象
const dog = createObject(proto);
// 增强对象
dog.name = name;
dog.breed = breed;
dog.bark = function() {
console.log('汪汪汪!');
};
return dog;
}
// 要继承的对象
const animal = {
type: 'animal',
eat: function() {
console.log('进食中...');
}
};
// 使用寄生式继承创建dog对象
const myDog = createDog(animal, 'Buddy', 'Golden Retriever');
console.log(myDog.name); // 输出:Buddy
console.log(myDog.type); // 输出:animal(继承自animal)
myDog.eat(); // 输出:进食中...(继承自animal)
myDog.bark(); // 输出:汪汪汪!(增强的方法)
优缺点
优点:
- 可以增强对象,添加新的属性和方法
- 实现简单,不需要创建构造函数
缺点:
- 引用类型属性会被所有实例共享
- 无法传递参数给父类构造函数
- 增强的方法会在每个实例上创建副本,造成内存浪费
6. 寄生组合式继承
寄生组合式继承是组合继承的优化版本,它通过寄生式继承来继承父类的原型,解决了组合继承中父类构造函数被调用两次的问题,是目前最理想的继承方式之一。
实现原理
- 使用构造函数继承继承父类的属性
- 使用寄生式继承继承父类的原型(而不是创建父类实例)
- 修复子类原型的
constructor指向
代码示例
// 父类:Animal
function Animal(type) {
this.type = type;
this.skills = ['run', 'jump'];
}
// 在父类原型上添加方法
Animal.prototype.eat = function() {
console.log('进食中...');
};
// 子类:Dog
function Dog(name, breed) {
// 使用构造函数继承继承属性
Animal.call(this, 'dog');
this.name = name;
this.breed = breed;
}
// 寄生组合式继承函数
function inheritPrototype(subType, superType) {
// 创建父类原型的副本
const prototype = Object.create(superType.prototype);
// 修复constructor指向
prototype.constructor = subType;
// 设置子类原型
subType.prototype = prototype;
}
// 实现继承
inheritPrototype(Dog, Animal);
// 在子类原型上添加方法
Dog.prototype.bark = function() {
console.log('汪汪汪!');
};
// 创建实例
const myDog = new Dog('Buddy', 'Golden Retriever');
console.log(myDog.name); // 输出:Buddy
console.log(myDog.type); // 输出:dog
myDog.eat(); // 输出:进食中...
myDog.bark(); // 输出:汪汪汪!
优缺点
优点:
- 只调用一次父类构造函数
- 避免了在子类原型上创建不必要的属性
- 保持了原型链的完整性
- 是最理想的继承方式之一
缺点:
- 实现相对复杂
7. ES6 Class继承
ES6引入了class关键字,提供了更接近传统类语言的语法糖,但底层仍然基于原型链实现。
实现原理
- 使用
class关键字定义父类 - 使用
class关键字定义子类,并使用extends关键字继承父类 - 在子类构造函数中使用
super()调用父类构造函数
代码示例
// 父类:Animal
class Animal {
constructor(type) {
this.type = type;
this.skills = ['run', 'jump'];
}
// 实例方法
eat() {
console.log('进食中...');
}
// 静态方法
static create(type) {
return new Animal(type);
}
}
// 子类:Dog
class Dog extends Animal {
constructor(name, breed) {
// 必须先调用super()
super('dog');
this.name = name;
this.breed = breed;
}
// 子类方法
bark() {
console.log('汪汪汪!');
}
// 重写父类方法
eat() {
console.log('狗进食中...');
}
}
// 创建实例
const myDog = new Dog('Buddy', 'Golden Retriever');
console.log(myDog.name); // 输出:Buddy
console.log(myDog.type); // 输出:dog
myDog.eat(); // 输出:狗进食中...(重写后的方法)
myDog.bark(); // 输出:汪汪汪!
// 访问静态方法
const cat = Animal.create('cat');
console.log(cat.type); // 输出:cat
优缺点
优点:
- 语法简洁清晰,更接近传统类语言
- 自动处理原型链和constructor指向
- 支持
super关键字调用父类方法 - 支持静态方法继承
缺点:
- 底层仍然基于原型链,理解原型仍然很重要
- ES6之前的环境需要转译
三、各种继承方式的对比
| 继承方式 | 优点 | 缺点 |
|---|---|---|
| 原型链继承 | 实现简单 | 引用类型属性共享,无法传递参数 |
| 构造函数继承 | 避免引用类型共享,可传递参数 | 无法继承原型方法,方法重复创建 |
| 组合继承 | 继承属性和方法,避免引用类型共享 | 父类构造函数调用两次 |
| 原型式继承 | 实现简单,适合浅拷贝 | 引用类型属性共享 |
| 寄生式继承 | 增强对象,实现简单 | 引用类型共享,方法重复创建 |
| 寄生组合式继承 | 最优继承方式,只调用一次父类构造函数 | 实现复杂 |
| ES6 Class继承 | 语法简洁,支持super关键字 | 底层仍是原型链 |
四、继承的最佳实践
1. 优先使用ES6 Class继承
ES6的class语法提供了更清晰、更易读的继承实现方式,建议优先使用。
class Parent {
constructor(name) {
this.name = name;
}
sayName() {
console.log(this.name);
}
}
class Child extends Parent {
constructor(name, age) {
super(name);
this.age = age;
}
sayAge() {
console.log(this.age);
}
}
2. 避免使用原型链继承和构造函数继承
这两种继承方式都有明显的缺点,建议使用组合继承或寄生组合式继承(ES5环境下)。
3. 理解原型链的工作原理
即使使用ES6的class语法,理解原型链的工作原理仍然很重要,这有助于你调试和优化代码。
4. 避免修改内置对象的原型
修改内置对象(如Array、Object)的原型可能会导致命名冲突和意外行为。
// 不推荐:修改内置对象原型
Array.prototype.sum = function() {
return this.reduce((a, b) => a + b, 0);
};
// 推荐:创建工具函数
function sumArray(arr) {
return arr.reduce((a, b) => a + b, 0);
}
5. 使用组合而非继承
在某些情况下,使用组合(对象组合)可能比继承更灵活。组合是指一个对象包含另一个对象,而不是继承它。
// 组合方式
const animal = {
eat: function() {
console.log('进食中...');
}
};
const dog = {
...animal, // 组合animal的功能
name: 'Buddy',
bark: function() {
console.log('汪汪汪!');
}
};
五、总结
通过本文的学习,我们已经全面了解了JavaScript中各种继承方式的原理和实现:
- 原型链继承:通过设置子类原型为父类实例实现
- 构造函数继承:通过在子类构造函数中调用父类构造函数实现
- 组合继承:结合原型链继承和构造函数继承
- 原型式继承:基于
Object.create()创建对象的浅拷贝 - 寄生式继承:在原型式继承基础上增强对象
- 寄生组合式继承:组合继承的优化版本,只调用一次父类构造函数
- ES6 Class继承:提供更简洁的语法糖,底层仍基于原型链
每种继承方式都有其优缺点,在实际开发中,我们应根据具体需求选择合适的继承方式。ES6的class语法是目前推荐的方式,它提供了更清晰、更易读的代码结构。
记住,理解JavaScript的原型机制是掌握继承的关键,即使使用ES6的class语法,原型链仍然在底层发挥着重要作用。
思考与练习
- 为什么JavaScript采用基于原型的继承而不是基于类的继承?
- 组合继承和寄生组合式继承的主要区别是什么?
- ES6的
class继承和ES5的继承有什么不同? - 尝试实现一个完整的寄生组合式继承案例
- 什么时候应该使用组合而不是继承?
参考资料
如果你觉得本文对你有帮助,欢迎点赞、收藏、分享,也欢迎关注我,获取更多前端技术干货!