Optimizing Lodash imports with jscodeshift

Avoid enormous network payloads!

Since the beginning of the year, I’ve spent a lot of time at work getting ready for Google Core Web Vitals-ageddon. Most of the techniques we’ve tried are fairly well-documented and I don’t want to retread the great advice and tutorials that are already out there (although I should put together a roundup of links). A few have required a little more investigation and experimentation, though, and those seemed worth writing up.

Remove unused JavaScript! Avoid enormous network payloads!

One easy trick for creating huge JavaScript bundles and making Google angry is importing the entire Lodash library when you only use a few methods. A lot has been written on Lodash and bundle sizes and best practices for imports (I’m partial to The Correct Way to Import Lodash Libraries - A Benchmark on BlazeMeter) but what I found lacking were tips on how to update an older, monolithic Rails app with inconsistent import patterns and the continual risk of unmanageable merge conflicts.

Enter jscodeshift.

jscodeshift is a toolkit that allows you to run codemods over JavaScript files and it was a lifesaver in this situation. According to the article above, direct imports are the way to go and jscodeshift makes it possible to instantly transform any files:

  • Importing the full library (import _ from 'lodash')
  • Importing methods from Lodash with curly brackets (import { name } from 'lodash')
  • Calling methods starting with _.
To get started with jscodeshift, run npm install -g jscodeshift to install it globally and follow along below.

The codemod.

I’ll start with the finished codemod (also available in this Gist) and then break it down into smaller parts.

export default (fileInfo, api) => {
  const j = api.jscodeshift;
  const root = j(fileInfo.source);
  let specifiers = [];

  root
    .find(j.ImportDeclaration, isLodashImport)
    .forEach((path) => specifiers.push(...path.node.specifiers.map((specifier) => specifier.local.name)))
    .remove();

  root
    .find(j.CallExpression, isLodashExpression)
    .forEach((path) => specifiers.push(path.node.callee.property.name))
    .replaceWith((path) => replaceExpression(path, j));

  if (specifiers.length) {
    cleanSpecifiers(specifiers).forEach((specifier) => {
      root.find(j.Declaration).at(0).get()
        .insertBefore(createImport(j, specifier));
    });
  }

  return root.toSource();
};

function isLodashImport (node) {
  return node.source.value.startsWith('lodash');
}

function isLodashExpression (node) {
  return node.callee.type === 'MemberExpression' && node.callee.object && node.callee.object.name === '_';
}

function replaceExpression (path, j) {
  return j.callExpression(j.identifier(path.node.callee.property.name), path.node.arguments);
}

function cleanSpecifiers (specifiers) {
  return specifiers.filter((specifier, i) => {
    return specifier !== '_' && specifiers.indexOf(specifier) === i;
  });
}

function createImport (j, specifier) {
  return j.importDeclaration(
    [j.importDefaultSpecifier(j.identifier(specifier))],
    j.stringLiteral(`lodash/${specifier}`)
  );
}

So what is this actually doing? The file starts with a little jscodeshift boilerplate which saves the jscodeshift API to a variable, converts the source code into AST nodes, and then returns the transformed source code at the end.

export default (fileInfo, api) => {
  const j = api.jscodeshift;
  const root = j(fileInfo.source);
  ...
  return root.toSource();
};

The first block uses the jscodeshift API to find any imports from modules starting with “lodash”. The import names are pushed into the specifiers array to save for later before the node is removed from the code.

export default (fileInfo, api) => {
  ...
  let specifiers = [];

  root
    .find(j.ImportDeclaration, isLodashImport)
    .forEach((path) => specifiers.push(...path.node.specifiers.map((specifier) => specifier.local.name)))
    .remove();
  ...
};

function isLodashImport (node) {
  return node.source.value.startsWith('lodash');
}

That takes care of the imports but the code could still contain references to the full Lodash library using _.. Luckily, jscodeshift can also look up any function calls using the object _. In those cases, the plain name replaces the object and gets pushed into the specifier list from above.

One thing to watch out for here is any collisions if your files have variable names that match the renamed Lodash methods.

export default (fileInfo, api) => {
  ...
  root
    .find(j.CallExpression, isLodashExpression)
    .forEach((path) => specifiers.push(path.node.callee.property.name))
    .replaceWith((path) => replaceExpression(path, j));
  ...
};

...

function isLodashExpression (node) {
  return node.callee.type === 'MemberExpression' && node.callee.object && node.callee.object.name === '_';
}

function replaceExpression (path, j) {
  return j.callExpression(j.identifier(path.node.callee.property.name), path.node.arguments);
}

To finish things up, the list of specifiers needs to be re-added to the code. After removing _ and any duplicate names from the array, jscodeshift can generate the import declarations and insert them at the beginning of the file.

export default (fileInfo, api) => {
  ...
  if (specifiers.length) {
    cleanSpecifiers(specifiers).forEach((specifier) => {
      root.find(j.Declaration).at(0).get()
        .insertBefore(createImport(j, specifier));
    });
  }
  ...
};

...

function cleanSpecifiers (specifiers) {
  return specifiers.filter((specifier, i) => {
    return specifier !== '_' && specifiers.indexOf(specifier) === i;
  });
}

function createImport (j, specifier) {
  return j.importDeclaration(
    [j.importDefaultSpecifier(j.identifier(specifier))],
    j.stringLiteral(`lodash/${specifier}`)
  );
}

Once that’s done, the only thing left is to run the code with the command jscodeshift /path/to/javascript/ -t filename.js (with your JavaScript source directory and filename). We reduced our main bundle size by about 33% without disrupting ongoing work and hopefully you can too!

Comments