放假了,我还在利用碎片时间在写文章,不知道长假还有没有人看,试试水吧!
这个文章系列将带大家深入浅出 Babel, 这个系列将分为上下两篇:上篇主要介绍 Babel 的架构和原理,顺便实践一下插件开发的;下篇会介绍 `babel-plugin-macros , 利用它来写属于 Javascript 的’宏‘。
✨满满的干货,不容错过哦. 写文不易,点赞是最大的鼓励。
注意: 本文不是 Babel 的基础使用教程!如果你对 Babel 尚不了解,请查看官方网站, 或者这个用户手册
文章大纲
- Babel 的处理流程
- Babel 的架构
- 访问者模式
- 节点的遍历
- 节点的上下文
- 副作用的处理
- 作用域的处理
- 搞一个插件呗
- 最后
- 扩展
Babel 的处理流程
Babel 的处理流程
上图是 Babel 的处理流程, 如果读者学习过编译器原理,这个过程就相当亲切了.
首先从源码 解析(Parsing) 开始,解析包含了两个步骤:
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 的架构
我在《透过现象看本质: 常见的前端架构风格和案例?》 提及 Babel 和 Webpack 为了适应复杂的定制需求和频繁的功能变化,都使用了微内核 的架构风格。也就是说它们的核心非常小,大部分功能都是通过插件扩展实现的。
所以简单地了解一下 Babel 的架构和一些基本概念,对后续文章内容的理解, 以及Babel的使用还是有帮助的。
一图胜千言。仔细读过我文章的朋友会发现,我的风格就是能用图片说明的就不用文字、能用文字的就不用代码。虽然我的原创文章篇幅都很长,图片还是值得看看的。
Babel 是一个 MonoRepo 项目, 不过组织非常清晰,下面就源码上我们能看到的模块进行一下分类, 配合上面的架构图让你对Babel有个大概的认识:
1️⃣ 核心:
@babel/core 这也是上面说的‘微内核’架构中的‘内核’。对于Babel来说,这个内核主要干这些事情:
- 加载和处理配置(config)
- 加载插件
- 调用 Parser 进行语法解析,生成 AST
- 调用 Traverser 遍历AST,并使用访问者模式应用'插件'对 AST 进行转换
- 生成代码,包括SourceMap转换和源代码生成
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)方法。
节点的遍历
假设我们的代码如下:
代码语言:javascript复制
代码语言:javascript复制br
代码语言:javascript复制
解析后的 AST 结构如下:
代码语言:javascript复制br
代码语言:javascript复制复制代码
访问者会以深度优先的顺序, 或者说递归地对 AST 进行遍历,其调用顺序如下图所示:
上图中绿线表示进入该节点,红线表示离开该节点。下面写一个超简单的'具体访问者'来还原上面的遍历过程:
代码语言:javascript复制
代码语言:javascript复制br
代码语言:javascript复制
查看代码执行结果
当访问者进入一个节点时就会调用 enter(进入) 方法,反之离开该节点时会调用 exit(离开) 方法。一般情况下,插件不会直接使用enter方法,只会关注少数几个节点类型,所以具体访问者也可以这样声明访问方法:
代码语言:javascript复制br
代码语言:javascript复制
那么 Babel 插件是怎么被应用的呢?
Babel 会按照插件定义的顺序来应用访问方法,比如你注册了多个插件,babel-core 最后传递给访问器的数据结构大概长这样:
代码语言:javascript复制br
当进入一个节点时,这些插件会按照注册的顺序被执行。大部分插件是不需要开发者关心定义的顺序的,有少数的情况需要稍微注意下,例如
plugin-proposal-decorators:
代码语言:javascript复制br
代码语言:javascript复制复制代码
所有插件定义的顺序,按照惯例,应该是新的或者说实验性的插件在前面,老的插件定义在后面。因为可能需要新的插件将 AST 转换后,老的插件才能识别语法(向后兼容)。下面是官方配置例子, 为了确保先后兼容,stage-*阶段的插件先执行:
代码语言:javascript复制br
代码语言:javascript复制
注意Preset的执行顺序相反,详见官方文档
节点的上下文
访问者在访问一个节点时, 会无差别地调用 enter 方法,我们怎么知道这个节点在什么位置以及和其他节点的关联关系呢?
通过上面的代码,读者应该可以猜出几分,每个visit方法都接收一个 Path 对象, 你可以将它当做一个‘上下文’对象,类似于JQuery的 JQuery(const $el = $('.el')) 对象,这里面包含了很多信息:
- 当前节点信息
- 节点的关联信息。父节点、子节点、兄弟节点等等
- 作用域信息
- 上下文信息
- 节点操作方法。节点增删查改
- 断言方法。isXXX, assertXXX
下面是它的主要结构:
代码语言:javascript复制
代码语言:javascript复制br
代码语言:javascript复制
你可以通过这个手册来学习怎么通过 Path 来转换 AST. 后面也会有代码示例,这里就不展开细节了
副作用的处理
实际上访问者的工作比我们想象的要复杂的多,上面示范的是静态 AST 的遍历过程。而 AST 转换本身是有副作用的,比如插件将旧的节点替换了,那么访问者就没有必要再向下访问旧节点了,而是继续访问新的节点, 代码如下。
代码语言:javascript复制br
上面的代码, 将console.log('hello' v '!')语句替换为return "hello" v;, 下图是遍历的过程:
我们可以对 AST 进行任意的操作,比如删除父节点的兄弟节点、删除第一个子节点、新增兄弟节点... 当这些操作'污染'了 AST 树后,访问者需要记录这些状态,响应式(Reactive)更新 Path 对象的关联关系, 保证正确的遍历顺序,从而获得正确的转译结果。
作用域的处理
访问者可以确保正确地遍历和修改节点,但是对于转换器来说,另一个比较棘手的是对作用域的处理,这个责任落在了插件开发者的头上。插件开发者必须非常谨慎地处理作用域,不能破坏现有代码的执行逻辑。
代码语言:javascript复制
代码语言:javascript复制br
代码语言:javascript复制
比如你要将 add 函数的第一个参数 foo 标识符修改为a, 你就需要递归遍历子树,查出foo标识符的所有引用, 然后替换它:
代码语言:javascript复制br
代码语言:javascript复制
?慢着,好像没那么简单,替换成 a 之后, console.log(a, b) 的行为就被破坏了。所以这里不能用 a,得换个标识符, 譬如c.
这就是转换器需要考虑的作用域问题,AST 转换的前提是保证程序的正确性。我们在添加和修改引用时,需要确保与现有的所有引用不冲突。Babel本身不能检测这类异常,只能依靠插件开发者谨慎处理。
Javascript采用的是词法作用域, 也就是根据源代码的词法结构来确定作用域:
在词法区块(block)中,由于新建变量、函数、类、函数参数等创建的标识符,都属于这个区块作用域. 这些标识符也称为绑定(Binding),而对这些绑定的使用称为引用(Reference)
在Babel中,使用Scope对象来表示作用域。我们可以通过Path对象的scope字段来获取当前节点的Scope对象。它的结构如下:
代码语言:javascript复制br
代码语言:javascript复制
Scope 对象和 Path 对象差不多,它包含了作用域之间的关联关系(通过parent指向父作用域),收集了作用域下面的所有绑定(bindings), 另外还提供了丰富的方法来对作用域仅限操作。
我们可以通过bindings属性获取当前作用域下的所有绑定(即标识符),每个绑定由Binding类来表示:
代码语言:javascript复制
代码语言:javascript复制br
代码语言:javascript复制复制代码
通过Binding对象我们可以确定标识符被引用的情况。
Ok,有了 Scope 和 Binding, 现在有能力实现安全的变量重命名转换了。为了更好地展示作用域交互,在上面代码的基础上,我们再增加一下难度:
代码语言:javascript复制
代码语言:javascript复制br
代码语言:javascript复制
现在你要重命名函数参数 foo, 不仅要考虑外部的作用域, 也要考虑下级作用域的绑定情况,确保这两者都不冲突。
上面的代码作用域和标识符引用情况如下图所示:
来吧,接受挑战,试着将函数的第一个参数重新命名为更短的标识符:
代码语言:javascript复制// 用于获取唯一的标识符
上面的例子虽然没有什么实用性,而且还有Bug(没考虑label),但是正好可以揭示了作用域处理的复杂性。
Babel的 Scope 对象其实提供了一个generateUid方法来生成唯一的、不冲突的标识符。我们利用这个方法再简化一下我们的代码:
代码语言:javascript复制br
代码语言:javascript复制
能不能再短点!
代码语言:javascript复制br
代码语言:javascript复制
查看generateUid的实现代码
非常简洁哈?作用域操作最典型的场景是代码压缩,代码压缩会对变量名、函数名等进行压缩... 然而实际上很少的插件场景需要跟作用域进行复杂的交互,所以关于作用域这一块就先讲到这里。
搞一个插件呗
等等别走,还没完呢,这才到2/3。
学了上面的知识,总得写一个玩具插件试试水吧?
现在打算模仿babel-plugin-import, 写一个极简版插件,来实现模块的按需导入. 在这个插件中,我们会将类似这样的导入语句:
代码语言:javascript复制
代码语言:javascript复制br
代码语言:javascript复制
转换为:
代码语言:javascript复制br
首先通过 AST Explorer 看一下导入语句的 AST 节点结构:
通过上面展示的结果,我们需要处理 ImportDeclaration 节点类型,将它的specifiers拿出来遍历处理一下。另外如果用户使用了默认导入语句,我们将抛出错误,提醒用户不能使用默认导入.
基本实现如下:
代码语言:javascript复制br
代码语言:javascript复制
逻辑还算简单,babel-plugin-import可比这复杂得多。
接下来,我们将它封装成标准的 Babel 插件。按照规范,我们需要创建一个babel-plugin-*前缀的包名:
代码语言:javascript复制br
你也可以通过 generator-babel-plugin 来生成项目模板.
在 index.js 文件中填入我们的代码。index.js默认导出一个函数,函数结构如下:
代码语言:javascript复制
代码语言:javascript复制br
代码语言:javascript复制
我们可以从访问器方法的第二个参数state中获取用户传入的参数。假设用户配置为:
代码语言:javascript复制br
代码语言:javascript复制
我们可以这样获取用户传入的参数:
代码语言:javascript复制
代码语言:javascript复制br
代码语言:javascript复制
打完收工 ?,发布!
代码语言:javascript复制
最后
新世界的大门已经打开: ⛩
本文主要介绍了 Babel 的架构和原理,还实践了一下 Babel 插件开发,读到这里,你算是入了 Babel 的门了.
接下来你可以去熟读Babel手册, 这是目前最好的教程,ASTExplorer是最好的演练场,多写代码多思考。你也可以去看Babel的官方插件实现, 迈向更高的台阶。
本文还有下篇,我将在下篇文章中介绍 babel-plugin-macros, 敬请期待!
点赞是对我最好鼓励。