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.
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.mapContent 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)
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
WeDoctor Frontend Technology
Official WeDoctor Group frontend public account, sharing original tech articles, events, job postings, and occasional daily updates from our tech team.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
