国庆放假了,我还在利用碎片时间在写文章,不知道长假还有没有人看,试试水吧!css
这个文章系列将带你们深刻浅出 Babel
, 这个系列将分为上下两篇:上篇主要介绍 Babel 的架构和原理,顺便实践一下插件开发的;下篇会介绍 `babel-plugin-macros , 利用它来写属于 Javascript 的’宏‘,前端
✨满满的干货,不容错过哦. 写文不易,点赞是最大的鼓励。node
注意: 本文不是 Babel 的基础使用教程!若是你对 Babel 尚不了解,请查看官方网站, 或者这个用户手册react
文章下篇已经更新:深刻浅出 Babel 下篇:既生 Plugin 何生 Macros 有点冷清,赞起来。欢迎转载,让更多人看到个人文章,转载请注明出处git
文章大纲github
上图是 Babel 的处理流程, 若是读者学习过编译器原理
,这个过程就至关亲切了.shell
首先从源码 解析(Parsing)
开始,解析包含了两个步骤:express
1️⃣词法解析(Lexical Analysis): 词法解析器(Tokenizer)
在这个阶段将字符串形式的代码转换为Tokens(令牌)
. Tokens 能够视做是一些语法片断组成的数组. 例如for (const item of items) {}
词法解析后的结果以下:设计模式
从上图能够看,每一个 Token 中包含了语法片断、位置信息、以及一些类型信息. 这些信息有助于后续的语法分析。数组
2️⃣语法解析(Syntactic Analysis):这个阶段语法解析器(Parser)
会把Tokens
转换为抽象语法树(Abstract Syntax Tree,AST)
什么是AST?
它就是一棵'对象树',用来表示代码的语法结构,例如console.log('hello world')
会解析成为:
Program
、CallExpression
、Identifier
这些都是节点的类型,每一个节点都是一个有意义的语法单元。 这些节点类型定义了一些属性来描述节点的信息。
JavaScript的语法愈来愈复杂,并且 Babel 除了支持最新的JavaScript规范语法, 还支持 JSX
、Flow
、如今还有Typescript
。想象一下 AST 的节点类型有多少,其实咱们不须要去记住这么多类型、也记不住. 插件开发者会利用 ASTExplorer
来审查解析后的AST树, 很是强大👍。
AST 是 Babel 转译的核心数据结构,后续的操做都依赖于 AST。
接着就是**转换(Transform)**了,转换阶段会对 AST 进行遍历,在这个过程当中对节点进行增删查改。Babel 全部插件都是在这个阶段工做, 好比语法转换、代码压缩。
Javascript In Javascript Out, 最后阶段仍是要把 AST 转换回字符串形式的Javascript,同时这个阶段还会生成Source Map。
我在《透过现象看本质: 常见的前端架构风格和案例🔥》 说起 Babel
和 Webpack
为了适应复杂的定制需求和频繁的功能变化,都使用了微内核 的架构风格。也就是说它们的核心很是小,大部分功能都是经过插件扩展实现的。
因此简单地了解一下 Babel 的架构和一些基本概念,对后续文章内容的理解, 以及Babel的使用仍是有帮助的。
一图胜千言。仔细读过我文章的朋友会发现,个人风格就是能用图片说明的就不用文字、能用文字的就不用代码。虽然个人原创文章篇幅都很长,图片仍是值得看看的。
Babel 是一个 MonoRepo
项目, 不过组织很是清晰,下面就源码上咱们能看到的模块进行一下分类, 配合上面的架构图让你对Babel有个大概的认识:
1️⃣ 核心:
@babel/core
这也是上面说的‘微内核’架构中的‘内核’。对于Babel来讲,这个内核主要干这些事情:
Parser
进行语法解析,生成 AST
Traverser
遍历AST,并使用访问者模式
应用'插件'对 AST 进行转换2️⃣ 核心周边支撑
Parser(@babel/parser
): 将源代码解析为 AST 就靠它了。 它已经内置支持不少语法. 例如 JSX、Typescript、Flow、以及最新的ECMAScript规范。目前为了执行效率,parser是不支持扩展的,由官方进行维护。若是你要支持自定义语法,能够 fork 它,不过这种场景很是少。
Traverser(@babel/traverse
): 实现了访问者模式
,对 AST 进行遍历,转换插件
会经过它获取感兴趣的AST节点,对节点继续操做, 下文会详细介绍访问器模式
。
Generator(@babel/generator
): 将 AST 转换为源代码,支持 SourceMap
3️⃣ 插件
打开 Babel 的源代码,会发现有好几种类型的‘插件’。
语法插件(@babel/plugin-syntax-*
):上面说了 @babel/parser
已经支持了不少 JavaScript 语法特性,Parser也不支持扩展. 所以plugin-syntax-*
实际上只是用于开启或者配置Parser的某个功能特性。
通常用户不须要关心这个,Transform 插件里面已经包含了相关的plugin-syntax-*
插件了。用户也能够经过parserOpts
配置项来直接配置 Parser
转换插件: 用于对 AST 进行转换, 实现转换为ES5代码、压缩、功能加强等目的. Babel仓库将转换插件划分为两种(只是命名上的区别):
@babel/plugin-transform-*
: 普通的转换插件@babel/plugin-proposal-*
: 还在'提议阶段'(非正式)的语言特性, 目前有这些预约义集合(@babel/presets-*
): 插件集合或者分组,主要方便用户对插件进行管理和使用。好比preset-env
含括全部的标准的最新特性; 再好比preset-react
含括全部react相关的插件.
4️⃣ 插件开发辅助
@babel/template
: 某些场景直接操做AST太麻烦,就好比咱们直接操做DOM同样,因此Babel实现了这么一个简单的模板引擎,能够将字符串代码转换为AST。好比在生成一些辅助代码(helper)时会用到这个库
@babel/types
: AST 节点构造器和断言. 插件开发时使用很频繁
@babel/helper-*
: 一些辅助器,用于辅助插件开发,例如简化AST操做
@babel/helper
: 辅助代码,单纯的语法转换可能没法让代码运行起来,好比低版本浏览器没法识别class关键字,这时候须要添加辅助代码,对class进行模拟。
5️⃣ 工具
@babel/node
: Node.js CLI, 经过它直接运行须要 Babel 处理的JavaScript文件
@babel/register
: Patch NodeJs 的require方法,支持导入须要Babel处理的JavaScript模块
@babel/cli
: CLI工具
转换器会遍历 AST 树,找出本身感兴趣的节点类型, 再进行转换操做. 这个过程和咱们操做DOM
树差很少,只不过目的不太同样。AST 遍历和转换通常会使用访问者模式
。
想象一下,Babel 有那么多插件,若是每一个插件本身去遍历AST,对不一样的节点进行不一样的操做,维护本身的状态。这样子不只低效,它们的逻辑分散在各处,会让整个系统变得难以理解和调试, 最后插件之间关系就纠缠不清,乱成一锅粥。
因此转换器操做 AST 通常都是使用访问器模式
,由这个访问者(Visitor)
来 ① 进行统一的遍历操做,② 提供节点的操做方法,③ 响应式维护节点之间的关系;而插件(设计模式中称为‘具体访问者’)只须要定义本身感兴趣的节点类型,当访问者访问到对应节点时,就调用插件的访问(visit)方法。
假设咱们的代码以下:
function hello(v) {
console.log('hello' + v + '!')
}
复制代码
解析后的 AST 结构以下:
File
Program (program)
FunctionDeclaration (body)
Identifier (id) #hello
Identifier (params[0]) #v
BlockStatement (body)
ExpressionStatement ([0])
CallExpression (expression)
MemberExpression (callee) #console.log
Identifier (object) #console
Identifier (property) #log
BinaryExpression (arguments[0])
BinaryExpression (left)
StringLiteral (left) #'hello'
Identifier (right) #v
StringLiteral (right) #'!'
复制代码
访问者会以深度优先
的顺序, 或者说递归地对 AST 进行遍历,其调用顺序以下图所示:
上图中绿线
表示进入该节点,红线
表示离开该节点。下面写一个超简单的'具体访问者'来还原上面的遍历过程:
const babel = require('@babel/core')
const traverse = require('@babel/traverse').default
const ast = babel.parseSync(code)
let depth = 0
traverse(ast, {
enter(path) {
console.log(`enter ${path.type}(${path.key})`)
depth++
},
exit(path) {
depth--
console.log(` exit ${path.type}(${path.key})`)
}
})
复制代码
enter Program(program)
enter FunctionDeclaration(0)
enter Identifier(id)
exit Identifier(id)
enter Identifier(0)
exit Identifier(0)
enter BlockStatement(body)
enter ExpressionStatement(0)
enter CallExpression(expression)
enter MemberExpression(callee)
enter Identifier(object)
exit Identifier(object)
enter Identifier(property)
exit Identifier(property)
exit MemberExpression(callee)
enter BinaryExpression(0)
enter BinaryExpression(left)
enter StringLiteral(left)
exit StringLiteral(left)
enter Identifier(right)
exit Identifier(right)
exit BinaryExpression(left)
enter StringLiteral(right)
exit StringLiteral(right)
exit BinaryExpression(0)
exit CallExpression(expression)
exit ExpressionStatement(0)
exit BlockStatement(body)
exit FunctionDeclaration(0)
exit Program(program)
复制代码
当访问者进入一个节点时就会调用 enter(进入)
方法,反之离开该节点时会调用 exit(离开)
方法。 通常状况下,插件不会直接使用enter
方法,只会关注少数几个节点类型,因此具体访问者也能够这样声明访问方法:
traverse(ast, {
// 访问标识符
Identifier(path) {
console.log(`enter Identifier`)
},
// 访问调用表达式
CallExpression(path) {
console.log(`enter CallExpression`)
},
// 上面是enter的简写,若是要处理exit,也能够这样
// 二元操做符
BinaryExpression: {
enter(path) {},
exit(path) {},
},
// 更高级的, 使用同一个方法访问多种类型的节点
"ExportNamedDeclaration|Flow"(path) {}
})
复制代码
那么 Babel 插件是怎么被应用的呢?
Babel 会按照插件定义的顺序来应用访问方法,好比你注册了多个插件,babel-core 最后传递给访问器的数据结构大概长这样:
{
Identifier: {
enter: [plugin-xx, plugin-yy,] // 数组形式
}
}
复制代码
当进入一个节点时,这些插件会按照注册的顺序被执行。大部分插件是不须要开发者关心定义的顺序的,有少数的状况须要稍微注意如下,例如plugin-proposal-decorators
:
{
"plugins": [
"@babel/plugin-proposal-decorators", // 必须在plugin-proposal-class-properties以前
"@babel/plugin-proposal-class-properties"
]
}
复制代码
全部插件定义的顺序,按照惯例,应该是新的或者说实验性的插件在前面,老的插件定义在后面。由于可能须要新的插件将 AST 转换后,老的插件才能识别语法(向后兼容)。下面是官方配置例子, 为了确保前后兼容,stage-*
阶段的插件先执行:
{
"presets": ["es2015", "react", "stage-2"]
}
复制代码
注意Preset的执行顺序相反,详见官方文档
访问者在访问一个节点时, 会无差异地调用 enter
方法,咱们怎么知道这个节点在什么位置以及和其余节点的关联关系呢?
经过上面的代码,读者应该能够猜出几分,每一个visit
方法都接收一个 Path
对象, 你能够将它当作一个‘上下文’对象,相似于JQuery
的 JQuery
(const $el = $('.el')
) 对象,这里面包含了不少信息:
下面是它的主要结构:
export class NodePath<T = Node> {
constructor(hub: Hub, parent: Node);
parent: Node;
hub: Hub;
contexts: TraversalContext[];
data: object;
shouldSkip: boolean;
shouldStop: boolean;
removed: boolean;
state: any;
opts: object;
skipKeys: object;
parentPath: NodePath;
context: TraversalContext;
container: object | object[];
listKey: string; // 若是节点在一个数组中,这个就是节点数组的键
inList: boolean;
parentKey: string;
key: string | number; // 节点所在的键或索引
node: T; // 🔴 当前节点
scope: Scope; // 🔴当前节点所在的做用域
type: T extends undefined | null ? string | null : string; // 🔴节点类型
typeAnnotation: object;
// ... 还有不少方法,实现增删查改
}
复制代码
你能够经过这个手册来学习怎么经过 Path 来转换 AST. 后面也会有代码示例,这里就不展开细节了
实际上访问者的工做比咱们想象的要复杂的多,上面示范的是静态 AST 的遍历过程。而 AST 转换自己是有反作用的,好比插件将旧的节点替换了,那么访问者就没有必要再向下访问旧节点了,而是继续访问新的节点, 代码以下。
traverse(ast, {
ExpressionStatement(path) {
// 将 `console.log('hello' + v + '!')` 替换为 `return ‘hello’ + v`
const rtn = t.returnStatement(t.binaryExpression('+', t.stringLiteral('hello'), t.identifier('v')))
path.replaceWith(rtn)
},
}
复制代码
上面的代码, 将console.log('hello' + v + '!')
语句替换为return "hello" + v;
, 下图是遍历的过程:
咱们能够对 AST 进行任意的操做,好比删除父节点的兄弟节点、删除第一个子节点、新增兄弟节点... 当这些操做'污染'了 AST 树后,访问者须要记录这些状态,响应式(Reactive)更新 Path 对象的关联关系, 保证正确的遍历顺序,从而得到正确的转译结果。
访问者能够确保正确地遍历和修改节点,可是对于转换器来讲,另外一个比较棘手的是对做用域的处理,这个责任落在了插件开发者的头上。插件开发者必须很是谨慎地处理做用域,不能破坏现有代码的执行逻辑。
const a = 1, b = 2
function add(foo, bar) {
console.log(a, b)
return foo + bar
}
复制代码
好比你要将 add
函数的第一个参数 foo
标识符修改成a
, 你就须要递归遍历子树,查出foo
标识符的全部引用
, 而后替换它:
traverse(ast, {
// 将第一个参数名转换为a
FunctionDeclaration(path) {
const firstParams = path.get('params.0')
if (firstParams == null) {
return
}
const name = firstParams.node.name
// 递归遍历,这是插件经常使用的模式。这样能够避免影响到外部做用域
path.traverse({
Identifier(path) {
if (path.node.name === name) {
path.replaceWith(t.identifier('a'))
}
}
})
},
})
console.log(generate(ast).code)
// function add(a, bar) {
// console.log(a, b);
// return a + bar;
// }
复制代码
🤯慢着,好像没那么简单,替换成 a
以后, console.log(a, b)
的行为就被破坏了。因此这里不能用 a
,得换个标识符, 譬如c
.
这就是转换器须要考虑的做用域问题,AST 转换的前提是保证程序的正确性。 咱们在添加和修改引用
时,须要确保与现有的全部引用不冲突。Babel自己不能检测这类异常,只能依靠插件开发者谨慎处理。
Javascript采用的是词法做用域, 也就是根据源代码的词法结构来肯定做用域:
在词法区块(block)中,因为新建变量、函数、类、函数参数等建立的标识符,都属于这个区块做用域. 这些标识符也称为绑定(Binding),而对这些绑定的使用称为引用(Reference)
在Babel中,使用Scope
对象来表示做用域。 咱们能够经过Path对象的scope
字段来获取当前节点的Scope
对象。它的结构以下:
{
path: NodePath;
block: Node; // 所属的词法区块节点, 例如函数节点、条件语句节点
parentBlock: Node; // 所属的父级词法区块节点
parent: Scope; // ⚛️指向父做用域
bindings: { [name: string]: Binding; }; // ⚛️ 该做用域下面的全部绑定(即该做用域建立的标识符)
}
复制代码
Scope
对象和 Path
对象差很少,它包含了做用域之间的关联关系(经过parent指向父做用域),收集了做用域下面的全部绑定(bindings), 另外还提供了丰富的方法来对做用域仅限操做。
咱们能够经过bindings
属性获取当前做用域下的全部绑定(即标识符),每一个绑定由Binding
类来表示:
export class Binding {
identifier: t.Identifier;
scope: Scope;
path: NodePath;
kind: "var" | "let" | "const" | "module";
referenced: boolean;
references: number; // 被引用的数量
referencePaths: NodePath[]; // ⚛️获取全部应用该标识符的节点路径
constant: boolean; // 是不是常量
constantViolations: NodePath[];
}
复制代码
经过Binding
对象咱们能够肯定标识符被引用的状况。
Ok,有了 Scope
和 Binding
, 如今有能力实现安全的变量重命名转换了。 为了更好地展现做用域交互,在上面代码的基础上,咱们再增长一下难度:
const a = 1, b = 2
function add(foo, bar) {
console.log(a, b)
return () => {
const a = '1' // 新增了一个变量声明
return a + (foo + bar)
}
}
复制代码
如今你要重命名函数参数 foo
, 不只要考虑外部的做用域
, 也要考虑下级做用域
的绑定状况,确保这二者都不冲突。
上面的代码做用域和标识符引用状况以下图所示:
来吧,接受挑战,试着将函数的第一个参数从新命名为更短的标识符:
// 用于获取惟一的标识符
const getUid = () => {
let uid = 0
return () => `_${(uid++) || ''}`
}
const ast = babel.parseSync(code)
traverse(ast, {
FunctionDeclaration(path) {
// 获取第一个参数
const firstParam = path.get('params.0')
if (firstParam == null) {
return
}
const currentName = firstParam.node.name
const currentBinding = path.scope.getBinding(currentName)
const gid = getUid()
let sname
// 循环找出没有被占用的变量名
while(true) {
sname = gid()
// 1️⃣首先看一下父做用域是否已定义了该变量
if (path.scope.parentHasBinding(sname)) {
continue
}
// 2️⃣ 检查当前做用域是否认义了变量
if (path.scope.hasOwnBinding(sname)) {
// 已占用
continue
}
// 再检查第一个参数的当前的引用状况,
// 若是它所在的做用域定义了同名的变量,咱们也得放弃
if (currentBinding.references > 0) {
let findIt = false
for (const refNode of currentBinding.referencePaths) {
if (refNode.scope !== path.scope && refNode.scope.hasBinding(sname)) {
findIt = true
break
}
}
if (findIt) {
continue
}
}
break
}
// 开始替换掉
const i = t.identifier(sname)
currentBinding.referencePaths.forEach(p => p.replaceWith(i))
firstParam.replaceWith(i)
},
})
console.log(generate(ast).code)
// const a = 1,
// b = 2;
// function add(_, bar) {
// console.log(a, b);
// return () => {
// const a = '1'; // 新增了一个变量声明
// return a + (_ + bar);
// };
// }
复制代码
上面的例子虽然没有什么实用性,并且还有Bug(没考虑label
),可是正好能够揭示了做用域处理的复杂性。
Babel的 Scope
对象其实提供了一个generateUid
方法来生成惟一的、不冲突的标识符。咱们利用这个方法再简化一下咱们的代码:
traverse(ast, {
FunctionDeclaration(path) {
const firstParam = path.get('params.0')
if (firstParam == null) {
return
}
let i = path.scope.generateUidIdentifier('_') // 也可使用generateUid
const currentBinding = path.scope.getBinding(firstParam.node.name)
currentBinding.referencePaths.forEach(p => p.replaceWith(i))
firstParam.replaceWith(i)
},
})
复制代码
能不能再短点!
traverse(ast, {
FunctionDeclaration(path) {
const firstParam = path.get('params.0')
if (firstParam == null) {
return
}
let i = path.scope.generateUid('_') // 也可使用generateUid
path.scope.rename(firstParam.node.name, i)
},
})
复制代码
generateUid(name: string = "temp") {
name = t
.toIdentifier(name)
.replace(/^_+/, "")
.replace(/[0-9]+$/g, "");
let uid;
let i = 0;
do {
uid = this._generateUid(name, i);
i++;
} while (
this.hasLabel(uid) ||
this.hasBinding(uid) ||
this.hasGlobal(uid) ||
this.hasReference(uid)
);
const program = this.getProgramParent();
program.references[uid] = true;
program.uids[uid] = true;
return uid;
}
复制代码
很是简洁哈?做用域操做最典型的场景是代码压缩,代码压缩会对变量名、函数名等进行压缩... 然而实际上不多的插件场景须要跟做用域进行复杂的交互,因此关于做用域这一块就先讲到这里。
等等别走,还没完呢,这才到2/3。学了上面得了知识,总得写一个玩具插件试试水吧?
如今打算模仿babel-plugin-import, 写一个极简版插件,来实现模块的按需导入. 在这个插件中,咱们会将相似这样的导入语句:
import {A, B, C as D} from 'foo'
复制代码
转换为:
import A from 'foo/A'
import 'foo/A/style.css'
import B from 'foo/B'
import 'foo/B/style.css'
import D from 'foo/C'
import 'foo/C/style.css'
复制代码
首先经过 AST Explorer 看一下导入语句的 AST 节点结构:
经过上面展现的结果,咱们须要处理 ImportDeclaration
节点类型,将它的specifiers
拿出来遍历处理一下。另外若是用户使用了默认导入
语句,咱们将抛出错误,提醒用户不能使用默认导入.
基本实现以下:
// 要识别的模块
const MODULE = 'foo'
traverse(ast, {
// 访问导入语句
ImportDeclaration(path) {
if (path.node.source.value !== MODULE) {
return
}
// 若是是空导入则直接删除掉
const specs = path.node.specifiers
if (specs.length === 0) {
path.remove()
return
}
// 判断是否包含了默认导入和命名空间导入
if (specs.some(i => t.isImportDefaultSpecifier(i) || t.isImportNamespaceSpecifier(i))) {
// 抛出错误,Babel会展现出错的代码帧
throw path.buildCodeFrameError("不能使用默认导入或命名空间导入")
}
// 转换命名导入
const imports = []
for (const spec of specs) {
const named = MODULE + '/' + spec.imported.name
const local = spec.local
imports.push(t.importDeclaration([t.importDefaultSpecifier(local)], t.stringLiteral(named)))
imports.push(t.importDeclaration([], t.stringLiteral(`${named}/style.css`)))
}
// 替换原有的导入语句
path.replaceWithMultiple(imports)
}
})
复制代码
逻辑还算简单,babel-plugin-import
可比这复杂得多。
接下来,咱们将它封装成标准的 Babel 插件。 按照规范,咱们须要建立一个babel-plugin-*
前缀的包名:
mkdir babel-plugin-toy-import
cd babel-plugin-toy-import
yarn init -y
touch index.js
复制代码
你也能够经过 generator-babel-plugin 来生成项目模板.
在 index.js
文件中填入咱们的代码。index.js
默认导出一个函数,函数结构以下:
// 接受一个 babel-core 对象
export default function(babel) {
const {types: t} = babel
return {
pre(state) {
// 前置操做,可选,能够用于准备一些资源
},
visitor: {
// 咱们的访问者代码将放在这里
ImportDeclaration(path, state) {
// ...
}
},
post(state) {
// 后置操做,可选
}
}
}
复制代码
咱们能够从访问器方法的第二个参数state
中获取用户传入的参数。假设用户配置为:
{
plugins: [['toy-plugin', {name: 'foo'}]]
}
复制代码
咱们能够这样获取用户传入的参数:
export default function(babel) {
const {types: t} = babel
return {
visitor: {
ImportDeclaration(path, state) {
const mod = state.opts && state.opts.name
if (mod == null) {
return
}
// ...
}
},
}
}
复制代码
打完收工 🙏,发布!
yarn publish # good luck
复制代码
新世界的大门已经打开: ⛩
本文主要介绍了 Babel 的架构和原理,还实践了一下 Babel 插件开发,读到这里,你算是入了 Babel 的门了.
接下来你能够去熟读Babel手册, 这是目前最好的教程, ASTExplorer是最好的演练场,多写代码多思考。 你也能够去看Babel的官方插件实现, 迈向更高的台阶。
本文还有下篇,我将在下篇文章中介绍babel-plugin-macros, 敬请期待!
点赞是对我最好鼓励。