babel 原理与演进

Posted 茂树24

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了babel 原理与演进相关的知识,希望对你有一定的参考价值。

什么是 babel

官网上的定义是 babel 是一个 javascript 编译器,具体来说,babel 是一个工具用于将 ECMAScript 2015+ 语法编写的代码转化成向后兼容的 JavaScript 语法,以便于能够运行在当前或者旧版本浏览器或其他环境中。



如图所示先来回顾下 v8 执行 js 代码的整体流程,有一段 js 代码经过词法分析将代码解析成几个 token,然后经过语法分析生成一棵 AST 数,再经过语义分析解析出作用域、领域等上下文环境。最终生成中间代码的形态(中间代码可能是分词表以及上下文对象等形态组成),再放入到主线程中,加载相关的全局作用域、上下文等环境要素进行代码执行,在执行过程中还可以对代码进行优化,比如 JIT、延迟解析、隐藏类、内联缓存等技术。


但是如果 v8 解析器版本低不支持对新语法比如 class 解析,那么会在语法分析阶段报错表示解析器不知道怎么处理该语法,所以我们需要提前的将高级语法转成低级别语法,以便于 v8 解析器中识别,比如说可以将 class 转成使用基于函数原型的方式模拟类。所以说,babel 就是一种将 JavaScript 语言高级语法转化成低级语法的工具。这个转化过程是在开发打包发布代码阶段就完成的,用户浏览器中运行的代码是转化后的低级别的代码。

note:
babel是一个将高级语法转成低级语法的工具。这个过程是在发布之前就完成,v8 解析运行的是转化后的代码。



babel 如何工作

babel是一个将高级代码转成低级代码的工具,输入的是高级别代码的文本,输出的是低级代码文本。

如图所示,输入的是 jsx 代码 jsx v8 是解析不了的, 输出的是可以被 v8 解析的使用 createElement 函数创建的节点。(此处可以忽略 react 相关的东西,举 jsx 例子更能凸显出 babel 各个阶段的特点)babel 核心也仅仅是提供了一个转化的框架,具体需要转化什么语法,怎么转化取决于各种的插件。


babel 大概的执行流程也是词法分析、语法分析构建出 AST 树,修改 AST 树然后转成字符串输出的过程。babel 将这个处理流程抽象出来,抽象成了 parse、traverse、generate 三个过程,同时对于每一个过程中都有相应的暴露钩子函数,如何对某一种语法怎么处理由插件提供,插件对钩子函数进行了实现。

note:
babel 提供了对高级代码转成低级代码的抽象工具,抽象为 parse、traverse、generator 阶段,同时对于处理特定的语法需要提供具体的插件,插件中对相应的钩子函数进行实现,从而可以兼容各种语法。

parse

parse 阶段是最基本的阶段,主要工作是把输入的源代码字符串你经过词法分析、语法分析转化成能够后续处理的 AST 中间代码表示结构,AST 遵循 estree 规范,同时也可以使用 astesplorer 网站清晰的看出源代码解析成 ast 的结果。比如对 jsx 代码的解析:



对代码 (< div >text</ div >) 经过 parse 的词法分析、语法分析后得到 AST 的表示如图所示,一个完整的 AST 是有很多的单元组成的,比如图中 JSXElement、JSXOpeningElement、JSXClosingElement、JSXIdentifier、JSXText 单元。每一个单元都有一些共有属性和特有属性,共有属性有 描述类型的type、描述词语所在文件中的开始结束位置 start、end 方便与后续代码 sourcemap 生成代码定位。特有属性不同的单元会不一样,比如 JSXElement 有开始标签 openingElement、结束标签 closingElement,以及孩子节点 children。


有个比较容易忽略的问题就是词法分析的时候是怎么识别 jsx 语言的?正常的 ECMAScript 标准文档中确实是没有对 jsx 分词作出说明的。但是 babel 的 @babel/parser 包中在 token 识别生成 AST 的时候已经加了这种识别 jsx 代码的逻辑(在 parseMaybeAssign 方法中声明识别处理 jsx 语法,具体源码在 lib 文件夹中的 index 文件的 3693 行代码)。


从图一代码中可以看到判断了 hasPlugin 中是否有 jsx 标志位,图二表示 hasPlugin 是 BaseParser 类的实例方法,主要目的是在维护 parse 过程中用到的插件(this.plugins 是一个保存一些标志符的数组)。换言之,在 parse 阶段目的就是在词法分析的时候是否需要将 (< div >text</ div >) 这种语法解析成 jsx 还是小于号还是报错处理。所以我们需要开发一个插件并且在插件中说明需要 parse 时候识别 jsx,这一部分具体的代码是在 @babel/plugin-syntax-jsx 包中实现的源码如下:

这个包的源码非常简单,就是返回一个描述插件的对象,对象中有一个 manipulateOptions 方法,这个方法第一个入参 opts 就是 之前介绍 BaseParser 类的参数,parserOpts 是 BaseParser 实例,然后 parserOpts.plugins.push(‘jsx’) 就表明在 parse 过程中需要识别 jsx 语法了。在 .babelrc 文件中配置了这个插件后,parse 阶段就会读取这些插件的 manipulateOptions 属性, 在执行 parse 过程中也就是执行 hasPlugins(‘jsx’) 为 true。所以把 (< div >text</ div >) 语法当做 jsx 处理而不是 小于号 错误之类的处理。

note:
parse 阶段是将出入的高级语法转化成 AST 过程。在该过程中会创建 BaseParser 对象,同时初始化 plugins 属性(执行引入从插件挂载的manipulateOptions钩子函数)。AST 是由多个单元组成的,每一个单元对应一个 token,同时会有共有属性 start、end(与 sourcemap 有关) 和各自特点的私有属性。

traverse

traverse 阶段主要是对 parse 阶段生成的 AST 进行深度优先遍历,遍历的方式根据 type 类型决定分支是什么从而往下遍历。比如说:

遍历到 JSXElement 单元的时候,读取 type 值是 “JSXElement” 所以知道了往下遍历的又 openingElement、closingelement、children 属性,从该节点开始深度遍历,先遍历 openingelement 单元,同理查看 type 是 "JSXOpengingElement"类型所以 name 字段是往下遍历的属性,所以继续遍历 name,依次往下遍历直到 "div"是一个字符串不是单元位置,然后回溯继续对 closingElement、children 属性进行深度遍历。


在遍历过程中可能需要在执行到特定的单元的时候做特殊处理以便于能够将高级语法 AST 转成低级别语法 AST,所以这就需要插件在响应的钩子中挂载执行方法。对于 jsx 处理是在 @babel/plugin-transform-react-jsx 包中,如源码所示:

返回的对象中有 visitor 对象,这个对象中的属性都是 AST 每一个单元的 type 类型,在 traverse 遍历过程中,如果进入某个单元比如 JSXElement 会执行 visitor 对应 JSXElement 属性的 enter 方法,当离开该单元的时候会调用 exit,如果 只有 enter也可以直接 JSXElement(){} 函数的方式省略 enter。
在每一个插件回调钩子里面都有 path 作为入参,path 中可以使用 node 字段获取 AST 单元,以及 replacewith 对 单元进行替换达到替换局部 AST 目的,同时也可以借助 @babel/type @babel/template 方式快捷的进行 AST 单元的替换。

note:
traverse 能够对 AST 单元进行遍历,然后调用插件中 visitor 字段对特定的单元执行特定的处理函数(进入执行 enter,离开执行 exit)达到对 AST 单元处理替换的目的。在编写插件过程中可以借助 @babel/type @babel/template 包快捷方便的编写处理插件代码。

generate

generate 能够将 traverse 遍历修改的 AST 转化成源码的模块,只不过在遍历 AST 过程中会根据每一个单元的 type 类型调用不用的 generate 函数输出不同的源代码,比如图中所示 @babel/generator 包中的代码就是对 JSXOpeningElement 单元生成字符串的过程。



generator 可以配置是否需要输出 sourcemap,这是 generator 另一个比较重要的点。对于 sourcemap 就是一种编译后得到的代与源码的映射,sourcemap 存在的好处一是开发的时候可以方便的快速定位源码位置,二是上线的时候通过对源代码和对应的 sourcemap 分开部署,捕获线上的错误根据 sourcemap 就可以方便的定位源码位置。具体可以参考文章 sourcemap 学习更多的关于 sourcemap 知识。

在这里你只需要知道 sourcemap 本质就是维护了源代码行列位置与最终输出代码行列位置的映射关系。所以我们需要如下信息:源代码行数,源代码列数,目标代码行数,目标代码列数。

在源码经过 parse 解析成 AST 树的时候每一个单元都维护了 token 的 start、end、loc 信息,也就是 token 对应源码的行列位置。经过 transform 处理后虽然单元的内容可能会被替换,但是位置信息还是旧的并不会替换。在 generate 将 AST 转成目标代码过程中时候,遍历 AST 单元的时候能得到之前的位置信息也就是 源代码行数,源代码列数,然后也知道接下来输出的字符串的输出位置也就是 目标代码行数,目标代码列数。这样子就得到了 映射关系,经过一些压缩算法生成了 sourcemap。

note:
generator 能够将经过 transform 修改后的 AST 输出成目标代码,在这过程中根据新旧位置形成 sourcemap。


到此我们已经学会了 babel 在整个工程层面处于什么位置,以及 babel 是干什么用的,babel 的工作原理,以及 babel 几个集成好了的包是怎么工作的以及它们之间上下游是什么关系。但是最终还是需要在项目中使用各种各样的插件配置 babel 达到适用于项目的配置。接下来将会从 配置时候需要关注的几个概念层面去更深一层理解 babel。



plugin

plugin 基础

根据前面描述的 babel 工作流程大概能够知道 plugin 的作用。babel 经过 parse traverse generate 几个抽象的过程,将高级别源码转化成低级别源码进行了抽象,而 plugin 就是对具体语法转化的实现。下面是一个插件的例子:

const pluginUtils = require("@babel/helper-plugin-utils");

const plugin = api => {
    return {
        name: "plugin-demo",

        manipulateOptions(opts, parserOpts) {
            parserOpts.plugins.push("jsx");
        },

        visitor: {
            JSXElement: {
                exit(path, file) {}
            }
        }
    };
};

exports.default = pluginUtils.declare(plugin);

插件返回的是一个被 pluginUtils 加工后的对象,比较重要的是传入的函数,该函数返回一个描述对象这里面就是对 babel 抽象处理过程中关键的钩子进行实现。name 说明该插件的唯一标识。

babel 首先经过 parse 对高级源码进行词法分析和语法分析转成 AST,在 manipulateOptions 钩子中事先表明在 parse 过程中需要将 jsx 风格的语法进行识别。在 traverse 过程中对 AST中的每一个单元做处理,处理的过程就是在 visitor 对象中,其中对象的 key 就是处理单元的 type,传入该单元的 path 描述执行对应 key 的函数就能够对高级源码转成低级别实现,然后再经过 generate 输出成字符串。

高级语法支持


既然 plugin 是对具体语法转化的实现,那么 plugin 是非常多的,babel 根据转化内容进行了一些分类,对于 plugin 分类还应该从将高级别语法转成低级别语法转化说起。

有一类高级语法是可以使用低级别的语法实现的,比如乘方运算符,高级别中语法是这样的:

x = 10 ** 2;

经过 babel 支持乘方的 plugin 处理后得到的低级别代码如下:

x = Math.pow(10, 2);

也就是在 parse 阶段识别到 运算符后,在 traverse 阶段会将 转化成 Math.pow 函数,具体实现的 plugin 为 @babel/plugin-transform-exponentiation-operator 包中。

你会发现这一类的转化后没有引入非标准语法的部分,也就是没有人为写的帮主函数去实现。这一类我们称为 syntax transform 也就是单纯的词法语法的转化。

还有一类语法是需要写一个帮助函数的,目的是低级别语法不支持这种实现,只能通过低级别基础语法写基础函数的方式去实现。比如 class 语法转化之前:

class Test {
  constructor(name) {
    this.name = name;
  }

  logger() {
    console.log("Hello", this.name);
  }
}

转化之后使用 function、prototype 来实现 class 功能:

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

var Test = (function() {
  function Test(name) {
    _classCallCheck(this, Test);

    this.name = name;
  }

  Test.prototype.logger = function logger() {
    console.log("Hello", this.name);
  };

  return Test;
})();

这种需要使用一些帮助函数比如 _classCallCheck 来达到高级语法的能力称之为 helper。 当然比如新特性 array 中有 find prototype 转化低级语法过程中也需要添加 支持 find 的 polyfill。在这里统称为 api polyfill。

所以说 通过 syntax transform + api polyfill ,就可以实现高级语法以及高级的 api。


语言特性分类

1.ecma

ecma 语言特性是指那些已经进入 ECMAScript 标准文档中的标准特性,所有的宿主环境是必须兼容实现的,比如 es2015、es2016、 es2017 等。

2. proposal

proposal 语言特性是指那些还未进入标准文档中,但是进入提案过程的特性,对于语法特性从提出到进入标准会有一个过程大概会经历如下阶段:

  • 阶段 0 - Strawman: 只是一个想法,可能用 babel plugin 实现。
  • 阶段 1 - Proposal: 值得继续的建议。
  • 阶段 2 - Draft: 建立 spec。
  • 阶段 3 - Candidate: 完成 spec 并且在浏览器实现。
  • 阶段 4 - Finished: 会加入到下一年的 es20xx spec。

3. other

other 预言特性是指那些不是标准或者提案一部分,但是在代码中会用到的,比如 jsx、typescript、flow 等。这些也需要在 babel 编译的时候识别并且解析,否则就会解释失败。

plugin 分类

babel 根据解析过程,以及预言特性的分类,将 plugin分为了三大类型分别是 syntax、transform、proposal。如果使用过 babel 会在项目的 node_modules @babel 文件夹中发现一些 plugin-syntax-***、plugin-transform-***、plugin-proposal-*** 开头的插件,下面会介绍这些插件的不同。

1. syntax

syntax 插件只是对 manipulateOptions 钩子函数的实现,目的就是让 babel 在解析过程中能够对特定语法的支持,避免解析不出来报错。比如 parse 章节中介绍的

在 manipulateOptions中 parserOpts 对象中 plugins 属性放入 jsx 标识,在 parse过程中就会对 <> 这种类似的语法解析成标签而不是 小于号运算符从而 babel 能够解析通过而不报错。当然配置 syntax 可以与 具体 traverse 中使用的 visitor 实现是分开的,因为语法识别出来也可以不需要解析成 低级语法形式。或许你运行的环境就支持 jsx 语法呢~

2. transform

transform plugin 中大多是是对高级语法的转化实现,比如各种 es20xx 语言特性、typescript、jsx 等语言特性等。在这种 plugin 中是对 visitor 对象不同的 AST 单元的具体转化动作实现,所以前提是 parse 阶段需要对解析语法识别,所以一般 @babel/plugin-transform-** 都会引用继承 @babel/plugin-syntax-**。

3. proposal

未加入语言标准的特性的 AST 转换插件叫 proposal plugin,其实他也是 transform plugin,但是为了和标准特性区分,所以这样叫。
完成 proposal 特性的支持,有时同样需要 综合 syntax plugin 和 proposal plugin,比如 function bind (:: 操作符)就需要同时使用 @babel/plugin-syntax-function-bind 和 @babel/plugin-proposal-function-bind。

note:
plugin 是babel 抽象流程中对具体语法转化的实现,高级语法可以通过 syntax transform + api polyfill转成低级别语法**,** 根据语言特性和 babel 抽象流程将 plugin 分为三大类 syntax、transform、proposal。

preset

preset 基础

由于 plugin 是非常多的,为了方便使用将一些 plugin 聚合在一起就形成了 preset,@babel/preset-react 源码来看 preset 返回的就是一个包含 plugins 属性的对象,被组织的 plugin 位于 plugins 中。



babel 演进史

babel6

在 babel6 版本中,根据语言特性的不同分为了多种 preset,比如有支持 ecma 标准的 preset-es2015 等 特别的 preset-env 总是能兼容最新的标准语法,有支持非标准的草案特性比如 preset-stage-draft 等,对于 other 类型的特性有 preset-react、preset-typescript 等。

但随着逐步的使用 babel6 preset 发现了一些问题:

  • **如何更好的支持目标环境增量的编译呢?**比如引用了 preset-es2015 后所有的标准语法都会准成低级别的 es5 语法,但是可能面对的用户是 chrome 最新的一批用户对一些 es2015 特性已经支持了,仅仅是部分新特性不支持但是全部转化带来了额外的负担,做了一些无用功。所以需要根据用户产品面对的代理环境支持情况进行增量的编译。
  • **preset 包变化非常频繁不够灵活。**babe6 中对于特定的标准 比如 es2015 有特定的 preset-es2015,preset-es2016等,对于非标准的提案版本有 preset-stage-draft、preset-stage-proposal 等, 但是当语法从 draft 阶段变更到proposal阶段就需要改动所有的包引用是非常不灵活的,更甚者其实开发过程中使用提案的语法也是局限于高频几个罢了,引入全部的 preset 带来了团队协作的维护成本。



babel7

在 babel7 中使用 targets 设置编译到的版本,来设置目标环境的增量编译,从而保持不同的产品代理环境不一致的差异化。对于变动非常频繁的包来说,根据使用的情况自己引入对应的 plugin。

(0) preset-env 配置

@babel/preset-env 主要配置主要是 targets、useBuiltIns、corejs 字段组成,如下图所示,在 babel 配置文件中的 presets 字段中的配置:

其中 targets 字段设置目标环境版本,表明需要将目前的最新标准语法解析到什么程度,达到增量编译的目的。在 plugin 中介绍过由高级语言编译成低级语言需要由 syntax + api polyfill 组成,所以 corejs useBuiltIns 对怎么使用 polyfill 做出说明限制。比如代码中全量引入 helper/polyfill 函数还是增量引入。使用 新版本的还是旧版本的等。下面就详细介绍 targets 与 polyfill。


(1) targets

  • targes 值为 false,代表默认编译成 es5 。等价于 babel6 中引入 preset-env,没有根据目标代理环境做相应的增量处理。
  • targets 值为代理环境对应兼容到最低版本对象。
{
  "targets": {
    "chrome": "58",
    "ie": "11"
  }
}

如上配置代表 编译的代码最低需要 chrome 的 >= 58 支持,并且 ie 的 >= 11 支持。那怎么知道比如 chrome 浏览器不同的版本支持那些语法呢,答案是 preset-env 使用 compat-table 库, 在这个库中维护了那种语法在那种环境中支持,比如:

class 语法在 chrome49 就已经支持了。从而如何设置 targets 中 chrome 58 则 class 是不需要编译成函数原型的方式的。

  • targets 值为 browsweslist query (browserslist 的 query 文档),比如 “last 1 version, > 1%”。这种事对上一种代理环境对象写法的简写。perset-env 借助 query 与 @babel/compat-data 可以实现对 query 的翻译。


(2) polyfill


在正式介绍 polyfill 之前先介绍下代码中 helper 函数,helper 函数分为了两类,一种是为了支持新的语法而不得不添加的帮助函数,比如为了支持 class 需要使用 _classCallCheck 等帮助函数去使用 function prototype 方式模拟实现 class,另一种是从外部引入的自有实现的 polyfill,比如 array 的 fill from 方法等。

比如src/index.js 文件代码如下:

class par { };

class sub extends par { };

new Array(5).fill('1'); // chrome 45 版本支持

Array.from([]); // chrome 45 版本不支持



1. useBuiltIns 为 false

原始处理 polyfill 的配置 .babelrc:

{
  "presets": [
    [
      "@babel/preset-env", {
        "targets": { "chrome": 45 },
        "useBuiltIns": false // 默认值就是 false,可以省略。
      }
    ]
  ],
  "plugins": []
}

运行 babel src/ -d dist/ 命令后发现得到的文件如下:

"use strict";

function _inherits(subClass, superClass) { 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 } }); 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); }

function _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; }

function _possibleConstructorReturn(self, call) { if (call && (typeof call === "object" || typeof call === "function")) { return call; } else if (call !== void 0) { throw new TypeError("Derived constructors may only return object or undefined"); } return _assertThisInitialized(self); }

function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; }

function _isNativeReflectConstruct() { if (typeof Reflect === "undefined" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === "function") return true; try { Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); return true; } catch (e) { return false; } }

function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); }

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

var par = function par() {
  _classCallCheck(this, par);
};

;
var sub = /*#__PURE__*/function (_par) {
  _inherits(sub, _par);

  var _super = _createSuper(sub);

  function sub() {
    _classCallCheck(this, sub);

    return _super.apply(this, arguments);
  }

  return sub;
}(par);

;
new Array(5).fill('1');
Array.from([]);

会发现 前 17 行的代码是为了支持 class 语法的 helper 函数,而 无论 fill 还是 from 函数都没有添加 polyfill 函数,这样子在 chrome 45 版本中运行肯定是报错。

**优点:targets 智能解析语法。 **preset-env 可以根据 targets 智能的语法解析了。
**缺点:没有对 api polyfill 处理。**只添加了 syntax 类型的 plugin,也就是说仅仅是对高级语法转化过程中进行了兼容处理,也就是说 高级语法可以通过 syntax transform + api polyfill转成低级别语法,但是这里只做了 syntax transform 动作,对于 api polyfill 并没有进行处理。


2. useBuiltIns 为 entry

处理 polyfill 的配置 .babelrc:

{
  "presets": [
    [
      "@babel/preset-env", {
        "targets": { "chrome": 45 },
        "useBuiltIns": "entry",
        "corejs": 3
      }
    ]
  ],
  "plugins": []
}

当 useBuiltIns 配置成 entry 的时候,需要 src/index.js 文件添加如下的头部, 并且安装 core-js,regenerator-runtime 包

import "core-js"; //增加
import "regenerator-runtime/runtime.js"; //增加

class par { };

class sub extends par { };

new Array(5).fill('1');

Array.from([]);

运行命令后得到的结果:

"use strict";

require("core-js/modules/es.symbol.js");

require("core-js/modules/es.symbol.description.js");

require("core-js/modules/es.symbol.async-iterator.js");

require("core-js/modules/es.symbol.has-instance.js");

...

require("regenerator-runtime/runtime.js");

function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass以上是关于babel 原理与演进的主要内容,如果未能解决你的问题,请参考以下文章

babel 原理与演进

babel 原理与演进

从 Babel 转译过程浅谈 ES6 实现继承的原理

Linux 操作系统原理 — Basic NICSmartNICDPU 设备演进与运行原理

How Javascript works (Javascript工作原理) (十五) 类和继承及 Babel 和 TypeScript 代码转换探秘

《3GPP系统架构演进(SAE)原理与设计》 | 详细目录