Mastering Webpack: From Custom Loaders to Powerful Plugins

This article explains how Webpack loaders work, outlines development guidelines, demonstrates synchronous, asynchronous, raw, and pitching loaders with code examples, and then guides you through creating and configuring custom plugins, covering hooks, async handling, and a practical build‑info plugin.

WeDoctor Frontend Technology
WeDoctor Frontend Technology
WeDoctor Frontend Technology
Mastering Webpack: From Custom Loaders to Powerful Plugins

Loader

Jiao Chuan Kai, Frontend Platform Support Team at WeDoctor. Learning should also be engaging.

1.1 What does a loader do?

Webpack can only understand JavaScript and JSON files out of the box. Loaders enable Webpack to process other file types, converting them into valid modules that can be added to the dependency graph.

In other words, Webpack treats every file as a module; a loader can import any type of module, but Webpack does not natively parse files like CSS. Loaders bridge that gap using two main properties:

test – identifies which files should be transformed.

use – specifies which loader to apply during the transformation.

How do you create a custom loader?

1.2 Development Guidelines

As the saying goes, "Without rules there is no square." Webpack provides a set of Guidelines that you should follow to standardize your loader:

Simple and easy to use .

Support chaining – each loader should have a single responsibility.

Produce modular output.

Be stateless – each run must be independent of previous compilations.

Leverage the official loader utilities .

Record loader dependencies.

Parse module dependency relationships.

Depending on the module type, dependencies may be declared differently (e.g., @import or url(...) in CSS). They can be resolved by converting them to require statements or using this.resolve .

Extract common code.

Avoid absolute paths.

Use peer dependencies when a loader simply wraps another package.

1.3 Getting Started

A loader is a Node.js module that exports a function with a single argument – a string containing the resource content. The function returns the processed content. The simplest loader looks like this:

module.exports = function (content) {
  // content is the source string
  return content;
}

When a loader is used, it receives only one argument – the source string. From here, you can add richer functionality.

1.4 Four Types of Loaders

Common loaders can be classified into four categories:

Synchronous loader

Asynchronous loader

"Raw" loader

Pitching loader

① Synchronous vs. Asynchronous Loader

Typical loaders are synchronous and simply return the result:

module.exports = function (content) {
  // process content
  const res = doSomething(content);
  return res;
}

You can also use this.callback() to signal Webpack and return undefined. The callback signature is:

this.callback(
  err,          // Error or null
  content,      // string or Buffer
  sourceMap?,   // optional source map
  meta?         // optional metadata
);

Example using this.callback:

Note the use of this.getOptions() (Webpack 5) to retrieve loader options:

From Webpack 5 onward, this.getOptions replaces loader-utils 's getOptions .
module.exports = function (content) {
  // get user‑provided options
  const options = this.getOptions();
  const res = someSyncOperation(content, options);
  this.callback(null, res, sourceMaps);
  return;
}

This completes a synchronous loader.

Asynchronous loaders are needed for operations like network requests. Use this.async() to obtain a callback and return undefined:

module.exports = function (content) {
  var callback = this.async();
  someAsyncOperation(content, function (err, result) {
    if (err) return callback(err);
    callback(null, result, sourceMaps, meta);
  });
}

② "Raw" Loader

By default, resources are converted to UTF‑8 strings before being passed to a loader. Setting module.exports.raw = true makes the loader receive a raw Buffer instead:

module.exports = function (content) {
  console.log(content instanceof Buffer); // true
  return doSomeOperation(content);
}
module.exports.raw = true;

③ Pitching Loader

Each loader can define a pitch method. Loaders are executed from right to left, but their pitch methods run from left to right before the normal execution. The pitch signature is:

remainingRequest – absolute paths of loaders after the current one, joined by !.

precedingRequest – absolute paths of loaders before the current one, joined by !.

data – an object shared between pitch and normal phases.

Data set in pitch can be accessed later via this.data:

module.exports = function (content) {
  return someSyncOperation(content, this.data.value); // this.data.value === 42
};
module.exports.pitch = function (remainingRequest, precedingRequest, data) {
  data.value = 42;
};

If a pitch returns a value, the loader chain stops and the returned value is used directly. Example:

1.5 Other Loader APIs

this.addDependency

– add a file to watch for changes. this.cacheable – enable/disable caching (default is cacheable). this.clearDependencies – clear all dependencies. this.context – directory of the resource file. this.data – shared object between pitch and normal phases. this.getOptions(schema) – retrieve loader options. this.resolve(context, request, callback) – resolve a request like require. this.loaders – array of all loaders (modifiable during pitch). this.resource – full request string (e.g., /abc/resource.js?query). this.resourcePath – path without query. this.sourceMap – boolean indicating whether to generate a source map.

For more APIs, refer to the official documentation.

1.6 Simple Practice

Feature Implementation

We create two loaders:

company-loader.js – adds a comment /** Company@Year */ to the compiled code.

console-loader.js – removes console.log statements.

module.exports = function (source) {
  const options = this.getOptions();
  this.callback(null, addSign(source, options.sign));
  return;
}
function addSign(content, sign) {
  return `/** ${sign} */
${content}`;
}
module.exports = function (content) {
  return handleConsole(content);
}
function handleConsole(content) {
  return content.replace(/console.log\(['|"](.*?)['|"]\)/, '');
}

Testing the Loaders

Two ways to test a local loader: npm link or direct path configuration.

Match a single loader by setting path.resolve to the loader file.

{
  test: /\.js$/,
  use: [
    { loader: path.resolve('path/to/loader.js'), options: { /* ... */ } }
  ]
}

Match multiple loaders by configuring resolveLoader.modules to include a custom loaders directory.

resolveLoader: {
  modules: ['node_modules', path.resolve(__dirname, 'loaders')]
}

Webpack Configuration

module: {
  rules: [
    {
      test: /\.js$/,
      use: [
        'console-loader',
        {
          loader: 'company-loader',
          options: { sign: 'we-doctor@2021' }
        }
      ]
    }
  ]
}

Source file index.js:

function fn() {
  console.log("this is a message");
  return "1234";
}

Compiled bundle shows the comment and the removed console.log:

/******/ (() => { // webpackBootstrap
var __webpack_exports__ = {};
/** we-doctor@2021 */
function fn() {
  return "1234"
}
/******/ })();

Plugin

Why Use Plugins?

Plugins provide capabilities beyond loaders by tapping into Webpack's lifecycle hooks, allowing developers to inject custom behavior at various stages of the build.

Basic Structure

A JavaScript class.

An apply method that receives the compiler object.

Use appropriate hooks to perform actions.

For async work, call callback or return a Promise.

class HelloPlugin {
  apply(compiler) {
    compiler.hooks.<hookName>.tap(PluginName, (params) => {
      /** do something */
    })
  }
}
module.exports = HelloPlugin;

Compiler and Compilation

The compiler represents the whole Webpack environment (options, loaders, plugins) and is a singleton. The compilation object is created for each build and holds module information, generated assets, and dependency tracking. Both expose many hooks (e.g., SyncHook, AsyncParallelHook, etc.) via the tapable library.

Tip: In older versions you might see compiler.plugin , but Webpack 5 uses the new hook API.

Synchronous vs. Asynchronous Hooks

Use .tap for synchronous hooks. For asynchronous hooks, use .tapAsync (with a callback) or .tapPromise (return a Promise).

tapAsync Example

class HelloPlugin {
  apply(compiler) {
    compiler.hooks.emit.tapAsync(HelloPlugin, (compilation, callback) => {
      setTimeout(() => {
        console.log('async');
        callback();
      }, 1000);
    });
  }
}
module.exports = HelloPlugin;

tapPromise Example

class HelloPlugin {
  apply(compiler) {
    compiler.hooks.emit.tapPromise(HelloPlugin, (compilation) => {
      return new Promise((resolve) => {
        setTimeout(() => {
          console.log('async');
          resolve();
        }, 1000);
      });
    });
  }
}
module.exports = HelloPlugin;

Practical Plugin: BuildInfo Logger

This plugin creates a markdown file in the output directory that records the build time, each asset name, and its size.

class OutLogPlugin {
  constructor(options) {
    this.outFileName = options.outFileName;
  }
  apply(compiler) {
    const { webpack } = compiler;
    const { Compilation } = webpack;
    const { RawSource } = webpack.sources;
    compiler.hooks.compilation.tap('OutLogPlugin', (compilation) => {
      compilation.hooks.processAssets.tap({
        name: 'OutLogPlugin',
        stage: Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE,
      }, (assets) => {
        let resOutput = `buildTime: ${new Date().toLocaleString()}

`;
        resOutput += `| fileName | fileSize |
| -------- | -------- |
`;
        Object.entries(assets).forEach(([pathname, source]) => {
          resOutput += `| ${pathname} | ${source.size()} bytes |
`;
        });
        compilation.emitAsset(`${this.outFileName}.md`, new RawSource(resOutput));
      });
    });
  }
}
module.exports = OutLogPlugin;

Plugin Configuration

const OutLogPlugin = require('./plugins/OutLogPlugin');
module.exports = {
  plugins: [
    new OutLogPlugin({ outFileName: "buildInfo" })
  ]
};

After building, the dist folder contains:

dist
├─ buildInfo.md
├─ bundle.js
└─ bundle.js.map

Content of buildInfo.md (example):

Reference Articles

Writing a Loader | webpack (https://webpack.js.org/contribute/writing-a-loader/)

Writing a Plugin | webpack (https://webpack.js.org/contribute/writing-a-plugin/)

深入浅出 Webpack (https://webpack.wuhaolin.cn/)

webpack/webpack | GitHub (https://github.com/webpack/webpack/blob/master/lib/Compiler.js)

Full source code: GitHub (https://github.com/rodrick278/your-loader-and-plugin)

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

FrontendJavaScriptpluginWebpackloaderbuild-tools
WeDoctor Frontend Technology
Written by

WeDoctor Frontend Technology

Official WeDoctor Group frontend public account, sharing original tech articles, events, job postings, and occasional daily updates from our tech team.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.