Writing loaders and plugins

webpack · Expert · 15 min

Custom build steps in large codebases are implemented as loaders and plugins.

A loader is a function exported from a module that receives a file's source and returns new source. It runs in a loader context (this):

  • this.getOptions() reads options (validate them with a schema).
  • this.resourcePath is the file being processed; this.addDependency(file) makes webpack watch extra files.
  • Return a string, or call this.callback(err, content, sourceMap) to also pass a source map; for async work use const done = this.async().
  • Loaders should be pure and cacheable (they are by default; this.cacheable(false) opts out). A pitch method can run before the chain in reverse order.

A plugin is a class with apply(compiler). webpack's internals are built on Tapable hooks, and plugins tap them:

  • Compiler hooks cover the whole lifecycle: beforeRun, compile, thisCompilation, emit, done, watchRun.
  • A compilation represents one build of the graph. In thisCompilation, tap compilation.hooks.processAssets with a stage (e.g. PROCESS_ASSETS_STAGE_ADDITIONAL) to add or modify output files via compilation.emitAsset and webpack.sources.
  • Hooks can be sync (tap), callback-async (tapAsync) or promise-based (tapPromise).

Use compilation.errors.push(new webpack.WebpackError(...)) or this.emitError in loaders to report problems instead of throwing raw exceptions.

Example

// banner-loader.js: prepend a comment to every matched module
module.exports = function bannerLoader(source) {
  const { text } = this.getOptions();
  return `/* ${text} */\n${source}`;
};

// build-info-plugin.js: emit build-info.json with the asset list
const { Compilation, sources } = require("webpack");
class BuildInfoPlugin {
  apply(compiler) {
    compiler.hooks.thisCompilation.tap("BuildInfoPlugin", (compilation) => {
      compilation.hooks.processAssets.tap(
        { name: "BuildInfoPlugin", stage: Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE },
        (assets) => {
          const info = { builtAt: new Date().toISOString(), files: Object.keys(assets) };
          compilation.emitAsset("build-info.json", new sources.RawSource(JSON.stringify(info)));
        },
      );
    });
  }
}
module.exports = BuildInfoPlugin;
// config: module.rules: [{ test: /\.js$/, use: { loader: path.resolve("banner-loader.js"), options: { text: "v1" } } }]
//         plugins: [new BuildInfoPlugin()]

Remember

  • Loaders: functions from source to source, with this.getOptions/callback/async
  • Plugins: apply(compiler) tapping Tapable hooks
  • Modify output in processAssets with the right stage
  • Report errors through webpack instead of throwing

Watch out for

  • Mutating assets in the emit hook (deprecated style) instead of processAssets stages.

Check yourself

  1. Which hook should a plugin use to add a new output file in webpack 5?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: webpack.js.org/contribute/writing-a-plugin/