Skip to content

Vue3 hook useModal的封装:让弹窗管理变得轻松愉快

· 8 min

最近在项目中遇到了一个让人头疼的问题——弹窗满天飞。你懂的,现在的前端项目,弹窗简直就像雨后春笋,到处都是。确认弹窗、表单弹窗、详情弹窗…每次写一个新弹窗,都要重复一堆逻辑:状态管理、挂载卸载、参数传递等等。说实话,写到后面我都快吐了。

于是我琢磨着,能不能搞个一劳永逸的方案?毕竟程序员的三大美德之一就是懒惰嘛。经过一番折腾,我基于 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 的 createVNoderender 方法,把这个过程封装起来。用户只需要传入组件和参数,剩下的事情交给 Hook 来处理。

核心特性包括:

  1. 动态渲染:通过 createVNode 动态创建组件实例
  2. 生命周期管理:自动处理组件的挂载和卸载
  3. 状态同步:支持灵活的参数传递和回调处理
  4. 高度复用:一个 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()?.appContext
render(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?.()
})
}

这个版本的亮点在于:

实际使用场景#

现在用起来就简单多了:

基础用法

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 虽然代码不多,但确实解决了我们项目中弹窗管理的大部分痛点。现在团队里的小伙伴都在用,反馈还不错。

当然,这个方案也不是银弹,比如对于一些特别复杂的弹窗交互,可能还是需要传统的方式来处理。但对于大部分场景来说,已经够用了。

最重要的是,它让我们的代码变得更加简洁和可维护。毕竟,能用一行代码解决的事情,为什么要写十行呢?