We are writing our own plugin for Webpack

We are writing our own plugin for Webpack

Introduction

In today’s world of web application development, we often have to resort to the help of assemblers. And although there is currently a large selection of application compilation tools, a significant proportion of written projects use Webpack as a compiler. And it happens that the standard functionality of Webpack does not cover our needs, and there are no plugins that can do it in public access. Then we come to the conclusion that we need to write our own plugin. This article covers the basics you’ll need to understand how Webpack plugins work and how to get started writing them.

What is Webpack?

Webpack is a powerful modular packager that allows developers to optimize their applications, use the latest JavaScript standards, and easily integrate third-party libraries. However, most of the time, the capabilities of the standard Webpack settings are not enough to solve the specific tasks of the project. In such cases, plugins come to the rescue – extensions that add new functionality and facilitate routine operations.

What is the article about?

This article is about creating your own Webpack plugin. We will consider how plugins are arranged, we will analyze the two most important objects in the development of plugins, the hooks of these objects and the types of these hooks, as well as step by step we will analyze the process of developing a plugin using an example. Whether you want to optimize your build, implement the specific requirements of your project, or simply better understand how Webpack works from the inside, writing your own plugin is a great way to deepen your knowledge and skills. Let’s start a journey into the world of Webpack plugins, which will allow you to unlock the full potential of this tool!

Class structure, main methods and instances

A webpack plugin is just a function or class. Using an example, let’s talk and consider the option of using the class. A plugin class must have a method apply. This method is called once by the Webpack compiler during plugin installation. Method apply takes a reference to the Webpack compiler as the first parameter, which provides access to the compiler hooks. The plugin is structured as follows:

class MyPlugin {
    apply(compiler) {
        console.log('Ссылка на компилятор webpack', compiler)
    }
}

Compiler and Compilation

Among the two most important objects of plug-in development are: compiler and compilation.

class MyPlugin {
    apply(compiler) {
        compiler.hooks.compilation.tap('MyPlugin', (compilation) => {
            console.log('Создан новый объект компиляции:', compilation);
            compilation.hooks.buildModule.tap('MyPlugin', (module) => {
                console.log('Собираемый модуль:', module.resource);
            });
        });
    }
}

They refer to different stages of the assembly process, both have hooks and this can cause confusion. Let’s find out what is the difference between them:

  • Compiler refers to the stage when Webpack starts compiling. This is before the compilation object is created. At this stage, you can interact with the compilation process – for example, change or check configuration parameters, change Webpack config settings, for example, interact with plugins, etc. Compiler hooks are available in plugins and allow you to perform actions such as setting the build context or changing parameters before the build starts.

  • Compilation refers to a later stage that starts after the compilation object has been created. Using compilation hooks, you can perform actions with modules, transform them, add new files or modify existing ones.

A compilation object

In Webpack, the compilation object (or Compilation Object) is the central element in the compilation process. It is created for each entry point in the build process and contains all information about the current state of the build, including modules, dependencies, and resources. You can get it in various hooks, for example compilation:

class MyPlugin {
    apply(compiler) {
        // Используем хук compilation для доступа к объекту компиляции
        compiler.hooks.compilation.tap('MyPlugin', (compilation) => {
			// `compilation` здесь - это объект компиляции
            console.log('Создан новый объект компиляции:', compilation);
        });
    }
}

hooky

Hooks are divided into two types, they are synchronous and asynchronous. I think it’s clear from the name that synchronous hooks block the main thread and Webpack waits until such a hook is executed to continue working. A method is used when registering a synchronous hook tap. While asynchronous hooks run parallel to the main thread but require a callback (if you use tapAsync to register a hook) or return a promise (if you use tapPromise).

class MyPlugin {
  apply(compiler) {
    compiler.hooks.emit.tapAsync(
      'MyPlugin',
      (compilation, callback) => {
       // Что-то асинхронное
        setTimeout(function () {
          callback(); // Обязательно вызвать callback в конце
        }, 1000);
      }
    );
  }
}

We write our plugin

For example, let’s write a plugin that will output the path of the module being assembled to the console.

First, let’s create a folder, go to it, initialize the project and install the dependencies we need.

mkdir webpack-module-example
cd webpack-module-example
npm init
npm i webpack webpack-cli esbuild-loader --save-dev

The next step is to create the actual file with our plugin, index.ts for the webpack entry point and a simple Webpack config with one rule.

// myPlugin.js
class MyPlugin {
    apply(compiler) {
        compiler.hooks.compilation.tap('MyPlugin', (compilation) => {
            compilation.hooks.buildModule.tap('MyPlugin', (module) => {
                console.log('Собираемый модуль:', module.resource);
            });
        });
    }
}
module.exports = { MyPlugin };
// index.ts
console.log('Hello');
// webpack.config.js
const path = require('path');
const { MyPlugin } = require('./myPlugin.js');
module.exports = {
    mode: 'production',
    entry: path.resolve(__dirname, './index.ts'),
    output: {
        path: path.resolve(__dirname, './dist'),
        filename: 'bundle.js',
    },
    module: {
        rules: [
            {
                test: /\.(ts|js)x?$/,
                exclude: /node_modules/,
                loader: 'esbuild-loader',
                options: {
                    target: 'es2015',
                },
            },
        ],
    },
    resolve: {
        extensions: ['.tsx', '.jsx', '.ts', '.js'],
    },
    plugins: [new MyPlugin()],
};

And the last step is to add a script for launching the webpack to our package.json.

// package.json
{
    "name": "webpack-module-example",
    "version": "1.0.0",
    "description": "",
    "main": "index.js",
    "scripts": {
        "build": "webpack --config webpack.config.js"
    },
    "author": "",
    "license": "ISC",
    "devDependencies": {
        "esbuild-loader": "^4.2.2",
        "webpack": "^5.94.0",
        "webpack-cli": "^5.1.4"
    }
}

Result

As a result, our plugin should display all the modules being assembled in the console:

Examples of cases where writing your own plugin would be a good solution

For example, those familiar with the Module Federation plugin for Webpack, which allows you to organize micro-frontends, encountered the fact that when creating a plugin instance, it needs to pass all static addresses to each module:

new ModuleFederationPlugin({
    name: 'app',
    filename: 'remoteEntry.js',
    remotes: {	
	    app2: 'app2@http://localhost:3002/remoteEntry.js',
	    app3: 'app3@http://localhost:3003/remoteEntry.js',
	    app4: 'app4@http://localhost:3004/remoteEntry.js',
	    app5: 'app5@http://localhost:3005/remoteEntry.js',
	    app6: 'app6@http://localhost:3006/remoteEntry.js',
	},
    exposes: {
        './MyComponent': './src/MyComponent',
    },
    shared: [
        'react',
        'react-dom',
    ],
})

And if you want to use dynamic modules, then promises get there, which further increase the complexity of creating a plugin instance and make it more difficult to read.

new ModuleFederationPlugin({
    name: 'host',
    remotes: {
        app1: `promise new Promise(resolve => {
            const urlParams = new URLSearchParams(window.location.search)
            const version = urlParams.get('app1VersionParam')
            const remoteUrlWithVersion = 'http://localhost:3001/' + version + '/remoteEntry.js'
            const script = document.createElement('script')
            script.src = remoteUrlWithVersion
            script.onload = () => {
                const proxy = {
                    get: (request) => window.app1.get(request),
                    init: (arg) => {
	                    try {
	                        return window.app1.init(arg)
	                    } catch(e) {
	                        console.log('remote container already initialized')
	                    }
                    }
                }
                resolve(proxy)
            }
            document.head.appendChild(script);
        })`
    }
})

You can, of course, use a function to create such promises and field values remotes in your config will only contain a single function call with the module names in the arguments. However, this is another topic. As part of this article, I’d suggest breaking this out into a separate plugin that can run on top of the Module Federation plugin, contain all the logic, take all the arguments it needs, maybe execute some logic before your modules request, and call the Module Federation plugin.

Also, the Module Federation plugin does not provide typing for remote modules, in which case a self-written plugin that will unload declarations for the remote modules used in your package can also help. It remains only to generate them at the stage of assembling your project and put them somewhere, but this is another topic.

Conclusion

Finally, we considered the structure of plugins, the main objects used in their development, the hooks of these objects and their types. We also created a simple plugin as an example. I hope this article helps you better understand the main aspects of plugins for Webpack.

You can learn all the hooks in the documentation: Compiler and Compilation.

Related posts