由babel-plugin-import 出发,深入解析babel的插件机制

681 阅读4分钟

在看篇文章之前,希望你有一点ast树babel 插件基础知识的了解。

起因

由于对 babel-plugin-import 的代码非常感兴趣,研究了源码。代码简化为如下所示:


{
  CallExpression(path, state) {
    const { node } = path;
    const file = (path && path.hub && path.hub.file) || (state && state.file);
    const { name } = node.callee;
    const { types } = this;
    const pluginState = this.getPluginState(state);


    // 也就是说,antd其实也做了进一步的tree-shaking ,只有被使用的组件才会被替换
    node.arguments = node.arguments.map(arg => {
      const { name: argName } = arg;
      if (
        pluginState.specified[argName] &&
        path.scope.hasBinding(argName) &&
        path.scope.getBinding(argName).path.type === 'ImportSpecifier'
      ) {
        // 本质上是这里发生了替换
        return this.importMethod(pluginState.specified[argName], file, pluginState);
      }
      return arg;
    });
  }

}

最核心的逻辑其实很简单的,以antd为例:

  • 找到CallExpression语句(比如React.createElement)中 arguements包含Button关键字,进行替换。比如:
React.createElement(Button, null, "xxxx")

变为

import Button from "antd/lib/button"

React.createElement(Button, null, "xxxx")

到这里,发现一个很有意思的问题。在 babel-plugin-import 插件解析之前,jsx语法已经被转化为了 React.createElement 了。有点意思,值得深度研究一下。

  • babel 的插件机制

如果希望babel既要支持react 语法,又要支持按需加载,可以这样调用

import { transformFileSync } from '@babel/core';
transformFileSync(actualFile, {
            presets: ['@babel/preset-react'],
            plugins: ['babel-plugin-import']})

ps: transformFileSync 是babel的一个转化代码的函数

而根据 babel官方文档,plugin和presets的优先级如下所示:

image.png

也就是说,plugins会优先执行。那这里不是矛盾了吗? 假如babel-plugin-import优先执行于 @babel/preset-react,此时,jsx语法还没有转化为React.createElement 函数,babel-plugin-import如何发挥作用。我们深入 babel 源码和 @babel/preset-react 源码看看

@babel/preset-react

@babel/preset-react 中,本质上和jsx转化有关系是插件 @babel/babel-helper-builder-react-jsx,它的代码如下所示:

  visitor.JSXElement = {
    exit(path, file) {
      const callExpr = buildElementCall(path, file);
      if (callExpr) {
        path.replaceWith(t.inherits(callExpr, path.node));
      }
    },
  };

你可以这样理解,上面的代码是将

<div>
  111
</div>

转化为

React.createElement("div", null, "111");

也就是说,按照结果,倒推解析顺序,插件执行顺序

image.png

再继续分析一下babel的源码

babel

处理plugin和presets顺序的代码中在 @babel/babel-core。的确就是babel文档中所说,plugin的优先级高于preset,有兴趣的小伙伴可以自行看源码了解,这里不贴出来分析了。

按照babel中代码逻辑,将plugins 和presets中fn合并,生成visitors,结构如下所示

{
    JSXElement: ['function  in  @babel/babel-helper-builder-react-jsx'],
    CallExpression: ['function in  babel-plugin-import']
}

babel中做代码转化相关的是 @babel/babel-traverse 这个包。首先入口函数是这个

export function traverseNode(
  node: t.Node,
  opts: TraverseOptions,
  scope?: Scope,
  state?: any,
  path?: NodePath,
  skipKeys?: string[],
): boolean {
  const keys = VISITOR_KEYS[node.type];
  if (!keys) return false;

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

  return false;
}

这里需要注意一下:

  • VISITOR_KEYS,这是一个映射,比如当前 node.type == 'JSXElement', VISITOR_KEYS['JSXElement']=['openingElement', 'children', 'closingElement'],代表需要访问当前节点的哪些属性。因为这个映射存在,能递归往下遍历节点

  • opts 也很重要,里面包含上面得到的 visitors对象

  • context.path 函数,将代码简化如下所示:

this.queue = queue;
...
for (const path of queue) {
    ....
    if (path.visit()) {
        stop = true;
        break;
      }
}
....

进入到path.visit函数看看,简化部分代码


 if (this.call("enter")) {
    return this.shouldStop;
  }
  
  ....
  
  this.shouldStop = traverseNode(
    this.node,
    this.opts,
    this.scope,
    this.state,
    this,
    this.skipKeys,
  );
  ...

  this.call("exit");
  

this.call 函数,其实是根据当前节点的类型,顺序调用visitor[node.type]的"enter"或者 "exit"方法,这里有一个小tips:

image.png

this.call简化代码如下所示:


export function _call(this: NodePath, fns?: Array<Function>): boolean {
  if (!fns) return false;

  for (const fn of fns) {
    if (!fn) continue;

    const node = this.node;
    if (!node) return true;

    const ret = fn.call(this.state, this, this.state);
    
    // node has been replaced, it will have been requeued
    if (this.node !== node) return true;
  }

  return false;
}


export function call(this: NodePath, key: string): boolean {
  const opts = this.opts;


  if (this.node) {
    return this._call(opts[this.node.type] && opts[this.node.type][key]);
  }

  return false;
}


根据上面的核心代码,可以总结插件函数的执行顺序。如果我的ast树结构如下所示

- FunctionDeclaration
  - Identifier (id)

同时我的visitors如下所示:

{
    "FunctionDeclaration": {
        "enter": [fn1, fn2]
        "exit": [fn3]
    },
    "Identifier": {
        "enter": [fn4]
        "exit": [fn5,fn6]
    } 
}

那么node遍历顺序应该是

enter FunctionDeclaration
    enter Identifier
    exit Identifier
exit FunctionDeclaration

其中visitors的执行顺序是

fn1
fn2
fn4
fn5
fn6
fn3

按照上面总结流程,假如代码片段是 <Button></Button>执行过程

image.png

但是还是没有解释为什么在节点变化为React.createElement("Button", null),也就是 CallExpression类型以后,到底是什么机制,能让变化的新节点执行babel-plugin-import 插件中 CallExpression 对应的方法

{
     CallExpression: ['from babel-plugin-import']
}

在这里纠结了很久,一直没有想通是什么改变了babel处理节点的顺序。按说这个节点已经处理过了,不会重新再次处理。到底和什么有关系?难道是context.path 函数中 queue?对,只有这个可能了, 那么是什么改变了queue。我再次重新去看了 @babel/babel-helper-builder-react-jsx的代码

visitor.JSXElement = {
    exit(path, file) {
      const callExpr = buildElementCall(path, file);
      if (callExpr) {
        path.replaceWith(t.inherits(callExpr, path.node));
      }
    },
  };

重点是path.replaceWith函数,path是babel的对象,再去翻一下这个函数干了什么

export function replaceWith(this: NodePath, replacement: t.Node | NodePath){
    ....
     // requeue for visiting
     this.requeue();
    ....
}

找到了,这个改变后的节点被重新push到了队列中。所以会被 babel-plugin-import插件进行处理。

到此为止,逻辑完整了,可以总结babel在处理<Button/>的代码流程了

image.png

那么针对babel中插件机制顺序的文档,可以补充一点

  • 如果插件中调用了 path.replaceWith函数,并且节点发生变更。那么变更后的节点将再次执行一遍,其对应的插件函数。