Vue3 custom element into Vue2 app using external framework

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?


Solution 1:

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/[email protected]/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 :)