经验首页 前端设计 程序设计 Java相关 移动开发 数据库/运维 软件/图像 大数据/云计算 其他经验
当前位置:技术经验 » JS/JS库/框架 » JavaScript » 查看文章
简单实现 babel-plugin-import 插件
来源:cnblogs  作者:axuebin  时间:2021/4/19 8:56:29  对本文有异议

前言

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

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

babel-plugin-import 介绍

Why:为什么需要这个插件

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

  1. // antd
  2. export { default as Button } from './button';
  3. export { default as Table } from './table';
  1. // element
  2. import Button from '../packages/button/index.js';
  3. import Table from '../packages/table/index.js';
  4. export default {
  5. Button,
  6. Table,
  7. };

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

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

  1. // antd
  2. import 'antd/dist/antd.css';
  3. // element
  4. import 'element-ui/lib/theme-chalk/index.css';

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

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

  1. import Button from 'antd/lib/button';
  2. import 'antd/lib/button/style';

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

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

  1. import { Affix, Avatar, Button, Rate } from 'antd';
  2. import 'antd/lib/affix/style';
  3. import 'antd/lib/avatar/style';
  4. import 'antd/lib/button/style';
  5. import 'antd/lib/rate/style';

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

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

What:这个插件做了什么

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

  1. import { Button } from 'antd';
  2. var _button = require('antd/lib/button');
  3. require('antd/lib/button/style');

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

How:这个插件怎么用

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

  1. {
  2. "libraryName": "antd", // 包名
  3. "libraryDirectory": "lib", // 目录,默认 lib
  4. "style": true, // 是否引入 style
  5. }

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

babel-plugin-import 源码分析

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

以下面这段代码为例:

  1. import { Button, Rate } from 'antd';
  2. 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 收集起来

来看代码:

  1. ImportDeclaration(path, state) {
  2. const { node } = path;
  3. if (!node) return;
  4. // 代码里 import 的包名
  5. const { value } = node.source;
  6. // 配在插件 options 的包名
  7. const { libraryName } = this;
  8. // babel-type 工具函数
  9. const { types } = this;
  10. // 内部状态
  11. const pluginState = this.getPluginState(state);
  12. // 判断是不是需要使用该插件的包
  13. if (value === libraryName) {
  14. // node.specifiers 表示 import 了什么
  15. node.specifiers.forEach(spec => {
  16. // 判断是不是 ImportSpecifier 类型的节点,也就是是否是大括号的
  17. if (types.isImportSpecifier(spec)) {
  18. // 收集依赖
  19. // 也就是 pluginState.specified.Button = Button
  20. // local.name 是导入进来的别名,比如 import { Button as MyButton } from 'antd' 的 MyButton
  21. // imported.name 是真实导出的变量名
  22. pluginState.specified[spec.local.name] = spec.imported.name;
  23. } else {
  24. // ImportDefaultSpecifier 和 ImportNamespaceSpecifier
  25. pluginState.libraryObjs[spec.local.name] = true;
  26. }
  27. });
  28. pluginState.pathsToRemove.push(path);
  29. }
  30. }

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

第二步 判断是否使用

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

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

  1. ReactDOM.render(<Button>Hello</Button>);
  2. React.createElement(Button, null, "Hello");

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

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

来看代码:

  1. CallExpression(path, state) {
  2. const { node } = path;
  3. const file = (path && path.hub && path.hub.file) || (state && state.file);
  4. // 方法调用者的 name
  5. const { name } = node.callee;
  6. // babel-type 工具函数
  7. const { types } = this;
  8. // 内部状态
  9. const pluginState = this.getPluginState(state);
  10. // 如果方法调用者是 Identifier 类型
  11. if (types.isIdentifier(node.callee)) {
  12. if (pluginState.specified[name]) {
  13. node.callee = this.importMethod(pluginState.specified[name], file, pluginState);
  14. }
  15. }
  16. // 遍历 arguments 找我们要的 specifier
  17. node.arguments = node.arguments.map(arg => {
  18. const { name: argName } = arg;
  19. if (
  20. pluginState.specified[argName] &&
  21. path.scope.hasBinding(argName) &&
  22. path.scope.getBinding(argName).path.type === 'ImportSpecifier'
  23. ) {
  24. // 找到 specifier,调用 importMethod 方法
  25. return this.importMethod(pluginState.specified[argName], file, pluginState);
  26. }
  27. return arg;
  28. });
  29. }

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

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

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

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

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

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

  1. import { Button } from 'antd';
  2. var _button = require('antd/lib/button');
  3. require('antd/lib/button/style');

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

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

  1. import { addSideEffect, addDefault, addNamed } from '@babel/helper-module-imports';
  2. importMethod(methodName, file, pluginState) {
  3. if (!pluginState.selectedMethods[methodName]) {
  4. // libraryDirectory:目录,默认 lib
  5. // style:是否引入样式
  6. const { style, libraryDirectory } = this;
  7. // 组件名转换规则
  8. // 优先级最高的是配了 camel2UnderlineComponentName:是否使用下划线作为连接符
  9. // camel2DashComponentName 为 true,会转换成小写字母,并且使用 - 作为连接符
  10. const transformedMethodName = this.camel2UnderlineComponentName
  11. ? transCamel(methodName, '_')
  12. : this.camel2DashComponentName
  13. ? transCamel(methodName, '-')
  14. : methodName;
  15. // 兼容 windows 路径
  16. // path.join('antd/lib/button') == 'antd/lib/button'
  17. const path = winPath(
  18. this.customName
  19. ? this.customName(transformedMethodName, file)
  20. : join(this.libraryName, libraryDirectory, transformedMethodName, this.fileName),
  21. );
  22. // 根据是否有导出 default 来判断使用哪种方法来生成 import 语句,默认为 true
  23. // addDefault(path, 'antd/lib/button', { nameHint: 'button' })
  24. // addNamed(path, 'button', 'antd/lib/button')
  25. pluginState.selectedMethods[methodName] = this.transformToDefaultImport
  26. ? addDefault(file.path, path, { nameHint: methodName })
  27. : addNamed(file.path, methodName, path);
  28. // 根据不同配置 import 样式
  29. if (this.customStyleName) {
  30. const stylePath = winPath(this.customStyleName(transformedMethodName));
  31. addSideEffect(file.path, `${stylePath}`);
  32. } else if (this.styleLibraryDirectory) {
  33. const stylePath = winPath(
  34. join(this.libraryName, this.styleLibraryDirectory, transformedMethodName, this.fileName),
  35. );
  36. addSideEffect(file.path, `${stylePath}`);
  37. } else if (style === true) {
  38. addSideEffect(file.path, `${path}/style`);
  39. } else if (style === 'css') {
  40. addSideEffect(file.path, `${path}/style/css`);
  41. } else if (typeof style === 'function') {
  42. const stylePath = style(path, file);
  43. if (stylePath) {
  44. addSideEffect(file.path, stylePath);
  45. }
  46. }
  47. }
  48. return { ...pluginState.selectedMethods[methodName] };
  49. }

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

addSideEffect

  1. addSideEffect(path, 'source');
  2. import "source"

addDefault

  1. addDefault(path, 'source', { nameHint: "hintedName" })
  2. import hintedName from "source"

addNamed

  1. addNamed(path, 'named', 'source', { nameHint: "hintedName" });
  2. 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 为代码自动引入依赖

最简功能实现

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

  1. {
  2. "libraryName": "antd",
  3. "libraryDirectory": "lib",
  4. "style": true,
  5. }

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

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

入口文件

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

  1. import Plugin from './Plugin';
  2. export default function({ types }) {
  3. let plugins = null;
  4. // 将插件作用到节点上
  5. function applyInstance(method, args, context) {
  6. for (const plugin of plugins) {
  7. if (plugin[method]) {
  8. plugin[method].apply(plugin, [...args, context]);
  9. }
  10. }
  11. }
  12. const Program = {
  13. // ast 入口
  14. enter(path, { opts = {} }) {
  15. // 初始化插件实例
  16. if (!plugins) {
  17. plugins = [
  18. new Plugin(
  19. opts.libraryName,
  20. opts.libraryDirectory,
  21. opts.style,
  22. types
  23. ),
  24. ];
  25. }
  26. applyInstance('ProgramEnter', arguments, this);
  27. },
  28. // ast 出口
  29. exit() {
  30. applyInstance('ProgramExit', arguments, this);
  31. },
  32. };
  33. const ret = {
  34. visitor: { Program },
  35. };
  36. // 插件只作用在 ImportDeclaration 和 CallExpression 上
  37. ['ImportDeclaration', 'CallExpression'].forEach(method => {
  38. ret.visitor[method] = function() {
  39. applyInstance(method, arguments, ret.visitor);
  40. };
  41. });
  42. return ret;
  43. }

核心代码

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

  1. import { join } from 'path';
  2. import { addSideEffect, addDefault } from '@babel/helper-module-imports';
  3. /**
  4. * 转换成小写,添加连接符
  5. * @param {*} _str 字符串
  6. * @param {*} symbol 连接符
  7. */
  8. function transCamel(_str, symbol) {
  9. const str = _str[0].toLowerCase() + _str.substr(1);
  10. return str.replace(/([A-Z])/g, $1 => `${symbol}${$1.toLowerCase()}`);
  11. }
  12. /**
  13. * 兼容 Windows 路径
  14. * @param {*} path
  15. */
  16. function winPath(path) {
  17. return path.replace(/\\/g, '/');
  18. }
  19. export default class Plugin {
  20. constructor(
  21. libraryName, // 需要使用按需加载的包名
  22. libraryDirectory = 'lib', // 按需加载的目录
  23. style = false, // 是否加载样式
  24. types // babel-type 工具函数
  25. ) {
  26. this.libraryName = libraryName;
  27. this.libraryDirectory = libraryDirectory;
  28. this.style = style;
  29. this.types = types;
  30. }
  31. /**
  32. * 获取内部状态,收集依赖
  33. * @param {*} state
  34. */
  35. getPluginState(state) {
  36. if (!state) {
  37. state = {};
  38. }
  39. return state;
  40. }
  41. /**
  42. * 生成 import 语句(核心代码)
  43. * @param {*} methodName
  44. * @param {*} file
  45. * @param {*} pluginState
  46. */
  47. importMethod(methodName, file, pluginState) {
  48. if (!pluginState.selectedMethods[methodName]) {
  49. // libraryDirectory:目录,默认 lib
  50. // style:是否引入样式
  51. const { style, libraryDirectory } = this;
  52. // 组件名转换规则
  53. const transformedMethodName = transCamel(methodName, '');
  54. // 兼容 windows 路径
  55. // path.join('antd/lib/button') == 'antd/lib/button'
  56. const path = winPath(
  57. join(this.libraryName, libraryDirectory, transformedMethodName)
  58. );
  59. // 生成 import 语句
  60. // import Button from 'antd/lib/button'
  61. pluginState.selectedMethods[methodName] = addDefault(file.path, path, {
  62. nameHint: methodName,
  63. });
  64. if (style) {
  65. // 生成样式 import 语句
  66. // import 'antd/lib/button/style'
  67. addSideEffect(file.path, `${path}/style`);
  68. }
  69. }
  70. return { ...pluginState.selectedMethods[methodName] };
  71. }
  72. ProgramEnter(path, state) {
  73. const pluginState = this.getPluginState(state);
  74. pluginState.specified = Object.create(null);
  75. pluginState.selectedMethods = Object.create(null);
  76. pluginState.pathsToRemove = [];
  77. }
  78. ProgramExit(path, state) {
  79. // 删除旧的 import
  80. this.getPluginState(state).pathsToRemove.forEach(
  81. p => !p.removed && p.remove()
  82. );
  83. }
  84. /**
  85. * ImportDeclaration 节点的处理方法
  86. * @param {*} path
  87. * @param {*} state
  88. */
  89. ImportDeclaration(path, state) {
  90. const { node } = path;
  91. if (!node) return;
  92. // 代码里 import 的包名
  93. const { value } = node.source;
  94. // 配在插件 options 的包名
  95. const { libraryName } = this;
  96. // babel-type 工具函数
  97. const { types } = this;
  98. // 内部状态
  99. const pluginState = this.getPluginState(state);
  100. // 判断是不是需要使用该插件的包
  101. if (value === libraryName) {
  102. // node.specifiers 表示 import 了什么
  103. node.specifiers.forEach(spec => {
  104. // 判断是不是 ImportSpecifier 类型的节点,也就是是否是大括号的
  105. if (types.isImportSpecifier(spec)) {
  106. // 收集依赖
  107. // 也就是 pluginState.specified.Button = Button
  108. // local.name 是导入进来的别名,比如 import { Button as MyButton } from 'antd' 的 MyButton
  109. // imported.name 是真实导出的变量名
  110. pluginState.specified[spec.local.name] = spec.imported.name;
  111. } else {
  112. // ImportDefaultSpecifier 和 ImportNamespaceSpecifier
  113. pluginState.libraryObjs[spec.local.name] = true;
  114. }
  115. });
  116. // 收集旧的依赖
  117. pluginState.pathsToRemove.push(path);
  118. }
  119. }
  120. /**
  121. * React.createElement 对应的节点处理方法
  122. * @param {*} path
  123. * @param {*} state
  124. */
  125. CallExpression(path, state) {
  126. const { node } = path;
  127. const file = (path && path.hub && path.hub.file) || (state && state.file);
  128. // 方法调用者的 name
  129. const { name } = node.callee;
  130. // babel-type 工具函数
  131. const { types } = this;
  132. // 内部状态
  133. const pluginState = this.getPluginState(state);
  134. // 如果方法调用者是 Identifier 类型
  135. if (types.isIdentifier(node.callee)) {
  136. if (pluginState.specified[name]) {
  137. node.callee = this.importMethod(
  138. pluginState.specified[name],
  139. file,
  140. pluginState
  141. );
  142. }
  143. }
  144. // 遍历 arguments 找我们要的 specifier
  145. node.arguments = node.arguments.map(arg => {
  146. const { name: argName } = arg;
  147. if (
  148. pluginState.specified[argName] &&
  149. path.scope.hasBinding(argName) &&
  150. path.scope.getBinding(argName).path.type === 'ImportSpecifier'
  151. ) {
  152. // 找到 specifier,调用 importMethod 方法
  153. return this.importMethod(
  154. pluginState.specified[argName],
  155. file,
  156. pluginState
  157. );
  158. }
  159. return arg;
  160. });
  161. }
  162. }

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

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

总结

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

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

原文链接:http://www.cnblogs.com/axuebin/p/babel-plugin-import.html

 友情链接:直通硅谷  点职佳  北美留学生论坛

本站QQ群:前端 618073944 | Java 606181507 | Python 626812652 | C/C++ 612253063 | 微信 634508462 | 苹果 692586424 | C#/.net 182808419 | PHP 305140648 | 运维 608723728

W3xue 的所有内容仅供测试,对任何法律问题及风险不承担任何责任。通过使用本站内容随之而来的风险与本站无关。
关于我们  |  意见建议  |  捐助我们  |  报错有奖  |  广告合作、友情链接(目前9元/月)请联系QQ:27243702 沸活量
皖ICP备17017327号-2 皖公网安备34020702000426号