前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >[第9期] 深入浅出 Babel 上篇:架构和原理 + 实战

[第9期] 深入浅出 Babel 上篇:架构和原理 + 实战

作者头像
皮小蛋
发布2020-03-03 19:20:23
1.1K0
发布2020-03-03 19:20:23
举报
文章被收录于专栏:前端皮小蛋前端皮小蛋

放假了,我还在利用碎片时间在写文章,不知道长假还有没有人看,试试水吧!

这个文章系列将带大家深入浅出 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, 敬请期待!

点赞是对我最好鼓励。

本文参与?腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2019-10-05,如有侵权请联系?cloudcommunity@tencent.com 删除

本文分享自 前端皮小蛋 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与?腾讯云自媒体分享计划? ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • Babel 的处理流程
  • Babel 的架构
  • 访问者模式
    • 节点的遍历
      • 节点的上下文
        • 副作用的处理
          • 作用域的处理
          • 搞一个插件呗
          • 最后
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
          http://www.vxiaotou.com