Creating A Custom Plugin for Vite: The Easiest Guide

Written by gmakarov | Published 2024/01/21
Tech Story Tags: rollup | create-custom-vite-plugin | frontend | how-to-publish-your-plugin | vite-hooks | create-plugins-tutorial | hackernoon-top-story | vite-tutorial-for-beginners | hackernoon-es | hackernoon-hi | hackernoon-zh | hackernoon-fr | hackernoon-bn | hackernoon-ru | hackernoon-vi | hackernoon-pt | hackernoon-ja | hackernoon-de | hackernoon-ko | hackernoon-tr

TLDRFor the development server, it uses esbuild with native ES modules, which are supported by modern browsers, and we don’t need to bundle code into a single file, and it gives us fast HRM (Hot Module Replacement). For the bundle, it uses a rollup.js because it’s flexible and has a large ecosystem; it allows the creation of highly optimized production bundles with different output formats. Vite’s plugin interface is based on Rollup’s but with additional options and hooks for working with the dev server.via the TL;DR App

Vite is becoming more and more popular among developers, but since the community is not as large (as in Webpack), you may need to create your own custom plugin to solve your problems. In this article, we will discuss how to create a plugin for Vite, and I will break down my own plugin.

How Plugins Work in Vite

To create a plugin, it is important to know that Vite uses different build systems for the development server (command vite) and the bundle (command vite build).

For the development server, it uses esbuild with native ES modules, which are supported by modern browsers, and we don’t need to bundle code into a single file, and it gives us fast HRM (Hot Module Replacement).

For the bundle, it uses a rollup.js because it’s flexible and has a large ecosystem; it allows the creation of highly optimized production bundles with different output formats.

Vite’s plugin interface is based on Rollup’s but with additional options and hooks for working with the dev server.

Creating a Basic Plugin

When you create a plugin, you can inline it into your vite.config.js. There is no need to create a new package for it. Once you see that a plugin has been useful in your projects, consider sharing it with the community and contributing to the Vite ecosystem.

Also, since rollup.js has a bigger community and ecosystem, you might consider creating a plugin for rollup.js, and it will work just as fine in Vite. So, if your plugin functionality works only for the bundle, you can go with the rollup.js plugin instead of Vite, and users can use your rollup plugin in their Vite projects without any problems.

If you create a plugin for rollup, you will cover more users who use only rollup.js. If your plugin will affect the development server, then you go with the Vite plugin.

Let’s start creating plugin directly in vite.config.ts:

// vite.config.ts
import { defineConfig, Plugin } from 'vite';

function myPlugin(): Plugin {
  return {
    name: 'my-plugin',
    configResolved(config) {
      console.log(config);
	},
  };
}

export default defineConfig({
  plugins: [
    myPlugin(),
  ],
});

In this example, I have created a plugin called myPlugin which prints the Vite config as soon as it is resolved in the console in both stages: dev server and bundle. If I want to print config only in dev server mode, then I should add apply: 'serve', and apply: 'build' for bundle.

// vite.config.ts
import { defineConfig, Plugin } from 'vite';

function myPlugin(): Plugin {
  return {
    name: 'my-plugin',
	apply: 'serve',
    configResolved(config) {
      console.log(config);
	},
  };
}

export default defineConfig({
  plugins: [
    myPlugin(),
  ],
});

Also, I can return an array of plugins; it is useful for separating functionality for the dev server and bundle:

// vite.config.ts
import { defineConfig, Plugin } from 'vite';

function myPlugin(): Plugin[] {
  return [
    {
      name: 'my-plugin:serve',
      apply: 'serve',
      configResolved(config) {
        console.log('dev server:', config);
      },
    },
    {
      name: 'my-plugin:build',
      apply: 'build',
      configResolved(config) {
        console.log('bundle:', config);
      },
    },
  ];
}

export default defineConfig({
  plugins: [
    myPlugin(),
  ],
});

And that is pretty much it; you can easily add small plugins to the Vite config. And if a plugin gets too big, I prefer to move it to another file or even create a package.

If you need something more complex, there are a lot of useful hooks you can explore in Vite's documentation. But as an example, let's break down my own plugin below.

Breaking Down a Real Plugin

So, I have a plugin for creating SVG sprites based on icon files - vite-plugin-svg-spritemap.

The goal is to grab all icons .svg in the src/icons folder and collect their content into a single .svg file which is called SVG sprite. Let’s start from the bundle stage:

import { Plugin, ResolvedConfig } from 'vite';
import path from 'path';
import fs from 'fs-extra';

function myPlugin(): Plugin {
  let config: ResolvedConfig;

  return {
    name: 'my-plugin:build',
    apply: 'build',
    async configResolved(_config) {
      config = _config;
    },
    writeBundle() {
      const sprite = getSpriteContent({ pattern: 'src/icons/*.svg' });
      const filePath = path.resolve(config.root, config.build.outDir, 'sprite.svg');
      fs.ensureFileSync(filePath);
      fs.writeFileSync(filePath, sprite);
    },
  };
}

  1. Hook configResolved allows us to get config when it’s resolved to use it in the next hooks;

  2. The writeBundle hook is called after the bundling process is finished, and here, I will create the sprite.svg file;

  3. The getSpriteContent function returns a string of prepared SVG sprites based on the src/icons/*.svg pattern. I won't go deeper with this one; you can check out my other article explaining the whole process of SVG sprite generation;

  4. Then, I create the absolute path to the sprite.svg to put the SVG sprite content into with path.resolve(), make sure that file exists (or create one) with fs.ensureFileSync., and write the SVG sprite content into it.

Now, for the most interesting part - the dev server stage. I can’t use writeBundle here, and I can’t host files when the dev server is running, so we need to use server middleware to catch the request to sprite.svg.

import { Plugin, ResolvedConfig } from 'vite';

function myPlugin(): Plugin {
  let config: ResolvedConfig;

  return {
    name: `my-plugin:serve`,
    apply: 'serve',
    async configResolved(_config) {
      config = _config;
    },
    configureServer(server) { // (1)
      return () => {
        server.middlewares.use(async (req, res, next) => { // (2)
          if (req.url !== '/sprite.svg') {
            return next(); // (3)
          }
          const sprite = getSpriteContent({ pattern, prefix, svgo, currentColor });
          res.writeHead(200, { // (4)
            'Content-Type': 'image/svg+xml, charset=utf-8',
            'Cache-Control': 'no-cache',
          });
          res.end(sprite);
        });
      };
    },
  };
}

  1. configureServer is a hook to configure the dev server. It triggers before Vite’s internal middleware is installed; in my case, I need to add custom middleware after the internal middleware, so I return a function;

  2. To add custom middleware to catch every request to the dev server, I use server.middlewares.use(). I need it to detect requests with the URL [localhost:3000/sprite.svg](http://localhost:3000/sprite.svg), so I can emulate file behavior;

  3. If the request URL is not /sprite.svg - skip to the next middleware (i.e., pass control to the next handler in the chain);

  4. To prepare file content, I put the result of getSpriteContent into the variable sprite and send it as a response with configured headers (content type and 200 HTTP status).

As a result, I simulated file behavior.

But if files in src/icons are changed, deleted, or added, we should restart the server to generate new sprite content via getSpriteContent; for this, I will use file watching library - chokidar. Let’s add chokidar handlers to the code:

import { Plugin, ResolvedConfig } from 'vite';
import chokidar from 'chokidar';

function myPlugin(): Plugin {
  let config: ResolvedConfig;
  let watcher: chokidar.FSWatcher; // Defined variable for chokidar instance.

  return {
    name: `my-plugin:serve`,
    apply: 'serve',
    async configResolved(_config) {
      config = _config;
    },
    configureServer(server) {
	  function reloadPage() { // Function that sends a signal to reload the server.
        server.ws.send({ type: 'full-reload', path: '*' });
      }

      watcher = chokidar
        .watch('src/icons/*.svg', { // Watch src/icons/*.svg
          cwd: config.root, // Define project root path
          ignoreInitial: true, // Don't trigger chokidar on instantiation.
        })
        .on('add', reloadPage) // Add listeners to add, modify, delete.
        .on('change', reloadPage)
        .on('unlink', reloadPage); 

      return () => {
        server.middlewares.use(async (req, res, next) => {
          if (req.url !== '/sprite.svg') {
            return next();
          }
          const sprite = getSpriteContent({ pattern, prefix, svgo, currentColor });
          res.writeHead(200, {
            'Content-Type': 'image/svg+xml, charset=utf-8',
            'Cache-Control': 'no-cache',
          });
          res.end(sprite);
        });
      };
    },
  };
}

As you can see, the API of plugin creation is not really complicated. You just need to find hooks from Vite or Rollup that will fit your tasks. In my example, I'm using writeBundle from Rollup.js (as I said, it's used to generate the bundle), and configureServer from Vite because Rollup.js doesn't have native dev server support.

In the case of the writeBundle it was very simple, we took the SVG sprite content and put it into a file. In the case of the dev server, it confused me why I couldn't do the same; I looked at other authors’ plugins, and they all do about the same.

So, I use configureServer and via the server argument, I add middleware that triggers every request to the dev server by intercepting the sprite.svg request.

Using Vite Hooks

As I mentioned before, to create more useful plugins, you need to explore the hooks. They are explained in detail in the documentation:

https://vitejs.dev/guide/api-plugin#universal-hooks

https://vitejs.dev/guide/api-plugin#vite-specific-hooks

How to Name a Plugin

In terms of naming, Vite has some conventions for plugins, so you better check it out before you finish. Here are some key points:

  • Vite plugins should have a unique name with a vite-plugin- prefix;

  • Include the vite-plugin keyword in package.json;

  • Include a section in the plugin docs explaining why it is a Vite-only plugin (e.g., it uses Vite-specific plugin hooks);

  • If your plugin will only work for a specific framework, include its name as part of the prefix (vite-plugin-vue-, vite-plugin-react-, vite-plugin-svelte-).

How to Publish and Share Your Plugin

If you decide to publish your plugin in NPM, which I recommend because sharing knowledge and expertise is a fundamental principle of the IT community, fostering collective growth. To learn how to publish and maintain your package, check out my guide → The easiest way to create an NPM package.

I also highly recommend submitting your plugin to the vite’s community list - awesome-vite. Many people are looking for the most suitable plugins there, and it would be a great opportunity to contribute to the Vite ecosystem! The process of submitting your plugin there is simple - just make sure you meet the terms and create a pull request. You can find the list of terms here.

Overall, it needs to be specific to Vite (not rollup), open source, and have good documentation. Good luck with your plugins!


Written by gmakarov | Senior Software Engineer | Frontend | React | TypeScript
Published by HackerNoon on 2024/01/21