中后台写久了,弹窗大概是出现频率最高的交互。新建、编辑、审核、拒绝、填个备注------哪次需求里没有它。
同一份「新增用户角色」,在我手里换过三套写法:
- 最普通的写法 :
el-dialog+v-model+ 一堆visible - BaseDialog :把
open/close藏进组件,用trigger插槽让弹窗"自己打开自己" - vue-layerx :像路由导航一样
open(),内容回归纯粹的普通组件
这篇文章用同一个业务场景把这三次演变摊开。不是为了证明后一种一定"吊打"前一种,而是把每一步到底在解决什么痛点说清楚------以及为什么我后来发现,第二种看似"自己搞的偏门"写法,其实大有来头。
这份业务长什么样?
场景很常见:用户角色列表页,点击顶部工具栏的「新增角色」按钮,弹出一个对话框,填写角色、用户、战队、新增原因,提交成功后刷新列表。
列表页的诉求极其朴素:
html
<!-- UserRoleList.vue -->
<template>
<div class="toolbar">
<!-- 点击弹出新增表单,提交成功后调用 fetchList 刷新 -->
<ElButton type="primary" @click="???">新增角色</ElButton>
</div>
<ElTable :data="list"><!-- ... --></ElTable>
</template>
下面三种写法,都在完成这同一件事。
第一次:最普通的写法(v-model 模式)
Element Plus 官方文档里的示例,几乎是所有前端人的起点:
html
<template>
<ElButton type="primary" @click="visible = true">新增角色</ElButton>
<ElDialog v-model="visible" title="新增用户角色">
<ElForm :model="form">
<ElFormItem label="角色"><RolePicker v-model="form.roleId" /></ElFormItem>
<ElFormItem label="用户"><UserPicker v-model="form.userId" /></ElFormItem>
<ElFormItem label="战队"><TeamPicker v-model="form.teamId" /></ElFormItem>
<ElFormItem label="原因">
<ElInput v-model="form.remark" type="textarea" placeholder="请输入新增原因" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="visible = false">取消</ElButton>
<ElButton type="primary" @click="submit">提交</ElButton>
</template>
</ElDialog>
</template>
<script setup>
import { ref } from 'vue'
const visible = ref(false)
const form = ref({ roleId: '', userId: '', teamId: '', remark: '' })
async function submit() {
await saveUserRole(form.value)
visible.value = false
emit('submited')
}
</script>
为了复用,我们通常会抽成 AddUserRole.vue 组件,然后把 visible 抛给父组件维护:
html
<!-- UserRoleList.vue -->
<template>
<div class="toolbar">
<ElButton type="primary" @click="addVisible = true">新增角色</ElButton>
<AddUserRole v-model="addVisible" @submited="fetchList" />
</div>
</template>
这种写法能跑,但在复杂业务里会迅速膨胀:
- 状态极其臃肿 :N 个弹窗 ≈ N 个
xxxVisible+ N 个打开前的初始化函数 + N 个关闭后的重置逻辑。 - 父页面被显隐控制"劫持":列表页本来只关心"提交后刷新",却不得不感知每个弹窗的状态变量。
- 深层嵌套很拧巴 :如果触发按钮在表格的操作列内(甚至多层插槽下),你要么把整个
ElDialog塞进每一行重复渲染,要么把状态与数据层层往上$emit。 - 内容与"壳"死绑 :
AddUserRole.vue顶层就是ElDialog,如果这个表单以后需要直接平铺嵌在页面里,或者换成抽屉(Drawer),就必须重构文件。
第二次:BaseDialog,让弹窗"自己打开自己"
为了摆脱各种 visible 的折磨,我们在项目中做了一个非常轻量的 BaseDialog 封装(参考项目 fe-backend-mobius 中的实现):
html
<!-- BaseDialog.vue -->
<script lang="ts">
import { defineComponent, ref } from 'vue'
export default defineComponent({ inheritAttrs: false })
</script>
<script lang="ts" setup>
const visible = ref(false)
const firstVisible = ref(false) // 懒加载标志
const open = () => {
if (!firstVisible.value) firstVisible.value = true
visible.value = true
}
const close = () => { visible.value = false }
defineExpose({ open, close })
</script>
<template>
<!-- 提供 trigger 插槽,把 open 抛给触发按钮 -->
<slot name="trigger" :open="open" />
<el-dialog
v-if="firstVisible"
v-model="visible"
align-center
append-to-body
destroy-on-close
draggable
v-bind="$attrs"
>
<!-- 内容与底部,均暴露 close 方法 -->
<slot :close="close" />
<slot name="footer" :close="close" />
</el-dialog>
</template>
这个小封装干了 3 件非常关键的事:
visible内聚:调用方完全不需要声明任何显隐变量。trigger插槽 :按钮和弹窗内聚在同一个组件里,按钮点击时直接通过插槽参数调用open。- 性能懒挂载 :配合
v-if="firstVisible",第一次点击时才挂载ElDialogDOM 结构,避免一进页面就创建几十个隐藏弹窗。
现在,我们的 AddUserRole.vue 变成了这样:
html
<!-- AddUserRole.vue -->
<script setup>
import { ref } from 'vue'
import BaseDialog from '@/components/BaseDialog.vue'
const emit = defineEmits(['submited'])
const form = ref({ roleId: '', userId: '', teamId: '', remark: '' })
const refreshForm = () => {
form.value = { roleId: '', userId: '', teamId: '', remark: '' }
}
const submit = () => {
return saveUserRole(form.value).then(() => {
ElMessage.success('新增成功')
refreshForm()
emit('submited')
})
}
</script>
<template>
<BaseDialog title="新增用户角色">
<!-- 触发按钮:自己打开自己 -->
<template #trigger="{ open }">
<ElButton type="primary" @click="open">新增角色</ElButton>
</template>
<!-- 主体表单 -->
<template #default>
<ElFormItem label="角色"><RolePicker v-model="form.roleId" /></ElFormItem>
<ElFormItem label="用户"><UserPicker v-model="form.userId" /></ElFormItem>
<ElFormItem label="战队"><TeamPicker v-model="form.teamId" /></ElFormItem>
<ElFormItem label="原因">
<ElInput v-model="form.remark" type="textarea" placeholder="请输入新增原因" />
</ElFormItem>
</template>
<!-- 底部操作按钮 -->
<template #footer="{ close }">
<ElButton @click="() => { close(); refreshForm() }">取消</ElButton>
<ElButton type="primary" @click="() => submit().then(() => close())">提交</ElButton>
</template>
</BaseDialog>
</template>
而父列表页,彻底清爽了:
html
<!-- UserRoleList.vue -->
<template>
<div class="toolbar">
<AddUserRole @submited="fetchList" />
</div>
</template>
不用定义变量、不用预埋弹窗标签,父组件只需要倾听业务事件。
戏剧性的一幕:这不就是 Vuetify 吗?!
写完这套 BaseDialog 很长一段时间后,我去翻看 Vue 生态老牌 UI 库 Vuetify.js 的文档,直接当场愣住。
Vuetify 里的 Dialog 交互是这样的:
vue
<v-dialog max-width="500">
<!-- Vuetify 叫 activator,我们叫 trigger -->
<template #activator="{ props: activatorProps }">
<v-btn v-bind="activatorProps">新增角色</v-btn>
</template>
<template #default="{ isActive }">
<v-card title="新增">
<!-- 表单内容 -->
<v-card-actions>
<v-btn @click="isActive.value = false">取消</v-btn>
</v-card-actions>
</v-card>
</template>
</v-dialog>
#activator 对 #trigger,isActive 对 open/close。设计骨架几乎一模一样!
果然,好的设计往往是心有灵犀的 。我们在 Element Plus 的 v-model 地狱里受尽折磨后自己悟出的路子,大洋彼岸的团队早就在框架层面固化为了最佳实践。
而且这里有个有趣的现象:在 npm downloads 上,Vuetify 的周下载量长期在 100 万左右,远超 Element Plus 的 70 万左右。
我们在国内做中后台,习惯了把 Element Plus + v-model 当成世界唯一的规则,甚至以为自己的写法才是"主流"。但放眼全球 Vue 生态,Element Plus 的 v-model 弹窗其实才是少数人的写法! 我们以为的"偏门技巧",不过是补上了 Element 没替我们做的这一层而已。
但,第二种写法还留下了什么痛点?
BaseDialog 解决了显隐变量膨胀,但它的核心模型依然是"把 Dialog 容器预埋在触发按钮旁边"。
这在以下场景会撞墙:
- 表格行操作区(100 行数据的惨剧) : 如果在表格操作列里,每一行都有"拒绝"、"转款"、"审核"等按钮,每一行都要在模板里渲染多个
BaseDialog壳。即便有懒加载,Vue 组件树层级依然极其臃肿。 - 内容与容器依旧无法解耦 :
AddUserRole.vue内部写死了BaseDialog。一旦产品要求"移动端改抽屉,PC端用弹窗",或者"某个表单在其他页面需要直接嵌入",这份代码就拆不掉了。 - 没有 trigger 的纯命令式唤起 : 比如"异步请求成功后弹窗"、"根据 WebSocket 消息弹窗"------模板里根本没有按钮,你只能额外定义一个
ref去手动调.open(),#trigger显得无比多余。
第三次:vue-layerx,唤起像路由,内容像普通组件
第三次演变,我不打算再做任何 UI 容器的封装,而是改变调用模型:
唤起应该像路由导航(
router.push),内容应该像普通组件(Component)。 你绝对不会在当前页面的模板里预埋所有可能跳转的子页面,再用v-if控制显隐。弹窗同理,它不该被预埋在组件树里。
基于这个思路,我开源了 vue-layerx。它不替代 Element/Antd 的 UI 容器,只负责调度:
ts
const useDialog = createLayer(ElDialog) // 注册你喜欢的 UI 容器
const dialog = useDialog(HelloWorld) // 绑定普通业务组件
dialog.open() // 命令式唤起
我们用 vue-layerx 重构同一份业务:
1. 内容部分:回归纯粹的业务表单
AddUserRole.vue 里不再包含任何 ElDialog 或 BaseDialog 壳,它只管自己的表单数据:
vue
<!-- AddUserRole.vue -->
<script setup>
import { ref } from 'vue'
import { defineLayer, LayerTemplate } from 'vue-layerx'
const emit = defineEmits(['submited', 'cancel'])
const form = ref({ roleId: '', userId: '', teamId: '', remark: '' })
// 定义弹层元信息:当它被当作弹窗打开时的配置
const layer = defineLayer({
props: { title: '新增用户角色', width: '480px' },
content: { closeOn: ['submited', 'cancel'] }, // 收到这些事件时自动关闭弹窗
})
const submit = () => {
return saveUserRole(form.value).then(() => {
ElMessage.success('新增成功')
emit('submited')
})
}
</script>
<template>
<ElForm>
<ElFormItem label="角色"><RolePicker v-model="form.roleId" /></ElFormItem>
<ElFormItem label="用户"><UserPicker v-model="form.userId" /></ElFormItem>
<ElFormItem label="战队"><TeamPicker v-model="form.teamId" /></ElFormItem>
<ElFormItem label="原因">
<ElInput v-model="form.remark" type="textarea" placeholder="请输入新增原因" />
</ElFormItem>
</ElForm>
<!-- 传送门:自动将按钮投射到弹窗容器的 footer 插槽中 -->
<LayerTemplate :to="layer" name="footer">
<ElButton @click="emit('cancel')">取消</ElButton>
<ElButton type="primary" @click="submit">提交</ElButton>
</LayerTemplate>
</template>
设计边界极度清晰:
defineLayer仅在弹层模式生效 :如果这份表单直接当成普通组件<AddUserRole/>嵌入页面,defineLayer会静默忽略,不会凭空多出 Dialog。- **业务组件不感知
close()**:点击取消/提交只需emit事件,关不关窗由closeOn策略控制。 - 模板语法投射插槽 :借助
<LayerTemplate>,你可以用熟悉的 Vue Template 语法将底部按钮传送至弹窗的footer区域,无需写烦人的 JSX/Render 函数。
2. 调用方:模板零预埋
ts
// Composables/dialog.ts (统一配置)
import { createLayer } from 'vue-layerx'
import { ElDialog } from 'element-plus'
export const useDialog = createLayer(ElDialog, {
props: { appendToBody: true, destroyOnClose: true }
})
在列表页中:
vue
<!-- UserRoleList.vue -->
<script setup>
import { useDialog } from '@/composables/dialog'
import AddUserRole from './components/AddUserRole.vue'
// 实例化弹层控制器
const addUserRoleDialog = useDialog(AddUserRole)
</script>
<template>
<div class="toolbar">
<!-- 点击直接调 .open(),传入回调 -->
<ElButton type="primary" @click="addUserRoleDialog.open({
props: { onSubmited: fetchList }
})">
新增角色
</ElButton>
</div>
<ElTable :data="list">
<!-- 表格操作列场景:整表共用一个 rejectDialog 实例 -->
<ElTableColumn label="操作">
<template #default="{ row }">
<ElButton type="text" @click="rejectDialog.open({
props: { userWithdrawId: row.id, onSubmited: fetchList }
})">
拒绝提现
</ElButton>
</template>
</ElTableColumn>
</ElTable>
</template>
模板里没有 visible,没有 AddUserRole 标签,更没有 Dialog 预埋。打开弹窗就是一次轻量的函数调用,体验极其接近 router.push!
对于表格行等场景,表格不管包含多少行,全局/全表都只共用同一个弹层调度实例,打开时动态传入参数即可,模板和内存占用都极其干净。
三次写法的横向对比
| 维度 | 第一次 (v-model) | 第二次 (BaseDialog) | 第三次 (vue-layerx) |
|---|---|---|---|
| 打开方式 | 修改父组件 visible = true |
点击 #trigger 插槽中的按钮 |
函数命令式 dialog.open() |
| 显隐状态 | 散落在各个父页面中 | 内聚在 BaseDialog 内部 | 由调度器统一接管 |
| 模板预埋 | 必须在模板中预埋 <ElDialog> |
必须在模板中预埋 <BaseDialog> |
零预埋,点击时按需调度 |
| 表格行场景 | 每行维护状态,或手写数据同步 | 每行绑定一个 BaseDialog 实例 | 全局/全表共用 1 个实例 |
| 内容复用性 | 与 Dialog 强绑定 | 与 BaseDialog 强绑定 | 解耦(既能弹窗,又能平铺嵌在页面) |
| 更换容器 | 修改业务组件内部代码 | 修改业务组件内部代码 | 仅需改变 useDialog 为 useDrawer |
| 与谁思想契合 | Element Plus 官方 Example | Vuetify activator 思想 |
Vue Router 路由导航思想 |
总结:什么时候停在哪一步?
当然,技术方案没有绝对的优劣,只有场景的适配:
- 简单且唯一的确认框 :第一次的
v-model或ElMessageBox依然是最快最直观的选择。 - 页面仅有 1-2 个独立弹窗,且有明确绑定按钮:第二次的 BaseDialog / Vuetify activator 已经足够优雅。
- 复杂中后台、表格行密集操作、异步/深层调用弹窗、或者同一表单既要弹窗又要嵌入:第三次(vue-layerx 这种命令式解耦方案)才能真正展现威力,彻底拯救你的模板。
演变的本质,是从"把弹窗当成子组件显隐 ",走到了"把弹窗当成一次独立的交互导航"。
你在业务中用过哪种写法?或者有什么更奇妙的弹窗封装技巧?欢迎在评论区一起交流!