同一份弹窗我重构了三次:从 v-model 地狱到路由式调用,终于治好了模板臃肿

中后台写久了,弹窗大概是出现频率最高的交互。新建、编辑、审核、拒绝、填个备注------哪次需求里没有它。

同一份「新增用户角色」,在我手里换过三套写法:

  1. 最普通的写法el-dialog + v-model + 一堆 visible
  2. BaseDialog :把 open / close 藏进组件,用 trigger 插槽让弹窗"自己打开自己"
  3. 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 件非常关键的事:

  1. visible 内聚:调用方完全不需要声明任何显隐变量。
  2. trigger 插槽 :按钮和弹窗内聚在同一个组件里,按钮点击时直接通过插槽参数调用 open
  3. 性能懒挂载 :配合 v-if="firstVisible",第一次点击时才挂载 ElDialog DOM 结构,避免一进页面就创建几十个隐藏弹窗。

现在,我们的 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#triggerisActiveopen/close。设计骨架几乎一模一样!

果然,好的设计往往是心有灵犀的 。我们在 Element Plus 的 v-model 地狱里受尽折磨后自己悟出的路子,大洋彼岸的团队早就在框架层面固化为了最佳实践。

而且这里有个有趣的现象:在 npm downloads 上,Vuetify 的周下载量长期在 100 万左右,远超 Element Plus 的 70 万左右

我们在国内做中后台,习惯了把 Element Plus + v-model 当成世界唯一的规则,甚至以为自己的写法才是"主流"。但放眼全球 Vue 生态,Element Plus 的 v-model 弹窗其实才是少数人的写法! 我们以为的"偏门技巧",不过是补上了 Element 没替我们做的这一层而已。


但,第二种写法还留下了什么痛点?

BaseDialog 解决了显隐变量膨胀,但它的核心模型依然是"把 Dialog 容器预埋在触发按钮旁边"。

这在以下场景会撞墙:

  1. 表格行操作区(100 行数据的惨剧) : 如果在表格操作列里,每一行都有"拒绝"、"转款"、"审核"等按钮,每一行都要在模板里渲染多个 BaseDialog 壳。即便有懒加载,Vue 组件树层级依然极其臃肿。
  2. 内容与容器依旧无法解耦AddUserRole.vue 内部写死了 BaseDialog。一旦产品要求"移动端改抽屉,PC端用弹窗",或者"某个表单在其他页面需要直接嵌入",这份代码就拆不掉了。
  3. 没有 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 里不再包含任何 ElDialogBaseDialog 壳,它只管自己的表单数据:

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 强绑定 解耦(既能弹窗,又能平铺嵌在页面)
更换容器 修改业务组件内部代码 修改业务组件内部代码 仅需改变 useDialoguseDrawer
与谁思想契合 Element Plus 官方 Example Vuetify activator 思想 Vue Router 路由导航思想

总结:什么时候停在哪一步?

当然,技术方案没有绝对的优劣,只有场景的适配:

  • 简单且唯一的确认框 :第一次的 v-modelElMessageBox 依然是最快最直观的选择。
  • 页面仅有 1-2 个独立弹窗,且有明确绑定按钮:第二次的 BaseDialog / Vuetify activator 已经足够优雅。
  • 复杂中后台、表格行密集操作、异步/深层调用弹窗、或者同一表单既要弹窗又要嵌入:第三次(vue-layerx 这种命令式解耦方案)才能真正展现威力,彻底拯救你的模板。

演变的本质,是从"把弹窗当成子组件显隐 ",走到了"把弹窗当成一次独立的交互导航"。

你在业务中用过哪种写法?或者有什么更奇妙的弹窗封装技巧?欢迎在评论区一起交流!

相关推荐
悟空瞎说2 小时前
从 CRA 到 Vite:含 Cesium 的真实项目迁移实战记录
前端
悟空瞎说2 小时前
Vite 中零配置接入 Cesium.js:vite-plugin-cesium-engine 深度解析
前端
To_OC2 小时前
后端接口还没交付,前端如何独立把整套业务跑通
前端·react.js·全栈
王琦03183 小时前
WEB服务
前端
霹雳桃3 小时前
Vue3 + Vite 构建版本注入实战:一份 version.json 终结「线上到底是哪一版」
前端
黄金决明子3 小时前
浏览器Window底层操作全解
前端·javascript
ydyd202604213 小时前
设备OEE怎么提升?数据采集+分析优化的完整方案
java·服务器·前端
(╹◡╹)4 小时前
11.RK3588本地大模型内存评估优化
java·linux·前端