Babel 插件原理的理解与深刻

如今谈到 babel 确定你们都不会感受到陌生,虽然平常开发中不多会直接接触到它,但它已然成为了前端开发中不可或缺的工具,不只可让开发者能够当即使用 ES 规范中的最新特性,也大大的提升了前端新技术的普及(学不动了...)。可是对于其转换代码的内部原理咱们大多数人却知之甚少,因此带着好奇与疑问,笔者尝试对其原理进行探索。

Babel 是一个通用的多功能 JavaScript 编译器,但与通常编译器不一样的是它只是把同种语言的高版本规则转换为低版本规则,而不是输出另外一种低级机器可识别的代码,而且在依赖不一样的拓展插件下可用于不一样形式的静态分析。(静态分析:指在不须要执行代码的前提下对代码进行分析以及相应处理的一个过程,主要应用于语法检查、编译、代码高亮、代码转换、优化、压缩等等)html

babel 作了什么

和编译器相似,babel 的转译过程也分为三个阶段,这三步具体是:前端

  • 解析 Parse

将代码解析生成抽象语法树( 即AST ),也就是计算机理解咱们代码的方式(扩展:通常来讲每一个 js 引擎都有本身的 AST,好比熟知的 v8,chrome 浏览器会把 js 源码转换为抽象语法树,再进一步转换为字节码或机器代码),而 babel 则是经过 babylon 实现的 。简单来讲就是一个对于 JS 代码的一个编译过程,进行了词法分析与语法分析的过程。node

  • 转换 Transform

对于 AST 进行变换一系列的操做,babel 接受获得 AST 并经过 babel-traverse 对其进行遍历,在此过程当中进行添加、更新及移除等操做。git

  • 生成 Generate

将变换后的 AST 再转换为 JS 代码, 使用到的模块是 babel-generatorgithub

babel-core 模块则是将三者结合使得对外提供的API作了一个简化。web

此外须要注意的是,babel 只是转译新标准引入的语法,好比ES6箭头函数:而新标准引入的新的原生对象,部分原生对象新增的原型方法,新增的 API 等(Proxy、Set 等), 这些事不会转译的,须要引入对应的 polyfill 来解决。chrome

而咱们编写的 babel 插件则主要专一于第二步转换过程的工做,专一于对于代码的转化规则的拓展,解析与生成的偏底层相关操做则有对应的模块支持,在此咱们理解它主要作了什么便可。express

好比这样一段代码:编程

console.log("hello")

则会获得这样一个树形结构(已简化):json

{
    "type": "Program", // 程序根节点
    "body": [
        {
            "type": "ExpressionStatement", // 一个语句节点
            "expression": {
                "type": "CallExpression", // 一个函数调用表达式节点
                "callee": {
                    "type": "MemberExpression", // 表达式
                    "object": {
                        "type": "Identifier",
                        "name": "console"
                    },
                    "property": {
                        "type": "Identifier",
                        "name": "log"
                    },
                    "computed": false
                },
                "arguments": [
                    {
                        "type": "StringLiteral",
                        "extra": {
                            "rawValue": "hello",
                            "raw": "\"hello\""
                        },
                        "value": "hello"
                    }
                ]
            }
        }
    ],
    "directives": []
}

其中的全部节点名词,均来源于 ECMA 规范

抽象语法树是怎么生成的

谈到这点,就要说到计算机是怎么读懂咱们的代码的。解析过程分为两个步骤:

1.分词: 将整个代码字符串分割成语法单元数组(token)

JS 代码中的语法单元主要指如标识符(if/else、return、function)、运算符、括号、数字、字符串、空格等等能被解析的最小单元。好比下面的代码生成的语法单元数组以下:
在线分词工具

function demo (a) {
    console.log(a || 'a');
}
=> 

[
    { "type": "Keyword","value": "function" },
    { "type": "Identifier","value": "demo" },
    { "type": "Punctuator","value": "(" },
    { "type": "Identifier","value": "a" },
    { "type": "Punctuator","value": ")" },
    { "type": "Punctuator","value": "{ " },
    { "type": "Identifier","value": "console" },
    { "type": "Punctuator","value": "." },
    { "type": "Identifier","value": "log" },
    { "type": "Punctuator","value": "(" },
    { "type": "Identifier","value": "a" },
    { "type": "Punctuator","value": "||" },
    { "type": "String","value": "'a'" },
    { "type": "Punctuator","value": ")" },
    { "type": "Punctuator","value": "}" }
]

2.语义分析: 在分词结果的基础上分析语法单元之间的关系。

语义分析则是将获得的词汇进行一个立体的组合,肯定词语之间的关系。考虑到编程语言的各类从属关系的复杂性,语义分析的过程又是在遍历获得的语法单元组,相对而言就会变得更复杂。

先理解两个重要概念,即语句和表达式。

  • 语句(statement),即指一个具有边界的代码区域,相邻的两个语句之间从语法上来说互补影响,即调换顺序也不会产生语法错误。
  • 表达式(expression),则指最终有个结果的一小段代码,他能够嵌入到另外一个表达式,且包含在语句中。

简单来讲语义分析既是对语句和表达式识别,这是个递归过程,在解析中,babel 会在解析每一个语句和表达式的过程当中设置一个暂存器,用来暂存当前读取到的语法单元,若是解析失败,就会返回以前的暂存点,再按照另外一种方式进行解析,若是解析成功,则将暂存点销毁,不断重复以上操做,直到最后生成对应的语法树。

{"type": "Program",
"body": [{
    "type": "FunctionDeclaration",
    "id": { "type": "Identifier", "name": "demo" },
    "params": [{ "type": "Identifier", "name": "a" }],
    "body": {
        "type": "BlockStatement",
        "body": [{
            "type": "ExpressionStatement",
            "expression": {
                "type": "CallExpression",
                "callee": {
                    "type": "MemberExpression",
                    "computed": false,
                    "object": { "type": "Identifier", "name": "console" },
                    "property": { "type": "Identifier", "name": "log" }
                },
                "arguments": [{   
                    "type": "LogicalExpression",
                    "operator": "||",
                    "left": { "type": "Identifier", "name": "a" },
                    "right": { "type": "Literal", "value": "a", "raw": "'a'" }
                }]
            }
        }]
    },
}]}

推荐
the-super-tiny-compiler 这是一个只用了百来行代码的简单编译器开源项目,里面的做者也很用心的编写了详尽的注释,经过代码能够更好地理解这个过程。

具体过程分析

了解源代码的 AST 结构则是咱们转换过程的关键点,能够借助直观的树形结构转换 AST Explorer,更加直观的理解 AST 结构。

Visitors
对于这个遍历过程,babel 经过实例化 visitor 对象完成,既其实咱们生成出来的 AST 结构都拥有一个 accept 方法用来接收 visitor 访问者对象的访问,而访问者其中也定义了 visit 方法(即开发者定义的函数方法)使其可以对树状结构不一样节点作出不一样的处理,借此作到在对象结构的一次访问过程当中,咱们可以遍历整个对象结构。(访问者设计模式:提供一个做用于某对象结构中的各元素的操做表示,它使得能够在不改变各元素的类的前提下定义做用于这些元素的新操做)

遍历结点让咱们能够定位并找到咱们想要操做的结点,在遍历每个节点时,存在enter和exit两个时态周期,一个是进入结点时,这个时候节点的子节点还没触达,遍历子节点完成的后,会离开该节点并触发exit方法。

Paths
Visitors 在遍历到每一个节点的时候,都会给咱们传入 path 参数,包含了节点的信息以及节点和所在的位置,供咱们对特定节点进行修改,之因此称之为 path 是其表示的是两个节点之间链接的对象,而非指当前的节点对象。path属性有几个重要的组成,主要以下:

例如,若是访问到下面这样的一个节点

{
    type: "FunctionDeclaration",
    id: {
        type: "Identifier",
        name: "square"
    }
}

而他的 path 关联路径获得的对象则是这样的。

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

能够看到 path 实际上是一个节点在树中的位置以及关于该节点各类信息的响应式表示,即咱们访问过程当中操做的并非节点自己而是路径,且其中包含了添加、更新、移动和删除节点有关的其余不少方法,当调用一个修改树的方法后,路径信息也会被更新。主要目的仍是为了简化操做,尽量作到无状态。

实际运用
假若有以下代码:

NEJ.define(["./modal"], function(Modal){});

=> transform 为
define(["./modal"], function(Modal){});

咱们想要把 NEJ.define转化为 define,为了将模块依赖系统转换为标准的 AMD 形式,则能够用编写 babel 插件的方式去作。

首先咱们先分析须要访问修改的 AST 结构

{
    ExpressionStatement {
        expression: CallExpression {
            callee: MemberExpression {
                object: Identifier {
                    name: "NEJ"
                }
                property: Identifier {
                    name: "define"
                }
            }
            arguments: [
                ArrayExpression{},
                FunctionExpression{}
            ]
        }
    }
}

=>  转化为下面这样

{
    ExpressionStatement {
        expression: CallExpression {
            callee:  Identifier {
                 name: "define"
            }
            arguments: [
                ArrayExpression{},
                FunctionExpression{}
            ]
        }
    }
}

分析结构能够看到,arguments 是代码中传入的参数部分,这部分保持不变直接拿到就能够了,咱们须要修改的是 MemberExpression 表达式节点下的name 为 'NEJ' 的 Identifier部分,因为修改后的结构是一个CallExpression函数调用形式的表达式,那么总体思路如今就是建立一个CallExpression替换掉原来的 MemberExpression便可。这里借用了 babel-type( 为 babel提供多种辅助函数,相似于 loadsh 与 js之间的关系)建立节点。

const babel = require('babel-core');
const t = require('babel-types');
const code = 'NEJ.define(["./modal"], function(Modal){});';
let args = [];
const visitor = {
    ExpressionStatement(path) {
        if (path.node && path.node.arguments) {
            args = path.node.arguments;
        }
    },
    MemberExpression(path) {
        if (path.node && path.node.object && path.node.object.name === 'NEJ') {
            path.replaceWith(t.CallExpression(
                t.identifier('define'), args
            ))
        }
    }
}
const result = babel.transform(code, {
    plugins: [{
        visitor
    }]
})
console.log(result.code)

执行后便可看到结果

define((["./modal"], function (Modal) {});

在代码中能够看到,对于每一步访问到的节点咱们都要严格的判断是否与咱们预想的类型一致,这样不只是为了排除到其余状况,更是为了防止 Visitor 在访问相同节点时误入到其中,可是它可能没有须要的属性,那么就很是容易出错或者误伤,严格的控制节点的获取流程将会省去很多没必要要的麻烦。

须要注意什么

State 状态

状态是抽象语法树 AST 转换的敌人,状态管理会不断牵扯咱们的精力,并且几乎全部你对状态的假设,老是会有一些未考虑到的语法最终证实你的假设是错误的。

Scope 做用域

在 JavaScript 中,每当你建立了一个引用,无论是经过变量(variable)、函数(function)、类型(class)、参数(params)、模块导入(import)仍是标签(label)等,它都属于当前做用域。

当编写一个转换时,必需要当心做用域。咱们得确保在改变代码的各个部分时不会破坏已经存在的代码。在添加一个新的引用时须要确保新增长的引用名字和已有的全部引用不冲突,或者仅仅想找出使用一个变量的全部引用, 咱们只想在给定的做用域(Scope)中找出这些引用。

做用域能够被表示为以下形式:

{
  path: path,
  block: path.node,
  parentBlock: path.parent,
  parent: parentScope,
  bindings: [...]
}

即在建立一个新的做用域的时候,须要给出它的路径和父做用域,以后在遍历的过程当中它会在该做用域内收集全部的引用,收集完毕后既能够在做用域上调用方法。

例以下面代码中,我么须要将函数中的 n 转换为 x 。

function square(n) {
  return n * n;
}
var n = 1;

// 定义的 visitor(错误版❌)
let paramName;

const MyVisitor = {
  FunctionDeclaration(path) {
    const param = path.node.params[0];
    paramName = param.name;
    param.name = "x";
  },

  Identifier(path) {
    if (path.node.name === paramName) {
      path.node.name = "x";
    }
  }
};

若是不考虑做用域的问题,则会致使函数外的 n 也被转变,因此在转换的过程当中咱们能够在 FunctionDeclaration 节点中进行 n 的转变,把须要遍历的转换方法放在其中,防止对外部的代码产生做用。

// 改进后
const updateParamNameVisitor = {
  Identifier(path) {
    if (path.node.name === this.paramName) {
      path.node.name = "x";
    }
  }
};

const MyVisitor = {
  FunctionDeclaration(path) {
    const param = path.node.params[0];
    const paramName = param.name;
    param.name = "x";

    path.traverse(updateParamNameVisitor, { paramName });
  }
};

path.traverse(MyVisitor);

Bindings 绑定
全部引用属于特定的做用域,引用和做用域的这种关系称做为绑定。

例如须要将 const 转换为 var,而且对 const 声明的值给予只读保护。

const  a = 1;
const  b = 4;
function test (){
    let a = 2;
      a = 3;
}
a = 34;

而对于上面的这种状况,因为 function 有本身的做用域,因此在 function 内 a 能够被修改,而在外面则不能被修改。因此在实际应用中就须要考虑到绑定关系。

使用配置

常见作法是设置一个根目录下的 .babelrc 文件,统一将 babel 的设置都放在这里。

经常使用 options 字段说明

  • env:env 的核心目的是经过配置得知目标环境的特色,而后只作必要的转换。例如目标浏览器支持 es2015,那么 es2015 这个 preset 实际上是不须要的,因而代码就能够小一点(通常转化后的代码老是更长),构建时间也能够缩短一些。若是不写任何配置项,env 等价于 latest,也等价于 es2015 + es2016 + es2017 三个相加(不包含 stage-x 中的插件)。
  • plugins:要加载和使用的插件,插件名前的babel-plugin-可省略;plugin列表按从头至尾的顺序运行
  • presets:要加载和使用的preset ,每一个 preset 表示一个预设插件列表,preset名前的babel-preset-可省略;presets列表的preset按从尾到头的逆序运行(为了兼容用户使用习惯)
  • 同时设置了presets和plugins,那么plugins的先运行;每一个preset和plugin均可以再配置本身的option

常见的配置方法

{
    "plugins": [
        "transform-remove-strict-mode",
        ["transform-nej-module", {"mode": "web"}]
    ],
    "presets": [
        "env"
    ]
}

参考

推荐工具

相关文章
相关标签/搜索