探究babel背后的工作原理🤔

2,234 阅读8分钟

babel简介

Babel是一款多功能的JS编译器,你可以通过插件化配置的方式使用和创建新一代JavaScriptJavaScript工具。说人话就是说:Babel是一个语言编译器,通过可配置的插件,可以实现将定制化的语法转移成兼容性好的语法(如ES5)。同时由于其插件定制化的特点,我们可以在转译时扩展源码的功能(如代码插桩统计覆盖率)以实现我们所需的JS工具需求。

babel是怎么工作的

和传统的编译器类似,babel同样经历了解析、转换、生成三个步骤。babel在设计上采用轻量的微内核设计,内核@bable/core类似一个调度器,并不直接实现功能细节,而是调度各模块插件去实现相关功能。

调度器

@babel/core的功能可以简单概况为 向外读取配置 => 向内调度插件模块协同工作 => 向外输出转译后的源码。详细版本如下:

  • 加载配置文件,读取所需使用的插件、预处理器等等
  • 调用@babel/parser进行词法分析、语法分析后转换为AST
  • 调用@babel/traverseAST进行遍历,并采用visitor模式应用配置项中的插件对AST进行转换
  • 调用@babel/generator生成源码和源码对应的sourceMap

解析(parse

解析的核心是词法分析将源码分词和语法分析将分词后的源码按照JS语法逻辑转换为AST

简单说说AST的含义:抽象语法树,可以理解为一种树状结构的程序说明书。它会对程序中的每一个节点(包括变量声明、语句等等)即节点下的分词进行详细说明。命名遵守ECMA规范,如下是一个简单的实例。

// 源码
const a = 1;
​
// AST(已简化)
{
  "type": "Program", // 程序根节点
  "comments": [],
  "sourceType": "module",
  "body": [
    {
      "type": "VariableDeclaration", // 变量声明节点
      "declarations": [
        {
          "type": "VariableDeclarator",  // 变量符号
          "id": {
            "type": "Identifier",
            "name": "a"
          },
          "init": {
            "type": "Literal", // 文本
            "value": 1,
          }
        }
      ],
      "kind": "const"
    }
  ]
}
​

词法分析

词法分析是源自编译原理的一个概念,其依赖词法分析器将输入的源码按语言的词法要求分割为保留关键词(如function)、条件判断词(if/else)、运算符、数字、字符串、空格等。如下是一个简单的例子。(这块大家有兴趣的话,我可以在后续出一篇编译原理的文章)

// demo
const a = 1;
​
// 词法分析的结果
[    {        "type": "Keyword",        "value": "const"    },    {        "type": "Identifier",        "value": "a"    },    {        "type": "Punctuator",        "value": "="    },    {        "type": "Numeric",        "value": "1"    },    {        "type": "Punctuator",        "value": ";"    }]

语法分析

语法分析同样源自编译原理,按照更准确地做法应该将这一步分为语法分析和语义分析。

  • 语法分析的作用是将词法分析生成的分词组合为各类型的语法短语,如“语句”、“表达式”等等,(表达式包含于语句中,区别如图所示),这个过程是一个递归的过程。

    // 语句和表达式的简单区分
    let a = 1,b = 2,c;
    c = a + b;
    ​
    /* 
      如上第二句中:a + b 是一个表达式,一个可以计算出结果的式子
      c = a + b 是将a + b的结果赋值给c的赋值语句
    */
    
  • 语义分析是对语法分析后的短语进行逻辑上的检查,如)前方是否有(

const a = 1;
// 语法分析、语义分析后的结果
{
  "type": "Program",
  "body": [
    {
      "type": "VariableDeclaration",
      "declarations": [
        {
          "type": "VariableDeclarator",
          "id": {
            "type": "Identifier",
            "name": "a"
          },
          "init": {
            "type": "Literal",
            "value": 1,
            "raw": "1"
          }
        }
      ],
      "kind": "const"
    }
  ],
  "sourceType": "script"
}

转换(Traverser)

节点遍历

转换器会遍历AST树,然后按照配置的插件对其中需要转换的节点进行操作,类似于dom操作。

由于babel的微内核、插件化的设计,允许插件开发者直接访问AST看上去是个十分糟糕的设计。因为这会导致AST的过分定制化,也对插件开发者不够友好。

于是乎,babel采用了代理模式去解决这样一个问题。babel提供了vistor这一代理器,由vistor进行统一的遍历操作,提供节点的操作方法,并响应式地维护节点之间的关系。

而插件只需要标记出自己需要访问的节点,当vistor访问到对应节点时,就调用插件定制的visit方法。

visitor示意图和示意代码如下,其访问顺序是一个递归访问的过程。

function makeVisitor ({types: t}) {
  return {
    visitor: {
      Program: {
        enter (path) {
          // 进入节点前做些什么
        },
        exit (path) {
          // 退出节点后做些什么
        }
      }
    }
  }
}

节点上下文

从上面代码你可能发现了无论是enter还是exit时,都会有一个path参数。如你所想,vistor遍历到每个节点的时候,都会给我们传入path参数,其包含了节点的信息一级节点所在的位置,以供我们对特定的节点进行修改。因此path表示的是两个节点之间连接的桥梁,而非当前的节点。它的结构如下:

{
    "parent": {
        "type": "FunctionDeclaration",
        "id": {...},...
    }, {
        "node": {
            "type": "Identifier",
            "name": "square"
        }
    }
}

从中可以看到path提供了节点的上下文信息,并允许我们对上下文信息调用增加、更新、移动和删除节点有关的方法。同时当我们调用一个修改AST的方法后,其上下文信息也会更新。这样设计的目的是为了简化操作,尽可能做到无状态。

作用域

JavaScript当你创建了一个标识符(如一个变量,一个函数等)时,它都应该属于当前的作用域。因此在处理一个转换时,应当特别的小心,得确保修改后的代码不会破坏已经存在的代码。比如在添加一个标识符时,需要确保新增的标识符不会和现有的所有标识符冲突。比如将标识符a转换标识符b时得确保引用b这个标识符未在别的地方被使用。作用域的简单示意如下:

{
  path: path; // 上下文
  block: path.node; // 上下文中锁定的节点
  parentBlock: path.parent;   // 父节点
  parent: Scope;       // 父节点的作用域
  bindings: { [name: string]: Binding; }; // 该作用域下面的所有绑定的标识符(引用标识)
}

举个🌰说明一下

// 使用babel将 b => c
var b = 2;
const add = (b, b) => b + b;
​
​
// 下面的写法会污染外部的 b
const MyVisitor = {
  let paramName;
  FunctionDeclaration(path) {
    const param = path.node.params[0];
    paramName = param.name;
    param.name = 'c';
  },
  
  Identifier(path) {
    // 这里会将所有的b标识符替换为c
    if (path.node.name === paramName) {
      path.node.name = 'c';
    }
  }
}
​
// 改进后
const updateParamNameVisitor = {
  Identifier(path) {
    if (path.node.name === this.paramName) {
      path.node.name = 'c';
    }
  }
}
​
const MyVisitor = {
  FunctionDeclaration(path) {
    const param = path.node.params[0];
    paramName = param.name;
    param.name = 'c';
    path.traverse(updateParamNameVisitor, { paramName });
  },
}

作用域绑定

你可能发现了前面的作用域中有bingdings这样一个参数。其含义是:所有标识符属于特定的作用域,这种标识符和作用域的关系被称之为绑定。因此我们可以通过bingdings属性获取当前作用域下的所有标识符,基于作用域绑定的特点我们能在特定作用域下复用部分标识符。比如下面代码中,修改函数内部的a,并不会影响外部,这点与js作用域一致。

const a = 1;
function() {
  let a = 2;
  a = 3;
}

生成(generator)

调度器调用generator插件将AST转译成源码,其过程没什么太多可说的,就是调用相关转换函数。

周边配套设施

babel-cli

babel官方提供的脚手架,允许你以命令行的方式运行babel

$ yarn global add babel-cli
​
# 将示例js输出到编译后的js中
$ babel example.js -o compiled.js

babel-register

通过绑定node.jsrequire来自动转译require引用的js代码文件。

请注意,这不适用于生产用途。部署以这种方式编译的代码被认为是不好的做法。在部署之前提前编译要好得多。但是,这对于构建脚本或您在本地运行的其他东西非常有效。以下是一段示例代码

$ yarn add babel-register -D
​
// a.js
console.log('a');
​
// b.js
console.log('b');
​
// register.js
require("babel-register");
require("./a.js");
require("./b.js");
​
$ node register.js

babel-plugin vs babel-preset

因为babel存在太多的plugin,实现某种功能,比如ES2015语法兼容时需要用到众多的插件,而为了减轻使用者的负担,babel-preset应运而生。preset是同类plugin的集合,为了实现某种特定类型的功能。

babel-polyfill vs babel-runtime

babel在进行转译时仅处理语法层面,未进行api进行兼容。基于babel的插件化设计,babel-polyfill补齐了这一功能。但由于babel-polyfill需要全量引入,影响了包体积。于是通过 preset的 useBuiltIns 来实现按需加栽。后面为了npm组件化开发又推出了babel-runtime

  • babel-polyfill不存在环境隔离,api会引入到全局环境中。这在应用内部使用是可控的,但作为一个library提供给别人使用显然是不可控的。(注入到全局的api会污染使用者的全局环境)
  • 按需加载只需要zaibabel.config.xxx中配置useBuiltIns: 'usage'
  • babel-runtime实现了环境隔离,避免了上述的环境冲突。(具体做法其实就是对库中的代码加入一些前缀进行隔离)
  • 但是babel-runtime也是不十全十美的,因为对库中的代码都加了前缀会导致js中的关键词如Array变成_Array,从而无法访问其原型链,因此需要配合babel-polyfill进行使用

参考文献

babel官方文档

babel原理

Babel 插件原理的理解与深入

深入浅出 Babel 上篇:架构和原理 + 实战