loading...

How to write simple babel macro

stereobooster profile image stereobooster ・Updated on ・3 min read

Macro is a small program which you can write to manipulate the source code of your application at transpilation (compilation) time. Think of it as a way to tweak how your compiler behaves.

babel-plugin-macros is a plugin for babel, to write macros for JavaScript (or Flow). The juicy part here is that as soon as babel-plugin-macros included you don't need to touch babel config to use your macros (contrary to other babel plugins). This is super useful in locked setups, like Creat React App. Also, I like that it is explicit - you clearly see where the macro is used.

Task

I picked up toy size problem which is easy to solve with macro.

When you use dynamic import in Webpack it will generate hard readable names for chunks (at least this is what it does in CRA), like 1.chunk.js, 2.chunk.js. To fix this you can use the magic comment /* webpackChunkName: MyComponent */, so you will get MyComponent.chunk.js, but this annoying to put this comment by hand every time. Let's write babel macro exactly to fix this.

We want code like this:

import wcImport from "webpack-comment-import.macro";

const asyncModule = wcImport("./MyComponent");

To be converted to

const asyncModule = import(/* webpackChunkName: MyComponent */ "./MyComponent");

Boilerplate

So I want to jump directly to coding, so I won't spend time on boilerplate. There is a GitHub repo with the tag boilerplate, where you can see the initial code.

export default createMacro(webpackCommentImportMacros);
function webpackCommentImportMacros({ references, state, babel }) {
  // lets walk through all calls of the macro
  references.default.map(referencePath => {
    // check if it is call expression e.g. someFunction("blah-blah")
    if (referencePath.parentPath.type === "CallExpression") {
      // call our macro
      requireWebpackCommentImport({ referencePath, state, babel });
    } else {
      // fail otherwise
      throw new Error(
        `This is not supported: \`${referencePath
          .findParent(babel.types.isExpression)
          .getSource()}\`. Please see the webpack-comment-import.macro documentation`,
      );
    }
  });
}
function requireWebpackCommentImport({ referencePath, state, babel }) {
  // Our macro which we need to implement
}

There are also tests and build script configured. I didn't write it from scratch. I copied it from raw.macro.

Let's code

First of all get babel.types. Here is the deal: when you working with macros, mainly what you do is manipulating AST (representation of source code), and babel.types contains a reference to all possible types of expressions used in babel AST. babel.types readme is the most helpful reference if you want to work with babel AST.

function requireWebpackCommentImport({ referencePath, state, babel }) {
  const t = babel.types;

referencePath is wcImport from const asyncModule = wcImport("./MyComponent");, so we need to get level higher, to actual call of function e.g. wcImport("./MyComponent").

  const callExpressionPath = referencePath.parentPath;
  let webpackCommentImportPath;

Now we can get arguments with which our function was called, to make sure there is no funny business happening let's use try/catch. First argument of function call supposes to be a path of the import e.g. "./MyComponent".

  try {
    webpackCommentImportPath = callExpressionPath.get("arguments")[0].evaluate()
      .value;
  } catch (err) {
    // swallow error, print better error below
  }

  if (webpackCommentImportPath === undefined) {
    throw new Error(
      `There was a problem evaluating the value of the argument for the code: ${callExpressionPath.getSource()}. ` +
        `If the value is dynamic, please make sure that its value is statically deterministic.`,
    );
  }

Finally AST manipulation - let's replace wcImport("./MyComponent") with import("./MyComponent");,

  referencePath.parentPath.replaceWith(
    t.callExpression(t.identifier("import"), [
      t.stringLiteral(webpackCommentImportPath),
    ]),
  );

Let's get the last part of the path e.g. transform a/b/c to c.

  const webpackCommentImportPathParts = webpackCommentImportPath.split("/");
  const identifier =
    webpackCommentImportPathParts[webpackCommentImportPathParts.length - 1];

And put the magic component before the first argument of the import:

  referencePath.parentPath
    .get("arguments")[0]
    .addComment("leading", ` webpackChunkName: ${identifier} `);
}

And this is it. I tried to keep it short. I didn't jump into many details, ask questions.

PS

Babel documentation is a bit hard, the easiest way for me were:

  1. inspect type of the expression with console.log(referencePath.parentPath.type) and read about it in babel.types
  2. read the source code of other babel-plugin which doing a similar thing

The full source code is here

Hope it helps. Give it a try. Tell me how it goes. Or simply share ideas of you babel macros.

Follow me on twitter and github.

Discussion

pic
Editor guide
Collapse
moranf profile image
Moran Fine

Hi, how do you unit test this Babel macro?

Collapse
stereobooster profile image
stereobooster Author

With AVA and snapshots. See here

Collapse
moranf profile image
Moran Fine

When I try to run the tests in IDEA, using Jest, I get "Empty tests suite", and it says that the tests passed: 0 of 2 tests, and it's also not producing any snapshots. Is there any other way to run them?

Thread Thread
stereobooster profile image
stereobooster Author

Oh wait a second you right it is Jest. Why I thought it was AVA? Anyway, you should be able to run npm test in console.

Thread Thread
moranf profile image
Moran Fine

Do you know maybe how can I import several custom babel-macros from one place in my project (For example index.js)?

Thread Thread
stereobooster profile image
stereobooster Author

As far as I know this is not possible, this kind of contradicts the idea of macro. You need explicitly declare in each file which macro you want to use. If you want something to be applied everywhere you need to use babel-plugin for this.

I can be wrong here. You can open issue in babel-macro repository with this question.

Thread Thread
moranf profile image
Moran Fine

Thank you for your answers!
Is that possible to at least to import the macro from an absolute path instead of the relative one?

Thread Thread
stereobooster profile image
stereobooster Author

Yes, should be possible. I don't see why not

Collapse
pojntfx profile image
Felix Pojtinger

define for JS. Love it!