)
How to create an Universal Library for Vue 2 and 3
September 06, 2022
As you probably know by now, last September Evan You announced the new version of Vue (Vue 3.0 or "One Piece" for friends) during the Vue.js Global Event - Official release here.
The hype for upgrading code to the latest version of Vue exploded and everyone (including me) was eager to start. But they are breaking changes, especially on the global API, forcing library/plugin authors to migrate their code to support the new version and the Composition API. If you want to understand better why I wrote an article on how to do the migration from 2.x to 3.x here - How to migrate your library from Vue 2.x to Vue 3.x
As an author of a Vue library, I have to say that the migration wasn't an easy job, imitating what major libraries did: separating the support for each targeting version in separate branches and tags (main for vue 2.x and next for vue 3.x) or even having a separate repo to ensure better code isolation.
As Vue.js core member @antfu (Anthony Fu) explains in this post:
The drawback of this is that you will need to maintain two codebases that double your workload. For small scale libraries or new libraries that want to support both versions, doing bugfix or feature supplements twice is just no ideal. I would not recommend using this approach at the very beginning of your projects.
It's possible to achieve this by using a developing tool that the same @antfu created called Vue-demi.
So if you are interested to learn how to create a universal library/plugin for both versions of Vue, this article is for you.
Create base setup
Let's begin by creating a new project using vue-cli prompt.
vue create vue-universal-libBe sure you select the 3.x version for Vue, and for the rest I leave it to your preferences, but I strongly suggest you use the same options as I describe here to be on the same page:
Options selected:
Babel
Typescript
Linter
Use class-style component syntax No
Use Babel alongside TypeScript Yes
Pick a linter: ESLint + Prettier
After some seconds we will have a basic structure to start with. You probably need to get rid of some stuff like the App.vue and main.ts since we mainly are going to work with an index.ts file.
Find a purpose
Sounds epic right? Fun apart find a necessity, some functionality often used in Web development that you want to implement in Vue and make it reusable, something that you think will bring value being a library/plugin.
For this tutorial's sake, we will create a simple library that allows you to animate numbers like a counter, similar to this:
This type of component is often used in landing pages to show KPIs.
Hands dirty
First of all, let's create the counter-number component under src/components/CounterNumber.ts using defineComponent.
import { ref, defineComponent, h } from 'vue';
export const CounterNumber = defineComponent({
name: 'Awesome',
props,
setup(props, ctx) {
const value = ref(640);
return () =>
h(
'span',
{
class: 'counter-number',
},
value,
);
},
});For the moment let's leave it as a presentational component without the animation, later we will add the functionality through a composable function to take advantage of Vue3's Composition API.
You might also notice there is no template for the component in here, the setup function returns a render function with a <span> element holding the counter value. That's intended and will be explained in the Caveates section of the post.
For demo purposes leave out a main.ts and the App.vue to test the new component using npm serve.
Plugin installation
For creating the plugin itself create a src/index.ts:
import { App, inject, InjectionKey } from 'vue';
import { CounterNumber } from './components/CounterNumber';
export interface VueCounterOptions {
theme: string;
}
export interface VueCounterPlugin {
options?: VueCounterOptions;
install(app: App): void;
}
export const VueCounterPluginSymbol: InjectionKey<VueCounterPlugin> = Symbol();
export function VueCounterPlugin(): VueCounterPlugin {
const VueCounterPlugin = inject(VueCounterPluginSymbol);
if (!VueCounterPlugin) throw new Error('No VueCounterPlugin provided!!!');
return VueCounterPlugin;
}
export function createVueCounterPlugin(
options?: VueCounterOptions,
): VueCounterPlugin {
const plugin: VueCounterPlugin = {
options,
install(app: App) {
app.component('vue-counter', CounterNumber);
app.provide(VueCounterPluginSymbol, this);
},
};
return plugin;
}Let's break this down into parts, the function createVueCounterPlugin will allow you to install the plugin via the install method when using createApp.use() in your app.
This will add to the app instance all the components, properties of your library like you see above with app.component('vue-counter', CounterNumber);
To get most of the Composition API and be able to inject into your library components things like options or utilities we create a Plugin Symbol to be used along with app.provide in the install method where we pass the createVueCounterPlugin itself as a parameter. This might look complicated at the moment, but it's the standard way:
// index.ts
...
export const VueCounterPluginSymbol: InjectionKey<VueCounterPlugin> = Symbol();
export function VueCounterPlugin(): VueCounterPlugin {
const VueCounterPlugin = inject(VueCounterPluginSymbol);
if (!VueCounterPlugin) throw new Error('No VueCounterPlugin provided!!!');
return VueCounterPlugin;
}
...To install the plugin and test it, go to your src/main.ts:
import { createApp } from 'vue';
import App from './App.vue';
import './assets/styles/main.css';
import { createVueCounterPlugin } from './';
const VueCounterPlugin = createVueCounterPlugin();
createApp(App).use(VueCounterPlugin).mount('#app');If you like to pass options to your plugin you can do it like this
const VueCounterPlugin = createVueCounterPlugin({ theme: 'light' });The magic behind what we did is that using app.provide in the plugin install method is that we can inject the plugin options as a dependency later.
Now let's add the CounterNumber component into the src/App.vue.
// App.vue
<template>
<h2 class="font-bold text-2xl mb-8 text-gray-600">
Vue Counter animation
</h2>
<div
class="card bg-gray-100 rounded-xl p-8 auto shadow-lg mx-auto w-1/3 text-indigo-400 font-bold text-xl"
>
<vue-counter />
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
export default defineComponent({
name: 'App',
});
</script>If you are curious about the utility classes I used here, is the awesome TailwindCSS which I love for doing quick prototypes. You can install it also by following this guide. Just make sure you add those dependencies as devDependencies to your package.json or they will be included in your library bundle.
Let's see how it looks on the browser with npm run serve
Animation and composition
Looks beautiful, but needs more magic. Let's add the easing animation for the counter. To achieve a smooth animation, we will be using a library called anime.js, which is really lightweight and offers and plain simple API.
We could add the logic directly on the CounterNumber component, but since we talked before about Composition API let's use it for this purpose.
Create a useCounter.ts file under src/composables and export a function called useCounter like this:
import { ref } from 'vue';
import anime from 'animejs/lib/anime.es.js';
export function useCounter() {
const count = ref(0);
const counter = {
value: 0,
};
anime({
targets: counter,
duration: 2000, // 2000ms
value: 640,
easing: 'easeOutQuad',
update: () => {
count.value = Math.round(counter.value);
},
});
return {
count,
};
}We import a factory function called 'anime' from 'animejs/lib/anime.es.js' and we pass a target (in this case an obj containing a ref with the value to be animated).
The anime() the function accepts a lot of parameters to customize the behavior of the animation such as duration, delay, easing, and callbacks like an update that triggers every time the animation updates the target object. The interesting thing is that you can pass as property the same property you want to animate, in this case value, in the example above will go from 0 to 640. For more info about the animejs API check the docs
Go back to your CounterNumber.ts component and get the use the count.value inside the span like this:
export const CounterNumber = defineComponent({
name: 'Awesome',
props,
setup(props, ctx) {
const { count } = useCounter();
return () =>
h(
'span',
{
class: 'counter-number',
},
count.value,
);
},
});Now go back to the browser and refresh to see how the counter goes from 0 to 640 in 2 seconds.
Make it customizable
At the moment, all values are hardcoded, but since we are doing a library, these parameters for the animation should be customizable and therefore passed as props to the component and down to the composition function.
First, let's add some props that make sense:
// src/components/Counternumber
const props = {
from: {
type: [Number, String],
default: 0,
},
to: {
type: [Number, String],
required: true,
default: 0,
},
duration: {
type: Number,
default: 1000, // Duration of animation in ms
},
easing: {
type: String,
default: 'easeInOutQuad',
},
delay: {
type: Number,
default: 0, // Delay the animation in ms
},
};
export const CounterNumber = defineComponent({
name: 'Awesome',
props,
setup(props, ctx) {
const { count } = useCounter(props);
...
},
});Make sure you pass the props to the useCounter(props) function;
Go to App.vue and create some variables to pass to the component as props:
<template>
<h2 class="font-bold text-2xl mb-8 text-gray-600">Vue Counter animation</h2>
<div
class="card bg-gray-100 rounded-xl p-8 auto shadow-lg mx-auto w-1/3 text-indigo-400 font-bold text-xl"
>
<vue-counter :from="0" :to="640" :duration="3000" :delay="2000" />
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
export default defineComponent({
name: 'App',
});
</script>Of course, we would need to add more code to make it create a new instance of the anime object every time a prop changes, but the scope of the article is more than enough.
Make it universal
So great, we have our awesome library ready, at the moment, is only usable on a project with Vue 3, how can we achieve an isomorphic installation?
That's where vue-demi comes to the rescue.
npm i vue-demi
# or
yarn add vue-demiAdd vue and @vue/composition-api to your plugin's peer dependencies to specify what versions you support.
// package.json
{
"dependencies": {
"vue-demi": "latest"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-beta.12",
"vue": "^2.6.11 || >=3.0.5"
}
}Now comes the important part 📝, to take notes on it: replace all the imports coming from vue to vue-demi, like so:
import { defineComponent, ref } from 'vue';Will become:
import { defineComponent, ref } from 'vue-demi';The library will redirect to vue@2 + @vue/composition-api or vue@3 based on users' environments.
That's powerful.
Build config
You can build your plugin bundle in so many different ways, webpack, vue-cli (webpack also), parser, rollup, etc. It's up to you, but I really recommend using rollup.js, is a great module bundler, really easy to get into, and is used in most of the major Vue plugins out there, such as Vue Router.
yarn add rollup rollup-plugin-vue rollup-plugin-typescript2 rollup-plugin-terser @rollup/plugin-node-resolve @rollup/plugin-commonjs @rollup/plugin-replace -DAlso, we will need to tweak a little bit the configuration so it externalizes vue-demi instead of vue setting it as a global at the building moment. Because the rollup.config.js is quite large, here is the link to it at the example repo.
In the method createConfig make sure you have vue-demi set in the property globals like this:
// rollup.config.js
...
output.globals = { 'vue-demi': 'VueDemi' };
...
const external = ['vue-demi'];Finally, let's add a script in the package.json and the paths for the package build:
/ package.json
"scripts": {
"build": "rollup -c rollup.config.js",
}
"main": "dist/vue-universal-lib.cjs.js",
"browser": "dist/vue-universal-lib.esm.js",
"unpkg": "dist/vue-universal-lib.global.js",
"jsdelivr": "dist/vue-universal-lib.global.js",
"module": "dist/vue-universal-lib.esm-bundler.js",
"types": "dist/vue-universal-lib.d.ts",Caveats
Of course, is not all roses 🌹 and unicorns 🦄, the use case of vue-demi is rather for vue plugins that don't rely too much on rendering components because Vue 2 and Vue 3 render functions are quite different and the breaking changes between both, i.e. v-model on a component expecting differently named events in Vue 2 vs 3 (ìnput vs update:modelValue).
That's why we used a render function for our component definition and a .ts file instead of a .vue file. For this example library, it will not affect the end result but it's something you need to take into consideration.
One way to possibly adapt breaking changes in your lib component would be the use of extra APIs from Vue Demi to help distinguish users' environments and to do some version-specific logic.
isVue2 isVue3
import { isVue2, isVue3 } from 'vue-demi';
if (isVue2) {
// Vue 2 only
} else {
// Vue 3 only
}That being said I hope this article was illustrative enough on the journey of creating a universal plugin for Vue. Let me hear your thoughts and questions below.
Happy coding! 😎