Building a Vue Auto Component Importer - A Better Dev Experience

When first learning Vue, you are taught you need to import and add components to components in the script block.

<script>
import HelloWorld from '@/components/HelloWorld.vue'

export default {
  components: {
    HelloWorld
  }
}
</script>

<template>
  <HelloWorld />
</template>

However, there's been a recent trend to "upgrade" the Vue developer experience (DX), having components magically import themselves at compile-time.

<template>
  <HelloWorld />
</template>

In the wild, you can find auto component imports in most popular Vue frameworks, as part of the core or a plugin.

This article will look at: why automatic component imports exist, how you can easily build our own auto component importer using a Webpack loader and what the performance cost of using them has on your app.

Finally, we'll look at some other compile-time DX upgrades that are possible.

Why Automatic Component Imports?

The why that comes first to my mind, is the developer experience is great. No more confusion or typos on import paths, refactoring becomes easier and there's less code overall.

The unintuitive but equally great advantage is found in the problem that this feature first solved.

The UI framework Vuetify is a huge library of over 80 components, coming in at 99.4KB for their scripts. As far as I know, they were the first to introduce automatic component imports.

Problem: UI Framework Bloat

One of the complaints you'll hear about using a UI framework over something simple like TailwindCSS, is the bloat it will add to your app.

This is a valid concern. It's unlikely your application is going to need half the components that a UI framework has to offer. Forcing browsers to download code that will never run, dead code, is not ideal.

Additionally, this component bloat can make import paths harder to work with and further scope for issues to pop up.

So, how do Vuetify and other UI frameworks overcome their inherent bloat?

Solution: webpack Optimisations

As is the way, webpack is here to magically solve our problems with tree shaking and code splitting optimisations.

If tree shaking is new to you, you can think of it as an optimisation to remove code that isn't explicitly used. Banishing 'dead' code to the shadow realm.

The tree shaking optimisation requires ES2015 module syntax, (i.e. import and export) and a production build. The code can't be compiled to CommonJS modules (i.e. require) for it to work.

So how does all this relate to automatic component imports?

With Vuetify handling the imports of your components (à la carte as they call it), they can ensure webpack optimisations are running out of the box for your app with their component library.

The A la carte system enables you to pick and choose which components to import, drastically lowering your build size.

This will also make code-splitting more effective, as webpack will only load the components required for that chunk to be displayed.

Fundamental: How Does webpack Load Vue Files?

Before we jump into building our own automatic component importer, we'll need to have a basic understanding of how webpack loads Vue files.

When you request a resource (such as a file) in webpack, it pushes the request through a pipeline of webpack loaders to resolve the output. A webpack loader is a piece of code which will transform a resource from one thing into another, it has an input and output.

For example, the raw-loader will read a file and give you the string contents. The input is a path to a file in your filesystem, the output is the string contents of the file.

import txt from 'raw-loader!./hello.txt'

// txt=HelloWorld

The vue-loader is the loader for .vue files. The loader compiles and bundles your component Single File Component (SFC) into code that the browser can understand and run.

Vue Loader in Action

Let's take a look at an example of input and output from the vue-loader.

Input: App.vue

This is the default entry file for Vue CLI with Vue 3.

<script>
import HelloWorld from './components/HelloWorld.vue'

export default {
  name: 'App',
  components: {
    HelloWorld
  }
}
</script>

<template>
  <img alt="Vue logo" src="./assets/logo.png">
  <HelloWorld msg="Welcome to Your Vue.js App" />
</template>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

Output: App.vue

Internally, the loader parses this code using the compiler, getting an SFC descriptor object that is used to create the final string output of the loader.

import { render } from './App.vue?vue&type=template&id=7ba5bd90'
import script from './App.vue?vue&type=script&lang=js'

import './App.vue?vue&type=style&index=0&id=7ba5bd90&lang=css'

export * from './App.vue?vue&type=script&lang=js'
script.render = render
script.__file = 'src/App.vue'

export default script

Note: I've removed the Hot Module Reloading (HMR) code for simplicity here.

The output of the loader isn't that important to understand, just know that the vue-loader has an in and out function. The output is usually parsed to another loader such as babel-loader before being chunked.

Building an Automatic Component Importer

If you have some spare time, I'd encourage you to join along. You can use Vue CLI with the Vue 3 preset.

vue create auto-component-importer -p __default_vue_3__

To begin, let's remove the manual import from the entry SFC, like so:

New App.vue

App.vue
<script>
export default {
  name: 'App',
}
</script>

<template>
  <img alt="Vue logo" src="./assets/logo.png">
  <HelloWorld msg="Welcome to Your Vue.js App" />
</template>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

When we load our App.vue, the HelloWorld doesn't work, as expected. Our goal is to get it to work without touching the Vue code.

Step 1. Modify the webpack Configuration

We need to make sure the loader we'll be making is going to run after the vue-loader.

./vue.config.js
module.exports = {
  chainWebpack: (config) => {
    config.module
      .rules
      .get('vue')
      .use('components')
      .loader(require.resolve('./imports-loader'))
      .before('vue-loader')
      .end()
  }
}

If you'd like to see the raw webpack config example, open the below.

webpack.config.js example
webpack.config.js
module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      }
    ]
  }
}

Knowing that webpack loaders are loaded from bottom to top, we would modify the configuration as so:

webpack.config.js
module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.vue$/,
        use: [
          {
            loader: require.resolve('./imports-loader'),
          },
          {
            loader: 'vue-loader',
          }
        ]
      },
    ]
  }
}
Normally a webpack would handle this configuration changing for you.

Now we create the loader called imports-loader.js in your apps root directory. We're going to make sure we only run it for the virtual SFC module.

imports-loader.js
module.exports = function loader(source) {
  // only run for the virtual SFC
  if (this.resourceQuery)
    return source

  console.log(source)
  return source
}

The source variable is the output of the vue-loader, the Output: App.vue.

Here we can now change anything about how our components work by modifying the vue-loader output.

Step 2. Dumb Compile-Time Import

As a proof of concept, let's try to import the HelloWorld.vue component so our New App.vue works.

At this stage, we can just append the import code on to the source.

imports-loader.js
module.exports = function loader(source) {
  // only run for the virtual SFC
  if (this.resourceQuery)
    return source

  return `${source}
import HelloWorld from "@/components/HelloWorld.vue"
script.components = Object.assign({ HelloWorld }, script.components)
`
}

Your App.vue now knows what the HelloWorld component is and works. Try it yourself.

Note: This is a dumb solution, as it will be modifying HelloWorld.vue to also import itself.

Step 3. Making it smart

A smarter solution would give us the ability to add components to our component folder and use them straight away without any imports.

a. Scan components

The first step in making it smarter is we need to create a map of the components files we want to automatically import.

We recursively iterate over the components folder and do some mapping.

a. Scan components
const base = './src/components/'
const fileComponents = (await globby('*.vue', { cwd: base })).map((c) => {
  const name = path.parse(c).name
  const shortPath = path.resolve(base).replace(path.resolve('./src'), '@')
  return {
    name,
    import: `import ${name} from "${shortPath}/${c}"`
  }
})
// [ { name: 'HelloWorld', import: 'import HelloWorld from "@/components/HelloWorld.vue"' } ]

b. Find the template tags

To understand what components are being used, we need to have our new loader to compile the SFC <template> blocks.

b. Find the template tags
const compiler = require('@vue/compiler-sfc')

const parsed = compiler.parse(fs.readFileSync(`${this.context}/${path.basename(this.resourcePath)}`, 'utf8')).descriptor
const template = compiler.compileTemplate({
  id: 'tmp',
  source: parsed.template.content,
  filename: this.resourcePath,
})
const componentTags = template.ast.components
// [ 'HelloWorld' ]

Note: For simplicity, we're using Vue 3's compiler. The above won't work for Vue 2.

c. Matchmaking

With our freshly compiled template, we need to match the components we found in our template with the mapped component files from a. Scan Components.

c. Match making
const matches = []
componentTags.forEach(tag => matches.push(first(filter(fileComponents, c => c.name === tag))))
// [ { name: 'HelloWorld', import: 'import HelloWorld from "@/components/HelloWorld.vue"' } ]

If you wanted to match non-PascalCase names, you would modify this matcher function.

d. Insert the new dynamic imports

The final piece of the puzzle is appending the list of matched components and inserting the import line and assigning the components.

d. Insert the new dynamic imports
if (!matches.length)
  return source

const newContent = `
${matches.map(c => c.import).join('\n')}
script.components = Object.assign({ ${matches.map(c => c.name).join(', ')} }, script.components);
`
const hotReload = source.indexOf('/* hot reload */')
if (hotReload > -1)
  source = `${source.slice(0, hotReload) + newContent}\n\n${source.slice(hotReload)}`
else
  source += `\n\n${newContent}`

return source

We need to insert the new content before the HMR code if available, otherwise we need to restart our app to find new components.

Putting it all together

Below is the full imports-loader.js for reference. This loader should just work. Create a new component and then use it straight away, make sure you use PascalCase.

imports-loader.js
const fs = require('node:fs')
const path = require('node:path')
const globby = require('globby')
const first = require('lodash/first')
const filter = require('lodash/filter')

module.exports = async function loader(source) {
  // only run for the non-query requests
  if (this.resourceQuery)
    return source

  // a. Scan components
  const base = './src/components/'
  const fileComponents = (await globby('*.vue', { cwd: base })).map((c) => {
    const name = path.parse(c).name
    const shortPath = path.resolve(base).replace(path.resolve('./src'), '@')
    return {
      name,
      import: `import ${name} from "${shortPath}/${c}"`
    }
  })

  // b. Find the template tags
  const compiler = require('@vue/compiler-sfc')
  const parsed = compiler.parse(fs.readFileSync(`${this.context}/${path.basename(this.resourcePath)}`, 'utf8')).descriptor
  const template = compiler.compileTemplate({
    id: 'na',
    source: parsed.template.content,
    filename: this.resourcePath,
  })
  const componentTags = template.ast.components

  // c. Match making
  const matches = []
  componentTags.forEach(tag => matches.push(first(filter(fileComponents, c => c.name === tag))))

  // d. Insert the new dynamic imports
  if (!matches.length)
    return source

  const newContent = `
${matches.map(c => c.import).join('\n')}
script.components = Object.assign({ ${matches.map(c => c.name).join(', ')} }, script.components);
`
  const hotReload = source.indexOf('/* hot reload */')
  if (hotReload > -1)
    source = `${source.slice(0, hotReload) + newContent}\n\n${source.slice(hotReload)}`
  else
    source += `\n\n${newContent}`

  return source
}
There are several issues and edge cases with the above code, this is merely a proof of concept and shouldn't be used in production.If you're after a more complete solution you should clone the repos listed before.

Problems With Automatic Component Imports

Hopefully, you now have a better understanding of how auto component importing works. While working through that rough proof of concept, you may have foreseen some issues.

Performance Cost

For automatic component imports to provide their magic, they need to parse the SFC and compile the template at compile time. If you recall, we are running our loader after the vue-loader, that means this compilation has already been completed and is happening again.

This means that by using this feature, we are potentially doubling our component build time. Which affects the hot module replacement speed, the web-dev-server boot-time and the production build time.

Saying that certain optimisations can and are made. Loader output can be cached with one line, so unless we change a file we don't need to recompile it.

imports-loaders.js
// ...
module.exports = async function loader(source) {
  this.cache()
  // ...
}

For Vue 3 there may be a new way to optimise this feature. I've based the proof of concept on how the existing Vue 2 plugins work.

Static code only

If you have a dynamic import then it's not going to work. I don't think this is a massive issue as you can work around it with manual imports or using the v-if on inline components. Consider the below code:

<script>
export default {
  computed: {
    myComponent() {
      return Math.random() * 100 > 50 ? ComponentA : ComponentB
    }
  }
}
</script>

<template>
  <component :is="myComponent" />
</template>

For now, the automatic import of ComponentA and ComponentB is not possible.

Stricter component naming

Due to the nature of mapping a file name to a component name, it sets a few requirements around how you name your components. If you're following the Vue style-guide for component naming, you shouldn't have an issue.

If you're going to adopt automatic component imports I'd recommend the following rules:

  • Namespace all components (i.e HButtonOutline, H is the namespace)
  • Avoid non-unique component file names
  • Use nested folders to separate scopes

Other Compile-Time "Upgrades"

Import Directive Support

When compiling the template, we can also see when specific directives are used. If the directive is not globally registered, then we can do an automatic compile-time import of it.

<script>
export default {
  data() {
    return {
      bar: 'foo'
    }
  }
}
</script>

<template>
  <div>
    <input v-my-directive="bar">
  </div>
</template>

We could imagine that we could write some code which would inject the directive such as import MyDirective from "@/directives/MyDirective".

Progressive Images

We hook into the compiling again and replace the source of our images with compile-time low-resolution versions.

Input

<template>
  <v-img src="@/images/my-image" />
</template>

Output

<template>
  <v-img placeholder="@/images/my-image-placeholder" src="@/images/my-image" />
</template>

Async imports

The @nuxt/components package does offer this functionality as opt-in through a Lazy prefix on the component.

It makes use webpack's Lazy Loading which bundles imports as their own dependency. This is useful for if you have heavy components that aren't above the fold.

Conclusion

Vue is already one of the most developer-friendly frontend frameworks around, with continued improvements in the dev experience Vue will continue to flourish.

While these compile-time upgrades are not needed, they do make life easier. The possibilities with injecting code at compile time opens up many opportunities for reducing the 'chores' that seem to follow us around project to project.

Thanks for reading

webpack and Vue internals are a challenging topic and if you made it all the way through, pat yourself on the back.

If you like the technical side of Vue and Laravel, I'll be posting regular articles on this site. The best way to keep up to date is by following me @harlan_zw or signing up for the newsletter below.