Updating the `classnames` package for 2021

Alex Moon

8 min read ·

I was recently using the invaluable classnames tool in a project and thought to myself, what’s under the hood? Well here it is:

(function () {
  'use strict';

  var hasOwn = {}.hasOwnProperty;

  function classNames() {
    var classes = [];
    for (var i = 0; i < arguments.length; i++) {
      var arg = arguments[i];
      if (!arg) continue;
      var argType = typeof arg;
      if (argType === 'string' || argType === 'number') {
        classes.push(arg);
      } else if (Array.isArray(arg)) {
        if (arg.length) {
          var inner = classNames.apply(null, arg);
          if (inner) {
            classes.push(inner);
          }
        }
      } else if (argType === 'object') {
        if (arg.toString !== Object.prototype.toString) {
          classes.push(arg.toString());
        } else {
          for (var key in arg) {
            if (hasOwn.call(arg, key) && arg[key]) {
              classes.push(key);
            }
          }
        }
      }
    }

    return classes.join(' ');
  }

  if (typeof module !== 'undefined' && module.exports) {
    classNames.default = classNames;
    module.exports = classNames;
  } else if (typeof define === 'function' && typeof define.amd === 'object' && define.amd) {
    // register as 'classnames', consistent with npm package name
    define('classnames', [], function () {
      return classNames;
    });
  } else {
    window.classNames = classNames;
  }
}());

By looking at it, I’m guessing this code is ES3 compatible. This is on purpose and means without transpilation, the code can be run in just about any browser since IE 8. Array.isArray is the only reason it doesn’t run in anything older (and that can be polyfilled). But here is the thing, we’ve had a lot of cool additions to JavaScript since ES3, what would it look like in modern JavaScript? I wondered that myself, so here you go:

export function classNames(...args) {
  const classes = args.reduce((acc, arg) => {
    if (!arg) return acc;

    const argType = typeof arg;
    if (argType === "string" || argType === 'number') {
      acc.push(arg);
    } else if (Array.isArray(arg) && arg.length) {
      const inner = classNames(...arg);
      if (inner) acc.push(inner);
    } else if (argType === "object") {
      if (arg.toString !== Object.prototype.toString) {
        classes.push(arg.toString());
      } else {
        for (const [key, value] of Object.entries(arg)) {
          if (value) acc.push(key);
        }
      }
    }

    return acc
  }, [])

  return classes.join(' ');
}

I’ve reduced that code from 48 lines to 27 or 1310 characters to 726. That’s nearly half the size. Yay! Functionality should be identical, it’s all semantics that changed.

This got me thinking. Given the JS eco system is going through a migration to ES6 modules currently, I think it’s time there was a version of classnames that supported ES Modules. But because only modern browsers only support ES Modules that means this new code would be supported (or mostly). Modern JS might also perform better. So I’ll be looking into offering a PR to classnames that adds module support, and modernize the JavaScript for better performance and less bytes over the wire.

UPDATE: Performance Benchmarks

They didn’t go well. I’m using the benchmarks in the clsx repository. clsx is an alternative to classNames. I’m just running these on my personal laptop with node v14.15.4. After Running tests I found 4 areas for improvement. I’ll cover all the performance improvements below and the end with all the benchmarks.

array.join(' ')

We’re already iterating through the args once with the reduce…this join does so a second time. Instead We can modify our reducer to be a accumulator to be a string and append args. This significantly improves performance.

function append(acc, arg) {
  return acc + arg + " "
}

exports.noJoin = function classNames(...args) {
  return args.reduce((acc, arg) => {
    if (!arg) return acc;

    const argType = typeof arg;
    if (argType === "string" || argType === 'number') {
      acc = append(acc, arg);
    } else if (Array.isArray(arg) && arg.length) {
      const inner = classNames(...arg);
      if (inner) acc = append(acc, inner);
    } else if (argType === "object") {
      if (arg.toString !== Object.prototype.toString) {
        acc = append(acc, arg.toString());
      } else {
        for (const [key, value] of Object.entries(arg)) {
          if (value) acc = append(acc, key);
        }
      }
    }

    return acc
  }, '')
}

array.reduce(...)

I was curious if a for loop would be faster…sure enough, it was again a measurable improvement.

exports.noReduce = function classNames(...args) {
  let classes = ""

  for (const arg of args) {
    if (!arg) continue;
    const argType = typeof arg;
    if (argType === "string" || argType === 'number') {
      classes = append(classes, arg);
    } else if (Array.isArray(arg) && arg.length) {
      const inner = classNames(...arg);
      if (inner) classes = append(classes, inner);
    } else if (argType === "object") {
      if (arg.toString !== Object.prototype.toString) {
        classes = append(classes, arg.toString());
      } else {
        for (const [key, value] of Object.entries(arg)) {
          if (value) classes = append(classes, key);
        }
      }
    }
  }

  return classes
}

arg.toString !== Object.prototype.toString

I don’t know why this line of code exists in the classNames library. I left it in my update cause I thought it may matter and it was a better direct comparison.

My guess is somewhere someone added a custom toString to their Object prototype. This allowed that custom toString to be called instead of the object being handled in the “normal” way.

Now I’m curious if git blame can help with this. BRB…Yup, I was correct… https://github.com/JedWatson/classnames/pull/170. Maybe there’s a use case but at least clsx doesn’t handles this situation so I’m not to worried about it.

CLSX advantages

The one test clsx clearly beat out my updates is in objects. But I know why and I think clsx has a bug. When handling objects specifically they uses a for...in loop instead of for...of with Object.entities(). This means key’s that are part of the object can get included…not something you want randomly added to your classes. Calling Object.entities() creates just enough overhead clsx performs better, but may have a bug.

Results

I’m very happy with these results. I quite easily matched or beat classnames results. While other libraries often outperform my updates that wasn’t my original goal. There is some room for improvement but considering this code is shorter and way more readable compared to [clsx](https://github.com/lukeed/clsx/blob/master/src/index.js) I’m happy with the results.

Here are the results:

# Strings
  classcat*       x 6,186,310 ops/sec ±0.67% (89 runs sampled)
  classnames      x 3,501,755 ops/sec ±0.88% (93 runs sampled)
  clsx (prev)     x 7,233,993 ops/sec ±0.30% (91 runs sampled)
  clsx            x 8,111,636 ops/sec ±0.25% (96 runs sampled)
  mine-original   x 2,799,886 ops/sec ±0.39% (96 runs sampled)
  mine-noJoin     x 6,779,248 ops/sec ±0.44% (95 runs sampled)
  mine-noReduce   x 7,078,812 ops/sec ±0.30% (94 runs sampled)
  mine-noObjCheck x 6,965,877 ops/sec ±0.36% (93 runs sampled)

# Objects
  classcat*       x 5,510,322 ops/sec ±0.45% (93 runs sampled)
  classnames      x 3,419,268 ops/sec ±0.23% (94 runs sampled)
  clsx (prev)     x 4,153,895 ops/sec ±0.67% (88 runs sampled)
  clsx            x 5,765,054 ops/sec ±0.87% (91 runs sampled)
  mine-original   x 2,141,475 ops/sec ±0.76% (95 runs sampled)
  mine-noJoin     x 3,146,063 ops/sec ±0.41% (95 runs sampled)
  mine-noReduce   x 3,519,887 ops/sec ±0.50% (93 runs sampled)
  mine-noObjCheck x 3,553,842 ops/sec ±0.44% (91 runs sampled)

# Arrays
  classcat*       x 5,234,652 ops/sec ±0.53% (92 runs sampled)
  classnames      x 1,753,297 ops/sec ±0.46% (98 runs sampled)
  clsx (prev)     x 5,168,463 ops/sec ±0.79% (91 runs sampled)
  clsx            x 5,778,139 ops/sec ±1.06% (93 runs sampled)
  mine-original   x 1,344,179 ops/sec ±0.76% (89 runs sampled)
  mine-noJoin     x 3,850,240 ops/sec ±0.47% (94 runs sampled)
  mine-noReduce   x 3,863,943 ops/sec ±1.19% (89 runs sampled)
  mine-noObjCheck x 3,835,394 ops/sec ±0.94% (89 runs sampled)

# Nested Arrays
  classcat*       x 4,201,828 ops/sec ±0.92% (90 runs sampled)
  classnames      x 1,052,590 ops/sec ±0.53% (90 runs sampled)
  clsx (prev)     x 4,216,418 ops/sec ±0.36% (94 runs sampled)
  clsx            x 4,572,854 ops/sec ±0.34% (94 runs sampled)
  mine-original   x 759,297 ops/sec ±0.37% (94 runs sampled)
  mine-noJoin     x 2,301,710 ops/sec ±0.27% (90 runs sampled)
  mine-noReduce   x 2,619,063 ops/sec ±0.25% (96 runs sampled)
  mine-noObjCheck x 2,610,460 ops/sec ±0.28% (93 runs sampled)

# Nested Arrays w/ Objects
  classcat*       x 4,368,391 ops/sec ±0.72% (91 runs sampled)
  classnames      x 1,647,880 ops/sec ±0.34% (89 runs sampled)
  clsx (prev)     x 4,051,660 ops/sec ±0.37% (97 runs sampled)
  clsx            x 4,778,679 ops/sec ±0.27% (92 runs sampled)
  mine-original   x 1,116,337 ops/sec ±0.22% (92 runs sampled)
  mine-noJoin     x 2,420,024 ops/sec ±0.80% (92 runs sampled)
  mine-noReduce   x 2,710,329 ops/sec ±0.30% (98 runs sampled)
  mine-noObjCheck x 2,688,791 ops/sec ±0.84% (92 runs sampled)

# Mixed
  classcat*       x 4,442,229 ops/sec ±0.79% (90 runs sampled)
  classnames      x 2,052,068 ops/sec ±0.45% (92 runs sampled)
  clsx (prev)     x 4,337,904 ops/sec ±0.47% (94 runs sampled)
  clsx            x 5,130,340 ops/sec ±0.47% (93 runs sampled)
  mine-original   x 1,402,928 ops/sec ±0.30% (96 runs sampled)
  mine-noJoin     x 2,850,388 ops/sec ±0.32% (91 runs sampled)
  mine-noReduce   x 3,226,104 ops/sec ±0.54% (94 runs sampled)
  mine-noObjCheck x 3,143,698 ops/sec ±0.26% (97 runs sampled)

# Mixed (Bad Data)
  classcat*       x 1,364,445 ops/sec ±0.71% (90 runs sampled)
  classnames      x 1,052,648 ops/sec ±0.47% (92 runs sampled)
  clsx (prev)     x 1,669,767 ops/sec ±0.40% (92 runs sampled)
  clsx            x 1,884,248 ops/sec ±0.51% (91 runs sampled)
  mine-original   x 665,626 ops/sec ±0.35% (95 runs sampled)
  mine-noJoin     x 879,206 ops/sec ±0.56% (93 runs sampled)
  mine-noReduce   x 980,827 ops/sec ±0.40% (96 runs sampled)
  mine-noObjCheck x 1,033,283 ops/sec ±0.41% (92 runs sampled)

If you’d like to see any of the code or run benchmarks yourself you can find them at https://github.com/moonmeister/clsx/tree/mybenchmarks/bench