前言
使用 useCommandComponent 封装命令式弹窗时,会遇到三个底层机制问题,导致行为与模板中使用组件不一致:
- Provide 数据污染:多次打开弹窗,inject 到上一次的残留数据
- 关闭动画丢失:弹窗关闭时 DOM 被过早销毁,动画消失
- 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 | 状态 |
|---|---|---|---|
| 先打开一次 DialogA | A 嵌套注册 B,全局 appContext 被污染 | - | ❌ 污染发生 |
| 关闭 DialogA | 污染未恢复 | - | - |
| 打开 DialogC | C 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.value | props.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(动画前)清理 DOM | bindOnClosed 确保 @closed(动画后)清理 |
| ref 作为 props 不解包 | createVNode 不像模板那样自动解包 ref | reactive() 解包 + watch 同步后续变更 |