VueJs is known for its simplicity and low learning curve, helping launch apps from beginners to senior devs alike.
But anyone who's spent time building up a codebase in Vue has learned with growth comes pain. Because of that it's important to address those scalable issues early on before an organization is stuck in a quagmire of tech debt and spaghetti code that can take days, weeks, and even months to correct.
Versioning components is one of those issues that can rub against a developers ego, but to care for "6 months in the future you", versioning components is an incredibly important time and energy saving strategy.
Tools like bit.dev handle this issue very well, but I'm preferential to duck tape and toothpick homegrown solutions that work just as well as a service that can cost upwards of $200 per month.
Why Do I Need To Version Components
If you're asking this question then you haven't had to deal with a design team that gets a new lead.
If you're asking this question then you haven't found a new library that more efficiently handles an issue that had been buggy since it's inception.
If you're asking this question then you haven't attended a Vue Conference and walked away thinking "duh, why haven't I always done it that way?"
In other words, your code will change, and in Vue if it's a component that's implemented in a hundred different files, then you will be kicking yourself as you ctrl+shift+F
your way through your codebase.
Standard Component Usage
For this example, we'll take a simple Vue Button Component:
<template>
<button
:class="['button', { block, color }]"
@click="$emit('click')">
<slot />
</button>
</template>
<script>
import { defineComponent } from '@vue/composition-api'
export default defineComponent({
name: 'Button',
props: {
block: Boolean,
color: {
type: String,
default: 'primary'
},
setup(props) {
const colors = {
primary: 'green',
error: 'red',
secondary: 'purple'
}
return {
color: `style-${colors[props.color] || 'green'}`
}
}
})
Where things get tricky is if you decide to take a new approach to how you want colors to set. Rather than using a named color table it'll instead act as a pass through style.
<template>
<button
:class="['button', { block }]"
:style="buttonStyle"
@click="$emit('click')">
<slot />
</button>
</template>
<script>
[...]
props: {
color: {
type: String,
default: 'gray'
},
setup(props) {
return {
buttonStyle: computed(() => { color: props.color })
}
}
}
This will, of course, break any instance in which you had used the Button component.
Handling Component Versions
Approaching this problem, the most straightforward solution is to create a stopgap between the code of the component, and how the component is called.
In this mindset then, we'll create a shell component that'll wrap around versioned components.
Most likely you're used to organizing your components as such:
src/
components/
VButton.vue
Which is probably useful in almost every scenario, but if you've happened to come across Vue - The Road to Enterprise by Thomas Findlay (which I highly recommend if you're beginning to architect large scale Vue apps), then you'll know that organizing Vue components is vital for a digestible code base.
Borrowing a few concepts from Thomas, this is a good organizational strategy to handle component versioning:
src/
components/
global/
VButton/
index.vue <-- shell
VButton-v1.vue <-- versioned
This will help keep your components nice and tidy, and with folders collapsed, the various component folders will provide easy reference for the grouping of shell and versioned components inside.
Writing a Shell Component
For the sake of this Button component, and most likely all simple components, there's going to be 4 main things we have to handle when building a shell:
- Passing props
- Passing attrs
- Carrying emits
- Passing slots
But first is how to handle the loading of the versioned component file:
<template>
<component :is="buttonComponent">
Button
</component>
</template>
<script>
import { defineAsyncComponent, defineComponent } from '@nuxtjs/composition-api'
export default defineComponent({
name: 'VButton',
props: {
version: {
type: String,
default: 'v1'
},
},
setup(props) {
const versionComponent = (version) => defineAsyncComponent(() => {
return import(`./VButton-${version}.vue`)
})
return {
buttonComponent: ref(versionComponent(props.version)),
}
}
})
</script>
Thanks to old tried and true <component>
paired with Vue3's defineAsyncComponent
this was actually a fairly easy lift.
Next is handling props, attrs, and emits:
<template>
<component
v-bind="{ ...$attrs, ...$props }"
:is="nButtonComponent"
@click="$emit('click')">
Button
</component>
</template>
Using built-in elements $attrs
and $props
, attrs and props are very easily passed to a child component to be digested.
And lastly, slots:
<template>
<component
v-bind="{ ...$attrs, ...$props }"
:is="nButtonComponent"
@click="$emit('click')">
<slot
v-for="(_, name) in $slots"
:name="name"
:slot="name" />
</component>
</template>
The one flaw with using $slots
is that they're not dynamic, but this mostly gets the job done. Since each shell is specific to each component then it would be easy to more explicitly define slots if need be.
And that's it. It's easy as importing your component just as you might normally:
import VButton from '@/components/global/VButton
But then when you use the component, passing a version prop notifies the shell which versioned component to use, and that should help curtail many breakages and allow adoption of the change to be handled over time:
<Button
color="purple"
version="v1"
@click="handleClick">
Click Me!
</Button>
Note: This is an MVP for this concept. Someone can rightly criticize this approach for some of the following reasons:
- It's not globally useable
- It could be much strong written in pure Vue3 render functions (this example comes from a Nuxt 2.15 app using the nuxtjs/composition-api plugin, which is missing some features from Vue3, including
resolveComponent
which would most likely be able to solve this issue) - This wouldn't be useful for more complex components
While these are true, I still think this is a very useful strategy especially if you are the type of dev who builds their own UI from scratch.
Update
After a bit of messing out on codesandbox, I put together a working example that also uses the render function as the shell component:
Note: In this Vue3 example slots
can just be directly passed as the third parameter, but in Nuxt (and possibly Vue2 with the composition-api plugin) it needs to be: map(slots, slot => slot)
using lodash.
Update 2
After working with the concept for a bit I hit a particular tricky spot - emits.
The issue with emits is that, to my knowledge, there isn't a way to handle a passthrough of them as directly as you are able to with props or attributes.
This makes the shell component a bit less "user friendly" because each shell becomes more customized, and forces there to be two components that need to have emits maintained.
This is not optimal.
Then I remembered an article I read about an anti-pattern in Vue, but a common one in React, passing functions as props (I wish I could find the article to link to it).
Rather then:
@click="$emit('myFunction', value)
It becomes:
@click="myFunction(value)"
// in <script>
props: {
myFunction: Function
}
I will say that this strategy is helpful on high-level components, but very low level components, like a button or input wrapper, would probably still be best served using emits in two places so that their events are easily consumed.
Top comments (2)
Hi, for the event passed on the component, i think you can write,
v-on=“$listeners”
I have a question, does
v-bind=“{...$attrs, ...$props}”
will also pass custom directive? for example when using v-mask
I'll have to check out $listeners, even though I've been more focused on using the render function, but maybe it's the same thing.
As for directives, I haven't really had a use case for it yet. But I'd have to wrack my brain to this if I would ever implement a directive effecting the inside of a component from the parent of the component.
Meaning that the Versioned component, not the shell component, would be the only place it would be needed.
Definitely still lots to figure out.