同一份弹窗我重构了三次

作者:袖梨 2026-09-04

在前端开发内容学习中,同一份弹窗我重构了三次:从 v-model 地狱到路由式调用,终于治好了模板臃肿是常见主题。很多人在阅读时会遇到概念分散、步骤不清和注意点难以归纳的问题。本文按照基础概念、操作流程和关键细节,对相关内容进行整理。

Vue 弹窗组件的三次架构演进

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

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

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

  1. 最普通的写法el-dialog + v-model + 一堆 visible 变量。
  2. BaseDialog 封装:把显隐逻辑藏进组件,用 trigger 插槽让弹窗“自己打开自己”。
  3. 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>

这种写法能跑,但在复杂业务里会迅速遇到两个致命痛点:

  1. 状态极其臃肿:N 个弹窗 ≈ N 个 xxxVisible 变量,父页面被显隐控制“劫持”,还要处理各种打开前/关闭后的重置逻辑。
  2. 业务(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 样式的一致性)。
  • 如果这个表单以后不当弹窗了,直接平铺在页面里,defineLayerLayerTemplate 会静默降级,只渲染表单本身。

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 的命令式解耦方案才能真正展现威力,彻底拯救你的模板、状态流与组件复用。

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

相关文章

精彩推荐