使用外部框架将 Vue3 自定义元素添加到 Vue2 应用程序中

Vue3 custom element into Vue2 app using external framework

我有一个用 Vue2 编写的应用程序,它还没有准备好升级到 Vue3。但是,我想开始在 Vue3 中编写一个组件库,然后将组件导入回 Vue2,以便在准备就绪后最终进行升级。

Vue 3.2+ 引入了 defineCustomElement,效果很好,但是一旦我在 Vue3 环境中使用框架(例如 Quasar)附加到 Vue 实例,它开始在 Vue2 应用程序中抛出错误,可能是因为defineCustomElement(SomeComponent) 的结果尝试使用应附加到应用程序的框架中的内容。

我考虑过扩展 HTMLElement 并将应用程序安装到 connectedCallback 但后来我失去了反应性,不得不手动处理所有 props/emits/.. 像这样:

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);

所以最后的问题是 - 是否有可能以某种方式将 defineCustomElement 与附加到应用程序的框架结合起来?

所以,经过一番挖掘,我得出了以下结论。

首先,让我们创建一个使用我们的外部库(在我的例子中是 Quasar)的组件

// 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>

然后我们准备要构建的组件(这就是神奇的地方)

// 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);

现在,我们需要将其构建为库(我使用 Vite,但也应该适用于 vue-cli)

// vite.config.ts

export default defineConfig({
  ...your config here...,
  build: {
    lib: {
      entry: 'path/to/build.ts',
      name: 'ComponentsLib',
      fileName: format => `components-lib.${format}.js`
    }
  }
})

现在我们需要在具有 Vue3 的上下文中导入构建的库,在我的例子中 index.html 工作正常。

// 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>

现在我们已经准备好在我们的 Vue2(或任何其他)代码库中使用我们的组件,就像我们习惯的方式一样,并进行一些小的更改,请查看下面的评论。

// 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>

现在,您可以在 Vue2 中使用 Vue3 组件了:)