深入原理:Babel原理与Babel插件实践
Jan 28, 2020 21:49 · 384 words · 2 minute read
前言
前端开发从「刀耕火种」过渡到「现代化」自动构建工程体系的过程中,Babel扮演了一个举足轻重的地位,使得我们可以用与时俱进的代码语法且最大程度上不受浏览器版本的约束,本文对如何使用Babel不再赘述,着重介绍Babel的原理和如何实现Babel插件。
什么是Babel?
Babel是JavaScript的编译器,它可以将你ES6(ES2015)+语法的代码编译到ES5版本从而兼容落后的浏览器。Babel也设计了一套插件架构去做代码转换,所以你可以去设计独创的代码转换规则。
在编写Babel插件之前,大家需要了解一下抽象语法树/AST的知识(熟知编译原理的可以快速跳过)。
抽象语法树(AST)
抽象语法树是高级编程语言(Java、JavaScript等)转换成机器语言的桥梁。解析器会根据ECMAScript 标准「JavaScript语言规范」来对代码字符串进行词法分析,拆分成一个个词法单元,再遍历各个词法单元进行语法分析构造出AST。我们通过如下代码来分析原理:
let str = 'test';
str + 'abc';
词法分析(Lexical Analysis)
对代码解析时先进入词法分析阶段,tokenizer(分词器)会将原始代码按照特定字符(如let、var、=等保留字)分成一个个叫token
的东西。token
由分号、标签、变量、字符串等组成,tokenizer
将tokens
构造成如下数据结构。
这是一个JavaScript简易编译器中词法分析的实现:https://github.com/jamiebuilds/the-super-tiny-compiler/blob/master/the-super-tiny-compiler.js#L381
[
{ "type": "Keyword", "value": "let" },
{ "type": "Identifier", "value": "str" },
{ "type": "Punctuator", "value": "=" },
{ "type": "String", "value": "'test'" },
{ "type": "Punctuator", "value": ";" },
{ "type": "Identifier", "value": "str" },
{ "type": "Punctuator", "value": "+" },
{ "type": "String", "value": "'abc'" },
{ "type": "Punctuator", "value": ";" }
]
语法分析(Syntactic Analysis)
词法分析完毕后进入语法分析阶段,语法分析将tokens
重新格式化为描述语法各部分及其相互关系的表示形式,这就是抽象语法树。
这个抽象语法树就是写Babel插件的核心概念,因为代码转换就是针对各个节点进行操作。
这是一个JavaScript简易编译器中语法分析的实现:https://github.com/jamiebuilds/the-super-tiny-compiler/blob/master/the-super-tiny-compiler.js#L555
接下来我们来看词法分析后的结果
我们用在线AST查看器来查看下这段代码在Babel眼中是什么样的
可以看到,代码转成AST后有了清晰的树结构,为转换代码提供了操作的标识,让我们方便地去操作各个节点进行修改。
接下来我们具体分析一下第一行代码
先从declarations
看起:
- 类型是
VariableDeclaration(变量声明)
,拥有两个属性id
和init
id
表示变量名称节点,type表示类型是一个变量Identifier
,name
属性代表它的声明值init
表示初始值的表达式,type表示类型是一个字面量Literal
,value
表示值,raw
表示原生代码。
以上就是用白话的形式来描述AST是如何表示这行代码的
Babel编译过程
- 用解析器将代码转换成AST(抽象语法树)
@babel/parser
- 遍历AST后使用插件进行修改
@babel/traverse
- 将AST转化成代码
@babel/generator
如何使用Babel去转换代码
import { parse } from "@babel/parser";
import traverse from "@babel/traverse";
import generate from "@babel/generator";
/*--------原代码----------*/
const code = `
let str = 'test';
str + 'abc';
`;
/*-----------------------*/
// code -> ast
const ast = parse(code);
// ast转换
traverse(ast, {
enter(path) {
// 判断变量名为str
if (path.isIdentifier({ name: "str" })) {
path.node.name = "transformStr";
}
}
});
// ast -> code
const output = generate(ast, {}, code);
console.log(output.code);
/*-------转换后的代码-------*/
let transformStr = 'test';
transformStr + 'abc';
/*-----------------------*/
这段代码展示了如何使用@babel/parser
、@babel/traverse
、@babel/generator
这三个包来进行转换代码,实现的功能是将代码中的变量str
替换成transformStr
编写一个最简单的Babel插件
Babel插件之Vistor
首先Babel大量使用了访问者模式(Visitor pattern),在遍历阶段,Babel将进行深度优先的搜索遍历,并访问AST中的每个节点,你可以在Vistor中指定一个回调方法,这样在访问节点时,Babel将使用当前访问的节点调用回调方法。
Babel插件的基础结构
export default function (babel) {
const { types: t } = babel;
return {
visitor: {
Identifier(path) {
// 判断变量名为str
if (path.isIdentifier({ name: "str" })) {
path.node.name = "transformStr";
}
}
}
};
}
这几行代码就上文实现替换变量的Babel插件模板写法。
我们的代码需要写在visitor object
中,用以node
的type
为名称的函数对当前节点进行修改。
这个函数接受两个参数path
和state
path
path
代表着在遍历AST的过程中连接两个节点的路径,你可以通过path.node
获取当前的节点path.parent.node
获得父节点,它也提供了path.replaceWith
, path.remove
等API,这样就能通过一定条件来获取特点的节点进行修改了。
state
state
在官方教程中并没有说的很明白,我认为state
是一个global state
,你可以在遍历AST中使用和更改它。
@babel/types
@babel/types
是一个Babel的npm包,其中包含了Babel中所有的API,我们可以通过这些API来对AST进行操作和判断也可以查看每个表达式的类型如:CallExpression
、MemberExpression
等。
文档地址:https://babeljs.io/docs/en/babel-types
写一个自定义的插件
最近想进行技术改造,将node端的代码重构成用TypeScript编写的,首当其冲的问题就是将node.js中的CommonJS
模块规范改成ES modules
模块规范
举例:
const xx = require('xxx')
->import xx from 'xxx'
module.exports = x
->export default x
有了想法后,我们可以分析如何操作节点就修改成我们想要的样子。
首先,我们分析下const xx = require('xxx')
的AST构造,查看它的特点
可以看出我们可以使用Babel的visitor
去遍历CallExpression
,然后找到callee
中name
为require
便可以锁定这行代码的AST节点,然后提取变量名和依赖名,使用Babel的API来构造import
语句并替换其父节点就好了。
通过查阅上文的@babel/types
提供的API文档可以在visitor中来这样判断
visitor: {
CallExpression(path) {
// const xx = require("xxx")
if (
t.isIdentifier(path.node.callee, { name: "require" }) &&
t.isStringLiteral(path.node.arguments[0]) &&
path.node.arguments.length === 1
) {
// 提取require的依赖名称
const depName = path.node.arguments[0].value;
if (
t.isVariableDeclarator(path.parentPath.node) &&
t.isIdentifier(path.parentPath.node.id)
) {
// 提取require的声明变量
const importName = t.identifier(path.parentPath.node.id.name);
if (t.isVariableDeclaration(path.parentPath.parentPath.node)) {
path.parentPath.parentPath.replaceWith(
// 用@babel/types构造import语句
t.importDeclaration(
[t.importDefaultSpecifier(importName)],
t.stringLiteral(depName)
)
);
}
}
}
}
}
然后我们分析下module.exports = x
的AST构造,查看它的特点
这里也可以使用Babel的visitor
去遍历MemberExpression
,通过判断node.object.name === 'module'
和 node.property.property === 'exports'
就能定位该节点并构造export default
语句将其替换。
visitor: {
MemberExpression(path) {
// module.exports = xxx
// babel推荐使用@babel/types中的api来做判断,不要使用等于号判断
if (
t.isIdentifier(path.node.object, { name: "module" }) &&
t.isIdentifier(path.node.property, { name: "exports" })
) {
// 获取声明该语句的节点
const assignmentExpression = path.parentPath;
// 构造export default
if (t.isExpression(assignmentExpression.node.right)) {
assignmentExpression.parentPath.replaceWith(
t.exportDefaultDeclaration(assignmentExpression.node.right)
);
}
}
}
}
将上述两个Visitor拼接起来就可以得到完整的Babel插件。此插件将CommonJS
模块规范改成ES modules
模块规范并没有覆盖所有例子,仅作演示使用。
接下来我们看下最终效果
代码demo参考:https://github.com/spiritree/babel-plugin-commonjs-esmodule
总结
其实写一个Babel插件并没有我们想象中的难,通过工具解析AST后我们可以清晰的了解到代码的结构与特征,在@babel/type
API的加持下,让我有种用jQuery
操作DOM树的感觉。在学习过程中我们不能光会配置,理解原理深入工具是很有必要的。