详解模拟类行为中的 JavaScript 知识点

646 阅读6分钟

前言

ES6 新增了 class 语法,但是不同于传统面向对象编程(OOP)类的实现,其本质上是基于原型实现的语法糖,本文主要介绍其中的实现原理。

类必须通过 new 实例化

类是通过调用构造函数来创建对象的,在 JavaScript 中构造函数与普通函数的区别主要有以下两点:

  • 约定构造函数名必须大写,可以很好的与普通函数区别开发,增强代码的可读性
  • 构造函数必须通过 new 关键字调用

为了确保构造函数是通过 new 关键字调用,可以通过 instanceof 运算符来检测当前实例的原型链上是否包含该构造函数的 prototype 属性

  function _classCallCheck(instance, Constructor) {
    if (!(instance instanceof Constructor)) {
      throw new TypeError("Cannot call a class as a function");
    }
  }

要理解这里为什么可以通过 instanceof 运算符来实现,需要理解以下两点:

原型链

原型链是 JavaScript 中查找对象属性的一种机制,在一定程度上使得对象之间产生联系。

当访问一个对象的属性时,JavaScript 引擎首先会查找该对象自身是否包含该属性,如果没有,则会查找该对象 [[Prototype]] 属性指向的原型对象,直到找到这个属性或者 [[Prototype]] 属性为 null 时,终止查找。

而 [[Prototype]] 属性对于开发者是透明的,在 ES6 之前,部分浏览器实现了 proto 私有属性来读取和设置 [[Prototype]] 属性的指向。

ES6 之后可以使用更友好的 Object.getPrototypeOf 和 Object.setPrototypeOf 方法来替代,但是对于更改对象的 [[Prototype]] 属性是一个影响性能的操作,应该避免这样的操作。

new 运算符

细心的同学会发现:通常构造函数上都会携带 Prototype 属性,那么这个属性和原型链是什么关系呢?

要想解决上述疑问,就需要理解 new 运算符的执行过程。

  function $new(Constructor, ...restArgs) {
    // 创建新对象
    const instance = Object.create(null);
    // 设置 [[prototype]] 属性
    instance.__proto__ = Constructor.prototype;
    // 执行构造函数并且绑定 this
    const result = Constructor.apply(instance, restArgs);
    // 返回对象
    return result instanceof Object ? result : instance;
  }

new 运算符在执行构造函数的过程中默默地做了很多事情,而更改 instance(也就是构造函数中的 this) 的 [[Prototype]] 属性就是能够通过 instanceof 来判断当前构造函数的调用方式的原因。

模拟实现 instanceof

理解上述两个知识点之后,可以通过模拟 instanceof 运算符来更好的理解其查找的过程:

  function instance_of(instance, constructor) {
    let currentPrototype = Object.getPrototypeOf(instance);
    while(currentPrototype !== null) {
      if (currentPrototype === constructor.prototype) {
        return true;
      }
      currentPrototype = Object.getPrototypeOf(currentPrototype);
    }

    return false;
  }

借用构造函数

借用构造函数主要解决实例属性的继承问题。

对于类的每一个实例应该有属于自己的一份实例属性副本,这样才能保证实例的隔离性。

  function SuperClass(name) {
    this.name = name;
  }

这里利用 call 方法改变父类构造函数执行时的 this 指向,从而将父类构造函数中的实例属性赋值到指定的实例上:

  function SubClass(name, color) {
    const _this = SuperClass.call(this, name) || this;
    _this.color = color;
    return _this;
  }

如果这里不显式的改变 this 的指向,那么在非严格模式下,SuperClass 构造函数中的实例属性会复制到 window 上面。

原型链

对于类的成员函数则是采用基于原型的继承方式,这样每个实例可以共享方法的引用,最大限度地节约了内存。

在构造函数的 prototype 属性上添加方法有很多种方式:

  SuperClass.prototype.sayHi = function () {}

  SuperClass.prototype.xxx = function() {}

这种方式虽然可读性极高,但是冗余的 SuperClass.prototype 不免让人看了觉得不够优雅,并且不支持 setter 和 getter 方法的设置。

为了解决上述问题,你可能会想到这种方式:

  SuperClass.prototype = {
    sayHi: function() {},
    get xxx() {},
    set xxx() {}
  }

利用对象字面量的确解决了上述问题,但是它重新赋值了 prototype 属性,只能在恰当的时机使用一次,否则会造成预期之外的 Bug。

解决上述问题的最佳实践:

  • 约定 JSON Schema
  • Object.defineProperty
  function _createClass(Constructor, props) {
    for (var i = 0; i < props.length; i++) {
      var descriptor = props[i];
      descriptor.enumerable = descriptor.enumerable || false;
      descriptor.configurable = true;
      if ("value" in descriptor) descriptor.writable = true;
      Object.defineProperty(Constructor.prototype, descriptor.key, descriptor);
    }
  }

  _createClass(SuperClass , [
    {
      key: 'sayHi',
      value: function sayHi() {}
    },
    {
      key: 'xxx',
      get: function get() {},
      set: function set() {}
    }
  ])

子类继承这些共享方法时,只需要将子类构造函数的 prototype 属性关联到父类构造函数的 prototype 即可以利用原型链查找的机制达到方法共享的目的,并且在子类构造函数的 prototype 上定义同名方法,提前终止原型链的搜索,从而完成父类方法的重写。

但是这个关联操作非常地讲究,也就是下面要介绍的原型继承

原型继承

原型继承主要解决的问题是:高效且无副作用地实现子类构造函数 prototype 属性和父类构造函数 prototype 属性的关联。

直接赋值引用

  SubClass.prototype = SuperClass.prototype;

这种直接引用父类构造函数 prototype 属性的行为存在副作用。

当子类构造函数在 prototype 属性上修改属性时,相当于直接修改父类构造函数的 prototype 属性,会对父类以及其他子类造成预期之外的影响。

修改 [[prototype]]

为了让子类不能直接操作父类构造函数的 prototype 属性,可以采用直接更改 [[prototype]] 的方式完成关联操作。

ES6 之前,可以采用浏览器实现的私有 API:

  SubClass.prototype.__proto__ = SuperClass.prototype;

ES6 可以直接利用新增的原型指定方法:

  Object.setPrototypeOf(SubClass.prototype, SuperClass.prototype);

但是更改对象的 [[Prototype]] 在各个浏览器和 JavaScript 引擎上都是一个很慢的操作,不建议直接更改,应该采用创建一个带有指定 [[Prototype]] 属性的新对象的方式来替代。

new 运算符

前文提到的 new 运算符就可以快速地得到一个带有指定 [[Prototype]] 属性的对象:

  SubClass.prototype = new SuperClass();

这种方式同样不完美,new 运算符生成的实例是包含构造函数中的实例属性的,而这些实例属性对于 SubClass.prototype 是无用的。

原型继承

为了解决 new 运算符的缺陷,只需要将父类构造函数替换成一个不包含实例属性的构造函数即可:

  function create(prototype) {
    const F = function() {};
    F.prototype = prototype;
    return new F();
  }

  SubClass.prototype = create(SuperClass.prototype);

寄生式继承

上述提到的原型继承,除了 Object.setPrototypeOf 方式,其它的方式都存在 prototype.constructor 指向不正确的问题。

这时就需要修正子类构造函数 prototype 上的 constructor 属性的指向,而寄生式继承就是一种增强对象的方式。

  function inheritPrototype(SubClass, SuperClass) {
    const prototype = create(SuperClass.prototype);
    Object.defineProperty(prototype, 'constructor', {
      value: subClass, writable: true, configurable: true
    })
    SubClass.prototype = prototype;
  }

利用 ES5 的 Object.create 方法可以简化上述寄生式继承函数:

  subClass.prototype = Object.create(superClass.prototype, {
    constructor: { value: subClass, writable: true, configurable: true }
  });

总结

以上就是 JavaScript 模拟类行为涉及到的知识点,在实际的开发过程中,一般都是利用 Babel 或者 TypeScript 来转译 class 语法,他们转化的方式实际上就是目前社区中实现类的最佳实践,本文也是基于其转译代码的分析,感兴趣的读者可以自行研究。

参考资料

  • 《JavaScript高级程序设计(第三版)》
  • 《你不知道的 JavaScript 上下卷》
  • Babel 和 TypeScript 转译代码

本文使用 mdnice 排版