babel 原理与演进

在这里插入图片描述

什么是 babel

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


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


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

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



babel 如何工作

babel是一个将高级代码转成低级代码的工具,输入的是高级别代码的文本,输出的是低级代码文本。
image.png
如图所示,输入的是 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 代码的解析:


image.png
对代码 (< 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 行代码)。
image.png
image.png
从图一代码中可以看到判断了 hasPlugin 中是否有 jsx 标志位,图二表示 hasPlugin 是 BaseParser 类的实例方法,主要目的是在维护 parse 过程中用到的插件(this.plugins 是一个保存一些标志符的数组)。换言之,在 parse 阶段目的就是在词法分析的时候是否需要将 (< div >text</ div >) 这种语法解析成 jsx 还是小于号还是报错处理。所以我们需要开发一个插件并且在插件中说明需要 parse 时候识别 jsx,这一部分具体的代码是在 @babel/plugin-syntax-jsx 包中实现的源码如下:
image.png
这个包的源码非常简单,就是返回一个描述插件的对象,对象中有一个 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 类型决定分支是什么从而往下遍历。比如说:
image.png
遍历到 JSXElement 单元的时候,读取 type 值是 “JSXElement” 所以知道了往下遍历的又 openingElement、closingelement、children 属性,从该节点开始深度遍历,先遍历 openingelement 单元,同理查看 type 是 "JSXOpengingElement"类型所以 name 字段是往下遍历的属性,所以继续遍历 name,依次往下遍历直到 "div"是一个字符串不是单元位置,然后回溯继续对 closingElement、children 属性进行深度遍历。


在遍历过程中可能需要在执行到特定的单元的时候做特殊处理以便于能够将高级语法 AST 转成低级别语法 AST,所以这就需要插件在响应的钩子中挂载执行方法。对于 jsx 处理是在 @babel/plugin-transform-react-jsx 包中,如源码所示:
image.png
返回的对象中有 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 单元生成字符串的过程。
image.png


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

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

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

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值