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

100 阅读5分钟

前言

使用 useCommandComponent 封装命令式弹窗时,会遇到三个底层机制问题,导致行为与模板中使用组件不一致:

  1. Provide 数据污染:多次打开弹窗,inject 到上一次的残留数据
  2. 关闭动画丢失:弹窗关闭时 DOM 被过早销毁,动画消失
  3. ref 作为 props 不解包:传 ref 给命令式组件,内部拿到的是 ref 对象而非 .value

这三个问题分别涉及 Vue 的上下文隔离、DOM 生命周期和响应式解包机制。下面逐一分析。


修复点1:AppContext 上下文污染

问题复现

假设弹窗 A 内部又注册了一个子弹窗 B,页面上还有一个独立的弹窗 C:

<!-- DialogA.vue -->
<script setup>
import { provide } from 'vue'
import DialogB from './DialogB.vue'

provide('dialogId', 'A-' + Math.random())

// 弹窗 A 内部嵌套注册子弹窗
const showB = useCommandComponent(DialogB)
</script>
<!-- DialogB.vue -->
<script setup>
import { inject } from 'vue'
const id = inject('dialogId')
</script>
<!-- DialogC.vue -->
<script setup>
import { inject } from 'vue'
const id = inject('dialogId')
console.log('DialogC inject:', id)
</script>
// Parent.vue
const showA = useCommandComponent(DialogA)
const showC = useCommandComponent(DialogC)

实际表现:

操作说明DialogC inject状态
先打开一次 DialogAA 嵌套注册 B,全局 appContext 被污染-❌ 污染发生
关闭 DialogA污染未恢复--
打开 DialogCC inject 'dialogId''A-0.123'❌ 拿到 A 的残留

DialogC 跟 DialogA 是平级关系,从来没有嵌套过。但 DialogC 打开时,inject 到了 DialogA 上次残留的值。

根因分析

问题出在旧写法直接引用了全局 appContext:

// ❌ 旧写法:直接引用全局 appContext
const appContext = instance?.appContext
Reflect.set(appContext, 'provides', currentProvides)

污染链路:

打开 DialogA
  → DialogA.provides = Object.create(appContext.provides)
  → provide('dialogId', 'A-0.123')
  → DialogA.provides = { dialogId: 'A-0.123' }

  → DialogA setup 中调用 useCommandComponent(DialogB)
  → currentProvides = DialogA.provides = { dialogId: 'A-0.123' }
  → Reflect.set(appContext, 'provides', DialogA.provides)
  → 全局 appContext.provides = { dialogId: 'A-0.123' }  ❌ 被覆盖

关闭 DialogA
  → appContext.provides 没有恢复,仍然是 { dialogId: 'A-0.123' }

打开 DialogC(跟 A 平级,从未嵌套)
  → DialogC.provides = Object.create(appContext.provides)
  → 原型链指向 { dialogId: 'A-0.123' }(A 的残留)
  → inject('dialogId') → 'A-0.123' ❌ 拿到 A 的旧数据

关键点:单独打开两个不嵌套的弹窗不会污染。 每个弹窗的 provides 是独立对象,通过原型链链接到 appContext.provides,不会修改全局。只有弹窗内部再注册弹窗(嵌套调用 useCommandComponent)时,才会触发 Reflect.set 覆盖全局。

修复方案

// ✅ 浅拷贝 appContext,每个实例独立
const appContext = {...getCurrentInstance()?.appContext}
const currentProvides = getCurrentInstance()?.['provides']
Reflect.set(appContext, 'provides', currentProvides)

{...appContext} 创建全新的独立对象,与原始 appContext 没有关联。即使嵌套调用,每个 useCommandComponent 都有自己的 appContext 副本,不会覆盖全局。


修复点2:关闭动画丢失

Element Plus 的关闭事件

<el-dialog> 有两个关闭相关事件:

事件触发时机说明
@close用户点击关闭按钮时动画开始前
@closed关闭动画播放完成后动画结束后

问题

命令式弹窗需要在关闭后清理 DOM:

const baseClosed = () => {
    render(null, container)        // 卸载组件
    container.parentNode?.removeChild(container)  // 移除 DOM
}

如果在 @close(动画前)执行清理:

用户点击关闭 → @close 触发 → 立即执行 baseClosed()
  → 组件被销毁,动画无法播放
  → 用户看到弹窗"瞬间消失"

如果在 @closed(动画后)执行清理:

用户点击关闭 → @close 触发 → 播放动画(300ms)→ @closed 触发
  → 执行 baseClosed()
  → 动画完整播放,DOM 安全清理 ✅

修复方案:bindOnClosed

const bindOnClosed = (state, closed) => {
    if (typeof state.onClosed !== 'function') {
        // 用户没传 onClosed → 直接用 closed 清理函数
        state.onClosed = closed
    } else {
        // 用户传了 onClosed → 包装一层
        const originOnClosed = state.onClosed
        state.onClosed = (...args) => {
            originOnClosed(...args)  // 先执行用户回调
            closed()                 // 再执行 DOM 清理
        }
    }
}

配合 Vue 的属性透传:如果弹窗组件没有在 defineProps 中声明 onClosed,Vue 会自动透传给内部的 <el-dialog>,相当于 @closed="onClosed"

这样无论用户是否传了 onClosed,都能保证 DOM 在动画结束后才被清理。


修复点3:ref 作为 props 不解包

这是最隐蔽的问题,也是引入 reactive()setupPropsSync 的根本原因。

问题描述

在模板中使用组件时,ref 作为 props 会被自动解包:

<template>
  <UserProfile :username="username" />
  <!-- 组件内 props.username 拿到的是 '张三',不是 ref 对象 -->
</template>

<script setup>
const username = ref('张三')
</script>

但命令式组件直接传 ref,情况不同:

const username = ref('张三')
const showProfile = useCommandComponent(UserProfile)
showProfile({ username })
// 组件内 props.username 拿到的是 ref 对象,不是 '张三' ❌

根因分析

原始的写法是直接把 options 传给 createVNode

// ❌ 原始写法
const CommandComponent = (options = {}) => {
    const vNode = createVNode(Component, options)  // 直接传
    render(vNode, container)
}

createVNode 接收的 props 是一个普通对象,Vue 不会对其中的 ref 值做解包。它只是把对象原样赋值给 vNode.component.props

而模板编译时,Vue 会自动处理 ref 的解包——这是模板编译器的能力,不是 createVNode 的能力。

对比:

模板createVNode
<Child :username="refVal" />props.username = refVal.valueprops.username = refVal(不解包)
解包时机编译时

修复方案

分两步解决:

第1步:用 reactive 包装,解包 ref

const prepareState = (options) => {
    const state = reactive({...options})
    if (!Reflect.has(state, 'visible')) {
        state.visible = true
    }
    return state
}

reactive() 会自动解包内部的 ref 和 shallowRef。传入 { username: ref('张三') } 后,state.username 的值是 '张三' 而非 ref 对象。

第2步:用 watch 同步后续变更

首次渲染通过 {...state} 展开为普通对象传给 createVNode,ref 已被解包:

const vNode = initInstance(Component, {...state}, container, appContext)

但之后修改 ref 怎么同步到组件?靠 setupPropsSync

const setupPropsSync = (state, vNode) => {
    const propKeys = getDeclaredPropKeys(vNode)
    if (propKeys.length === 0) return () => {}

    return watch(
        state,
        () => {
            if (!vNode.component) return
            const patch = {}
            for (const key of propKeys) {
                if (key in state) {
                    patch[key] = state[key]
                }
            }
            Object.assign(vNode.component.props, patch)
        }
    )
}

watch 监听 reactive state 的变化,只同步组件声明过的 props key,行为与模板更新一致。

关闭时需要停止 watch:

const closed = () => {
    if (stopWatch) {
        stopWatch()    // 停止 watch,避免组件销毁后仍触发同步
        stopWatch = null
    }
    baseClosed()       // 卸载 VNode + 移除 DOM
    if (currentClose === closed) {
        currentClose = null
    }
}

这是引入 watch 后的自然产物:组件销毁时必须停止监听,否则回调会尝试更新已不存在的组件。

修复效果

const username = ref('张三')
showProfile({ username })

// ✅ 组件内 props.username 拿到 '张三',而非 ref 对象
// ✅ 修改 username.value = '李四' 能触发组件更新
// ✅ 与模板行为一致

小结

问题根因修复方式
provide 数据污染多个实例共用同一个 appContext{...appContext} 浅拷贝隔离
关闭动画丢失在 @close(动画前)清理 DOMbindOnClosed 确保 @closed(动画后)清理
ref 作为 props 不解包createVNode 不像模板那样自动解包 refreactive() 解包 + watch 同步后续变更

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

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

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