如何实现一个 Babel Macros

本文首发于 Ahonn's Blog: 如何实现一个 Babel Macrosjavascript

经过 babel 插件,咱们很容易的就在编译时将某些代码转换成其余代码以实现某些优化。例如 babel-plugin-lodash 能够帮咱们将直接 import 的 lodash 替换成可以进行 tree shaking 的代码;经过 babel-plugin-preval 在编译时执行脚本并使用返回值原位替换。前端

一切看起来都很美好,但实际上在使用 babel 插件时咱们还须要对 .babelrc 或者 babel.config.js 进行配置。java

{
  "plugins": ["preval"]
}
复制代码

在暴露 babel 配置文件的项目下或许还可以接受,但在 create-react-app 下就不得不破坏原来的和谐, eject 一下配置再进行相关的配置了。react

有没有什么更好的方式呢?有的,咱们能够用 babel-plugin-macroswebpack

babel-plugin-macros 是什么?

babel-plugin-macros 显而易见是一个 babel 插件,它提供了一种零配置编译时替换代码的方式。咱们只须要在 babel 配置里添加 babel-plugin-macros 插件配置就可使用了。显然这个 “零配置” 是把自身除外的。但别担忧,create-react-app 已经内置了这个插件,能够开箱即用。git

{
  "plugins": ["macros"]
}
复制代码

而后就能够开始真正的零配置体验,引入咱们须要的 macro 直接使用。github

// 编译前
import preval from 'preval.macro';
const one = preval`module.exports = 1 + 2 - 1 - 1`;

// 编译后
const one = 3;
复制代码

与 babel-plugin-preval 相比,咱们不在须要再进行额外的配置,而是经过 import macro 来使用对应的功能。babel 在编译期会读取以 .macro 结尾的包,并执行对应的逻辑来替换代码,这种方式比插件来的更加直观,咱们不再会出现 “这个 preval 是哪里引进来?” 的疑问了。web

那么怎么实现一个 babel macros 呢?npm

实现一个 Babel macros

假设咱们有这么一个场景:咱们的项目中包括先后端的代码,后端的 Node.js 经过 dotenv 读取项目根目录下的 .env 获取某些配置,如今咱们有一些前端 JavaScript 代码也须要使用到 .env 里到某些配置,但不能把全部的配置都暴露到 JavaScript 中。json

通常状况下,咱们能够将 .env 中的某些配置传入 webpack 的 DefinePlugin 插件中,前端代码经过读取全局变量的方式进行访问。如今咱们经过 Babel macros 的方式来实现以下效果:

# .env
NAME=ahonn
NUMBER=123
复制代码
// 编译前
import dotenv from 'dotenv.macro';

const NAME = dotenv('NAME');
const NUMBER = dotenv('NUMBER');

// 编译后
const NAME = "ahonn";
const NUMBER = "123";
复制代码

建立 Macro

babel-plugin-macros 会把引入的 .macro 或者 .macro.js 当成宏进行处理,全部首先咱们须要建立一个名为 dotenv.macro.js 的文件,而且这个文件导出的应该是一个经过 createMacro 包装后的函数。

若是没有经过 createMacro 进行包装的话,执行 babel 就会提示:The macro imported from "../../dotenv.macro" must be wrapped in "createMacro" which you can get from "babel-plugin-macros".

const { createMacro } = require('babel-plugin-macros');

module.exports = createMacro(({ references, state, babel }) => {
  // TODO
});
复制代码

传入 createMacro 的函数接受三个参数:

  • references: 编译的代码中对该宏的引用
  • state: 编译状态信息
  • babel: babel-core 对象,与 require(‘@babel/core’) 相同

在咱们的例子中 references 的值是 { default: [ NodePath {...} ] },这里的 default 中的 NodePath 便是上面编译前代码中 dotenv 调用在 AST 中的节点。 (若是对 AST 或者 babel 插件开发不太熟悉的话,推荐阅读 babel-handbook/plugin-handbook.md

判断调用形式

拿到对应的 AST 节点(后面称为 path)以后,咱们须要对调用形式进行判断来肯定如何转换代码,这里咱们经过判断 path.parentPath 的节点类型来判断。

咱们能够经过传入 createMacro 的函数的第三个参数 babel 来获取一些用于判断节点类型的函数,babel.types 等价于 @babel/types

  • 经过 babel.types.isCallExpression 来判断是否为函数形式调用
  • 经过 babel.types.isTaggedTemplateExpression 来判断是否为模版字符串形式调用

咱们只对函数形式调用处理:

const { createMacro } = require('babel-plugin-macros');

module.exports = createMacro(({ references, state, babel }) => {
  references.default.forEach((path) => {
    if (path.parentPath && babel.types.isCallExpression(path.parentPath)) {
      // TODO
    }
  });
});
复制代码

获取目标值

作完前置的条件判断以后,如今咱们就能够经过 dotenv 来获取 .env 中配置的值,而后将对应的值替换对应的 AST 节点,从而使得编译后的代码在 macro 引用位置被替换为目标值。

const dotenv = require('dotenv');
const { createMacro } = require('babel-plugin-macros');

module.exports = createMacro(({ references, state, babel }) => {
  const env = dotenv.config();

  references.default.forEach((path) => {
    if (path.parentPath && babel.types.isCallExpression(path.parentPath)) {
      const args = path.parentPath.get('arguments'); 
      const key = args[0].evaluate().value;
      const value = env.parsed[key]; // ahonn
    }
  });
});
复制代码

咱们经过 path.parentPath.get('arguments') 获取到父节点(即节点类型为 CallExpression 的节点)中的 arguments 属性(即函数调用参数列表)。而后经过 args[0].evaluate().value 来获取第一个参数的值,即为 dotenv('NAME') 中的 'NAME'。最后从 dotenv 解析的 env 对象中获取目标值 'ahonn'

AST 节点替换

最后一步,咱们须要判断上一步获取的目标值的类型,而后根据不一样的类型进行 AST 转换。以咱们上面的例子来讲就是:

  • const NAME = dotenv('NAME'); 转换为 const NAME = 'ahonn';
  • const NUMBER = dotenv('NUMBER'); 转换为 const NUMBER = 123;
const dotenv = require('dotenv');
const { createMacro } = require('babel-plugin-macros');

module.exports = createMacro(({ references, state, babel }) => {
  const env = dotenv.config();

  references.default.forEach((path) => {
    if (path.parentPath && babel.types.isCallExpression(path.parentPath)) {
      const args = path.parentPath.get('arguments');
      const key = args[0].evaluate().value;
      const value = env.parsed[key];

      if (typeof value === 'number') {
        path.parentPath.replaceWith(babel.types.numericLiteral(value));
      } else {
        path.parentPath.replaceWith(babel.types.stringLiteral(value));
      }
    }
  });
});
复制代码

经过 typeof value 判断目标值的类型,这里只处理数字与字符串,非数字的值都当成字符串处理。而后再一次的经过 babel.types 中提供的 numericLiteralstringLiteral 来建立对应的 AST 节点。最后将 path.parentParh 替换为生产的节点。

到这里,一个读取 .env 中对应的值并在编译时替换相应的代码的 macro 就完成了。上面咱们提到的 preval.macro 的实现也与上面相似。

Q&A

  • 为何是替换掉 path.parentPath ? A: 由于咱们拿到的 references 中的引用只是对应的宏的 AST 节点,而通常 Babel macros 中咱们经过函数调用或者模版字符串形式进行调用,所以须要往上一层进行替换。

  • 能够经过 Babel macros 拓展 JavaScript 语法么? 不行,由于 Babel 只可以识别合法的 JavaScript 语法,即便使用 babel-plugin-macros 也没法改变这一事实。若是想要拓展 JavaScript 语法的话须要修改 babel-parser。具体怎么作,能够查看这篇文章:Creating custom JavaScript syntax with Babel | Tan Li Hau

总结

看到这里,能够发现实现一个 Babel macros 的过程与开发 Babel 插件的流程相似,都是对 AST 进行操做。babel-plugin-macro 只是提供一个在“外部”进行 AST 修改的方式,经过这种方式可以灵活的对 Babel 编译时进行拓展。但话又说回来,这种方式用多了会不会令代码变得很差维护呢?欢迎留言讨论。

参考

相关文章
相关标签/搜索