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