Interesting points of Webpack

Nuh

Optimisations

  • Webpack’s default for prod optimisation is very aggressive - for js, the only language webpack understands natively. It minifies, uglifies, compresses, eliminates dead code and modularises so that nothing leaks out of scope and nothing clashes
  • usedExports is the flag for enabling or disabling dead code elimination
  • minimize controls the enabling/disabling of minification (with the default minifier being the webpack terser plugin) while minimizer specifies the minifier. You can pass your own minimizer function or pass options to your terser plugin instances such as which files to minimise, parallel computation when minimizing and extractComments which enables extracting all comments (and managing how) from bundles into a separate file (seems to have been intended to generate a License file)

Note that most config options in optimization need corresponding config in other areas e.g. when it comes to chunking, output must be prepared with appropriate template for naming the bundles and chunks.

Outputs

By default, webpack will output a single javascript bundle. However, it is not the limit of its capabilities, it is also capable of loading any type of file/asset given an appropriate loader has been provided for webpack to use and process the respective files with. As such, we can do the following with each type of asset:

  • bundle it with the js file that required it

    When we import a resource such as CSS, webpack will use a provided loader to understand the CSS content and transform it to JS and combine it with the rest of the JS. It will emit only a JS file unless we specifically tell it otherwise.

  • emit as a file of its own after being transformed or processed

    Using plugins, we can extract all generated / transformed resources into files of their own. And we can also continue to use the same or more plugins to automate how we link these resource to the pages that will be using them. In SPA, it will mean injecting style and script tags into the index.html header.

  • emit as a file without any transformations

    Webpack now supports emitting asset files as modules (Asset Modules), yielding a separate file or being emitted in other forms such as a Data URI. In its simplest case, we can use it to transfer assets such as images, SVGs, icons and others that don’t need processing to the dist folder.

Webpack exposes options for us to configure how the bundles are outputted. These include:

  • filename, assetModuleFilename, chunkFilename, sourceMapFilename

    We can configure how we name these types of outputs based on a combination of generated identifiers and the filename of the file that’s being processed. Take filename for example, we can form a combination from the following:

, name of the file being processed, contenthash as generated by Webpack, id as generated by Webpack, hash as generated by Webpack

So we can have single uses of the above

filename: [id].bundle.js

Or a combination of them

filename: [name].[contenthash].js

For chunks and asset modules, we can use ext to denote the extension of the file that’s being processed

assetModuleFilename: [name][ext]
  • clean

    Accepting either a boolean or an object, it is passed to a webpack plugin by the name of CleanPlugin. Its job is to determine whether webpack should clean/clear out the output directory before writing new files and if so, what to keep or remove. The options available are:

    output: {
      clean: true // remove old build output. If false, doesn't remove anything
    }

    and when it is not a boolean, it defaults to true for the base question of to remove or not to remove?

    output: {
      clean: {
        dry: true // Log the assets that should be removed instead of deleting them
      }
    }
    output: {
      keep: /ignored\/dir\//, // Keep these assets under 'ignored/dir'.
    }
    
    //OR
    
     output: {
         clean: {
             keep(asset) {
                 return asset.includes('ignored/dir');
             },
         }
     }
  • environment

    This property of output tells webpack what kind of ES-features may be used in the generated runtime-code.

    I am wondering if the above sentence means webpack will skip polyfilling for these features or is it the other way round where webpack will transpile code to ES-features that we declare?

    The answer seems to be the second half of the question. For optimisation purposes, webpack will convert its generated code to the matching features we specify whenever it is better to do so (and conversely, barring it from assuming a feature is available).

    Actually, I change my mind - webpack never transpiles source code without specific instruction and corresponding plugins, the code in reference here is the runtime code that webpack injects/generates which it wholly owns and as such, transpiles to match target environment.

    module.exports = {
      output: {
        environment: {
          // The environment supports arrow functions ('() => { ... }').
          arrowFunction: true,
          // The environment supports BigInt as literal (123n).
          bigIntLiteral: false,
          // The environment supports const and let for variable declarations.
          const: true,
          // The environment supports destructuring ('{ a, b } = obj').
          destructuring: true,
          // The environment supports an async import() function to import EcmaScript modules.
          dynamicImport: false,
          // The environment supports 'for of' iteration ('for (const x of array) { ... }').
          forOf: true,
          // The environment supports ECMAScript Module syntax to import ECMAScript modules (import ... from '...').
          module: false,
          // The environment supports optional chaining ('obj?.a' or 'obj?.()').
          optionalChaining: true,
          // The environment supports template literals.
          templateLiteral: true,
        },
      },
    }
  • iife

    A boolean that tells Webpack whether to add Immediately Invocable Function Expression (IIFE) wrapper around emitted bundle.

    Why is this important? Well, it forms an immediate scoping of everything that has been bundled away from external scripts. Moreover, it allows control of what is made available publicly available to the rest of the page scripts. E.g. we can export a “library” with the properties and methods we want to expose and this will be the only thing from the bundle that webpack adds to the global scope.

    What’s the risk? We need to make sure we don’t load the bundle before DOM is loaded if anything needs to interact with the DOM.

  • publicPath

    This config in output allows Webpack to specify where browsers should look when they wish to load content/files on-demand or external assets. It is especially useful when assets aren’t hosted in the same location as the server or the rest of the source code e.g CDNs. This option accepts a string or a function that returns a string of the path.

    output: {
        // One of the below
        publicPath: 'auto', // It automatically determines the public path from either `import.meta.url`, `document.currentScript`, `<script />` or `self.location`.
        publicPath: 'https://cdn.example.com/assets/', // CDN (always HTTPS)
        publicPath: '//cdn.example.com/assets/', // CDN (same protocol)
        publicPath: '/assets/', // server-relative
        publicPath: 'assets/', // relative to HTML page
        publicPath: '../assets/', // relative to HTML page
        publicPath: '', // relative to HTML page (same directory)
    }

    Moreover, if the publicPath isn’t known during compile time, you can set it in the entry file dynamically during runtime using the free variable i.e. global variable

    __webpack_public_path__ = myRuntimePublicPath
    
    //rest of your entry file

, scriptType to specify a custom script type for async chunks such as type=“module”

Module

  • When creating module rules for resolving a file type, did you know why webpack applies loaders in an array from right to left? I honestly don’t know but did you know that there is a step before this where webpack “pitches” the modules it matched to the loaders from left to right?

    This pitching is important because a loader can cancel all subsequent actions if they decide that the process shouldn’t continue due to, say, an error in the file that was resolved or a condition that was met. It is also useful for gathering stats on before and after applying a loader. See Pitching Loader for more info.

    According to our trusted LLM friend, webpack is mirroring how UNIX pipes work and allows for chaining loaders. I guess it is easier for them to traverse in one-direction then reverse the traversal than to restart again from the starting point.

  • Loaders have categories and the categories also determine the order in which they get applied or get pitched to. See Enforce for more details

  • Loaders can also be added or disabled inline when importing a resource. You will see examples of the syntax for disabling inline in the enforce section

  • layer, issuer, issuerLayer properties in rules. layer in Webpack that allows grouping of modules into what it calls “layers”. This will be useful in split chunks, stats or entry options. issuerLayer can be used to filter through issuers based on their layer. issuer is a property that can be used to capture dependencies that a matching module has requested and applying something to those.

  • type in a rule sets the type for a matching module. This prevents default rules and their default importing behaviors from occurring. For example, if you want to load a .json file through a custom loader, you’d need to set the type to javascript/auto to bypass webpack’s built-in json importing

  • resolve at rule level allows for overriding the default resolution of a module request. E.g. if index.js is requesting test.js but we want it to specifically look for and use test-2.js, we will set the config as

    rules: [
      {
        test: /\.js$/,
        resolve: {
          alias: {
            test: `/test-2.js`,
          },
        },
      },
    ]

Resolve

  • alias: In addition to creating an alias for directories and modules so that we don’t have to spell out long paths, we also have the ability to cause a module to be ignored by using its path as the key and setting false as the value

        resolve: {
        alias: {
        'ignored-module': false,
        './ignored-module': false,
        },
    },
  • extensions: If multiple files share the same name but have different extensions, webpack will resolve the one with the extension that appears earliest in the array and skip the rest.

    module.exports = {
      //...
      resolve: {
        extensions: [".js", ".json", ".wasm"],
      },
    }

    This is what allows us to leave off the extension when importing

    import File from "../path/to/file"

    Note that using resolve.extensions like above will override the default array, meaning that webpack will no longer try to resolve modules using the default extensions. However you can use '...' to access the default extensions:

    resolve: {
      extensions: ['.ts', '...'],
    },
  • fallback: This is for setting fallback options when resolving. It is particularly useful for ensuring that modules that vary based on environment are appropriately swapped for when the environment changes. This will happen at build time because webpack will be compiling under the assumption of an environment (such as web, or node) which can vary between development and production builds.

    resolve: {
        fallback: {
            assert: require.resolve('assert'),
            buffer: require.resolve('buffer'),
            console: require.resolve('console-browserify'),
            constants: require.resolve('constants-browserify'),
            crypto: require.resolve('crypto-browserify'),
            //... and so on
        }
    }
  • modules: This property of resolve allow us to specify where webpack should look to resolve modules such as the third party packages you installed via npm. It takes an array of paths (relative and absolute) that are traversed from left to right.

    resolve: {
        modules: ['node_modules','shared_modules'],
    },

Entry

Entry is an object that specifies to webpack where it should begin compiling your source code from. It can take many entry points into your app and bundle it up into a corresponding output bundles.

My key takeaways beyond the default uses of the entry property are:

  • When aiming for chunking, you will not see a corresponding output for each chunk unless you are dynamically importing a module. Each (dynamic import) import('path/to/file') discovered by webpack triggers creation of a chunk after which its output is emitted. Why? Webpack organises code into modules and collects modules into a single file (bundle) unless there is reason to separate. Specifying different entry points is an explicit manner of declaring that you want multiple bundles. Dynamic import requires that webpack forego including the modules imported in the bundle during build time. When the default bundle makes it to the client, it will then request the dynamic module when it needs it (using the “url” webpack gave when extracting the chunk). As such, webpack will maintain this module as an emitted chunk which is ready to be served upon request from the client. Other chunks are required immediately so will be included in the bundle at build time.

  • Webpack has a null module whose dependencies are all entry modules (see the linked article below)

This in-depth perspective on the bundling process is a great read and highly recommended.

These two ( code-splitting | multiple pages and in-depth intro to split chunks plugin) are also good reading materials.

Plugins

An array of plugins

DevServer

Webpack only compiles code for use when we run the build script but that will be a tedious process if we ever want to do some work on the app. How about getting webpack to recompile every time we make a change in the source code? There are three options here:

  • webpack’s watch mode

    The watch property of the config instructs webpack to watch our files for changes and recompile whenever it detects changes. That means we no longer have to keep running the build script.

    module = {
      //...
      watch: true,
    }

    Webpack also accepts options for the watch in a watchOptions property of config. This is especially usefully for tweaking when developing in a non-conventional environment like containers, VMs etc.

     watchOptions: {
      // gap between refreshes when changes happen (ms)
       aggregateTimeout: 100,
      // how often we check for changes (ms)
      poll: 1000,
      // directories or files to ignore when watching, accepts string(s), regex and glob patterns
       ignored: ['**/node_modules'],
    }
  • dev-server plugin

  • dev-middleware plugin

In most cases, you probably would want to use webpack-dev-server, but let’s explore all of the above options.

DevTool

devtool is a webpack option which determines how source maps are generated, which helps developers to debug and trace issues in the original source code. The options available include eval, inline-source-map, source-map, cheap-source-map, and hidden-source-map. These options as well as other variations impact build speed and bundle sizes so care needs to be taken depending on project requirements. Source maps should never added to prod build if need be, then it should be hidden-source-map or similar to ensure it remains hidden from the browser.

what is a sourcemap? source map is what helps the browser rewrite the prod output which is usually minified, uglified, compressed back into the source code. So it “maps” generated code back to the source code. This is useful for debugging purposes as you’ll no doubt encounter runtime errors and will wish to find exactly where something is breaking. Browsers will use the sourcemap to pinpoint where the code breaks for errors and the like.

Extends

When working with complex Webpack projects, we often need to handle multiple configurations for different environments such as development, production, and testing environments. This can lead to duplication of code and make it difficult to maintain the configurations.

webpack-merge helps to simplify this process by allowing us to split the configuration files for each environment and then merge them together based on their environment-specific properties.

For example, if you have a development configuration file and a production configuration file, they can be merged using webpack-merge as shown below:

const merge = require('webpack-merge');
const devConfig = require('./webpack.dev.js');
const prodConfig = require('./webpack.prod.js');

module.exports = merge(devConfig, prodConfig);

This will merge the devConfig and prodConfig files, prioritizing properties from the second parameter (in this case, prodConfig) during conflicts.

By using webpack-merge, we can keep our configuration files clean and maintainable and make it easier to manage different configurations for different environments.

To make this even simpler, webpack has a config option called “extends” which under the hood will be using webpack-merge. You can just specify what extends what and write directly any overrides after the extends key is specified.

Target

Perfect for ensuring you compile to the right environment

Externals

Webpack’s externals option allows the user to specify dependencies that should not be bundled into the final JavaScript file. These external dependencies can be loaded at runtime instead of being bundled with the application code.

By using externals, the size of the final bundle can be reduced and the performance of the application can be improved as the browser does not need to download unnecessary code.

module.exports = {
  externals: {
    // Use the jQuery global variable instead of bundling it
    jquery: 'jQuery'
  }
};

Performance

This options allows you customize how webpack handles performance-related issues like asset size and load time. You can optimize the size and loading time of your assets, and prevent performance-related issues.

The performance option is an object that can have the following properties:

  • hints: Determines the types of performance hints to generate. Available values include “warning”, “error”, and false to turn off performance hints entirely.
  • maxAssetSize: The maximum size of an individual asset file. Files larger than this size will trigger performance warnings/errors. The value can be specified in bytes, or a string with a unit (e.g. ‘100kb’ or ‘1MB’).
  • maxEntrypointSize: The maximum size of the entrypoint file. Files larger than this size will trigger performance warnings/errors.
  • assetFilter: A function that allows you to filter which assets to include in performance calculations.

Here’s an example configuration that sets maxAssetSize to 250kb and maxEntrypointSize to 500kb:

module.exports = {
  // other configuration options...
  
  performance: {
    maxAssetSize: 250000, // 250kb
    maxEntrypointSize: 500000, // 500kb
  },
};

By setting these options, you can ensure that your application’s assets are optimized for performance, and that any issues related to asset size or loading time are detected before they become problems.

Stats

Nuh © 2024