babel-plugin-import 使用

7,574 阅读5分钟

前言

使用 React 技术栈的同学,都有接触过 antd、material-ui 等 UI 组件库。

早期(没有 tree shaking 的时代)为了实现按需引入功能,我们会通过 babel-plugin-import 来优化我们的项目打包体积,做到只打包我们项目中所用到的模块。

但在现在新版的 antd 和 material-ui 中,默认已支持基于 ES modules 的 tree shaking 功能;而打包工具如:Webpack、Rollup 等在打包层面也支持了 tree shaking,使得我们不需要额外配置 babel-plugin-import 也能实现按需引入,这得益于 tree shaking。

虽然现代框架技术都已支持 tree shaking,但总归在一些老的项目所用到的老技术是无法支持 tree shaking 的,这就需要本文的主角 babel-plugin-import 来完成这件事情。

概念

我们知道,代码的转换需要交给 babel 来处理,而 Babel 的代码转换离不开 babel 插件(或预设)。

babel-plugin-import 是 babel 的模块化导入插件,支持对 antd、material-ui、loadsh 等依赖包的模块按需引入。

示例

这里拿 antd UI 组件库举例,我们在 .babelrc 配置文件中配置 import 插件如下:

{
  ...
  "plugins":  [
    ...
    ["import", { "libraryName": "antd", style: "css" }]
  ]
}

我们在项目中可以这样使用,经过打包编译后,require 的路径可以具体到模块的所在路径:

import { Button } from 'antd';
ReactDOM.render(xxxx);

↓ ↓ ↓ ↓ ↓ ↓

var _button = require('antd/lib/button'); 
require('antd/lib/button/style/css'); 
ReactDOM.render(<_button>xxxx);

这就是按需引入,不再是引入整个依赖包,而是具体到依赖包下的某个模块。

使用

  • 1、安装
npm install babel-plugin-import --save-dev
  • 2、在 babel 配置文件 .babelrc or babel-loader 中配置
{
  plugins: [
    ["import", { 
      "libraryName": "antd", // 指定导入包的名称
      "libraryDirectory": "lib", // 指定模块的存放目录
      style: "css", // 导入 css 样式
    }]
  ]
}
  • 3、配置多个包按需引入 有时候项目中需要对多个依赖包做按需引入处理,这里需要区分 babel@ 版本,不同的版本配置方式存在差异。
// 如果是 babel@6 版本,可以将 import.options 配置为一个数组:
[
  {
    "libraryName": "antd",
    "libraryDirectory": "lib",
    "style": true
  },
  {
    "libraryName": "antd-mobile"
  },
]

// 如果是 babel@7+ 版本,可以配置多个 `import` 插件实例:
{
  "plugins": [
    ["import", { "libraryName": "antd", "libraryDirectory": "lib"}, "antd"],
    ["import", { "libraryName": "antd-mobile", "libraryDirectory": "lib"}, "antd-mobile"]
  ]
}

具体实现

下面我们分析一下 babel-plugin-import 源码中的具体实现。

1、分析 ES module import 语句的 AST 语法树结构

babel 的工作离不开 AST语法树 的转换,假如我们有这样一行模块导入代码:

import { Button, Input } from 'antd';

转换为 AST 语法树后结构如下:(语法转换可以在这里尝试:astexplorer.net)

{
  "type": "Program",
  "body": [
    {
      "type": "ImportDeclaration",
      "specifiers": [
        {
          "type": "ImportSpecifier",
          "imported": {
            "type": "Identifier",
            "name": "Button"
          },
          "local": {
            "type": "Identifier",
            "name": "Button"
          }
        },
        {
          "type": "ImportSpecifier",
          "imported": {
            "type": "Identifier",
            "name": "Input"
          },
          "local": {
            "type": "Identifier",
            "name": "Input"
          }
        }
      ],
      "source": {
        "type": "Literal",
        "value": "antd",
      }
    }
  ],
  "sourceType": "module"
}

以上 JSON 数据中,我们关注以下几个跟 import 导入有关的信息:

  • source.value:antd;
  • specifiers.imported.name:Button;
  • specifiers.local.name: Button;

这里我们只需要先了解一下有 AST语法树 结构即可,后面我们会用到,下面开始编写插件。

2、插件入口文件

入口文件在 src/index.js中,这里先贴一下代码:

import Plugin from './Plugin';

// babel plugin 是一个函数,接收 babel 作为入参,用到的对象为 babel.types 来修改 AST 语法
export default function ({ types }) {
  let plugins = null;

  // 当babel遍历语法树遇到 method 节点类型时,调用 Plugin 实例上的相应方法去处理
  function applyInstance(method, args, context) {
    for (const plugin of plugins) {
      if (plugin[method]) {
        plugin[method].apply(plugin, [...args, context]);
      }
    }
  }

  // 定义程序
  const Program = {
    // Babel 为了提供更灵活的配置方式,将访问阶段分为 enter 和 exit。
    // enter 代表程序进入这个节点,也就是入栈,exit 表示程序退出节点。
    enter(path, { opts = {} }) {
      // Init plugin instances once.
      if (!plugins) {
        if (Array.isArray(opts)) {
          plugins = opts.map(({ libraryName, libraryDirectory, style }, index) => {
            return new Plugin(
              libraryName,
              libraryDirectory,
              style,
              types,
              index,
            );
            },
          );
        } else {
          plugins = [
            new Plugin(
              opts.libraryName,
              opts.libraryDirectory,
              opts.style,
              types,
            ),
          ];
        }
      }
      applyInstance('ProgramEnter', arguments, this);
    },
    exit() {
      applyInstance('ProgramExit', arguments, this);
    },
  };

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

  // 需要处理的 AST Node 节点类型名称。比如 ImportDeclaration 处理 import 语句
  const methods = [
    'ImportDeclaration',
    'CallExpression',
  ];
  for (const method of methods) {
    ret.visitor[method] = function () {
      // method 的具体实现由 Plugin 实例提供
      applyInstance(method, arguments, ret.visitor);
    };
  }

  return ret;
}
  • 首先,插件是一个函数,接收 babel 对象作为入参,这里用到了 babel.types 来修改 AST 语法节点;
  • 每个插件都需要返回一个访问者(visitor)作为编写插件的逻辑处理池;比如可以定义 enter、exit,以及特定 AST 节点的类型处理;
  • 可以通过将 options 配置为一个数组,来支持多个 Plugin 实例,实现多包的按需引入;
  • 每个包的按需引入,都对应一个 Plugin 实例;

从入口文件中可以看到,通过 new Plugin 创建了插件实例,核心的按需引入逻辑都在 Plugin 中。

3、Plugin 插件实例

babel-plugin-import 的核心实现都在 Plugin 中:

  • 收集 import 语句 { xxx } 中的模块名称;
  • 分析模块导入后,是否被 call 使用到
  • 如果有被使用到,改写 import 语句,使得 path 具体到模块的所在目录。

具体代码如下:

// Plugin.js

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

function transCamel(_str, symbol) {
  const str = _str[0].toLowerCase() + _str.substr(1);
  return str.replace(/([A-Z])/g, $1 => `${symbol}${$1.toLowerCase()}`);
}

function winPath(path) {
  return path.replace(/\\/g, '/');
}

export default class Plugin {
  constructor(
    libraryName, // 需要使用按需加载的包名
    libraryDirectory = 'lib', // 按需加载的目录
    style = false, // 是否加载样式
    types, // babel-type 工具函数
    index = 0,
  ) {
    this.libraryName = libraryName;
    this.libraryDirectory = libraryDirectory;
    this.style = style;
    this.types = types;
    this.pluginStateKey = `importPluginState${index}`;
  }

  // 获取内部状态,收集依赖,state 指向 plugin.visitor
  getPluginState(state) {
    if (!state[this.pluginStateKey]) {
      state[this.pluginStateKey] = {};
    }
    return state[this.pluginStateKey];
  }

  // 生成按需引入 import 语句(核心代码)
  importMethod(methodName, file, pluginState) {
    ...
  }

  ProgramEnter(path, state) {
    const pluginState = this.getPluginState(state);
    // 初始化插件实例的 state 对象
    pluginState.specified = Object.create(null);
    pluginState.libraryObjs = Object.create(null);
    pluginState.selectedMethods = Object.create(null);
    pluginState.pathsToRemove = [];
  }

  ProgramExit(path, state) {
    // 删除旧的 import
    this.getPluginState(state).pathsToRemove.forEach(p => !p.removed && p.remove());
  }

  // import 语句的处理方法,收集 import { xxx } 中的模块
  ImportDeclaration(path, state) {
    ...
  }

  // import 的模块被使用时的处理方法。上面收集了依赖之后,得判断这些 import 的变量是否被用到
  // 比如使用方式为:React.createElement(Button, null, "Hello"); 可将这行代码转换为 AST 节点树结合更容易理解 CallExpression 做的事情
  CallExpression(path, state) {
    ...
  }
}
  • 1、首先是实例的属性初始化,将插件的配置信息 options 绑定到插件实例上;

  • 2、接着在 visitor.enter 阶段调用 ProgramEnter 方法初始化 Plugin 实例的 state 对象;

    • pluginState.specified:import 包下面的模块名,如:Button;
    • pluginState.selectedMethods:有效的 import 包下面的模块名,也就是导入了,且被使用到的模块;
    • pluginState.pathsToRemove:用来存储 import xxx from 'antd' 源代码,用于替换删除旧的 import;
ProgramEnter(path, state) {
  const pluginState = this.getPluginState(state);
  // 初始化插件实例的 state 对象
  pluginState.specified = Object.create(null);
  pluginState.libraryObjs = Object.create(null);
  pluginState.selectedMethods = Object.create(null);
  pluginState.pathsToRemove = [];
}
  • 3、收集导入的模块 分析每一条 import 语句,如果导入的包名和配置的 plugin.libraryName 一致,则收集导入的模块名称。
// import 语句的处理方法,收集 import { xxx } 中的模块
ImportDeclaration(path, state) {
  const { node } = path;
  if (!node) return;
  // import 语句中 from 的包名,如这里的 antd
  const { value } = node.source;
  // 在插件 options 上配置的包名
  const { libraryName } = this;
  const { types } = this;
  const pluginState = this.getPluginState(state);
  // 如果 import 的包名和插件中配置的包名一致,开启工作
  if (value === libraryName) {
    // 遍历 import antd 下面的模块(如:Button)
    node.specifiers.forEach(spec => {
      // 判断类型是否为 ImportSpecifier (大括号 {} import 模块形式)
      if (types.isImportSpecifier(spec)) {
        // 收集依赖。也就是 pluginState.specified.Button = Button
        // local.name 是 `导入` 进来的别名,比如 import { Button as MyButton } from 'antd' 的 MyButton
        // imported.name 是包 antd 真实 `导出` 的变量名
        pluginState.specified[spec.local.name] = spec.imported.name;
      } else {
        pluginState.libraryObjs[spec.local.name] = true;
      }
    });
    // 收集旧的依赖
    pluginState.pathsToRemove.push(path);
  }
}
  • 4、查找模块是否被使用 调用 CallExpression 分析被使用到的模块名,调用 importMethod 方法改写 import 路径.
// import 的模块被使用时的处理方法。上面收集了依赖之后,得判断这些 import 的变量是否被用到
// 比如使用方式为:React.createElement(Button, null, "Hello"); 可将这行代码转换为 AST 节点树结合更容易理解 CallExpression 做的事情
CallExpression(path, state) {
  const { node } = path;
  const file = (path && path.hub && path.hub.file) || (state && state.file);
  // 方法调用者的 name,如:Button
  const { name } = node.callee;
  const { types } = this;
  const pluginState = this.getPluginState(state);

  // 如果方法调用者是 Identifier 类型(标识符)
  if (types.isIdentifier(node.callee)) {
    if (pluginState.specified[name]) {
      node.callee = this.importMethod(pluginState.specified[name], file, pluginState);
    }
  }

  // 参数形式,如 React.createElement(Button, null, "Hello"),会将 Button 作为第一个参数
  node.arguments = node.arguments.map(arg => {
    const { name: argName } = arg;
    if (
      pluginState.specified[argName] &&
      path.scope.hasBinding(argName) && // 检查当前作用域内是否存在 Button 变量
      path.scope.getBinding(argName).path.type === 'ImportSpecifier' // 并且变量通过 import 方式创建
    ) {
      return this.importMethod(pluginState.specified[argName], file, pluginState);
    }
    return arg;
  });
}
  • 5、改写模块导入路径(实现按需引入) importMethod 是 babel-plugin-import 的核心方法,import 模块的路径改写在这里处理。
// 生成按需引入 import 语句(核心代码)
importMethod(methodName, file, pluginState) {
  if (!pluginState.selectedMethods[methodName]) {
    const { style, libraryDirectory } = this;

    // camel2DashComponentName 为 true,会转换成小写字母,并且使用 - 作为连接符
    const transformedMethodName = this.camel2DashComponentName ? transCamel(methodName, '-') : methodName;

    // 兼容 windows 路径
    // // path.join('antd', 'lib', 'button') == 'antd/lib/button'
    const path = winPath(
      this.customName
        ? this.customName(transformedMethodName, file)
        : join(this.libraryName, libraryDirectory, transformedMethodName)
    );

    // 根据是否有导出 default 来判断使用哪种方法来生成 import 语句,默认为 true
    // addDefault(path, 'antd/lib/button', { nameHint: 'button' })
    // addNamed(path, 'button', 'antd/lib/button')
    pluginState.selectedMethods[methodName] = this.transformToDefaultImport
      ? addDefault(file.path, path, { nameHint: methodName })
      : addNamed(file.path, methodName, path);

    // 根据不同配置 import 样式
    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) {
      addSideEffect(file.path, `${path}/style`);
    } else if (style === '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] };
}

可以看到,这里将插件指定的 包名模块目录名模块名 进行拼接,得到最终的导入路径,如:antd/lib/button

const path = join(this.libraryName, libraryDirectory, transformedMethodName);

而模块的style 样式文件导入规则如下:

if (style === true) {
  addSideEffect(file.path, `${path}/style`);
} else if (style === 'css') {
  addSideEffect(file.path, `${path}/style/css`);
}
  • 6、addSideEffect, addDefault 和 addNamed 是 @babel/helper-module-imports 的三个方法,作用都是创建一个 import 语句方法,生成表现如下:
addSideEffect(filePath, 'sourcePath');
      ↓ ↓ ↓ ↓ ↓ ↓
import "sourcePath";


addDefault(filePath, 'sourcePath', { nameHint: "hintedName" });
      ↓ ↓ ↓ ↓ ↓ ↓
import hintedName from "sourcePath";


addNamed(filePath, 'named', 'sourcePath');
      ↓ ↓ ↓ ↓ ↓ ↓
import named from "sourcePath";

最后

贴出的源代码较多,源码中涉及到的 babel 相关知识较多,可以掌握 按需引入 的思想即可。

import 依赖包的导入路径,改写为具体到所使用的模块所在路径(可以支持 js、css 模块),实现打包资源最小化。

babel-plugin-import 使用文档

借鉴 - babel-plugin-import 源码解析