从原型链到 Class 继承:JavaScript 的进化之路

172 阅读8分钟

📌引言

  • 为什么 JavaScript 的继承如此重要?
  • 继承在前端开发中的实际应用场景(React/Vue 组件、Class 继承等)
  • 常见面试题:“手写 JS 继承” 考察的核心是什么?

本文基于原型和原型链的基础——面试官:是不是所有对象都有隐式原型? - 掘金

🔍什么是JavaScript的继承

JavaScript 的继承是指一个对象能够继承另一个对象的属性和方法,从而实现代码的重用和功能扩展。继承是面向对象编程(OOP)中的一个重要概念,允许子类通过继承父类的属性和方法,避免重复编写相同的代码,同时可以添加或修改自己的特性。

继承的核心思想:

  • 复用性:避免重复代码,子类直接继承父类的功能。
  • 扩展性:子类可以在父类基础上新增或修改行为。
  • 层次性:通过继承关系形成清晰的层级结构。

🛠️继承的两种主要方式

1. ES5中经典的继承

JavaScript 是基于原型链实现继承的。每个对象都有一个内部属性 [[Prototype]],通常通过 __proto__ 访问,这指向它的原型对象。V8在查找属性和方法时,会先去对象的显示属性中查找,找不到就会去对象的__proto__中查找,还找不到就会顺着__proto__一直往上查找,直到找到null为止,这种查找的关系链条就叫做原型链。而继承就是子类可以直接通过原型链访问父类的属性和方法。

另外,在JavaScript中,new操作符在创建实例对象时,就能够让实例对象的隐式原型(__proto__)等于构造函数的显示原型(prototype),所以new在继承过程中发挥着重要作用。

new的原理:

  1. 创建一个新对象
  2. 新对象的__proto__指向构造函数的prototype
  3. 构造函数的this指向新对象
  4. 执行构造函数的代码
  5. 当构造函数中有返回值且返回值的类型是引用类型时,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 函数是用来初始化子类实例的。

当你使用 classextends 关键字创建子类时,子类通常会调用父类的构造函数来继承父类的实例属性。它会首先执行子类自己的构造代码,接着可以通过 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