coverPiccoverPic

JavaScript 的继承机制演进:从原型到 class

前言

JS 的继承核心机制基于原型,而非传统意义上的“类”。传统意义上的类(如 Java 和 C++)是一个静态的“模板”,类需要被实例化才能使用,继承时extends这样的关键字获得其他类的属性和方法。在 JS 中,没有完整的类,所谓的类是被用作构造函数的对象。对象通过原型链直接连接另一个实例对象实现继承,当访问属性时,JS 引擎会沿着原型链逐层向上查找。

每个对象都有隐藏的[[prototype]]属性,它指向对象的原型,可以通过__proto__或者Object.getPrototypeOf()访问。对象的构造函数的prototype属性定义了new操作符创建对象时的默认原型。JS 中的class语法仅仅是原型机制的语法糖。独特的原型机制也影响了开发者进行面向对象编程时继承的方式。

这篇博客主要记录 JS 中常见继承实现方式,了解它们出现的缘由。这篇博客也算是一个笔记,在《JavaScript 高级程序设计》中,详细的介绍了相关内容。

原型链继承

原型链继承(Prototype Chaining)是基于 JS 本身原型机制实现继承的方式。

js
  1. function SuperType () {
  2. this.superProperty = []
  3. }
  4. function SubType () {
  5. this.subProperty = 'subProperty'
  6. }
  7. // 设置原型
  8. SubType.prototype = new SuperType()
  9. SubType.prototype.constructor = SubType
  10. // 创建子类
  11. const subTypeImpl1 = new SubType()
  12. const subTypeImpl2 = new SubType()

对象间的关系:

这种方式只需要基于原型链本身的功能,但是这样子的继承方式是存在缺点的:

  1. 子类实例对象共享原型对象,操作原型上的属性会污染其他子类:
js
  1. subTypeImpl1.superProperty.push(1)
  2. console.log(subTypeImpl2.superProperty) // [1]
  1. 原型对象从一开始就被设定了,无法向超类的构造函数传参。

这是一种有明显缺陷的继承方式,一般不会单独使用。

借用构造函数继承

借用构造函数继承(Constructor Stealing)是当时社区中提出的,在子类构造函数中调用超类构造函数实现继承超类属性的方法:

js
  1. function SuperType (id) {
  2. this.id = id
  3. }
  4. function SubType (id, name) {
  5. // 继承属性
  6. SuperType.call(this, id)
  7. // 增强属性
  8. this.name = name
  9. this.sayHello = () => console.log('Hello')
  10. }
  11. // 创建子类
  12. const subTypeImpl = new SubType(1, 'test')

对象间的关系:

这种方式可以在调用超类构造函数时传递参数。但是也有明显的缺陷:

  1. 方法是定义在实例对象上的,每次实例化都需要重新创建,无法重用。
  2. 只调用了超类构造函数,实例无法访问超类原型:
js
  1. console.log(subTypeImpl.__proto__.__proto__ === Object.prototype) // true
  2. console.log(subTypeImpl.__proto__.__proto__ === SuperType.prototype) // false

这也是一种有明显缺陷的继承方式,一般不会单独使用。

组合继承

组合继承(Combination Inheritance)或者说伪经典继承(Pseudo-Classical Inheritance)是结合原型链继承和借用构造函数继承的继承方式。它利用借用构造函数继承子对象上继承超类的属性,再利用原型链继承正确设置超类原型。

js
  1. function SuperType (id) {
  2. this.id = id
  3. }
  4. SuperType.prototype.sayHi = () => console.log('Hi')
  5. function SubType (id, name) {
  6. // 继承属性
  7. SuperType.call(this, id)
  8. // 增强属性
  9. this.name = name
  10. }
  11. // 增强方法
  12. SubType.prototype.sayHello = () => console.log('Hello')
  13. // 设置原型
  14. SubType.prototype = new SuperType()
  15. SubType.prototype.constructor = SubType
  16. // 创建子类
  17. const subTypeImpl = new SubType(1, 'test')

对象间的关系:

这种方式很好地维护了 JS 原型链instanceof__proto__的特性,又避免了共享原型对象造成数据污染的问题。但是它也存在缺点:

  1. 超类构造函数被调用 2 次,存在效率问题。
  2. 超类的属性在子类实例对象和原型链上重复出现,不够优雅。

原型式继承

2006年,Douglas Crockford 在博客文章 Prototypal Inheritance in JavaScript,提出了原型式继承(Prototypal Inheritance)。它是一种利用工厂函数,从一个已有的超类实例对象中创建子类实例的方法。

js
  1. function object(o) {
  2. function F() {}
  3. F.prototype = o
  4. return new F()
  5. }
  6. function SuperType (id) {
  7. this.id = id
  8. }
  9. const superTypeImpl = new SuperType(1)
  10. const subTypeImpl = object(superTypeImpl)

对象间的关系:

原型式继承适合不需要单独创建构造函数,但是需要在对象之间共享数据的场景。它有着原型链继承共享原型对象的缺点。

原型式继承由 ES5 原生支持,可以通过Object.create(proto)实现。

寄生式继承

寄生式继承同样由 Douglas Crockford 提出,详见 Classical Inheritance in JavaScript。它是和原型式继承类似的方式,思路类似寄生构造函数和工厂模式,基本思想是实现一个继承的函数,以某种方法增强对象,最后返回这个对象。这是《JavaScript 高级程序设计》提到的寄生式继承的模式:

js
  1. function inherit (obj) {
  2. // 继承属性
  3. const instance = object(obj)
  4. // 增强对象
  5. instance.name = name
  6. instance.sayHello = () => console.log('Hello')
  7. }

这里的原型式继承的object函数不是必需的,只需要是一个返回对象的函数就可以了。它和原型式继承一样,适用于只关注对象,无需构造函数的情况。这种方式也存在借用构造函数继承在对象上定义方法的缺点。

原生 JS 中Object.create(proto, propertiesObject)中第二个参数可以做到对象增强。

寄生式组合继承

组合式继承存在效率问题,它超类的构造函数需要调用两次。为了解决这个问题,寄生式组合继承使用借用构造函数继承的方式继承超类的属性,使用原型式继承的方式,让子类原型继承超类原型。这样子无需调用两次超类构造函数。开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式。

js
  1. function inheritPrototype (SubType, SuperType) {
  2. const prototype = object(SuperType.prototype)
  3. prototype.constructor = SubType
  4. SubType.prototype = prototype
  5. }
  6. function SuperType (id) {
  7. this.id = id
  8. }
  9. SuperType.prototype.sayHi = () => console.log('Hi')
  10. function SubType (id, name) {
  11. // 继承属性
  12. SuperType.call(this, id)
  13. // 增强属性
  14. this.name = name
  15. }
  16. // 子类原型继承超类原型
  17. inheritPrototype(SubType, SuperType)
  18. // 增强方法
  19. SubType.prototype.sayHello = () => console.log('Hello')

对象间的关系:

这里超类构造函数SuperType只调用了一次,效率更高。而且,像组合继承,它维护了原型链,使得instanceofObject.getPrototypeOf()这些 API 可以正常使用。

ES6 的继承

ES6 新增了class关键字用于定义类型,用extends进行继承。ES6 的继承机制和寄生式组合继承非常相似。

js
  1. class SuperType {
  2. constructor (id) {
  3. this.id = id
  4. }
  5. sayHi () {
  6. console.log('Hi')
  7. }
  8. }
  9. class SubType extends SuperType {
  10. constructor (id, name) {
  11. super(id)
  12. this.name = name
  13. }
  14. sayHello () {
  15. console.log('Hello')
  16. }
  17. }

我们来稍微利用 Babel 的转写来窥视一下 ES6 继承的实现原理,这是上面这段代码被 Babel 转写后的结果,省略掉了和继承无关的代码。

js
  1. function _inherits(subClass, superClass) {
  2. if (typeof superClass !== "function" && superClass !== null) {
  3. throw new TypeError("Super expression must either be null or a function");
  4. }
  5. subClass.prototype = Object.create(
  6. superClass && superClass.prototype,
  7. { constructor: { value: subClass, writable: true, configurable: true } }
  8. );
  9. Object.defineProperty(subClass, "prototype", { writable: false });
  10. if (superClass) _setPrototypeOf(subClass, superClass);
  11. }
  12. function _setPrototypeOf(o, p) {
  13. _setPrototypeOf = Object.setPrototypeOf
  14. ? Object.setPrototypeOf.bind()
  15. : function _setPrototypeOf(o, p) {
  16. o.__proto__ = p;
  17. return o;
  18. };
  19. return _setPrototypeOf(o, p);
  20. }
  21. // SuperType...
  22. var SubType = /*#__PURE__*/function (_SuperType) {
  23. _inherits(SubType, _SuperType);
  24. // 这里省略掉代码做了一下几件事情:
  25. // 1. 在实例化时把超类属性加入到子类构造函数中
  26. // 2. 在子类 prototype 原型中定义方法
  27. // 3. 返回一个返回实例化对象的函数...
  28. return SubType;
  29. }(SuperType);

我们先来梳理一下这段代码的逻辑。

  • subClass.prototype = Object.create(...),和寄生式组合继承相似,子类原型subClass.prototype继承了超类原型superClass.prototype
  • _setPrototypeOf(subClass, superClass)子类构造函数的__proto__指向超类构造函数。

我们来看一下这些对象间的关系:

和寄生式组合继承相比,我们可以发现,增加了SubType.__proto__指向SuperType的继承关系。这很容易猜到,这是用来继承静态属性和静态方法的。

此外,在省略掉的代码里,ES6 继承时先设置子类原型上的方法,再对子类进行实例化,和上文中的寄生式组合继承顺序略有差别。

ES6 的继承很大程度上使用了寄生式组合继承的思想,在这之上完善了对静态属性、静态方法的继承。

结语

继承方式的演进历程:

  1. 原型链继承:最基础的继承方式,但存在共享原型对象和无法传参的问题;
  2. 借用构造函数继承:解决了属性共享问题,但方法无法复用,无法继承原型方法;
  3. 组合继承:结合前两者优点,但存在效率问题和属性重复;
  4. 原型式继承:轻量级方案,适合对象间数据共享,存在共享原型对象的问题;
  5. 寄生式继承:工厂模式增强版,有方法无法复用的问题;
  6. 寄生组合式继承:接近最完美的方案,解决了组合继承的效率问题;
  7. ES6class继承:语法糖下的寄生组合继承,加入继承静态属性。

JavaScript 的继承机制经历了从探索到成熟的演进过程。在这篇博客,我们复习了多种继承实现方式,每种方法都试图解决前者的缺陷,最终形成了今天广泛使用的模式。

0 条评论未登录用户
Ctrl or + Enter 评论
© 2023-2025 LittleRangiferTarandus, All rights reserved.
🌸 Run