数栈技术分享:史上最全babel-plugin-import源码详解

本文将带领你们解析babel-plugin-import 实现按需加载的完整流程,解开业界所承认 babel 插件的面纱。css

首先供上babel-plugin-import插件node

1、初见萌芽linux

首先 babel-plugin-import 是为了解决在打包过程当中把项目中引用到的外部组件或功能库全量打包,从而致使编译结束后包容量过大的问题,以下图所示:git

babel-plugin-import 插件源码由两个文件构成github

  • Index 文件便是插件入口初始化的文件,也是笔者在 Step1 中着重说明的文件
  • Plugin 文件包含了处理各类 AST 节点的方法集,以 Class 形式导出

先来到插件的入口文件 Index :express

import Plugin from './Plugin';
export default function({ types }) {
  let plugins = null;
  /**
   *  Program 入口初始化插件 options 的数据结构
   */
  const Program = {
    enter(path, { opts = {} }) {
      assert(opts.libraryName, 'libraryName should be provided');
      plugins = [
        new Plugin(
          opts.libraryName,
          opts.libraryDirectory,
          opts.style,
          opts.styleLibraryDirectory,
          opts.customStyleName,
          opts.camel2DashComponentName,
          opts.camel2UnderlineComponentName,
          opts.fileName,
          opts.customName,
          opts.transformToDefaultImport,
          types,
        ),
      ];
      applyInstance('ProgramEnter', arguments, this);
    },
    exit() {
      applyInstance('ProgramExit', arguments, this);
    },
  };
  const ret = {
    visitor: { Program }, // 对整棵AST树的入口进行初始化操做
  };
  return ret;
}

首先 Index 文件导入了 Plugin ,而且有一个默认导出函数,函数的参数是被解构出的名叫 types 的参数,它是从 babel 对象中被解构出来的,types 的全称是 @babel/types,用于处理 AST 节点的方法集。以这种方式引入后,咱们不须要手动引入 @babel/types。 进入函数后能够看见观察者( visitor ) 中初始化了一个 AST 节点 Program,这里对 Program 节点的处理使用完整插件结构,有进入( enter )与离开( exit )事件,且需注意:windows

通常咱们缩写的 Identifier() { ... } 是 Identifier: { enter() { ... } } 的简写形式。

这里可能有同窗会问 Program 节点是什么?见下方 const a = 1 对应的 AST 树 ( 简略部分参数 )数组

{
  "type": "File",
  "loc": {
    "start":... ,
    "end": ...
  },
  "program": {
    "type": "Program", // Program 所在位置
    "sourceType": "module",
    "body": [
      {
        "type": "VariableDeclaration",
        "declarations": [
          {
            "type": "VariableDeclarator",
            "id": {
              "type": "Identifier",
              "name": "a"
            },
            "init": {
              "type": "NumericLiteral",
              "value": 1
            }
          }
        ],
        "kind": "const"
      }
    ],
    "directives": []
  },
  "comments": [],
  "tokens": [
       ...
  ]
}

Program 至关于一个根节点,一个完整的源代码树。通常在进入该节点的时候进行初始化数据之类的操做,也可理解为该节点先于其余节点执行,同时也是最晚执行 exit 的节点,在 exit 时也能够作一些”善后“的工做。 既然 babel-plugin-importProgram 节点处写了完整的结构,必然在 exit 时也有很是必要的事情须要处理,关于 exit 具体是作什么的咱们稍后进行讨论。 咱们先看 enter ,这里首先用 enter 形参 state 结构出用户制定的插件参数,验证必填的 libraryName [库名称] 是否存在。Index 文件引入的 Plugin 是一个 class 结构,所以须要对 Plugin 进行实例化,并把插件的全部参数与 @babel/types 所有传进去,关于 Plugin 类会在下文中进行阐述。 接着调用了 applyInstance 函数:babel

export default function({ types }) {
  let plugins = null;
  /**
   * 从类中继承方法并利用 apply 改变 this 指向,并传递 path , state 参数
   */
  function applyInstance(method, args, context) {
    for (const plugin of plugins) {
      if (plugin[method]) {
        plugin[method].apply(plugin, [...args, context]);
      }
    }
  }
  const Program = {
    enter(path, { opts = {} }) {
      ...
      applyInstance('ProgramEnter', arguments, this);
    },
      ...
   }
}

此函数的主要目的是继承 Plugin 类中的方法,且须要三个参数antd

  1. method(String):你须要从 Plugin 类中继承出来的方法名称
  2. args:(Arrray):[ Path, State ]
  3. PluginPass( Object):内容和 State 一致,确保传递内容为最新的 State

主要的目的是让 Program 的 enter 继承 Plugin 类的 ProgramEnter 方法,而且传递 path 与 state 形参至 ProgramEnterProgram 的 exit 同理,继承的是 ProgramExit 方法。

如今进入 Plugin 类:

export default class Plugin {
  constructor(
    libraryName,
    libraryDirectory,
    style,
    styleLibraryDirectory,
    customStyleName,
    camel2DashComponentName,
    camel2UnderlineComponentName,
    fileName,
    customName,
    transformToDefaultImport,
    types, // babel-types
    index = 0, // 标记符
  ) {
    this.libraryName = libraryName; // 库名
    this.libraryDirectory = typeof libraryDirectory === 'undefined' ? 'lib' : libraryDirectory; // 包路径
    this.style = style || false; // 是否加载 style
    this.styleLibraryDirectory = styleLibraryDirectory; // style 包路径
    this.camel2DashComponentName = camel2DashComponentName || true; // 组件名是否转换以“-”连接的形式
    this.transformToDefaultImport = transformToDefaultImport || true; // 处理默认导入
    this.customName = normalizeCustomName(customName); // 处理转换结果的函数或路径
    this.customStyleName = normalizeCustomName(customStyleName); // 处理转换结果的函数或路径
    this.camel2UnderlineComponentName = camel2UnderlineComponentName; // 处理成相似 time_picker 的形式
    this.fileName = fileName || ''; // 连接到具体的文件,例如 antd/lib/button/[abc.js]
    this.types = types; // babel-types
    this.pluginStateKey = `importPluginState${index}`;
  }
  ...
}

在入口文件实例化 Plugin 已经把插件的参数经过 constructor 后被初始化完毕啦,除了 libraryName 之外其余全部的值均有相应默认值,值得注意的是参数列表中的 customeName 与 customStyleName 能够接收一个函数或者一个引入的路径,所以须要经过 normalizeCustomName 函数进行统一化处理。

function normalizeCustomName(originCustomName) {
  if (typeof originCustomName === 'string') {
    const customeNameExports = require(originCustomName);
    return typeof customeNameExports === 'function'
      ? customeNameExports
      : customeNameExports.default;// 若是customeNameExports不是函数就导入{default:func()}
  }
  return originCustomName;
}

此函数就是用来处理当参数是路径时,进行转换并取出相应的函数。若是处理后 customeNameExports 仍然不是函数就导入 customeNameExports.default ,这里牵扯到 export default 是语法糖的一个小知识点。

export default something() {}
// 等效于
function something() {}
export ( something as default )

回归代码,Step1 中入口文件 Program 的 Enter 继承了 Plugin 的 ProgramEnter 方法

export default class Plugin {
  constructor(...) {...}

  getPluginState(state) {
    if (!state[this.pluginStateKey]) {
      // eslint-disable-next-line no-param-reassign
      state[this.pluginStateKey] = {}; // 初始化标示
    }
    return state[this.pluginStateKey]; // 返回标示
  }
  ProgramEnter(_, state) {
    const pluginState = this.getPluginState(state);
    pluginState.specified = Object.create(null); // 导入对象集合
    pluginState.libraryObjs = Object.create(null); // 库对象集合 (非 module 导入的内容)
    pluginState.selectedMethods = Object.create(null); // 存放通过 importMethod 以后的节点
    pluginState.pathsToRemove = []; // 存储须要删除的节点
    /**
     * 初始化以后的 state
     * state:{
     *    importPluginState「Number」: {
     *      specified:{},
     *      libraryObjs:{},
     *      select:{},
     *      pathToRemovw:[]
     *    },
     *    opts:{
     *      ...
     *    },
     *    ...
     * }
     */
  }
   ...
}

ProgramEnter 中经过 getPluginState**初始化 state 结构中的 importPluginState 对象,getPluginState 函数在后续操做中出现很是频繁,读者在此须要留意此函数的做用,后文再也不对此进行赘述。 可是为何须要初始化这么一个结构呢?这就牵扯到插件的思路。正像开篇流程图所述的那样 ,babel-plugin-import 具体实现按需加载思路以下:通过 import 节点后收集节点数据,而后从全部可能引用到 import 绑定的节点处执行按需加载转换方法。state 是一个引用类型,对其进行操做会影响到后续节点的 state 初始值,所以用 Program 节点,在 enter 的时候就初始化这个收集依赖的对象,方便后续操做。负责初始化 state 节点结构与取数据的方法正是 getPluginState。 这个思路很重要,而且贯穿后面全部的代码与目的,请读者务必理解再往下阅读。

2、唯恍唯惚

借由 Step1,如今已经了解到插件以 Program 为出发点继承了 ProgramEnter 而且初始化了 Plugin 依赖,若是读者还有还没有梳理清楚的部分,请回到 Step1 仔细消化下内容再继续阅读。 首先,咱们再回到外围的 Index 文件,以前只在观察者模式中注册了 Program 的节点,没有其余 AST 节点入口,所以至少还需注入 import 语句的 AST 节点类型 ImportDeclaration

export default function({ types }) {
  let plugins = null;
  function applyInstance(method, args, context) {
      ...
  }
  const Program = {
      ...
   }
  const methods = [ // 注册 AST type 的数组
    'ImportDeclaration' 
  ]

  const ret = {
    visitor: { Program }, 
  };

  // 遍历数组,利用 applyInstance 继承相应方法
  for (const method of methods) { 
    ret.visitor[method] = function() {
      applyInstance(method, arguments, ret.visitor);
    };
  }

}

建立一个数组并将 ImportDeclaration 置入,通过遍历调用 applyInstance_ _和 Step1 介绍同理,执行完毕后 visitor 会变成以下结构

visitor: {
  Program: { enter: [Function: enter], exit: [Function: exit] },
  ImportDeclaration: [Function],
}

如今回归 Plugin,进入 ImportDeclaration

export default class Plugin {
  constructor(...) {...}
  ProgramEnter(_, state) { ... }

  /**
   * 主目标,收集依赖
   */
  ImportDeclaration(path, state) {
    const { node } = path;
    // path 有可能被前一个实例删除
    if (!node) return;
    const {
      source: { value }, // 获取 AST 中引入的库名
    } = node;
    const { libraryName, types } = this;
    const pluginState = this.getPluginState(state); // 获取在 Program 处初始化的结构
    if (value === libraryName) { //  AST 库名与插件参数名是否一致,一致就进行依赖收集
      node.specifiers.forEach(spec => {
        if (types.isImportSpecifier(spec)) { // 不知足条件说明 import 是名称空间引入或默认引入
          pluginState.specified[spec.local.name] = spec.imported.name; 
          // 保存为:{ 别名 :  组件名 } 结构
        } else {
          pluginState.libraryObjs[spec.local.name] = true;// 名称空间引入或默认引入的值设置为 true
        }
      });
      pluginState.pathsToRemove.push(path); // 取值完毕的节点添加进预删除数组
    }
  }
  ...
}

ImportDeclaration 会对 import 中的依赖字段进行收集,若是是名称空间引入或者是默认引入就设置为 { 别名 :true },解构导入就设置为 { 别名 :组件名 } 。getPluginState 方法在 Step1 中已经进行过说明。关于 import 的 AST 节点结构 用 babel-plugin 实现按需加载 中有详细说明,本文再也不赘述。执行完毕后 pluginState 结构以下

// 例: import { Input, Button as Btn } from 'antd'

{
  ...
  importPluginState0: {
     specified: {
      Btn : 'Button',
      Input : 'Input'
    },
    pathToRemove: {
      [NodePath]
    }
    ...
  }
  ...
}

这下 state.importPluginState 结构已经收集到了后续帮助节点进行转换的全部依赖信息。 目前已经万事俱备,只欠东风。东风是啥?是能让转换 import 工做开始的 action。在 用 babel-plugin 实现按需加载 中收集到依赖的同时也进行了节点转换与删除旧节点。一切工做都在 ImportDeclaration 节点中发生。而 babel-plugin-import 的思路是寻找一切可能引用到 Import 的 AST 节点,对他们所有进行处理。有部分读者也许会直接想到去转换引用了 import 绑定的 JSX 节点,可是转换 JSX 节点的意义不大,由于可能引用到 import 绑定的 AST 节点类型 ( type ) 已经够多了,全部应尽量的缩小须要转换的 AST 节点类型范围。并且 babel 的其余插件会将咱们的 JSX 节点进行转换成其余 AST type,所以能不考虑 JSX 类型的 AST 树,能够等其余 babel 插件转换后再进行替换工做。其实下一步能够开始的入口有不少,但仍是从咱最熟悉的 React.createElement 开始。

class Hello extends React.Component {
    render() {
        return <div>Hello</div>
    }
}

// 转换后

class Hello extends React.Component {
    render(){
        return React.createElement("div",null,"Hello")
    }
}

JSX 转换后 AST 类型为 CallExpression(函数执行表达式),结构以下所示,熟悉结构后能方便各位同窗对以后步骤有更深刻的理解。

{
  "type": "File",
  "program": {
    "type": "Program",
    "body": [
      {
        "type": "ClassDeclaration",
        "body": {
          "type": "ClassBody",
          "body": [
            {
              "type": "ClassMethod",
              "body": {
                "type": "BlockStatement",
                "body": [
                  {
                    "type": "ReturnStatement",
                    "argument": {
                      "type": "CallExpression", // 这里是处理的起点
                      "callee": {
                        "type": "MemberExpression",
                        "object": {
                          "type": "Identifier",
                          "identifierName": "React"
                        },
                        "name": "React"
                      },
                      "property": {
                        "type": "Identifier",
                        "loc": {
                          "identifierName": "createElement"
                        },
                        "name": "createElement"
                      }
                    },
                    "arguments": [
                      {
                        "type": "StringLiteral",
                        "extra": {
                          "rawValue": "div",
                          "raw": "\"div\""
                        },
                        "value": "div"
                      },
                      {
                        "type": "NullLiteral"
                      },
                      {
                        "type": "StringLiteral",
                        "extra": {
                          "rawValue": "Hello",
                          "raw": "\"Hello\""
                        },
                        "value": "Hello"
                      }
                    ]
                  }
                ],
                "directives": []
              }
            }
          ]
        }
      }
    ]
  }
}

所以咱们进入 CallExpression 节点处,继续转换流程。

export default class Plugin {
  constructor(...) {...}
  ProgramEnter(_, state) { ... }

  ImportDeclaration(path, state) { ... }

  CallExpression(path, state) {
    const { node } = path;
    const file = path?.hub?.file || state?.file;
    const { name } = node.callee;
    const { types } = this;
    const pluginState = this.getPluginState(state);
    // 处理通常的调用表达式
    if (types.isIdentifier(node.callee)) {
      if (pluginState.specified[name]) {
        node.callee = this.importMethod(pluginState.specified[name], file, pluginState);
      }
    }
    // 处理React.createElement
    node.arguments = node.arguments.map(arg => {
      const { name: argName } = arg;
      // 判断做用域的绑定是否为import
      if (
        pluginState.specified[argName] &&
        path.scope.hasBinding(argName) &&
        types.isImportSpecifier(path.scope.getBinding(argName).path)
      ) {
        return this.importMethod(pluginState.specified[argName], file, pluginState); // 替换了引用,help/import插件返回节点类型与名称
      }
      return arg;
    });
  } 
  ...
}

能够看见源码调用了importMethod 两次,此函数的做用是触发 import 转换成按需加载模式的 action,并返回一个全新的 AST 节点。由于 import 被转换后,以前咱们人工引入的组件名称会和转换后的名称不同,所以 importMethod 须要把转换后的新名字(一个 AST 结构)返回到咱们对应 AST 节点的对应位置上,替换掉老组件名。函数源码稍后会进行详细分析。 回到一开始的问题,为何 CallExpression 须要调用 importMethod 函数?由于这两处表示的意义是不一样的,CallExpression 节点的状况有两种:

  1. 刚才已经分析过了,这第一种状况是 JSX 代码通过转换后的 React.createElement
  2. 咱们使用函数调用一类的操做代码的 AST 也一样是 CallExpression 类型,例如:
import lodash from 'lodash'

lodash(some values)

所以在 CallExpression 中首先会判断 node.callee 值是不是 Identifier ,若是正确则是所述的第二种状况,直接进行转换。若否,则是 React.createElement 形式,遍历 React.createElement 的三个参数取出 name,再判断 name 是不是先前 state.pluginState 收集的 import 的 name,最后检查 name 的做用域状况,以及追溯 name 的绑定是不是一个 import 语句。这些判断条件都是为了不错误的修改函数本来的语义,防止错误修改因闭包等特性的块级做用域中有相同名称的变量。若是上述条件均知足那它确定是须要处理的 import 引用了。让其继续进入importMethod 转换函数,importMethod 须要传递三个参数:组件名,File(path.sub.file),pluginState

import { join } from 'path';
import { addSideEffect, addDefault, addNamed } from '@babel/helper-module-imports';

 export default class Plugin {
   constructor(...) {...}
   ProgramEnter(_, state) { ... }
   ImportDeclaration(path, state) { ... }
   CallExpression(path, state) { ... } 

  // 组件原始名称 , sub.file , 导入依赖项
   importMethod(methodName, file, pluginState) {
    if (!pluginState.selectedMethods[methodName]) {
      const { style, libraryDirectory } = this;
      const transformedMethodName = this.camel2UnderlineComponentName // 根据参数转换组件名称
        ? transCamel(methodName, '_')
        : this.camel2DashComponentName
        ? transCamel(methodName, '-')
        : methodName;
       /**
       * 转换路径,优先按照用户定义的customName进行转换,若是没有提供就按照常规拼接路径
       */
      const path = winPath(
        this.customName
          ? this.customName(transformedMethodName, file)
          : join(this.libraryName, libraryDirectory, transformedMethodName, this.fileName), // eslint-disable-line
      );
      /**
       * 根据是不是默认引入对最终路径作处理,并无对namespace作处理
       */
      pluginState.selectedMethods[methodName] = this.transformToDefaultImport // eslint-disable-line
        ? addDefault(file.path, path, { nameHint: methodName })
        : addNamed(file.path, methodName, path);
      if (this.customStyleName) { // 根据用户指定的路径引入样式文件
        const stylePath = winPath(this.customStyleName(transformedMethodName));
        addSideEffect(file.path, `${stylePath}`);
      } else if (this.styleLibraryDirectory) { // 根据用户指定的样式目录引入样式文件
        const stylePath = winPath(
          join(this.libraryName, this.styleLibraryDirectory, transformedMethodName, this.fileName),
        );
        addSideEffect(file.path, `${stylePath}`);
      } else if (style === true) {  // 引入 scss/less 
        addSideEffect(file.path, `${path}/style`);
      } else if (style === 'css') { // 引入 css
        addSideEffect(file.path, `${path}/style/css`);
      } else if (typeof style === 'function') { // 如果函数,根据返回值生成引入
        const stylePath = style(path, file);
        if (stylePath) {
          addSideEffect(file.path, stylePath);
        }
      }
    }
    return { ...pluginState.selectedMethods[methodName] };
  }
  ...
}

进入函数后,先别着急看代码,注意这里引入了两个包:path.join 和 @babel/helper-module-imports ,引入 join 是为了处理按需加载路径快捷拼接的需求,至于 import 语句转换,确定须要产生全新的 import AST 节点实现按需加载,最后再把老的 import 语句删除。而新的 import 节点使用 babel 官方维护的 @babel/helper-module-imports 生成。如今继续流程,首先无视一开始的 if 条件语句,稍后会作说明。再捋一捋 import 处理函数中须要处理的几个环节:

  • 对引入的组件名称进行修改,默认转换以“-”拼接单词的形式,例如:DatePicker 转换为 date-picker,处理转换的函数是 transCamel。
function transCamel(_str, symbol) {
  const str = _str[0].toLowerCase() + _str.substr(1); // 先转换成小驼峰,以便正则获取完整单词
  return str.replace(/([A-Z])/g, $1 => `${symbol}${$1.toLowerCase()}`); 
  // 例 datePicker,正则抓取到P后,在它前面加上指定的symbol符号
}

转换到组件所在的具体路径,若是插件用户给定了自定义路径就使用 customName 进行处理,babel-plugin-import 为何不提供对象的形式做为参数?由于 customName 修改是以 transformedMethodName 值做为基础并将其传递给插件使用者,如此设计就能够更精确的匹配到须要按需加载的路径。处理这些动做的函数是 withPath,withPath 主要兼容 Linux 操做系统,将 Windows 文件系统支持的 '\' 统一转换为 '/'。

function winPath(path) {
  return path.replace(/\\/g, '/'); 
  // 兼容路径: windows默认使用‘\’,也支持‘/’,但linux不支持‘\’,遂统一转换成‘/’
}

对 transformToDefaultImport 进行判断,此选项默认为 true,转换后的 AST 节点是默认导出的形式,若是不想要默认导出能够将 transformToDefaultImport 设置为 false,以后便利用 @babel/helper-module-imports 生成新的 import 节点,最后**函数的返回值就是新 import 节点的 default Identifier,替换掉调用 importMethod 函数的节点,从而把全部引用旧 import 绑定的节点替换成最新生成的 import AST 的节点。

最后,根据用户是否开启 style 按需引入与 customStyleName 是否有 style 路径额外处理,以及 styleLibraryDirectory(style 包路径)等参数处理或生成对应的 css 按需加载节点。

到目前为止一条最基本的转换线路已经转换完毕了,相信你们也已经了解了按需加载的基本转换流程,回到 importMethod 函数一开始的if 判断语句,这与咱们将在 step3 中的任务息息相关。如今就让咱们一块儿进入 step3。

3、了如指掌

在 step3 中会进行按需加载转换最后的两个步骤:

  1. 引入 import 绑定的引用确定不止 JSX 语法,还有其余诸如,三元表达式,类的继承,运算,判断语句,返回语法等等类型,咱们都得对他们进行处理,确保全部的引用都绑定到最新的 import,这也会致使importMethod 函数被从新调用,但咱们确定不但愿 import 函数被引用了 n 次,生成 n 个新的 import 语句,所以才会有先前的判断语句。
  2. 一开始进入 ImportDeclaration 收集信息的时候咱们只是对其进行了依赖收集工做,并无删除节点。而且咱们还没有补充 Program 节点 exit 所作的 action

接下来将以此列举须要处理的全部 AST 节点,而且会给每个节点对应的接口(Interface)与例子(不关注语义):

MemberExpression

MemberExpression(path, state) {
    const { node } = path;
    const file = (path && path.hub && path.hub.file) || (state && state.file);
    const pluginState = this.getPluginState(state);
    if (!node.object || !node.object.name) return;
    if (pluginState.libraryObjs[node.object.name]) {
      // antd.Button -> _Button
      path.replaceWith(this.importMethod(node.property.name, file, pluginState));
    } else if (pluginState.specified[node.object.name] && path.scope.hasBinding(node.object.name)) {
      const { scope } = path.scope.getBinding(node.object.name);
      // 全局变量处理
      if (scope.path.parent.type === 'File') {
        node.object = this.importMethod(pluginState.specified[node.object.name], file, pluginState);
      }
    }
  }

MemberExpression(属性成员表达式),接口以下

interface MemberExpression {
    type: 'MemberExpression';
    computed: boolean;
    object: Expression;
    property: Expression;
}
/**
 * 处理相似:
 * console.log(lodash.fill())
 * antd.Button
 */

若是插件的选项中没有关闭 transformToDefaultImport ,这里会调用 importMethod 方法并返回@babel/helper-module-imports 给予的新节点值。不然会判断当前值是不是收集到 import 信息中的一部分以及是不是文件做用域下的全局变量,经过获取做用域查看其父节点的类型是不是 File,便可避免错误的替换其余同名变量,好比闭包场景。

VariableDeclarator

VariableDeclarator(path, state) {
   const { node } = path;
   this.buildDeclaratorHandler(node, 'init', path, state);
}

VariableDeclarator(变量声明),很是方便理解处理场景,主要处理 const/let/var 声明语句

interface VariableDeclaration : Declaration {
    type: "VariableDeclaration";
    declarations: [ VariableDeclarator ];
    kind: "var" | "let" | "const";
}
/**
 * 处理相似:
 * const foo = antd
 */

本例中出现 buildDeclaratorHandler 方法,主要确保传递的属性是基础的 Identifier 类型且是 import 绑定的引用后便进入 importMethod 进行转换后返回新节点覆盖原属性。

buildDeclaratorHandler(node, prop, path, state) {
    const file = (path && path.hub && path.hub.file) || (state && state.file);
    const { types } = this;
    const pluginState = this.getPluginState(state);
    if (!types.isIdentifier(node[prop])) return;
    if (
      pluginState.specified[node[prop].name] &&
      path.scope.hasBinding(node[prop].name) &&
      path.scope.getBinding(node[prop].name).path.type === 'ImportSpecifier'
    ) {
      node[prop] = this.importMethod(pluginState.specified[node[prop].name], file, pluginState);
    }
  }

ArrayExpression

ArrayExpression(path, state) {
    const { node } = path;
    const props = node.elements.map((_, index) => index);
    this.buildExpressionHandler(node.elements, props, path, state);
  }

ArrayExpression(数组表达式),接口以下所示

interface ArrayExpression {
    type: 'ArrayExpression';
    elements: ArrayExpressionElement[];
}
/**
 * 处理相似:
 * [Button, Select, Input]
 */

本例的处理和刚才的其余节点不太同样,由于数组的 Element 自己就是一个数组形式,而且咱们须要转换的引用都是数组元素,所以这里传递的 props 就是相似 [0, 1, 2, 3] 的纯数组,方便后续从 elements 中进行取数据。这里进行具体转换的方法是 buildExpressionHandler,在后续的 AST 节点处理中将会频繁出现

buildExpressionHandler(node, props, path, state) {
    const file = (path && path.hub && path.hub.file) || (state && state.file);
    const { types } = this;
    const pluginState = this.getPluginState(state);
    props.forEach(prop => {
      if (!types.isIdentifier(node[prop])) return;
      if (
        pluginState.specified[node[prop].name] &&
        types.isImportSpecifier(path.scope.getBinding(node[prop].name).path)
      ) {
        node[prop] = this.importMethod(pluginState.specified[node[prop].name], file, pluginState); 
      }
    });
  }

首先对 props 进行遍历,一样确保传递的属性是基础的 Identifier 类型且是 import 绑定的引用后便进入 importMethod 进行转换,和以前的 buildDeclaratorHandler 方法差很少,只是 props 是数组形式

LogicalExpression

LogicalExpression(path, state) {
    const { node } = path;
    this.buildExpressionHandler(node, ['left', 'right'], path, state);
  }

LogicalExpression(逻辑运算符表达式)

interface LogicalExpression {
    type: 'LogicalExpression';
    operator: '||' | '&&';
    left: Expression;
    right: Expression;
}
/**
 * 处理相似:
 * antd && 1
 */

主要取出逻辑运算符表达式的左右两边的变量,并使用 buildExpressionHandler 方法进行转换

ConditionalExpression

ConditionalExpression(path, state) {
    const { node } = path;
    this.buildExpressionHandler(node, ['test', 'consequent', 'alternate'], path, state);
  }

ConditionalExpression(条件运算符)

interface ConditionalExpression {
    type: 'ConditionalExpression';
    test: Expression;
    consequent: Expression;
    alternate: Expression;
}
/**
 * 处理相似:
 * antd ? antd.Button : antd.Select;
 */

主要取出相似三元表达式的元素,同用 buildExpressionHandler 方法进行转换。

IfStatement

IfStatement(path, state) {
    const { node } = path;
    this.buildExpressionHandler(node, ['test'], path, state);
    this.buildExpressionHandler(node.test, ['left', 'right'], path, state);
  }

IfStatement(if 语句)

interface IfStatement {
    type: 'IfStatement';
    test: Expression;
    consequent: Statement;
    alternate?: Statement;
}
/**
 * 处理相似:
 * if(antd){ }
 */

这个节点相对比较特殊,但笔者不明白为何要调用两次 buildExpressionHandler ,由于笔者所想到的可能性,都有其余的 AST 入口能够处理。望知晓的读者可进行科普。

ExpressionStatement

ExpressionStatement(path, state) {
    const { node } = path;
    const { types } = this;
    if (types.isAssignmentExpression(node.expression)) {
      this.buildExpressionHandler(node.expression, ['right'], path, state);
    }
 }

ExpressionStatement(表达式语句)

interface ExpressionStatement {
    type: 'ExpressionStatement';
    expression: Expression;
    directive?: string;
}
/**
 * 处理相似:
 * module.export = antd
 */

ReturnStatement

ReturnStatement(path, state) {
    const { node } = path;
    this.buildExpressionHandler(node, ['argument'], path, state);
  }

ReturnStatement(return 语句)

interface ReturnStatement {
    type: 'ReturnStatement';
    argument: Expression | null;
}
/**
 * 处理相似:
 * return lodash
 */

ExportDefaultDeclaration

ExportDefaultDeclaration(path, state) {
    const { node } = path;
    this.buildExpressionHandler(node, ['declaration'], path, state);
  }

ExportDefaultDeclaration(导出默认模块)

interface ExportDefaultDeclaration {
    type: 'ExportDefaultDeclaration';
    declaration: Identifier | BindingPattern | ClassDeclaration | Expression | FunctionDeclaration;
}
/**
 * 处理相似:
 * return lodash
 */

BinaryExpression

BinaryExpression(path, state) {
    const { node } = path;
    this.buildExpressionHandler(node, ['left', 'right'], path, state);
  }

BinaryExpression(二元操做符表达式)

interface BinaryExpression {
    type: 'BinaryExpression';
    operator: BinaryOperator;
    left: Expression;
    right: Expression;
}
/**
 * 处理相似:
 * antd > 1
 */

NewExpression

NewExpression(path, state) {
    const { node } = path;
    this.buildExpressionHandler(node, ['callee', 'arguments'], path, state);
  }

NewExpression(new 表达式)

interface NewExpression {
    type: 'NewExpression';
    callee: Expression;
    arguments: ArgumentListElement[];
}
/**
 * 处理相似:
 * new Antd()
 */

ClassDeclaration

ClassDeclaration(path, state) {
    const { node } = path;
    this.buildExpressionHandler(node, ['superClass'], path, state);
  }

ClassDeclaration(类声明)

interface ClassDeclaration {
    type: 'ClassDeclaration';
    id: Identifier | null;
    superClass: Identifier | null;
    body: ClassBody;
}
/**
 * 处理相似:
 * class emaple extends Antd {...}
 */

Property

Property(path, state) {
    const { node } = path;
    this.buildDeclaratorHandler(node, ['value'], path, state);
  }

Property(对象的属性值)

/**
 * 处理相似:
 * const a={
 *  button:antd.Button
 * }
 */

处理完 AST 节点后,删除掉本来的 import 导入,因为咱们已经把旧 import 的 path 保存在 pluginState.pathsToRemove 中,最佳的删除的时机即是 ProgramExit ,使用 path.remove() 删除。

ProgramExit(path, state) {
    this.getPluginState(state).pathsToRemove.forEach(p => !p.removed && p.remove());
}

恭喜各位坚持看到如今的读者,已经到最后一步啦,把咱们所处理的全部 AST 节点类型注册到观察者中

export default function({ types }) {
  let plugins = null;
  function applyInstance(method, args, context) { ... }
  const Program = { ... }

  // 补充注册 AST type 的数组
  const methods = [ 
    'ImportDeclaration'
    'CallExpression',
    'MemberExpression',
    'Property',
    'VariableDeclarator',
    'ArrayExpression',
    'LogicalExpression',
    'ConditionalExpression',
    'IfStatement',
    'ExpressionStatement',
    'ReturnStatement',
    'ExportDefaultDeclaration',
    'BinaryExpression',
    'NewExpression',
    'ClassDeclaration',
  ]

  const ret = {
    visitor: { Program }, 
  };

  for (const method of methods) { ... }

}

到此已经完整分析完 babel-plugin-import 的整个流程,读者能够从新捋一捋处理按需加载的整个处理思路,其实抛去细节,主体逻辑仍是比较简单明了的。

4、一些思考

笔者在进行源码与单元测试的阅读后,发现插件并无对 Switch 节点进行转换,遂向官方仓库提了 PR,目前已经被合入 master 分支,读者有任何想法,欢迎在评论区畅所欲言。 笔者主要补了 SwitchStatementSwitchCase 与两个 AST 节点处理。

SwitchStatement

SwitchStatement(path, state) {
    const { node } = path;
    this.buildExpressionHandler(node, ['discriminant'], path, state);
}

SwitchCase

SwitchCase(path, state) {
    const { node } = path;
    this.buildExpressionHandler(node, ['test'], path, state);
}

5、小小总结

这是笔者第一次写源码解析的文章,也因笔者能力有限,若是有些逻辑阐述的不够清晰,或者在解读过程当中有错误的,欢迎读者在评论区给出建议或进行纠错。

如今 babel 其实也出了一些 API 能够更加简化 babel-plugin-import 的代码或者逻辑,例如:path.replaceWithMultiple ,但源码中一些看似多余的逻辑必定是有对应的场景,因此才会被加以保留。

此插件经受住了时间的考验,同时对有须要开发 babel-plugin 的读者来讲,也是一个很是好的事例。不只如此,对于功能的边缘化处理以及操做系统的兼容等细节都有作完善的处理。

若是仅仅须要使用babel-plugin-import ,此文展现了一些在 babel-plugin-import 文档中未暴露的API,也能够帮助插件使用者实现更多扩展功能,所以笔者推出了此文,但愿能帮助到各位同窗。


本文首发于:数栈研习社

数栈是云原生—站式数据中台PaaS,咱们在github上有一个有趣的开源项目:FlinkX。FlinkX是一个基于Flink的批流统一的数据同步工具,既能够采集静态的数据,好比MySQL,HDFS等,也能够采集实时变化的数据,好比MySQL binlog,Kafka等,是全域、异构、批流一体的数据同步引擎,你们若是有兴趣,欢迎来github社区找咱们玩~

相关文章
相关标签/搜索