最新下载
热门教程
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
同一份弹窗我重构了三次
时间:2026-09-04 18:39:51 编辑:袖梨 来源:一聚教程网
在前端开发内容学习中,同一份弹窗我重构了三次:从 v-model 地狱到路由式调用,终于治好了模板臃肿是常见主题。很多人在阅读时会遇到概念分散、步骤不清和注意点难以归纳的问题。本文按照基础概念、操作流程和关键细节,对相关内容进行整理。
Vue 弹窗组件的三次架构演进
中后台写久了,弹窗大概是出现频率最高的交互。新建、编辑、审核、拒绝、填个备注——哪次需求里没有它?
同一份「新增用户角色」的需求,在我手里换过三套写法:
- 最普通的写法:
el-dialog+v-model+ 一堆visible变量。 - BaseDialog 封装:把显隐逻辑藏进组件,用
trigger插槽让弹窗“自己打开自己”。 - vue-layerx 调度:像路由导航一样
open(),内容彻底回归纯粹的普通组件。
这部分内容用同一个业务场景把这三次演变摊开。不是为了证明后一种一定“吊打”前一种,而是把每一步到底在解决什么痛点说清楚——以及为什么我后来发现,第二种看似“自己搞的偏门”写法,其实大有来头;而最终的第三种写法,又是如何解决前面遗留的“死局”的。
这份业务长什么样?
场景很常见:用户角色列表页,点击顶部工具栏的「新增角色」按钮,弹出一个对话框,填写表单,提交成功后刷新列表。
列表页的诉求极其朴素,它只想干一件事:
<!-- UserRoleList.vue -->
<template>
<div class="toolbar">
<!-- 点击弹出新增表单,提交成功后调用 fetchList 刷新 -->
<ElButton type="primary" @click="???">新增角色</ElButton>
</div>
<ElTable :data="list"><!-- ... --></ElTable>
</template>
下面三种写法,都在填补这个 ???。
第一次:最普通的写法(v-model 地狱)
Element Plus 官方文档里的示例,几乎是所有前端人的起点:直接在 UserRoleList 里写 <ElDialog>。当代码膨胀后,为了复用,我们通常会把上面这坨代码连着弹窗壳子一起,抽成一个 UserRoleDialog.vue 组件:
<!-- UserRoleDialog.vue -->
<template>
<ElDialog v-model="visible" title="新增用户角色">
<ElForm :model="form">
<ElFormItem label="角色"><RolePicker v-model="form.roleId" /></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: '' })
// 提交逻辑...
</script>
再在父组件(列表页)里引入并维护状态:
<!-- UserRoleList.vue -->
<template>
<div class="toolbar">
<ElButton type="primary" @click="addVisible = true">新增角色</ElButton>
<UserRoleDialog v-model="addVisible" @submited="fetchList" />
</div>
</template>
这种写法能跑,但在复杂业务里会迅速遇到两个致命痛点:
- 状态极其臃肿:N 个弹窗 ≈ N 个
xxxVisible变量,父页面被显隐控制“劫持”,还要处理各种打开前/关闭后的重置逻辑。 - 业务(UserRole)与壳子(Dialog)死绑:表单逻辑和弹窗 UI 完全写在了一起。如果产品明天要求这个表单直接平铺嵌在某个页面里,或者换成侧边抽屉(Drawer),你只能硬着头皮把文件拆掉重写。
第二次:BaseDialog,让弹窗“自己打开自己”
为了摆脱 visible 的折磨,同时把“业务表单”和“弹窗壳子”解耦,我们在项目中做了一个极其轻量的 BaseDialog 封装:
<!-- BaseDialog.vue -->
<script lang="ts" setup>
import { ref } from 'vue'
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" v-bind="$attrs">
<slot :close="close" />
<slot name="footer" :close="close" />
</el-dialog>
</template>
有了 BaseDialog,我们终于可以把 UserRoleDialog.vue 剥离为一个纯粹的业务组件 UserRole.vue(内部不再有任何 Dialog 代码)。
再,我们直接在列表页里现场组装:
<!-- UserRoleList.vue -->
<template>
<div class="toolbar">
<BaseDialog title="新增用户角色">
<!-- 触发按钮:自己打开自己 -->
<template #trigger="{ open }">
<ElButton type="primary" @click="open">新增角色</ElButton>
</template>
<!-- default 插槽:放纯粹的业务组件 -->
<template #default="{ close }">
<UserRole @submited="() => { fetchList(); close() }" />
</template>
</BaseDialog>
</div>
</template>
父列表页清爽了,不用定义变量,也不用操心组件怎么复用。
戏剧性的一幕:这不就是 Vuetify 吗?
写完这套 BaseDialog 很久后,我去翻看 Vue 生态老牌 UI 库 Vuetify.js 的文档,当场愣住。Vuetify 的 Dialog 交互是这样的:
<v-dialog>
<!-- Vuetify 叫 activator,我们叫 trigger -->
<template #activator="{ props: activatorProps }">
<v-btn v-bind="activatorProps">新增角色</v-btn>
</template>
</v-dialog>
设计骨架几乎一模一样!我们在国内习惯了把 Element Plus + v-model 当成唯一规则,甚至以为自己的改良是“偏门技巧”。但放眼全球,Vuetify 早就在框架层面把这套思想固化为了最佳实践。
但,第二种写法留下的“死局”
BaseDialog 解决了显隐变量,也实现了业务与容器的解耦,但它在真实业务中很快暴露出两个极其难受的痛点:
-
操作按钮位置冲突:
- 保存按钮如果写在 UserRole 内部:表单自带了按钮,脱离了 BaseDialog 原生 Footer 的排版,失去了统一的底部吸附外观,极其难看。
- 保存按钮如果写在外部(BaseDialog 的 footer 插槽):外观统一了,但外层的“提交”按钮想要触发内层 UserRole 的表单校验或展示 loading,就需要通过 ref 进行繁琐的跨组件通信,代码变得无比沉重。
-
表格行操作的妥协与别扭:
- 如果在表格操作列里使用,100 行数据渲染 100 个 BaseDialog 节点显然极度臃肿。
- 为了复用唯一实例解决性能问题,我们往往不得不采用一种很丑陋的解法:把整个
<ElTable>包进 BaseDialog 的 #trigger 插槽里。虽然能解决问题,但“一个弹窗触发器包裹了整个数据列表”,DOM 结构和组件语义变得极度别扭。
第三次:vue-layerx,唤起像路由,内容像组件
为了打破上述死局,我不打算再做任何 UI 容器的封装,而是改变调用模型:
唤起应该像路由导航(
router.push),内容应该像普通组件。 你绝对不会在当前页面的模板里预埋所有可能跳转的子页面,再用v-if控制显隐。弹窗同理,它不该被预埋在组件树里。
基于这个思路,我开源了 vue-layerx。它不替代 Element/Antd 的 UI 容器,只负责调度。
1. 内容部分:彻底解决按钮位置痛点
UserRole.vue 依然是那个剥离了弹窗外壳的纯粹业务组件。最关键的是,它利用 <LayerTemplate> 完美解决了按钮放置的死局:
<!-- UserRole.vue -->
<script setup>
import { ref } from 'vue'
import { defineLayer, LayerTemplate } from 'vue-layerx'
const emit = defineEmits(['submited', 'cancel'])
const form = ref({ roleId: '' })
// 定义弹层元信息:当它被当作弹窗打开时的外壳配置
const layer = defineLayer({
props: { title: '新增用户角色', width: '480px' },
content: { closeOn: ['submited', 'cancel'] }, // 收到这些事件时自动关闭弹窗
})
const submit = () => {
// 处理提交逻辑...
emit('submited')
}
</script>
<template>
<ElForm>
<ElFormItem label="角色"><RolePicker v-model="form.roleId" /></ElFormItem>
</ElForm>
<!-- 传送门:自动将按钮投射到外部弹窗容器的 footer 插槽中 -->
<LayerTemplate :to="layer" name="footer">
<!-- 由于取消是纯关闭弹窗逻辑,我们甚至可以把它放到 Dialog 里,这里可以直接不写 -->
<!-- <ElButton @click="emit('cancel')">取消</ElButton> -->
<ElButton type="primary" @click="submit">提交</ElButton>
</LayerTemplate>
</template>
这里的边界极度清晰:
- 按钮直接写在业务组件内部,可以轻松执行
submit(没有跨组件通信的痛苦)。 - 运行时,
<LayerTemplate>会把这些按钮无缝投射到外层弹窗的 Footer 插槽里(保证了 UI 样式的一致性)。 - 如果这个表单以后不当弹窗了,直接平铺在页面里,
defineLayer和LayerTemplate会静默降级,只渲染表单本身。
2. 调用方:模板零预埋,命令式触发
先统一配置你的容器:
// composables/dialog.ts
import { createLayer } from 'vue-layerx'
import { ElDialog } from 'element-plus'
export const useDialog = createLayer(ElDialog, {
props: { appendToBody: true, destroyOnClose: true }
})
在列表页中,直接当函数调用:
<!-- UserRoleList.vue -->
<script setup>
import { useDialog } from '@/composables/dialog'
import UserRole from './components/UserRole.vue'
// 实例化弹层控制器
const createDialog = useDialog(UserRole, {
props: {
type: 'add',
onSubmited: fetchList
}
})
const editDialog = useDialog(UserRole, {
props: {
type: 'edit',
onSubmited: fetchList
}
})
</script>
<template>
<div class="toolbar">
<!-- 点击直接调 .open(),传入回调 -->
<ElButton type="primary" @click="createDialog.$open()">
新增角色
</ElButton>
</div>
<ElTable :data="list">
<!-- 表格行场景:无论多少行数据,全表只共用 1 个实例 -->
<ElTableColumn label="操作">
<template #default="{ row }">
<ElButton type="text" @click="editDialog.$open({
rowId: row.id,
})">
编辑
</ElButton>
</template>
</ElTableColumn>
</ElTable>
</template>
模板里没有任何 visible 变量,没有 BaseDialog,更没有 UserRole 子组件标签。打开弹窗就是一次轻量的函数调用,体验极其接近 router.push。
三次写法的横向对比
| 维度 | 第一次 (v-model) | 第二次 (BaseDialog) | 第三次 (vue-layerx) |
|---|---|---|---|
| 组件形态 | UserRoleDialog 业务与壳子死绑 | 现场组装,剥离出纯粹的 UserRole | 纯粹的 UserRole 组件 |
| 打开方式 | 修改父组件 visible = true | 点击 #trigger 插槽中的按钮 | 函数命令式 dialog.$open() |
| 显隐状态 | 散落在各个父页面中 | 内聚在 BaseDialog 内部 | 由调度器统一接管 |
| 操作按钮 | 必须写在 UserRoleDialog 壳子里 | 写内破坏 UI,写外难以通信 | <LayerTemplate> 完美投射 |
| 表格行场景 | 每行维护状态,或手写数据同步 | 须将表格包入 #trigger,代码别扭 | 全局/全表共用 1 个调度实例 |
| 核心思想 | Element Plus 官方基础示例 | Vuetify activator 思想 | Vue Router 路由导航思想 |
总结:你应该停在哪一步?
技术方案没有绝对的优劣,只有场景的适配:
- 简单且唯一的确认框:第一次的
v-model或自带的ElMessageBox依然是最快最直观的选择。 - 页面仅有 1-2 个独立弹窗,且有明确绑定按钮:第二次的
BaseDialog已经足够优雅,能屏蔽大部分状态烦恼。 - 复杂中后台、表格行密集操作、跨组件调起弹窗、或者同一表单既要弹窗又要平铺嵌入:第三次基于 vue-layerx 的命令式解耦方案才能真正展现威力,彻底拯救你的模板、状态流与组件复用。
演变的本质,是从“把弹窗当成子组件显隐”,走到了“把弹窗当成一次独立的交互导航”。
相关文章
- 迷你DAYZ手游电锯强度解析迷你DAYZ手游器械武器全面评测 09-04
- 奇门手游内测资格获取渠道汇总奇门手游测试时间与核心玩法详细说明 09-04
- AutoDraw能提高办公效率吗怎么看-事实变化和判断依据 09-04
- reactnaviteJSBridge通信机制、异步通信特点 09-04
- React组件通信详解 09-04
- 手写深拷贝:从JSON到递归,彻底解决循环引用问题 09-04
