Writing Your Own Plugin for Webpack

Introduction

Today, in the world of web application development, we very often have to resort to the help of bundlers. And although there is currently a large selection of application assembly tools, a significant proportion of written projects use Webpack as a bundler. And it happens that the standard functionality of Webpack does not cover our needs, and there are no plugins that can do this in the public domain. Then we come to the conclusion that we need to write our own plugin. This article is dedicated to the necessary base that you will need to understand how plugins for Webpack are structured and how to start writing them.

What is Webpack?

Webpack is a powerful modular bundler that allows developers to optimize their applications, use the latest JavaScript standards, and easily integrate third-party libraries. However, the capabilities of the standard Webpack settings are often not enough to solve specific project tasks. 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 look at how plugins work, examine the two most important objects when developing plugins, the hooks of these objects and the types of these hooks, and also walk through the process of developing a plugin step by step using an example. Whether you want to optimize the build, implement specific requirements of your project, or just better understand how Webpack works from the inside, writing your own plugin is a great way to deepen your knowledge and skills. Let's begin our journey into the world of Webpack plugins, which will allow you to unleash the full potential of this tool!

Class structure, main methods and instances

Webpack plugin is just a function or class. Let's talk about an example and consider the use case of a class. The plugin class must have a method apply. This method is called once by the Webpack compiler when the plugin is installed. Method apply receives a reference to the Webpack compiler as the first parameter, which in turn 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 when developing plugins 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 are related to different stages of the build process, both have hooks and this can cause confusion. Let's see what the difference is between them:

  • Compiler refers to the stage where Webpack starts building. This happens before the compilation object is created. At this stage, you can interact with the compilation process – for example, changing or inspecting configuration options, changing Webpack config settings, interacting with plugins, etc. Compiler hooks are available in plugins and allow you to do things like setting up the build context or changing options before the build itself starts.

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

Compilation object.

In Webpack, the Compilation Object is the central element of the build process. It is created for each entry point during the build process and contains all the information about the current build state, including modules, dependencies, and resources. It can be accessed in various hooks, such as in compilation:

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

Hooks

Hooks are divided into two types, synchronous and asynchronous. I think it is already clear from the name that synchronous hooks block the main thread and Webpack waits until such a hook is executed to continue working. When registering a synchronous hook, the method is used tap. While asynchronous hooks run in parallel to the main thread, but require a callback to be called (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);
      }
    );
  }
}

Writing your own plugin

Let's write a plugin as an example that will output the path of the module being built to the console.

First, let's create a folder, go into 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 to launch 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 output all the modules being collected to the console:

Examples of cases when 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, have encountered the fact that when creating an instance of the plugin, it needs to pass all the 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 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 the value of the field remotes in your config will contain only a call to one function with module names in arguments. However, this is another topic. Within the framework of this article, I would suggest separating this into a separate plugin that can work on top of the Module Federation plugin, contain all the logic, accept all the arguments it needs, possibly execute some logic before requesting your modules and call the Module Federation plugin.

In addition, the Module Federation plugin does not provide typing for remote modules, and in this case, a custom plugin can also help, which will unload declarations for remote modules used in your package. All that remains is to generate them at the stage of building your project and put them somewhere, but this is another topic.

Conclusion

In conclusion, we looked at 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 a visual example. I hope this article will help you better understand the main aspects of plugins for Webpack.

You can study all the hooks in the documentation: Compiler And Compilation.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *