《前端面试必刷:JavaScript继承的四种实现与ES6底层原理剖析》

247 阅读9分钟

前言

作者最近在看神三元大佬的JS灵魂之问系列同中的“JS如何实现继承这部分”时,感触很深,又结合了一下《你不知道的JavaScript》,准备借这篇文章对这部分内容进行总结输出,一方面可以巩固自己的记忆,另一方面可以给部分想要了解这个知识的jym进行参考。

这篇文章会参考部分神三元大佬的内容,但是在阅读过《你不知道的JavaScrip》和问了很多次DeepSeek后,加入了很多自己的解读而且还会拓展一些知识,会讲的很细,保证大家能够理解!


继承特点是什么

首先我们先思考一下,继承的特点是什么

继承的核心特点可以概括为:

  1. 代码复用:子类能够继承父类的属性和方法,避免重复编写相同代码
  2. 隔离性:子类是一个独立的实例,在子类上的操作不会影响父类
  3. 多态支持:子类可以重写父类方法,实现不同行为
  4. 扩展能力:子类可以在继承基础上添加新属性和方法

我们接下来实现时则需要思考这些点。


es5 如何实现继承的

在ECMAScript6之前,JavaScript中的继承可谓是非常的繁琐的,有各种各样的继承:

1. call/apply继承

实现原理:在子类构造函数中用call/apply调用父类构造函数

function Parent(name) {
  this.name = name;
  this.getName = function() {
    return this.name;
  }
}

function Child(name) {
  Parent.call(this, name); // 核心代码
  this.type = 'child';
}

let child = new Child('Tom');
console.log(child.name); // Tom

但这样子做会出现一个问题,子类无法继承父类的方法:

Parent.prototype.sayHi = function(){
    console.log("hi!");
}

Child.sayHi()  //报错:child.sayHi is not a function

2.原型链继承

实现原理:将子类的原型对象指向父类的实例

function Parent() {
  this.name = 'parent';
  this.play = [1, 2, 3];
}
Parent.prototype.sayHi = function(){
    console.log("hi")
}

function Child() {
  this.type = 'child';
}
Child.prototype = new Parent();

var s1 = new Child();
s1.sayHi()  //“hi” 能够继承父类的方法

这样子虽然解决了不能继承方法的缺点,但是还是会出现问题:

var p = new Child();  //父类实例
var s1 = new Child();  //子类1实例
var s2 = new Child();  //子类2实例

s1.play.push(4);  //操作子类1的属性

// 影响了父类属性
console.log(p.play);  //[1,2,3,4]
console.log(s1.play)  //[1,2,3,4]
console.log(s2.play)  //[1,2,3,4]

这不符合继承的隔离性:子类是一个独立的实例,在子类上的操作不会影响父类


3.组合继承

实现原理:结合原型链继承和构造函数继承

function Parent(name) {
  this.name = name;
  this.colors = ['red', 'blue'];
}
Parent.prototype.sayName = function() {
  console.log(this.name);
}

function Child(name, age) {
  Parent.call(this, name); 
  this.age = age;
}
Child.prototype = new Parent();
Child.prototype.constructor = Child;
Child.prototype.sayAge = function() {
  console.log(this.age);
}

var child1 = new Child('Tom', 20);
child1.colors.push('green');
console.log(child1.colors); // ["red","blue","green"]
child1.sayName(); // Tom
child1.sayAge(); // 20

var child2 = new Child('Jack', 18);
console.log(child2.colors); // ["red","blue"]

可以看到,组合式继承可以完美地解决上述问题,实现了继承所有特性

  1. 代码复用:子类能够继承父类的属性和方法,避免重复编写相同代码
  2. 隔离性:子类是一个独立的实例,在子类上的操作不会影响父类
  3. 多态支持:子类可以重写父类方法,实现不同行为
  4. 扩展能力:子类可以在继承基础上添加新属性和方法

可是它的实现仍然存在一个问题:你会发现这里调用了两次父类构造函数Parent.call(this) + new Parent()

  • 第一次:Parent.call(this)(在子类构造函数里)
  • 第二次:Child.prototype = new Parent()(设置子类原型时)
  • 这会导致 子类原型上多出一份冗余的父类实例属性(虽然实例属性会覆盖原型上的同名属性,但仍然浪费内存)。

4.组合继承改进方案(最推荐)

实现原理:使用 Object.create() 替代 new Parent(),避免重复调用父类构造函数:

function Parent(name) {
  this.name = name;
  this.colors = ['red', 'blue'];
}
Parent.prototype.sayName = function() {
  console.log(this.name);
};

function Child(name, age) {
  Parent.call(this, name); // 只在这里调用一次 Parent
  this.age = age;
}

// 关键改进:用 Object.create 建立原型链,避免 new Parent()
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

Child.prototype.sayAge = function() {
  console.log(this.age);
};

const child1 = new Child('Tom', 20);
child1.colors.push('green');
console.log(child1.colors); // ["red", "blue", "green"]
child1.sayName(); // "Tom"
child1.sayAge(); // 20

const child2 = new Child('Jack', 18);
console.log(child2.colors); // ["red", "blue"]

在改进之后,

  • 不再调用 new Parent(),而是直接让 Child.prototype 继承 Parent.prototype,避免了冗余的父类实例属性。

通常来说,这一版的继承方案就是我们最推荐的ES5中的继承方案!


说说上述 Object.create(Parent.prototype) 的作用

Object.create() 方法创建一个新对象,并使用传入的对象作为这个新对象的原型__proto__)。

Child.prototype = Object.create(Parent.prototype);

这行代码做了以下事情:

  1. 创建一个空对象let newObj = {}
  2. 设置这个空对象的原型:将这个空对象的 __proto__ 指向 Parent.prototype。即 newObj.__proto__ = Parent.prototype
  3. 将这个新对象赋值给 Child.prototype:现在,Child.prototype 是一个空对象,但它的原型是 Parent.prototype

这样做的巨大好处

  • 实现了原型链继承:现在,任何通过 new Child() 创建的实例,它的原型链是:
    child1 -> Child.prototype (一个空对象) -> Parent.prototype -> Object.prototype -> null
    因此,child1 可以顺利找到并调用 Parent.prototype.sayName 方法。
  • 避免了执行父构造函数Object.create() 不会调用 Parent 构造函数,所以 Child.prototype 这个对象本身是干净的,没有多余的 name 和 colors 属性。父构造函数只在 Child 构造函数内部通过 Parent.call(this, name) 被调用,用于初始化实例自身的属性。


es6是如何实现继承的

ES6发布之后,JavaScript规范中多了一个"语法糖"——class关键字,它很大程度上模仿了面向过程语言的规范,其中就包括了类继承要用到的关键字extends1!

class Parent {
  constructor(name) {
    this.name = name;
  }
  sayName() {
    console.log('Parent name:', this.name);
  }
  static staticMethod() {
    console.log('static method');
  }
}

class Child extends Parent {
  constructor(name, age) {
    super(name); // 调用父类的constructor
    this.age = age;
  }
  sayAge() {
    console.log('Child age:', this.age);
  }
}

const child = new Child('Tom', 18);
child.sayName(); // Parent name: Tom
child.sayAge(); // Child age: 18
Child.staticMethod(); // static method

es6的继承源码解读

ES6的class语法本质上是语法糖,其底层仍然是基于原型链实现的。我们可以通过Babel等工具查看其转译后的ES5代码:

"use strict";

function _typeof(obj) { /*...*/ }
// 关键:继承部分
// 实现类继承的辅助函数
function _inherits(subClass, superClass) { 
  // 类型检查:确保父类是函数或null
  if (typeof superClass !== "function" && superClass !== null) {
    throw new TypeError("Super expression must either be null or a function");
  }

  // 创建子类原型对象,继承父类原型方法
  subClass.prototype = Object.create(superClass && superClass.prototype, {
    constructor: { 
      value: subClass,   // 重置构造函数指向子类
      writable: true,
      configurable: true 
    } 
  });

  // 设置子类的__proto__继承父类(用于静态方法继承)
  if (superClass) _setPrototypeOf(subClass, superClass);
}

// 设置对象原型的兼容性写法
function _setPrototypeOf(o, p) { 
  _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) {
    o.__proto__ = p;  // 兼容旧环境
    return o;
  };
  return _setPrototypeOf(o, p);
}

// 创建父类构造函数的引用(用于super调用)
function _createSuper(Derived) { /*...*/ }

// 父类构造函数
var Parent = function Parent(name) {
  _classCallCheck(this, Parent);  // 类型检查
  this.name = name;
};

// 父类原型方法
Parent.prototype.sayName = function sayName() {
  console.log('Parent name:', this.name);
};

// 父类静态方法
Parent.staticMethod = function staticMethod() {
  console.log('static method');
};

// 子类定义
var Child = /*#__PURE__*/function (_Parent) {
  _inherits(Child, _Parent);  // 继承父类
  
  var _super = _createSuper(Child);  // 创建super引用
  
  function Child(name, age) {
    _classCallCheck(this, Child);
    // 调用父类构造函数
    _super.call(this, name);
    this.age = age;
  }
  
  return Child;
}(Parent);

这里的代码可能一些基础不太好的朋友们看起来比较吃力,建议大家可以去用ai软件解读一下这里面的每个部分,实际上就是一些api的调用规则,作者也是一边叫ai解读一边理解的!

这里由于篇幅有限,作者就给出里面最关键的两段代码深度解读一下:

一、_inherits实现继承中的原型更改

function _inherits(subClass, superClass) {
  // 创建子类原型对象(核心继承逻辑)
  subClass.prototype = Object.create(superClass && superClass.prototype, {
    constructor: { 
      value: subClass,
      writable: true,
      configurable: true
    }
  });
  
  // 设置静态方法继承(关键扩展点)
  if (superClass) _setPrototypeOf(subClass, superClass);
}

这里的_inherits函数的目的就是为了使用原型链的方式继承(这里就是我们上面提到的4.组合继承):

首先接受两个参数subClass表示子类, superClass表示父类

Object.create()中:

  • 第一个参数: 使用&&表达式,解决当superClass传入为空时,属性superClass.prototype 报错:不能读取空对象的prototype的情况。

  • 第二个参数:属性描述符,目的是用于修正constructor指向

  • 效果:subClass.prototype = Object.create(...)创建 subClass.prototype 对象,该对象原型链指向父类原型,同时保证 constructor 正确指向子类

二、子类定义

var Child = /*#__PURE__*/function (_Parent) {
  _inherits(Child, _Parent);  // 继承父类
  
  var _super = _createSuper(Child);  // 创建super引用
  
  function Child(name, age) {
    _classCallCheck(this, Child);
    // 调用父类构造函数
    _super.call(this, name);
    this.age = age;
  }

  return Child;
}(Parent);
  • 开始的/*#__PURE__*/注释,这有助于打包工具进行tree-shaking

  • 该函数第一行 使用了一、_inherits实现继承中的原型更改的方法,通过更改原型链来继承父类,

  • var _super = _createSuper(Child);的部分是实现了super的定义

  • function Child(name, age)这部分是实现子类构造函数的定义,方便我们创建实例的时候用


经典问题解析

为什么ES6继承必须先调用super?

ES6的继承机制与ES5完全不同,ES6是先创造父类的实例对象this(所以必须先调用super方法),然后再用子类的构造函数修改this。如果不调用super方法,子类就得不到this对象。

ES6的class是真正的类吗?

ES6的class本质上仍然是基于原型的继承,只是语法糖。JavaScript中没有真正的类,class语法只是让原型继承的写法更加清晰、更像面向对象编程的语法而已。

什么时候可以省略constructor?

如果子类没有显式定义constructor方法,这个方法会被默认添加。也就是说,不管有没有显式定义,任何一个子类都有constructor方法。


总结

JavaScript的继承机制经历了一个从复杂到简洁的演进过程:

  1. ES5时代:我们需要通过原型链、构造函数、组合继承等多种方式手动实现继承,最完美的方式是组合式继承
  2. ES6时代:通过class和extends关键字简化了继承的实现,但其本质仍然是基于原型的继承

理解JavaScript继承的关键在于深入理解原型链机制。ES6的class语法虽然让代码更加简洁易读,但背后仍然是基于原型的继承模型。掌握这些底层原理,对于编写高质量的JavaScript代码至关重要。

在实际开发中,建议:

  • 现代项目优先使用ES6的class和extends
  • 需要兼容老旧环境时,可以使用寄生组合式继承
  • 理解底层原理,避免因误解class机制而导致的问题

JavaScript的继承机制体现了这门语言的灵活性和强大功能,虽然它的实现方式与传统的面向对象语言有所不同,但通过深入理解原型链,我们可以充分利用这些特性构建出优雅高效的代码结构。