太一生水

发布于
编程式地(Programmatically)在 Vue 中插入组件

太一生水,水反辅太一,是以成天; 天反辅太一,是以成地。 天地复相辅,是以成神明。

使用 Vue.extend 拓展组件

在 Vue 2 时代可以用 Vue.extend 来拓展一个组件,实现使用纯 js 来完成一些功能而不需要关心 Dom ,比如实现一个简单的 Toast。

到 Vue 3 时代,官网上明确表示,extends 是为选项式 API 设计的,不建议用于组合式 API。

这里是很多年前的一个解决方案,mm-toast ,通过 Vue.extend 来实现的 Toast。首先创建一个 mm-toast.vue 组件,然后通过 Vue.extend 来拓展一个组件,然后通过 Vue.prototype 来挂载到全局上。可以直接用 $msg 来在页面上弹出消息。

import TempToast from './mm-toast.vue'

let instance, showToast = false, time; // 存储toast显示状态
const mmToast = {
    install(Vue, options = {}) {
        let opt = TempToast.data();//获取组件中的默认配置
        Object.assign(opt, options);//合并配置
        Vue.prototype.$msg = (message, position) => {
            if (showToast) {
                clearTimeout(time);
                instance.vm.visible = showToast = false;
                document.body.removeChild(instance.vm.$el)
                //return;// 如果toast还在,则不再执行
            }
            if (message) {
                opt.message = message;//如果有传message,则使用所传的message
            }
            if (position) {
                opt.position = position;//如果有传type,则使用所传的type
            }
            let TempToastConstructor = Vue.extend(TempToast);
            instance = new TempToastConstructor({
                data: opt
            });
            instance.vm = instance.$mount();
            document.body.appendChild(instance.vm.$el);
            instance.vm.visible = showToast = true;
            
            time = setTimeout(function () {
                instance.vm.visible = showToast = false;
                document.body.removeChild(instance.vm.$el)
            }, opt.duration)
        }
    }
};

export default mmToast

创建 VNode 虚拟节点

搜到一篇 Vue 插入组件到 Dom 的最佳实践 ,提供了两种思路:

  • 使用 h() / createVNode() 函数,将引入的组件转为虚拟节点,然后用 render() 挂在到 Dom 上
  • 使用 createApp() 创建一个应用实例,使用 app.mount() 挂载到 Dom 节点上

自己结合实际场景应用了一下,假设我需要创建很多迷你对话框,每一个都有关闭按钮,下面这样基本能实现需求。

首先定义一个容器,用于挂载组件,然后创建一个ref 来保存组件名称列表,然后通过 h() 函数创建一个虚拟节点,通过 render() 函数挂载到 mdContainer 上。每一个 MiniDialog 都有一个 onClose 事件,用于移除自身,然后重新渲染。

import MiniDialog from './components/MiniDialog.vue';

const mdContainer = ref()
const CompNameList = ref < string[] > ([])
const openMiniDialog = (val: string) => {
    CompNameList.value.push(val)
    const newList = h('div',
        CompNameList.value.map(val => h(MiniDialog, {
            data: { q: 23, val },
            key: new Date().getTime(),
            onClose: () => {
                CompNameList.value.splice(CompNameList.value.indexOf(val), 1)
                render(newList, mdContainer.value!)
            }
        }))
    )
    render(newList, mdContainer.value!)
}

// 调用方法
openMiniDialog(val)

由于 Vnodes 必须唯一 这里需要一个函数返回新的组件 VNode 列表,而且外面容器 mdContainer 也需要一个 div 来包裹,造成不必要的嵌套;然后关闭的时候需要再次渲染,会造成方法的重复引用,看起来不够优雅。

更加 Vue 的方式

目前需要的是动态渲染组件列表,搜索了一下,看到 这一个 答案,本质上还是用 <Component /> 渲染动态组件,然后直接循环一个列表,这样就可以实现动态插入多个弹窗了。

<Component :is="item.comp" :ckey="item.key" :key="item.key" :data="item.data" @close="closeMiniDialog" v-for="item in MdList"></Component>
const MdList = ref<{
  comp:Component
  key: string,
  data: any
}[]>([])
const openMiniDialog = (data: any) => {
  MdList.value.push({
    comp: markRaw(MiniDialog),
    key: new Date().getTime() + '',
    data: data,
  })
}
const closeMiniDialog = (key: string) => {
  const index = MdList.value.findIndex(x => x.key === key)
  MdList.value.splice(index, 1)
}

这里最开始没有加 :key ,会造成 关闭 A 弹窗,B 弹窗跑到了 A 弹窗的位置。排除了半天,最后通过 :key 来解决。