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

4 阅读4分钟

问题背景

在使用 useCommandComponent 封装命令式弹窗时,遇到了两个典型的底层机制问题:

  1. Provide/Inject 数据污染:多次打开弹窗后,子组件 inject 到了上一次残留的旧数据。
  2. 关闭动画丢失:弹窗关闭时 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.123456undefinedundefined✅ 正常
关闭弹窗----
第2次打开0.789012undefined0.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 清理
    }
  }
  
  // ...
}

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

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

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