engineering Logo

Writing a vite plugin

With Vue 3 on the horizon, learn how to write your first vite plugin

by Andrew Walker

Sunday 26 July, 2020 · 6 min read
Decorative

With Vue 3 recently reaching release candidate stage, it’s a great time to dive in and give it a try. To get started right now you’ll need to use vite, the new web dev build tool from the creators of Vue. Vite offers a new plugin system for extending vite’s capabilities. Today we’ll take a look at setting up a simple vite plugin.


What will the plugin do?

Our plugin will automatically generate vue-router routes based on a directory of Vue components. This takes inspiration from Nuxt’s routing functionality, ie we want to turn this directory structure:

src/
|-- pages/
    |-- about.vue
    |-- contact.vue

into these routes automatically:

[
  {
    name: 'about',
    path: '/about',
    component: '/src/pages/about.vue',
  },
  {
    name: 'contact',
    path: '/contact',
    component: '/src/pages/contact.vue',
  },
]

About vite

Why vite?

Vite offers drastically faster build times than vue-cli and other webpack based setups. You can learn more about how and why vite came about through reading their explanation here or listening to Evan You discuss vite on Full Stack Radio. If you’ve ever had to use hot reloading with webpack or similar before, give vite’s hot reloads a try and you’ll see why Evan created it!

Vite plugin concepts

At the time of writing this, the documentation isn’t really there yet for creating vite plugins. Some of the concepts are discussed in the vite repository readme, but you need to do some digging in the vite source code to see exactly how to write a plugin. Let’s discuss a few key concepts:

The dev server: Modern browsers can deal (somewhat) with ES module imports when it comes to javascript, but not if it encounters imports of other files types. Every time your browser finds an import in your code, it will go through vite’s dev server before being served directly to the browser. Those imports can be javascript, vue, css - anything really if you tell vite how to deal with it. That’s why the vite dev server is so useful - it deals with this browser limitation for you.

The rollup production bundle: For static content, you’re not going to have to the vite dev server available to you in production builds. So vite uses rollup to bundle your code for production.

Vue custom block transformations: Sometimes other libraries you use will let you add custom blocks to your vue file, such as <docs> or <story>. Vite lets you specify how to deal with these blocks when it encounters them.


Writing our plugin

The general approach we’ll take is to automatically generate a vue-auto-routes.js file from which we’ll export our array of routes. We’ll think of this file as a virtual file, one that is generated dynamically both at runtime (for the dev server) and at build time (for rollup). Then we can import these routes in our code and use them in our app.

Generating vue-router routes automatically

To populate our vue-auto-routes.js file we’ll need to parse our src/pages directory and turn it into some import statements and an array of routes. We’ll crudely use the builtin fs node module to do this:

function parsePagesDirectory() {
  const files = fs
    .readdirSync('./src/pages')
    .map((f) => ({ name: f.split('.')[0], importPath: `/src/pages/${f}` }))

  const imports = files.map((f) => `import ${f.name} from '${f.importPath}'`)

  const routes = files.map(
    (f) => `{
        name: '${f.name}',
        path: '/${f.name}',
        component: ${f.name},
      }
      `,
  )

  return { imports, routes }
}

We return two lists from this function:

  • imports: our list of imports statements, eg "import about from 'src/pages/about.vue'"

  • routes: our array of routes, eg "{ name: 'about', path: '/about', component: about }"

Remember that right now these are strings because we’re creating a javascript file with them. Note that for component in our routes array, we use the imported variable, rather than a string or dynamic component - you’ll see why later.

Creating an empty plugin

A vite plugin is just an object that has various options added to it. We’ll start adding those options shortly. Let’s create a javascript file that can actually be used as a plugin:

module.exports = function () {
  return {}
}

We can then use this in our vite.config.js like so:

const viteAutoRoute = require('./plugin.js')

module.exports = {
  plugins: [viteAutoRoute()],
}

We use a function rather than just a plain object so that in future we can add our own customisation options to the plugin. For example, maybe we want to specify a custom pages directory with something like viteAutoRoute({ pagesDir: './src/docs' }).

Fetching our routes in local development

Now let’s actually make use of those routes we created earlier. For this we’ll use the configureServer vite plugin option:

module.exports = function () {
  const { imports, routes } = parsePagesDirectory()

  const moduleContent = `
    ${imports.join('\n')}
    export const routes = [${routes.join(', \n')}]
  `

  const configureServer = [
    async ({ app }) => {
      app.use(async (ctx, next) => {
        if (ctx.path.startsWith('/@modules/vue-auto-routes')) {
          ctx.type = 'js'
          ctx.body = moduleContent
        } else {
          await next()
        }
      })
    },
  ]

  return { configureServer }
}

First, we create a string moduleContent that contains the desired contents of our routes javascript file.

Then we create configureServer — a list of functions to be used as additional middleware in the vite dev server. Whenever vite encounters an import inside one of your javascript or vue files, it will make a request for that file to its dev server, do some transformations where necessary, and then send it back in a form the browser can deal with.

When it encounters something like import { routes } from 'vue-auto-routes', it will make a request for @/modules/vue-auto-routes. So what we want to do is intercept that request and return our generated moduleContent as the body and declare it as of type js.

Finally, we add this configureServer array to our returned object for use by Vite. Vite will see this and merge our list (of 1) middleware with its own middleware. Now we can use these dynamically generated routes in our own router:

import { createApp } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import { routes } from 'vue-auto-routes'
import App from './App.vue'

const router = createRouter({
  history: createWebHashHistory(),
  routes,
})
createApp(App).use(router).mount('#app')

Now when we run yarn dev our routes will work, eg http://localhost:3000/#/about 🎉

Note that we’re using vue-router-next — the upcoming router for Vue 3.

Accessing our routes in the production build

When we run yarn build it doesn’t look at the configureServer stuff we just made because it uses rollup instead of vite’s dev server. So we need to add some additional configuration to get this working in production:

const virtual = require('@rollup/plugin-virtual')

module.exports = function () {
  // our previous moduleContent and configureServer...

  const rollupInputOptions = {
    plugins: [virtual({ 'vue-auto-routes': moduleContent })],
  }

  return { configureServer, rollupInputOptions }
}

Here we make use of the rollupInputOptions vite plugin option. This lets us pass in rollup plugins. We use @rollup/plugin-virtual which takes module names and lets you return whatever javascript content you want. Essentially doing the same thing as our dev server solution.

Now our routes will work both in local development and in production.

Adding custom blocks

Individual vue pages may want to provide additional options for the route they correspond to. For example, we may want to add some additional meta options to our route, or use a custom route name. For this, we’ll reimplement vue-cli-plugin-auto-routing's route block, so that we do the following in our vue components:

<route>
{
  "meta": {
    "requiresLogin": true,
  }
}
</route>

To do this, we’ll make use of the vueCustomBlockTransforms option. This allows you to tell vite how to deal with custom blocks in vue files when it encounters them. As this is the last thing we’ll add, let’s take a look at this as part of the whole plugin:

const fs = require('fs')
const virtual = require('@rollup/plugin-virtual')

function parsePagesDirectory() {
  const files = fs
    .readdirSync('./src/pages')
    .map((f) => ({ name: f.split('.')[0], importPath: `/src/pages/${f}` }))

  const imports = files.map((f) => `import ${f.name} from '${f.importPath}'`)

  const routes = files.map(
    (f) => `{
        name: '${f.name}',
        path: '/${f.name}',
        component: ${f.name},
        ...(${f.name}.__routeOptions || {}),
      }
      `,
  )

  return { imports, routes }
}

module.exports = function () {
  const { imports, routes } = parsePagesDirectory()

  const moduleContent = `
    ${imports.join('\n')}
    export const routes = [${routes.join(', \n')}]
  `

  const configureServer = [
    async ({ app }) => {
      app.use(async (ctx, next) => {
        if (ctx.path.startsWith('/@modules/vue-auto-routes')) {
          ctx.type = 'js'
          ctx.body = moduleContent
        } else {
          await next()
        }
      })
    },
  ]

  const rollupInputOptions = {
    plugins: [virtual({ 'vue-auto-routes': moduleContent })],
  }

  const vueCustomBlockTransforms = {
    route: ({ code }) => {
      return `
        export default function (Component) {
          Component.__routeOptions = ${code}
        }
      `
    },
  }

  return { configureServer, rollupInputOptions, vueCustomBlockTransforms }
}

We’ve added a vueCustomBlockTransforms object that maps the key route (our block name) to a function that essentially returns another virtual javascript file. This has a default export function that takes the component for the vue file in question, and adds an extra field of our choosing, __routeOptions that maps to the code we declare inside any custom <route> block.

We then use this in our route generation code (...(${f.name}.__routeOptions || {})) and merge it with the other settings for a given route. This is why we import the component in our virtual vue-auto-routes file - so we can access the __routeOptions field that we’ve added.

Now we can use things like $route.meta.requiresLogin within our vue page component!

Use this in your projects today

We have published a vite plugin with additional features like automatic route params and nested routes. You can install this with:

yarn add -D vite-plugin-auto-routes

Note: Custom <route> blocks are not yet available in this published version owing to issues with dynamic component imports.


Wrapping up

Hopefully you’ve learned a little about how vite works and how to write some basic plugins. Look out for Vue’s official v3 release in the near future, where hopefully they’ll provide more documentation on everything else you can do with vite plugins!