Writing cross-compatible components
Writing cross-compatible Vue components is possible, but it comes with a few limitations and challenges. The goal of Vue-Bridge is to support authors in this process as far as possible.
Limitations and Challenges in Cross-Compatibility
Vue 3 has some new features that cannot be replicated in Vue 2, so when writing cross-compatible components, we can't use these. Examples:
- Multiple Root nodes in templates
<teleport/>
- reactive Maps and Sets
Similarly, Vue 2 has some features there were dropped in Vue 3, so we also can't use them. The list is much shorter here and really only consists of template filter syntax (1000
)
Then there a number of small changes in the public APIs that we have to account for. These are not insurmountable problems, but need to be taken care of in order to work in both versions. Examples:
- Lifecycle hooks
destroyed
is now namedunmounted
in Vue 3. <TransitionGroup>
components no longer have a wrapper root element by default in Vue 3.- The
v-on.native
modifier is no longer supported in Vue 3.
Compatibility Listing
You can read a full listing of all important compatibility aspects in our Compatibility Listing, which provides you with a detailed overview as well as per-change instructions on how to deal with them.
We strongly recommend you take the time to read through them to get a feeling for the scope and nature of these differences.
Tooling support for cross-compatibility
If you have read through the Compatibility Listing, you will have noticed that a lot the the points listed there are marked to be supported by either 🔍 Eslint and/or ✅ Plugin. Here's what that means:
@vue-bridge/eslint-config
provides a bunch of rules that will give you linter warnings/errors when using features or APIs that are not cross-compatible. An example would be to warn you that you can't use multiple root elements in a template, which is only supported in Vue 3.@vue-bridge/runtime
is a tiny runtime library that you will include in your project, which will ensure cross-compatibility for a bunch of APIs that work/look a bit different between Vue 2 and 3 - and example would be prop and event names to supportv-model
on a component.
With these two tools added to your project, you will already be able to circumvent a lot of the possible mistakes you could make when trying to write cross-compatible components.
Workflow
While Vue 2 will stay around for quite some time, Vue 3 is clearly the future, so VueBridge assumes that your code will be written in Vue 3 style wherever possible, and @vue-bridge/runtime
will change/polyfill your component definition where necessary (and possible) to ensure Vue 2 compatibility.
The way in which it does that is very convenient for developers: by wrapping the defineComponent()
function that you might already know from Vue 3.
In Vue 3, it originally doesn't really do anything besides signaling Typescript (or your editor/IDE) that the object passed to it is actually a Vue component definition, which enables type checking/autocomplete.
It looks like this:
import { defineComponent } from 'vue'
export default defineComponent({
name: 'MyComponent',
data: () => ({
msg: 'Hello World!'
}),
beforeUnmount() {
console.log('unmounted')
}
})
Now, the above code would have a bug in Vue 2 because there is no beforeUnmount
hook - there's beforeDestroy
. We can fix that by importing defineComponent
from @vue-bridge/runtime
instead:
- import { defineComponent } from 'vue'
+ import { defineComponent } from '@vue-bridge/runtime'
export default defineComponent({
name: 'MyComponent',
data: () => ({
msg: 'Hello World!'
}),
beforeUnmount() {
console.log('unmounted')
}
})
And just like that, this code can now run in Vue 2 and Vue 3 properly, because our version of defineComponent()
does some additional work on the component definition and adds compatibility features where necessary.
And if you are using @vue-bridge/eslint-config
(because of course you do), eslint will even warn you if you import it from vue
instead @vue-bridge/runtime
.
You can learn more about the ways in which defineComponent()
supports cross-compatibility by reading the package docs for @vue-bridge/runtime
.
Dealing with Pitfalls
There are some compatibility differences on the Compatibility Listing that can not be supported by tooling as of now (marked with 👩🏽💻 Discipline or ⚠️ Pitfall). You will have to ensure cross-compatibility yourself by properly testing your code for both versions.
Some of them are kind of edge cases or very simple and obvious fixes. But there are some rather important ones which we will cover now.
INFO
We have a chapter about Testing Cross-Compatibility which explains in detail how you can run your unit tests with both Vue 2 and Vue 3. This is important to make sure that you properly dealt with these compatibility differences for both versions.
Reactivity Caveats
Vue 2's Reactivity System has a couple of caveats where reactive change detection doesn't work (see Vue 2 docs).
To work around them, you are probably used to using this.$set
and this.$delete
. Since Vue 3 doesn't provide these methods, defineComponent
will add them to your component with a mixin. But you still have to make sure to properly use them.
In short:
- Always use
$set()
to add new properties to a reactive object. - Always use
$delete()
to remove properties from a reactive object. - Always use
$set()
to add or replace an item in a reactive array. - Always use
$delete()
to remove an item from a reactive array. - Don't use Maps and Sets (in reactive code, i.e.
data()
), as Vue 2 can't make these reactive
If you are used to work in Vue 3, where these caveats don't exist, using these helpers is very easy to miss from time to time. So it's very important to run your unit tests against both Vue 2 and Vue 3 to catch such mistakes before you release a version of your package.
$attrs
(and $listeners
)
The behavior of this.$attrs
differs between Vue 2 and 3 in multiple ways:
- In Vue 3, it contains all attributes, including
class
&style
, while those are excluded in Vue 2 because they will always be applied to the component's root element - In Vue 3,
$attrs
also contains any events that were declared on a component, but not defined in that component'semits:
option
Also, Vue 2's $listeners
simply doesn't exist anymore, and the same goes for the v-on.native
modifier.
At the same time, Vue 3 will now apply any events passed to a component to the component's root element unless they are explicitly declared as events in that component's emits:
option.
All of this combined poses a challenge for cross-compatibility when dealing with components that want to make use of these features - (most commonly when the inheritAttrs: false
option is set to pass these values on to a child element or component).
Custom Directives
Since Custom directives' lifecycle hooks were renamed in Vue 3, we also need to give them a bit of a treatment for cross-compatibility. We do this with defineDirective()
provided by @vue-bridge/runtime
.
// my-directive.js
import { defineDirective } from '@vue-bridge/runtime'
export const myDirective = defineDirective({
// Vue 2 equivalent: bind()
beforeMount(el, binding) {
el.addEventListener('binding.arg', binding.value)
}
})
Similar to defineComponent()
, this wrapper function will take care that the names of the hooks are changed to their proper Vue 2 equivalents when used in a Vue 2 context.
Then you can use them like any other directive:
import { myDirective } from './myDirective.js'
// Vue 3
app.directive('my-directive', myDirective)
// Vue 2
Vue.directive('my-directive', myDirective)
// locally in a component:
export default {
directives: {
myDirective,
}
}
Learn more
Be sure to read the page in the compatiblity Listing about custom directives as there are some other things to keep an eye out for, such as a few lifecycle hooks that are only available in Vue 2 or Vue 3, and therefore should not be used.
Composition API
- Use
@vue/compostion-api
throughvue-demi
- Both are peer dependencies of
@vue-bridge/runtime
already