# Babel 插件

babel的插件有两种,一种是语法插件,这类插件是在解析阶段辅助解析器工作;另一类插件是转译插件,这类插件是在转换阶段参与进行代码的转译工作,这也是我们使用babel最常见也最本质的需求

# 插件原理

为了开发babel插件,我们必须先要了解babel的插件设计模式,想象一下,Babel 有那么多插件,如果每个插件自己去遍历AST,对不同的节点进行不同的操作,维护自己的状态。这样子不仅低效,它们的逻辑分散在各处,会让整个系统变得难以理解和调试, 最后插件之间关系就纠缠不清,乱成一锅粥,所以转换器操作 AST 一般都是使用访问器模式。同理,babel插件设计也采用访问者模式

# 访问者模式

由一个访问者(Visitor)来进行统一的遍历操作,提供节点的操作方法,响应式维护节点之间的关系; 而插件(设计模式中称为"具体访问者")只需要定义自己感兴趣的节点类型,当访问者访问到对应节点时,就调用插件的访问(visit)方法

Babel在遍历时以深度优先的顺序,或者说递归地对 AST 进行遍历。

# Visitor 节点的遍历

当Babel处理一个节点时,是以访问者的形式获取节点信息,并进行相关操作,这种方式是通过一个visitor对象来完成的,在visitor对象中定义了对于各种节点的访问函数,这样就可以针对不同的节点做出不同的处理。我们编写的Babel插件其实也是通过定义一个实例化visitor对象处理一系列的AST节点来完成我们对代码的修改操作

我们想要处理代码中用来加载模块的import命令语句

import { Ajax } from '../lib/utils';

那么我们的Babel插件就需要定义这样的一个visitor对象:

visitor: {
  Program: {
    enter(path, state) {
      console.log('start processing this module...');
    },
    exit(path, state) {
      console.log('end processing this module!');
    }
  },
  ImportDeclaration(path, state) {
    console.log('processing ImportDeclaration...');
    // do something
  }
}

当把这个插件用于遍历中时,每当处理到一个import语句,即ImportDeclaration节点时,都会自动调用ImportDeclaration()方法,这个方法中定义了处理import语句的具体操作。ImportDeclaration()都是在进入enter()ImportDeclaration节点时调用的,我们也可以让插件在退出节点时调用exit()方法进行处理

visitor: {
  ImportDeclaration: {
    enter(path, state) {
      console.log('start processing ImportDeclaration...');
      // do something
    },
    exit(path, state) {
      console.log('end processing ImportDeclaration!');
      // do something
    }
  },
}

当进入ImportDeclaration节点时调用enter()方法,退出ImportDeclaration节点时调用exit()方法。上面的Program节点(Program节点可以通俗地解释为一个模块节点)也是一样的道理。值得注意的是,AST的遍历采用深度优先遍历,所以上述import代码块的AST遍历的过程如下

─ Program.enter() 
  ─ ImportDeclaration.enter()
  ─ ImportDeclaration.exit()
─ Program.exit() 

同理还有其他的节点定义:

traverse(ast, {
  // 访问标识符
  Identifier(path) {
    console.log(`enter Identifier`)
  },
  // 访问调用表达式
  CallExpression(path) {
    console.log(`enter CallExpression`)
  },
  // 上面是enter的简写,如果要处理exit,也可以这样
  // 二元操作符
  BinaryExpression: {
    enter(path) {},
    exit(path) {},
  },
  // 更高级的, 使用同一个方法访问多种类型的节点
  "ExportNamedDeclaration|Flow"(path) {}
})

有关AST中各种节点类型的定义:

# Path 节点的上下文

从上面的visitor对象中,可以看到每次访问节点方法时,都会传入一个path参数,这个path参数中包含了节点的信息以及节点和所在的位置,以供对特定节点进行操作。具体来说Path 是表示两个节点之间连接的对象。这个对象不仅包含了当前节点的信息,也有当前节点的父节点的信息,同时也包含了添加、更新、移动和删除节点有关的其他很多方法。具体地,Path对象包含的属性和方法主要如下:

── 属性      
  - node   当前节点
  - parent  父节点
  - parentPath 父path
  - scope   作用域
  - context  上下文
  - ...
── 方法
  - get   当前节点
  - findParent  向父节点搜寻节点
  - getSibling 获取兄弟节点
  - replaceWith  用AST节点替换该节点
  - replaceWithMultiple 用多个AST节点替换该节点
  - insertBefore  在节点前插入节点
  - insertAfter 在节点后插入节点
  - remove   删除节点
  - ...

这里我们继续上面的例子,看看path参数的node属性包含哪些信息:

visitor: {
  ImportDeclaration(path, state) {
    console.log(path.node);
    // do something
  }
}

打印结果如下

{
  type: 'ImportDeclaration',
  start: 5,
  end: 41,
  loc: SourceLocation {
    start: Position { line: 2, column: 4 },
    end: Position { line: 2, column: 40 }
  },
  specifiers: [
    Node {
      type: 'ImportSpecifier',
      start: 14,
      end: 18,
      loc: [SourceLocation],
      imported: [Node],
      local: [Node]
    }
  ],
  source: Node {
    type: 'StringLiteral',
    start: 26,
    end: 40,
    loc: SourceLocation { 
      start: [Position], 
      end: [Position] 
    },
    extra: { rawValue: '../lib/utils', raw: '\'../lib/utils\'' },
    value: '../lib/utils'
  }
}

可以发现除了type、start、end、loc这些常规字段,ImportDeclaration节点还有specifiers和source这两个特殊字段,specifiers表示import导入的变量组成的节点数组,source表示导出模块的来源节点。这里再说一下specifier中的imported和local字段,imported表示从导出模块导出的变量,local表示导入后当前模块的变量,还是有点费解,我们把import命令语句修改一下

import { Ajax as ajax } from '../lib/utils';

然后继续打印specifiers第一个元素的local和imported字段:

Node {
  type: 'Identifier',
  start: 22,
  end: 26,
  loc: SourceLocation {
     start: Position { line: 2, column: 21 },
     end: Position { line: 2, column: 25 },
     identifierName: 'ajax' },
     name: 'ajax' 
  }

Node {
  type: 'Identifier',
  start: 14,
  end: 18,
  loc: SourceLocation {
     start: Position { line: 2, column: 13 },
     end: Position { line: 2, column: 17 },
     identifierName: 'Ajax' },
     name: 'Ajax' 
  }

这样就很明显了。如果不使用as关键字,那么imported和local就是表示同一个变量的节点了。

# State

State是visitor对象中每次访问节点方法时传入的第二个参数。如果看Babel手册里的解释,可能还是有点困惑,简单来说,state就是一系列状态的集合,包含诸如当前plugin的信息、plugin传入的配置参数信息,甚至当前节点的path信息也能获取到,当然也可以把babel插件处理过程中的自定义状态存储到state对象中

# Scopes(作用域)

这里的作用域其实跟js说的作用域是一个道理,也就是说babel在处理AST时也需要考虑作用域的问题,比如函数内外的同名变量需要区分开来,这里直接拿Babel手册里的一个例子解释一下。考虑下列代码

function square(n) {
  return n * n;
}

我们来写一个把 n 重命名为 x 的visitor。

visitor: {
  FunctionDeclaration(path) {
    const param = path.node.params[0];
    paramName = param.name;
    param.name = "x";
  },

  Identifier(path) {
    if (path.node.name === paramName) {
      path.node.name = "x";
    }
  }
}

对上面的例子代码这段访问者代码也许能工作,但它很容易被打破

function square(n) {
  return n * n;
}
var n = 1;

上面的visitor会把函数square外的n变量替换成x,这显然不是我们期望的。更好的处理方式是使用递归,把一个访问者放进另外一个访问者里面

visitor: {
  FunctionDeclaration(path) {
    const updateParamNameVisitor = {
      Identifier(path) {
        if (path.node.name === this.paramName) {
          path.node.name = "x";
        }
      }
    };
    const param = path.node.params[0];
    paramName = param.name;
    param.name = "x";
    path.traverse(updateParamNameVisitor, { paramName });
  },
}

# 副作用

AST 转换本身是有副作用的,比如插件将旧的节点替换了,那么访问者就没有必要再向下访问旧节点了,而是继续访问新的节点, 我们可以对 AST 进行任意的操作,比如删除父节点的兄弟节点、删除第一个子节点、新增兄弟节点,当这些操作'污染'了 AST 树后,访问者需要记录这些状态,响应式(Reactive)更新 Path 对象的关联关系, 保证正确的遍历顺序,从而获得正确的转译结果。

# 插件顺序

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的执行顺序相反(从后向前),详见官方文档 (opens new window)

  • Plugins run before Presets. //插件在Presets运行前执行
  • Plugin ordering is first to last. //插件按定义顺序执行
  • Preset ordering is reversed (last to first). //Preset 按定义顺序反向执行

# 插件开发

陕ICP备20004732号-3