Vue3 custom element into Vue2 app using external framework - vue.js

I have an application written in Vue2 which is not really ready to be upgraded to Vue3. But, I would like to start writing a component library in Vue3 and import the components back in Vue2 to eventually make the upgrade once it's ready.
Vue 3.2+ introduced defineCustomElement which works nicely but once I use a framework in the Vue3 environment (for example Quasar) that attaches to the Vue instance, it starts throwing errors in the Vue2 app, possibly because the result of defineCustomElement(SomeComponent) tries to use something from the framework that should be attached to the app.
I've thought about extending the HTMLElement and mounting the app on connectedCallback but then I lose the reactivity and have to manually handle all props/emits/.. like so:
class TestQuasarComponentCE extends HTMLElement {
// get init props
const prop1 = this.getAttribute('prop1')
// handle changes
// Mutation observer here probably...
const app = createApp(TestQuasarComponent, { prop1 }).use(Quasar)
app.mount(this)
}
customElements.define('test-quasar-component-ce', TestQuasarComponentCE);
So finally the question is - is it possible to somehow combine the defineCustomElement with a framework that attaches to the app?

So, after a bit of digging, I came up with the following.
First, let's create a component that uses our external library (Quasar in my case)
// SomeComponent.vue (Vue3 project)
<template>
<div class="container">
// This is the quasar component, it should get included in the build automatically if you use Vite/Vue-cli
<q-input
:model-value="message"
filled
rounded
#update:model-value="$emit('update:message', $event)"
/>
</div>
</template>
<script setup lang="ts>
defineProps({
message: { type: String }
})
defineEmits<{
(e: 'update:message', payload: string | number | null): void
}>()
</script>
Then we prepare the component to be built (this is where the magic happens)
// build.ts
import SomeComponent from 'path/to/SomeComponent.vue'
import { reactive } from 'vue'
import { Quasar } from 'quasar' // or any other external lib
const createCustomEvent = (name: string, args: any = []) => {
return new CustomEvent(name, {
bubbles: false,
composed: true,
cancelable: false,
detail: !args.length
? self
: args.length === 1
? args[0]
: args
});
};
class VueCustomComponent extends HTMLElement {
private _def: any;
private _props = reactive<Record<string, any>>({});
private _numberProps: string[];
constructor() {
super()
this._numberProps = [];
this._def = SomeComponent;
}
// Helper function to set the props based on the element's attributes (for primitive values) or properties (for arrays & objects)
private setAttr(attrName: string) {
// #ts-ignore
let val: string | number | null = this[attrName] || this.getAttribute(attrName);
if (val !== undefined && this._numberProps.includes(attrName)) {
val = Number(val);
}
this._props[attrName] = val;
}
// Mutation observer to handle attribute changes, basically two-way binding
private connectObserver() {
return new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === "attributes") {
const attrName = mutation.attributeName as string;
this.setAttr(attrName);
}
});
});
}
// Make emits available at the parent element
private createEventProxies() {
const eventNames = this._def.emits as string[];
if (eventNames) {
eventNames.forEach(evName => {
const handlerName = `on${evName[0].toUpperCase()}${evName.substring(1)}`;
this._props[handlerName] = (...args: any[]) => {
this.dispatchEvent(createCustomEvent(evName, args));
};
});
}
}
// Create the application instance and render the component
private createApp() {
const self = this;
const app = createApp({
render() {
return h(self._def, self._props);
}
})
.use(Quasar);
// USE ANYTHING YOU NEED HERE
app.mount(this);
}
// Handle element being inserted into DOM
connectedCallback() {
const componentProps = Object.entries(SomeComponent.props);
componentProps.forEach(([propName, propDetail]) => {
// #ts-ignore
if (propDetail.type === Number) {
this._numberProps.push(propName);
}
this.setAttr(propName);
});
this.createEventProxies();
this.createApp();
this.connectObserver().observe(this, { attributes: true });
}
}
// Register as custom element
customElements.define('some-component-ce', VueCustomElement);
Now, we need to build it as library (I use Vite, but should work for vue-cli as well)
// vite.config.ts
export default defineConfig({
...your config here...,
build: {
lib: {
entry: 'path/to/build.ts',
name: 'ComponentsLib',
fileName: format => `components-lib.${format}.js`
}
}
})
Now we need to import the built library in a context that has Vue3, in my case index.html works fine.
// index.html (Vue2 project)
<!DOCTYPE html>
<html lang="">
<head>
// Vue3
<script src="https://cdn.jsdelivr.net/npm/vue#3/dist/vue.global.prod.js"></script>
// Quasar styles
<link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons" rel="stylesheet" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/quasar#2.4.3/dist/quasar.prod.css" rel="stylesheet" type="text/css">
// Our built component
<script src="path/to/components-lib.umd.js"></script>
</head>
...rest of your html...
</html>
Now we are ready to use our component within our Vue2 (or any other) codebase same way we are used to with some minor changes, check comments below.
// App.vue (Vue2 project)
<template>
<some-component-ce
:message="message" // For primitive values
:obj.prop="obj" // Notice the .prop there -> for arrays & objects
#update:message="message = $event.detail" // Notice the .detail here
/>
</template>
<script>
export default {
data() {
return {
message: 'Some message here',
obj: { x: 1, y: 2 },
}
}
}
</script>
Now, you can use Vue3 components in Vue2 :)

Related

Vue js 3 render dynamic component template from server

I have a problem with Vue 3, using vue from CDN.
I want to use a template generated by the server, the template is changed but methods and data are not bound.
<script>
// reproduction of the issue in vue3 vite
import { compile, computed, h } from 'vue/dist/vue.esm-bundler'; // vite
// import { compile, computed, h } from 'vue/dist/vue.esm-bundler'; // webpack
export default {
data() {
return {
htmlTemplate: '<span #click="test()">this is a test {{ testVariable }}</span>', // This is a test from what would be loaded from the server
testVariable: 'test variable',
}
},
methods: {
test() {
console.log('test');
}
},
render() {
const textCompRef = computed(() => ({ render: compile(this.htmlTemplate) }));
console.log('textCompRef', textCompRef);
return h(textCompRef.value);
}
}
</script>
When I click on this is a test then vue#3:1807 Uncaught TypeError: test is not a function
Can someone point me in the right direction?
Thanks in advance
I tried setting the template in the create life cycle with this.$options.template = response from the server that worked on 3-rd click and was not changing when new template is loaded.

dynamically highlight block with highlight.js in vue app

I have a VueJS where I have created a component for rendering the contents from a WYSIWYG component (tiptap).
I have the following content being returned from the backend
let x = 0;
enum A {}
function Baa() {}
I'm using highlight.js to highlight this code snippet in the following manner:
import { defineComponent, h, nextTick, onMounted, onUpdated, ref, watch } from 'vue';
// No need to use a third-party component to highlight code
// since the `#tiptap/extension-code-block-lowlight` library has highlight as a dependency
import highlight from 'highlight.js'
export const WYSIWYG = defineComponent({
name: 'WYSIWYG',
props: {
content: { type: String, required: true },
},
setup(props) {
const root = ref<HTMLElement>(null);
const highlightClass = 'hljs';
const hightlightCodes = async () => {
console.log(root.value?.querySelectorAll('pre code')[0]);
setTimeout(() => {
root.value?.querySelectorAll('pre code').forEach((el: HTMLElement) => {
highlight.highlightElement(el as HTMLElement);
});
}, 2000);
}
onMounted(hightlightCodes);
watch(() => props.content, hightlightCodes);
return function render() {
return h('div', {
class: 'WYSIWYG',
ref: root,
innerHTML: props.content
});
};
},
});
Now, when I visit the page by typing the URL in the browser, it highlights the typescript code
Whenever I visit a different page and click on my browser's "Go back" button, it makes the code completely vanishes
What I have tried
I can see that the line root.value?.querySelectorAll('pre code') is returning the correct items and the correct code is present but the code vanishes after the 2 seconds passes - due to setTimeout.
How can I make highlight.js highlight the code parts whenever props.content changes?
Option 1
Use Highlight.js Vue integration (you need to setup the plugin first, check the link):
<script setup>
const props = defineProps({
content: { type: String, required: true },
})
</script>
<template>
<highlightjs :code="content" language="ts" />
</template>
Option 2
Use computed to reactively compute highlighted HTML of props.content
Use sync highlight(code, options) function to get the highlighted HTML
Use HTML as-is via innerHTML prop or v-html directive
<script setup>
import { computed } from 'vue'
import highlight from 'highlight.js'
const props = defineProps({
content: { type: String, required: true },
})
const html = computed(() => {
const { value } = highlight.highlight(props.content, { lang: 'ts' })
return value
})
</script>
<template>
<div v-html="html" />
</template>

vue 3 directives listen vue emit

What i want to achieve is to build a loading vue directives,
function with v-loading directive will render spinner and block event till function promise resolve or reject.
What i had tried so far:
use addEventListener, but it can only listen dom's native event, not vue event
hijack vue $emit function, but get a warning said that don't override vue native function named $, even if this solution work, i think this is a bad solution.
in directives argument, binding.instance[binding.value.name] refer to onEvent function in component, i tried override it but it doesn't work. When onEvent trigger again, it run old onEvent before override.
third party event emitter(eg, mitt). this method works well, but custom-component have to write extra code to emit event.
As example code below,
user of v-loading have to remember to write 2 emit (mitt and vue's emit).
It is not that straight forward, and it has extra dependency.
// mitt solution
// custom-component template
<custom-component v-loading:event="onEvent">
// custom-component script
setup(props, {emit}) {
function emitEvent() {
emit("event");
// bad: have to remember to write this extra line, and it is third party dependency
mittEmitter.emit("event");
}
}
So, any other solution to listen vue's event(not dom's native event) from vue's $emit?
LoadingDirective.ts
import { Directive } from "vue";
const Loading: Directive = {
mounted(el, binding) {
const eventName = binding.arg;
const onEvent = binding.value;
// I want to listen vue's event(eventName) here
// do something extra
onEvent(); // original onEvent() function to run in App.vue
// do something extra
}
};
export default Loading;
App.vue
<template>
<!-- when onEvent triggered, a spinner will be render in custom-component -->
<custom-component v-loading:event="onEvent" />
</template>
CustomComponent.vue
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
setup(props, {emit}) {
function emitEvent() {
// use only vue's emit
emit("event")
}
return {
onEvent
};
}
});
</script>
The Vue 3 documentation recommends using an external library such as mitt or tiny-emitter.
JSFiddle Example
<template>
<div id="app">
<custom-component v-loading="eventHandler" />
</div>
</template>
const emitter = mitt();
const customComponent = { template: '<h1>Example</h1>' };
const app = Vue.createApp({
components: { customComponent },
setup() {
setTimeout(() => {
emitter.emit('loadingEvent', { colour: 'Green' });
}, 1000);
const eventHandler = e => console.log('Handled!', e);
return { eventHandler };
},
});
app.directive('loading', {
mounted(el, binding) {
const func = binding.value;
emitter.on('loadingEvent', data => {
// your logic here...
func(data);
});
},
});
app.mount('#app');

Vue.js infinite loop with prop array

In my App.vue:
I'm using updated() for getting user info after callback and each time I change the route and getting info and
mounted(), if I used F5 or go to my website already connected with SSO
The problem is that in my updated(), I set an array which is passed to a component. It make infinity loop.
How can I avoid it ?
App.vue :
<template>
<Sidebar v-if="isLoggedIn" :displayName="currentUser" v-bind:role="rolesT" />
</template>
<script lang="ts">
public rolesT: ReadonlyArray<String> = ["test"];
public initUser() {
this.auth = Vue.prototype.$auth;
this.auth.getUser().then((user) => {
if (user !== null) {
this.currentUser = user.profile.name!;
this.accessTokenExpired = user.expired;
this.rolesT = []; // if I use only this one, I have infinite loop
this.rolesT = user.profile.roles ; // if I use only this one, I have infinite loop
this.rolesT = this.rolesT; // if I use only this one, I dont have infinite loop, but its useless, it was just for test
console.log(this.rolesT);
}
this.isLoggedIn = (user !== null && !user.expired);
});
}
public mounted() {
console.log("mounted");
this.initUser();
}
public updated() {
console.log("updated");
this.initUser();
}
}
</script>
Sidebar.vue (I do nothing with the array for now, but still loop) :
[...]
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator';
#Component
export default class Sidebar extends Vue {
#Prop({default: 'Unknow name'})
private displayName!: string;
#Prop({default: 'Unknow username'})
private username!: string;
#Prop()
private role;
}
</script>

How can I test a custom input Vue component

In the Vue.js documentation, there is an example of a custom input component. I'm trying to figure out how I can write a unit test for a component like that. Usage of the component would look like this
<currency-input v-model="price"></currency-input>
The full implementation can be found at https://v2.vuejs.org/v2/guide/components.html#Form-Input-Components-using-Custom-Events
The documentation says
So for a component to work with v-model, it should (these can be configured in 2.2.0+):
accept a value prop
emit an input event with the new value
How do I write a unit test that ensures that I've written this component such that it will work with v-model? Ideally, I don't want to specifically test for those two conditions, I want to test the behavior that when the value changes within the component, it also changes in the model.
You can do it:
Using Vue Test Utils, and
Mounting a parent element that uses <currency-input>
Fake an input event to the inner text field of <currency-input> with a value that it transforms (13.467 is transformed by <currency-input> to 13.46)
Verify if, in the parent, the price property (bound to v-model) has changed.
Example code (using Mocha):
import { mount } from '#vue/test-utils'
import CurrencyInput from '#/components/CurrencyInput.vue'
describe('CurrencyInput.vue', () => {
it("changing the element's value, updates the v-model", () => {
var parent = mount({
data: { price: null },
template: '<div> <currency-input v-model="price"></currency-input> </div>',
components: { 'currency-input': CurrencyInput }
})
var currencyInputInnerTextField = parent.find('input');
currencyInputInnerTextField.element.value = 13.467;
currencyInputInnerTextField.trigger('input');
expect(parent.vm.price).toBe(13.46);
});
});
In-browser runnable demo using Jasmine:
var CurrencyInput = Vue.component('currency-input', {
template: '\
<span>\
$\
<input\
ref="input"\
v-bind:value="value"\
v-on:input="updateValue($event.target.value)">\
</span>\
',
props: ['value'],
methods: {
// Instead of updating the value directly, this
// method is used to format and place constraints
// on the input's value
updateValue: function(value) {
var formattedValue = value
// Remove whitespace on either side
.trim()
// Shorten to 2 decimal places
.slice(0, value.indexOf('.') === -1 ? value.length : value.indexOf('.') + 3)
// If the value was not already normalized,
// manually override it to conform
if (formattedValue !== value) {
this.$refs.input.value = formattedValue
}
// Emit the number value through the input event
this.$emit('input', Number(formattedValue))
}
}
});
// specs code ///////////////////////////////////////////////////////////
var mount = vueTestUtils.mount;
describe('CurrencyInput', () => {
it("changing the element's value, updates the v-model", () => {
var parent = mount({
data() { return { price: null } },
template: '<div> <currency-input v-model="price"></currency-input> </div>',
components: { 'currency-input': CurrencyInput }
});
var currencyInputInnerTextField = parent.find('input');
currencyInputInnerTextField.element.value = 13.467;
currencyInputInnerTextField.trigger('input');
expect(parent.vm.price).toBe(13.46);
});
});
// load jasmine htmlReporter
(function() {
var env = jasmine.getEnv()
env.addReporter(new jasmine.HtmlReporter())
env.execute()
}())
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/jasmine/1.3.1/jasmine.css">
<script src="https://cdn.jsdelivr.net/jasmine/1.3.1/jasmine.js"></script>
<script src="https://cdn.jsdelivr.net/jasmine/1.3.1/jasmine-html.js"></script>
<script src="https://npmcdn.com/vue#2.5.15/dist/vue.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-template-compiler#2.5.15/browser.js"></script>
<script src="https://rawgit.com/vuejs/vue-test-utils/2b078c68293a41d68a0a98393f497d0b0031f41a/dist/vue-test-utils.iife.js"></script>
Note: The code above works fine (as you can see), but there can be improvements to tests involving v-model soon. Follow this issue for up-to-date info.
I would also mount a parent element that uses the component. Below a newer example with Jest and Vue Test Utils. Check the Vue documentation for more information.
import { mount } from "#vue/test-utils";
import Input from "Input.vue";
describe('Input.vue', () => {
test('changing the input element value updates the v-model', async () => {
const wrapper = mount({
data() {
return { name: '' };
},
template: '<Input v-model="name" />',
components: { Input },
});
const name = 'Brendan Eich';
await wrapper.find('input').setValue(name);
expect(wrapper.vm.$data.name).toBe(name);
});
test('changing the v-model updates the input element value', async () => {
const wrapper = mount({
data() {
return { name: '' };
},
template: '<Input v-model="name" />',
components: { Input },
});
const name = 'Bjarne Stroustrup';
await wrapper.setData({ name });
const inputElement = wrapper.find('input').element;
expect(inputElement.value).toBe(name);
});
});
Input.vue component:
<template>
<input :value="$attrs.value" #input="$emit('input', $event.target.value)" />
</template>