javascriptbabeljsreact-intlbabel-core

Babel plugin development for react-intl


I noticed that there are some performance gain opportunities for react-intl after comparing intl.formatMessage({ id: 'section.someid' }) vs intl.messages['section.someid']. See more here: https://github.com/yahoo/react-intl/issues/1044

The second is 5x faster (and makes a huge difference in pages with a lot of translated elements) but doesn't seem to be the official way to do it (I guess they might change the variable name in future versions).

So I had the idea to create a babel plugin that does the transformation (formatMessage( to messages[). But I'm having trouble doing it because babel plugins creation is not well documented (I found some tutorials but it doesn't have what I need). I understood the basics but didn't find the visitor function name I need yet.

My boilerplate code is currently:

module.exports = function(babel) {
  var t = babel.types;
  return {
    visitor: {
      CallExpression(path, state) {
        console.log(path);
      },
    }
  };
};

So here are my questions:


Solution

  • Which visitor method do I use to extract classes calls - intl.formatMessage (is it it really CallExpression) ?

    Yes, it is a CallExpression, there is no special AST node for a method call compared to a function call, the only thing that changes is the receiver (callee). Whenever you're wondering what the AST looks like, you can use the fantastic AST Explorer. As a bonus, you can even write the Babel plugin in the AST Explorer by selecting Babel in the Transform menu.

    How do I detect a call to formatMessage ?

    For brevity I will only focus on the exact call to intl.formatMessage(arg), for a real plugin you would need to cover other cases as well (e.g. intl["formatMessage"](arg)) which have a different AST representation.

    The first thing is to identify that the callee is intl.formatMessage. As you know, that is a simple object property access, and the corresponding AST node is called MemberExpression. The visitor receives the matching AST node, CallExpression in this case, as path.node. That means we need to verify that path.node.callee is a MemberExpression. Thankfully, that is quite simple, because babel.types provides methods in the form of isX where X is the AST node type.

    if (t.isMemberExpression(path.node.callee)) {}
    

    Now we know that it's a MemberExpression, which has an object and a property that correspond to object.property. So we can check if object is the identifier intl and property the identifier formatMessage. For this we use isIdentifier(node, opts), which takes a second argument that allows you to check that it has a property with the given value. All isX methods are of that form to provide a shortcut, for details see Check if a node is a certain type. They also check for the node not being null or undefined, so the isMemberExpression was technically not necessary, but you might want to handle another type differently.

    if (
      t.isIdentifier(path.node.callee.object, { name: "intl" }) &&
      t.isIdentifier(path.node.callee.property, { name: "formatMessage" })
    ) {}
    

    How do I detect the number of parameters in the call ? (the replacement is not supposed to happen if there is formatting)

    The CallExpression has an arguments property, which is an array of the AST nodes of the arguments. Again, for brevity, I'll only consider calls with exactly one argument, but in reality you could also transform something like intl.formatMessage(arg, undefined). In this case it's simply checking the length of path.node.arguments. We also want the argument to be an object, so we check for an ObjectExpression.

    if (
      path.node.arguments.length === 1 &&
      t.isObjectExpression(path.node.arguments[0])
    ) {}
    

    An ObjectExpression has a properties property, which is an array of ObjectProperty nodes. You could technically check that id is the only property, but I will skip that here and instead only look for an id property. The ObjectProperty has a key and value, and we can use Array.prototype.find() to search for the property with the key being the identifier id.

    const idProp = path.node.arguments[0].properties.find(prop =>
      t.isIdentifier(prop.key, { name: "id" })
    );
    

    idProp will be the corresponding ObjectProperty if it exists, otherwise it will be undefined. When it is not undefined we want to replace the node.

    How I do the replacement ? (intl.formatMessage({ id: 'something' }) to intl.messages['something'] ?

    We want to replace the entire CallExpression and Babel provides path.replaceWith(node). The only thing left, is creating the AST node that it should be replaced with. For that we first need to understand how intl.messages["section.someid"] is represented in the AST. intl.messages is a MemberExpression just like intl.formatMessage was. obj["property"] is a computed property object access, which is also represented as a MemberExpression in the AST, but with the computed property set to true. That means that intl.messages["section.someid"] is a MemberExpression with a MemberExpression as the object.

    Remember that these two are semantically equivalent:

    intl.messages["section.someid"];
    
    const msgs = intl.messages;
    msgs["section.someid"];
    

    To construct a MemberExpression we can use t.memberExpression(object, property, computed, optional). For creating intl.messages we can reuse the intl from path.node.callee.object as we want to use the same object, but change the property. For the property we need to create an Identifier with the name messages.

    t.memberExpression(path.node.callee.object, t.identifier("messages"))
    

    Only the first two arguments are required and for the rest we use the default values (false for computed and null for optional). Now we can use that MemberExpression as the object and we need to lookup the computed property (the third argument is set to true) that corresponds to the value of the id property, which is available on the idProp we computed earlier. And finally we replace the CallExpression node with the newly created one.

    if (idProp) {
      path.replaceWith(
        t.memberExpression(
          t.memberExpression(
            path.node.callee.object,
            t.identifier("messages")
          ),
          idProp.value,
          // Is a computed property
          true
        )
      );
    }
    

    Full code:

    export default function({ types: t }) {
      return {
        visitor: {
          CallExpression(path) {
            // Make sure it's a method call (obj.method)
            if (t.isMemberExpression(path.node.callee)) {
              // The object should be an identifier with the name intl and the
              // method name should be an identifier with the name formatMessage
              if (
                t.isIdentifier(path.node.callee.object, { name: "intl" }) &&
                t.isIdentifier(path.node.callee.property, { name: "formatMessage" })
              ) {
                // Exactly 1 argument which is an object
                if (
                  path.node.arguments.length === 1 &&
                  t.isObjectExpression(path.node.arguments[0])
                ) {
                  // Find the property id on the object
                  const idProp = path.node.arguments[0].properties.find(prop =>
                    t.isIdentifier(prop.key, { name: "id" })
                  );
                  if (idProp) {
                    // When all of the above was true, the node can be replaced
                    // with an array access. An array access is a member
                    // expression with a computed value.
                    path.replaceWith(
                      t.memberExpression(
                        t.memberExpression(
                          path.node.callee.object,
                          t.identifier("messages")
                        ),
                        idProp.value,
                        // Is a computed property
                        true
                      )
                    );
                  }
                }
              }
            }
          }
        }
      };
    }
    

    The full code and some test cases can be found in this AST Explorer Gist.

    As I've mentioned a few times, this is a naive version and many cases are not covered, which are eligible for the transformation. It isn't difficult to cover more cases, but you have to identify them and pasting them into the AST Explorer will give you all the information you need. For example, if the object is { "id": "section.someid" } instead of { id: "section.someid" } it won't be transformed, but covering this is as simple as also checking for a StringLiteral besides an Identifier, like this:

    const idProp = path.node.arguments[0].properties.find(prop =>
      t.isIdentifier(prop.key, { name: "id" }) ||
      t.isStringLiteral(prop.key, { value: "id" })
    );
    

    I also didn't introduce any abstractions on purpose to avoid additional cognitive load, therefore the conditions look very lengthy.

    Helpful resources: