🛠Bridging Vue 2 and Svelte

Summary
As developers, we all have our favorite tools and frameworks. But sometimes we find ourselves in situations where we need to use multiple technologies together, either because of legacy code, third-party dependencies, or simply because we want to try something new. This is exactly what happened to me when I tried to improve the stack of the WorkAdventure dashboard built with Laravel Spark (classic), a popular PHP framework for SAAS that uses Vue 2 under the hood.
While Vue 2 was a powerful and flexible framework, it has since be replaced by more advanced frameworks. One of its drawbacks is the lack of type safety, which can lead to bugs and errors that are hard to catch. As a big fan of typed code, I wanted to add Typescript to my project, but migrating to Vue 3 would have required a major overhaul of the existing codebase. Indeed, I would have had to migrate all the code base at once.
Instead, I decided to use Svelte, a lightweight and fast front-end framework that I had already used inside the WorkAdventure platform. Simply replacing Vue 2 with Svelte was not an option either, as it would have broken the existing code and caused a lot of headaches. I came up with a solution that allowed me to use Svelte components for new features, while keeping the existing Vue 2 components for legacy code. The solution was to build a bridge between Vue 2 and Svelte, which would allow me to mix and match components from both frameworks in the same app. This bridge is a temporary solution allowing us to take some time to migrate.
In this blog post, I will explain how I built the bridge between Vue 2 and Svelte, and how it allowed me to add new features to my Laravel Spark dashboard while keeping the existing codebase intact. I will also discuss the benefits and limitations of using the bridge, and show some code examples of how it works in practice. If you're facing a similar challenge of mixing different front-end technologies, or if you're simply curious about how Vue and Svelte can play together, this article is for you.
Vue and Svelte are component based frameworks
Surprisingly, building a bridge between Vue 2 and Svelte was easier than I initially expected. The reason for this is that both Vue and Svelte are component-based frameworks, which means that they operate on a similar model of inputs and outputs.
In component-based frameworks, a component takes props as an input and emits events as an output. Props are essentially variables that can be passed down from a parent component to a child component, while events are triggered by the child component and can be listened to by the parent component.
The key to building a bridge between Vue and Svelte, therefore, was to find a way to pass props from Vue to Svelte and events from Svelte to Vue. Once we had a way to do this, we could seamlessly integrate Svelte components into our Vue codebase.
The "toVue" function
To build a bridge between Vue 2 and Svelte, I wrote a function called `toVue` that accepts a Svelte component as a parameter and converts it into a Vue component. This allowed me to use Svelte components within my existing Vue 2 codebase, and gradually transition to using more Svelte components over time.
Typical usage looks like this:
import { Carousel, Slide } from "vue-carousel";
import toVue from "./SvelteAdapter";
import RegionList from "./RegionList.svelte";
export default {
name: "SomeVueComponent",
// Look here: RegionList is actually a Svelte component, used in Vue.
components: { Carousel, Slide, RegionList: toVue(RegionList) },
//...
To build a Svelte component, we need to call its constructor. The constructor takes a "target" element and a number of "props".
Hopefully, in the "mounted" function of our Vue component, we can access the target HTML element in "this.$el" and the Vue props in "this.$attrs".
Hence, the beginning of our bridge looks like this:
export default function toVue<Props>(SvelteComponent: any, containerTag = "div"): VueConstructor<Props & Vue> {
return Vue.extend<Vue, {}, {}, Props>({
mounted() {
this.props = this.$attrs;
const instance = (this.instance = new SvelteComponent({
target: this.$el,
props: this.$attrs,
}));
// ...
A Svelte component has 3 methods:
-
$set (to set a prop on the component). We need to listen on any property change on our Vue component and pass the change to the Svelte component. This is doable with `$listeners` in Vue.
-
$on (to register a callback called when the component dispatches an event). Luckily, Vue has a similar event system between components.
-
$destroy (to remove the component from the DOM): we call this method when Vue decides to remove the component. We catch this event in Vue with `beforeDestroy`.
Our solution
The final code of the bridge fits in 100 lines of code. Here they are, with an explanation:
import Vue from "vue";
import type { VueConstructor } from "vue";
export default function toVue<Props>(SvelteComponent: any, containerTag = "div"): VueConstructor<Props & Vue> {
return Vue.extend<Vue, {}, {}, Props>({
mounted() {
this.props = this.$attrs;
const instance = (this.instance = new SvelteComponent({
target: this.$el,
props: this.$attrs,
}));
const watchers: { name: string; callback: Function }[] = [];
for (const key in this.$listeners) {
instance.$on(key, this.$listeners[key]);
const watchRe = /watch:([^]+)/;
const watchMatch = key.match(watchRe);
if (watchMatch && typeof this.$listeners[key] === "function") {
watchers.push({
name: `${watchMatch[1][0].toLowerCase()}${watchMatch[1].slice(1)}`,
callback: this.$listeners[key],
});
}
}
if (watchers.length) {
const tempInstance = instance;
const update = instance.$$.update;
instance.$$.update = function () {
watchers.forEach((watcher) => {
const index = tempInstance.$$.props[watcher.name];
watcher.callback(tempInstance.$$.ctx[index]);
});
update.apply(null, arguments);
};
}
patchSvelteInstanceEvents(this, instance);
observePropsDiff(this, (diff) => {
instance.$set(diff);
});
},
beforeDestroy() {
const { instance } = this;
instance.$destroy();
},
render(elementCreator) {
return elementCreator(containerTag);
},
});
}
function observePropsDiff(vm: Vue, cb: (diff: Record<string, any>) => void): void {
const props = vm.$attrs || {};
const propKeys = Object.keys(props);
let prevProps = copy(props);
if (propKeys.length === 0) return;
vm.$watch(
() => {
const diff: Record<string, any> = {};
propKeys.forEach((key) => {
const value = vm.$attrs[key];
if (prevProps[key] !== value || (value !== null && typeof value === "object")) {
diff[key] = value;
}
});
if (Object.keys(diff).length > 0) {
cb(diff);
prevProps = copy(vm.$attrs || {});
}
},
() => {}
);
}
function patchSvelteInstanceEvents(vm: Vue, svelte: any): void {
const originalFire = svelte.fire;
svelte.fire = (eventName: string, data: any): void => {
vm.$emit(eventName, data);
return originalFire.call(svelte, eventName, data);
};
}
function copy<T extends object>(value: T): T {
return Object.keys(value).reduce<any>((acc, key) => {
acc[key] = (value as any)[key];
return acc;
}, {});
}
The function creates a new Vue component using "Vue.extend" and then performs a number of operations on the Svelte component in order to make it work within the Vue ecosystem.
First, the function sets the props of the Vue component to the attributes of the root element, which is where the Svelte component will be mounted. It then creates a new instance of the Svelte component and sets the "target" and "props" options appropriately.
The function then registers event listeners on the Svelte instance, using the `$on` method, and watches for changes to these events using $listeners in Vue. It also observes property differences in the Vue component and updates the Svelte instance using the $set method when necessary.
Finally, the function patches the Svelte instance events to emit Vue events and registers a beforeDestroy hook to destroy the Svelte instance when the Vue component is destroyed.
Other existing solutions
A number of packages exist out there that are doing this bridge. We encountered a number of issues when testing those.
Either the props were not correctly dispatched, or the event were not flowing back, or only one instance of the bridge was allowed.
Given the fact that the bridge is relatively easy to write, we went with embedding it directly in our code, for as long as we will need it.
The code present in this article is heavily inspired by the vue-svelte-adapter.
Let's also mention a Vite plugin that writes the bridge on the fly: vite-plugin-svelte-bridge.
Conclusion
In conclusion, the bridge we built between Vue 2 and Svelte is a great way to progressively migrate from Vue to Svelte. By allowing us to use Svelte components in a Vue 2 application, we can take advantage of the benefits of Svelte, such as a better Typescript integration, improved performance and ease of use. The bridge enables a step-by-step migration approach, as we can gradually replace Vue components with Svelte components, ultimately leading to a fully migrated Svelte application. The bridge may no longer be necessary at that point, but it can certainly make the transition process smoother. Overall, we are really happy of this progressive approach and we hope you'll find it interesting too!
By the way, if you are interested in virtual universes (for your work, for organizing an event, or just for your friends), don't hesitate to take a look at our great product: WorkAdventure! It is an open, decentralized, extensible platform to build and manage virtual universes, with a lovely retro 2D design.