Bootstrap

babel原理&动手实现babel插件

前言

巴比伦塔 (希伯来语:מִגְדַּל בָּבֶל,**Migdal Bāḇēl)也译作巴贝尔塔巴别塔,或意译为通天塔**),本是犹太教《塔纳赫·创世纪篇》中的一个故事,说的是人类产生不同语言的起源。在这个故事中,一群只说一种语言的人在“大洪水”之后从东方来到了示拿地区,并决定在这修建一座城市和一座“能够通天的”高塔;上帝见此情形就把他们的语言打乱,让他们再也不能明白对方的意思,并把他们分散到了世界各地。摘自 Wikipedia - Tower of Babel[1]

f5617494c97279fd12141afcd00ed5f0.png

Babel 是什么

Babel 官网是这样定义的:Babel is a JavaScript compiler。Babel 是一套解决方案,主要用来把 ECMAScript 2015+ 的代码转化为浏览器或者其它环境支持的代码。它主要可以做以下事情:

  • 语法转换

  • 为目标环境提供 Polyfill 解决方案

  • 源码转换

  • 其它可参考 Videos about Babel[2]

Babel 的历史

2014 年,高中生 Sebastian McKenzie 首次提交了 babel 的代码,当时的名字叫 6to5。从名字就能看出来,它主要的作用就是将 ES6 转化为 ES5。于是很多人评价,6to5 只是 ES6 得到支持前的一个过渡方案,但作者非常不同意这个观点,他认为 6to5 不光会按照标准逐步完善,依然具备非常大的潜力反过来影响并推进标准的制定。正因为如此,后来的 6to5 团队觉得 '6to5' 这个名字并没有准确的传达这个项目的目标。加上 ES6 正式发布后,被命名为 ES2015,对于 6to5 来说更偏离了它的初衷。于是 2015 年 2 月 15 号,6to5 正式更名为 Babel。(把 ES6 送上天的通天塔)

Babel 的使用

了解完 babel 是什么后,我们接下来看如何使用它。根据官网中提供的用法,我们初始化一个基础项目并安装依赖。

npm install --save-dev @babel/core @babel/cli @babel/preset-env
  • 目录结构如下

    b15e25eaa4f71979e387d3825d89a3cd.png
  • package.json 中新增 babel 命令

    15b0d3597fc7e07bf463758f07243152.png
  • babel.config.js 配置

    7b05a33de78c6dd95a93c081947363d3.png
  1. 配置中的 debug 用于打印 babel 命令执行的日志;

  2. presets 主要是配置用来编译的预置,plugins 主要是配置完成编译的插件,具体的含义后面会讲。

src/index.js

940f8e78860bca843372ebe81f0f8486.png

接下来,在命令行执行 npm run babel 命令,看看转换效果。

c97dffd754c206b2d8413d294d33f28f.png

从上图中可以看到,const 被转换成了 var,箭头函数转换成了普通 function,同时打印出来如下日志:

a1ea306c7603299215615f05df1928c9.png

Babel 原理

了解完成 babel 的基础使用后,我们来分析 babel 的工作原理。babel 作为一个编译器,主要做的工作内容如下:

  1. 解析源码,生成 AST

  2. 对 AST 进行转换,生成新的 AST

  3. 根据新的 AST 生成目标代码

整体流程图下:

43050c47a3816bae7d7cca53b4696cf5.png

根据上图中的流程,我们依次进行分析。

Parse(解析)阶段

一般来说,Parse 阶段可以细分为两个阶段:词法分析(Lexical Analysis, LA)和语法分析(Syntactic Analysis, SA)。

  • 词法分析

词法分析是对代码进行分词,把代码分割成被称为 Tokens 的东西。Tokens 是一个数组,由一些代码的碎片组成,比如数字、标点符号、运算符号等等,例如这样:

// 代码
const a = 1;

// Tokens https://esprima.org/demo/parse.html#
[
    {
        "type": "Keyword",
        "value": "const"
    },
    {
        "type": "Identifier",
        "value": "a"
    },
    {
        "type": "Punctuator",
        "value": "="
    },
    {
        "type": "Numeric",
        "value": "1"
    },
    {
        "type": "Punctuator",
        "value": ";"
    }
]
  • 语法分析

词法分析之后,代码就已经变成了一个 Tokens 数组,现在需要通过语法分析把 Tokens 转化为 AST。例如上面的代码转成的 AST 结构如下(在线查看[3]):

e9756797c0037a1e7fe03833791f4db4.png

在 babel 中,以上工作是通过 @babel/parser 来完成的,它基于ESTree 规范[4],但也存在一些差异。从上图中,我们可以看到最终生成的 AST 结构中有很多相似的元素,它们都有一个 type 属性(可以通过官网提供的说明文档来查看所有类型),这样的元素被称作节点。一个节点通常含有若干属性,可以用于描述 AST 的节点信息。

Transform(转换)阶段

转换阶段,Babel 对 AST 进行深度优先遍历,对于 AST 上的每一个分支 Babel 都会先向下遍历走到尽头,然后再向上遍历退出刚遍历过的节点,然后寻找下一个分支。在遍历的过程中,可以增删改这些节点,从而转换成实际需要的 AST。

以上是 babel 转换阶段操作节点的思路,具体实现是:babel 维护一个称作 Visitor 的对象,这个对象定义了用于 AST 中获取具体节点的方法,如果匹配上一个 type,就会调用 visitor 里的方法,实现如下:

03b4c4442d1e2cdede474c6572136f2f.png

一个简单的 Visitor 对象如下:

const visitor = {
    FunctionDeclaration(path, state) {
        console.log('我是函数声明');
    }
};

在遍历 AST 的过程中,如果当前节点的类型匹配 visitor 中的类型,就会执行对应的方法。上面提到,遍历 AST 节点的时候会遍历两次(进入和退出),因此,上面的 Vistor 也可以这样写:

const visitor = {
    FunctionDeclaration: {
        enter(path, state) {
            console.log('enter');
        },
        exit(path, state) {
            console.log('exit');
        }
    }
};

Visitor 中的每个函数接收 2 个参数:path 和 state。

path:表示两个节点之间连接的对象,对象包含:当前节点、父级点、作用域等元信息,以及增删改查 AST 的 api。

state:遍历过程中 AST 节点之间传递数据的方式,插件可以从 state 中拿到 opts,也就是插件的配置项。

例如使用上面 visitor 遍历如下代码时:

// 源码
function test() {
  console.log(1)
}

输出如下:

e875bd02c81d96fb9531ac893aa65360.png

Generator(生成)阶段

经过上面两个阶段,需要转译的代码已经经过转换,生成新的 AST ,最后一个阶段理所应当就是根据这个 AST 来输出代码。在生成阶段,会遍历新的 AST,递归将节点数据打印成字符串,会对不同的 AST 节点做不同的处理,在这个过程中把抽象语法树中省略掉的一些分隔符重新加回来。比如 while 语句 WhileStatement 就是先打印 while,然后打印一个空格和 '(',然后打印 node.test 属性的节点,然后打印 ')',之后打印 block 部分。

export function WhileStatement(this: Printer, node: t.WhileStatement) {
  this.word("while");
  this.space();
  this.token("(");
  this.print(node.test, node);
  this.token(")");
  this.printBlock(node);
}

@babel/generator 的 src/generators[5] 下定义了每一种 AST 节点的打印方式,通过上述处理,就可以生成最终的目标代码了。

Plugin 插件

上面介绍了 Babel 的原理,知道了 babel 是如何进行代码解析和转换,以及生成最终的代码。那么转换阶段,babel 是怎么知道要进行哪些转换操作呢?答案是通过 plugin,babel 为每一个新的语法提供了一个插件,在 babel 的配置中配置了哪些插件,就会把插件对应的语法给转化掉。插件被命名为 @babel/plugin-xxx 的格式。

插件的使用:

// babel配置文件
"plugins": [
  "pluginA",
  ['pluginB'],
  ["babel-plugin-b", { options }] // 如果需要传参就用数组格式,第二个元素为参数。
]

常用插件介绍

  • @babel/plugin-transform-react-jsx:将 jsx 转换成 react 函数调用

// 源码
const profile = (
  <div>
    <img src="avatar.png" className="profile" />
    <h3>{[user.firstName, user.lastName].join(" ")}</h3>
  </div>
);
// 出码
const profile = React.createElement(
  "div",
  null,
  React.createElement("img", { src: "avatar.png", className: "profile" }),
  React.createElement("h3", null, [user.firstName, user.lastName].join(" "))
);
  • @babel/plugin-transform-arrow-functions:将箭头函数转成普通函数

// 源码
var a = () => {};
// 出码
var a = function() {};
  • @babel/plugin-transform-destructuring:解构转换

// 源码
let { x, y } = obj;
let [a, b, ...rest] = arr;
// 出码
function _toArray(arr) { ... }
let _obj = obj,
  x = _obj.x,
  y = _obj.y;

let _arr = arr,
  _arr2 = _toArray(_arr),
  a = _arr2[0],
  b = _arr2[1],
  rest = _arr2.slice(2);

更多 babel 插件[6]请参考官网。

插件的形式:

babel 插件支持两种形式,一是函数,二是对象。

  • 函数形式

export default funciton(babel, options, dirname) {
    return {
        // 继承某个插件
        inherits: parentPlugin,
        // 修改参数
        manipulateOptions(options, parserOptions) {
            options.xxx = '';
        },
        // 遍历前调用
        pre(file) {
          this.cache = new Map();
        },
        // 指定 traverse 时调用的函数
        visitor: {
          FunctionDeclaration(path, state) {
            this.cache.set(path.node.value, 1);
          }
        },
        // 遍历后调用
        post(file) {
          console.log(this.cache);
        }
    }
}
  • 对象形式

export default plugin = {
    pre(state) {
      this.cache = new Map();
    },
    visitor: {
      FunctionDeclaration(path, state) {
        this.cache.set(path.node.value, 1);
      }
    },
    post(state) {
      console.log(this.cache);
    }
};

执行顺序:从前往后

Preset 预设

上面介绍了插件的使用和具体实现,在实际的项目中,转换时会涉及到非常多的插件,如果我们依次去添加对应的插件,效率会非常低,而且记住插件的名字和其对应功能本身就是一件很难的事。我们能不能把通用的插件封装成一个集合,用的时候只需要安装一个插件即可,这就是 preset。一句话总结:preset 就是对 babel 配置的一层封装。

a031c3d7c4116d0cd4fe4be61361b65c.png dd81a7b810f4a91b0dedaf8e010212f5.png

预设的使用

Preset 预设使用详情[7]可参考官网。

// babel配置文件
{
  "presets": [
    "presetA", // 字符串
    ["presetA"], // 数组
    [
        "presetA",  // 如果有参数,数组第二项为对象
        {
        target: {
            chrome: '58' // 目标环境是chrome版本 >= 58
            }
        }
    ]
  ]
}

执行顺序:从后往前;

插件 & 预设执行顺序:先执行插件,后执行预设。

Polyfill

让我们再次回到开始的源码转换

2184c62a57dbdb850ff1fbd124f6d9d5.png

从转换结果来看,const 和 var 都进行了转换,但 startsWith 方法却保留原样,这是怎么回事呢?原因是在 babel 中,把 ES6 的标准分为 syntax 和 built-in 两种类型。syntax 就是语法,像 const、=> 这些默认被 Babel 转译的就是 syntax 类型。而对于那些可以通过改写覆盖的语法就认为是 built-in,像 startsWith 和 includes 这些都属于 built-in。而 Babel 默认只转译 syntax 类型的,对于 built-in 类型的就需要通过 @babel/polyfill 来完成转译。@babel/polyfill 实现的原理也非常简单,就是覆盖那些 ES6 新增的 built-in。示意如下:

Object.defineProperty(Array.prototype, 'startsWith',function(){...})

由于 Babel 在 7.4.0 版本中宣布废弃 @babel/polyfill ,而是通过 core-js 替代,所以本文直接使用 core-js 来讲解 polyfill 的用法。

core-js 使用

  • 安装:npm install --save core-js

  • 配置 corejs

// babel.config.js
const presets = [
  [
    '@babel/env',
    {
      debug: true,
+      useBuiltIns: 'usage', // usage | entry | false
+      corejs: 3, // 2 | 3
    }
  ]
]
  • 再次执行 npm run babel

7add173f07a8488819df965666f34fbd.png

可以看到,代码顶部多了 require("core-js/modules/es.string.starts-with.js"),通过阅读 require 近来的源码[8],它内部实现了字符串的 startsWith 方法,这样就完成了 built-in 类型的转换。

66666ae5b73f85bb9b585a067dfcc556.png

手写 babel 插件

通过上面的介绍,我们对插件的形式和实现有了基本的了解,接下来我们将通过手写一个简单的插件来切身感受下 babel 的魅力。

在我们的日常开发中,经常会在 async 函数中使用 tryCatch 来封装代码,例如:

async function getName() {
    try {
        // code
        const name = await api.getName();
    } catch(error) {
        // do somethine
    }
}

上述每个这样的函数我们都需要封装一次,我们能否把封装的工作交给 babel 来处理呢?答案是肯定的,让我们一起看看怎么实现?

  1. 我们先给插件起个名字:babel-plugin-try-catch

  2. 实现功能

const template = require('@babel/template'); // 使用它来将代码批量生成节点
function babelPlugintryCatch({ types: t }) {
  return {
    visitor: {
      FunctionDeclaration: {
        enter(path) {
          /**
           * 1. 获取当前函数体
           * 2. 如果是async函数,则创建tryCatch并将原函数内容放到try体内
           * 3. 替换原函数
          */
          // 1. 获取当前函数节点信息
          const { params, generator, async, id, body } =path.node;
          // 如果是async,则执行替换
          if (async) {
            // 生成 console.log(error) 的节点数据
            const catchHandler = template.statement('console.log(error)')();
            // 创建trycatch节点,并把原函数体内的代码放到try{}中,把刚刚生成的catchHandler放到catch体内
            const tryStatement = t.tryStatement(body, t.catchClause(t.identifier('error'), t.BlockStatement([catchHandler])));
            // 创建一个新的函数节点并替换原节点
            path.replaceWith(t.functionDeclaration(id, params, t.BlockStatement([tryStatement]), generator, async))
            // 跳过当前节点,否则会重新进入当前节点
            path.skip();
          }
        }
      }
    }
  }
}

module.exports = babelPlugintryCatch
  1. 添加配置

dec524972c4f1f90bd9c15e320ac6e96.png
  1. 执行命令 npm run babel,看转换结果

d595362d875a07047d63a0ad130df249.png

从结果来看,我们已经实现了基本的转换需求,但还不是一个完善的插件,例如如果已经有 trycatch 了就不需要再转换了,又例如可以在 catch 体内做一些错误上报等。其它功能留给大家去探索~

总结

babel 是一款 javascript 编译器,它的作用是将 js 编译成目标环境可运行的代码,编译原理是先解析源代码生成 AST,对 AST 进行操作并生成新的 AST,最后根据新的 AST 生成最终的代码。在转换过程中,遍历到不同的节点类型时,会调用在插件中定义的访问者函数来处理,而单个插件的管理成本太大,因此,babel 在插件的基础上通过抽象一层 preset 来批量引入 plugin 并进行配置。(没有什么问题是不能通过增加一个抽象层解决的,如果有,再增加一层)最后我们一起手动实现了一个简单的 babel 插件,对 babel 的转换原理有了更加深入的理解。

参考资料

[1]

Wikipedia - Tower of Babel: https://en.wikipedia.org/wiki/Tower_of_Babel

[2]

Videos about Babel: https://babeljs.io/videos.html

[3]

在线转换 AST: https://astexplorer.net/

[4]

ESTree 规范: https://github.com/estree/estree

[5]

@babel/generator 的 src/generators: https://github.com/babel/babel/tree/main/packages/babel-generator/src/generators

[6]

更多 babel 插件: https://babeljs.io/docs/en/babel-plugin-transform-react-jsx

[7]

Preset 预设使用详情: https://babeljs.io/docs/en/presets

[8]

require 近来的源码: https://github.com/zloirock/core-js/blob/master/packages/core-js/modules/es.string.starts-with.js

[9]

What is Babel?: https://babeljs.io/docs/en/

[10]

blockstatement: https://github.com/babel/babel/blob/main/packages/babel-parser/ast/spec.md#blockstatement

[11]

Babel 插件手册 - 路径: https://github.com/jamiebuilds/babel-handbook/blob/master/translations/zh-Hans/plugin-handbook.md#toc-paths

[12]

es.string.starts-with: https://github.com/zloirock/core-js/blob/master/packages/core-js/modules/es.string.starts-with.js

[13]

Babel 设计,组成: https://zhuanlan.zhihu.com/p/57883838

[14]

Babel:把 ES6 送上天的通天塔: https://zhuanlan.zhihu.com/p/129089156

[15]

2015 in review: https://medium.com/@sebmck/2015-in-review-51ac7035e272

[16]

在线转换 Tokens: https://esprima.org/demo/parse.html#

;