Vue 3 命令式弹窗使用指南
1. 快速开始
通过 useCommandComponent,你可以像调用函数一样打开一个弹窗,而无需在模板中写 <Dialog /> 标签。
import { useCommandComponent } from './hooks/useCommandComponent'
import MyDialog from './components/MyDialog.vue'
import { ref } from 'vue'
// 1. 创建弹窗构造函数
const showDialog = useCommandComponent(MyDialog)
// 2. 调用函数打开弹窗
const title = ref('提示') // 支持传 ref,组件内自动解包
showDialog({
title,
content: '这是一个命令式弹窗',
onClosed: (result) => {
console.log('弹窗关闭,返回结果:', result)
}
})
2. 两种核心用法
模式 A:Props 驱动(推荐简单场景)
适用于表单提交、确认框等一次性交互。你只需要传入参数并监听关闭回调。
组件定义 (ConfirmDialog.vue):
<template>
<el-dialog :model-value="visible" :title="title" @closed="handleClosed">
<p>{{ content }}</p>
<template #footer>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleConfirm">确定</el-button>
</template>
</el-dialog>
</template>
<script setup>
defineProps(['visible', 'title', 'content'])
const emit = defineEmits(['closed'])
const handleConfirm = () => emit('closed', { action: 'confirm' })
const handleCancel = () => emit('closed', { action: 'cancel' })
const handleClosed = () => emit('closed', { action: 'close' })
</script>
调用方式:
const showConfirm = useCommandComponent(ConfirmDialog)
showConfirm({
title: '删除确认',
content: '确定要删除这条数据吗?',
onClosed: (res) => {
if (res.action === 'confirm') deleteItem()
}
})
模式 B:Expose 驱动(推荐复杂交互)
适用于多步骤向导、需要外部触发更新或获取内部状态的复杂弹窗。
组件定义 (WizardDialog.vue):
<template>
<el-dialog v-model="internalVisible" title="向导">
<div>当前步骤: {{ step }}</div>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue'
const internalVisible = ref(false)
const step = ref(1)
const open = (options) => {
internalVisible.value = true
step.value = options.startStep || 1
}
defineExpose({ open })
</script>
调用方式:
const showWizard = useCommandComponent(WizardDialog)
const dialogInstance = showWizard() // 此时弹窗未显示
dialogInstance.open({ startStep: 2 }) // 手动控制打开并传参
3. 基础配置项
| 属性 | 类型 | 说明 |
|---|---|---|
| visible | Boolean | 默认为 true,控制弹窗显隐 |
| appendTo | String / HTMLElement | 挂载点,默认为 document.body |
| onClosed | Function | 弹窗完全关闭(动画结束)后触发的回调 |
| slots | Object | 插槽渲染函数集合,见第 4 节 |
4. Slots 注入
命令式调用没有模板,无法直接写 <template #footer>。但可以通过 slots 配置项传入渲染函数。
slots 是一个对象:
- 键为插槽名(默认插槽用
default) - 值为渲染函数,可接收组件内部通过
<slot :xxx="...">暴露的作用域参数
一个例子覆盖所有用法
弹窗组件 (MyDialog.vue):
<template>
<el-dialog v-model="visible">
<slot :form="form" :loading="loading" />
<slot name="footer" :on-confirm="handleConfirm" />
</el-dialog>
</template>
<script setup>
import { reactive, ref } from 'vue'
const visible = ref(false)
const form = reactive({ name: '' })
const loading = ref(false)
const handleConfirm = () => {
loading.value = true
// 模拟异步提交
setTimeout(() => {
loading.value = false
visible.value = false
}, 1000)
}
defineExpose({ form, loading })
</script>
调用方式:
import { h } from 'vue'
showDialog({
slots: {
// 默认插槽,接收组件内部暴露的 form 和 loading
default: ({ form, loading }) => h('div', null, [
h('input', {
value: form.name,
onInput: (e) => { form.name = e.target.value },
disabled: loading.value
})
]),
// 具名插槽,接收组件内部暴露的 onConfirm
footer: ({ onConfirm }) => h('button', { onClick: onConfirm }, '确定')
}
})
默认插槽即 default,具名插槽在键中指定名称,作用域参数由组件内部 <slot> 的属性决定。
5. 核心思路概览
坦白说,这份代码是踩了一年坑迭代出来的,每个细节背后都有具体的场景和教训。这一节不讲原理,只帮你建立一个大概的心智模型——知道调用时发生了什么、每个部分在干嘛,就足够了。
一句话总结
useCommandComponent 做的事情:把"创建一个组件实例"这件事包装成"调用一个函数"。
核心流程
showDialog(options)
→ 清理上一个实例(如果有)
→ options 包一层 reactive(ref 能自动解包)
→ 创建 vNode,渲染到一个独立 container,挂到 body
→ 建立 watch:之后修改传入的 ref,弹窗内容跟着变
→ 返回一个 Proxy:能 .closed() 关闭,也能访问组件 expose 的方法
→ 弹窗 @closed 触发 → 自动停止 watch + 销毁 DOM
就这些。每个步骤展开讲都会涉及一堆边界情况,比如:
- 为什么
container要复用而不是每次新建 reactive解包和模板行为的一致性- watch 同步时为什么要过滤"组件声明过的 prop"
- Proxy 为什么要代理
exposed而不是直接返回 vNode
这些问题在源码注释里都写了。但坦率说,真正理解这些细节需要你实际踩过相应的坑。没踩过的时候看注释只觉得"哦,知道了";踩过之后回头看才会"原来当时那个 bug 是因为这个"。
所以这一节不再试图把每个设计决策解释清楚。第 6 节的源码是完整的、带注释的,你可以直接拿去用。用的时候遇到疑问,回到对应函数看注释;还不够的话,评论区见。
6. 完整源码
import { createVNode, getCurrentInstance, reactive, render, watch } from 'vue'
/**
* 获取最终挂载的 DOM 元素
* 支持传入选择器字符串或 HTMLElement,无效时回退到 body
*/
const getAppendToElement = (props) => {
let appendTo = document.body
if (props.appendTo) {
if (typeof props.appendTo === 'string') {
appendTo = document.querySelector(props.appendTo)
} else if (props.appendTo instanceof HTMLElement) {
appendTo = props.appendTo
}
if (!(appendTo instanceof HTMLElement)) appendTo = document.body
}
return appendTo
}
/**
* 创建 vNode,渲染到 container,并将 container 插入到目标元素
*
* 注意 createVNode 的第三个参数就是 slots:
* - 键为插槽名(默认插槽用 'default')
* - 值为渲染函数,参数由组件内部 <slot :xxx> 的作用域属性决定
* 命令式调用天然支持插槽,无需额外适配
*/
const initInstance = ({ Component, props, slots, container, appContext }) => {
const vNode = createVNode(Component, props, slots)
vNode.appContext = appContext
render(vNode, container)
getAppendToElement(props).appendChild(container)
return vNode
}
/**
* 预处理 options:转为 reactive 并设置 visible 默认值
*
* 为什么用 reactive?
* reactive 内部通过 Proxy 拦截属性读取,
* 当存储的值是 ref / shallowRef 时,访问 state.xxx 自动返回 ref.value,
* 与模板中直接使用 ref 的行为一致。
*
* shallowRef 的语义也保持:
* - 整体替换 .value 会触发更新
* - 修改 .value 内部属性不会触发更新(shallowRef 特性)
*/
const prepareState = (options) => {
const state = reactive({ ...options })
if (!Reflect.has(state, 'visible')) {
state.visible = true
}
return state
}
/**
* 绑定 onClosed 回调
* 确保无论用户是否传入 onClosed,
* 关闭动画结束后都会执行内部清理(停止 watch + 销毁 DOM)
*/
const bindOnClosed = (state, closed) => {
if (typeof state.onClosed !== 'function') {
state.onClosed = closed
} else {
const originOnClosed = state.onClosed
state.onClosed = (...args) => {
originOnClosed(...args)
closed()
}
}
}
/**
* 获取组件声明的 props 名称列表
* 用于后续精确同步,避免把 onClosed、appendTo 等额外属性注入组件
*/
const getDeclaredPropKeys = (vNode) => {
const propsOptions = vNode.component?.type.props
return propsOptions ? Object.keys(propsOptions) : []
}
/**
* 建立 state → 组件实例 props 的响应式同步
*
* 首次渲染用的是 {...state} 快照,之后 state 变化靠这里同步。
*
* 为什么不需要 deep: true?
* state 本身就是 reactive,watch 默认深度监听 reactive 对象。
*
* 为什么不设置 flush: 'post'?
* 默认 'pre' 与模板父传子 props 的更新时机一致,
* 不会引入额外的时序问题。
*/
const setupPropsSync = (state, vNode) => {
const propKeys = getDeclaredPropKeys(vNode)
return watch(state, () => {
if (!vNode.component) return
if (propKeys.length === 0) return
const patch = {}
for (const key of propKeys) {
if (key in state) {
patch[key] = state[key]
}
}
Object.assign(vNode.component.props, patch)
})
}
/**
* 创建代理,暴露 closed 方法以及组件通过 defineExpose 暴露的内容
*
* 访问优先级:closed > exposed > vNode 自身属性
* - closed 是自定义的,优先级最高
* - exposed 优先于 vNode 内部属性,避免内部细节泄漏
* - 兜底返回 vNode 自身属性
*/
const createProxy = (vNode, closed) => {
return new Proxy(vNode, {
get(target, prop) {
if (prop === 'closed') return closed
const exposed = vNode.component?.exposed
if (exposed && Reflect.has(exposed, prop)) {
return Reflect.get(exposed, prop)
}
return Reflect.get(target, prop)
},
has(target, prop) {
if (prop === 'closed') return true
const exposed = vNode.component?.exposed
if (exposed && Reflect.has(exposed, prop)) return true
return Reflect.has(target, prop)
}
})
}
/**
* 命令式调用组件 Hook
*
* @param {Object} Component - 要打开的组件(例如弹窗组件)
* @returns {Function} 返回创建组件实例的函数,接收 options 并返回 vNode 的 Proxy
*/
export const useCommandComponent = (Component) => {
// 浅拷贝 appContext,隔离每个 useCommandComponent 的上下文
// 同时传递 provides,使弹窗组件可以通过 inject 拿到调用方的依赖
const appContext = { ...getCurrentInstance()?.appContext }
const currentProvides = getCurrentInstance()?.['provides']
Reflect.set(appContext, 'provides', currentProvides)
// container 在闭包中只创建一次,所有实例复用
// 正因如此,每次新建前必须先清理旧实例,否则会残留
const container = document.createElement('div')
// 记录当前活跃实例的清理函数
let currentClose = null
const baseClosed = () => {
render(null, container)
container.parentNode?.removeChild(container)
}
const CommandComponent = (options = {}) => {
// 步骤①:清理旧实例,避免多实例并存和内存泄漏
if (currentClose) {
currentClose()
currentClose = null
}
// 提取 slots,其余作为 props
const { slots, ...propsOptions } = options
// 步骤②:reactive 包装,ref 自动解包
const state = prepareState(propsOptions)
// 当前实例的清理函数
let stopWatch = null
const closed = () => {
if (stopWatch) {
stopWatch()
stopWatch = null
}
baseClosed()
if (currentClose === closed) {
currentClose = null
}
}
// 步骤③:合并用户回调和内部清理
bindOnClosed(state, closed)
// 步骤④:渲染并挂载(slots 作为 createVNode 第三参数传入)
const vNode = initInstance({
Component,
props: { ...state },
slots,
container,
appContext
})
// 步骤⑤:state 变化 → 同步到组件 props
stopWatch = setupPropsSync(state, vNode)
currentClose = closed
CommandComponent.closed = closed
// 步骤⑥:返回代理对象
return createProxy(vNode, closed)
}
return CommandComponent
}
export default useCommandComponent