loading...
Cover image for Granular chunks and JavaScript modules for faster page loads

Granular chunks and JavaScript modules for faster page loads

yoriiis profile image Joris Daniel Updated on ãƒģ6 min read

The race to performance increases from year to year and the front-end ecosystem is evolving more than never.

This article covers how to build a Webpack configuration to improve page load performance. Learn how to set up a granular chunking strategy to split common code. Then, serve modern code with JavaScript modules to modern browsers.

Webpack configuration

To start, the configuration has the following features:

  • Multiple Page Application
  • Development and production environment
  • JavaScript transpilation with Babel and preset-env
  • CSS extraction
  • Default optimization behavior

First, let's write our Webpack starter configuration.

webpack.config.js

const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

// Export a function for environment flexibility
module.exports = (env, argv) => {

  // Webpack mode from the npm script
  const isProduction = argv.mode === 'production';

  return {
    watch: !isProduction,

    // Object entry for Multiple Page Application
    entry: {
      home: 'home.js',
      news: 'news.js'
    },

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

    module: {
      rules: [
        // Babel transpilation for JavaScript files
        {
          test: /\.js$/,
          loader: 'babel-loader',
          options: {
            presets: [
              [
                '@babel/preset-env',
                {
                  targets: {
                    browsers: ['last 2 versions']
                  },
                  // Include polyfills from core-js package
                  useBuiltIns: 'usage',
                  corejs: 3
                }
              ]
            ]
          }
        },

        // Extract content for CSS files
        {
          test: /\.css$/i,
          use: [MiniCssExtractPlugin.loader, 'css-loader']
        }
      ]
    },

    resolve: {
      extensions: ['.js', '.css']
    },

    plugins: [
      // Configure CSS extraction
      new MiniCssExtractPlugin({
        filename: '[name].css',
        chunkFilename: '[name].css'
      })
    ],

    // Default optimization behavior depending on environment
    optimization: {
      minimize: isProduction
    }
  }
};

For more flexibility, the configuration exports a function, but other configuration types are available.

The entry key is an object to accept multiple entries (Multiple Page Application). Each entry contains the code for a specific page of the site (ex: home, news, etc.).

The module.rules key is an array with two rules, one for the JavaScript files and one for the CSS files.

The babel-loader is used to transpile JavaScript with the presets from @babel/preset-env.

💡 Browsers list
The last 2 versions transpiles the code for the last two versions of every browsers. It's not the best option, choose instead a browsers list to match your real audience.

The css-loader is used to interpret CSS files and MiniCssExtractPlugin to extract CSS content in a dedicated file.

The plugins array has a unique plugin MiniCssExtractPlugin to extract CSS content.

The optimization object has the default behavior; the minimize option depends of the Webpack mode (development or production).

💡 Minimizer
The minimize key can be replaced by minimizer to perform optimization with TerserPlugin.

Let's add the npm scripts that will start and build Webpack:

package.json

{
  "start": "webpack --mode=development",
  "build": "webpack --mode=production"
}

Granular chunks

Split common code

Webpack splitChunks allows to split common code used inside all entrypoints.

This generates one entrypoint file for JavaScript and CSS plus multiple chunk files which contain common code.

Imagine the pages share some common code for the header. Without the optimization, common code is duplicated across every entrypoints.

Webpack — SplitChunks disabled

With the optimization, a chunk is automatically created with the shared code.

Webpack — SplitChunks enabled

To use this option with multiple entrypoints, the easiest is to install the chunks-webpack-plugin.

npm install chunks-webpack-plugin --save-dev

Then, update the Webpack configuration to add the plugin.

const ChunksWebpackPlugin = require('chunks-webpack-plugin');

module.exports = (env, argv) => {
  return {
    // ...
    plugins: [
      new ChunksWebpackPlugin({
        outputPath: path.resolve(__dirname, './dist/templates'),
        fileExtension: '.html.twig',
        templateStyle: '<link rel="stylesheet" href="{{chunk}}" />',
        templateScript: '<script defer src="{{chunk}}"></script>'
      })
    ]
  };
};

Enable the optimization.splitChunks to target all type of chunks.

module.exports = (env, argv) => {
  return {
    // ...
    optimization: {
      splitChunks: {
        chunks: 'all',
        name: false
      }
    }
  };
};

💡 Build performance
Enable splitChunks only for production. I strongly advise you to enable the name option for debugging.

That's all, granular chunking is done, no more configuration 🎉

Include chunk templates

Now that everything is set up, include the generated templates in the page templates.

With a multiple page application, a base layout is commonly used and pages override blocks. The layout defines the blocks. The pages includes specific files inside these blocks.

base.html.twig

<!DOCTYPE html>
<html>
  <head>
    {% block styles %}{% endblock %}
    {% block scripts %}{% endblock %}
  </head>
  <body>
    {% block body %}
      {# Application code here #}
    {% endblock %}
  </body>
</html>

home.html.twig

{% extends 'base.html.twig' %}

{% block styles %}
  {{ include "dist/templates/home-styles.html.twig" }}
{% endblock %}

{% block body %}{% endblock %}

{% block scripts %}
  {{ include "dist/templates/home-script.html.twig" }}
{% endblock %}

news.html.twig

{% extends 'base.html.twig' %}

{% block styles %}
  {{ include "dist/templates/news-styles.html.twig" }}
{% endblock %}

{% block body %}{% endblock %}

{% block scripts %}
  {{ include "dist/templates/news-script.html.twig" }}
{% endblock %}

Content of these generated templates will looks like this:

home-styles.html.twig

<link rel="stylesheet" href="dist/assets/vendors~home~news.css" />
<link rel="stylesheet" href="dist/assets/home.css" />

home-scripts.html.twig

<script src="dist/assets/vendors~home~news.js"></script>
<script src="dist/assets/home.js"></script>

Script type module & nomodule

Many polyfills are not needed for modern browsers. By using modules, Babel transpilation can be avoided and bundle sizes are reduced.

HTML provides useful attributes for the <script> tag to detect modern browsers and JavaScript modules' support.

<script type="module">

Serve JavaScript modules with ES2015+ syntax for modern browsers (without Babel transpilation).

<script src="dist/assets/modern/home.js" type="module"></script>

<script nomodule>

Serve JavaScript with ES5 syntax for older browsers (with Babel transpilation).

<script src="dist/assets/legacy/home.js" nomodule></script>

Browsers support

Browsers that support modules ignore scripts with the nomodule attribute. And vice versa, browsers that do not support modules ignore scripts with the type="module" attribute.

This feature is supported by all latest versions of modern browsers, see on Can I use.

⚠ī¸ Bad guys
IE 11 and Safari 10 are a problematic and download both bundles. Discussion is in progress to fix it on Safari, see the Github Gist.

Multiple Webpack configurations

Instead of exporting a single Webpack configuration, you may export multiple configurations. Simply wrap the different object configurations inside an array.

Let's create a function to avoid code duplication between our configurations.

config-generator.js

const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const ChunksWebpackPlugin = require('chunks-webpack-plugin');

const configGenerator = ({ browsers, isProduction, presets }) => {
  // Custom attribute depending the browsers
  const scriptAttribute = browsers === 'modern' ? 'type="module"' : 'nomodule';

  return {
    // The name of the configuration
    name: browsers,

    watch: !isProduction,
    entry: {
      home: 'home.js',
      news: 'news.js'
    },
    output: {
      path: path.resolve(__dirname, `./dist/assets/${browsers}`),
      filename: '[name].js'
    },
    module: {
      rules: [
        {
          test: /\.js$/,
          loader: 'babel-loader',
          options: {
            // Presets depending the browsers
            presets
          }
        },
        {
          test: /\.css$/i,
          use: [MiniCssExtractPlugin.loader, 'css-loader']
        }
      ]
    },
    resolve: {
      extensions: ['.js', '.css']
    },
    plugins: [
      new MiniCssExtractPlugin({
        filename: '[name].css',
        chunkFilename: '[name].css'
      }),
      new ChunksWebpackPlugin({
        outputPath: path.resolve(__dirname, `./dist/templates/${browsers}`),
        fileExtension: '.html.twig',
        templateStyle: '<link rel="stylesheet" href="{{chunk}}" />',
        // Custom tags depending the browsers
        templateScript: `<script defer ${scriptAttribute} src="{{chunk}}"></script>`
      })
    ],
    optimization: {
      splitChunks: {
        chunks: 'all',
        name: false
      }
    }
  };
};

Next, the webpack.config.js needs to export two configuration with the configGenerator function. The first for modern browsers and the second for legacy browsers, with the different Babel presets. The presets target esmodules browsers instead of a browsers list.

webpack.config.js

import configGenerator from './config-generator';

module.exports = (env, argv) => {
  const isProduction = argv.mode === 'production';

  // Modern browsers that support Javascript modules
  const configModern = configGenerator({
    browsers: 'modern',
    isProduction,
    presets: [
      [
        '@babel/preset-env',
        {
          targets: {
            esmodules: true
          }
        }
      ]
    ]
  });

  // Legacy browsers that do not support Javascript modules
  const configLegacy = configGenerator({
    browsers: 'legacy',
    isProduction,
    presets: [
      [
        '@babel/preset-env',
        {
          targets: {
            esmodules: false
          },
          useBuiltIns: 'usage',
          corejs: 3
        }
      ]
    ]
  });

  return [configModern, configLegacy];
};

When running Webpack, all configurations are built.

💡 Run a specific configuration
Add --config-name=<BROWSERS> at the end of the npm script. Replace <BROWSERS> by the name of the Webpack configuration (modern or legacy in the example above).

Update chunk templates

Include both bundles for JavaScript to target modern and legacy browsers. For CSS, the configuration is identical for both browsers, you can import one or the other.

home.html.twig

{% extends 'base.html.twig' %}

{% block styles %}
  {{ include "dist/templates/modern/home-styles.html.twig" }}
{% endblock %}

{% block body %}{% endblock %}

{% block scripts %}
  {{ include "dist/templates/modern/home-script.html.twig" }}
  {{ include "dist/templates/legacy/home-script.html.twig" }}
{% endblock %}

news.html.twig

{% extends 'base.html.twig' %}

{% block styles %}
  {{ include "dist/templates/modern/news-styles.html.twig" }}
{% endblock %}

{% block body %}{% endblock %}

{% block scripts %}
  {{ include "dist/templates/modern/news-script.html.twig" }}
  {{ include "dist/templates/legacy/news-script.html.twig" }}
{% endblock %}

Conclusion

You now understand how to customize the Webpack configuration to improve page load performance.

Granular chunks with Webpack and chunks-webpack-plugin offer a better strategy to shares common code.

Next, JavaScript modules provide minimal polyfills and smaller bundles for modern browsers.

The complete example is available on Github, so you can have fun with it! 🧑‍đŸ’ģ

Additional reading

Photo by @dylan_nolte on Unsplash
With thanks to Emilie Gervais for her review

Posted on by:

yoriiis profile

Joris Daniel

@yoriiis

Hello! I'm a Front-end Engineer at Prisma Media. Passionate about code, web performance, UX and design, I work on several open-source projects. 😎 đŸ’ģ đŸŒĩ 🏍 ✈ 🌏

Discussion

pic
Editor guide