Easily Handle Component Versioning with Vue3

rdelga80

Ricardo Delgado

Posted on April 29, 2021

Easily Handle Component Versioning with Vue3

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'}`
    }
  }
})  
Enter fullscreen mode Exit fullscreen mode

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 })
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Passing props
  2. Passing attrs
  3. Carrying emits
  4. 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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

It becomes:

@click="myFunction(value)"

// in <script>
props: {
  myFunction: Function
}
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
rdelga80
Ricardo Delgado

Posted on April 29, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related