Vue 3 命令式弹窗使用指南
1. 快速开始
通过 useCommandComponent,你可以像调用函数一样打开一个弹窗,而无需在模板中写 <Dialog /> 标签。
import { useCommandComponent } from './hooks/useCommandComponent'
import MyDialog from './components/MyDialog.vue'
// 1. 创建弹窗构造函数
const showDialog = useCommandComponent(MyDialog)
// 2. 调用函数打开弹窗
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 WizardDialog = useCommandComponent(WizardDialog)
const dialogInstance = WizardDialog() // 此时弹窗未显示
dialogInstance.open({ startStep: 2 }) // 手动控制打开并传参
3. 响应式传参与 ref 自动解包
新版 useCommandComponent 使用 reactive() 包装传入的 options,这意味着:
- ref 会自动解包,行为与模板中使用一致
- 传入后可保持响应式,修改外部变量能实时同步到弹窗组件
import { ref, shallowRef } from 'vue'
const title = ref('初始标题')
const data = shallowRef({ name: '张三' })
const showModal = useCommandComponent(TestModal)
showModal({ title, data })
// ✅ ref 和 shallowRef 都会被 reactive 自动解包
// 组件内 props.title 直接拿到 '初始标题',而非 ref 对象
// ✅ 修改 ref 能触发组件更新
title.value = '新标题'
// ✅ 整体替换 shallowRef 的 .value 也能触发更新
data.value = { name: '李四' }
// ❌ 修改 shallowRef 内部属性不会触发更新(shallowRef 的特性)
data.value.name = '王五'
原理:
reactive()会自动解包内部的ref和shallowRef,这与 Vue 模板中的行为完全一致。
4. 常用配置项
| 属性 | 类型 | 说明 |
|---|---|---|
| visible | Boolean | 默认为 true,控制弹窗显隐 |
| appendTo | String/HTMLElement | 挂载点,默认为 body |
| onClosed | Function | 弹窗完全关闭(动画结束)后的回调 |
5. 核心源码实现
你可以直接将以下代码保存为 useCommandComponent.js。它封装了 Vue 3 的底层渲染逻辑,支持自动挂载、上下文传递、ref 自动解包以及实例方法暴露。
import {createVNode, getCurrentInstance, render, reactive, watch} from "vue"
/**
* 获取最终挂载的 DOM 元素
* 支持 string 选择器和 HTMLElement 两种形式,兜底 document.body
* @param {Object} props - 组件 props(可能包含 appendTo)
* @returns {HTMLElement}
*/
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
}
// 兜底:如果用户传了无效值,回退到 body
if (!(appendTo instanceof HTMLElement)) appendTo = document.body
}
return appendTo
}
/**
* 创建 VNode,渲染到容器,并将容器插入目标 DOM
* 这是命令式组件的核心挂载流程:createVNode → render → appendChild
* @param {Object} Component - 要渲染的组件
* @param {Object} props - 组件 props
* @param {HTMLElement} container - 挂载容器(一个临时 div)
* @param {Object} appContext - 应用上下文(包含 provides,确保 inject 可用)
* @returns {import('vue').VNode}
*/
const initInstance = (Component, props, container, appContext) => {
const vNode = createVNode(Component, props)
// 手动设置 appContext,让命令式组件能 inject 到 App 层 provide 的数据
vNode.appContext = appContext
render(vNode, container)
getAppendToElement(props).appendChild(container)
return vNode
}
/**
* 预处理 options:转为 reactive 并设置 visible 默认值
* reactive() 会自动解包内部的 ref / shallowRef,行为与模板一致
* 例如传入 { title: ref('hello') },state.title 会是 'hello' 而非 ref 对象
* @param {Object} options - 原始配置(可包含 ref / shallowRef)
* @returns {Object} 响应式 state(ref 已被自动解包)
*/
const prepareState = (options) => {
const state = reactive({...options})
// 默认显示弹窗,除非用户显式传入 visible
if (!Reflect.has(state, 'visible')) {
state.visible = true
}
return state
}
/**
* 绑定 onClosed 回调,确保弹窗关闭动画结束后自动执行 DOM 清理
* - 用户没传 onClosed → 直接用 closed 清理函数
* - 用户传了 onClosed → 包装一层,先执行用户回调,再执行清理
* @param {Object} state - 响应式 state
* @param {Function} closed - 关闭并清理的函数
*/
const bindOnClosed = (state, closed) => {
if (typeof state.onClosed !== 'function') {
state.onClosed = closed
} else {
const originOnClosed = state.onClosed
state.onClosed = (...args) => {
originOnClosed(...args)
closed()
}
}
}
/**
* 获取组件声明的 props 名称列表
* 从 VNode 的组件定义中读取 props 选项,用于后续精确同步
* @param {import('vue').VNode} vNode
* @returns {string[]}
*/
const getDeclaredPropKeys = (vNode) => {
const propsOptions = vNode.component?.type.props
return propsOptions ? Object.keys(propsOptions) : []
}
/**
* 建立 state → 组件 props 的响应式同步
* 首次渲染通过 createVNode 的 props 参数完成,此函数负责后续的变更同步
* 监听 reactive state 的变化,只更新组件声明过的 props key
* @param {Object} state - 响应式 state
* @param {import('vue').VNode} vNode
* @returns {Function} 停止监听的函数(用于 closed 时清理)
*/
const setupPropsSync = (state, vNode) => {
const propKeys = getDeclaredPropKeys(vNode)
// 组件没有声明 props,无需监听
if (propKeys.length === 0) return () => {}
return watch(
state,
() => {
if (!vNode.component) return
const patch = {}
// 只同步组件声明过的 props,避免传入无关字段
for (const key of propKeys) {
if (key in state) {
patch[key] = state[key]
}
}
Object.assign(vNode.component.props, patch)
}
// reactive 默认深度监听,无需 { deep: true }
// 不加 flush,默认 'pre',与模板 props 更新时机一致
)
}
/**
* 创建代理对象,暴露 closed 方法和组件 defineExpose 的内容
* 使用 Proxy 让返回值既能 .closed() 关闭弹窗,又能访问组件暴露的方法
* @param {import('vue').VNode} vNode
* @param {Function} closed - 当前实例的清理函数
* @returns {Object}
*/
const createProxy = (vNode, closed) => {
return new Proxy(vNode, {
get(target, prop) {
// 优先返回 closed 方法
if (prop === 'closed') return closed
// 其次尝试返回组件 exposed 的属性
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
* 核心特性:
* 1. reactive 包装 options,自动解包 ref / shallowRef,与模板行为一致
* 2. watch 监听 state 变更,实时同步到组件 props
* 3. 独立 appContext 隔离,避免嵌套调用时 provide 数据污染
* 4. 自动清理上一个实例,防止内存泄漏
*
* @param {Object} Component - 要打开的组件
* @returns {Function} 创建组件实例的函数,接收 props 并返回代理对象
*/
export const useCommandComponent = (Component) => {
// 浅拷贝 appContext,隔离每个 useCommandComponent 实例的上下文
// 避免嵌套调用时互相覆盖全局 appContext.provides(详见第三篇)
const appContext = {...getCurrentInstance()?.appContext}
const currentProvides = getCurrentInstance()?.['provides']
Reflect.set(appContext, 'provides', currentProvides)
// 容器在闭包中只创建一次,复用同一个 div
const container = document.createElement('div')
// 记录当前实例的清理函数,用于重复调用时先销毁旧实例
let currentClose = null
// 基础清理:卸载 VNode + 移除 DOM
const baseClosed = () => {
render(null, container)
container.parentNode?.removeChild(container)
}
const CommandComponent = (options = {}) => {
// 重复调用时,先清理上一个实例(停 watch + 清 DOM)
if (currentClose) {
currentClose()
currentClose = null
}
// reactive 包装:ref 自动解包,后续 watch 能追踪深层变更
const state = prepareState(options)
let stopWatch = null
const closed = () => {
// 停止 watch,避免组件销毁后仍触发同步
if (stopWatch) {
stopWatch()
stopWatch = null
}
baseClosed()
// 安全置空:只有当前实例的 closed 才能清空 currentClose
if (currentClose === closed) {
currentClose = null
}
}
// 包装 onClosed:动画结束后自动执行 closed
bindOnClosed(state, closed)
// 首次渲染:将 state 展开为普通对象传给 createVNode
const vNode = initInstance(Component, {...state}, container, appContext)
// 建立响应式同步:后续 state 变更会通过 watch 更新到组件 props
stopWatch = setupPropsSync(state, vNode)
// 记录当前实例,供下次调用时清理
currentClose = closed
CommandComponent.closed = closed
// 返回代理:支持 .closed() 和组件 exposed 的方法
return createProxy(vNode, closed)
}
return CommandComponent
}
export default useCommandComponent
6. 源码分段解析
整个 Hook 被拆成了 7 个独立函数,每个职责单一。下面按执行顺序分三个阶段讲解。
阶段一:初始化(Hook 调用时执行一次)
当外部调用 useCommandComponent(MyComponent) 时,会创建一个闭包,保存以下状态:
const appContext = {...getCurrentInstance()?.appContext}
const currentProvides = getCurrentInstance()?.['provides']
Reflect.set(appContext, 'provides', currentProvides)
const container = document.createElement('div')
let currentClose = null
- appContext:浅拷贝当前实例的 appContext,隔离每个 useCommandComponent 的上下文,避免嵌套调用时 provide 数据互相污染(详见第三篇)
- container:一个 div 容器,在闭包中只创建一次,所有实例复用
- currentClose:记录当前活跃实例的清理函数,用于重复调用时先销毁旧实例
阶段二:调用(每次 showModal() 时执行)
调用 CommandComponent(options) 时的完整流程:
const CommandComponent = (options = {}) => {
// ① 清理上一个实例
if (currentClose) {
currentClose()
currentClose = null
}
// ② 包装为 reactive state
const state = prepareState(options)
// ③ 绑定关闭回调
bindOnClosed(state, closed)
// ④ 首次渲染
const vNode = initInstance(Component, {...state}, container, appContext)
// ⑤ 建立响应式同步
stopWatch = setupPropsSync(state, vNode)
// ⑥ 返回代理
return createProxy(vNode, closed)
}
关键设计:prepareState 的 ref 自动解包
const prepareState = (options) => {
const state = reactive({...options})
// ...
return state
}
reactive() 会自动解包内部的 ref 和 shallowRef。当外部传入 { title: ref('hello') } 时,state.title 的值是 'hello' 而非 ref 对象。这与模板中使用 ref 的行为完全一致。
关键设计:首次渲染 + 后续同步的分离
// 首次:直接展开 state 传给 createVNode
const vNode = initInstance(Component, {...state}, container, appContext)
// 后续:通过 watch 将 state 变更同步到组件 props
stopWatch = setupPropsSync(state, vNode)
首次渲染用展开运算符 {...state} 拍平 reactive 对象为普通 props。之后 setupPropsSync 通过 watch 监听 state 变化,只同步组件声明过的 props key,实现精确更新。
阶段三:关闭
const closed = () => {
if (stopWatch) {
stopWatch() // 停止 watch,避免组件销毁后仍触发同步
stopWatch = null
}
baseClosed() // 卸载 VNode + 移除 DOM
if (currentClose === closed) {
currentClose = null
}
}
closed 函数有两个职责:
- 停止 watch:防止组件已销毁但 watch 回调仍在尝试更新 props
- 清理 DOM:卸载 VNode 并移除容器元素
bindOnClosed 确保 closed 在 @closed 事件(动画结束后)才执行,而非 @close(动画开始前),保证关闭动画能完整播放。
代理对象
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)
}
})
}
使用 Proxy 让返回值:
- 可以直接调用
.closed()关闭弹窗 - 可以访问组件通过
defineExpose暴露的方法