专栏名称: SegmentFault思否
SegmentFault (www.sf.gg)开发者社区,是中国年轻开发者喜爱的极客社区,我们为开发者提供最纯粹的技术交流和分享平台。
目录
相关文章推荐
OSC开源社区  ·  RAG市场的2024:随需而变,从狂热到理性 ·  昨天  
程序猿  ·  41岁DeepMind天才科学家去世:长期受 ... ·  2 天前  
程序员小灰  ·  清华大学《DeepSeek学习手册》(全5册) ·  3 天前  
程序员小灰  ·  3个令人惊艳的DeepSeek项目,诞生了! ·  2 天前  
程序员的那些事  ·  惊!小偷“零元购”后竟向 DeepSeek ... ·  4 天前  
51好读  ›  专栏  ›  SegmentFault思否

探索 babel 和 babel 插件是怎么工作的

SegmentFault思否  · 公众号  · 程序员  · 2018-02-28 08:00

正文

你有可能会听到过这个词 webpack工程师 ,这个看似像是一个专业很强的职位其实很多时候是一些前端对现在前端工作方式对一些吐槽,对于一个之前没有接触过 webpack nodejs babel 之类的工具的人来说,看到大量的配置文件后很多人都会看懵。

很多人就干脆不管这些东西,直接上手写业务代码,把这些构建工具就相当于 黑科技 ,我们把所有的文件都经过这些工具最终生成一个或者几个打包后的文件,其中关于优化和代码转换问题其实一大部分都是在这些配置里面的。如果我们不去了解其中的一部分原理,后面遇到很多问题( 如打包后文件体积过大 )时候都是束手无策,而且万一哪天构建工具出现问题时候可能连工作都开展不下去了。

既然我们日常都要用到,最好的方式就是去研究一下这些工具的原理的作用,让这些工具成为我们手中的 利器 ,而不是工作上的 绊脚石 ,而且这些工具的设计者都是顶级的工程师,当你敲开壁垒探究内部秘密时候,我相信你会感受到其中的编程之美。

这里我们去探索一下 babel 的原理。

babel 是什么?

Babel · The compiler for writing next generation JavaScript

6to5

你在 npm 上可以看到这样一个包名字是6to5(https://www.npmjs.com/package/6to5),光看名字可能会让人感觉到很诧异,名字看起来可能有点奇怪,其实 babel 在开始的时候名字就是这个。简单粗暴 es6->es5 ,一下子就看懂了 babel 是用来干啥的,但是很明显这不是一个好名字,这个名字会让人感觉到 es6 普及之后这个库就没用了,为了保持活力这个库可能要不停的修改名字。下面是 babel 作者一次分享中假设如果按这个命名法则可能出现的名称。

很明显发生这种情况是很不合理的,团队内部经过大量讨论后,最终选择了 babel ,这与电影 银河系漫游指南 中的Babel fish相应,也有关系到圣经中的一个故事Tower of Babel(https://en.wikipedia.org/wiki/Tower of Babel)。(ps.优秀的人总是也很有情怀。)

babel is the new jQuery

redux 的作者曾说过这样一句话,可以换一种理解为

  1. babel : AST :: jQuery : DOM

babel 对于 AST 就相当于 jQuery 对于 DOM , 就是说 babel 给予了我们便捷查询和修改 AST 的能力。 AST->AbstractSyntaxTree 抽象语法树 后面会讲到。

为什么要用babel转换代码

我们之前做一些兼容都会都会接触一些 Polyfill 的概念,比如如果某个版本的浏览器不支持 Array.prototype.find 方法,但是我们的代码中有用到 Array find 函数,为了支持这些代码,我们会人为的加一些兼容代码。

  1. if (!Array.prototype.find) {

  2.  Object.defineProperty(Array.prototype, 'find', {

  3.      // 实现代码

  4.      ...

  5.  });

  6. }

对于这种情况做兼容也很好实现,引入一个 Polyfill 文件就可以了,但是有一些情况我们使用到了一些新语法,或者一些其他写法:

  1. // 箭头函数

  2. var a = () => {}

  3. // jsx

  4. var Component = () =>

这种情况靠 Polyfill ,因为一些浏览器根本就不识别这些代码,这时候就需要把这些代码转换成浏览器识别的代码。 babel 就是做这个事情的。

babel做了哪些事情

为了转换我们的代码, babel 做了三件事:

  • Parser 解析我们的代码转换为 AST

  • Transformer 利用我们配置好的 plugins/presets Parser 生成的 AST 转变为新的 AST

  • Generator 把转换后的 AST 生成新的代码

从图上看 Transformer 占了很大一块比重,这个转换过程就是 babel 中最复杂的部分,我们平时配置的 plugins/presets 就是在这个模块起作用。

从简单的说起

可以看到要想搞懂 babel , 就是去了解上面三个步骤都是在干什么,我们先把比较容易看懂的地方开始了解一下。

Parser 解析

解析步骤接收代码并输出 AST ,这其中又包含两个阶段 词法分析 语法分析 。词法分析阶段把字符串形式的代码转换为 令牌(tokens) 流。语法分析阶段会把一个令牌流转换成 AST 的形式,方便后续操作。

Generator 生成

代码生成步骤把最终(经过一系列转换之后)的 AST 转换成字符串形式的代码,同时还会创建源码映射(source maps)。代码生成其实很简单:深度优先遍历整个 AST,然后构建可以表示转换后代码的字符串。

babel的核心内容

看起来 babel 的主要工作都集中在把解析生成的 AST 经过 plugins/presets 然后去生成 新的AST 这上面了。

AST抽象语法树

我们一直在提到 AST 它究竟是什么呢,既然它的名字叫做 抽象语法树 ,我们可以想象一下如果把我们的程序用树状表示会是什么样呢。

  1. var a = 1 + 1

  2. var b = 2 + 2

我们想象一下要表示上述代码应该是什么样子,首先必须有东西可以表示这些具体的 声明 , 变量 , 常量 的具体信息,比如 (这棵树上肯定有二个变量,变量名是a和b,肯定有两个运算语句,操作符是+) ,有了这些信息还不够,我们必须建立起它们之间的关系,比如 一个声明语句,声明类型是var,左侧是变量,右侧是表达式 。有了这些信息我们就可以还原这个程序,这也是把代码解析成 AST 时候所做的事情,对应上面我们说的 词法分析 语法分析

AST 中我们用 node (节点)来表示各个代码片段,比如我们上面程序整体就是一个节点 Program 节点 (所有的 AST 根节点都是 Program 节点) ,因为它下面有两条语句所以它的 body 属性上就两个声明节点 VariableDeclaration 。所以上面程序的 AST 就类似这样。

可以看到在节点上用各个的属性去表示各种信息以及程序之间的关系,那这些节点每一个叫什么名字,都用哪些属性名呢?我们可以在说明文档(https://github.com/babel/babylon/blob/master/ast/spec.md#variabledeclaration)上找到这些说明。

关于接口

看这个文档时候我们可以看到说明大多是类似这种

  1. interface Node {

  2.  type: string;

  3.  loc: SourceLocation | null;

  4. }

这里提到 interface 这个我们在其他语言中是比较常见的,比如 Node 规定了 type loc 属性,如果其他节点继承自 Node ,那么它也会实现 type loc 属性就是说继承自 Node 的节点也会有这些属性,基本所有节点都继承自 Node ,所以我们基本可以看到 loc 这个属性 loc 表示个一些位置信息。

节点单位

我们程序很多地方都会被拆分成一个个的节点,节点里面也会套着其他的节点,我们在文档中可以看到 AST 结构的各个 Node 节点都很细微,比如我们声明函数,函数就是一个节点 FunctionDeclaration ,函数名和形参那么参数都是一个变量节点 Identifier 。生成的节点往往都很复杂,我们可以借助astexplorer来帮助我们分析 AST 结构。

图像展示

有了上面这些概念我们已经可以大概了解 AST 的概念,以及各个模块代表的含义,假设我们有这样一个程序,我们用图形简易的分析下它的结构:

  1. function square (n) {

  2.     return n * n

  3. }

节点遍历

经过一番努力我们终于了解了 AST 以及其中内容的含义,但是这一部分基本不需要我们做什么, babel 会借助Babylon帮我们生成我们需要的 AST 结构。我们更多要去做的是去修改和改变 Babylon 生成的这个抽象语法树。

babel 拿到抽象语法树后会使用 babel-traverse 进行递归的树状遍历,对于每一个节点都会向下遍历到尽头,然后向上遍历退出分支去寻找下一个分支。这样确保我们能找到任何一个节点,也就是能访问到我们代码的任何一个部分。可是我们要怎么去完成修改操作呢, babel 给我们提供了下面这两个概念。

visitor

我们已经知道 babel 会遍历节点组成的抽象语法树,每一个节点都会有自己对应的 type ,比如变量节点 Identifier 等。我们需要给 babel 提供一个 visitor 对象,在这个对象上面我们以这些节点的 type 做为 key ,已一个函数作为值,类似如下:

  1. const visitor = {

  2.    Identifier: {

  3.        enter() {

  4.              console.log('traverse enter a Identifier node!')

  5.        },

  6.        exit() {

  7.              console.log('traverse exit a Identifier node!')

  8.        }

  9.      }

  10. }

这样在遍历进入到对应到节点时候, babel 就会去执行对应的 enter 函数,向上遍历退出对应节点时候, babel 就会去执行对应的 exit 函数,接着上面的代码我们可以做一个测试。

  1. const babel = require('babel-core')

  2. const code = `var a = b + c + d`

  3. // 如果plugins是个函数则返回的对象要有visitor属性,如果是个对象则直接定义visitor属性

  4. const MyVisitor = {

  5.  visitor

  6. }

  7. babel.transform(code, {

  8.  plugins: [MyVisitor]

  9. })

我们执行对应代码可以看到上面 enter exit 函数分别执行了四次:

  1. traverse enter a Identifier node!

  2. traverse exit a Identifier node!  

  3. ... x4

从上面简单的代码上也可以看到 a,b,c,d 四个变量,它们应该属于同一级别的节点树上,所以遍历时候会分别进入对应节点然后退出再去下一个节点。

Paths

我们通过 visitor 可以在遍历到对应节点执行对应的函数,可是要修改对应节点的信息,我们还需要拿到对应节点的信息以及节点和所在的位置 (即和其他节点间的关系) , visitor 在遍历到对应节点执行对应函数时候会给我们传入 path 参数,辅助我们完成上面这些操作。注意 Path 是表示两个节点之间连接的对象,而不是当前节点,我们上面访问到了 Identifier 节点,它传入的 path 参数看起来是这样的:

  1. {

  2.  "parent": {

  3.    "type": "VariableDeclarator",

  4.    "id": {...},

  5.    ....

  6.  },

  7.  "node": {

  8.    "type": "Identifier",

  9.    "name": "..."

  10.  }

  11. }

从上面我们可以看到 path 表示两个节点之间的连接,通过这个对象我们可以访问到节点、父节点以及进行一系列跟节点操作相关的方法。我们修改一下上面的 visitor 函数

  1. const visitor = {

  2.    Identifier: {

  3.    enter(path) {

  4.      console.log('traverse enter a Identifier node the name is ' + path.node.name)

  5.    },

  6.    exit(path) {

  7.      console.log( 'traverse exit a Identifier node the name is ' + path.node.name)

  8.    }

  9.  }

  10. }

在执行一下上面的代码就可以看到 name 打印出来的依次是 a , b , c , d 。这样我们就有可以修改操作我们需要改变的节点了。另外 path 对象上还包含添加、更新、移动和删除节点有关的其他很多方法,我们可以通过文档去了解。

一些有用的工具

babel 为了方便我们开发,在每一个环节都有很多人性化的定义也提供了很多实用性的工具,比如之前我们在定义 visitor 时候分别定义了 enter , exit 函数,可很多时候我们其实只用到了一次在 enter 的时候做一些处理就行了。所以我们如果我们直接定义节点的 key 为函数,就相当于定义了 enter 函数。

  1. const visitor = {

  2.    Identifier(){

  3.        // dosmting

  4.    }

  5. }

  6. // 等同于 ↓ ↓ ↓ ↓ ↓ ↓

  7. const visitor = {

  8.    Identifier: {

  9.        enter() {

  10.             // dosmting

  11.        }

  12.    }

  13. }

上面我们还提到了plugins是函数的情况,其实我们写的差距一般都是一个函数,这个入口函数上 babel 也会穿入一个 babel-types ,这是一个用于 AST 节点的 Lodash 式工具库(类似 lodash 对于 js 的帮助), 它包含了构造、验证以及变换 AST 节点的方法。 该工具库包含考虑周到的工具方法,对编写处理AST逻辑非常有用。

实际运用

假如我们有如下代码:

  1. const a = 3 * 103.5 * 0.8

  2. log(a)

  3. const b = a + 105 - 12

  4. log(b)

我们发现这里把 console.log 简写成了 log ,为了让这些代码可以执行,我们现在用 babel 装置去转换一下这些代码。

改变log函数调用本身

既然是 console.log 没有写全,我们就改变这个 log 函数调用的地方,把每一个 log 替换成 console.log ,我们看一下 log(*) 属于函数执行语句,相对应的节点就是 CallExpression ,我们看下它的结构:

  1. interface CallExpression <:>Expression {

  2.  type: "CallExpression";

  3.  callee: Expression | Super | Import;

  4.  arguments: [ Expression | SpreadElement ];

  5.  optional: boolean | null;

  6. }

callee 是我们函数执行的名称, arguments 就是我们穿入的参数,参数我们不需要改变,只需要把函数名称改变就好了,之前的 callee 是一个变量,我们现在要把它变成一个表达式 (取对象属性值的表达式) ,我们看一下手册可以看到是一个 MemberExpression 类型的值,这里也可以借助之前提到的网站astexplorer(https://astexplorer.net/)来帮助我们分析。有了这些信息我们就可以去实现我们的目的了,我们这里手动引入一下 babel-types







请到「今天看啥」查看全文