Vue 3 命令式弹窗组件

120 阅读5分钟

juejin.cn/post/725306… 整篇文章其实就是在这篇文章的基础上做了一些改进,但是我使用的很舒服,想着分享出来。

Vue 3 命令式弹窗组件完整指南

核心实现原理

import { createVNode, getCurrentInstance, render } from 'vue'/**
 * 获取弹窗挂载的DOM元素
 * 支持三种方式:1.字符串选择器 2.HTMLElement对象 3.默认body
 */
const getAppendToElement = (props) => {
  let appendTo = document.body
​
  if (props.appendTo) {
    if (typeof props.appendTo === 'string') {
      // 字符串选择器,如 "#app" 或 ".container"
      appendTo = document.querySelector(props.appendTo)
    } else if (props.appendTo instanceof HTMLElement) {
      // 直接传入DOM元素
      appendTo = props.appendTo
    }
​
    // 如果获取失败,回退到body
    if (!(appendTo instanceof HTMLElement)) {
      appendTo = document.body
    }
  }
  return appendTo
}
​
/**
 * 初始化组件实例
 * 核心:创建虚拟节点并渲染到DOM
 */
const initInstance = (Component, props, container, appContext = null) => {
  // 1. 创建虚拟节点
  const vNode = createVNode(Component, props)
​
  // 2. 继承当前组件的上下文(解决provide/inject问题)
  vNode.appContext = appContext
​
  // 3. 渲染到临时容器
  render(vNode, container)
​
  // 4. 将容器挂载到目标位置
  getAppendToElement(props).appendChild(container)
​
  return vNode
}
​
/**
 * 核心Hook:将声明式组件转换为命令式组件
 */
export const useCommandComponent = (Component) => {
  // 修复点1:创建独立的appContext,避免多实例间provides污染
  const appContext = Object.create(getCurrentInstance()?.appContext)
​
  // 关键:继承当前组件的provides链,用于依赖注入
  if (appContext) {
    const currentProvides = getCurrentInstance()?.provides
    // 保持原型链完整
    Reflect.set(appContext, 'provides', currentProvides)
  }
​
  // 创建临时容器(内存中,尚未挂载到DOM)
  const container = document.createElement('div')
​
  /**
   * 关闭弹窗并清理资源
   * 这个closed函数会暴露给外部调用
   */
  const closed = () => {
    // 1. 卸载Vue组件
    render(null, container)
    // 2. 从DOM中移除容器
    container.parentNode?.removeChild(container)
  }
​
  /**
   * 命令式组件调用函数
   * 这是实际被调用的函数,返回一个代理对象
   */
  const CommandComponent = (options = {}) => {
    // 自动设置visible为true,让弹窗默认显示
    if (!Reflect.has(options, 'visible')) {
      options.visible = true
    }
​
    // 修复点2:确保onClosed回调正确处理,无论用户是否提供自定义回调
    if (typeof options.onClosed !== 'function') {
      // 如果没有提供onClosed,使用默认关闭函数
      options.onClosed = closed
    } else {
      // 如果提供了onClosed,包裹一层确保能清理DOM
      const originOnClosed = options.onClosed
      options.onClosed = (...args) => {
        // 先执行用户回调,可以传递参数
        originOnClosed(...args)
        // 再执行清理
        closed()
      }
    }
​
    // 创建组件实例
    const vNode = initInstance(Component, options, container, appContext)
​
    /**
     * 返回一个代理对象,提供两种访问方式:
     * 1. 通过closed属性直接关闭弹窗
     * 2. 访问组件通过defineExpose暴露的方法
     */
    return new Proxy(vNode, {
      // 拦截属性访问
      get(target, prop) {
        // 1. 支持直接调用closed()关闭弹窗
        if (prop === 'closed') {
          return closed
        }
​
        // 2. 支持访问组件expose的方法
        const exposed = target.component?.exposed
        if (exposed && Reflect.has(exposed, prop)) {
          return Reflect.get(exposed, prop)
        }
​
        // 3. 返回原始vNode的属性
        return Reflect.get(target, prop)
      },
​
      // 拦截in操作符
      has(target, prop) {
        if (prop === 'closed') {
          return true
        }
​
        const exposed = target.component?.exposed
        if (exposed && Reflect.has(exposed, prop)) {
          return true
        }
​
        return Reflect.has(target, prop)
      }
    })
  }
​
  // 为函数对象本身也添加closed方法
  CommandComponent.closed = closed
​
  return CommandComponent
}
​
export default useCommandComponent

核心修复点详解

修复点1:AppContext 隔离问题

背景

在 Vue 3 中,appContext包含应用的全局配置、组件、指令、混入等信息。当多个弹窗实例共享同一个 appContext时,它们的 provides对象会相互干扰,导致依赖注入出现问题。

问题代码
// ❌ 直接共享appContext,会导致多个弹窗实例间的provides相互污染
const appContext = getCurrentInstance()?.appContext
解决方案
// ✅ 每个弹窗保持独立的appContext,不是使用当前实例的appContext
const appContext = {...getCurrentInstance()?.appContext}
​
if (appContext) {
  const currentProvides = getCurrentInstance()?.provides
  // 通过Reflect.set设置独立的provides,同时保持原型链
  Reflect.set(appContext, 'provides', currentProvides)
}
解决原理
  1. 独立上下文:每个弹窗实例拥有独立的 appContext对象
  2. 原型继承:通过 Object.create()创建的上下文仍然能访问父级的原型链
  3. provides 隔离:每个实例有自己的 provides对象,避免多个弹窗间的依赖注入冲突
  4. 功能完整:保持 provide/inject功能正常工作

修复点2:onClosed 回调处理

问题背景

在 Element Plus 的 ElDialog 组件中,关闭事件分为两个阶段:

  • @close:关闭操作开始时触发
  • @closed:关闭动画完全结束后触发

当使用命令式弹窗时,我们需要在 动画完全结束后 再执行清理操作,否则会出现弹窗突然消失的问题。

Vue 隐式属性透传机制

Vue 3 有一个特性:父组件传递给子组件的属性,如果子组件没有在 defineProps中明确声明,这些属性会自动添加到子组件的根元素上。

问题场景

<!-- 弹窗组件 -->
<template>
  <!-- 假设组件没有声明 onClose 作为 props -->
  <el-dialog v-model="visible">
    <!-- onClose 会被透传到 el-dialog 元素 -->
  </el-dialog>
</template><script setup>
// 注意:这里没有声明 onClose
// const props = defineProps(['onClose'])
</script>

外部调用时:

options.onClose = () => {
  // 1. 卸载Vue组件
  render(null, container)
  // 2. 从DOM中移除容器
  container.parentNode?.removeChild(container)
}

onClose会被透传给 <el-dialog>元素,这会:

  1. 立即触发移除 dom
  2. 跳过关闭动画
  3. el-dialog 的 closed 事件因为没有关闭动画永远不会触发
解决方案

使用 onClosed 而不是 onClose,确保在动画完全结束后执行清理:

if (typeof options.onClosed !== 'function') {
  // 用户没有提供 onClosed 时,使用默认清理函数
  options.onClosed = closed
} else {
  // 用户提供了 onClosed 时,先执行用户回调,再执行清理
  const originOnClosed = options.onClosed
  options.onClosed = (...args) => {
    originOnClosed(...args)  // 执行用户自定义逻辑
    closed()                 // 执行 DOM 清理
  }
}
为什么使用 onClosed?
  1. 时机正确@closed是 ElDialog 动画完成后的事件
  2. 避免冲突:不会与 ElDialog 内部的 @close事件冲突
  3. 确保清理:无论用户是否提供回调,都能保证资源清理
  4. 完整动画:用户能看到完整的关闭过渡效果

使用示例

方式一:通过 Props 传递参数

弹窗组件
<!-- BaseDialog.vue -->
<template>
  <el-dialog
    :model-value="visible"
    :title="title"
    width="500px"
    :before-close="handleBeforeClose"
  >
    <div>{{ content }}</div>
    <template #footer>
      <span class="dialog-footer">
        <el-button @click="handleCancel">取消</el-button>
        <el-button type="primary" @click="handleConfirm">确定</el-button>
      </span>
    </template>
  </el-dialog>
</template><script setup>
import { defineEmits, defineProps } from 'vue'const props = defineProps({
  visible: { type: Boolean, default: false },
  title: { type: String, default: '提示' },
  content: { type: String, default: '' },
  payload: { type: Object, default: () => ({}) }
})
​
const emit = defineEmits(['closed'])
​
const handleBeforeClose = (done) => {
  emit('closed', { type: 'cancel', payload: props.payload })
  done()
}
​
const handleConfirm = () => {
  emit('closed', { type: 'confirm', payload: props.payload })
}
​
const handleCancel = () => {
  emit('closed', { type: 'cancel', payload: props.payload })
}
</script>
使用方式
import { useCommandComponent } from './composables/useCommandComponent.js'
import BaseDialog from './components/BaseDialog.vue'const DialogConstructor = useCommandComponent(BaseDialog)
​
const openDialog = () => {
  DialogConstructor({
    title: '标题',
    content: '内容',
    payload: { userId: 123 },
    onClosed: (result) => {
      console.log('弹窗关闭:', result)
    }
  })
}

方式二:通过 Expose 方法控制

弹窗组件
<!-- ExposeDialog.vue -->
<template>
  <el-dialog
    v-model="internalVisible"
    :title="params.title"
    width="500px"
  >
    <div>{{ params.content }}</div>
    <template #footer>
      <el-button @click="internalVisible = false">关闭</el-button>
    </template>
  </el-dialog>
</template><script setup>
import { reactive, ref } from 'vue'const internalVisible = ref(false)
const params = reactive({})
​
const open = (options = {}) => {
  internalVisible.value = true
  Object.assign(params, options)
}
​
const customMethod = () => {
  return `当前弹窗状态: ${internalVisible.value}`
}
​
defineExpose({
  open,
  customMethod,
})
</script>
使用方式
import { useCommandComponent } from './composables/useCommandComponent.js'
import ExposeDialog from './components/ExposeDialog.vue'const DialogConstructor = useCommandComponent(ExposeDialog)
​
const openDialog = () => {
  const dialogInstance = DialogConstructor()
  dialogInstance.open({
    title: '标题',
    content: '内容'
  })
  
  // 调用暴露的方法
  const result = dialogInstance.customMethod()
  console.log(result)
}