Writing a Custom Webpack Loader

Saravanan M
Nerd For Tech
Published in
5 min read3 days ago

In the previous post, we explored what loaders are and how they function and it might have left you thinking that creating loaders involves some complex magic and is beyond the reach of mere mortals but believe me, creating a loader is dead simple and anyone can do it. In this article, I will prove you that by creating a not-so-trivial loader.

Just to recap, a loader is essentially a function that processes the content of a file and returns the transformed result. Additionally, loaders can optionally emit extra files if necessary. When combined, multiple loaders create what’s known as a “loader chain,” enabling more complex yet modular transformations of file content.

While going through a real world example, we will together try understanding some core concepts for writing a custom loader.

Let’s get started.

Photo by Wolf Schram on Unsplash

Scenario

Suppose you want a loader that processes image files and generates a metadata file containing the dimensions of the image. This loader will:

  1. Transform the image file by converting it to a Base64 string so that it can be embedded in the main bundle itself.
  2. Emit an additional JSON file with the image’s metadata (width, height & its filepath).

Step-by-Step Example

  1. Install Required Dependencies

We need image-size to get the dimensions of the image. Install it using:

npm install image-size

2. Create the Custom Loader

Create a file named image-metadata-loader.js in your project directory with the following content:

// This loader processes image files and emits metadata
module.exports = function (source) {

// Convert image to Base64
const base64Image = `data:image/${path.extname(this.resourcePath).slice(1)};base64,${source.toString('base64')}`;// Get image dimensions
const dimensions = sizeOf(this.resourcePath);

// Generate metadata
const metadata = {
width: dimensions.width,
height: dimensions.height,
originalFile: path.basename(this.resourcePath)
};
// Define the metadata file name
const metadataFileName = path.basename(this.resourcePath, path.extname(this.resourcePath)) + '-metadata.json';
// Emit the metadata file
this.emitFile(metadataFileName, JSON.stringify(metadata, null, 2));
// Return the Base64 image data as the content

const transformedContent = `module.exports = '${base64Image}';`
return transformedContent;
};

module.exports.raw = true; // since we are dealing with binary

Note: Loaders can receive input as either a string or a buffer. Since we’re working with binary data (an image, in this case), we can instruct Webpack to pass the content as a buffer by setting module.exports.raw = true

We have already seen in the previous post how webpack loaders are called in a sequential pipeline-like fashion(by loader-runner), where the input of one loader is fed to the next loader in the chain.

However, Webpack also allows loaders to operate asynchronously. This means that while each loader processes files in sequence, Webpack can handle multiple files concurrently, improving efficiency and resource utilization.

Loader context:

Before we delve deeper into synchronous versus asynchronous loaders, let’s understand an essential concept: the loader context

Within the loader function, there are bunch of methods and variables available to us from the this context which is prefilled by webpack and loader-runner(a program that runs the loaders). These allow us to perform tasks such as emitting additional files, accessing configuration options, and specifying dependencies to watch for changes, among other functions.

Synchronous Loaders

  • By default, loaders are synchronous. This means that the loader chain of one file runs on the main thread and blocks execution of the other files until it completes.
  • Returning the transformed output with the return statement at the end makes the loader synchronous.
  • You can also use this.callback to signal the completion of the loader’s work while keeping the execution synchronous.
module.exports = function (source) {
...
this.callback(null, transformedContent);
}
  • this.callback should be used to indicate failure, handle source maps, and pass additional metadata for subsequent loaders. It has the following signature:
this.callback(
err: Error | null, // Error, if any occurred during processing
content: string | Buffer, // The transformed content to be returned
sourceMap?: SourceMap, // Optional source map
meta?: any // Optional metadata
);

Asynchronous Loaders

For loaders that perform time-consuming tasks (such as network requests or heavy computations), you can make them asynchronous. Use this.async to turn the loader into an asynchronous function. This method returns a callback function that you should call with the transformed result once it's ready.

Here’s how to use this.async:

  1. Call this.async() at the start of your loader function.
  2. Perform your asynchronous operations.
  3. Once your processing is complete, call the callback returned by this.async() with the result
module.exports = function (source) {
const callback = this.async();
...
...
callback(null, transformedContent);
}

When webpack(loader-runner) sees a call tothis.async , it makes the loader execution asynchronous. While the next loader still has to wait for this loader to return the output, making a loader asynchronous helps in unblocking the main thread in case of long network requests or heavy computation and its always recommend to use this.async whenever possible.

3. Update Webpack Configuration

const path = require('path');
module.exports = {
module: {
rules: [
{
test: /\.(png|jpe?g|gif)$/i, // Apply to image files
use: [
{
loader: path.resolve(__dirname, 'image-metadata-loader.js'),
},
],
type : 'javascript/auto'
},
],
},
};

We use type: ‘javascript/auto’ because our loader converts images into JavaScript modules that export base64 strings. This ensures Webpack processes the images as JavaScript modules, embedding them directly in the bundle instead of emitting them as separate files in the output folder.

getOptions

Let’s say we want our users to control whether the dimensions (width and height) are included in the emitted meta.json file.

In the previous post, we explored how the options field in webpack.config.js allows us to modify loader’s behavior. Similarly, we can use options in our case to achieve exactly what we want — to make our custom loader “configurable”.

Here’s how the webpack.config.js looks like

const path = require('path');

module.exports = {
module: {
rules: [
{
...
use: [
{
loader: path.resolve(__dirname, 'image-metadata-loader.js'),
options: { includeDimensions: false } // options
},
],
},
],
},
};

But how can we consume the includeDimensions option from our loader?

Remember the loader context we discussed earlier? It provides a useful function called getOptions, which allows us to access the configuration parameters passed through the options field.

module.exports = function () {
const options = this.getOptions() || {};

const includeDimensions = options.includeDimensions !== undefined
? options.includeDimensions
: true;

const dimensions = includeDimensions ? sizeOf(this.resourcePath) : {};

// Additional code here...
};

4. Testing the Loader

Add an image file named sample.png to your project directory. In your CSS file, reference the image using url(‘./sample.png’). Run Webpack, and you’ll see that in the dist(output) folder

  1. The image is embedded as a Base64 string within our main bundle.
  2. Additionally, a sample-metadata.json file containing the image’s dimensions.

Understanding these basics will help you write most loaders, but if you’re interested in delving deeper into custom loaders, I highly recommend checking out the official Webpack guidelines provided by the core team in this article. Additionally, to explore more functions/variables available in the loader context, take a look at this resource.

I hope you enjoyed the article! If you found it helpful, please leave a comment and give it a clap. Your feedback is greatly appreciated!

--

--

Saravanan M
Nerd For Tech

Writes about Functional Programming, Web Development...