Babel 插件是如何生效的

810 阅读12分钟

我们在如何写一个 Babel 插件 中,了解了 Babel 插件的写法。Babel 插件处理的对象是 AST,那 Babel 究竟是如何调用插件的,插件是如何生效的呢。在分析 Babel 调用插件的代码时,会顺带解决以下几个问题。

  1. Babel 插件的准备和收尾函数。
  2. 多个插件是如何合并成一个的。
  3. AST 遍历方式。
  4. 插件的访问者模式如何实现的。
  5. 插件的 path 对象是怎么来的。

Babel 插件调用

Babel 在完成 AST 编译后,会调用插件对 AST 做修改,调用的方法就是 transformFile。transformFile 方法的入参是用户配置的所有插件集合,该方法将插件调用分为了四个阶段。

  1. 遍历插件集合,执行插件的 pre 方法。
  2. 遍历插件集合,合并插件的 visitor 方法,输出是一个包含了所有插件逻辑的 visitor 方法。
  3. 执行第二步合成的 visitor 方法。
  4. 遍历插件集合,执行插件的 post 方法。

babel-core\src\transformation\index.js

function* transformFile(file: File, pluginPasses: PluginPasses): Handler<void> {
  for (const pluginPairs of pluginPasses) {
    ...

    // 执行插件的 pre 方法
    for (const [plugin, pass] of passPairs) {
      const fn = plugin.pre;
      if (fn) {
        const result = fn.call(pass, file);

        ...
      }
    }

    // 插件合并
    const visitor = traverse.visitors.merge(
      visitors,
      passes,
      file.opts.wrapPluginVisitorMethod,
    );
    // 执行插件 visitor 中定义的方法
    traverse(file.ast, visitor, file.scope);

    // 执行插件 post 方法
    for (const [plugin, pass] of passPairs) {
      const fn = plugin.post;
      if (fn) {
        const result = fn.call(pass, file);

        ...
      }
    }
  }
}

从这段代码中我们可以发现,Babel 插件遵循这样的一个执行顺序,pre -> visitor -> post。而且比较特殊的是,pre 和 post 是标准的方法,而 visitor 是个对象,这主要是由于 visitor 的特殊设计方法决定的。

pre 和 post 的入参只有 state,这也决定了这两个方法是无法改变 AST 结构的,pre 和 post 方法的用法就比较单一了,一般在 pre 中创建一些临时对象, post 中再将这些对象销毁掉。。

export default function({ types: t }) {
  return {
    pre(state) {
      this.cache = new Map();
    },
    visitor: {
      StringLiteral(path) {
        this.cache.set(path.node.value, 1);
      }
    },
    post(state) {
      console.log(this.cache);
    }
  };
}

Babel 插件集合在执行之前做合并,主要是为了提高后续的处理效率。Babel 的插件集合来自于 Babel 读取配置文件(.babelrc)中的 presets 和 plugins 配置项。

之前Babel 编译流程分析 文章中分析过,Babel 会读取配置文件(.babelrc)中的 presets 和 plugins 配置项,根据配置项,依次读取插件的代码,将其包装为一个数组,数组中的每一项都是一个插件对象,包括插件的名称,配置项,pre 和 post 生命周期方法,以及最重要的 visitor 对象,visitor 中定义了该插件遇到不同类型节点的处理方法。

我们如果在 .babelrc 中配置了 preset-env。那么会引入 40 个插件用于处理新语法。

"presets": ["@babel/preset-env"]

以下是插件集合的一部分,transform-block-scoping 插件用于转换 const 和 let, transform-arrow-functions 插件用于编译箭头函数。

[
  {
    key:"transform-block-scoping",
    options: {...},
    post: undefined,
    pre: undefined,
    visitor: {
      BlockStatement: {...},
      DoWhileStatement: {...},
      ForInStatement: {...},
      ForOfStatement: {...},
      VariableDeclaration: {...}
      ...
    }
  },
  {
    key:"transform-arrow-functions",
    options: {...},
    post: undefined,
    pre: undefined,
    visitor: {
      ArrowFunctionExpression: {...}
    }
  }
  ...
]

Babel 在 AST 的遍历过程中,会遇到不同类型的节点,不同的插件中可能会对同一种节点做处理,Babel 为了提升处理效率,会一次性获得插件对于该节点的处理逻辑,这也是 Babel 对插件的 visitor 对象做合并的原因。合并的原则是对于相同类型的节点,将处理方法组合成一个数组,当遇到该类型节点的时候,一次执行处理方法,traverse.visitors.merge 的逻辑较为简单,下面列出合并后 visitor 的数据结构。

{
  ArrowFunctionExpression: {
    enter: [...]
  },
  BlockStatement: {
    enter: [...],
    exit: [...]
  },
  DoWhileStatement: {
    enter: [...]
  }
}

合并后的 visitor 对象,和单个 visitor 的区别在于每个节点的处理函数变成了一个数组,该数组是由之前的不同插件中同种处理函数组成的。

AST 遍历方式

接下来就是执行 traverse 方法,该方法由 babel-traverse 包定义。

import traverse from "@babel/traverse";

traverse(file.ast, visitor, file.scope);

在看 traverse 方法之前,我们先了解 AST 的遍历方式,AST 是一个多叉树,Babel 采用递归的方式对其进行深度优先遍历。类似于二叉树的先序遍历。

tree

如图是模拟的一个 AST 结构,程序会访问根结点 A,然后递归访问 B,C。我们在递归调用的过程中,递归条件是该节点有子节点,如果没有子节点则返回。Babel 为了提供更灵活的配置方式,将访问阶段分为 enter 和 exit。enter 代表程序进入这个节点,也就是入栈,exit 表示程序退出节点。很少有插件的行为必须在 exit 阶段执行,所以我们在插件中定义的 Identifier() { ... } 其实是 Identifier: { enter() { ... } } 的简写形式。

我们在图中有箭头标出了每个节点的 enter 和 exit 阶段。红色的箭头代表 enter,绿色的箭头代表 exit。数字表示每个阶段的触发顺序。以节点 B 为例。阶段 1 表示访问节点 B,如果节点 B 的类型满足插件的执行条件,那此时执行插件中 visitor 对象的 enter 方法,阶段 8 表示离开节点 B,执行插件中 visitor 对象的 exit 方法。

了解了遍历方式后,回过头来看 traverse 方法,traverse 会调用 traverse.node 方法。

packages/babel-traverse/src/index.js

export default function traverse(
  parent: Object | Array<Object>,
  opts?: Object,
  scope?: Object,
  state: Object,
  parentPath: Object,
) {
  ...
  traverse.node(parent, opts, scope, state, parentPath);
}

我们先忽略中间一系列的调用方法,节点最终会调用到 visit 方法。visit 方法是执行插件调用的入口,首先是执行插件 enter 方法,接着就是递归调用 traverse.node 方法,处理子节点,子节点处理完毕后,执行插件 exit 方法。

packages/babel-traverse/src/path/context.js

export function visit(): boolean {
  ...
  // 执行插件 enter 方法
  if (this.shouldSkip || this.call("enter") || this.shouldSkip) {
    return this.shouldStop;
  }

  // 递归调用 traverse.node 
  traverse.node(
    this.node,
    this.opts,
    this.scope,
    this.state,
    this,
    this.skipKeys,
  );

  // 执行插件 exit 方法
  this.call("exit");

  return this.shouldStop;
}

了解 Babel 遍历 AST 的方法后,我们看下 Babel 是如何在遍历的过程中执行插件的。

访问者模式

我们写 Babel 插件的时候,最重要的就是要定义一个 visitor 对象。 这是因为 Babel 插件的设计遵循访问者(Visitor)模式。什么是访问者模式呢?简单来说,访问者模式是能把处理方法从数据结构中分离出来的一种模式,这种模式的好处就是可以根据需求增加新的处理方法,且不用修改原来的程序代码与数据结构。

我们拿 Babel 来说,AST 结构基本上是固定的,但是插件确是多变的,特别是 Babel 还希望用户可以自定义插件来做一些定制化的需求。在这种情况下,对于 AST 的操作就一定需要独立出去。这就是所谓的处理方式从数据结构中抽离。访问者模式中有两个比较重要的对象,访问者以及被访问的对象。我们可以认为,每个插件都是访问者,被访问的对象则是 AST 节点。由于 访问者并不需要访问整个 AST,有时候仅需访问特定节点,Babel 中定义一个控制器用于限制访问行为。

我们接着之前的 traverse.node 方法分析。该方法中新建了一个 TraversalContext 对象,并且将插件的调用委托给了该对象。

packages/babel-traverse/src/index.js

traverse.node = function (
  node: Object,
  opts: Object,
  scope: Object,
  state: Object,
  parentPath: Object,
  skipKeys?,
) {
  // VISITOR_KEYS 中存储了所有节点的属性
  const keys: Array = t.VISITOR_KEYS[node.type];
  if (!keys) return;

  const context = new TraversalContext(scope, opts, state, parentPath);
  for (const key of keys) {
    if (skipKeys && skipKeys[key]) continue;
    if (context.visit(node, key)) return;
  }
};

TraversalContext 对象就是 Babel 定义的控制器。从名字也可以看出,是用于管理遍历上下文的对象。

export default class TraversalContext {
  constructor(scope, opts, state, parentPath) {...}

  parentPath: NodePath;
  scope;
  state;
  opts;
  queue: ?Array<NodePath> = null;

  shouldVisit(node): boolean {...}

  create(node, obj, key, listKey): NodePath {...}

  maybeQueue(path, notPriority?: boolean) {...}

  visitMultiple(container, parent, listKey) {...}

  visitSingle(node, key): boolean {...}

  visitQueue(queue: Array<NodePath>) {...}

  visit(node, key) {...}
}

当我们遍历到某个 AST 节点的时候,其实并不是每个节点都会遍历到,因为该控制器会判断某个节点是否需要访问。主要根据两个来判断,首先判断是否有插件对该类型节点有修改,如果有的话,那就进入该节点。其次判断该节点是否有子节点,如果没有子节点,那就不需要进入该节点了。也就是说,AST 树的所有非叶子节点都会被遍历到,而叶子节点如果不是插件关注的节点,是不会被访问到的。这么做可以减少很多不必要的判断逻辑,提升遍历性能。

shouldVisit(node): boolean {
  const opts = this.opts;
  if (opts.enter || opts.exit) return true;

  // check if we have a visitor for this node
  if (opts[node.type]) return true;

  // check if we're going to traverse into this node
  const keys: ?Array<string> = t.VISITOR_KEYS[node.type];
  if (!keys?.length) return false;

  // we need to traverse into this node so ensure that it has children to traverse into!
  for (const key of keys) {
    if (node[key]) return true;
  }

  return false;
}

TraversalContext 提供了多种 visit 方法,区别在于节点是数组还是单个对象,最终都会调用到 visitQueue 方法。

this.visitQueue([this.create(node, node, key)]);

这里有个关键点,那就是 visitQueue 的入参是一个由 NodePath 组成的 queue(其实就是数组)。NodePath 就是根据当前 AST 结构生成的 path 对象,也是每个插件的 visitor 对象的入参。path 执行的 visit 方法,递归调用了 traverse.node 方法,这些我们在上文中讲 AST 遍历的时候讲过。

visitQueue(queue: Array<NodePath>) {
    this.queue = queue;
    ...
    for (const path of queue) {
      ...
      if (path.visit()) {
        stop = true;
        break;
      }
      ...
    }
    ...
  }

Babel 访问者模式的结构也就清晰了,Babel 插件作为访问者,定义了作用在被访问者上的操作。TraversalContext 负责控制整个访问的上下文环境,串起了整个访问的流程。NodePath 对象是根据当前 AST 节点生成的被访问者,通过 NodePath,我们不仅可以获得当前的 AST 对象,还能获得 Babel 为我们提供的很多工具方法,方便我们修改 AST 结构。

NodePath

TraversalContext 在调用 visitQueue 方法之前,生成了 NodePath 对象对列作为入参。我们现在看下生成 NodePath 的 create 方法。

create(node, obj, key, listKey): NodePath {
  return NodePath.get({
    parentPath: this.parentPath,
    parent: node,
    container: obj,
    key: key,
    listKey,
  });
}

create 方法调用了 NodePath 的静态方法 get 来生成 NodePath 对象,NodePath 由于对象的成员变量比较多,初始化代码也很长。我们在如何写一个 Babel 插件 中也看过 NodePath 的数据结构,就不详细看构造逻辑了。

export default class NodePath {
  constructor(hub: HubInterface, parent: Object) {...}

  parent: Object;
  hub: HubInterface;
  contexts: Array<TraversalContext>;
  data: Object;
  shouldSkip: boolean;
  shouldStop: boolean;
  removed: boolean;
  state: any;
  opts: ?Object;
  _traverseFlags: number;
  skipKeys: ?Object;
  parentPath: ?NodePath;
  context: TraversalContext;
  container: ?Object | Array<Object>;
  listKey: ?string;
  key: ?string;
  node: ?Object;
  scope: Scope;
  type: ?string;

  static get({ hub, parentPath, parent, container, listKey, key }): NodePath {...}

  getScope(scope: Scope) {...}

  setData(key: string, val: any): any {...}

  getData(key: string, def?: any): any {...}

  traverse(visitor: Object, state?: any) {...}

  ...
 
}

NodePath 本身定义了一些成员变量,以及少量的方法。NodePath 会作为参数传递给 Babel 插件,Babel 插件修改 NodePath 的结构,一切看似很完美。有个致命的问题就是 AST 的操作不是那么简单的,自己写很用以出错,于是 Babel 提供了巨量的工具函数,并将其绑在了 NodePath 上。

如下,NodePath 集成了 11 个工具类。通过这些工具类我们可以做很多事情,比如 NodePath_ancestry 用于向上查找路径上满足条件的节点。NodePath_removal 用于删除某个节点。NodePath_modification 用于插入或编辑某个节点。

Object.assign(
  NodePath.prototype,
  NodePath_ancestry,
  NodePath_inference,
  NodePath_replacement,
  NodePath_evaluation,
  NodePath_conversion,
  NodePath_introspection,
  NodePath_context,
  NodePath_removal,
  NodePath_modification,
  NodePath_family,
  NodePath_comments,
);

不仅如此,NodePath 还集成了 types 中所有用于判断,校验节点类型的方法。

for (const type of (t.TYPES: Array<string>)) {
  const typeKey = `is${type}`;
  const fn = t[typeKey];
  NodePath.prototype[typeKey] = function (opts) {
    return fn(this.node, opts);
  };

  NodePath.prototype[`assert${type}`] = function (opts) {
    if (!fn(this.node, opts)) {
      throw new TypeError(`Expected node path of type ${type}`);
    }
  };
}

for (const type of Object.keys(virtualTypes)) {
  if (type[0] === "_") continue;
  if (t.TYPES.indexOf(type) < 0) t.TYPES.push(type);

  const virtualType = virtualTypes[type];

  NodePath.prototype[`is${type}`] = function (opts) {
    return virtualType.checkPath(this, opts);
  };
}

如此巨无霸的一个对象,我们可以依赖它完成任何工作。但从设计模式的角度来将,访问者可以获得所有的被访问者的方法和数据,这其实违背了迪米特法则。这也是访问者模式的弊端之一。 Babel 将该模式用在插件上,最重要的还是看中了它能分离处理方法和数据结构的特点,毕竟插件最重要的就是扩展性。

总结

  1. Babel 插件遵循这样的执行顺序,pre -> visitor -> post,为了提高执行效率,Babel 将多个插件的 visitor 对象合并成为了一个 visitor 对象,方便后续的判断以及执行。

  2. 对于多个插件中 visitor 组成的数组,Babel 是这样合并的,以节点类型作为 key,处理方法作为 value,由于多个插件会对同一种节点做处理,所以 value 是个数组。这样合并的好处有两点,第一是在遍历 AST 节点的时候,能够快速判断是否存在该节点的处理函数,第二是能保证单个节点上插件的执行顺序。

  3. AST 遍历就是深度递归遍历,比较特殊的是,Babel 将这个阶段做了 enter 和 exit 的区分,默认使用 enter 阶段。

  4. Babel 插件的设计遵循访问者模式,插件就是一个个的访问者。TraversalContext 负责控制整个访问的上下文环境,串起了整个访问的流程。NodePath 对象是根据当前 AST 节点生成的被访问者。

  5. NodePath 作为被访问者,是一个集合了多种功能的大对象,不仅包含了本身的一些成员变量,还包含了很对方便用户判断,操作 AST 的函数,该对象由 TraversalContext 创建,作为访问者的入参,交由访问者控制。

Babel 插件的设计思路并不复杂,本质是在一个如何能够灵活变更数据结构的问题,Babel 的方式是,使用一个对象控制数据的遍历,使用访问者模式,以插件的形式将数据的变更抽离为一个个单独的程序。Babel 在初始化的时候读取这些插件。理解了这些,我们写 Babel 插件的时候会更加了解我们的代码是何时生效的,又是如何生效的。以后在遇到这种结构基本不变,但是处理方式经常变的的情形时,可以参考 Bable 插件的设计方式,相信会有所帮助。

如果您觉得有所收获,就请点个赞吧!