Mike Rockétt

Component Factories for Vue

It’s not uncommon to follow the default approach and register global Vue components manually. But there’s a better way. Two of them, in fact. And we can do it for Vuex too, but that’s for another post.

#Vue — 20 December 2020

Update, December 2020: This article (and the package linked at the end) is only really applicable to projects using Webpack and, more specifically, Vue 2.x. If you’re using Vite or some other modular “no-bundles-please” solution, then this won’t work for you. If and when I explore automated component registration in these setups, I’ll write up another post. Tools like Vite have this stuff built in, and writing custom wrappers for it is, well, pointless.

Have you ever worked on a medium to large app built with Vue? If so, ever found that massive file with all the references to Vue.component? I have, and all I can say is this: scrolling for days.

Yes, these references may or may not take advantage of dynamic imports, but this doesn’t make it any better – it’s still a massive chunk of repetition.

Vue.component('inline-loader', () => import('@/components/global/loaders/InlineLoader'))
Vue.component('overlay-loader', () => import('@/components/global/loaders/OverlayLoader'))
Vue.component('page', () => import('@/components/global/scaffolds/Page'))
Vue.component('page-header', () => import('@/components/global/scaffolds/PageHeader'))
Vue.component('page-tools', () => import('@/components/global/scaffolds/PageTools'))
Vue.component('page-footer', () => import('@/components/global/scaffolds/PageFooter'))
Vue.component('page-sidebar', () => import('@/components/global/scaffolds/PageSidebar'))
Vue.component('sidebar-section', () => import('@/components/global/scaffolds/SidebarSection'))
Vue.component('filter-controls', () => import('@/components/global/scaffolds/FilterControls'))
 
// … and the list goes on, and on, and on.

The moment I encountered this, my gut said: there’s a better way.

And because we like to give options, we should tackle both normal (read: static or require()) and dynamic (read: lazy or import(), as above) component registrations.

Enter require.context

This function is Webpack-specific, and allows you to glob a directory and iterate the file names and import everything you need.

Here’s a simple example that iterates a components/global directory, converts each filename to a component-friendly name, and registers the component accordingly. This is taken from Chris Fritz’ enterprise boilerplate, modified a tad for our use-case.

const requireComponent = require.context(
'./components/global', // look in this directory,
true, // and go into subdirectories,
/\w+\.vue$/ // and match all SFC files.
)
 
// For each file, require it (fetch it as a module),
// clean up the filename, and register it with Vue.
requireComponent.keys().forEach((fileName) => {
const componentConfig = requireComponent(fileName)
const componentName = fileName
.replace(/^\.\//, '')
.replace(/\.\w+$/, '')
.split('-')
.map((kebab) => kebab.charAt(0).toUpperCase() + kebab.slice(1))
.join('')
 
Vue.component(componentName, componentConfig.default || componentConfig)
})

This is all well and good, and a great way to quickly get rid of the very many imports mentioned atop this post.

At this point, you can say you’ve done your job – great! 🎉

But, if you’re anything like me, it’s just not enough.

Why, you ask? Well, two reasons: the first relates to re-usability. This example would need repetition if we needed to do it twice – say for global components and ​’base’ components (as used in Chris’ example). Second, it’s not super tidy. Sure, you don’t need to look at it much, but hey, I’m a sucker for clean code.

Let’s use our imaginations for a moment, and conjure a little something up:

Vue.use(ComponentFactory, {
context: require.context('./components/global', true, /\w+\.vue$/)
})

Much better, and we still know what’s going on. Yes?

One might argue that new members on project will not know that components in said project are being registered automatically. But, at the end of the day, the second they try to find the file where they can register a new component of their own making, they’ll quickly bump into this. And a little doc-block never hurt anyone.

The Component Factory

It’s time to abstract this sucker into a factory that we can re-use. Without further ado, let’s create a simple factory that helps us achieve this. As you’ll learn, though, this isn’t what we’ll land up with at the end, but it’s a pretty good start:

export class ComponentFactory {
constructor(requireContext, filenameReplacementPattern) {
this.requireComponent = requireContext
this.filenameReplacementPattern = filenameReplacementPattern
}
 
fetchComponent(contextFilename) {
const component = this.requireComponent(contextFilename)
const filename = contextFilename.replace(this.filenameReplacementPattern, '$1')
const componentName = component.name || filename
 
return { componentName, component, filename }
}
 
static register(vue, context, filenameReplacementPattern) {
const factory = new this(context, filenameReplacementPattern)
 
factory.requireComponent.keys().forEach((contextFilename) => {
const { componentName, component } = factory.fetchComponent(contextFilename)
vue.component(componentName, component.default || component)
})
}
}

Great! We’ve wrapped everything we need into ComponentFactory, which self-constructs via the static register method. The name of each registered component is derived from either the name property in the component script, or the filename if it’s not set.

Without further configuration, here’s how you’d use it:

import Vue from 'vue'
 
const context = require.context('./components/global', true, /\w+\.vue$/)
ComponentFactory.register(Vue, context, /.*\/(\w+).vue$/g)

This is what I’d settled on at the beginning. It was enough to reduce repetition significantly, and it looked far cleaner.

But wait! What’s that second regular expression I see there? Well-spotted! That’s simply a filename replacement pattern that’s used to extract the name of the component itself from the filename – fetchComponent handles this for us. With Vue, you don’t really need to kebab-case it yourself, and you can still reference the component in <kebab-case/>, even if you register it as KebabCase. So the kebabbing done in Chris’ example is no longer needed.

Again, if you’re anything like me, we haven’t come to the end yet.

Why, you ask? We haven’t given any consideration to async components, and passing in the global Vue instance is really not the best way to go about this.

Time to Break it Apart

So, we’re going to do a few things here:

Firstly, we’re going to split our factory into two: StaticFactory and LazyFactory. One for bundling components up into the main bundle/​chunk, and another for splitting components out into their own modules and registering them as async components.

Then, we’re going to turn it into an installable extension of Vue so that we may use it. With this strategy, we can make an internal determination of which factory to use, based on the context.

At this point, you might be wondering why the context hasn’t been included as part of the factory-abstraction itself. The answer to that is simple: require.context isn’t a runtime function, so the browser has no idea what it is (yes, there’s probably a package for that, but let’s not). Rather, it’s an indication to Webpack’s build-time compiler that the module being analysed (ie. the file that calls this function) would like to create its own require-context, relative to its position in the directory structure and based on a set of rules. I did try to throw it into the factory to make my code leaner than ever, but that didn’t work, and so I moved on…

Base Factory

Because we know we’re going to build two factories that share common code, let’s build a base factory we can extend later.

Off the bat, we know that each factory will have a register method, which sets up a new instance of that factory and then calls fetchComponent, which itself is common to both factories – so, we’ll include it in our base factory.

export class BaseFactory {
constructor(requireContext, filenameReplacementPattern) {
this.requireComponent = requireContext
this.filenameReplacementPattern = filenameReplacementPattern
}
 
fetchComponent(contextFilename) {
const component = this.requireComponent(contextFilename)
const filename = contextFilename.replace(this.filenameReplacementPattern, '$1')
const componentName = component.name || filename
 
return { componentName, component, filename }
}
}

All we’ve really done here is remove the register method and rename the class to BaseFactory.

Static Factory

Nothing else needs to change at this point, and we’re ready to create our StaticFactory:

import { BaseFactory } from './base-factory'
 
export class StaticFactory extends BaseFactory {
constructor(context, filenameReplacementPattern) {
super(
context, // passed in explicitly as Webpack references it at build-time.
filenameReplacementPattern
)
}
 
static register(vue, context, filenameReplacementPattern) {
const factory = new this(context, filenameReplacementPattern)
 
factory.requireComponent.keys().forEach((contextFilename) => {
const { componentName, component } = factory.fetchComponent(contextFilename)
vue.component(componentName, component.default || component)
})
}
}

That register method we removed is now in the static factory, and it’s there because it’ll register the component with Vue using the ​’static’ approach.

Note: Depending on which version of Webpack you’re using, the module could be self-contained (old Webpack, v3 I believe), or it could be contained within the default export (helps with Hot Module Reloading, or HMR) which, in this case, needs to be referenced explicitly, hence component.default || component.

Lazy Factory

Next up, let’s create a similar factory for async components, where the only difference is how each component is registered:

import { BaseFactory } from './base-factory'
 
export class LazyFactory extends BaseFactory {
constructor(context, filenameReplacementPattern) {
super(context, filenameReplacementPattern)
}
 
static register(vue, context, filenameReplacementPattern) {
const factory = new this(context, filenameReplacementPattern)
 
factory.requireComponent.keys().forEach((contextFilename) => {
const { componentName, component } = factory.fetchComponent(contextFilename)
vue.component(componentName, () => component)
})
}
}

See the difference? We’re just using a dynamic import now: () => component, where component is a Promise, akin to () => import('./SomeComponent.vue').

Fantastic – we’ve split it all up, and now we just need to figure out how to wrap it all up and actually use it. This is where our Vue extension comes in.

Do you even use?

The approach is quite simple: we want to use a ComponentFactory and provide it with a require-context that it can use to determine which factory to spawn up. This reduces our usage of the entire abstraction to just three humble lines of code that make sense.

import { StaticFactory } from './static-factory'
import { LazyFactory } from './lazy-factory'
 
export const ComponentFactory = (Vue, options = {}) => {
 
if (!options.context) throw 'ComponentFactory needs an instance of require.context.'
 
const wantsAsync = options.context.id.split(' ')[1] === 'lazy'
const factory = wantsAsync ? LazyFactory : StaticFactory
const { context, fileReplacementPattern } = options
const defaultFileReplacementPattern = /.*\/(\w+).vue$/g
 
factory.register(Vue, context, filenameReplacementPattern || defaultFileReplacementPattern)
}

The wantsAsync check simply looks at the context to see if lazy is declared, and then uses the LazyFactory if it does.

And finally, here’s how we’d use it:

import Vue from 'vue'
import { ComponentFactory } from './component-factory'
 
Vue.use(ComponentFactory, {
// To use the StaticFactory:
context: require.context('./components/global', true, /\w+\.vue$/)
 
// To use the LazyFactory:
// context: require.context('./components/global', true, /\w+\.vue$/, 'lazy')
})

Déjà vu, anyone?

Did I hear you say package?

But of course 👀