最近在项目中遇到了一个让人头疼的问题——弹窗满天飞。你懂的,现在的前端项目,弹窗简直就像雨后春笋,到处都是。确认弹窗、表单弹窗、详情弹窗…每次写一个新弹窗,都要重复一堆逻辑:状态管理、挂载卸载、参数传递等等。说实话,写到后面我都快吐了。
于是我琢磨着,能不能搞个一劳永逸的方案?毕竟程序员的三大美德之一就是懒惰嘛。经过一番折腾,我基于 Vue 3 的 Composition API 封装了一个 useModal
Hook,现在用起来那叫一个爽!
为什么要封装 useModal?#
在没有封装之前,每次写弹窗都是这样的:
<template> <div> <button @click="showModal = true">打开弹窗</button> <MyModal v-model:visible="showModal" @ok="handleOk" /> </div></template>
<script setup>import { ref } from 'vue'import MyModal from './MyModal.vue'
const showModal = ref(false)const handleOk = () => { // 处理确认逻辑 showModal.value = false}</script>
看起来还行?但是当你有十几个弹窗的时候,你就会发现自己在不停地重复写这些代码。而且最要命的是,有些弹窗还需要动态加载,有些需要嵌套使用,有些需要全局唯一…这时候你就会发现,事情开始变得复杂起来。
useModal 的核心思路#
我的想法很简单:既然弹窗本质上就是一个组件的动态渲染,那我就用 Vue 3 的 createVNode
和 render
方法,把这个过程封装起来。用户只需要传入组件和参数,剩下的事情交给 Hook 来处理。
核心特性包括:
- 动态渲染:通过
createVNode
动态创建组件实例 - 生命周期管理:自动处理组件的挂载和卸载
- 状态同步:支持灵活的参数传递和回调处理
- 高度复用:一个 Hook 搞定所有弹窗场景
源码解析#
让我们来看看这个 Hook 的具体实现:
import { createVNode, render, VNodeTypes, getCurrentInstance } from 'vue'import { message, Modal } from 'ant-design-vue'import { v4 as uuid } from 'uuid'import { app } from '@/createApp'import { importWithRetry } from '@/utils/importWithRetry'
export interface Options { destroyAll?: boolean onOpen?: (data?: unknown) => void onCancel?: (data?: unknown) => void}
const containerMap: Record<string, () => void> = Object.create(null)
const destroyAllModal = (id: string) => { for (const key in containerMap) { if (key !== id) { containerMap[key]() } } Modal.destroyAll()}
export const useModal = ( _Modal: VNodeTypes, props?: Record<string, unknown>, onOk?: (...data: any) => void, options?: Options) => { if (!props) { props = Object.create(null) }
let container: HTMLDivElement | null = document.createElement('div') const id = uuid()
// 移除组件 const remove = () => { if (container) { render(null, container) delete containerMap[id] container.remove() container = null } } containerMap[id] = remove
// 移除其他弹窗 if (options && options.destroyAll) { destroyAllModal(id) }
const getContainer = () => { return document.getElementById('app') }
const vm = createVNode(_Modal, { ...props, class: props.class ? props.class + ' system-modal' : 'system-modal', getContainer, remove, onOk, modalOptions: options || Object.create(null), destroyAllModal: () => destroyAllModal(id) })
vm.appContext = app._context || getCurrentInstance()?.appContext render(vm, container) return vm}
关键点解析#
1. 容器管理
const containerMap: Record<string, () => void> = Object.create(null)
这里我用了一个 Map 来管理所有的弹窗容器。每个弹窗都有一个唯一的 ID,对应一个清理函数。这样做的好处是可以精确控制每个弹窗的生命周期。
2. 动态渲染
const vm = createVNode(_Modal, { ...props, remove, onOk })vm.appContext = app._context || getCurrentInstance()?.appContextrender(vm, container)
这是整个 Hook 的核心。通过 createVNode
创建虚拟节点,然后用 render
方法渲染到 DOM 中。注意这里要设置 appContext
,确保组件能正确访问全局的上下文。
3. 资源清理
const remove = () => { if (container) { render(null, container) delete containerMap[id] container.remove() container = null }}
清理工作很重要,要确保组件卸载时能正确释放资源,避免内存泄漏。
异步加载的进阶版本#
除了基础的 useModal
,我还封装了一个 openAsyncModal
函数,专门用来处理动态导入的组件:
export const openAsyncModal = ( loader: AsyncComponentLoader, props?: Record<string, unknown>, onOk?: (data?: unknown) => void | Promise<void>, options?: AsyncModalOptions) => { window.clearTimeout(delayTimer) const realOptions: AsyncModalOptions = Object.assign(Object.create(null), defaultOptions, options) let isRemoved = false
let container: HTMLDivElement | null = document.createElement('div') const id = uuid()
const remove = () => { if (container) { isRemoved = true render(null, container) delete containerMap[id] container.remove() container = null } } containerMap[id] = remove
let hide: () => void delayTimer = window.setTimeout(() => { hide = message.loading('加载中...', 0) }, realOptions.delay)
importWithRetry(loader) .then((res) => { if (isRemoved) return const vm = createVNode(res.default, { ...props, remove, onOk, modalOptions: realOptions }) vm.appContext = app._context render(vm, container!) }) .catch((err) => { Modal.error({ title: '网络连接异常,请刷新后重试!', okText: '立即刷新', onOk() { window.location.reload() } }) throw err }) .finally(() => { window.clearTimeout(delayTimer) hide?.() })}
这个版本的亮点在于:
- 延迟加载提示:避免网络快的时候 loading 一闪而过
- 重试机制:通过
importWithRetry
处理网络异常 - 优雅降级:加载失败时给用户友好的提示
实际使用场景#
现在用起来就简单多了:
基础用法
import { useModal } from '@/hooks/useModal'import UserForm from './UserForm.vue'
// 打开用户表单弹窗const openUserForm = (userData) => { useModal(UserForm, { title: '编辑用户', userData }, (result) => { console.log('用户提交了:', result) })}
异步加载
import { openAsyncModal } from '@/hooks/useModal'
// 动态加载大型组件const openReportModal = () => { openAsyncModal( () => import('./ReportModal.vue'), { reportId: 123 }, (data) => console.log('报告生成完成:', data), { delay: 300 } )}
全局唯一弹窗
// 确保同时只有一个弹窗存在useModal(ConfirmModal, { message: '确定要删除吗?'}, handleConfirm, { destroyAll: true})
踩过的坑#
在封装过程中,我也踩了不少坑:
1. 上下文丢失
最开始忘记设置 appContext
,导致弹窗组件无法访问全局的 provide/inject,各种插件都用不了。
2. 内存泄漏 没有正确清理容器,导致页面用久了越来越卡。后来加了完善的清理机制才解决。
3. 异步组件的时序问题
用户快速点击时,可能出现组件还没加载完就被销毁的情况。通过 isRemoved
标志位解决了这个问题。
总结#
这个 useModal
Hook 虽然代码不多,但确实解决了我们项目中弹窗管理的大部分痛点。现在团队里的小伙伴都在用,反馈还不错。
当然,这个方案也不是银弹,比如对于一些特别复杂的弹窗交互,可能还是需要传统的方式来处理。但对于大部分场景来说,已经够用了。
最重要的是,它让我们的代码变得更加简洁和可维护。毕竟,能用一行代码解决的事情,为什么要写十行呢?