Introduction

At work right now, I'm building a feature for a plugin that fetches stories from UConn Today to display them on other sites. Currently, the plugin just creates links back to the stories. For some sites, that's ok. But for others, this update will add a "reader mode". This way when a user clicks on the story, a full screen modal will be displayed with the content of the story.

To do that, I built a plugin with react, Apollo Client, and WPGraphQL. I'm not going to get into all of that in this post. Rather, I want to look at how I brought the bundle size of the main app chunk down from 440kb to 15.5kb.

The Problem

Let's say that (like me) you're building an app with react and apollo. So, in your index.js file you do something like this.

import React from 'react'
import { render } from 'react-dom'
import { ApolloProvider } from '@apollo/client'

// all the app stuff

Without doing anything else, like minification, you're about to send 283.8KBs to your visitors.

react/react-dom/apollo file sizes

That's really bad. So the first thing to do is minify the built assets by setting webpack's mode property to production. But even this is only going to get you so far. You're still sending potentially hundreds of kilobytes to a user.

There wasn't much in this particular app to lazy load. So instead, what I wanted to do was split these big vendor libraries into their own chunks that I could then enqueue with WordPress.

The Solution

As I was researching this problem, I came across David Gilbertson's article "The 100% Correct Way to Split Your Chunks with Webpack. This was a really helpful start because he describes how to use webpacks optimization property to split apart all of the files required by his project. The issue I had with his approach was that it created an enormous number of files. Something like 35 in my case. Sure each one is small, but I felt like I needed a middle ground between 1 and 35.

What I wanted were the following chunks:

  • the main app chunk
  • one chunk each for react, apollo, fontawesome, and my analytics suite
  • a single chunk that would encapsulate the rest of the dependencies

Webpack Configuration

With that in mind, I did the following. I'll show you here and then explain.

// webpack.config.js

// I already had this part...
const path = require('path')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')

const env = process.env.NODE_ENV

const mode = env !== 'production' ? 'development' : 'production'
const devtool = env !== 'production' ? 'eval-source-map' : 'source-map'
const buildPath = env !== 'production' ? path.resolve(__dirname, 'dev-build') : path.resolve(__dirname, 'build')

// here's where the new part starts.
const optimization = {
  runtimeChunk: 'single',
  splitChunks: {
    chunks: 'all',
    maxInitialRequests: Infinity,
    minSize: 0,
    cacheGroups: {
      commons: {
        test: /[\\/]node_modules[\\/]/,
        filename: 'vendors.[contenthash].js',
        chunks: 'all'
      },
      apolloVendor: {
        test: /[\\/]node_modules[\\/](@apollo\/client)/,
        name: 'apolloVendor',
        chunks: 'all',
        priority: 11,
      },
      reactVendor: {
        test: /[\\/]node_modules[\\/](react|react-dom)/,
        name: 'reactVendor',
        chunks: 'all',
        priority: 10,
      },
      fontawesomeVendor: {
        test: /[\\/]node_modules[\\/](@fortawesome\/[\w-]+)/,
        name: 'fontawesomeVendor',
        chunks: 'all',
        priority: 10,
      },
      analyticsVendor: {
        test: /[\\/]node_modules[\\/]([@]?[analytics]+[\/\w-]+|use-analytics)/,
        name: 'analyticsVendor',
        chunks: 'all',
        priority: 10,
      },
    }
  }
}

The part to pay attention to here is the cacheGroups property. The objects here fulfill the requirements above. The code imported by your project is tested against the regex in the test property. However the priority property is also extremely important. Without setting that value correctly, you won't be able to get the chunks you want. I had this issue when I had the priority for the apollo chunk set to 10 instead of 11. Webpack simply didn't create that chunk. They describe why in the documentation

A module can belong to multiple cache groups. The optimization will prefer the cache group with a higher priority. The default groups have a negative priority to allow custom groups to take higher priority (default value is 0 for custom groups). Webpack Split Chunks Plugin Documetnation

So far so good. But we're not done yet. Each of those files needs to be named correctly in webpack's output. But we also want to make sure that we add a content hash to each file name. This will let us detect when there's a change to a file and serve the visitor the correct one. The rest will be served from the browser's cache and we don't need to request them again. To do that, we need to configure the output property for webpack.

// webpack.config.js

// I already had this part...
const path = require('path')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')

const env = process.env.NODE_ENV

const mode = env !== 'production' ? 'development' : 'production'
const devtool = env !== 'production' ? 'eval-source-map' : 'source-map'
const buildPath = env !== 'production' ? path.resolve(__dirname, 'dev-build') : path.resolve(__dirname, 'build')

// here's where the new part starts.
const optimization = {
  runtimeChunk: 'single',
  splitChunks: {
    chunks: 'all',
    maxInitialRequests: Infinity,
    minSize: 0,
    cacheGroups: {
      commons: {
        test: /[\\/]node_modules[\\/]/,
        filename: 'vendors.[contenthash].js',
        chunks: 'all'
      },
      apolloVendor: {
        test: /[\\/]node_modules[\\/](@apollo\/client)/,
        name: 'apolloVendor',
        chunks: 'all',
        priority: 11,
      },
      reactVendor: {
        test: /[\\/]node_modules[\\/](react|react-dom)/,
        name: 'reactVendor',
        chunks: 'all',
        priority: 10,
      },
      fontawesomeVendor: {
        test: /[\\/]node_modules[\\/](@fortawesome\/[\w-]+)/,
        name: 'fontawesomeVendor',
        chunks: 'all',
        priority: 10,
      },
      analyticsVendor: {
        test: /[\\/]node_modules[\\/]([@]?[analytics]+[\/\w-]+|use-analytics)/,
        name: 'analyticsVendor',
        chunks: 'all',
        priority: 10,
      },
    }
  }
}

module.exports = {
  entry: [
    path.resolve(__dirname, 'src/js/index.js')
  ],
  mode,
  devtool,
  output: {
    path: buildPath,
    filename: '[name].[contenthash].js',
    chunkFilename: '[name].[contenthash].js'
  },
  optimization,
  // other stuff...
}

When we use the format [name].[contenthash].js, we'll get file names like main.59759dfuidsyfiasdh.js. We can do similar things for CSS files as well. But now, we've got a serious problem. WordPress ummmm.... isn't great at handling this sort of thing natively. Usually we enqueue a file with a specific file name. If it needs dependencies, we use specific names for those as well. With this approach, we need a way around that.

Enqueueing Files with Hashed Names in WordPress

What I want to do now is make sure that WordPress correctly prepares and enqueues all these files. The challenge is that I have no way to predict what the file names are any more. But that's ok. I'll show you how to set up a class to handle it. We'll start by sketching it out.

This class will

  • Define the main chunk name
  • Create a list of all vendor chunk names
  • Register all the chunks before enqueuing them
  • Register the main app script
  • Enqueue all the vendor chunks
  • Enqueue the main script with all of its dependencies

NB - This class will inherit from a base Loader class that handles the actual enqueuing. I'm not going to get into that here. I just want to focus on managing the file names. It does some things for me like register a base handle for scripts and styles, define a build directory based on environment, and so on.

// lib/Assets/ScriptLoader.php

namespace MyPlugin\Assets\Loader;

class ScriptLoader extends Loader {

  public $mainChunk;
  public $chunkNames;

  public function __construct() {
    parent::__construct();

    $this->chunkNames = [];
    $this->mainChunk = ''; 
  }

  public function enqueue() {
    global $post;

    $content = $post->post_content;

    if (!has_shortcode($content, $this->shortcodeSlug)) {
      return;
    }
  }

  private function prepareAppChunks() {

  }

  private function prepareAppScript() {

  }
}

The first thing we need is a list of all the files to enqueue. Let's zoom into the construct method to do that. The glob function will create an array of all the files. From there, we can push all the names of every file which isn't the main file into an array. The one that's left will be saved as the mainChunk property of the class. Great! We're almost done now.

public function __construct() {
  parent::__construct();
  $this->chunkNames = [];
  $this->mainChunk = '';

  $files = glob(PLUGIN_DIR . $this->buildDir . '/*.js');

  foreach ($files as $file) {
    $info = pathinfo($file);
    if (strpos($file, 'main') === false) {
      array_push($this->chunkNames, $info['filename']);
    } else {
      $this->mainChunk = $info['filename'];
    }
  }
}

Now that we've got the names in an array available to the whole class, we can register the chunks before enqueuing them. Let's zoom into the the prepareAppChunks method...

private function prepareAppChunks() {
  foreach ($this->chunkNames as $name) {
    wp_register_script(
      $this->handle . '-' . $name,
      PLUGIN_URL . $this->buildDir . '/' . $name . '.js',
      [],
      false,
      true
    );
  }
}

Now, I can prepare the app script and get a list of dependencies according to the handles I registered.

private function prepareAppScript() {
  $scriptDeps = array_map(function($name) {
    return $this->handle . '-' . $name;
  }, $this->chunkNames);

  wp_register_script(
    $this->handle,
    PLUGIN_URL . $this->buildDir . '/' . $this->mainChunk . '.js',
    $scriptDeps,
    false,
    true
  );
}

Finally, I can go back to the enqueue method and use the wp_enqueue_script function to enqueue everything as needed.

public function enqueue() {
  global $post;

  $content = $post->post_content;

  if (!has_shortcode($content, $this->shortcodeSlug)) {
    return;
  }
  // register the scripts
  $this->prepareAppChunks();
  $this->prepareAppScript();

  // enqueue the dependencies
  foreach ($this->chunkNames as $name) {
    wp_enqueue_script($this->handle . '-' . $name);
  }

  // enqueue the app script
  wp_enqueue_script($this->handle);
}

Now, when I look at the network tab, I see a nice list of hashed file names, properly enqueued, with small file sizes.

Chrome network tab results showing chunk file sizes in production

Conclusion

I really like this approach to managing file sizes in projects like this. There's great potential for building these types of applications within WordPress. But I think that we need to make sure that we're developing in a way which best serves our visitors. I believe that it is a kind of accessibility practice to serve them the content they need in a way which respects time, bandwidth, and resources. Now that I have this method worked out, I'm going to go back and see where else I can apply it. I hope you enjoyed this post!