Code splitting is a technique that allows us to split our JavaScript code into smaller chunks, which can be loaded on demand when needed. This helps optimize the initial load time of our application by reducing the amount of code that needs to be downloaded and parsed upfront.

Consider a web application with various pages, such as a dashboard, user profile, settings page etc. Each of these pages requires a significant amount of JavaScript code to function properly. Without code splitting, the entire application code would need to be loaded upfront, resulting in a slow initial loading time and increased resource consumption.

With code splitting, we can dynamically load only the code that is needed for each specific page. For example, when a user navigates to the dashboard page, only the JavaScript code necessary for that page is fetched and executed. Similarly, when the user moves to the user profile or settings page, the corresponding code chunks are loaded on-demand.

Code Splitting explained via cartoon

Code Splitting explained via cartoon

Webpack provides built-in support for code splitting through its dynamic import syntax and a feature called splitChunks. Here's an overview of how we can use code splitting in webpack:

  1. To split our code, we can use the dynamic import syntax, which returns a Promise that resolves to the module we want to load. Here's an example:

    import './index.scss'
    
    const form = document.getElementById('add-form') as HTMLFormElement
    const output = document.getElementById('result') as HTMLOutputElement
    
    form.addEventListener('submit', async (e) => {
      e.preventDefault()
      const first = +(e.target as HTMLFormElement)['first'].value
      const second = +(e.target as HTMLFormElement)['second'].value
      const { sum } = await import('./utils')
      const result = sum([first, second])
      output.innerText = `Total = ${result}`
    })
    
    export function multiply(nums: number[]) {
      const result = nums.reduce((acc, num) => acc * num, 1)
      return result
    }
    
    export function sum(nums: number[]) {
      const result = nums.reduce((acc, num) => acc + num, 0)
      return result
    }
    
  2. In our codebase, we've organized our utility functions into a separate utils.ts file. One of these utility functions, let's say the sum function, is dynamically loaded only when the form is submitted.

  3. Next let’s update our module property in tsconfig.json from es6 to ESNext. ES6 modules, as they stand, have no way to support import().

    {
      "compilerOptions": {
        "esModuleInterop": true,
        "forceConsistentCasingInFileNames": true,
        "module": "ESNext",
        "skipLibCheck": true,
        "strict": true,
        "target": "es6"
      }
    }
    
  4. Webpack's default behavior does not include searching for .ts files during module resolution. To enable resolution of TypeScript files, we need to modify the resolve.extensions configuration. It is crucial to include the default values, such as .js, alongside .ts to maintain compatibility with modules that expect automatic resolution of .js files.

    const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
    const HtmlWebpackPlugin = require('html-webpack-plugin')
    const MiniCssExtractPlugin = require('mini-css-extract-plugin')
    const { PurgeCSSPlugin } = require('purgecss-webpack-plugin')
    const TerserPlugin = require('terser-webpack-plugin')
    const path = require('path')
    
    module.exports = {
      entry: './src/index.ts',
      module: {
        rules: [
          {
            test: /\\.scss$/,
            use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
          },
          {
            test: /\\.ts$/,
            use: 'ts-loader',
          },
        ],
      },
      optimization: {
        minimizer: [new CssMinimizerPlugin(), new TerserPlugin()],
      },
      output: {
        filename: 'main.js',
        path: path.resolve(__dirname, '../dist'),
      },
      plugins: [
        new HtmlWebpackPlugin({
          filename: 'index.html',
          template: './src/index.html',
        }),
        new MiniCssExtractPlugin({
          filename: 'index.css',
        }),
        new PurgeCSSPlugin({
          paths: ['./src/index.html'],
        }),
      ],
      resolve: {
        extensions: ['.ts', '.js'],
      },
    }
    
  5. After running the build process with the updated Webpack configuration, inspect the dist directory and observe that Webpack has generated two JavaScript files: the main bundle and a dynamically imported bundle.

    1. Main Bundle: The main bundle file, typically named something like main.js or bundle.js, contains the code from your entry point file(s) (e.g., index.js or main.ts) and any statically imported modules. It represents the initial bundle that is loaded when the application starts.
    2. Dynamically Imported Bundle: The dynamically imported bundle is a separate JavaScript file generated by Webpack to hold the code for dynamically imported modules. When we use dynamic imports in our code, Webpack creates a separate bundle specifically for the code that is loaded dynamically at runtime. This bundle is typically named with a unique identifier or hash, such as 1a2b3c.js.

By splitting the code into separate bundles, Webpack enables more efficient loading of your application. The main bundle is loaded initially, and the dynamically imported bundle is fetched and loaded only when the corresponding dynamic import is triggered during runtime.

⚠️ A Bundle Efficiency Concern

When using dynamic imports in Webpack, tree shaking may not work as expected by default. Inspect the utils bundle and observe that webpack has bundled both the sum and the multiply function in the final output. When a module is dynamically imported, it automatically makes that module ineligible for tree shaking. The result of the dynamic import is an object with all the exports of the module. Due to the dynamic nature of JavaScript, webpack can't easily determine which exports will be used, so webpack can't do any tree shaking.

However, there is a workaround available. The webpack team suggests utilizing the webpackExports magic comment to explicitly inform webpack about the specific imports that should be included in the bundle. By using this comment, only the specified imports will be included, resulting in a more optimized bundle size.

https://github.com/webpack/webpack.js.org/issues/2684

<aside> 📦 Webpack magic comments, also known as webpack chunk or module comments, are special annotations that you can include in our code to provide hints or instructions to the webpack bundler during the build process. These comments allow us to control the behavior of webpack in relation to code splitting, chunk naming, and other bundling optimizations.

</aside>

import './index.scss'

const form = document.getElementById('add-form') as HTMLFormElement
const output = document.getElementById('result') as HTMLOutputElement

form.addEventListener('submit', async (e) => {
  e.preventDefault()
  const first = +(e.target as HTMLFormElement)['first'].value
  const second = +(e.target as HTMLFormElement)['second'].value
  const { sum } = await import(/* webpackExports: ["sum"] */ './utils')
  const result = sum([first, second])
  output.innerText = `Total = ${result}`
})

After re-running the build process and examining the generated bundle for the utils module, you will notice that only the sum utility function has been bundled in the output.

Resources

The unexpected impact of dynamic imports on tree shaking