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)
}
解决原理
- 独立上下文:每个弹窗实例拥有独立的
appContext对象 - 原型继承:通过
Object.create()创建的上下文仍然能访问父级的原型链 - provides 隔离:每个实例有自己的
provides对象,避免多个弹窗间的依赖注入冲突 - 功能完整:保持
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>元素,这会:
- 立即触发移除 dom
- 跳过关闭动画
- 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?
- 时机正确:
@closed是 ElDialog 动画完成后的事件 - 避免冲突:不会与 ElDialog 内部的
@close事件冲突 - 确保清理:无论用户是否提供回调,都能保证资源清理
- 完整动画:用户能看到完整的关闭过渡效果
使用示例
方式一:通过 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)
}