DevTips – How to Compile Your Assets Efficiently

Published in WordPress.

One of the goals we had for this year was to refactor our two flagship plugins (Nelio Content and Nelio A/B Testing) to TypeScript and React Hooks. Well, we’re just half year through and we can already say that this goal has been a total success 🥳 However, I must admit: the road has been a bit more complicated than expected… especially if we consider that, after introducing TypeScript, our plugin build times went from a few seconds to over two minutes! Something was wrong and we didn’t know what.

Well, in today’s post I’d like to tell you a bit about that experience and what we did to fix it. After all, we all know TypeScript will always slow down the build process a bit (type checking comes at a price), but it shouldn’t be that much! Well, spoiler alert: the problem was never TypeScript… it was my config. TypeScript only made it “obvious.” So let’s get started, shall we?

Toy Project

To help you understand the issue we ran into a few weeks ago and how we fixed it, the best we can do is to create a very simple example for you to follow along. Let’s build a simple WordPress plugin that uses TypeScript and how a misconfiguration can result in extremely slow compilation times. If you need some help to get started, check out this post on WordPress development environments.

Plugin Creation

The first thing you should do is to create a new folder with the name of your plugin (for example, nelio) in your WordPress’ /wp-content/plugins directory. Then add the main file (nelio.php) with the following content:

<?php
/**
 * Plugin Name: Nelio
 * Description: This is a test plugin.
 * Version:     0.0.1
 *
 * Author:      Nelio Software
 * Author URI:  https://neliosoftware.com
 * License:     GPL-2.0+
 * License URI: http://www.gnu.org/licenses/gpl-2.0.txt
 *
 * Text Domain: nelio
 */
if ( ! defined( 'ABSPATH' ) ) {
  exit;
}

If you’ve done it right, you’ll see that you can now activate the plugin in WordPress:

Screenshot of our toy plugin in the plugins list
Screenshot of our toy plugin in the plugins list.

Sure, the plugin does nothing yet… but at least it shows up 🙂

TypeScript

Let’s add some TypeScript code! The first thing we will do is initialize npm in our plugin folder. Run this:

npm init

and follow the on-screen instructions. Then, install the dependencies:

npm add -D @wordpress/scripts @wordpress/i18n

and edit the package.json file to add the build scripts required by @wordpress/scripts:

{
  ...
  "scripts": {
    "build": "wp-scripts build",
    "start": "wp-scripts start",
  },
  ...
}

Once npm is ready, let’s customize TypeScript by adding a tsconfig.json file:

{
  "compilerOptions": {
    "target": "es5",
    "module": "esnext",
    "moduleResolution": "node",
    "outDir": "build",
    "lib": [ "es7", "dom" ]
  },
  "exclude": [ "node_modules" ]
}

Finally, let’s write some TS code. We want this to be very simple but “close enough” to what we had in Nelio A/B Testing and Nelio Content, so create a src folder in our project with a couple of TypeScript files inside: index.ts and utils/index.ts.

On the one hand, let’s assume utils/index.ts is a utility package. That is, it contains a few functions that other files in our project might need. For instance, let’s say it provides the classical min and max functions:

export const min = ( a: number, b: number ): number =>
  a < b ? a : b;
export const max = ( a: number, b: number ): number =>
  a > b ? a : b;

On the other hand, let’s take a look at the main file of our app: index.ts. For our testing purposes, all we want is a simple script that uses our utility package and a WordPress dependency. Something like this:

import { __, sprintf } from '@wordpress/i18n';
import { min } from './utils';
const a = 2;
const b = 3;
const m = min( a, b );
console.log(
  sprintf(
    /* translators: 1 -> num, 2 -> num, 3 -> num */
    __( 'Min between %1$s and %2$s is %3$s', 'nelio' ),
    a, b, m
  )
);

@wordpress/scripts Default Settings

If we were to build the project right now using npm run build, everything would work out of the box. And that’s simply because @wordpress/scripts (i.e. the underlying tool we’re using for building our project) has been designed to work with a codebase like that of our example. That is, if we have an index.ts file in the src folder, it will generate an index.js file in the build folder along with an index.asset.php dependency file:

> ls build
index.asset.php index.js

Why two files? Well, one’s the compiled JavaScript file (duh) and the other is a dependencies file with some useful information about our script. In particular, it tells us which JavaScript libraries, from those included in WordPress, it relies on. For example, our index.ts relies on the @wordpress/i18n package to internationalize strings, and that’s a library included in WordPress so… yup, wp-i18n will show up in index.asset.php:

build/index.asset.php
<?php
return array(
  'dependencies' => array( 'wp-i18n' ),
  'version'      => 'c6131c7f24df4fa803b7',
);

Unfortunately, the default configuration is not perfect, if you ask me. Here’s why.

If we introduce a bug in your code (e.g., let’s call the min function with a string arg instead of a number):

const m = min( `${ a }`, b );

this should trigger an error. But it doesn’t. It compiles without problems.

Type Checks during Compilation with TypeScript

To solve the aforementioned “limitation,” we just need to create our own webpack config file and tell it to use tsc (the TypeScript compiler) whenever it encounters TS code. In other words, we need the following webpack.config.json file:

const defaultConfig = require( '@wordpress/scripts/config/webpack.config' );
const config = {
  ...defaultConfig,
  module: {
    ...defaultConfig.module,
    rules: [
      ...defaultConfig.module.rules,
      {
        test: /\.tsx?$/,
        use: 'ts-loader',
        exclude: /node_modules/,
      },
    ],
  },
};
module.exports = {
  ...config,
  entry: './src/index',
  output: {
    path: __dirname + '/build',
    filename: 'index.js',
  },
};

As you can see, it starts by loading the default webpack configuration included in the @wordpress/scripts package and then extends the defaultConfig by adding a ts-loader to all .ts files. Easy peasy!

And now, behold:

> npm run build
...
ERROR in ...src/index.ts
  TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
webpack 5.74.0 compiled with 1 error in 4470 ms

compiling our project results in an error. Hurrah! Sure, it’s a little slower, but at least we have some security checks before uploading anything to production.

Enqueuing the Scripts

Well, now that you know there’s an issue in your code, fix it and compile the plugin again. Did it all work? Cool! Because now it’s time to enqueue the script and its dependencies on PHP so that we can try it out in our browser.

Open nelio.php and append the following snippet:

add_action(
  'admin_enqueue_scripts',
  function() {
    $path  = untrailingslashit( plugin_dir_path( __FILE__ ) );
    $url   = untrailingslashit( plugin_dir_url( __FILE__ ) );
    $asset = require( $path . '/build/index.asset.php' );
    wp_enqueue_script(
      'nelio',
      $url . '/build/index.js',
      $asset['dependencies'],
      $asset['version']
    );
  }
);

Next, go to the WordPress dashboard (any page will do the trick) and take a look at your browser’s JavaScript console. You should see the following text:

Min between 2 and 3 is 2

Nice!

What About MY Dependencies?

Let’s talk for a second about dependency management in JavaScript/webpack/WordPress. @wordpress/scripts is configured in such a way that, by default, if your project uses a dependency that’s packaged in WordPress core, it will be listed as such in the .asset.php file. This, for instance, explains why @wordpress/i18n was listed in the dependencies file of our script.

But what about dependencies to “other” packages? What happened to our utils package? Long story short: by default webpack compiles and merges all dependencies into the output script Just look at the generated JS file (compile it with npm run start to disable minification):

...
var __webpack_modules__ = ({
  "./src/utils/index.ts": ((...) => 
    ...
    var min = function (a, b) {
      return a < b ? a : b;
    };
    var max = function (a, b) {
      return a > b ? a : b;
    };
  }),
...

See? Our utils code is right there, embedded in our output script.

What about @wordpress/i18n? Well, it’s just a simple reference to a global variable:

...
var __webpack_modules__ = ({
  "./src/utils/index.ts": ...,
  "@wordpress/i18n": ((module)) => {
    module.exports = window["wp"]["i18n"];
  })
...

As I was saying, @wordpress/scripts comes with a plugin, Dependency Extraction Webpack Plugin, that “excludes” certain dependencies from the compilation process and generates code assuming they’ll be available in the global scope. In our example, for instance, we can see that @wordpress/i18n is in wp.i18n. That’s why, when enqueueing our script, we also need to enqueue its dependencies.

Custom Config to Generate Two Separate Scripts

With all this in mind, let’s say we want to achieve the same thing with our utils package. That is, we don’t want its content to be embedded in index.js, but rather it should be compiled into its own.js file and appear as a dependency on index.asset.php. How do we do that?

First, we should rename the import statement in index.js so that it seems a real package. In other words, instead of importing the script using a relative path (./utils), it’d be nice if we could use a name like @nelio/utils. To do this, all you have to do is edit the project’s package.json file to add a new dependency in dependencies :

{
  ...
  "dependencies": {
    "@nelio/utils": "./src/utils"
  },
  "devDependencies": {
    "@wordpress/i18n": ...,
    "@wordpress/scripts": ...
  },
  ...
}

run npm install to create a symlink in node_modules pointing to this “new” package, and finally run npm init in src/utils so that, from npm’s point of view, @nelio/utils is a valid package.

Then, to compile @nelio/utils into its own script, we need to edit our webpack.config.js configuration and define two exports:

  • the one we already had (./src/index.ts)
  • another export to compile ./src/utils to a different file, exposing its exports in a global variable named, for example, nelio.utils.

In other words, we want this:

module.exports = [
  {
...config,
    entry: './src/index',
    output: {
      path: __dirname + '/build',
      filename: 'index.js',
    },
  },
  {
...config,
    entry: './src/utils',
    output: {
      path: __dirname + '/build',
      filename: 'utils.js',
      library: [ 'nelio', 'utils' ],
      libraryTarget: 'window',
    },
  },
];

Compile the code again and take a look at the ./build folder — you’ll see that we now all have two scripts. Take a look at ./build/utils.js and you’ll see how it defines nelio.utils, as expected:

...
var min = function (a, b) {
  return a < b ? a : b;
};
var max = function (a, b) {
  return a > b ? a : b;
};
(window.nelio = window.nelio || {}).utils = __webpack_exports__;
...

Unfortunately, we’re only halfway through. If you also take a look at ./build/index.js, you’ll see that src/utils is still embedded in it… shouldn’t it be an “external dependency” and use the global variable we just defined?

Custom Config to Create External Dependencies

To transform @nelio/utils into an actual external dependency, we need to further customize our webpack and take advantage of the dependency extraction plugin we mentioned earlier. Simply reopen the webpack.config.js file and modify the config variable as follows:

const DependencyExtractionWebpackPlugin = require( '@wordpress/dependency-extraction-webpack-plugin' );
const config = {
  ...defaultConfig,
  module: {
    ...defaultConfig.module,
    rules: [
      ...defaultConfig.module.rules,
      {
        test: /\.tsx?$/,
        use: 'ts-loader',
        exclude: /node_modules/,
      },
    ],
  },
  plugins: [
    ...defaultConfig.plugins.filter( ( p ) =>
      p.constructor.name !== 'DependencyExtractionWebpackPlugin'
    ),
    new DependencyExtractionWebpackPlugin( {
      requestToExternal: ( request ) =>
        '@nelio/utils' === request ? [ 'nelio', 'utils' ] : undefined,
      requestToHandle: ( request ) =>
        '@nelio/utils' === request ? 'nelio-utils' : undefined,
      outputFormat: 'php',
    } ),
  ],
};

so that all references to @nelio/utils are translated as nelio.utils in the code, and there’s a dependency to the script handler nelio-utils. If we take a look at the dependencies of both scripts, we see the following:

build/index.asset.php
<?php
return array( 'dependencies' => array('nelio-utils', 'wp-i18n')... ?>
build/utils.asset.php
<?php
return array( 'dependencies' => array()... ?>

and if we look in./build/index.js we confirm that indeed the @nelio/utils dependency is now external:

...
var __webpack_modules__ = ({
  "@nelio/utils": ((module)) => {
    module.exports = window["nelio"]["utils"];
  }),
  "@wordpress/i18n": ((module)) => {
    module.exports = window["wp"]["i18n"];
  })
...

There’s one last problem we need to address, though. Go to your browser, refresh the dashboard page, and look at the console. Nothing shows up, right? Why? Well, nelio now depends on nelio-utils, but this scripts is not registered in WordPress… so its dependencies can’t be met right now. To fix this, edit nelio.php and register the new script:

add_action(
  'admin_enqueue_scripts',
  function() {
    $path  = untrailingslashit( plugin_dir_path( __FILE__ ) );
    $url   = untrailingslashit( plugin_dir_url( __FILE__ ) );
    $asset = require( $path . '/build/utils.asset.php' );
    wp_register_script(
      'nelio-utils',
      $url . '/build/utils.js',
      $asset['dependencies'],
      $asset['version']
    );
  }
);

How to Speed Up the Build Process

If we run the build process several times and average how long it takes to complete, we see that the build runs in about 10 seconds:

> yarn run build
...
./src/index.ts + 2 modules ...
webpack 5.74.0 compiled successfully in 5703 ms
...
./src/utils/index.ts ...
webpack 5.74.0 compiled successfully in 5726 m
Done in 10.22s.

which may not seem like much but this is a simple toy project and, as I was saying, real projects like Nelio Content or Nelio A/B Testing need minutes to compile.

Why is it “so slow” and what can we do to speed it up? As far as I could tell, the problem lied in our webpack configuration. The more exports you have in your wepack’s module.exports, the slower the compilation time becomes. However, a single export is way, way faster.

Let’s refactor our project slightly to use a single export. First of all, create an export.ts file in src/utils with the following content:

export * as utils from './index';

Then, edit your webpack.config.js so that it has a single export with two entries:

module.exports = {
  ...config,
  entry: {
    index: './src/index',
    utils: './src/utils/export',
  },
  output: {
    path: __dirname + '/dist',
    filename: 'js/[name].js',
    library: {
      name: 'nelio',
      type: 'assign-properties',
    },
  },
};

Finally, build the project again:

> yarn run build
...
built modules 522 bytes [built]
  ./src/index.ts + 2 modules ...
  ./src/utils/export.ts + 1 modules ...
webpack 5.74.0 compiled successfully in 4339 ms
Done in 6.02s.

It only took 6 seconds, which is almost half the time it used to! Pretty neat, huh?

Summary

TypeScript will help you to improve quality to the code, because it lets you check at compile time that the types are correct and there are no inconsistencies. But like everything in life, the advantages of using TypeScript come at a price: compiling the code becomes a bit slower.

In today’s post we have seen that, depending on your webpack’s config, compiling your project can be much faster (or slower). The sweet spot requires a single export… and that’s what we’ve talked about today.

I hope you liked the post. If so, please share it. If you know other ways to optimize webpack, please tell me in the comment section below. Have a good day!

Featured image by Saffu on Unsplash.

Leave a Reply

Your email address will not be published. Required fields are marked: •

I have read and agree to the Nelio Software Privacy Policy

Your personal data will be located on SiteGround and will be treated by Nelio Software with the sole purpose of publishing this comment here. The legitimation is carried out through your express consent. Contact us to access, rectify, limit, or delete your data.