Vue 3 命令式弹窗组件

juejin.cn/post/725306... 整篇文章其实就是在这篇文章的基础上做了一点点改进,但是我使用的很舒服,想着分享出来。

Vue 3 命令式弹窗组件

一、核心实现:useCommandComponent.js

javascript 复制代码
import { createVNode, getCurrentInstance, render } from 'vue'
​
/**
 * 获取弹窗挂载的DOM元素
 * 支持三种方式:1.字符串选择器 2.HTMLElement对象 3.默认body
 */
const getAppendToElement = (props) => {
  let appendTo = document.body
  
  if (props.appendTo) {
    if (typeof props.appendTo === 'string') {
      // 字符串选择器,如 "#app" 或 ".container"
      appendTo = document.querySelector(props.appendTo)
    } else if (props.appendTo instanceof HTMLElement) {
      // 直接传入DOM元素
      appendTo = props.appendTo
    }
    
    // 如果获取失败,回退到body
    if (!(appendTo instanceof HTMLElement)) {
      appendTo = document.body
    }
  }
  return appendTo
}
​
/**
 * 初始化组件实例
 * 核心:创建虚拟节点并渲染到DOM
 */
const initInstance = (Component, props, container, appContext = null) => {
  // 1. 创建虚拟节点
  const vNode = createVNode(Component, props)
  
  // 2. 继承当前组件的上下文(解决provide/inject问题)
  vNode.appContext = appContext
  
  // 3. 渲染到临时容器
  render(vNode, container)
  
  // 4. 将容器挂载到目标位置
  getAppendToElement(props).appendChild(container)
  
  return vNode
}
​
/**
 * 核心Hook:将声明式组件转换为命令式组件
 * 特点:简单、灵活、功能完整
 */
export const useCommandComponent = (Component) => {
  // 获取当前组件的应用上下文
  const appContext = getCurrentInstance()?.appContext
  
  // 关键:继承当前组件的provides链,用于依赖注入
  if (appContext) {
    const currentProvides = getCurrentInstance()?.provides
    // 保持原型链完整
    Reflect.set(appContext, 'provides', currentProvides)
  }
​
  // 创建临时容器(内存中,尚未挂载到DOM)
  const container = document.createElement('div')
​
  /**
   * 关闭弹窗并清理资源
   * 这个close函数会暴露给外部调用
   */
  const close = () => {
    // 1. 卸载Vue组件
    render(null, container)
    // 2. 从DOM中移除容器
    container.parentNode?.removeChild(container)
  }
​
  /**
   * 命令式组件调用函数
   * 这是实际被调用的函数,返回一个代理对象
   */
  const CommandComponent = (options = {}) => {
    // 自动设置visible为true,让弹窗默认显示
    if (!Reflect.has(options, 'visible')) {
      options.visible = true
    }
    
    /**
     * 处理关闭回调
     * 用户可以通过onClose接收关闭时的参数
     * 示例:onClose: (data) => console.log('收到数据:', data)
     */
    if (typeof options.onClose !== 'function') {
      // 如果没有提供onClose,使用默认关闭函数
      options.onClose = close
    } else {
      // 如果提供了onClose,包裹一层确保能清理DOM
      const originOnClose = options.onClose
      options.onClose = (...args) => {
        // 先执行用户回调,可以传递参数
        originOnClose(...args)
        // 再执行清理
        close()
      }
    }
    
    // 创建组件实例
    const vNode = initInstance(Component, options, container, appContext)
    
    /**
     * 返回一个代理对象,提供两种访问方式:
     * 1. 通过close属性直接关闭弹窗
     * 2. 访问组件通过defineExpose暴露的方法
     */
    return new Proxy(vNode, {
      // 拦截属性访问
      get(target, prop) {
        // 1. 支持直接调用close()关闭弹窗
        if (prop === 'close') {
          return close
        }
        
        // 2. 支持访问组件expose的方法
        const exposed = target.component?.exposed
        if (exposed && Reflect.has(exposed, prop)) {
          return Reflect.get(exposed, prop)
        }
        
        // 3. 返回原始vNode的属性
        return Reflect.get(target, prop)
      },
      
      // 拦截in操作符
      has(target, prop) {
        if (prop === 'close') {
          return true
        }
        
        const exposed = target.component?.exposed
        if (exposed && Reflect.has(exposed, prop)) {
          return true
        }
        
        return Reflect.has(target, prop)
      }
    })
  }
​
  // 为函数对象本身也添加close方法
  CommandComponent.close = close
​
  return CommandComponent
}
​
export default useCommandComponent

二、弹窗组件示例

1. 通过props打开的弹窗

xml 复制代码
<!-- BaseDialog.vue -->
<template>
  <el-dialog
      :model-value="visible"
      :title="title"
      width="500px"
      :before-close="handleBeforeClose"
  >
    <!-- 内容区域 -->
    <div>{{ content }}</div>
​
    <!-- 底部操作按钮 -->
    <template #footer>
      <span class="dialog-footer">
        <el-button @click="handleCancel">取消</el-button>
        <el-button type="primary" @click="handleConfirm">确定</el-button>
      </span>
    </template>
  </el-dialog>
</template>
​
<script setup>
import {defineEmits, defineExpose, defineProps, ref} from 'vue'
​
// 接收props
const props = defineProps({
  visible: { type: Boolean, default: false },
  title: { type: String, default: '提示' },
  content: { type: String, default: '' },
  // 接收父组件传递的数据
  payload: { type: Object, default: () => ({}) }
})
​
// 定义事件
const emit = defineEmits(['close'])
​
const resultData = ref(null)
​
// 关闭前处理
const handleBeforeClose = (done) => {
  // 可以通过close传递数据
  emit('close', {
    type: 'cancel',
    data: resultData.value,
    payload: props.payload
  })
  done()
}
​
// 确认操作
const handleConfirm = () => {
  resultData.value = { action: 'confirm', time: new Date() }
  emit('close', {
    type: 'confirm',
    data: resultData.value,
    payload: props.payload
  })
}
​
// 取消操作
const handleCancel = () => {
  resultData.value = { action: 'cancel', time: new Date() }
  emit('close', {
    type: 'cancel',
    data: resultData.value,
    payload: props.payload
  })
}
​
// 暴露给外部的方法
const showMessage = (message) => {
  console.log('弹窗内部方法被调用:', message)
  return `弹窗响应: ${message}`
}
​
const getDialogData = () => {
  return { message: '这是从弹窗获取的数据', time: new Date() }
}
​
// 定义expose
defineExpose({
  showMessage,
  getDialogData,
})
</script>

2. 通过expose方法打开的弹窗

xml 复制代码
<!-- ExposeDialog.vue -->
<template>
  <el-dialog
      v-model="internalVisible"
      :title="params.title"
      width="500px"
  >
    <div>{{ params.content }}</div>
​
    <template #footer>
      <el-button @click="internalVisible = false">关闭</el-button>
    </template>
  </el-dialog>
</template>
​
<script setup>
import {reactive, ref} from 'vue'
​
const emit = defineEmits(['close'])
​
// 内部状态
const internalVisible = ref(false)
const params = reactive({})
​
// 通过expose暴露的打开方法
const open = (options = {}) => {
  internalVisible.value = true
  if (options.title) params.title = options.title
  if (options.content) params.content = options.content
  return '弹窗已打开'
}
​
// 自定义方法
const customMethod = () => {
  return `自定义方法调用成功,当前数据: ${customData.value}`
}
​
// 定义expose
defineExpose({
  open,
  customMethod,
})
</script>

三、关键特性总结

1. 简单到极致

php 复制代码
// 一步转换
const createDialog = useCommandComponent(MyDialog)
​
// 一行调用
createDialog({ title: '提示', content: '内容' })

2. 完整的参数传递

php 复制代码
// 传递数据进去
createDialog({
  title: '编辑用户',
  content: '修改用户信息',
  payload: { userId: 123, userData: {...} }
})
​
// 接收数据出来
createDialog({
  onClose: (result) => {
    console.log('用户操作:', result.type)  // 'confirm' 或 'cancel'
    console.log('返回数据:', result.data)
    console.log('传递的数据:', result.payload)
  }
})

3. 灵活的expose调用

scss 复制代码
const dialog = createDialog({...})
​
// 调用expose的方法
dialog.showMessage('Hello')
dialog.getData()
dialog.customMethod()
​
// 多种关闭方式
dialog.close({ reason: '用户关闭' })  // 通过hook的close
dialog.closeDialog()                  // 通过组件expose的closeDialog

四、使用场景对比

传统声明式组件

ini 复制代码
<template>
  <MyDialog 
    v-model:visible="showDialog" 
    :title="dialogTitle"
    :content="dialogContent"
    @close="handleClose"
  />
</template>
​
<script setup>
// 需要维护多个状态
const showDialog = ref(false)
const dialogTitle = ref('')
const dialogContent = ref('')
​
// 打开弹窗需要多步操作
const openDialog = () => {
  dialogTitle.value = '标题'
  dialogContent.value = '内容'
  showDialog.value = true
}
</script>

命令式组件

javascript 复制代码
// 在任何地方,一行代码
const openDialog = () => {
  createDialog({
    title: '标题',
    content: '内容',
    onClose: (data) => { /* 处理关闭 */ }
  })
}
相关推荐
NEXT062 小时前
CSS基础-标准盒模型与怪异盒模型
前端·css
DaMu2 小时前
Dreamcore3D ARPG IDE “手搓”游戏引擎,轻量级实时3D创作工具,丝滑操作,即使小白也能轻松愉快的创作出属于你自己的游戏世界!
前端·架构·three.js
代码猎人2 小时前
什么是尾调用,使用尾调用有什么好处?
前端
AI_56782 小时前
Webpack从“配置到提速”,4步解决“打包慢、体积大”问题
前端·javascript·vue.js
pinkQQx2 小时前
手把手搭建前端跨平台服务(IPlatform + iOS / Android / Web)
前端·javascript
江启年2 小时前
对useEffect和 useMemo的一些总结与感悟
前端
中微子2 小时前
Web 安全:跨域、XSS 攻击与 CSRF 攻击
前端
Aotman_2 小时前
Vue el-table 字段自定义排序
前端·javascript·vue.js·es6
LaiYoung_2 小时前
🛡️ 代码质量的“埃癸斯”:为什么你的项目需要这面更懂业务的 ESLint 神盾?
前端·代码规范·eslint