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

145 阅读8分钟

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() 会自动解包内部的 refshallowRef,这与 Vue 模板中的行为完全一致。

4. 常用配置项

属性类型说明
visibleBoolean默认为 true,控制弹窗显隐
appendToString/HTMLElement挂载点,默认为 body
onClosedFunction弹窗完全关闭(动画结束)后的回调

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() 会自动解包内部的 refshallowRef。当外部传入 { 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 函数有两个职责:

  1. 停止 watch:防止组件已销毁但 watch 回调仍在尝试更新 props
  2. 清理 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 暴露的方法

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

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

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