这篇文章中是对 Creating custom JavaScript syntax with Babel 的展开,在原文中,作者通过一步步实现一个新的 js 语法特性的示例介绍了 babel 的基本运作情况,本文将沿用这个示例,并着重梳理了编译器的工作流程与基本概念,最后看看它们如何对应到 babel 中,并基于对这些内容的了解完成这个示例。
目标
本文的最终目标是通过 babel 实现一种新的 js 语法特性,这种特性允许在 function
关键字后标记 @@
来将该函数柯里化,其最终形式如下:
// '@@' makes the function `foo` curried
function @@ foo(a, b, c) {
return a + b + c;
}
console.log(foo(1, 2)(3)); // 6
为了达成这个目标,我们需要:
- 知道一些编译器的基本概念,并对其的工作流程有一定的了解。
- 了解 babel 工程的结构以及开发、测试流程
下面就开始吧。
编译器工作流程与基本概念
AST
AST 即 Abstract Syntax Tree 抽象语法树,是对一段代码文本以树的形式进行的语言结构描述,这种结构是源语言与目标语言之间的一种中间表达(IR intermediate representation)。与 AST 对应的是 Parse Tree 解析树(也称 CST Concrete Syntax Trees),两者的区别主要在于:
- AST 不包含诸如逗号、括号、分号这样的语法细节
- AST 使用折叠版本,不包含单后继节点(single-successor nodes,只有一个子节点的节点, 例如下面示例的 factor -> 3 / factor -> 4 等)
- 操作符 token(例如 +, -, x, /) 不会以叶节点的形式出现,而是作为内部节点(通常作为父节点的属性,例如 "operator": "*")
Parse Tree Abstract Syntax Tree
========== ====================
exp *
| / \
term 3 +
/|\ / \
term * factor 4 2
| /|\
| / | \
factor ( exp )
| /|\
3 exp + term
| |
term factor
| |
factor 2
|
4
下面展示一段 AST 解析的示例:
function * foo(){
yield 1;
function bar(){
return 2;
}
return bar();
}
这段代码有一个 foo 函数,它是 generator 函数,函数体内有一个 yield 表达式,一个 return 语句以及一个内部函数,经过 esprima 解析后 AST 结构如下:
esprima parsed AST
{
"type": "Program",
"body": [
{
"type": "FunctionDeclaration",
"id": {
"type": "Identifier",
"name": "foo",
"range": [
11,
14
]
},
"params": [],
"body": {
"type": "BlockStatement",
"body": [
{
"type": "ExpressionStatement",
"expression": {
"type": "YieldExpression",
"argument": {
"type": "Literal",
"value": 1,
"raw": "1",
"range": [
26,
27
]
},
"delegate": false,
"range": [
20,
27
]
},
"range": [
20,
28
]
},
{
"type": "FunctionDeclaration",
"id": {
"type": "Identifier",
"name": "bar",
"range": [
40,
43
]
},
"params": [],
"body": {
"type": "BlockStatement",
"body": [
{
"type": "ReturnStatement",
"argument": {
"type": "Literal",
"value": 2,
"raw": "2",
"range": [
57,
58
]
},
"range": [
50,
59
]
}
],
"range": [
45,
63
]
},
"generator": false,
"expression": false,
"async": false,
"range": [
31,
63
]
},
{
"type": "ReturnStatement",
"argument": {
"type": "CallExpression",
"callee": {
"type": "Identifier",
"name": "bar",
"range": [
73,
76
]
},
"arguments": [],
"range": [
73,
78
]
},
"range": [
66,
79
]
}
],
"range": [
16,
81
]
},
"generator": true,
"expression": false,
"async": false,
"range": [
0,
81
]
}
],
"sourceType": "module",
"range": [
0,
81
]
}
这段 AST 结构的重点内容梳理如下,这里有很多形如 {type: string, ...各种属性}
这样的结构,它们是 AST 这个树中的节点 (node)
- type 'FunctionDeclaration' --- 函数声明,和下面所有内容共同组成 foo 函数节点
- id --- 函数标识
- type: 'Identifier'
- name: 'foo' --- 函数名
- params: [], 函数形参列表
- generator: true,generator 函数标识
- body 函数体
- type: 'BlockStatement' 函数体内的语句块
- body 函数体内的语句块的内容,包含三个节点
- type: 'ExpressionStatement' 表达式语句, type 为 YieldExpression
- type: 'FunctionDeclaration' 函数声明,bar 函数
- type: 'BlockStatement' 表达式语句,type 为 ReturnStatement
- body 函数体内的语句块的内容,包含三个节点
- type: 'BlockStatement' 函数体内的语句块
注意:
- 使用 @babel/parser 生成的结构类似,只是 esprima 更为简洁,方便在这里展示。
- 在 astexplorer 可以查看输入代码的 AST,并支持多种语言。
像这样把一段代码从文本的输入转换为 AST 输出的过程,对于大多数编译器来说叫做 parse
,它一般需要经历 Lexical Analysis
和 Syntactic Analysis
两个阶段,即词法分析
和语法分析
Parse: Lexical Analysis
词法分析的第一步,也是 AST 解析的第一步:
文本扫描,这个过程将文本分解成尽可能小的部分,这些部分称为词素(lexemes),此时这些词素还是与语言无关的,他们看起来像是这样:
source: function * foo(){}
⬇️
lexemes:【function】【*】【foo】【(】【)】【{】【}】
接下来,词法分析的目标是要将这些内容转换为 token,处理这部分工作的一般被叫做 tokenizer
,转换后的 token 已经具备了一些语言特性,它们被标记了其在所属语言中所表达的含义:
[
{
"type": "Keyword",
"value": "function"
},
{
"type": "Punctuator",
"value": "*"
},
{
"type": "Identifier",
"value": "foo"
},
{
"type": "Punctuator",
"value": "("
},
// ......
]
在具体的实现中,文本扫描和 token 识别一般同时进行,整个 tokenizer 的过程大概像下面的伪代码展示的这样:
// 文本扫描指针
let index = 0;
// 完成的 token 列表
let tokens = [];
// 遍历输入文本
while(current < input.length) {
// 跳过注释和空白字符并移动 index
this.skipComment();
this.skipSpace();
// 获取当前扫描位置的字符
const char = input.charAt(index);
// 逐个匹配符号、操作符等
switch(char) {
case Char.comma: // ,
tokens.push({
type: Char.comma
});
index++;
break;
// ... 其它符号
// 处理 identifier 或者 keyword token
default:
// 读取完整 word
let word = readWord(char, index);
// 判断该 word 是否是关键字以设定不同的 type 给当前的 token
tokens.push({
type: isKeyword(word) ? 'keyword' : 'Identifier'
});
index += word.length;
}
}
esprima 提供了在线工具,可以方便地查看 token 的生成: https://esprima.org/demo/parse.html
Parse: Syntactic Analysis
语法分析接收 tokens 输入,输出 AST,这一过程中,解析器逐个遍历 token,结合 token 和语言的特性,在 AST 这棵树上做更详细的结构化标记。
AST 节点
一棵 AST 由若干个 Node
构成,一个基础的 Node 看起来像是下面这样,这些是 AST 上每个节点都会携带的信息:
type NodeBase = {
// 节点类型,例如 ExpressionStatement / FunctionDeclaration
type: string;
// 节点对应源码中开始位置文本的索引
start: number;
// 节点对应源码中结束位置文本的索引
end: number;
// 源码位置,开始符和结束符的行、列数值,生成 sourcemap 使用
loc: SourceLocation;
}
对于一个特定类型的节点,因为其所需要携带的信息不同,会基于上面的 NodeBase 进行扩展,例如对于正则表达式字面量,需要记录它的表达式(pattern)和匹配模式(flag):
// 正则表达式字面量
type RegExpLiteral = NodeBase & {
type: "RegExpLiteral";
// 表达式
pattern: string;
// 匹配模式标记: "gimsuy"
flags: RegExp$flags;
};
而有一些类型的节点可以容纳子节点,通常会为其扩展一个 body
属性,从而通过 body 的嵌套形成一棵完整的 AST:
// for 语句,可以容纳子节点
type ForStatement = NodeBase & {
type: "ForStatement";
// 初始语句: for(init; ;)
init?: VariableDeclaration | Expression;
// 判断条件: for(init; test;)
test?: Expression;
// 更新语句: for(init; test; update)
update?: Expression;
// 子语句: for(init; test; update) { body }
body: Statement;
};
另外 body
的类型也可能是数组,例如对于根节点 Program 来说,它可能包含多个子语句,这种类型的节点不是很多: Program / BlockStatement / ClassBody / StaticBlock:
type ClassBody = NodeBase & {
type: "ClassBody";
// ClassBody 是一个可以包含多个子语句的节点
body: Array;
};
PS. 对于 babel,在这里可以看到对所有节点的定义,包括每个节点应该包含的属性,属性值类型,以及校验规则。
解析过程
接下来看看语法解析是如何处理词法解析的结果以及在 Node 上做出标记的,依然以刚才的代码为例: function * foo(){}
,我们知道这个 function 是一个 generator,那么语法分析又是怎么处理它的呢,下面以 @babel/parser
为例来一探究竟。
经过 Lexical Analysis,我们已经知道,此时的 token 列表中包含关键字 function
、generator 标记 *
等等,他们在 babel 里的表示如下:
[tt._function, tt.star, ...]
补充说明: 在 babel/parser 中,如果 token 的 type 是语法关键字,则以
_
开头,例如这里的 function。
首先是语句解析 parseStatementContent
,通过一个 switch
语句尝试通过开头的关键字来识别语句类型,当 token 为 tt._function 时,交给 parseFunctionStatement
处理:
parseStatementContent() {
switch (starttype) {
case tt._function:
return this.parseFunctionStatement(node, false, !context);
// case tt._if;
// case tt._return;
// case tt._const:
// case tt._var:
// case tt.braceL:
// ...;
}
}
补充说明: 当语句不以语句关键字或左花括号
{
开头时(即未匹配到上述 switch 的任何一条 case),则 @babel/parser 会尝试将其作为表达式来解析,解析的结果存在两张可能,1. LabeledStatement 2. ExpressionStatement。
接下来进入到 parseFunctionStatement
,因为这里已经明确了是 function 语句,所以首先调用 this.next()
把处理的 token 移动到下一个位置以处理接下来的内容,移动过后就是 tt.star 了。
parseFunctionStatement() {
this.next();
return this.parseFunction(node);
}
调用 this.next() 之后:
[tt._function, tt.star, ...] => [tt._function, tt.star, ...]
⬆️ ⬆️
最后也是最关键的一步,this.eat(tt.star)
:
parseFunction(node){
// ...
node.generator = this.eat(tt.star);
// ...
}
这里的 node 就是当前正在处理的 AST 中的节点,generator 是作为一个 boolean 属性标记在 node 上的,this.eat
做了两件事情:
- 当前指针所在 token 的类型(type) 是否和入参一致,返回布尔值表示匹配与否
- 如果匹配,则继续移动指针到下一个 token
eat(type: TokenType): boolean {
if (this.state.type === type) {
this.next();
return true;
} else {
return false;
}
}
因为我们当前的 token 正是 tt.star,所以最后生成的 AST 在这个节点上的 generator 属性为 true,这和本文一开始举例的 AST 完全一致。
token 操作
正如上面的示例,在语法分析的过程中,编译器需要不断地移动指针和与特定语法特性尝试匹配来处理全部的 token,这里对这些方法做一些归纳:
-
match(type: TokenType): boolean
: 当前指针所在的 token 是否和入参的 token 匹配 (type 一致) -
next(): void
: 将指针移动到下一个 token -
eat(type: TokenType): boolean
: 返回当前指针所在的 token 是否和入参的 token 匹配(type 一致),如果是则调用next()
-
lookahead(): LookaheadState
: 仅获取下一个 token 而不移动指针,常用于对当前 token 的处理逻辑需要取决于下一个 token 的情况
Transform 转换
经过 parse 阶段后,已经拿到了 AST,Transform 这个阶段要做的事情,就是针对编译目标语言对 AST 进行修改,babel 的所有插件都工作在这个阶段,一般编译器通过访问者模式来处理这个过程,AST 具体的修改逻辑由访问者 Visitor 对象提供,编译器会对 AST 进行深度优先遍历,当遇到 type 匹配的节点时,调用Visitor 对象中与之对应的方法:
const MyVisitor = {
Identifier(path) {
// 每当遇到 {type='Identifier'} 节点时,该方法都将被调用
}
};
你可能注意到了,上面的示例中有一个参数,它包含了节点的元信息、关联节点信息,事实上在大多数情况下,很少出现单独对一个节点进行处理的情况,各种操作都需要考虑到和它关联的节点,path 参数提供了这些信息,同时它也提供了各种对节点操作的方法,像是删除、修改、创建节点等等,在 babel plugin handbook 有关于这些内容的说明,作为 plugin 的编写手册,这里对这部分内容描述得非常详细。
Generate
Generate 是编译器的最后一步,根据修改后的 AST ,生成目标语言。
扩展: the-super-tiny-compiler
如果你对这部分内容感兴趣,推荐看看 the-super-tiny-compiler 项目,它是一个使用 javascript 实现的超小编译器,该编译器的目标是编译 LISP 语言风格的函数调用语句到 C 语言风格,像是: (add 2 2)
=> add(2, 2)
,这没有什么实际用途,但它的实现思路与结构和大型的编译器完全一致,所谓麻雀虽小五脏俱全。
babel 工程
工程结构
babel 采用 monorepo 模式来管理工程,包括官方插件,这意味着只需要 clone 一个仓库就可以获得 babel 的全部代码。
git clone [email protected]:babel/babel.git
进入到 packages 目录,根据刚才对编译器的理解,我们找到了如下包:
- @babel/parser 负责词法、语法解析,生成 AST,原先叫做
babylon
, 从Acorn
的一个 fork 发展而来 - @babel/traverse AST 迭代器,接收两个参数:AST / Visitor 对象
- @babel/plugin-* 官方插件,AST 修改的具体实现,其返回内容主要是由 Visitor 对象构成
import { declare } from "@babel/helper-plugin-utils"; export default declare((api, options, dirname) => { return { name: '插件名', visitor: Visitor 对象 } });
- @babel/preset-* plugins 的集合
- @babel/generator 根据 AST 生成最终代码
另外还有一些包,是对以上包针对不同使用场景的组合使用封装:
- @babel/core 封装了各种类型的转译方法,以覆盖更全面的输入(string / file)、输出(code / AST)、调用过程(sync / async)、调用配置(各种 option) 的场景,像是 parse / transform / transformFromAst / transformFromFile 等等。
- @babel/cli 在 cli 模式执行的 @babel/core
- @babel/register 通过 Pirates 的 require hook 劫持 node 的 require,在运行时编译代码,动态编译性能较差,一般很少使用。
require("@babel/register");
最后还有一些作为辅助功能的包:
- @babel/types 主要包含了手动构造各类 AST 节点的 API,每种类型的节点有三个方法,其形式为
(前缀: 无/is/assert)节点类型(...参数)
,前缀分别对应创建、判断类型、断言类型,例如对于函数表达式 functionExpression,其 API 包括:functionExpression
创建函数表达式节点,isFunctionExpression
判断一个节点是否是函数表达式assertFunctionExpression
,断言一个节点是函数表达式,这个包提供的 API 非常多,虽然它们在编写插件时被大量使用,但建议了解即可,真正需要用到时再根据当时的 AST 构造场景查阅其文档。 - @babel/template 使用字符串模板创建 AST,在大规模生成 AST 的时候非常方便
- @babel/helpers 一系列使用
@babel/template
template.program.ast(tpl) 创建的 helper 函数,通常是一些运行时的公共函数,在插件中可以通过this.addHelper(helperName)
调用这些 helpers 方便地创建 AST,像是常见的_classCallCheck
_defineProperties
这些函数都是在这个包里预置的。
工程编译
初始化
babel 使用 Makefile 编排 + Gulp 执行 的模式来构建工程。
首先,你需要像下面这样来初始化工程:
make bootstrap
# or
yarn bootstrap
build 与 watch
# build
make build
# or
yarn build
# watch
make watch
test
# test + lint
make test
# test
make test-only
# 只测试某个模块
TEST_ONLY=babel-模块名 make test-only
# 只测试某个模块中与 TEST_GREP 匹配的用例
TEST_ONLY=babel-模块名 TEST_GREP="text" make test-only
因为 babel 的单元测试实际上是执行的 jest,也可以直接用 jest 来执行:
jest [TestPathPattern]
好了,现在执行一下 make watch
开始编码以实现目标。
为 babel 添加语法特性
首先回顾一下目标: 我们希望通过在 function
之后添加 @@
来使函数柯里化,然而 js 本身是没有这个语法的,尝试编译这样的语法会报错:
function @@ foo(a, b, c) {
return a + b + c;
}
SyntaxError: unknown: Unexpected token (1:9)
词法分析支持: 添加新 token
这个报错说的很明白,编译器不认识 @@
这个 token。结合上面的内容我们很容易想到需要调整 parser 模块的词法分析这部分内容,向其添加我们新定义的 token: @@
,这部分代码位于 packages/babel-parser/src/tokenizer/types.js
,这个文件主要内容就是 types 的定义,其中声明了 js 支持的 token 类型,从文本排版看出这些 token 大体做了一些分门别类:标点符号 / 运算符 / 关键字 等等,在这里加入 @@
:
export const types: { [name: string]: TokenType } = {
num: new TokenType("num", { startsExpr }),
string: new TokenType("string", { startsExpr }),
// Punctuations
comma: new TokenType(",", { beforeExpr }),
colon: new TokenType(":", { beforeExpr }),
atat: new TokenType("@@"),
// Operators
eq: new TokenType("=", { beforeExpr, isAssign }),
logicalOR: createBinop("||", 1),
// Keywords
_function: createKeyword("function", { startsExpr }),
_if: createKeyword("if"),
};
新的 token 类型添加好后,需要在词法分析器中解析它,这部分的代码位于与定义 types 相邻的 tokenizer/index.js
中,
getTokenFromCode(code: number): void {
switch (code) {
// ...
case charCodes.atSign:
// 如果下一个字符还是 @, 即 @@
if (this.input.charCodeAt(this.state.pos + 1) === charCodes.atSign) {
// 创建为 @@ token
this.finishOp(tt.atat, 2);
} else {
// 否则创建为 @ token
this.finishOp(tt.at, 1);
}
return;
// ...
}
}
这里的 finishOp(type: TokenType, size: number): void
是完成一个 token 解析后需要调用的方法,它记录当前的 token 信息,将解析位置向后移动。tokenizer 中解析部分的重点主干流程使用到的方法如下所示:
export default class Tokenizer extends ParserErrors {
// 已解析的 token 列表.
tokens: Array = [];
nextToken(): void {
// 从 this.state.pos 位置解析
this.getTokenFromCode(this.codePointAtPos(this.state.pos));
}
finishOp(type: TokenType, size: number): void {
const str = this.input.slice(this.state.pos, this.state.pos + size);
// 向后移动 size 位
this.state.pos += size;
this.finishToken(type, str);
}
finishToken(type: TokenType, val: any): void {
// 标记 token 信息
this.state.end = this.state.pos;
const prevType = this.state.type;
this.state.type = type;
this.state.value = val;
}
next(): void {
if (this.options.tokens) {
// 添加进 token 列表
this.pushToken(new Token(this.state));
}
// ...
this.nextToken();
}
}
现在新的 token: @@ 应该能够被正确解析了,为了验证这一步,我们尝试用使用新特性的代码进行解析,在 test 目录内创建一个测试文件,只编写简单的解析代码:
import { parse } from "../lib";
describe("curry function syntax", function () {
it("should parse", function () {
const code = `function @@ foo(){}`;
const ast = parse(code);
console.log(ast);
});
});
> jest curry-function
SyntaxError: Unexpected token (1:9)
at Parser._raise (/path-to-babel-project/packages/babel-parser/lib/parser/error.js:105:17)
at Parser.raiseWithData (/path-to-babel-project/packages/babel-parser/lib/parser/error.js:98:17)
at Parser.raise (/path-to-babel-project/packages/babel-parser/lib/parser/error.js:59:17)
at Parser.unexpected (/path-to-babel-project/packages/babel-parser/lib/parser/util.js:123:16)
at Parser.parseIdentifierName (/path-to-babel-project/packages/babel-parser/lib/parser/expression.js:1645:18)
运行之后依然报错,不过这是在意料之中的,因为目前为止,只是词法解析的部分支持了 @@
新特性,现有的语法解析逻辑依然无法处理这个 token,我们将在稍后解决这个问题。现在查看调用栈,进入到距离报错最近的正常流程中(上述高亮部分)打印日志看看:
parseIdentifierName(pos: number, liberal?: boolean): string {
console.log(this.state.type); // 当前处理的 token
console.log(this.lookahead().type); // 下一个 token
// ...
}
// 当前处理的 token
TokenType {
label: '@@',
// ...
}
// 下一个 token
TokenType {
label: 'name',
// ...
}
以上输出的内容证实了 babel 已经能够正确地解析新增的 token。
babel parser 的执行流程 (optional)
如果按照上面的步骤跟着调试代码,可能在这个过程中有一些困惑的地方,我们先来解决这些问题:
1. 为什么 Tokenizer 的 tokens 始终为空?
babel 没有单独的方法来生成 tokens,它只提供生成 AST 的相关方法,要查看 tokens,需要在 parse 的时候配置 tokens 为 true,这相当于告诉 babel-parser 请帮我收集 tokens,parser 在执行过程中通过查看这个配置,来决定是否将 token 放入 tokens 列表:
export default class Tokenizer extends ParserErrors {
next(): void {
if (this.options.tokens) {
// 添加进 token 列表
this.pushToken(new Token(this.state));
}
}
}
// 调用 parse 时,配置 tokens: true
const astWithTokens = parse(code, {
tokens: true,
});
// astWithTokens:
{
// ...
tokens: [Token, Token, ...]
}
2. 为什么收集到的 tokens 不包含语法分析报错部分之后的代码的 tokens,像是上面示例中的括号?
事实上,babel-parser 的执行流程并不是先解析完所有的 tokens 再交给语法分析处理(区别于 the-super-tiny-compiler),词法分析和语法分析是交替进行的,也就是: 词法分析 --- 一个token --- 语法分析 --- 下一次词法分析 --- 下一个 token --- 下一次语法分析。因此,如果在这个执行过程中,语法分析异常,词法分析也不会再继续执行。
从结构上来看,babel-parser 的各个部分也不是组合关系,而是通过层层继承来扩展 Parser 的能力,其继承关系如下:
Parser
-> StatementParser
-> ExpressionParser
-> LValParser
-> NodeUtils
-> UtilParser
-> Tokenizer
-> ParserErrors
-> CommentsParser
-> BaseParser
像上面代码使用到的 next()
在 Tokenizer 实现,其调用主要是在 StatementParser 和 ExpressionParser 中。
3. 我只想要 token 而跳过语法分析部分该怎么做?
通过问题 2 应该已经能够很容易看出,对于 babel来说这一点是无法做到的,但有些场景可能仅仅需要词法分析,例如语法高亮,额外又无用的语法分析过程只能带来额外的性能开销。Esprima tokenizer 提供了独立的词法分析模块,专门用于生成 token。但其实 Esprima 的执行流程和 babel 一样,也是词法分析和语法分析交替进行,tokenizer
模块仅仅是专门提供给外部的模块,和其内部的语法分析模块没有任何关联。
语法分析支持: 标记到 AST
参考本文最开始的一段 generator 函数的 AST 结构示例 的这个例子,对于 generator 函数,在语法解析阶段向 FunctionDeclaration
这个节点上添加了 "generator": true
这对属性值,我们要做的事情与之类似: 如果函数有 @@
token,那么同样在这个节点上添加上 curry: boolean
的标记,如果你还记得上一章里讲到的内容,我们稍后将在 Transform 阶段处理这个属性。现在先找到 parser/statement.js
文件里的 parseFunction
方法,
parseFunction(
node: T,
statement?: number = FUNC_NO_FLAGS,
isAsync?: boolean = false,
): T {
// ...
node.generator = this.eat(tt.star);
node.curry = this.eat(tt.atat);
// ...
}
关于 this.eat
在前文 token 操作 已经有过说明,这里的做法可以同时支持 generator 和 curry,但如果两个标记同时存在,则 *
必须写在 @@
前面,你也可以在这里通过执行不同的 token 操作逻辑,来定义自己的语法特性,下面我们按照上述规则执行一下测试:
describe("curry function syntax", function() {
const code = `function * @@ foo(){}`;
const ast = parse(code);
it("should has generator", function() {
expect(ast.program.body[0].generator).toBe(true);
});
it("should has curry", function() {
expect(ast.program.body[0].curry).toBe(true);
});
});
$ jest curry-function
PASS packages/babel-parser/test/curry-function.js
curry function syntax
✓ should has generator (1 ms)
✓ should has curry (1 ms)
到这里,parse 阶段要做的两件事情,词法分析和语法分析就已经都完成了。
Transform: 编写插件
在上一个步骤中,我们完成了在 AST 的 FunctionDeclaration
节点添加 curry: boolean
属性的工作,接下来要处理 AST 的转换,这部分工作在 babel 中通过 plugin 机制来实现。
假设我们有一个科里化函数叫做 currying
,它接收一个函数作为参数,并返回一个科里化后的函数,那么现在 Transform 的目标,就是把 curry
属性为 true
的 FunctionDeclaration
节点用 currying 函数包裹,以示例代码为例:
// 原始代码:
function @@ foo(a, b, c) {
return a + b + c;
}
// Transform 后期望的代码:
const foo = currying(
function foo(a, b, c) {
return a + b + c;
}
)
另外,parser
必须要使用刚才经过修改的版本,需要通过插件的 parserOverride 配置来覆盖默认的 parser:
import customParser from '/path-to-babel-project/packages/babel-parser/lib/index.js';
function myCustomPlugin({types}) {
return {
parserOverride(code, opts) {
return customParser.parse(code, opts);
},
};
}
接下来使用 Visitor 对象来访问和修改 AST,这部分的代码可能看起来比较繁琐,但请先阅读它们,代码中的注释里会尽可能地解释每段语句的作用:
function myCustomPlugin({t}) {
return {
// Visitor 对象
visitor: {
// 访问 FunctionDeclaration 节点
FunctionDeclaration(path) {
// 在 parse 阶段被标记 curry 的 FunctionDeclaration 节点
if (path.node.curry) {
// 还原 curry 标记
path.node.curry = false;
// 开始替换操作,使用参数内的内容进行替换:
path.replaceWith(
// 变量声明 t.variableDeclaration(kind 声明类型, declarations 内容);
t.variableDeclaration(
// 声明类型 "var" | "let" | "const", 这里使用 const
'const',
[
// t.variableDeclarator(id: 变量名, expression: 表达式);
t.variableDeclarator(
// 使用原函数名作为变量名
t.identifier(path.get('id.name').node),
// 调用表达式: t.callExpression(callee: 被调用方, arguments: 参数);
t.callExpression(
// 调用 'currying'
t.identifier('currying'),
// 传入参数,这里需要传入的是原函数:
[
// 方式一: 将当前节点 FunctionDeclaration 转换为表达式
// t.toExpression(path.node),
// 方式二: 手动创建函数表达式
t.functionExpression(
null /* 匿名函数 */,
path.node.params /* 沿用原函数参数 */,
path.node.body /* 沿用原函数体 */,
false, /* 非 generator */
false /* 非 async */
)
]
)
),
]
)
);
}
}
},
};
}
这就是我们需要实现的 Visitor 的全部内容了,它们总共只分为两个部分:
- 使用
path
访问当前节点的信息。 - 使用
t
创建 AST,这里的t
是babel-types
的内容,babel 在执行插件时将其传入,等同于import * as t from "@babel/types"
或者import { types as t } from "@babel/core"
,使用 t 作为变量名来接收似乎是一种约定俗成(官方插件全都这样写)。
到目前位置,这个插件已经可以工作了,接下来尝试在 transformSync
时使用这个插件:
const code = `function @@ test(a, b, c) {
return a + b + c
}`;
const output = babel.transformSync(code, {
plugins: [ myCustomPlugin ],
});
// output:
{
...,
code: 'const test = currying(function (a, b, c) {\n return a + b + c;\n});'
}
现在只剩下最后一个问题了,这个 currying
函数本身放在哪里呢?参考 babel 工程结构 的内容,这里的答案是 @babel/helpers
,但很可惜@babel/helpers
目前并没有开放给插件进行扩展,所以这部分实现需要放在 babel 源码里:
helpers.currying = helper("7.6.0" /* min version */)`
export default function currying(fn) {
const numParamsRequired = fn.length;
function curryFactory(params) {
return function (...args) {
const newParams = params.concat(args);
if (newParams.length >= numParamsRequired) {
return fn(...newParams);
}
return curryFactory(newParams);
}
}
return curryFactory([]);
}
`;
接下来修改插件,修改 currying
调用表达式为使用 helper:
// ...
types.callExpression(
// types.identifier('currying'),
this.addHelper("currying"),
[
types.functionExpression(null, path.node.params, path.node.body, false, false)
]
)
// ...
重新执行刚才的代码:
// output:
{
...,
code: 'function _currying(fn) { const numParamsRequired = fn.length; function curryFactory(params) { return function (...args) { const newParams = params.concat(args); if (newParams.length >= numParamsRequired) { return fn(...newParams); } return curryFactory(newParams); }; } return curryFactory([]); }\n' +
'\n' +
'const test = _currying(function (a, b, c) {\n' +
' return a + b + c;\n' +
'});'
}
现在 currying
函数已经输出到了编译后的代码,并且它只会存在一份。
插件到这里就已经全部实现完成了,最后回顾一下实现的过程:
- 根据对词法分析和语法分析的理解,修改
babel-parser
包,在 Parse 阶段支持@@
语法,并能够输出带curry: boolean
标记的 AST 节点 - 使用 babel 插件机制,在 Transform 阶段访问
FunctionDeclaration
节点,并利用babel-types
提供的方法,针对curry: true
的节点进行 AST 的调整,调整内容为使用currying
包裹原函数 - 将
currying
的实现放到babel-helpers
中,并在插件中通过addHelper
调用,使整个功能在运行时完备
参考
- Leveling Up One’s Parsing Game With ASTs
- Creating custom JavaScript syntax with Babel
- What's the difference between parse trees and abstract syntax trees?
- the-super-tiny-compiler
- Esprima
- Babel Plugin Handbook