📄 第三篇:Vue 3 命令式弹窗 Provide 污染与关闭动画修复
问题背景
在使用 useCommandComponent 封装命令式弹窗时,遇到了两个典型的底层机制问题:
- Provide/Inject 数据污染:多次打开弹窗后,子组件 inject 到了上一次残留的旧数据。
- 关闭动画丢失:弹窗关闭时 DOM 被过早销毁,导致过渡动画无法完整播放。
修复点1:AppContext 上下文污染
1. 问题复现
测试场景
<!-- TestModal.vue -->
<script setup>
import { provide, inject } from 'vue'
// 每次打开都 provide 一个随机值
provide('modalConfig', Math.random())
// 尝试 inject 同一个 key
const config = inject('modalConfig')
console.log('inject 结果:', config)
</script>
// App.vue
const showModal = useCommandComponent(TestModal)
操作流程与现象
| 操作 | provide 的值 | inject 预期 | inject 实际 | 状态 |
|---|---|---|---|---|
| 第1次打开 | 0.123456 | undefined | undefined | ✅ 正常 |
| 关闭弹窗 | - | - | - | - |
| 第2次打开 | 0.789012 | undefined | 0.123456 | ❌ 污染 |
关键特征:
- 首次运行正常,第2次才出问题。
- 不报错,只是数据不对(最难排查的 bug 类型)。
- 拿到的不是本次 provide 的值,而是上一次的残留。
2. 根本原因分析
错误代码
// useCommandComponent.js - 有问题的版本
export const useCommandComponent = (Component) => {
const instance = getCurrentInstance()
// ❌ 直接修改全局 appContext.provides
const appContext = instance?.appContext
const currentProvides = instance?.provides
if (appContext && currentProvides) {
Reflect.set(appContext, 'provides', currentProvides)
}
// ...
}
污染链路详解
初始状态:
App.appContext.provides = {}
第1次注册(App.vue 中调用):
const showModal = useCommandComponent(TestModal)
// instance = App 实例
// currentProvides = App.provides = {}
// 覆盖全局(此时是空对象,暂时没问题)
Reflect.set(App.appContext, 'provides', {})
第1次打开弹窗:
showModal()
// 创建 TestModal 实例1
TestModal实例1.provides = Object.create(App.appContext.provides)
// setup 执行
provide('modalConfig', 0.123456)
// TestModal实例1.provides = { modalConfig: 0.123456 }
const config = inject('modalConfig')
// 查询链:TestModal实例1.provides → App.appContext.provides
// 结果:undefined ✅(符合预期)
// ⚠️ 如果内部嵌套调用 useCommandComponent
const showChild = useCommandComponent(ChildComponent)
// currentProvides = TestModal实例1.provides
// Reflect.set(App.appContext, 'provides', TestModal实例1.provides)
// App.appContext.provides = { modalConfig: 0.123456 } ❌ 被污染!
关闭弹窗:
// unmount(TestModal实例1)
// ❌ 但 App.appContext.provides 没有被恢复
// 仍然是:{ modalConfig: 0.123456 }
第2次打开弹窗:
showModal()
// 创建 TestModal 实例2
TestModal实例2.provides = Object.create(App.appContext.provides)
// TestModal实例2.provides.__proto__ = { modalConfig: 0.123456 } ❌ 原型链指向旧数据
// setup 执行
provide('modalConfig', 0.789012)
// TestModal实例2.provides = { modalConfig: 0.789012 }
const config = inject('modalConfig')
// 查询链:TestModal实例2.provides → App.appContext.provides
// 结果:0.123456 ❌ 拿到了第1次的残留数据!
核心问题
嵌套调用 useCommandComponent
↓
覆盖全局 appContext.provides
↓
关闭后未恢复
↓
新实例的原型链指向旧数据
↓
inject 通过原型链查到残留值
3. 修复方案
修复代码
// useCommandComponent.js - 修复版本(第30-35行)
export const useCommandComponent = (Component) => {
const instance = getCurrentInstance()
// ✅ 先复制 appContext,再独立设置 provides
const appContext = { ...instance?.appContext }
Reflect.set(appContext, 'provides', currentProvides)
// ...
}
修复原理对比
修复前(有问题):
const appContext = instance?.appContext
Reflect.set(appContext, 'provides', currentProvides)
// ❌ 所有调用共用同一个 appContext,互相覆盖
修复后(正确):
const appContext = { ...instance?.appContext }
Reflect.set(appContext, 'provides', currentProvides)
// ✅ 两步操作:
// 1. 创建完全独立的新对象
// 2. 单独设置 provides 属性
// 彻底隔离,互不影响
关键点:
-
{ ...instance?.appContext }创建全新的独立对象。 - 新对象与原始
appContext没有任何关联。
修复点2:onClosed 回调与关闭动画
1. Element Plus 的关闭事件机制
Element Plus 的 <el-dialog> 有两个关闭相关的事件:
| 事件 | 触发时机 | 说明 |
|---|---|---|
@close |
用户点击关闭按钮时 | 动画开始前立即触发 |
@closed |
关闭动画播放完成后 | 动画结束后触发 |
完整执行流程:
用户点击关闭按钮
↓
① @close 触发(此时动画还没开始)
↓
② 播放关闭动画(约 300ms)
↓
③ @closed 触发(动画已完全结束)
2. 为什么要用 onClosed 而不是 onClose?
关键问题:DOM 清理时机
命令式弹窗需要在关闭后清理 DOM:
const closed = () => {
render(null, container) // 卸载组件
container.remove() // 移除 DOM
}
如果在 @close 时清理:
用户点击关闭
↓
@close 触发
↓
立即执行 closed() → render(null, container)
↓
❌ 组件被销毁,动画无法继续播放
❌ 用户看到弹窗"瞬间消失",没有过渡效果
如果在 @closed 时清理:
用户点击关闭
↓
@close 触发(动画开始)
↓
播放关闭动画(300ms)✅ 动画完整播放
↓
@closed 触发(动画结束)
↓
执行 closed() → render(null, container) ✅ 安全清理
3. Vue 属性透传的作用
当弹窗组件作为根元素且未在 defineProps 中声明 onClosed 时,Vue 会自动将其透传给内部的 <el-dialog>。
<!-- TestModal.vue -->
<template>
<!-- el-dialog 是根元素 -->
<el-dialog v-model="visible">
弹窗内容
</el-dialog>
</template>
<script setup>
// 没有声明 onClosed,Vue 自动透传
</script>
外部调用:
showModal({
onClosed: () => console.log('用户回调')
})
结果: onClosed 被透传给 <el-dialog>,相当于:
<el-dialog v-model="visible" @closed="onClosed">
4. 实现方案
核心逻辑
// useCommandComponent.js(第42-60行)
// 清理函数
const closed = () => {
render(null, container)
container.parentNode?.removeChild(container)
}
const CommandComponent = (options = {}) => {
// ... 其他逻辑
// ✅ 统一处理 onClosed,确保动画完整 + DOM 清理
if (typeof options.onClosed !== 'function') {
// 用户没提供 onClosed,使用默认清理函数
options.onClosed = closed
} else {
// 用户提供了 onClosed,包裹一层确保能清理 DOM
const originOnClosed = options.onClosed
options.onClosed = (...args) => {
originOnClosed(...args) // 先执行用户回调
closed() // 再执行 DOM 清理
}
}
// ...
}