本文旨在通过提取官方文档的关键内容,略去一些知识,快速了解插件的基本编写方式,方便你在较低学习成本下写出插件。
基础
Babel 是 JavaScript 编译器,更确切地说是源码到源码的编译器,通常也叫做“转换编译器(transpiler)”。 意思是说你为 Babel 提供一些 JavaScript 代码,Babel 更改这些代码,然后返回给你新生成的代码。
抽象语法树(ASTs)
这个处理过程中的每一步都涉及到创建或是操作抽象语法树,亦称 AST。
Babel 使用一个基于 ESTree 并修改过的 AST,它的内核说明文档可以在[这里](https://github. com/babel/babel/blob/master/doc/ast/spec. md)找到。.
function square(n) {
return n * n;
}
这个程序可以被表示成如下的一棵树:
- FunctionDeclaration:
- id:
- Identifier:
- name: square
- params [1]
- Identifier
- name: n
- body:
- BlockStatement
- body [1]
- ReturnStatement
- argument
- BinaryExpression
- operator: *
- left
- Identifier
- name: n
- right
- Identifier
- name: n
每一层结构也被叫做 节点(Node)。 一个 AST 可以由单一的节点或是成百上千个节点构成。 它们组合在一起可以描述用于静态分析的程序语法。
我们可以使用 AST Explorer 来对 AST 节点有一个更好的认识,在后续插件 的编写中,也可以通过它来准确遍历节点。
Babel 的处理步骤
Babel 的三个主要处理步骤分别是: 解析(parse),转换(transform),生成(generate)。.
解析
解析步骤接收代码并输出 AST。 这个步骤分为两个阶段:**词法分析(Lexical Analysis) **和 语法分析(Syntactic Analysis)。
转换
这里是插件介入工作的地方。 转换步骤接收 AST 并对其进行遍历,在此过程中对节点进行添加、更新及移除等操作。
生成
代码生成步骤把经过一系列转换之后的 AST 转换成字符串形式的代码,同时还会创建源码映射 (source maps)。.
代码生成其实很简单:深度优先遍历整个 AST,然后构建可以表示转换后代码的字符串。
了解你在插件里要做的事情
以及Babel为了方便你编写插件,已经做的事情。
接下来,我们都会以一个具体的需求为例,方便理解插件要做的事情:
对于同步的循环(for, for/in, for/of, while, do/while
)执行,期望增加一套检测程序,当循环陷入死循环时,及时抛出异常以终止循环,来避免程序崩溃。判断是否陷入死循环的方式是:增加迭代计数器,当计数到一个足够大的数时,便认为死循环。
如果我们直接在已有的一段代码上增加这段检测代码,应该是这样的,增加前:
while (true) {
console.log(1);
}
增加后:
let iterator = 0;
while (true) {
if(iterator++>9999999){
throw new RangeError("Infinite loop: exceeded 9999999 iterations.");
}
console.log(1);
}
试想如果要编写插件,我们要做的事情:
- 找到所有的循环语句
- 在该段循环的父级作用域声明变量,如
iterator
(变量名的前提是不能和已有的变量冲突),初始值为 0 - 对于循环执行语句,如果不是块语句(大括号包裹起来的代码),需要修改为块语句
- 在块语句开头,增加一段 if 语句,用于增加变量
iterator
的值,并判断iterator
是否已经超过设定的阈值(如9999999),如果是则抛出异常
借助Babel,我们希望可以快速达成这些事情:
-
通过声明告诉 Babel,我们需要在特定的条件下(循环语句这样一个AST节点)处理代码
-
对于节点的操作,可能包括节点本身的修改,也可能对节点的父节点进行访问,也可能是需要插入一个子节点。对此,Babel是通过路径 NodePath 来提供相应方法的,来表示节点之间的关联关系。一个 NodePath 对象主要有如下属性
{
"parent": Node, // 父节点
"node": Node, // 当前的节点
"parentPath": NodePath, // 对于父节点,其对应的 path 对象
"container": [...Node], // 路径的容器(包含所有同级节点的数组)
"scope": Scope, // 作用域对象,后面会讲
}同时 NodePath 对象还包含添加、更新、移动和删除节点有关的其他很多方法,以实现如修改为块语句这样的方法。
-
在作用域中声明变量,需要让新增加的变量名字和已有的所有变量不冲突。
-
对于新增的这段 if 语句,由 Babel (babel-template) 提供编写字符串形式且带有占位符的代码来代替手动创建 AST。
了解 API
访问特定的语句
在插件代码中,先写下如下代码,即在模块中导出一个函数,函数返回的对象中,visitor 属性就是这个插件的主要访问者
export default function() {
return {
visitor: {
// visitor contents
}
};
};
如果要访问 while 循环的代码,我们可以在 AST Explorer 中,查询其对应的 AST 节点名称,对于源代码:
while (true) {
console.log(1);
}
其 AST 形式如下:
我们便可以添加 WhileStatement
访问者方法:
export default function() {
return {
visitor: {
WhileStatement(path, state) {}
}
};
};
visitor
中的每个函数接收2个参数:path
和 state
path
就是当前访问的NodePath
对象state
是插件启动时传入的一些参数
{
cwd: '', // process.cwd()
file: File,
filename: ’‘, // 当前处理的文件名称
opts: {} // 接受用户可以指定的插件特定选项
}
opts 是用户引入当前插件时,传入的选项
{
plugins: [
["my-plugin", {
"option1": true,
"option2": false
}]
]
}
如有必要,你还可以把方法名用 |
分割成 Idenfifier |MemberExpression
形式的字符串,把同一个函数应用到多种访问节点。.
也可以在访问者中使用别名(如babel-types定义),如使用 Loop
表示所有循环语句:
return {
visitor: {
'Loop': () => {}
}
}
验证与创建节点
- 通过
@babel/core
导入types
对象
import { types as t } from '@babel/core';
types.isIdentifier(path.node)
- 通过
babel-types
导入
import * as t from "babel-types";
types.isIdentifier(path.node)
- 插件导出函数的入参也有
types
对象
该模块是一个用于 AST 节点的 Lodash 式工具库, 它包含了构造、验证以及变换 AST 节点的方法。 该工具库包含考虑周到的工具方法,对编写处理AST逻辑非常有用。
完整的方法可参考 babel-types API
验证节点
- 通过
t.isX
方法
t.isBinaryExpression(maybeBinaryExpressionNode);
这个测试用来确保节点是一个二进制表达式,另外你也可以传入第二个参数来确保节点包含特定的属性和值。
t.isBinaryExpression(maybeBinaryExpressionNode, { operator: "*" });
- 通过
t.assert
方法
这类方法会抛出异常而不是返回 true 或 false
t.assertBinaryExpression(maybeBinaryExpressionNode, { operator: "*" });
- 在 NodePath 上同样可以访问到
isX
方法
Identifier(path) {
if (path.isIdentifier({ name: "n" })) {
// ...
}
}
相当于
Identifier(path) {
if (t.isIdentifier(path.node, { name: "n" })) {
// ...
}
}
创建节点
按类似下面的方式使用:
t.binaryExpression("*", t.identifier("a"), t.identifier("b"));
可以创建如下所示的 AST:
{
type: "BinaryExpression",
operator: "*",
left: {
type: "Identifier",
name: "a"
},
right: {
type: "Identifier",
name: "b"
}
}
当打印出来之后是这样的:
a * b
创建一个无冲突的变量名
需要访问 scope
来生成一个标识符,不会与任何本地定义的变量相冲突
const node = path.scope.generateUidIdentifier("xxx");
其返回的是一个 Identifier
类型的 Node
,然后可以将该变量名和初始化值,传给 scope,以在当前作用域创建变量的声明与初始化。
path.scope.push({ id: node, init: t.numericLiteral(0) });