最新公告
  • 欢迎您光临网站无忧模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 简单实现 babel-plugin-import 插件

    正文概述 掘金(axuebin)   2020-12-14   324

    前几个月写的一篇文章,还没在掘金发过。原文链接

    前言

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

    插件地址:github.com/ant-design/…

    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
    }
    

    其它的看文档:github.com/ant-design/…

    babel-plugin-import 源码分析

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

    以下面这段代码为例:

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

    第一步 依赖收集

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

    先看一下 ast 吧:

    ![IMAGE](quiver-image-url/DBC2E9BF0D7FAB9E5ED62A74F63FD19B.jpg =463x815)

    可以从这个 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,很容易就找到这个节点:

    ![IMAGE](quiver-image-url/D69681FAEC50126D04F5D1F1BB5E0493.jpg =565x664)

    来看代码:

    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,源码中都有对应的处理方法,感兴趣的可以自己看一下: github.com/ant-design/… ,这里就不多说了。

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

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

    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 插件,可以自动加载单包和样式。

    完整代码:github.com/axuebin/bab…

    总结

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

    更多文章可以关注公众号「前端试炼」,分享每日前端精选文章。

    关于 Babel 你会用到的一些链接:

    • Babel 用户手册
    • Babel 插件手册
    • ast 分析
    • 节点规范

    下载网 » 简单实现 babel-plugin-import 插件

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元