Tree shaking CSS Modules

Eliminate collisions with CSS Modules and cut down bundle sizes by purging unused styles.

Profile photo

by Mark MurrayDecember 11, 20198 min read

Looking for a TL;DR? Go to the solution

Tree-shaking code as a front-end engineer is no trivial feat and, as a result, web applications typically send way too much unused code to their clients. The aim of this blog post is to show you how to eliminate collisions in your stylesheets by using CSS Modules and to purge unused styles from your stylesheets.

If you have a web app that bundles CSS files, your webpack might look something like this:

// webpack.config.js (production)
const path = require('path')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')

module.exports = {
  mode: 'production',

  entry: path.resolve('src/index.js'),

  output: {
    filename: '[name].js',
    path: path.resolve('dist'),
  },

  module: {
    rules: [
      {
        test: /\.jsx?$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
      },
      {        test: /\.css$/,        exclude: /node_modules/,        use: [          // Extract the css to file          MiniCssExtractPlugin.loader,          // Handle import of `.css` files in your app          'css-loader',        ],      },    ],
  },
  plugins: [
    // Extract all css into a separate file
    new MiniCssExtractPlugin({
      filename: '[name].css',
    }),
  ],
}

or if you use Sass/Less, your (s)css rule might look something like:

{
  test: /\.s?css$/,
  exclude: /node_modules/,
  use: [
    // Extract the css to file
    MiniCssExtractPlugin.loader,
    // Run CSS loader on imported css files
    'css-loader',
    // Compile SASS before importing with css-loader    'sass-loader'  ],
},

This is great because it lets us write our stylesheets however we like and import them in our JavaScript bundles.

// styles.scss
.margin {
  margin: 6px;
}

.padding {
  padding: 6px;
}
// index.js
import React from 'react'

import styles from './styles.scss'

const App = ({ children }) => {
  return <div className={styles.margin}>{children}</div>
}

Following compilation, our bundle would look something like:

// main.js
React.createElement('div', { className: 'margin' })

Which is what we wanted, except if we look at our CSS file, you will notice that it will contain both margin and padding classes, despite the fact that we only imported one.

// main.css
.margin {
  margin: 6px;
}
.padding {
  padding: 6px;
}

In this scenario, you might say it's not a big deal because we're only importing a single extraneous class. But in a real life scenario, your CSS files would most likely be a lot bigger.

Let's say we're using SASS to generate margin class helpers for our app. The helper file might look something like this:

@function capitalize($string) {
  @return to-upper-case(str-slice($string, 1, 1)) + str-slice($string, 2);
}

$sizes: (
  'none': none,
  'auto': auto,
  's': 12px,
  'm': 16px,
  'l': 24px,
);

$directions: left, right, top, bottom;

.margin {
  margin: 12px;
}

@each $key, $value in $sizes {
  .margin_#{$key} {
    margin: $value;
  }

  @each $direction in $directions {
    .margin#{capitalize($direction)}_#{$key} {
      margin-#{$direction}: $value;
    }
  }
}

which would generate all 25 possible classes for the specified $sizes and $directions like margin_s, marginRight_m, marginTop_l etc.

If we were to now import a single class in our app, we would end up with all 25 classes included in the bundled output - meaning 96% of the file is unused in our application and needlessly downloaded by the client - contributing to increased load times.

Collisions

On top of this, by not using CSS Modules here, all of our classes will be loaded exactly as-is in the file.

.margin_s {
}
.margin_m {
}
.margin_l {
}

If any external dependencies that are loaded in our app have the same class name then our content could potentially be overidden and cause problems for our layout.

CSS Modules

In order to get around this, we can leverage CSS Modules through the css-loader in webpack by simply appending ?modules to the css-loader.

use: [
  // Extract the css to file
  MiniCssExtractPlugin.loader,
  // Run CSS loader on imported css files
  'css-loader?modules',  // Compile SASS before importing with css-loader
  'sass-loader',
]

The CSS Modules feature is great because we can continue to import our styles the exact same way but now the classes will be base64 hashed, significantly reducing the chance of collisions. Instead of .margin_s in our CSS bundle, the class would look something like _2YXvmvhK1kiz8fYtvvwPOA and our CSS bundle would look like:

._2YXvmvhK1kiz8fYtvvwPOA {
  margin: 12px;
}

._1NvNf_l7rh91Ii7UjIRjLb {
  margin: 14px;
}

...

...plus 23 other class names.

If we look at our JavaScript bundle now, we'll also find that a hashmap has been added to the bundle which maps each class name to its respective hash:

e.exports={
  margin_s:"_2YXvmvhK1kiz8fYtvvwPOA",
  margin_m:"_1NvNf_l7rh91Ii7UjIRjLb",
  // ...plus 23 more pairs

So while we've solved the problem of collisions for our styles, we still have an excessive number of classes in our stylesheet and we have an unnecessary map of unused classes in our JavaScript bundle.

We can cut down the overall size of the stylesheet slightly by reducing the length of the hash for each class by controlling the size of the classname hash in our webpack config:

use: [
  // Extract the css to file
  MiniCssExtractPlugin.loader,
  // Run CSS loader on imported css files
  {    loader: 'css-loader',    options: {      modules: {        localIdentName: '[hash:base64:5]',      },    },  },  // Compile SASS before importing with css-loader
  'sass-loader',
]

But this doesn't actually solve the problem. We're just reducing the size of the file slightly.

Note: If you would like to use a combination of your class names with the auto-generated hash in production, you could modify the localIdentName to include the [local] class name: "[local]_[hash:base64:5]"

Solution

You will first need to have the following modules installed (preferrably the latest of each):

yarn add --dev \
    css-loader \
    cssnano \
    mini-css-extract-plugin \
    postcss-loader \
    style-loader \
    @fullhuman/postcss-purgecss \
    webpack \
    webpack-cli \
    postcss-scss \
    node-sass \
    sass-loader

You will then want to update your rules to run the postcss-loader with the postcss-purgecss plugin to sift through JavaScript files and purge any of the classNames that aren't found.

{
  test: /\.s?css$/,
  exclude: /node_modules/,
  use: [
    // Extract the css to file
    MiniCssExtractPlugin.loader,
    {
      loader: require.resolve('css-loader'),
      options: {
        // Enable CSS modules
        modules: {
          // Specify format of class names
          localIdentName: '[local]_[hash:base64:5]'
        },
      }
    },
    {      loader: require.resolve('postcss-loader'),      options: {        indent: 'postcss',        syntax: 'postcss-scss',        plugins: () => [            // Purge unused CSS from .js and .jsx files            require('@fullhuman/postcss-purgecss')({            // You'll want to modify this glob if you're using TypeScript            content: glob.sync('src/**/*.{js,jsx}', { nodir: true }),            extractors: [              {                extractor: class {                  static extract(content) {                    // See a note on this in the #addenum section below                    return content.match(/\w+/g) || [];                  }                },                extensions: ['js', 'jsx' ]              }            ]          }),          require('cssnano')        ]      }    },    'sass-loader'
  ]
}

If we bundle our app again, we should now only see a single class in our main.css file:

._2YXvmvhK1kiz8fYtvvwPOA {
  margin: 12px;
}

and similarly, we should only see a single key-value pair in our bundle hashmap:

e.exports = {
  margin: '_2YXvmvhK1kiz8fYtvvwPOA',
}

Happy days!


Addenum

It should be noted here that while the extractor we're using in the postcss-purgecss plugin above should work in most cases, there are scenarios where it will not work as expected.

The RegEx /w+/g will run over each .js(x) file in the app as specified by our glob, capturing every word each file and attempting to match each word against the stylesheet classes. This means that if you were to have a JavaScript file with a text node or variable with the same name as one of your classes then the styles for that classes would be included in the bundle.

For example, let's say our stylesheet looks liks this:

// styles.scss
.main {
  margin: auto;
}

.textAlignLeft {
  text-align: left;
}

and we have a simple React component that imports one of the classes:

import React from 'react'

import styles from './styles.scss'

const App = () => {
  return <div>This is the main component</div>
}

Because the extractor matches every word of the file, it will recognise "main" in the text node of the component and subsequently load the .main class of our styles.scss file, despite the fact that we never called styles.main anywhere in our file.

Another scenario where you might see issues is if you use BEM style class names with variable prefixes. For example, let's say we want to scope our component styles to the name of our app (this is not necessary since we're using CSS modules but let's explain for the point of argument):

.app {
  background: blue;

  &__input {
    padding: 1em;
  }
}

and use variable prefixes to define the classes:

import React from 'react'

import styles from './styles.scss'

const BASE_CLASS = 'app'

const App = () => {
  return (
    <div>
      <input className={styles[`${BASE_CLASS}__input`]} />
    </div>
  )
}

The extractor will match the BASE_CLASS "app" string, but won't match app__input, and so our CSS file will contain the .app {} styles but NOT the .app__input {} styles as the purgecss plugin will treat the styles as unused.

The solution here is to ditch the prefixes and rely on the hashing of your class names to scope your styles, importing them by name.


Want to see the code in action? View the example code here

Copyright © Mark Murray, 2024