Skip to content
Back to blog

Bundlers

A summary of build-related content

Updated

Bundle

Purpose

  • Compress code, reduce size
  • Merge files, reduce the number of HTTP requests
  • Syntax conversion, converting non-js syntax into js syntax

Main Process

  • Collect dependencies
  • Generate the dependency graph
  • Build and assemble the code

Implementation

// A module bundler compiles small pieces of code into larger and more complex code that can run in a Web browser.

// These small pieces are just JavaScript files and the dependencies between them, which are exactly what the module system represents
// https://webpack.js.org/concepts/modules

// A module bundler has the concept of an entry file. Instead of adding some
// script tags in the browser and letting them run, we let the bundler know
// which file is the main file of our application. This file can bootstrap
// our entire application.

// Our bundler will start from that entry file and try to understand
// which files it depends on. Then, it will try to understand which files
// its dependencies depend on, and it keeps doing this until it discovers
// every module in our application and how they depend on each other.

// This understanding of the project is called the `dependency graph`.

// In this example, we will create a dependency graph and use it to bundle
// all of its modules together.

// Let's begin :)

// > Please note: this is a very simplified example.
// Only one pass of handling cyclic dependencies, caching module exports, and parsing each module
// is performed; other aspects are skipped to keep this example as simple as possible.

const fs = require('node:fs')
const path = require('node:path')
const babylon = require('babylon')
const traverse = require('babel-traverse').default
const { transformFromAst } = require('babel-core')

let ID = 0

// We first create a function that accepts a file path, reads the content, and extracts its dependencies.
function createAsset(filename) {
  // Read the file's content as a string.
  const content = fs.readFileSync(filename, 'utf-8')

  // Now we try to find out which files this file depends on. We can do that by looking at its content

  // for `import` strings. However, this is a very clunky approach, so we will use a JavaScript parser instead.

  // A JavaScript parser is a tool that can read and understand JavaScript code.

  // They generate a more abstract model called an `ast (abstract syntax tree)`.

  // I strongly recommend you check out [`ast explorer`](https://astexplorer.net) to see what an `ast` looks like

  // The `ast` contains a lot of information about our code. We can query it to understand what our code is trying to do.
  const ast = babylon.parse(content, {
    sourceType: 'module',
  })

  // This array will hold the relative paths of the modules this module depends on.
  const dependencies = []

  // We traverse the `ast` to try to understand which modules this module depends on.
  // To do that, we check every `import` declaration in the `ast`. ❤️
  traverse(ast, {
    // `Ecmascript` modules are fairly simple because they are static. This means you can't `import` a variable,
    // or conditionally `import` another module.
    // Every time we see an `import` declaration, we can count its value as a `dependency`.
    ImportDeclaration: ({ node }) => {
      // We push the value we import into the dependencies array. ⬅️
      dependencies.push(node.source.value)
    },
  })

  // We also assign a unique identifier to this module by incrementing a simple counter.
  const id = ID++

  // We use `Ecmascript` modules and other JavaScript features that may not be supported in all browsers.
  // To ensure `our bundle` runs in all browsers,
  // we will use [babel](https://babeljs.io) to transpile it

  // The `presets` option is a set of rules that tells `babel` how to transpile our code.
  // We use `babel-preset-env` to convert our code into something browsers can run.
  const { code } = transformFromAst(ast, null, {
    presets: ['env'],
  })

  // Return all the information about this module.
  return {
    id,
    filename,
    dependencies,
    code,
  }
}

// Now that we can extract the dependencies of a single module, we will solve the problem by extracting the dependencies of the `entry file`.
// Then, we will extract the dependencies of each of its dependencies. And keep looping
// until we understand every module in the application and how they depend on each other. This understanding of the project is called the `dependency graph`.
function createGraph(entry) {
  // First parse the entire file.
  const mainAsset = createAsset(entry)

  // We will use a `queue` to parse the dependencies of each `asset`.
  // We are defining an array with only the entry asset.
  const queue = [mainAsset]

  // We use a `for ... of` loop to iterate over the queue.
  // Initially this queue only has one asset, but as we iterate it, we will push additional new assets into the queue.
  // This loop will terminate when the queue is empty.
  for (const asset of queue) {
    // Each of our assets has a list of relative paths of the modules it depends on.
    // We will iterate over them, parse them with our `createAsset()` function, and track this module's dependencies in this object.
    asset.mapping = {}

    // This is the directory this module is in.
    const dirname = path.dirname(asset.filename)

    // We iterate over its list of relative paths
    asset.dependencies.forEach((relativePath) => {
      // Our `createAsset()` function needs an absolute filename.
      // But the dependencies array holds an array of relative paths.
      // These paths are relative to the file importing them.
      // We can turn the relative path into an absolute one by joining the relative path with the path of the parent asset's directory.
      const absolutePath = path.join(dirname, relativePath)

      // Parse the asset, read its content, and extract its dependencies.
      const child = createAsset(absolutePath)

      // It is important for us to know that `asset` depends on `child`.
      // We express this one-to-one relationship by adding a new property (with value child.id) to the `asset.mapping` object.
      asset.mapping[relativePath] = child.id

      // Finally, we push the `child` asset into the queue so its dependencies will also be iterated and parsed.
      queue.push(child)
    })
  }

  // At this step, the queue is an array containing every module in the target application:
  // this is our representation graph.
  return queue
}

// Next, we define a function that will use our `graph` and return a bundle that can run in the browser.

// Our bundle will have just one self-invoking function:
// `(function() {})()`
// The function will receive only one parameter: an object containing the information of each module in the `graph`.
function bundle(graph) {
  let modules = ''

  // Before we get to the body of that function, we will build the object that serves as its parameter.
  // Please note that the string we build is wrapped by two curly braces ({}), so for each module,
  // we add a string of this format: `key: value,`.
  graph.forEach((mod) => {
    //  Each module in the graph has an `entry` in this object. We use the `module id` as the `key` and an array as the `value` (an array because we have 2 values in each module).

    // The first value is each module's code wrapped in a function. This is because modules should be scoped: defining a variable in one module should not affect other modules or the global scope.

    // After our modules are `transpiled {by babel}`, they use the `commonjs` module system: they expect a `require`, a `module`, and an `exports` object to be available. Those are typically not available in browsers, so we will implement them and inject them into the function wrapper.

    // For the second value, we use `stringify` to serialize the relationship between the module and its dependencies (i.e., the asset.mapping above). The serialized object looks like this: `{'./relative/path': 1}`.

    // This is because our transpiled modules call `require()` with relative paths. When this function is called, we should be able to know which module in the dependency graph corresponds to that relative path for this module.
    modules += `${mod.id}: [
      function (require, module, exports) { ${mod.code} },
      ${JSON.stringify(mod.mapping)},
    ],`
  })

  // Finally, we implement the body of the self-invoking function.

  // We first create a `require()`⏰ function: it accepts a `module ID` and looks it up in the `modules` object we built earlier.

  // Get our wrapper function and the `mappings` object by destructuring `const [fn, mapping] = modules[id]`.

  // Our module's code calls `require()` with relative paths instead of module IDs.

  // But our `require`🌟 function receives a `module ID`. Also, two modules may `require()` the same relative path but mean two different modules.

  // To handle this, when a module is required, we create a new, dedicated `require` function for it to use.

  // It will be specific and will know to convert `its relative paths` into `IDs` by using `the module's mapping object`.

  // This mapping object happens to be the mapping between `relative paths and module IDs` for that specific module.

  // Lastly, with `commonjs`, when a module needs to be exported, it can expose the module's values by changing the exports object.
  // The require function will finally return the exports object.
  const result = `
    (function(modules) {
      function require(id) { //🌟
        const [fn, mapping] = modules[id];

        function localRequire(name) { //⏰
          return require(mapping[name]); //🌟
        }

        const module = { exports: {} };

        fn(localRequire, module, module.exports);

        return module.exports;
      }

      require(0);
    })({${modules}})
  `

  // We simply return the result, hooray! :)
  return result
}

const graph = createGraph('./example/entry.js')
const result = bundle(graph)

console.log(result)

tree-shaking

Modules That Support tree-shaking

  • Generally only ESM modules are supported; CJS modules need extra tooling to handle, and the effect is not as good as with ESM modules
  • Use the form of individual imports: both import * as A and import { A } work, but import A does not
  • package.json does not define sideEffects, or its value is not true

Babel 7 converts code to cjs by default, causing esm modules to fail tree-shaking properly

Hot Update

---
title: Hot Update Principle
---
graph TB
  Editor[Text Editor/IDE]
  File[File System]

  subgraph DevServer[Webpack Dev Server]
    Compiler[Webpack Compiler]
    HMR[HMR Server]
    Bundle[Bundle Server]
  end

  subgraph Browser[Browser]
    subgraph BundleJS[bundle.js]
      HMRRuntime[HMR Runtime]
      JS[Built JS Code]
    end
  end

  Editor -->|Save changes| File
  File -->|Trigger build| Compiler
  Compiler -->|Notify the hot update server which resources changed| HMR
  Compiler -->|Transmit the built Chunk| Bundle
  Bundle -->|Make the local chunk accessible to the browser via WS| BundleJS
  HMRRuntime -->|Output the locally built chunk to the browser| JS

File Fingerprints

JS File Fingerprints

  • hash: unique to the entire project; updates after each rebuild
  • chunkhash: generates the hash according to the entry; when one of the chunks built from the entry is updated, the value changes
  • contenthash: independent for each chunk; as long as the current chunk's content has not changed, it will not change

file-loader File Fingerprints

  • hash: unique to the entire project; updates after each rebuild
  • contenthash: independent for each chunk; as long as the current chunk's content has not changed, it will not change

Webpack Build Output Analysis

Overall Structure

Webpack wraps the transformed module output in a closure function, in the following format:

// import, require, etc. are replaced with the __webpack_require__ function
// export, exports are unified into adding fields to exports
function(__unused_webpack_module, exports, __webpack_require__) {
  // In production mode, the eval here is replaced with the actual file content instead of executing via eval
  eval('Transformed JS code of the file')
}

The modules in the project are recorded in __webpack_modules__; if already executed, the exports are cached in the __webpack_module_cache__ object

/** The key is the module ID, generally the file path relative to the root directory, or an npm package name */
var __webpack_modules__ = {
  // The entry file
  // In production mode, the key is the file's sequence number — the order in which it was accessed — so the entry file is generally 0
  "./src/entry.js": ((__unused_webpack_module, exports, __webpack_require__) => {
    // In production mode, the eval here is replaced with the actual file content instead of executing via eval
    eval("Execute the entry file code")
  })
}

The __webpack_require__ function is responsible for loading modules; its code is as follows:

function __webpack_require__(moduleId) {
  // If the module has already been executed, don't execute it again; directly return the cached module exports
  var cachedModule = __webpack_module_cache__[moduleId];
  if (cachedModule !== undefined) {
    return cachedModule.exports
  }
  // If the module has not been executed, initialize the module cache first, with exports defaulting to an empty object
  var module = __webpack_module_cache__[moduleId] = {
    exports: {}
  }

  // Execute the module, also collecting the module's exports
  __webpack_modules__[moduleId](module, module.exports, __webpack_require__)

  // Return the content exported by the module
  return module.exports
}

Synchronous Chunks

Synchronous chunks are inlined directly into __webpack_modules__, e.g.:

/** The key is the module ID, generally the file path relative to the root directory, or an npm package name */
var __webpack_modules__ = {
  // Modules are arranged in order of reference, with the entry at the bottom
  // Some variables are appropriately renamed to avoid variable name conflicts
  // In production mode, the key is the file's sequence number — the order in which it was accessed
  "./src/a.js": ((__unused_webpack_module, exports, __webpack_require__) => {
    // In production mode, the eval here is replaced with the actual file content instead of executing via eval
    eval("Transformed a.js code")
  }),
    // In production mode, the key is the file's sequence number — the order in which it was accessed
  "./src/b.js": ((__unused_webpack_module, exports, __webpack_require__) => {
    // In production mode, the eval here is replaced with the actual file content instead of executing via eval
    eval("Transformed b.js code")
  }),
    // In production mode, the key is the file's sequence number — the order in which it was accessed
  "./src/c.mjs": ((__unused_webpack_module, exports, __webpack_require__) => {
    // ESM enables strict mode
    "use strict";
    // In production mode, the eval here is replaced with the actual file content instead of executing via eval
    eval("Transformed c.mjs code")
  }),
  // Other modules
  // ...
  // The entry file
    // In production mode, the key is the file's sequence number — the order in which it was accessed — so the entry file is generally 0
  "./src/entry.js": ((__unused_webpack_module, exports, __webpack_require__) => {
    // In production mode, the eval here is replaced with the actual file content instead of executing via eval
    eval("Execute the entry file code")
  })
}

Asynchronous Chunks

Async chunks are not inlined directly into __webpack_modules__, but loaded dynamically via JSONP. Webpack generates a separate file for each async chunk and loads it via a script tag when needed.

// The loading mechanism for async chunks
// 1. Define the global chunk loading function
var chunkLoadingGlobal = self["webpackChunk"] = self["webpackChunk"] || [];

// 2. Define the JSONP callback function to handle loaded chunks
var webpackJsonpCallback = (parentChunkLoadingFunction, data) => {
  var [chunkIds, moreModules, runtime] = data

  // Add the new modules to __webpack_modules__
  for (var moduleId in moreModules) {
    if (__webpack_require__.o(moreModules, moduleId)) {
      __webpack_require__.m[moduleId] = moreModules[moduleId]
    }
  }

  // Execute the runtime code
  if (runtime) {
    var result = runtime(__webpack_require__)
  }

  // Mark the chunks as loaded
  for (var i = 0; i < chunkIds.length; i++) {
    var chunkId = chunkIds[i]
    if (__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {
      installedChunks[chunkId][0]()
    }
    installedChunks[chunkId] = 0
  }

  return __webpack_require__.O(result)
}

// 3. Override the chunkLoadingGlobal.push method so it can handle newly loaded chunks
chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal))

When dynamic import syntax such as import() or require.ensure() is used, webpack generates code similar to the following:

// Dynamic import in the entry file
__webpack_require__.e(/* chunkId */ 889).then(__webpack_require__.bind(__webpack_require__, 889)).then((module) => {
  // Use the asynchronously loaded module
  console.log(module.default)
});

// Async chunk file (chunk-889.js)
(self["webpackChunk"] = self["webpackChunk"] || []).push([[889], {
  // Module definition
  "./src/async-module.js": (__unused_webpack_module, exports, __webpack_require__) => {
    "use strict"
    eval("Async module code")
  }
}])

Main characteristics of async chunks:

  1. On-demand loading: only loaded when needed, reducing initial loading time
  2. Independent files: each async chunk generates an independent JS file
  3. JSONP loading: dynamically loaded via JSONP, avoiding blocking the main thread
  4. Caching mechanism: loaded chunks are cached to avoid repeated loading

Webpack Runtime Functions

Webpack injects some runtime functions during the bundling process to support module loading and execution. Here are two important runtime functions:

The __webpack_require__.o Function

__webpack_require__.o is a helper function used to check whether an object owns a specific property; it is a wrapper around Object.prototype.hasOwnProperty.call:

/* webpack/runtime/hasOwnProperty shorthand */
(() => {
  __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
})()

This function is widely used inside webpack, mainly for:

  1. Checking whether a module has been loaded
  2. Checking whether an object contains a specific property
  3. Determining whether a property exists during module merging and dependency resolution

For example, when loading an async chunk, this function is used to check whether the module already exists in __webpack_modules__:

for (var moduleId in moreModules) {
  if (__webpack_require__.o(moreModules, moduleId)) {
    __webpack_require__.m[moduleId] = moreModules[moduleId]
  }
}

The __webpack_require__.e Function

__webpack_require__.e is the core function for loading async chunks; it is responsible for creating script tags and handling the loading process:

/* webpack/runtime/ensure chunk */
(() => {
  __webpack_require__.f = {}
  // This object stores the chunks that have already been loaded
  __webpack_require__.e = (chunkId) => {
    return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
      __webpack_require__.f[key](chunkId, promises)
      return promises
    }, []))
  }
})()

When dynamic imports (such as import()) are used, webpack generates code similar to the following:

// Dynamic import in the entry file
__webpack_require__.e(/* chunkId */ 889).then(__webpack_require__.bind(__webpack_require__, 889)).then((module) => {
  // Use the asynchronously loaded module
  console.log(module.default)
})

The main functions of __webpack_require__.e:

  1. Check whether the chunk has already been loaded
  2. If not loaded, create a script tag to load the corresponding chunk file
  3. Return a Promise that resolves when the chunk finishes loading
  4. Handle loading failures, providing an error handling mechanism

These two functions are important components of the webpack runtime environment; together they support webpack's module loading mechanism, especially the on-demand loading of async chunks.

The __webpack_require__.f Function

__webpack_require__.f is an object containing the different strategies webpack uses to load async chunks. In webpack 5, it mainly includes the following loading methods:

/* webpack/runtime/ensure chunk */
(() => {
  __webpack_require__.f = {}

  // 1. Loading via script tag
  __webpack_require__.f.j = (chunkId, promises) => {
    // Check whether the chunk is already loaded
    if (!__webpack_require__.o(installedChunks, chunkId) || installedChunks[chunkId] === undefined) {
      // Create a Promise and store it in installedChunks
      var promise = new Promise((resolve, reject) => {
        installedChunks[chunkId] = [resolve, reject]
      })
      promises.push(promise)

      // Create a script tag to load the chunk
      var script = document.createElement('script')
      script.src = __webpack_require__.p + __webpack_require__.u(chunkId)
      script.onerror = () => {
        reject(new Error('Chunk load failed for chunk: ' + chunkId))
      }
      document.head.appendChild(script)
    } else {
      promises.push(installedChunks[chunkId])
    }
  }

  // 2. Loading via import() (ESM)
  __webpack_require__.f.d = (exports, definition) => {
    for (var key in definition) {
      if (__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
        Object.defineProperty(exports, key, { enumerable: true, get: definition[key] })
      }
    }
  }

  // 3. Loading via require.ensure (CommonJS)
  __webpack_require__.f.require = (chunkId, promises) => {
    // Similar to script tag loading, but using the require.ensure syntax
  }
})()

The main roles of the __webpack_require__.f object:

  1. Provide multiple loading strategies: support script tags, import(), require.ensure, and other loading methods
  2. Unified loading interface: uniformly invoke these loading strategies through the __webpack_require__.e function
  3. Handle loading states: track the loading state of each chunk, ensuring each is loaded only once
  4. Error handling: provide an error handling mechanism for loading failures

In webpack 5, the __webpack_require__.f.j strategy is used by default, i.e., loading async chunks via script tags. The advantages of this approach are:

  • Good compatibility, supporting all browsers
  • Multiple chunks can be loaded in parallel
  • Supports caching and preloading
  • Can handle loading failures

__webpack_require__.f, together with __webpack_require__.e and __webpack_require__.o, forms the core mechanism of webpack's asynchronous loading, enabling webpack to support code splitting and on-demand loading.

The __webpack_require__.O Function

__webpack_require__.O is a helper function in the webpack runtime used to handle callbacks after asynchronous loading completes:

/* webpack/runtime/ensure chunk */
(() => {
  __webpack_require__.O = (result, fn) => (fn ? fn() : result)
})()

The main roles of this function are:

  1. Simplify the processing logic after asynchronous loading: provide a concise way to handle the result when an async chunk finishes loading
  2. Support optional callback functions: if a callback function is provided, execute it; otherwise directly return the result
  3. Uniformly handle asynchronous loading results: used in webpack's asynchronous loading mechanism to uniformly handle the logic after loading completes

In webpack's asynchronous loading code, you usually see usage like this:

// After async loading completes, process the result via __webpack_require__.O
__webpack_require__.e(/* chunkId */ 889)
  .then(__webpack_require__.bind(__webpack_require__, 889))
  .then((module) => {
    // Use the asynchronously loaded module
    console.log(module.default)
  })
  .then(__webpack_require__.O) // Use __webpack_require__.O to process the result

The design of the __webpack_require__.O function reflects the simplicity and flexibility of webpack runtime functions: through a simple function wrapper, it implements the processing of asynchronous loading results, making webpack's asynchronous loading mechanism more complete and robust.

The Async Task Queue

In the webpack build output, besides the runtime functions above, there is another important mechanism for maintaining async tasks: the async task queue (queue). This queue is mainly used to handle asynchronously loaded modules and resources.

/* webpack/runtime/ensure chunk */
(() => {
  // Initialize the async task queue
  var installedChunks = {
    // The chunkId of the main bundle (entry file) is 0
    0: 0
  }

  // The async task queue, used to store asynchronously loaded modules
  var deferredModules = []

  // The async loading function
  __webpack_require__.e = (chunkId) => {
    return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
      __webpack_require__.f[key](chunkId, promises)
      return promises
    }, []))
  }

  // The function that processes the async task queue
  var webpackJsonpCallback = (parentChunkLoadingFunction, data) => {
    var [chunkIds, moreModules, runtime] = data

    // Add the new modules to __webpack_modules__
    for (var moduleId in moreModules) {
      if (__webpack_require__.o(moreModules, moduleId)) {
        __webpack_require__.m[moduleId] = moreModules[moduleId]
      }
    }

    // Execute the runtime code
    if (runtime) {
      var result = runtime(__webpack_require__)
    }

    // Mark the chunks as loaded
    for (var i = 0; i < chunkIds.length; i++) {
      var chunkId = chunkIds[i]
      if (__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {
        installedChunks[chunkId][0]()
      }
      installedChunks[chunkId] = 0
    }

    // Process the deferred modules
    if (deferredModules.length) {
      deferredModules.forEach(([moduleId, chunkId]) => {
        if (__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId] === 0) {
          __webpack_require__(moduleId)
        }
      })
      deferredModules = []
    }

    return __webpack_require__.O(result)
  }

  // Override the chunkLoadingGlobal.push method
  var chunkLoadingGlobal = self.webpackChunk = self.webpackChunk || []
  chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal))
})()

The main features and roles of the async task queue:

  1. Deferred module management: the deferredModules array is used to store information about modules that need deferred loading, including module IDs and corresponding chunk IDs.

  2. Async loading state tracking: the installedChunks object is used to track the loading state of each chunk, ensuring each chunk is loaded only once.

  3. Async loading completion handling: when an async chunk finishes loading, the webpackJsonpCallback function processes the loading result and adds the deferred modules to __webpack_modules__.

  4. Deferred module execution: after an async chunk finishes loading, it checks whether there are modules in deferredModules that need to be executed, and executes them if so.

  5. Preventing duplicate loading: tracks loaded chunks via the installedChunks object, avoiding loading the same chunk repeatedly.

This async task queue mechanism enables webpack to efficiently handle asynchronously loaded modules, achieve on-demand loading and code splitting, and ensure the correct loading order and dependencies of modules.

Webpack Build Processing

Capturing Errors

You can listen to the done cycle via a plugin to get error information and handle the reporting logic yourself

class ErrorReportPlugin {
  constructor(options) {
    this.reportUrl = options.url // The target URL for reporting
  }

  apply(compiler) {
    compiler.hooks.done.tap('ErrorReportPlugin', (stats) => {
      if (stats.hasErrors()) {
        const errors = stats.toJson().errors
        const formattedErrors = errors.map(error => ({
          message: error.message,
          module: error.moduleName,
          stack: error.stack,
          timestamp: new Date().toISOString(),
        }))
        this.sendErrorsToServer(formattedErrors)
      }
    })
  }

  async sendErrorsToServer(errors) {
    try {
      await fetch(this.reportUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ errors }),
      })
    } catch (e) {
      console.error('Report failed:', e)
    }
  }
}

// Use the plugin in the Webpack configuration
module.exports = {
  plugins: [
    new ErrorReportPlugin({
      url: 'https://your-error-report-api.com/collect',
    }),
  ],
}

When calling webpack in a custom build tool, you can also get error information through the webpack function callback, without adding an extra plugin

const webpack = require('webpack')

// Import the configuration for the webpack build
const config = {}

webpack(config, (err, stats) => {
  if (err) {
    // There is a build error
    process.exit(-1)
  }
})

Smoke Testing

Sometimes the build may not report errors, but the generated output has problems. You can check the output with simple scripts; common checking methods include:

  1. Check whether the html file with the specified name is output in the specified path
  2. Check whether static asset files such as js and css are generated in the specified assets directory
  3. Start a local preview service, access the URL, and check whether the page displays normally and the console has no errors

Webpack Build Performance Optimization

Build performance is essentially execution performance; without replacing the build tool (such as using rsbuild), it ultimately comes down to finding ways to optimize NodeJS execution performance.

Analyzing loader/plugin Time Consumption

Use speed-measure-webpack-plugin to measure time; it outputs the time consumed by each loader and plugin in the terminal

const SpeedMeasureWebpackPlugin = require('speed-measure-webpack-plugin')

const smw = new SpeedMeasureWebpackPlugin()

module.exports = smw().wrap({
  entry: './src/entry.js',
})

Analyzing Chunk Output

Analyzing the output helps us find whether dependency libraries are being built repeatedly; applicable when dealing with packages that need babel transformation, mainly used for output size analysis, i.e., loading performance optimization.

const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')

module.exports = {
  entry: './src/entry.js',
  plugins: [
    new BundleAnalyzerPlugin()
  ],
}

Upgrading NodeJS

Upgrading NodeJS can effectively improve the running speed of js scripts; the NodeJS internals are also continuously optimizing performance with version iterations.

NodeJS 22 supports enabling code caching, further improving running speed

Replacing Time-Consuming Algorithms

For example, replacing the md5 algorithm with md4 can also effectively improve performance — md4 is 7.69% faster than md5. This optimization can also be applied to network loading performance, such as replacing RSA-encrypted PKCS certificates with ECC certificates using the elliptic curve encryption algorithm.

Multi-Threaded Building

loader Processing

Older code uses the HappyPack plugin for multi-threaded building.

HappyPack's operating principle is to create a thread pool, hand all tasks to the thread pool, and the thread pool assigns different tasks to different threads; after the child threads finish processing, the results are returned to the main thread.

const HappyPack = require('happypack')

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        include: path.resolve('src'),
        use: [
          // If there are multiple modules to process, this can be changed to 'happypack/loader?id=xxx'
          'happypack/loader',
        ],
      },
    ],
  },
  plugins: [
    new HappyPack({
      // // Set the plugin corresponding to the loader, default id is 1
      // id: 'xxx',
      // Configure the number of threads, default is 3
      threads: 4,
      // Which loader to use for processing
      loader: ['babel-loader']
    })
  ],
}

HappyPack is now basically unmaintained; you can switch to the officially provided thread-loader instead.

thread-loader's principle is similar to HappyPack: each time a module is parsed, the module and its dependencies are assigned to worker threads for processing.

const path = require('path')

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        include: path.resolve('src'),
        use: [
          // thread-loader needs to be placed first, loaded last
          // This way it can intercept other loaders during loader initialization for its own processing, avoiding redundant configuration
          {
            loader: 'thread-loader',
            options: {
              // Configure the number of threads, default is 2
              workers: 3,
            },
          },
          'babel-loader',
        ],
      },
    ],
  },
}

Code Minification

uglifyJS

Using the parallel-uglify-plugin / uglifyjs-webpack-plugin plugins, code can be minified in parallel; the underlying minification uses uglifyJS, so it cannot support minifying ES6 code

const ParallelUglifyPlugin = require('parallel-uglify-plugin')
const UglifyjsPlugin = require('uglifyjs-webpack-plugin')

module.exports = {
  plugins: [
    // parallel-uglify-plugin minifies faster, but its size and feature richness cannot match uglifyjs-webpack-plugin
    new ParallelUglifyPlugin({
      // uglify configuration
      uglifyJS: {
        output: {
          beautify: false,
          comments: false,
        },
        compress: {
          warnings: false,
          drop_console: true,
          collapse_vars: true,
          reduce_vars: true,
        },
      },
    }),

    // The UglifyJS plugin, supports enabling parallel minification; the output size is smaller than `parallel-uglify-plugin`, with richer features
    new UglifyjsPlugin({
      uglifyOptions: {
        warnings: false,
        parse: {},
        compress: {},
        mangle: true,
        output: null,
        toplevel: false,
        nameCache: null,
        ie8: false,
        keep_fnames: false,
      },
      parallel: true,
    }),
  ],
}
terser

webpack 4 uses terser to minify code by default, supporting ES6 code minification; parallel minification can be enabled via terser-webpack-plugin

const TerserPlugin = require('terser-webpack-plugin')

module.exports = {
  optimization: {
    minimizer: [
      new TerserPlugin({
        parallel: 4,
      })
    ],
  },
}

Code Splitting

By making part of the code that doesn't change easily external, the number of modules to process is reduced, which can effectively improve build speed.

Converting to CDN

With html-webpack-externals-plugin, dependency packages can be converted to be fetched from a CDN; at runtime they are packages loaded in UMD format.

If there are many dependency packages to load, requests can easily be blocked; it is suitable for externalizing a small number of packages.

const HtmlExternalsPlugin = require('html-webpack-externals-plugin')

module.exports = {
  plugins: [
    new HtmlExternalsPlugin({
      externals: [
        {
          // Package name
          module: 'vue',
          // Which CDN to load from instead
          entry: '//cdn.jsdelivr.net/npm/vue@3.5.13/dist/vue.global.prod.js',
          // The property name when mounted on Window
          global: 'Vue',
        },
      ],
    })
  ],
}

Converting to DLL

DLL technology builds infrequently changing dependency libraries into DLL static files, so those dependency packages do not need to be processed in every build.

DLL technology supports merging packages, merging multiple related packages into the same chunk.

// webpack.dll.config.js
const webpack = require('webpack')

module.exports = {
  // Essentially a DLL is a built bundle, so the build scope of the corresponding packages needs to be added to entry
  // Configuring multiple entries corresponds to multiple dlls
  entry: {
    // Base framework package
    library: [
      'vue',
      'vue-router',
      'pinia',
    ],
    // Chart package
    chart: [
      'echart',
    ],
  },
  output: {
    // Configure the entry filename of the dependency library; the browser cache can be invalidated via hash
    filename: '[name]_[chunkhash].dll.js',
    path: './build/library',
    library: '[name]',
  },
  plugins: [
    new webpack.DllPlugin({
      name: '[name]_[hash]',
      // Output in manifest.json format as the package's entry
      // It contains the package's description information, such as which packages there are, corresponding entries, etc.
      path: './build/library/[name].json',
    }),
  ],
}

In real projects, the DLL is referenced with the following code

// webpack.prod.config.js
const webpack = require('webpack')

module.exports = {
  plugins: [
    // Reference the previously built library entry
    new webpack.DllReferencePlugin({
      manifest: require('./build/library/library.json')
    }),
      // Reference the previously built chart entry
    new webpack.DllReferencePlugin({
      manifest: require('./build/library/chart.json')
    }),
  ],
}

Build Caching

Purpose: improve the speed of subsequent builds

Caching babel-loader

module.exports = {
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        // Parameters can be passed via the query parameter to enable caching
        use: 'babel-loader?cacheDirectory=true',
      },
    ],
  },
}

Caching terser-webpack-plugin

const TerserPlugin = require('terser-webpack-plugin')

module.exports = {
  optimization: {
    minimizer: [
      new TerserPlugin({
        parallel: 4,
        // Just enable it directly here
        cache: true,
      })
    ],
  },
}

Caching Module Resolution Results

Module resolution is used frequently in builds; it needs to map shorthand module names to concrete absolute paths on disk, and supports features like omission and parent directory recursion, so it is quite time-consuming overall.

const HardSourcePlugin = require('hard-source-webpack-plugin')

module.exports = {
  plugins: [
    new HardSourcePlugin()
  ],
}

cache-loader

cache-loader can cache loader processing results, used for loaders that do not provide cache functionality themselves

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: ['cache-loader', 'babel-loader'],
      },
    ],
  },
}

webpack.cache

In webpack5, cache-loader was deprecated; the cache configuration is built in directly, letting webpack decide the cache target itself without manual configuration.

module.exports = {
  // Equivalent to `cache: { type: 'memory' }`, cached in memory
  // Can be set to `cache: { type: 'filesystem' }` to cache to disk
  cache: true,
}

Narrowing the Build Target

Purpose: process as few modules as possible

For example, excluding packages inside node_modules by default

Excluding Third-Party Packages from Processing

module.exports = {
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        use: 'babel-loader',
        // Packages in node_modules are not processed by babel
        exclude: 'node_modules',
      },
    ],
  },
}

Reducing Package Retrieval Levels

const path = require('path')

module.exports = {
  resolve: {
    // Only search in this directory, no longer going further up
    modules: [
      path.resolve(__dirname, 'node_modules')
    ],
  },
}

Specifying Package mainFields

module.exports = {
  resolve: {
    // When reading third-party packages, only the main field needs to be recognized; conditional judgment logic such as exports and require does not need to be processed
    mainFields: 'main',
  },
}

Reducing extensions

module.exports = {
  resolve: {
    // When the suffix is omitted, only try to match .js files, not other suffixes
    extensions: ['.js'],
  },
}

alias

const path = require('path')

module.exports = {
  resolve: {
    alias: {
      // Directly use the minimal production package, also saving module lookup time
      vue: path.resolve(__dirname, './node_modules/vue/dist/vue.esm-browser.prod.js'),
    },
  },
}

Asset Size Optimization

Images

Format Conversion

Images occupy a very large amount of resources, especially png or svg with nested images ( for example, an svg referencing base64 code of other png images ). You can convert image formats, such as to webp, avif, jpeg

Or provide APIs for obtaining different image sizes through BFF, handling it by preloading low-pixel images.

Compression

If conversion really isn't possible, or the size is still large after conversion, image compression technology can be used to losslessly/lossily compress the images.

const ImageLoader = require('image-webpack-loader')

module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|svg|jpe?g|gif|webp)$/,
        use: [
          'file-loader',
          {
            loader: 'image-webpack-loader',
            options: {
              mozjpeg: {
                progressive: true,
              },
              // optipng is for png compression; pngquant is used here instead of optipng
              optipng: {
                enabled: false,
              },
              pngquant: {
                quality: [0.65, 0.90],
                speed: 4
              },
              gifsicle: {
                interlaced: false,
              },
              webp: {
                quality: 75
              }
            },
          },
        ],
      },
    ],
  },
}

CSS tree-shaking

PurifyCSS

Implementation principle: traverse the code and find the CSS that is used

Requires mini-css-extract-plugin + purgecss-webpack-plugin

const path = require('path')
const glob = require('glob')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const { PurgeCSSPlugin } = require('purgecss-webpack-plugin')

const PATHS = {
  src: path.join(__dirname, 'src'),
}

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'dist'),
  },
  optimization: {
    splitChunks: {
      cacheGroups: {
        styles: {
          name: 'styles',
          test: /\.css$/,
          chunks: 'all',
          enforce: true,
        },
      },
    },
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader'],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].css',
    }),
    new PurgeCSSPlugin({
      // Must be absolute paths; array-type values are supported
      paths: glob.sync(`${PATHS.src}/**/*`, { nodir: true }),
    }),
  ],
}

uncss

Implementation principle: open the page with a headless browser and use document.querySelector to identify which styles are used.

uncss is hard to adapt to SPA pages, so it is rarely used in general — only used when optimizing the first screen to remove useless css for the first screen, usually as a postcss plugin: postcss([ require('uncss').postcssPlugin ])

Dynamic polyfill

babel-Transpiled Code

Set the minimum supported version through babel and hard-code the polyfill into the output; suitable for intranet users, such as banking, finance, etc.

  • Advantage: simple to use
  • Disadvantages:
    • Every time the minimum supported version is upgraded, a rebuild is required
    • Users with newer browsers also have to bear the polyfill overhead, including loading, parsing, etc.

polyfill-service

Apply polyfills according to the user's browser version, reducing the waste of ineffective code.

The browser version is generally obtained through the UA, and can be handled with polyfill-service

Common polyfill-service providers include polyfill.io, but it is not recommended to use the service they provide, as malicious content has been detected in it — deploying the service yourself is recommended

Or use https://cdnjs.cloudflare.com/polyfill/v3/polyfill.min.js?version=4.8.0 / https://polyfill-fastly.io/v3/polyfill.min.js

If users modify their UA or use modified browsers, causing the loaded polyfill to still report errors, you can capture the global error and dynamically add a script with the full polyfill as a fallback.

Webpack Startup Process

The webpack Command

When the webpack command is triggered, it looks for webpack-cli / webpack-command. If neither is installed, it offers to install one automatically, and in the end only one of the two packages is used.

webpack-cli

webpack-cli mainly collects command-line arguments, converts them into webpack configuration, and calls webpack to bundle.

Some commands do not require bundling, so webpack-cli has a built-in NON_COMPILATION_ARGS array containing commands that should not trigger a webpack build, such as init / info.

When merging arguments, arguments specified on the command line take precedence over the configuration in webpack.config.js.

After merging, the compiler is created via compiler = webpack(options).

Depending on serve / build, it decides whether to call compiler.watch() or compiler.run().

tapable

Webpack hooks are basically managed by the tapable library; the core compiler and compilation both inherit from tapable.

tapable is a library similar to EventEmitter, mainly controlling the publish-subscribe of hooks.

It can be said that the whole of webpack is an event-stream-based programming paradigm: different hooks are triggered at different stages, and once a hook finishes executing, the main flow continues.

Supported Hooks

Synchronous Hooks

  • SyncHook: synchronous hook.
  • SyncBailHook: synchronous bail hook. A return immediately returns, preventing subsequent unhandled callbacks.
  • SyncWaterfallHook: synchronous waterfall hook. Passes the hook's result to the next hook.
  • SyncLoopHook: synchronous loop hook. A listener returning true means continue looping; returning undefined ends the loop.

Asynchronous Hooks

  • AsyncParalleHook: asynchronous parallel hook.

  • AsyncParalleBailHook: asynchronous parallel bail hook.

  • AsyncSeriesHook: asynchronous series hook.

  • AsyncSeriesBailHook: asynchronous series bail hook.

  • AsyncSeriesWaterfallHook: asynchronous series waterfall hook.

Methods for Binding Hooks

Operation Asynchronous hooks Synchronous hooks
Bind tapAsync / tapPromise / tap tap
Execute callAsync / promise call

webpack plugin

A webpack plugin can be seen as an operation that dynamically adds hook listeners; the timing and arguments of hook triggers are built into webpack.

The entire plugin system is a complete mechanism for dynamically updating strategies, centered on the strategy pattern.

webpack Build Process

Looking at the hook call order, the basic flow is as follows:

---
title: Call order of webpack's main hooks
---

graph TB
  entry["entry-option <br>( initialize options )"]
  run["run <br>( start compilation )"]
  make["make <br>( recursively analyze dependencies from the entry, build each dependent module )"]
  beforeResolve["before-resolve <br>( resolve the module's corresponding path )"]
  buildModule["build-module <br>( start building a module )"]
  normalModuleLoader["normal-module-loader <br>( compile the module loaded by loaders into an AST )"]
  program["program <br>( traverse the AST to collect dependencies )"]
  seal["seal <br>( build finished, start optimizing code )"]
  emit["emit <br>( output build artifacts to the file system )"]

  entry --> run
  run --> make
  make --> beforeResolve
  beforeResolve --> buildModule
  buildModule --> normalModuleLoader
  normalModuleLoader --> program
  program --> seal
  seal --> emit

loader

Definition: a loader is just a function that takes file content as input and outputs JS code.

The main purpose of loaders is to parse files that webpack cannot understand (non-JS / JSON) into modules webpack can recognize, unifying all resources under the same module standard, which helps webpack process them uniformly and extend support for new file types.

Loaders are always called from right to left. In some cases, a loader only cares about the metadata after the request and ignores the previous loader's result. Before actually executing the loaders (right to left), the pitch methods on the loaders are called from left to right.

loader-runner

loader-runner can run loaders without installing webpack, making it convenient to develop and debug loaders.

import { runLoaders } from "loader-runner"

runLoaders(
  {
    // which file to process; must be an absolute path, may include query parameters
    resource: "/abs/path/to/file.txt?query",
    // loaders to invoke; supported values are the same as module.rules.use in webpack
    // local loaders need absolute paths
    loaders: ["/abs/path/to/loader.js?query"],

    // declare that only the basic context is used
    context: { minimize: true },

    // the loader's processing result is passed back through this function, usable for output, testing, etc.
    // function signature is `function(context, path, function(err, buffer))`
    processResource: (loaderContext, resourcePath, callback) => { ... },

    // how to read the file; you can modify the file-reading operation for interception, testing, etc.
    // function signature is `function(path, function(err, buffer))`
    readResource: fs.readFile.bind(fs)
  },
  /**
   * @param {Error | undefined} err
   * @param {Object} result
   * @param {Buffer | string} result.result result exists only when there is no err
   * @param {Buffer} result.resourceBuffer raw Buffer data
   * @param {boolean} result.cacheable whether cacheable
   * @param {string[]} result.fileDependencies dependent files
   * @param {string[]} result.missingDependencies missing files
   * @param {string[]} result.contextDependencies context-dependent files
   * @returns {void}
   */
  function (err, result) {
  },
)

Creating a loader

Creation: either use the webpack generate-loader command provided by webpack, or create the corresponding file yourself.

The loader-utils package is usually used to conveniently operate on loaders, such as getting options.

Synchronous loader:

const LoaderUtils = require('loader-utils')

module.exports = function (source) {
  // loader options can be obtained here
  const { name: _name } = LoaderUtils.getOptions(this)

  // there are two ways to handle errors:
  // 1. throw the error directly
  throw new Error()
  // 2. return it via callback
  this.callback(
    /* err: Error | null */ null,
    /* content: string | Buffer */ source,
    /* sourceMap?: SourceMap */
    /* meta?: any */
  )

  // a synchronous loader can return source directly
  // when multiple values need to be returned, callback must be used
  // so it is recommended to consistently use callback
  // return source
}

Asynchronous loader:

module.exports = async function (source) {
  // an asynchronous loader needs to get the corresponding callback
  const callback = this.async()

  await new Promise((resolve) => {
    resolve(true)
  })

  callback(
    /* err: Error | null */ null,
    /* content: string | Buffer */ source,
  )
}

Loader Caching

By default webpack caches loaders; a loader with dependencies cannot be cached.

To manually disable caching, call this.cacheable(false) inside the loader.

Emitting Files

A loader can emit additional files directly via this.emitFile, which is why loaders like file-loader can output files.

const LoaderUtils = require('loader-utils')

module.exports = function (content) {
  // get the relative path of the output file
  const url = LoaderUtils.interpolateName(this, '[hash].[ext]', { content })

  // emit to a file
  this.emitFile(url, content)

  // change the file content to the path of the corresponding module
  // __webpack_public_path__ is a global environment variable, replaced with the output path
  const path = `__webpack_public_path__ + ${JSON.stringify(url)}`

  return `export default ${path}`
}

plugin

A plugin must run inside webpack; it cannot run standalone like a loader.

Creating a plugin

Creation: either use the webpack generate-plugin command provided by webpack, or create the corresponding file yourself.

const { RawSource } = require('webpack-sources')

module.exports = class {
  constructor(options) {
    // options validation can be done here; if validation fails, just throw an error
    throw new Error('invalid options')

    this.options = options
  }

  // entry point when the plugin is invoked
  apply(compiler) {
    compiler.plugin('emit', (compilation, cb) => {
      // if an error occurs during the call phase, it is generally recorded on compilation
      compilation.warnings.push('warning message')
      compilation.errors.push('error message')

      // emit a file; only a raw file is emitted here — there are others such as files with sourcemaps, see [webpack-sources](https://github.com/webpack/webpack-sources?tab=readme-ov-file#rawsource)
      compilation.assets['filename'] = new RawSource('file content')

      cb()
    })
  }
}

Plugins of plugins

As mentioned earlier, webpack's core is entirely event-based. If a plugin also registers events, you can develop a plugin for that plugin based on those events. For example, html-webpack-plugin provides many hooks.

By listening to these hooks, the plugin can be enhanced.

Creating Plugins

Preparation Phase

The preparation phase initializes the Compiler.

Then it decides whether to run watch or run based on the configuration.

compiler.run

Compiler also inherits from Tapable and has its own hook mechanism.

compiler.run triggers the beforeRun hook -> run hook -> compiler function in sequence.

Entry Handling

When the entry-option hook is triggered, the EntryOptionPlugin callback is invoked to check the entry configuration. If it is a function, DynamicEntryPlugin is called to register the entry; otherwise EntryPlugin is used.

The full code can be found at lib/EntryOptionPlugin.js.

Option Handling

Listens to the beforeRun hook.

Converts each options parameter into webpack built-in plugins, and registers built-in plugins according to the runtime environment, for example:

  • output.library: LibraryTemplatePlugin
  • externals: ExternalsPlugin
  • devtool: EvalDevtoolModulePlugin / SourceMapDevToolPlugin
  • AMDPlugin / CommonJsPlugin
  • RemoveEmptyChunksPlugin

The full code can be found at lib/WebpackOptionsApply.js.

Initializing compilation

A compilation is initialized for each compilation; in watch mode, every code change also re-initializes a compilation.

The initialization happens when compiler.compile is called, that is, after the run hook is triggered.

Build Phase

compiler.compile triggers beforeCompile -> compile -> make -> finishMake in sequence.

Module

Webpack abstracts all modules into Module. The specific differences are as follows:

  • NormalModule: regular resource files such as JS, CSS, images, read through loaders.
  • ContextModule: handles dynamic require statements, such as modules loaded via require.context().
  • ExternalModule: external dependencies, such as libraries included via CDN. This class does not inherit from Module, because it does not need to be built.
  • DelegateModule: handles delegated modules, such as DLL loading.
  • RawModule: uses the file content directly as the imported content. This class does not inherit from Module, because it does not need to be built.

make hook

The main building happens in the make phase. Taking a single-entry build as an example:

A single-entry build is converted to SingleEntryPlugin, which is EntryPlugin. As seen in the code, once triggered, this plugin calls compilation.addEntry, officially starting the build.

During the build, the build function of each different Module is called to perform the build.

  1. Instantiate loaderContext, and read file information via runLoader
  2. After reading the module information, parse it into an AST via parser.parse() — acron is used for JS modules, and @webassemblyjs/wasm-parser is used for WASM modules
  3. After all modules are parsed, they are stored in compilation.modules, triggering finishModules

webpack 4 used successModules instead of finishModules

Chunk Generation

By default, chunks are generated through the following process — the chunks in the final artifacts are the result after seal. The specific generation process is:

  1. webpack first generates a new chunk for each module corresponding to the entry
  2. Traverses the module's dependency list and adds the dependent modules into the chunk
  3. If a dynamically imported module is depended on, a new chunk is created for that module, and traversal continues
  4. Until all chunks are generated

Code Optimization ( seal )

This phase performs tree-shaking and other optimizations on the build artifacts. The specific execution order is:

  1. Build the chunk graph
  2. Optimize dependencies
  3. Optimize modules
  4. Optimize chunks
  5. tree-shaking
  6. Optimize ChunkModules
  7. Output Assets

Output Phase

Build artifacts are output via createChunkAssets, and written to the file system in emitAsset.

Complete Hook Reference Table

The compiler object triggers the following hooks in sequence:

Hook name Description
environment Triggered before reading the Webpack configuration file
afterEnvironment Triggered after the Webpack configuration file has been read and parsed
entryOption Triggered before processing the entry options
beforeRun Triggered before running the compiler, after all plugins' apply methods have been called
run Starts the main compilation flow; the Compilation object is created at this stage
watch-run (watch mode) Triggered when source code changes and recompilation starts
normal-module-factory Creates NormalModuleFactory; very useful for plugins that handle module loading logic
before-compile Triggered before creating the Compilation object
compile Creates the Compilation object
make or thisCompilation Triggered immediately after a new Compilation object is created, for resource and module processing
after-compile Triggered after a Compilation process completes
should-emit Decides whether the compilation result should be written to disk
emit Triggered before the compilation result is output to disk; output content can be modified or replaced
after-emit Triggered after the compilation result has been successfully output to disk; often used for cleanup or other post-processing
done Triggered when the entire compilation process completes, regardless of errors
failed Triggered if an error occurs during compilation

The compilation object triggers in sequence:

Hook name Description
build-module Triggered when a module starts building
normal-module-loader Triggered when a module is loaded with normal loaders
module-asset Triggered whenever a module adds an asset to the compilation
optimize-module-order Triggered before optimizing module order
optimize-modules-basic Triggered in the basic module optimization phase
optimize-module-ids Triggered when assigning unique identifiers (IDs) to modules
after-optimize-module-ids Triggered after module ID optimization completes
optimize-chunk-ids Triggered when assigning unique identifiers to chunks
after-optimize-chunk-ids Triggered after chunk ID optimization completes
record-modules Triggered before recording all module information
record-chunks Triggered before recording all chunk information
before-hash Triggered before computing the compilation's hash
content-hash Triggered when computing content-related hash values
after-hash Triggered after the compilation's hash has been computed
optimize-chunk-assets Triggered before optimizing chunk assets
optimize-assets Triggered before optimizing all assets
additional-chunk-assets Triggered after additional chunk assets are added to the compilation
additional-assets Triggered after other additional assets are added to the compilation
need-additional-pass Triggered if the compilation needs another pass
before-seal Triggered before sealing the compilation
optimize Triggered before executing the final optimization steps
after-optimize Triggered after all optimization steps have been executed
seal Triggered when the compilation is sealed (can no longer be modified)
chunk-graph Triggered when the chunk graph is available
after-seal Triggered after the compilation is fully sealed
optimize-tree-chunk Chunk-level optimization for tree shaking
optimize-tree-module Module-level optimization for tree shaking
after-optimizing Triggered after all optimization operations complete
revive-modules Triggered after modules are restored from cache
optimize-module-factory Triggered when optimizing the module factory creation process
optimize-module-factories Triggered after all module factory optimizations complete
before-module-ids Triggered before assigning ids to modules
module-id Triggered when each module id is determined
after-module-ids Triggered after all module ids are assigned
revive-chunks Triggered after chunks are restored from cache
optimize-chunks-basic Triggered in the basic chunk optimization phase
optimize-extracted-chunks Triggered when optimizing extracted chunks
after-optimize-extracted-chunks Triggered after optimizing extracted chunks completes
optimize-chunk-contents Triggered when optimizing chunk content
before-hash-for-chunk Triggered before computing the hash of an individual chunk
async Triggered after asynchronous tasks complete
child-compiler Triggered when a child compiler is created

Code Splitting

Glossary

Webpack internally contains three types of Chunk:

  • Initial Chunk: Chunks generated based on the Entry configuration
  • Async Chunk: asynchronous module references, such as the async Chunk corresponding to an import(xxx) statement
  • Runtime Chunk: a Chunk containing only runtime code

Default Strategy

Mainly about the code-splitting strategy of SplitChunksPlugin. SplitChunksPlugin decides whether to split based on three dimensions: module reference frequency, chunk size, and number of requests for the chunk. These decisions can all be customized via the optimization.splitChunks configuration. Based on these dimensions, you can bundle content of certain specific paths separately.

SplitChunksPlugin has two built-in configuration groups, default and defaultVendors, providing some out-of-the-box features:

  1. Resources in node_modules hit the defaultVendors rule and are bundled separately
  2. Only Chunks larger than 20kb are bundled separately
  3. The number of requests required to load an Async Chunk must not exceed 30
  4. The number of requests required to load an Initial Chunk must not exceed 30

Splitting by Module Usage Frequency

SplitChunksPlugin supports deciding whether to split based on the number of times a Module is referenced by Chunks; developers can set the minimum reference count via optimization.splitChunks.minChunks.

"The number of times referenced by Chunks" is not directly equivalent to the number of times it is imported — it depends on whether the upstream caller is treated as an Initial Chunk or an Async Chunk.

Splitting by Request Count

The "request count" here refers to the number of sub-chunks that must be loaded synchronously when loading a Chunk.

For example, for a Chunk A, if i child Chunks are split out according to the splitting rules (such as module reference count, third-party packages),

then when requesting A, the browser needs to request all child Chunks at the same time. The parallel request count equals the i sub-chunks plus the A main chunk, i.e. i+1.

On top of satisfying minChunks, you can also limit the number of splits via the maxInitialRequest / maxAsyncRequests options. Semantics of the options:

  • maxInitialRequest: sets the maximum number of parallel requests for an Initial Chunk
  • maxAsyncRequests: sets the maximum number of parallel requests for an Async Chunk
Summary of the key logic for parallel request counts:
  • An Initial Chunk itself counts as one request
  • An Async Chunk does not count as a parallel request
  • The runtime split out via runtimeChunk does not count as a parallel request
  • If two Chunks both satisfy the splitting rules, but the value of maxInitialRequests (or maxAsyncRequest) only allows splitting one more module, the larger module is split first

Splitting by Size

On top of satisfying minChunks and maxInitialRequests, SplitChunksPlugin further decides whether to split based on Chunk size:

  • minSize: only Chunks exceeding this size are formally split
  • maxSize: Chunks exceeding this size will attempt to be split further
  • maxAsyncSize: similar to maxSize, but only applies to asynchronously imported modules
  • maxInitialSize: similar to maxSize, but only applies to entry modules configured in entry
  • enforceSizeThreshold: Chunks exceeding this size are forcibly split, ignoring the other size limits above

Using cacheGroup

SplitChunksPlugin provides the cacheGroups option to set different rules for different file groups.

  • test: accepts a regular expression, function, or string; all Modules or Chunks matching the test condition are assigned to this group
  • type: accepts a regular expression, function, or string; similar to test, it filters modules for the group, but the difference is that it judges by file type rather than file name — for example, type = 'json' matches all JSON files
  • idHint: string type, used to set the Chunk ID; it is also appended to the final artifact file name — for example, when idHint = 'vendors', the output file name looks like vendors-xxx-xxx.js
  • priority: numeric type, used to set the priority of this group; if a module hits multiple cache groups, it is preferentially assigned to the group with the larger priority
// set the vendors cache group via the cacheGroups property,
// all modules matching the vendors.test rule are treated as the vendors group,
// and the splitting configs such as minChunks and minSize under that group are applied first
module.exports = {
  // ...
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          minChunks: 1,
          minSize: 0,
        },
      },
    },
  },
}

Glossary

  1. Entry: the compilation entry, the starting point of webpack compilation
  2. Compiler: the compilation manager; after webpack starts, a compiler object is created and stays alive until the process exits
  3. Compilation: the manager of a single compilation process; for example, when watch = true, there is only one compiler during the run, but every file change that triggers recompilation creates a new compilation object
  4. Dependence: the dependency object; webpack records inter-module dependency relationships based on this type
  5. Module: all resources inside webpack exist as module objects; all operations, transpilation, and merging of resources are performed with module as the basic unit
  6. Chunk: when compilation is complete and ready for output, webpack organizes modules into chunks according to specific rules; these chunks correspond one-to-one with the final output to some extent
  7. Loader: a resource content transformer — essentially a transformer that converts content A into B
  8. Plugin: during the webpack build process, corresponding events are broadcast at specific moments; plugins listen to these events and intervene in the compilation process at specific points in time

sourcemap

The data structure is as follows:

{
  /** The version of the source map. */
  "version": 4,
  /** The name of the transformed file. */
  "file": "out.js",
  /** The directory of the source files. Empty if in the same directory as the transformed file. */
  "sourceRoot": "",
  /** The source files. This is an array, indicating that multiple files may have been merged. */
  "sources": ["foo.js", "bar.js"],
  /** All variable names and property names before transformation. */
  "names": ["src", "maps", "are", "fun"],
  /** The string recording position information. */
  "mappings": "AAgBC,SAAQ,CAAEA"
}

vite VS webpack

  • vite can configure the build.sourcemap option to control sourcemap; the possible values are simplified to inline, hidden, true, false
    • true: generates the sourcemap to a separate file and appends a //# sourceURL comment after the module (i.e. webpack's source-map)
    • inline: generates the soucemap and embeds it into the file as a DataURI (i.e. webpack's inline-source-map)
    • hidden: generates the sourcemap to a separate file, but does not append a comment to the artifact (i.e. webpack's hidden-source-map)
    • false: does not generate a sourcemap
  • webpack can configure the devtool option to control sourcemap; the possible values are
    • eval: executes each module with eval and appends a //# sourceURL comment after the module
    • eval-cheap-source-map: generates a sourcemap without column mappings, and executes modules with eval
    • eval-cheap-module-source-map: similar to eval-cheap-source-map, but generates sourcemaps for the modules' loaders
    • eval-source-map: generates a complete sourcemap, and executes modules with eval
    • cheap-source-map: generates a sourcemap without column information
    • cheap-module-source-map: similar to cheap-source-map, but generates sourcemaps for the modules' loaders
    • inline-cheap-source-map: generates a sourcemap without column information, and embeds it into the file as a DataURI
    • inline-cheap-module-source-map: similar to inline-cheap-source-map, but generates sourcemaps for the modules' loaders
    • source-map: generates a complete sourcemap file
    • inline-source-map: generates a complete sourcemap, and embeds it into the file as a DataURI
    • hidden-source-map: generates a complete sourcemap file, but does not add a link comment to the artifact
    • nosources-source-map: generates a sourcemap file, but without the source code content

Security and Performance Optimization

You can specify the SourceMap by configuring the HTTP request header, and apply permission control at the same time — returning this header only for specific users to avoid source code leakage.

For example, in k8s, it can be configured via Ingress annotations:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: example-ingress
  annotations:
    nginx.ingress.kubernetes.io/configuration-snippet: |
      # this is the key part: check the condition and add the corresponding SourceMap header
      if ($http_cookie ~* "tag=123") {
        add_header SourceMap "$request_uri.map";
      }
spec:
  rules:
    - host: example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: backend-service
                port:
                  number: 80