📄 第一篇:Vue 3 命令式弹窗使用指南

209 阅读7分钟

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. 基础配置项

属性类型说明
visibleBoolean默认为 true,控制弹窗显隐
appendToString / HTMLElement挂载点,默认为 document.body
onClosedFunction弹窗完全关闭(动画结束)后触发的回调
slotsObject插槽渲染函数集合,见第 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

📄 第一篇:Vue 3 命令式弹窗使用指南

📄 第二篇:Vue 3 命令式弹窗 provide/inject 机制解析

📄 第三篇:Vue 3 命令式弹窗 Provide 污染与关闭动画修复