编写你的第一个 Babel Plugin

1,412 阅读8分钟

现在我们用 ReactVue 开发项目时,package.json 文件都会有 babel 的依赖,还会依赖于一些插件。什么是 babel,怎么样写一个 babel 插件呢,看完你就明白了。如果还有不懂或者文章有疑问的地方,欢迎留言~

背景知识 - 使用 babel 的原因和概念

使用 babel 原因 - 将 js 最新版本转成 es5 代码,兼容浏览器

es6/7/8 的出现,给我们带来了很多的方便,减少了很多代码的编写,但是不是所有的浏览器都是全部支持的,目前 Chrome 应该是支持率最高的。所以为了兼容市面上的浏览器,我们需要把我们的代码转成 es5,这应该是最初使用 babel 的缘由

使用 babel 原因 - 业务复杂化,需要很多定制化功能

随着业务的开发,我们会需要很多定制化的功能,单纯的 babel 并不能解决所有的问题,所以 babel 插件应用而来。

babel 是什么

Babel是一个工具链,主要用于在当前和较旧的浏览器或环境中将ECMAScript 2015+代码转换为JavaScript的向后兼容版本。以下是Babel可以为您做的主要事情:

  • Transform syntax 转换语法

  • Polyfill features that are missing in your target environment (through @babel/polyfill)

    目标环境中缺少的Polyfill功能(通过@babel/polyfill

  • Source code transformations (codemods)源代码转换

  • And more! (check out these videos for inspiration)

// Babel Input: ES2015 arrow function
[1, 2, 3].map((n) => n + 1);

// Babel Output: ES5 equivalent
[1, 2, 3].map(function(n) {
  return n + 1;
});

更多概念参考 官方文档

背景知识 - AST (抽象语法树)

为什么要谈 AST?

如果你查看目前主流项目中的 devDependencies, 会发现不计其数的插件诞生,比如:Taro,mpvue等。我们归纳一下: javascript 转换、代码压缩、css预处理器、eslint、pretier等。有很多的 js 模块我们在生产环境不会用到,但是在开发过程中充当着重要的角色。所有的上述工具,都建立在 AST 这个巨人的肩膀上。

什么是 AST?

"It is a hierarchical program representation that presents source code structure according to the grammar of a programming language, each AST node corresponds to an item of a source code." 估计很多同学会和我一样,看完这段官方的定义一脸懵逼。OK,我们来看例子: Demo

var ddd = 12

简单分析下 var ddd = 12这一句,首先我们知道这条语句是定义变量,定义了 ddd,并且赋值12,然后我们在对应看上图 AST 语法树,开始的 File 就是根节点, 然后是 program 节点,不用管,然后就是我们重点关注的 body节点。

VariableDeclaration 是变量声明,这就和我们前面的分析对应上了。接下来declarations是一个数组,为什么是一个数组呢?其实也很好理解,我们变量声明的时候是不是可以用逗号,隔开同时写多个变量呢,类似var ddd = 12, dddd1 = 13;,而他们在同一条声明的语句中,所以就用数组来标识了。 在看具体的 VariableDeclarator,里面一个 id,一个是 init,很直观就能看出 id 就是我们的变量名称,init 就是初始化的值,我们会看到有三个 key 在每个花括号中出现type start end,type 就是类型,start 是代码的起始位置,end 是结束位置。 关于type 类型可以查看官网有详细介绍

思考下面的简单函数 demo 可以怎么样进行解析呢?

Demo 具体解析可以自行思考

总结 AST

你会留意到 AST 的每一层都拥有相同的结构:

{
  type: "FunctionDeclaration",
  id: {...},
  params: [...],
  body: {...}
}
{
  type: "Identifier",
  name: ...
}
  type: "BinaryExpression",
  operator: ...,
  left: {...},
  right: {...}
}

注意:出于简化的目的移除了某些属性

这样的每一层结构也被叫做 节点(Node)。 一个 AST 可以由单一的节点或是成百上千个节点构成。 它们组合在一起可以描述用于静态分析的程序语法。

每一个节点都有如下所示的接口(Interface):

interface Node {
  type: string;
}

字符串形式的 type 字段表示节点的类型(如: "FunctionDeclaration","Identifier",或 "BinaryExpression")。 每一种类型的节点定义了一些附加属性用来进一步描述该节点类型。

Babel 还为每个节点额外生成了一些属性,用于描述该节点在原始代码中的位置。

{
  type: ...,
  start: 0,
  end: 38,
  loc: {
    start: {
      line: 1,
      column: 0
    },
    end: {
      line: 3,
      column: 1
    }
  },
  ...
}

每一个节点都会有 start,end,loc 这几个属性。

What’s babel plugin?

首先我们回顾下 babel 的处理流程: 解析(parse)、转换(transform)、生成(generate)

解析 - 解析步骤接收代码并输出 AST。 这个步骤分为两个阶段:词法分析(Lexical Analysis) 和 语法分析(Syntactic Analysis

词法分析

词法分析阶段把字符串形式的代码转换为 令牌(tokens) 流。.

你可以把令牌看作是一个扁平的语法片段数组:

n * n;
[
  { type: { ... }, value: "n", start: 0, end: 1, loc: { ... } },
  { type: { ... }, value: "*", start: 2, end: 3, loc: { ... } },
  { type: { ... }, value: "n", start: 4, end: 5, loc: { ... } },
  ...
]

每一个 type 有一组属性来描述该令牌:

{
  type: {
    label: 'name',
    keyword: undefined,
    beforeExpr: false,
    startsExpr: true,
    rightAssociative: false,
    isLoop: false,
    isAssign: false,
    prefix: false,
    postfix: false,
    binop: null,
    updateContext: null
  },
  ...
}

和 AST 节点一样它们也有 start,end,loc 属性。.

语法分析

语法分析阶段会把一个令牌流转换成 AST 的形式。 这个阶段会使用令牌中的信息把它们转换成一个 AST 的表述结构,这样更易于后续的操作。

转换 - 转换步骤接收 AST 并对其进行遍历,在此过程中对节点进行添加、更新及移除等操作。 这就是 Babel 或是其他编译器中最复杂的过程,同时也是插件将要介入工作的部分

生成 - 代码生成步骤把最终的 AST 转换成字符串形式的代码,同时还会创建源码映射(source maps)

具体如下图所示

How babel plugin work?

遍历(Traversal)

访问者会以深度优先的顺序, 或者说递归地对 AST 进行遍历,其调用顺序如下图所示:

在遍历过程中,由插件对自己感兴趣的节点类型, 进行转换操作,生成新的 AST

Babel 的转换步骤全都是AST的遍历过程

由于AST中的节点往往很多,如果每一个插件都对AST进行遍历 和转换,那可想而知效率极低,所以遍历一般使用访问者模式

访问者模式

提供一个作用于某种对象结构上的各元素的操作方式,使我们在不改变元素结构的前提下,定义作用于元素的新操作

访问者(Visitors)

访问者有以下作用:

  • 进行统一的遍历操作
  • 提供节点的操作方法
  • 响应式维护节点之间的关系

进入节点时会调用 enter方法 离开节点时会调用 exit方法

Babel 插件怎么执行的?

按照插件定义的顺序来应用访问方法,比如在babel config注册了多个插件, babel-core 最后传递给访问器的数据结构如下图:

插件执行顺序:

plugin: 从前到后 presets:从后往前

注意】:有时候需要注意一些插件的前后顺序

编写你的第一个简单 babel 插件

了解完前面的基础知识,我们就可以开始 babel 插件的开发。

插件格式

export default function({ types: t }) {
  return {
    visitor: {
      Identifier(path, state) {},
      ASTNodeTypeHere(path, state) {},
      CallExpression(path, state) {}
    }
  };
};

上面是插件的基本格式,一个函数,参数是 babel,这里我们用到的是 types 这个属性,所以只把它写了出来,然后函数的返回是一个对象,key 是 visitor,对应值的对象是我们熟悉的东西,是 babel-types 的类型,然后是一个函数,函数的两个参数 path 表示路径,state 表示状态。

visitor 字面意思就是访问者,这里也是这个意思,表示我们要访问哪个类型的节点。这里的 CallExpression(调用表达式),类似于 hanle()。

简单插件实现

我们写一个简单的插件,把前面定义的变量名 ddd 换成是 a。

首先我们要找到定义变量的地方,然后判断变量名称是不是 ddd,如果是就把它替换成 a,大概思路就是这么简单。 继续打开我们的代码网站 Demo

代码左侧就是我们定义的变量 ddd,右侧就是 AST 语法树如下图。

我们看到定义变量的节点类型是 VariableDeclarator,所以写的代码应该是下面的模板

export default function({ types: t }) {
  return {
    visitor: {
       VariableDeclarator(path, state) {}
    }
  };
};

我们只要判断 id 的 name 属性是不是 ddd 就行了,最后的代码如下:

export default function({ types: t }) {
  return {
    visitor: {
       VariableDeclarator(path, state) {
            if(path.node.id.name === 'ddd'){
                path.node.id = t.Identifier('a')
            }
        }
    }
  };
};

有人可能会想这里是不是直接path.node.id.name = 'a'就可以了,如果你是操作object,那你就对了,不过这里是 AST 语法树,所以想改变某个值,就是用对应的 AST 来替换

最终代码运行

总结

我们写插件的时候只需要以下几个步骤就可以完成:

  • 确认我们要修改的节点类型(把代码复制到 ASTExplorer 中,一一对应)
  • 找到修改的属性是哪个(这里我们修改 id 属性)
  • 根据旧的 AST 构建新的 AST 语句并替换

参考文献

接下来你可以去熟读Babel手册, 这是目前最好的教程, ASTExplorer 是最好的演练场,多写代码多思考。 你也可以去看 Babel的官方插件实现, 迈向更高的台阶。