专栏首页前端有的玩【源码解析+代码实现】一篇文章搞定 babel-plugin-import 插件

【源码解析+代码实现】一篇文章搞定 babel-plugin-import 插件

前言

平时在使用 antdelement 等组件库的时候,都会使用到一个 Babel 插件:babel-plugin-import,这篇文章通过例子和分析源码简单说一下这个插件做了一些什么事情,并且实现一个最小可用版本。

插件地址:?https://github.com/ant-design/babel-plugin-import

babel-plugin-import 介绍

Why:为什么需要这个插件

antdelement 这两个组件库,看它的源码, index.js 分别是这样的:

// antd
export { default as Button } from './button';
export { default as Table } from './table';
// element
import Button from '../packages/button/index.js';
import Table from '../packages/table/index.js';
export default {
  Button,
  Table,
};

antdelement 都是通过 ES6 Moduleexport 来导出带有命名的各个组件。

所以,我们可以通过 ES6import { } from 的语法来导入单组件的 JS 文件。但是,我们还需要手动引入组件的样式:

// antd
import 'antd/dist/antd.css';
// element
import 'element-ui/lib/theme-chalk/index.css';

如果仅仅是只需要一个 Button 组件,却把所有的样式都引入了,这明显是不合理的。

当然,你说也可以只使用单个组件啊,还可以减少代码体积:

import Button from 'antd/lib/button';
import 'antd/lib/button/style';

PS:类似 antd 的组件库提供了 ES Module 的构建产物,直接通过 import {} from 的形式也可以 tree-shaking,这个不在今天的话题之内,就不展开说了~

对,这没毛病。但是,看一下如们需要多个组件的时候:

import { Affix, Avatar, Button, Rate } from 'antd';

import 'antd/lib/affix/style';
import 'antd/lib/avatar/style';
import 'antd/lib/button/style';
import 'antd/lib/rate/style';

会不会觉得这样的代码不够优雅?如果是我,甚至想打人。

这时候就应该思考一下,如何在引入 Button 的时候自动引入它的样式文件。

What:这个插件做了什么

简单来说,babel-plugin-import 就是解决了上面的问题,为组件库实现单组件按需加载并且自动引入其样式,如:

import { Button } from 'antd';

      ↓ ↓ ↓ ↓ ↓ ↓

var _button = require('antd/lib/button');
require('antd/lib/button/style');

只需关心需要引入哪些组件即可,内部样式我并不需要关心,你帮我自动引入就 ok。

How:这个插件怎么用

简单来说就需要关心三个参数即可:

{
  "libraryName": "antd",     // 包名
  "libraryDirectory": "lib", // 目录,默认 lib
  "style": true,             // 是否引入 style
}

其它的看文档:?https://github.com/ant-design/babel-plugin-import#usage

babel-plugin-import 源码分析

主要来看一下 babel-plugin-import 如何加载 JavaScript 代码和样式的。

以下面这段代码为例:

import { Button, Rate } from 'antd';
ReactDOM.render(<Button>xxxx</Button>);

第一步 依赖收集

babel-plubin-import 会在 ImportDeclaration 里将所有的 specifier 收集起来。

先看一下 ast 吧:

可以从这个 ImportDeclaration 语句中提取几个关键点:

  • source.value: antd
  • specifier.local.name: Button
  • specifier.local.name: Rate

需要做的事情也很简单:

  1. import 的包是不是 antd,也就是 libraryName
  2. ButtonRate 收集起来

来看代码:

ImportDeclaration(path, state) {
  const { node } = path;
  if (!node) return;
  // 代码里 import 的包名
  const { value } = node.source;
  // 配在插件 options 的包名
  const { libraryName } = this;
  // babel-type 工具函数
  const { types } = this;
  // 内部状态
  const pluginState = this.getPluginState(state);
  // 判断是不是需要使用该插件的包
  if (value === libraryName) {
    // node.specifiers 表示 import 了什么
    node.specifiers.forEach(spec => {
      // 判断是不是 ImportSpecifier 类型的节点,也就是是否是大括号的
      if (types.isImportSpecifier(spec)) {
        // 收集依赖
        // 也就是 pluginState.specified.Button = Button
        // local.name 是导入进来的别名,比如 import { Button as MyButton } from 'antd' 的 MyButton
        // imported.name 是真实导出的变量名
        pluginState.specified[spec.local.name] = spec.imported.name;
      } else { 
        // ImportDefaultSpecifier 和 ImportNamespaceSpecifier
        pluginState.libraryObjs[spec.local.name] = true;
      }
    });
    pluginState.pathsToRemove.push(path);
  }
}

babel 遍历了所有的 ImportDeclaration 类型的节点之后,就收集好了依赖关系,下一步就是如何加载它们了。

第二步 判断是否使用

收集了依赖关系之后,得要判断一下这些 import 的变量是否被使用到了,我们这里说一种情况。

我们知道,JSX 最终是变成 React.createElement() 执行的:

ReactDOM.render(<Button>Hello</Button>);

      ↓ ↓ ↓ ↓ ↓ ↓

React.createElement(Button, null, "Hello");

没错,createElement 的第一个参数就是我们要找的东西,我们需要判断收集的依赖中是否有被 createElement 使用。

分析一下这行代码的 ast,很容易就找到这个节点:

来看代码:

CallExpression(path, state) {
  const { node } = path;
  const file = (path && path.hub && path.hub.file) || (state && state.file);
  // 方法调用者的 name
  const { name } = node.callee;
  // babel-type 工具函数
  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);
    }
  }

  // 遍历 arguments 找我们要的 specifier
  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'
    ) {
      // 找到 specifier,调用 importMethod 方法
      return this.importMethod(pluginState.specified[argName], file, pluginState);
    }
    return arg;
  });
}

除了 React.createElement(Button) 之外,还有 const btn = Button / [Button] ... 等多种情况会使用 Button,源码中都有对应的处理方法,感兴趣的可以自己看一下:?https://github.com/ant-design/babel-plugin-import/blob/master/src/Plugin.js#L163-L272 ,这里就不多说了。

第三步 生成引入代码(核心)

第一步和第二步主要的工作是找到需要被插件处理的依赖关系,比如:

import { Button, Rate } from 'antd';
ReactDOM.render(<Button>Hello</Button>);

Button 组件使用到了,Rate 在代码里未使用。所以插件要做的也只是自动引入 Button 的代码和样式即可。

我们先回顾一下,当我们 import 一个组件的时候,希望它能够:

import { Button } from 'antd';

      ↓ ↓ ↓ ↓ ↓ ↓

var _button = require('antd/lib/button');
require('antd/lib/button/style');

并且再回想一下插件的配置 ?options,只需要将 libraryDirectory 以及 style 等配置用上就完事了。

小朋友,你是否有几个问号?这里该如何让 babel 去修改代码并且生成一个新的 import 以及一个样式的 import 呢,不慌,看看代码就知道了:

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

importMethod(methodName, file, pluginState) {
  if (!pluginState.selectedMethods[methodName]) {
    // libraryDirectory:目录,默认 lib
    // style:是否引入样式
    const { style, libraryDirectory } = this;
    
    // 组件名转换规则
    // 优先级最高的是配了 camel2UnderlineComponentName:是否使用下划线作为连接符
    // camel2DashComponentName 为 true,会转换成小写字母,并且使用 - 作为连接符
    const transformedMethodName = this.camel2UnderlineComponentName
      ? transCamel(methodName, '_')
      : 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, this.fileName),
    );
    // 根据是否有导出 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] };
}

addSideEffect, addDefaultaddNamed@babel/helper-module-imports 的三个方法,作用都是创建一个 import 方法,具体表现是:

addSideEffect

addSideEffect(path, 'source');

      ↓ ↓ ↓ ↓ ↓ ↓

import "source"

addDefault

addDefault(path, 'source', { nameHint: "hintedName" })

      ↓ ↓ ↓ ↓ ↓ ↓

import hintedName from "source"

addNamed

addNamed(path, 'named', 'source', { nameHint: "hintedName" });

      ↓ ↓ ↓ ↓ ↓ ↓

import { named as _hintedName } from "source"

更多关于 @babel/helper-module-imports 见:?@babel/helper-module-imports

总结

一起数个 1 2 3,babel-plugin-import 要做的事情也就做完了。

我们来总结一下,babel-plugin-import 和普遍的 babel 插件一样,会遍历代码的 ast,然后在 ast 上做了一些事情:

  1. 收集依赖:找到 importDeclaration,分析出包 a 和依赖 b,c,d....,假如 alibraryName 一致,就将 b,c,d... 在内部收集起来
  2. 判断是否使用:在多种情况下(比如文中提到的 CallExpression)判断 收集到的 b,c,d... 是否在代码中被使用,如果有使用的,就调用 importMethod 生成新的 impport 语句
  3. 生成引入代码:根据配置项生成代码和样式的 import 语句

不过有一些细节这里就没提到,比如如何删除旧的 import 等... 感兴趣的可以自行阅读源码哦。

看完一遍源码,是不是有发现,其实除了 antdelement 等大型组件库之外,任意的组件库都可以使用 babel-plugin-import 来实现按需加载和自动加载样式。

没错,比如我们常用的 lodash,也可以使用 babel-plugin-import 来加载它的各种方法,可以动手试一下。

动手实现 babel-plugin-import

看了这么多,自己动手实现一个简易版的 babel-plugin-import 吧。

如果还不了解如何实现一个 Babel 插件,可以阅读 ?【Babel 插件入门】如何用 Babel 为代码自动引入依赖

最简功能实现

按照上文说的,最重要的配置项就是三个:

{
  "libraryName": "antd",
  "libraryDirectory": "lib",
  "style": true,
}

所以我们也就只实现这三个配置项。

并且,上文提到,真实情况中会有多种方式来调用一个组件,这里我们也不处理这些复杂情况,只实现最常见的 <Button /> 调用。

入口文件

入口文件的作用是获取用户传入的配置项并且将核心插件代码作用到 ast 上。

import Plugin from './Plugin';

export default function ({ types }) {
  let plugins = null;

  // 将插件作用到节点上
  function applyInstance(method, args, context) {
    for (const plugin of plugins) {
      if (plugin[method]) {
        plugin[method].apply(plugin, [...args, context]);
      }
    }
  }

  const Program = {
    // ast 入口
    enter(path, { opts = {} }) {
      // 初始化插件实例
      if (!plugins) {
        plugins = [
          new Plugin(
            opts.libraryName,
            opts.libraryDirectory,
            opts.style,
            types,
          ),
        ];
      }
      applyInstance('ProgramEnter', arguments, this);
    },
    // ast 出口
    exit() {
      applyInstance('ProgramExit', arguments, this);
    },
  };

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

  // 插件只作用在 ImportDeclaration 和 CallExpression 上
  ['ImportDeclaration', 'CallExpression'].forEach(method => {
    ret.visitor[method] = function () {
      applyInstance(method, arguments, ret.visitor);
    };
  });

  return ret;
}

核心代码

真正修改 ast 的代码是在 plugin 实现的:

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

/**
 * 转换成小写,添加连接符
 * @param {*} _str   字符串
 * @param {*} symbol 连接符
 */
function transCamel(_str, symbol) {
  const str = _str[0].toLowerCase() + _str.substr(1);
  return str.replace(/([A-Z])/g, $1 => `${symbol}${$1.toLowerCase()}`);
}

/**
 * 兼容 Windows 路径
 * @param {*} path 
 */
function winPath(path) {
  return path.replace(/\\/g, '/');
}

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

  /**
   * 获取内部状态,收集依赖
   * @param {*} state 
   */
  getPluginState(state) {
    if (!state) {
      state = {};
    }
    return state;
  }

  /**
   * 生成 import 语句(核心代码)
   * @param {*} methodName 
   * @param {*} file 
   * @param {*} pluginState 
   */
  importMethod(methodName, file, pluginState) {
    if (!pluginState.selectedMethods[methodName]) {
      // libraryDirectory:目录,默认 lib
      // style:是否引入样式
      const { style, libraryDirectory } = this;
      // 组件名转换规则
      const transformedMethodName = transCamel(methodName, '');
      // 兼容 windows 路径
      // path.join('antd/lib/button') == 'antd/lib/button'
      const path = winPath(join(this.libraryName, libraryDirectory, transformedMethodName));
      // 生成 import 语句
      // import Button from 'antd/lib/button'
      pluginState.selectedMethods[methodName] = addDefault(file.path, path, { nameHint: methodName });
      if (style) {
        // 生成样式 import 语句
        // import 'antd/lib/button/style'
        addSideEffect(file.path, `${path}/style`);
      }
    }
    return { ...pluginState.selectedMethods[methodName] };
  }
  
  ProgramEnter(path, state) {
    const pluginState = this.getPluginState(state);
    pluginState.specified = Object.create(null);
    pluginState.selectedMethods = Object.create(null);
    pluginState.pathsToRemove = [];
  }

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

  /**
   * ImportDeclaration 节点的处理方法
   * @param {*} path 
   * @param {*} state 
   */
  ImportDeclaration(path, state) {
    const { node } = path;
    if (!node) return;
    // 代码里 import 的包名
    const { value } = node.source;
    // 配在插件 options 的包名
    const { libraryName } = this;
    // babel-type 工具函数
    const { types } = this;
    // 内部状态
    const pluginState = this.getPluginState(state);
    // 判断是不是需要使用该插件的包
    if (value === libraryName) {
      // node.specifiers 表示 import 了什么
      node.specifiers.forEach(spec => {
        // 判断是不是 ImportSpecifier 类型的节点,也就是是否是大括号的
        if (types.isImportSpecifier(spec)) {
          // 收集依赖
          // 也就是 pluginState.specified.Button = Button
          // local.name 是导入进来的别名,比如 import { Button as MyButton } from 'antd' 的 MyButton
          // imported.name 是真实导出的变量名
          pluginState.specified[spec.local.name] = spec.imported.name;
        } else { 
          // ImportDefaultSpecifier 和 ImportNamespaceSpecifier
          pluginState.libraryObjs[spec.local.name] = true;
        }
      });
      // 收集旧的依赖
      pluginState.pathsToRemove.push(path);
    }
  }


  /**
   * React.createElement 对应的节点处理方法
   * @param {*} path 
   * @param {*} state 
   */
  CallExpression(path, state) {
    const { node } = path;
    const file = (path && path.hub && path.hub.file) || (state && state.file);
    // 方法调用者的 name
    const { name } = node.callee;
    // babel-type 工具函数
    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);
      }
    }

    // 遍历 arguments 找我们要的 specifier
    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'
      ) {
        // 找到 specifier,调用 importMethod 方法
        return this.importMethod(pluginState.specified[argName], file, pluginState);
      }
      return arg;
    });
  }
}

这样就实现了一个最简单的 babel-plugin-import 插件,可以自动加载单包和样式。

完整代码:?https://github.com/axuebin/babel-plugin-import-demo

总结

本文通过源码解析和动手实践,深入浅出的介绍了 babel-plugin-import 插件的原理,希望大家看完这篇文章之后,都能清楚地了解这个插件做了什么事。

本文分享自微信公众号 - 前端有的玩(gh_918bae0a9616),作者:axuebin

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2020-09-24

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • [第9期] 深入浅出 Babel 上篇:架构和原理 + 实战

    这个文章系列将带大家深入浅出 Babel, 这个系列将分为上下两篇:上篇主要介绍 Babel 的架构和原理,顺便实践一下插件开发的;下篇会介绍 `babel-p...

    皮小蛋
  • 深入浅出 Babel 下篇:既生 Plugin 何生 Macros

    我想我们对宏并不陌生,因为很多程序员第一门语言就是 C/C++; 一些 Lisp 方言也支持宏(如 Clojure、Scheme), 听说它们的宏写起来很优雅;...

    Nealyang
  • 深入浅出 Babel 上篇:架构和原理 + 实战

    这个文章系列将带大家深入浅出 Babel, 这个系列将分为上下两篇:上篇主要介绍 Babel 的架构和原理,顺便实践一下插件开发的;下篇会介绍 babel-pl...

    Nealyang
  • 深入浅出 Babel 上篇:架构和原理 + 实战

    这个文章系列将带大家深入浅出 Babel, 这个系列将分为上下两篇:上篇主要介绍 Babel 的架构和原理,顺便实践一下插件开发的;下篇会介绍 babel-pl...

    桃翁
  • WebPack 模块化打包工具(下)

    本篇博文的内容根据 入门 Webpack,看这篇就够了 该篇文章总结而来,其代码、模块示例、功能拓展部分均有所删减,若是想了解更多关于 WebPack 的详细内...

    Nian糕
  • Babel配置傻傻看不懂?

    答:我们上文提到,Babel在解析是时候会通过将code转换为AST抽象语法树,本质上是代码语法结构的一种抽象表示,通过以树?形的结构形式表现出它的语法结构,抽...

    树酱
  • Babel 的工作原理以及怎么写一个 Babel 插件

    在前端圈子里,对于 Babel,大家肯定都比较熟悉了。如果哪天少了它,对于前端工程师来说肯定是个噩梦。Babel 的工作原理是怎样的可能了解的人就不太多了。

    前端迷
  • webpack4大结局:加入腾讯IM配置策略,实现前端工程化环境极致优化

    Peter谭金杰
  • babel

    原文http://jiangyuan.me/blog/2016/08/13/babel/

    IMWeb前端团队
  • 前端工程师需要了解的 Babel 知识

    在前端圈子里,对于 Babel,大家肯定都比较熟悉了。如果哪天少了它,对于前端工程师来说肯定是个噩梦。Babel 的工作原理是怎样的可能了解的人就不太多了。

    桃翁
  • React Native 启动速度优化——JS 篇(全网最全,值得收藏)

    上一篇文章主要从 Native 的角度分析了 React Native 的初始化流程,并从源码出发,总结了几个 React Native 容器初始化的优化点。本...

    卤代烃
  • babel

    原文http://jiangyuan.me/blog/2016/08/13/babel/

    IMWeb前端团队
  • 前端工程师需要了解的 Babel 知识

    在前端圈子里,对于 Babel,大家肯定都比较熟悉了。如果哪天少了它,对于前端工程师来说肯定是个噩梦。Babel 的工作原理是怎样的可能了解的人就不太多了。

    Nealyang
  • npm依赖(构建编译)

    写到最后总结得差不多了,后续如果我想起还有哪些构建依赖遗漏的,会继续在这篇文章上补全,同时也希望各位倔友对文章里的要点进行补充或者提出自己的见解。欢迎在下方进行...

    JowayYoung
  • 知乎高赞:什么是前端工程化

    https://www.zhihu.com/question/433854153/answer/1713597311

    逆锋起笔
  • 用故事解读 MobX源码(四) 装饰器 和 Enhancer

    按照步骤,这篇文章应该写 观察值(Observable)的,不过在撰写的过程中发现,如果不先搞明白装饰器和 Enhancer(对这个单词陌生的,先不要着急,继续...

    JSCON简时空
  • 9102年:手写一个Vue的脚手架 【极致优化版】

    如果你对webpack不是很了解,请你关注我之前的文章,都是百星以上star的高质量文

    Peter谭金杰
  • Webpack 实现 Tree shaking 的前世今生

    如果看过 rollup 系列的这篇文章 - 无用代码去哪了?项目减重之 rollup 的 Tree-shaking,那你一定对 tree-shaking 不陌生...

    zz_jesse
  • 性能优化篇---Webpack构建代码质量压缩

    keyWords

扫码关注云+社区

领取腾讯云代金券