【JS面试向】深入原型链之class的继承

544 阅读3分钟

class 是如何实现继承的?

我相信时至今日,大部分同学看完题目都能很快的写出答案。

使用 ES 6 提供的,能够很快的实现继承。

class Parent {
  constructor() {
    this.name = '爸爸';
    this.books = ['JAVA']
  }

  showName() {
    console.log(this.name)
  }
}

class Child extends Parent {
  constructor() {
    super();
    this.name = '儿子';
    this.school = 'high school';
  }

  showSchool() {
    console.log(this.school);
  }
}

但是,其实 JavaScript 本身是没有类这个概念的,class 只是一个语法糖,Parent 与 Child 本质上还是函数。所以 class 提供的继承是基于 JavaScript 的原型链实现的。那么如何通过原型链实现继承呢?

ES5中的继承

JavaScript 中继承的本质就是子类共享父类的原型对象。

ps:仅个人观点,有什么不妥请大家及时反驳。

类式继承

类式继承通过子类的原型对象指向父类的实例,完成继承。

代码

function Parent() {
  this.name = '爸爸';
  this.books = ['JAVA'];
}

Parent.prototype.showName = function() {
  console.log(this.name);
}

function Child() {
  this.name = '儿子';
  this.school = 'high school'
}

Child.prototype = new Parent(); // 子类的原型对象指向父类的实例,子类就能访问 Parent 原型对象上的方法了

Child.prototype.showSchool = function() {
  console.log(this.school);
}

验证

const c = new Child();
// 调用父类方法
c.showName(); // 儿子
// 调用子类方法
c.showSchool(); // high school

不足

看起来类式继承很完美的完成了工作,Child 的实例化的对象正常调用了父类的方法。但是我们看看 c 到底长什么样子。

{
  name: '儿子',
  school: 'high school',
  __proto__: {
    books: ['JAVA'],
    name: '爸爸',
    showSchool: f (),
    __proto__: {
      showName: f(),
      constructor: f Parent()
      // ...
    }
  }
}

可以看出,类式继承具有以下几个问题:

  • 父类的属性只实例化了一次,如果所有实例共享 books 这个属性,任意一个修改了实例修改了 books 的话,而其他实例不知情,这种情况是很危险的。由于信息不对称,很容易出现bug。
  • 对象 c 的 proto 没有 constructor。
  • 有两个name变量,节约内存,可以去掉一个。

寄生组合式继承

  • 通过每次调用父类,传入this,使引用类型的值不共享。
  • 创建一个中间对象,显式的设置 constructor。
  • 子类的原型对象指向这个中间对象,中间对象的__proto__指向父类的原型对象。

ps:提个问题,这里的中间对象其实可以不用创建,直接指定子类的原型对象的__proto__为父类的原型对象(Object.setPrototypeOf)是否可行?文末说下一我的看法。

代码

function Parent() {
  this.name = '爸爸';
  this.books = ['JAVA'];
}

Parent.prototype.showName = function() {
  console.log(this.name);
}

function Child() {
  Parent.call(this); // 像不像 class 中的 super()
  this.name = '儿子';
  this.school = 'high school'
}

// Object.create 创建一个中间对象
// 中间对象的__proto__指向父类的原型对象
// 子类的原型对象指向这个中间对象
Child.prototype = Object.create(Parent.prototype, {
  // 显示的指定 constructor
  constructor: {
    value: Child,
    enumerable: false, // 不可遍历
    writable: true, // 可改写
    configurable: true
  }
})

Child.prototype.showSchool = function() {
  console.log(this.school);
}

验证

const c = new Child();
// 调用父类方法
c.showName(); // 儿子
// 调用子类方法
c.showSchool(); // high school

可以看一下 c 的结构

{
  name: "儿子", 
  books: ['JAVA'], 
  school: "high school",
  __proto__: {
    showSchool: ƒ (),
    constructor: ƒ Child(),
    __proto__: {
      showName: ƒ (),
      constructor: ƒ Parent(),
      // ...
    }
  }
}

上面提到的问题都得到了解决,有兴趣的同学可以去对比一下与class实例化后的继承的结构是否有区别。

小结

  1. JavaScript 本身是没有类的概念,继承也是通过原型链实现的,
  2. JavaScript 中继承的本质就是子类共享父类的原型对象。

ps: 上面的问题,我觉得是可行的。但是

由于现代 JavaScript 引擎优化属性访问所带来的特性的关系,更改对象的 [[Prototype]]在各个浏览器和 JavaScript 引擎上都是一个很慢的操作。其在更改继承的性能上的影响是微妙而又广泛的,这不仅仅限于 obj.proto = ... 语句上的时间花费,而且可能会延伸到任何代码,那些可以访问任何[[Prototype]]已被更改的对象的代码。如果你关心性能,你应该避免设置一个对象的 [[Prototype]]。相反,你应该使用 Object.create()来创建带有你想要的[[Prototype]]的新对象。 ——摘自 MDN

参考资料:

  • 【JavaScript 设计模式】 —— 张容铭
  • MDN