边学边译JS工作机制---15.类,继承,Babel和TS的转换

513 阅读6分钟

本系列其他译文请看[JS工作机制 - 小白1991的专栏 - 掘金 (juejin.cn)] (juejin.cn/column/6988…
本章阅读指数:3 本章实际的指导意义不大,但是扩充一下视野

这一章讨论实现JS类和继承的各种姿势。我们将先看看原型的工作机制,分析一些流行库模拟基于类的继承的方式。 然后,我们看看如何转换,增加一些原生不支持的特性,以及如何使用Babel和TS支持 ES2015规范中class。 最后,看一些例子看看V8是如何原生实现class的。

概览

在JS中,我们创建的一切都是对象,而且没有原始类型。例如,创建一个字符串:

const name = "SessionStack";

然后立刻调用这个对象的方法:

console.log(name.repeat(2)); // SessionStackSessionStack
console.log(name.toLowerCase()); // sessionstack

不像其他语言,JS中声明一个string或者number,都会自动创建一个对象。这个对象包含了值,并且提供了在原始类型上不同的方法

另一个有意思的是,一个复杂类型比如像array也是一个对象,如果你检查array的typeof,你可以看到看到它是个对象。数组中每个元素的索引,实际上就是对象的属性。所以,当我们访问数组的索引,实际上访问的一个对象的属性。当涉及到数据存储方式的时候,以下两种定义是相同的:

let names = [“SessionStack”];

let names = {
  “0”: “SessionStack”,
  “length”: 1
}

因此访问数组中的元素性能跟访问对象中的属性是一样的。理论上讲,访问数组元素会比访问对象属性要快,因为对象的访问时基于哈希的。但是在JS中,数组和对象的访问都是基于哈希映射的,耗费的时间相同。

使用原型模拟类

提到对象,首先想起类。我们构建应用大部分都是基于类和类之间的关系。虽然JS中对象到处都是,但是它并不是典型的基于类的集成,相反,依赖了 prototypes.

image.png

在JS中,每一个对象都关联了另一个对象---它的原型。当访问对象的属性或者方法时,会首先在对象自身中查找,然后再它的原型中查找。

看一个例子,定义我们基础类的构建函数

function Component(content) {
  this.content = content;
}

Component.prototype.render = function() {
    console.log(this.content);
}

我们给原型上添加了一个render函数,因为我们希望每一个Component类实例都可以找到它。无论从哪个实例中访问render,都会首先在实例中查找,然后会在原型中查找,最终在原型中找到了。

image.png 现在尝试扩展一下component类。我们引入一个新的child类。

function InputField(value) {
    this.content = `<input type="text" value="${value}" />`;
}

如果想要 InputField 扩展 component 类的方法且可以调用其 render 方法,就需要更改其原型。当调用child类的实例方法的时候,肯定不希望在一个空原型上进行查找(这里其实所有对象都一个共同的原型,这里原文不够严谨)。该查找会延续到 Component 类上。

InputField.prototype = Object.create(new Component());

这种方式,render方法可以在Component类的原型中查找到。为了实现继承,我们需要把InputField的原型链接到一个Component类的实例。大多数的库会使用Object.setPrototypeOf方法

image.png 但是,这不是我们能做的唯一的事情,每一次我们扩展类都需要这么做:

  • 将父类的实例设置为子类的原型
  • 在子类构造器中调用父构造器,这样父类构造器的初始化逻辑会被执行
  • 引入一个访问父方法的方式。当重写父类方法的时候,会想要调用父类方法的原始实现

可以看到,如果你想实现基于类的继承,每次都要执行很复杂的逻辑。我们经常需要创建很多类,那么把相关的代码封装成可复用的函数就比较合理。为了解决基于类的继承的问题,开发者使用不同的库来模拟它。这些解决方案的流行,凸显了语言本身的缺陷。因此,在ECMAScript 2015中引入了一个新的语法,去实现基于类的继承。

类转换

当在 ES6 或者 ECMAScript 2015 中提议新功能时,JavaScript 开发者社区就迫不及待想要引擎和浏览器实现支持。有一种好的实现方法是进行代码转换。它允许使用 ECMAScript 2015 来进行代码编写然后转换为任何浏览器均可以运行的 JS 代码。这包括使用基于类的继承来编写类并转换为可执行代码。

image.png 一个最流行的JS转换器是Babel。我们看看转换工作是如何进行的:

class Component {
  constructor(content) {
    this.content = content;
  }

  render() {
  	console.log(this.content)
  }
}

const component = new Component('SessionStack');
component.render();

Babel 将会这么转换类的定义

var Component = function () {
  function Component(content) {
    _classCallCheck(this, Component);

    this.content = content;
  }

  _createClass(Component, [{
    key: 'render',
    value: function render() {
      console.log(this.content);
    }
  }]);

  return Component;
}();

如你所见,代码被转换成ES5,可以在任何环境中执行。另外,添加了一些功能,他们是Babel标准库的部分。 编译结果中包含了_classCallCheck 和 _createClass 函数。
_classCallCheck确保了构建函数不会被当做一般函数被调用。这是通过检查被调用函数的上下文,是否是Component的实例。检查this是否指向这个实例。
_createClass 通过传入包含键和值的对象数组来创建对象(类)的属性。

看看继承是如何工作的,我们分析一下InputField类,它继承自Component

class InputField extends Component {
    constructor(value) {
        const content = `<input type="text" value="${value}" />`;
        super(content);
    }
}

Here is the output we get when we process the above example using Babel. 以上的例子使用Babel,会得到这样的输出

var InputField = function (_Component) {
  _inherits(InputField, _Component);

  function InputField(value) {
    _classCallCheck(this, InputField);

    var content = '<input type="text" value="' + value + '" />';
    return _possibleConstructorReturn(this, (InputField.__proto__ || Object.getPrototypeOf(InputField)).call(this, content));
  }

  return InputField;
}(Component);

这个例子中,继承的逻辑被封装在 _inherits 函数调用中。它执行了前面所说的一样的操作即设置子类的原型为父类的实例。

Babel做了一些事情。首先,解析ES2015然后转换成中间表征--也就是AST。然后AST转换成一个不同的语法抽象树,新树的每一个节点都已经被转换成了对应的ES5.最后,再把AST转换成代码。

Babel中的AST

AST中的节点,都只有一个父节点。在Babel中,有一个基本的节点类型。这个类型表示了这是个什么样子的节点,以及在代码中的哪里可以找到这个节点。有很多不同的节点类型,比如Literals表示string, numbers, nulls等等。也有一种特殊类型的类节点。它是基础节点类的子类,通过添加字段变量来存储基础类的引用和把类的正文作为单独的节点来拓展自身。

我们把如下的代码转换为AST:

class Component {
  constructor(content) {
    this.content = content;
  }

  render() {
    console.log(this.content)
  }
}

这里是它的AST:

image.png

AST创建之后,每一个节点就被转换成等价的ES5节点,然后重新转换成符合ES5标准的代码。转换器查找距离根节点最远的节点,并把他们转换为代码。然后他们的父节点再被转换成代码。这个过程被称为深度优先遍历。

在上面的例子中,首先会生成两个MethodDefinition节点,然后类的正文节点的代码,最后是 ClassDeclaration 节点的代码。

TypeScript转换

另一个流行的转换框架是TS。TS引入了一个新的语法来写JS应用,然后转换成ES5代码。看个TS的例子:

class Component {
    content: string;
    constructor(content: string) {
        this.content = content;
    }
    render() {
        console.log(this.content)
    }
}

它的AST树: image.png 它也支持继承

class InputField extends Component {
    constructor(value: string) {
        const content = `<input type="text" value="${value}" />`;
        super(content);
    }
}

转换结果如下:

var InputField = /** @class */ (function (_super) {
    __extends(InputField, _super);
    function InputField(value) {
        var _this = this;
        var content = "<input type="text" value="" + value + "" />";
        _this = _super.call(this, content) || this;
        return _this;
    }
    return InputField;
}(Component));

最终的结果同样是ES5,并包含了一些TS库的函数。__extends 中封装了和之前第一部分讨论的一样的继承逻辑。 Babel和TS已经广泛使用了,标准类和基于类的继承成为了一个标准的方式去构建JS应用。这进一步推进了浏览器去class的原生支持。

原生支持

2014年,Chrome引入了原生支持。不用任何库和编译器,就允许类的声明和执行

image.png

但原生的支持其实是一个语法糖。这只是一个优雅的语法,可以被转换为语言早已支持的原语法。使用新的易用的类定义,归根结底也是要创建构造函数和修改原型。

image.png

V8 支持

来看看V8是怎么支持ES2015的。正如之前讨论的,首先新的语法会被转换为有效的JS代码,然后添加到AST。类定义会被转换为[ClassLiteral]节点,并添加到AST上。

This node stores couple of things. First, it holds the constructor as a separate function. It also holds a list of class properties. They can be a method, a getter, a setter, a public field or a private field. This node also stores a reference to the parent class that this class extends which again stores the constructor, list of properties and the parent class. 这个节点保存两个关键的信息。首先,它把构造函数当成一个独立的函数。同样它也有一系列的类属性,比如方法,getter,setter,公有字段或者私有字段。这个节点也保存了一个父类的引用,该父类也储存了构造函数,属性集和及父类引用,依次类推。

一旦新生成的ClassLiteral被转换为代码,再将其转化为各种函数和原型