📌引言
- 为什么 JavaScript 的继承如此重要?
- 继承在前端开发中的实际应用场景(React/Vue 组件、Class 继承等)
- 常见面试题:“手写 JS 继承” 考察的核心是什么?
本文基于原型和原型链的基础——面试官:是不是所有对象都有隐式原型? - 掘金
🔍什么是JavaScript的继承
JavaScript 的继承是指一个对象能够继承另一个对象的属性和方法,从而实现代码的重用和功能扩展。继承是面向对象编程(OOP)中的一个重要概念,允许子类通过继承父类的属性和方法,避免重复编写相同的代码,同时可以添加或修改自己的特性。
继承的核心思想:
- 复用性:避免重复代码,子类直接继承父类的功能。
- 扩展性:子类可以在父类基础上新增或修改行为。
- 层次性:通过继承关系形成清晰的层级结构。
🛠️继承的两种主要方式
1. ES5中经典的继承
JavaScript 是基于原型链实现继承的。每个对象都有一个内部属性 [[Prototype]],通常通过 __proto__ 访问,这指向它的原型对象。V8在查找属性和方法时,会先去对象的显示属性中查找,找不到就会去对象的__proto__中查找,还找不到就会顺着__proto__一直往上查找,直到找到null为止,这种查找的关系链条就叫做原型链。而继承就是子类可以直接通过原型链访问父类的属性和方法。
另外,在JavaScript中,new操作符在创建实例对象时,就能够让实例对象的隐式原型(__proto__)等于构造函数的显示原型(prototype),所以new在继承过程中发挥着重要作用。
new的原理:
- 创建一个新对象
- 新对象的__proto__指向构造函数的prototype
- 构造函数的this指向新对象
- 执行构造函数的代码
- 当构造函数中有返回值且返回值的类型是引用类型时,new会返回构造函数的执行结果,否则才返回新对象
- 原型链继承
原型链继承的实现方式是让子类的显式原型成为父类的实例对象:Child.prototype = new Parent(),代码如下:
Parent.prototype.say = function(){
console.log('hello');
}
function Parent(){
this.name = 'parent';
this.age = 50 // 实例属性
}
// function Parent(age){
// this.name = 'parent';
// this.age = age // 实例属性
// }
function Child(name){
this.name = name
}
Child.prototype = new Parent() // Child.prototype.__proto__ = Parent.prototype 没办法传参
const c = new Child("child") // c.__proto__ = Child.prototype
console.log(c.name); // child
console.log(c.age); // 50
c.say() // hello
通过让子类的构造函数的原型成为父类构造函数的实例对象,也就是子类的原型的隐式原型等于父类的显式原型(Child.prototype.__proto__ = Parent.prototype),使得子类的实例对象c可以通过原型链访问父类的属性和方法(c.__proto__.__proto__ = Child.prototype.__proto__ = Parent.prototype)。
但是这个方法也有缺点:子类无法给父类传参。例如代码10-13行,父类需要传参age,如果我们将1-19行封装成了一个函数时,我的子类是无法给父类传参数的。
- 构造函数继承
Parent.call(this):显式绑定父类的this指向子类的this,而在创建子类的实例对象的时候,子类的this指向实例对象,实现了子类的实例对象也可以访问到父类的属性。
代码如下:
Parent.prototype.say = function(){
console.log('hello');
}
function Parent(age){
this.name = 'parent';
this.age = age // 实例属性
}
function Child(name, age){
Parent.call(this, age) // Child的this指向实例,Parent的this指向Child的this
this.name = name
}
const c = new Child("child",50) // c.__proto__ = Child.prototype
console.log(c.name); // child
console.log(c.age); // 50
c.say() // 访问不到 c.say is not a function
但是它也存在缺点:无法继承父类原型方法。因为实例对象c没办法通过原型链访问到父类的原型,所以父类原型上的属性和方法无法继承。
- 组合继承(最常用)
为了解决前面两种方式的缺点,于是就有了组合继承方式:Parent.call(this) + Child.prototype = new Parent(),因为call可以解决子类向父类传递参数的问题,只要结合修改子类的原型就可以完全继承父类的属性和方法。
代码如下:
Parent.prototype.say = function(){
console.log('hello');
}
function Parent(age){
this.name = 'parent';
this.age = age // 实例属性
}
Child.prototype = new Parent()
function Child(name, age){
Parent.call(this, age) // Child的this指向实例,Parent的this指向Child的this
this.name = name
}
const c = new Child("child",50) // c.__proto__ = Child.prototype
console.log(c.name); // child
console.log(c.age); // 50
c.say() // hello
它同样存在缺点,就是在new Parent()的时候已经调用了一次父类构造函数,然后在Parent.call(this)的时候又调用了一次父类构造函数,总共调用了两次父类构造函数。
- 原型式继承
Object.create() 方法用于创建一个新对象,并且可以指定该对象的原型对象(prototype)。这种方式被称为原型式继承,它不像传统的类继承那样需要显式的构造函数和类体系,而是通过直接操作对象的原型来实现继承,适用于简单对象克隆。代码如下:
const obj = {
name:'cc',
age:18
}
let newObj = Object.create(obj)
console.log(newObj.name, newObj.age);
它的缺点就是:Object.create() 创建的对象无法直接传递参数给父类,父类构造函数不能自动调用。
- 寄生组合继承(最优解)
Object.create()可以保证原型链的继承完整,结合构造函数继承,又可以确保父类的构造函数被调用,这就是实现寄生组合继承的方式:Parent.call(this) + Object.create(Parent.prototype)。
代码如下:
Parent.prototype.say = function(){
console.log('hello');
}
function Parent(age){
this.name = 'parent';
this.age = age // 实例属性
}
// Child.prototype.__proto__ = Parent.prototype // c.constructor [Function: Child]
Child.prototype = Object.create(Parent.prototype) // Child.prototype.__proto__ = Parent.prototype
Child.prototype.constructor = Child
function Child(name, age){
Parent.call(this, age)
this.name = name
}
const c = new Child("child", 50) // c.__proto__ = Child.prototype
console.log(c.name); // child
console.log(c.age); // 50
c.say() // hello
console.log(c instanceof Child); // true
console.log(c instanceof Parent); // true
console.log(c.constructor); // [Function: Child]
在上面的方式中,值得注意是,我们使用修改子类构造函数原型的方式去继承了父类的原型,在实例对象原型中的构造函数属性constructor也被修改了。所以我们需要再在子类中将子类的构造函数原型上的constructor属性改回去,即如上面代码的第11行。
2. ES6 的 Class 继承
class和extends语法糖
从 ECMAScript 6(ES6)开始,JavaScript 引入了 class 关键字来支持类的定义,这使得继承变得更加直观和简洁。class 的继承基于原型链,底层依然是通过原型链来实现的,但提供了更符合传统面向对象语言的语法。
代码举例如下:
class Parent{
constructor(name, age){
this.name = name
this.age = age
this.sayBey = function(){
console.log('Bey bey~')
}
}
say(){ // 这是父类的原型的方法
console.log('hello');
}
}
// 继承
class Child extends Parent{
constructor(name, age){
super(name, age) // 继承到这儿
this.sex = 'boy'
}
}
const c = new Child("mm", 18)
console.log(c); // Child { name: 'mm', age: 18, sex: 'boy' }
c.sayBey()
c.say() // hello
如上代码,在ES6的继承中使用constructor 即类的构造函数,用来创建类的实例并初始化实例的属性。
父类构造函数:父类的 constructor 函数是用来初始化实例属性的。它会在每次创建父类实例时自动调用。
子类构造函数:子类的 constructor 函数是用来初始化子类实例的。
当你使用 class 和 extends 关键字创建子类时,子类通常会调用父类的构造函数来继承父类的实例属性。它会首先执行子类自己的构造代码,接着可以通过 super() 来调用父类的构造函数,继承父类的实例属性。
但是值得注意的点是:super() 必须在子类的构造函数中调用,并且必须在使用 this 之前调用。如果子类没有显式定义 constructor,它会默认自动给子类添加一个构造函数,并且隐式的调用super()调用父类的构造函数。
举个例子:
class Animal {
constructor(name) {
this.name = name;
}
sayHello() {
console.log(`Hello, my name is ${this.name}`);
}
}
class Dog extends Animal {
// Dog 没有显式定义 constructor
}
const dog = new Dog('Buddy');
dog.sayHello(); // Hello, my name is Buddy
在这个例子中,子类Dog 没有定义 constructor,所以 JavaScript 自动为 Dog 提供了一个默认的构造函数,且这个默认构造函数会隐式地调用父类 Animal 的构造函数 (super(name)),并且将 name 作为参数传递给父类。所以Dog 的实例能够继承 Animal 的 name 属性,并调用 sayHello 方法。
- 对比 ES5 的寄生组合继承
| 特性 | ES6 继承 | ES5 寄生组合继承 |
|---|---|---|
| 语法简洁 | 是,使用 class 和 extends,语法更直观 | 否,需要手动实现构造函数继承和原型链继承 |
| 继承原型方法 | 自动通过 extends 继承父类原型方法 | 通过 Object.create 手动继承父类原型方法 |
| 调用父类构造函数 | 使用 super() 调用父类构造函数 | 使用 Parent.call(this, name) 手动调用父类构造函数 |
| 内存优化 | 内存优化较好,所有实例共享原型方法 | 内存优化较好,所有实例共享原型方法 |
| 可维护性 | 高,结构清晰易懂 | 较低,代码冗长,难以理解和维护 |
| 适用范围 | 推荐使用,尤其在现代 JavaScript 开发中 | 适用于没有 class 和 extends 关键字的旧版 JavaScript |