中后台写多了,弹窗这块的拧巴大家应该都不陌生:一页三个 Dialog、三套 visible,表单和容器焊死,想复用又动不了。于是很容易冒出一个念头------能不能像 ElMessageBox 那样,一行 open() 搞定?
我想过,也做过。做完之后越来越确信:一行 open() 只是直觉,不是理想。 业务弹层和轻提示不是一类东西;若只把声明式改成命令式挂载,拧巴往往会换一种形式回来。
我是 vue-layerx 的作者。这篇文章不是 API 教程,而是想跟掘金的朋友讲清楚:做命令式弹窗工具时该考虑什么,以及------为什么 vue-layerx 会变成现在这副样子。 它不是我先画好一套「好看的 API」再找理由,而是问题逼着走、约束一步步收窄解空间的结果。
在我看来,理想形态就是这条约束走廊(编排 + 运行时都算在内)。下文按这条线走:
问题 → 唤起为何必须命令式 → 为何还要拆容器与内容 → 编排税 → 运行时税 → 渐进接入 → 收束到 vue-layerx。
一、问题:拧巴从哪来?
Vue 里弹窗的「正统」写法大概是这样:
vue
<template>
<ElDialog v-model="visible" title="编辑用户">
<UserForm @success="onSuccess" />
</ElDialog>
</template>
这没问题:和组件树一致,好调试,数据流直观。单个弹窗、就地打开时,它甚至就是最优解。
一多、一深,问题就来了:
- 样板线性膨胀 :N 个弹层 ≈ N 套
visible+ N 段模板 - 调用位点别扭:打开点在表格行、很深的子树里时,要么往上抛事件,要么把 Dialog 塞进子组件(职责更乱)
- 容器和内容焊死 :
UserForm想页内嵌一份、弹层里再开一份,却已经和ElDialog焊在同一个文件里了
另一头,Message / MessageBox 调用很爽,但 UI 和交互基本固定,撑不起带校验、带复杂状态的业务表单。
痛点常被总结成「样板太多,要命令式」。我觉得这只说对了一半。
更根本的问题是:我们一直把「唤起弹层」当成「渲染一个子组件」。 模型选错了,后面怎么补丁都拧。
二、为什么唤起必须做成命令式?
先说结论,再讲理由:
业务弹层的唤起,应该做成命令式;内容仍然是普通的声明式组件。
命令式的是「去打开谁」,不是把整棵 UI 都改成函数。
为什么?因为弹层的唤起,本质上更像路由导航,而不像「多挂一个子节点」。
写过 Vue Router 的人都习惯两件事分开:
- 页面内容:普通组件,负责业务 UI
- 导航 / 唤起 :
router.push(...),或<RouterLink>这种糖
几乎没人会要求:每个可能跳到的页面,都先在当前父组件模板里挂好,再用一堆 v-if 切换「当前在哪一页」。那会极其拧巴------我们都承认,「去哪里」是导航问题,不是「多渲染一个子节点」的问题。
弹层其实更接近这种模型:
| 路由 | 弹层(我主张的) | |
|---|---|---|
| 内容 | 页面组件 | 业务内容(表单、详情、筛选面板) |
| 承载容器 | 布局 / RouterView |
Dialog / Drawer / Popup |
| 唤起 | push / Link |
open() |
| 「当前状态」 | 现在在哪条路上 | 开没开、打开的是谁 |
| 旁路默认 | route meta 等 | 内容旁的层默认(如 defineLayer) |
硬把唤起写进父组件的 v-model,等于把 router 塞进每一个父页面------弹层一多、调用点一深,必然拧巴。反过来:
内容层继续当普通组件;唤起层像导航一样,天然适合命令式。
声明式 Dialog 不是错,就像有的界面从不需要 router.push。它适合「就地、简单、和当前页强绑定」的场景。但当我们讨论的是可复用的业务弹层时,还把唤起当成子组件显隐,模型就选错了。
这股压力在 AI 页面 上往往更急------这里说的是另一层,和后面要讲的「内容拆分」不是一回事:对话里助手可能随时要你确认、填表;开哪个层、何时开,经常是运行时才决定的 ,不是页面写死的三个 Dialog。预挂 + visible 应付固定中后台已经够呛,动态唤起时更拧。命令式调度在这里几乎是刚需。
这是我做 vue-layerx 时的第一原则:
唤起归导航(命令式),内容归组件(声明式)。
三、光有 open() 不够:还要拆「容器」和「内容」
上一节只回答了「唤起该不该命令式」。若停在这里,很容易做成:open(UserDialog)------把焊死体换种方式打开。样板是少了,复用问题原封不动。
所以下一刀必须问:被打开的那坨东西,到底是什么?
业务弹层里其实一直混着两样职责完全不同的东西:
| 关心什么 | 例子 | |
|---|---|---|
| 内容 | 业务本身:字段、校验、提交 | UserForm、详情面板、筛选区 |
| 容器 | 怎么弹出:显隐、遮罩、标题栏、动画 | ElDialog、ElDrawer、项目 BaseDialog |
焊在一个 UserDialog.vue 里时,短期最省事。需求稍一展开就拧:
- 同一份表单,页内要嵌、弹层里也要开 → 内容想复用,却拖着 Dialog 走
- 同一份内容,有时 Dialog、有时 Drawer → 改的是容器,却不得不动内容文件
中后台里,这已经值得拆。到了 AI 页面 ,拆分会更急------注意,这和上一节说的「动态唤起」是另一层 压力:同一块业务 UI,既可能嵌在对话流 里,也可能进弹层。宿主在变,内容不该变;和 Dialog 焊死,等于逼你维护两份。
两股压力叠在一起时,走廊更窄:动态唤起 逼你把调度做成命令式;多宿主复用逼你把容器和内容拆开。只做其中一件,各种场景里很快会再撞墙。
这和路由是一个道理:没人会把 Layout 组件和内容组件焊成一个不可拆的组件再到处 push。
拆开,不是为了多两个名词,而是让「换容器」和「复用内容」变成两件独立的事。
不拆,工具的意义就只是少写了几个
visible。
硬标准也一并钉死:内容必须仍是普通组件------props in / emits out,页内、对话、弹层同一套写法,只是宿主在变。若内容仍偷偷依赖「我在 Dialog 里」,拆分就只是拆了文件。
顺带一个红利(注意:是红利,不是拆分的理由):项目里已经有 Element / Ant / 自研 BaseDialog,容器不用造、也不该换栈。缺的不是又一个 Modal 框架,而是中间那层编排。
于是 vue-layerx 落成这样:
ts
import { createLayer } from 'vue-layerx'
import { ElDialog } from 'element-plus'
import UserForm from './UserForm.vue'
export const useDialog = createLayer(ElDialog)
const dialog = useDialog(UserForm)
dialog.open()
三步对应三个决策:
createLayer(容器):用哪套承载(以及容器侧默认)useDialog(内容):打开哪份业务「页面」open():执行唤起
有人会问:为什么不写成 useDialog(ElDialog, UserForm) 一步到位?也能写。但「用哪套容器、容器侧默认是什么」通常是项目级 约定;「打开哪份内容」是功能级 绑定。并成一次调用,项目默认没地方钉,也难沉淀出稳定的 useDialog / useDrawer。所以我让 createLayer 先钉容器,再 use 钉内容,最后 open 做当次唤起。
四、拆开之后:编排上还要交哪些税
如果故事到「create → use → open」为止,很多库也能讲圆。真正决定形态的,是拆开之后还必须同时成立的约束。
这一节先谈编排契约 (配置、插槽、组件怎么协作);下一节再谈树跑在主应用外之后的运行时契约。编排上大致是:先有三处配置要合并 → 再问默认跟谁走 → 再问插槽怎么写;最后补一条------内容和层之间仍须是普通的 props / emit,不能让弹层变成内容的一等公民。
1. 三角色 → 三处配置 → 必须合并
拆开之后,场上有三个角色,各自都「有资格」写配置:
| 角色 | 典型会写什么 |
|---|---|
| 容器 / 项目 | 默认宽度、统一是否点遮罩关闭(createLayer) |
| 内容 | 这份业务打开时的标题、closeOn(defineLayer) |
| 调用方 | 当次改标题、临时加宽(use / open) |
默认标题跟内容走,项目要统一宽度,当次还得能盖------「标题到底听谁的」就变成真问题。
容器、内容、调用方一拆开,配置就必然落在至少三处;没有合并编排,拆分等于没法用。
合并不是我故意把 API 搞复杂,而是拆分之后几乎逃不掉的税。vue-layerx 里越靠近「这一次打开」优先级越高:
text
open(当次)
> useDialog 第二参(创建实例时)
> defineLayer(跟内容走的默认)
> createLayer(容器 / 项目级默认)
合并解决了「听谁的」。紧接着还有一问:默认本身该写在谁旁边?
2. 标题和操作区跟内容最紧 → 内容要能配容器
一份 UserForm 作为弹层打开时,标题叫什么、footer 里有哪些按钮,几乎总是跟这份内容 绑在一起,而不是跟某一个调用页绑在一起。若这些只能写在 open({...}) 或调用方模板里,契约会散落,换入口就漏、就漂。
所以拆开之后,内容必须能配置容器侧表现------至少包括:
- 标题等 props 默认
- 操作区等插槽 (确定 / 取消往往在容器的
#footer)
内容与标题、操作按钮联系最紧,因此内容要能配置容器的标题和插槽。
调用方最多覆盖,不该成为这些默认的唯一来源。
先看标题这一侧:vue-layerx 用内容旁的 defineLayer 声明容器默认,只在它被当作弹层内容打开时生效:
vue
<script setup>
import { defineLayer } from 'vue-layerx'
defineLayer({
props: { title: '编辑用户', width: '480px' },
})
</script>
<template>
<p>...表单...</p>
</template>
操作区还要进容器的 #footer,但内容里已经没有 ElDialog,不能再直接写 <template #footer>------插槽怎么投递,下一小节单独说。
3. 插槽的主路径不能靠 JSX
内容要往容器插槽投递时,实现上有人会用 JSX / h() 去拼 footer。技术上完全可行,我没把它当主路径:弹层是高频改操作区的地方;团队里很多人日常只写 SFC 模板,再强制 JSX 等于两套写 UI 的方式,协作成本上去。
内容要配容器插槽之后;
多人协作 + 弹层高频插槽,又否掉了「用 JSX 凑合」。
所以主路径必须是大家熟悉的模板语法。vue-layerx 里用 LayerTemplate:layer 来自 defineLayer(),把一块 UI 投到容器的同名插槽:
vue
<script setup>
import { defineLayer, LayerTemplate } from 'vue-layerx'
const layer = defineLayer()
</script>
<template>
<p>...表单...</p>
<LayerTemplate :to="layer" name="footer">
<button>确定</button>
</LayerTemplate>
</template>
这里只解决「按钮出现在容器 footer」;点了确定之后如何关层,是下一小节的组件契约问题。LayerTemplate 本身不是为了多一个概念,而是否掉 JSX 之后的落点。
4. 不要让弹层成为内容的一等公民
第三节钉死了:内容必须仍是普通组件。那它和「弹层框架」怎么协作?
答案其实很朴素------和其他任何使用这份内容的父组件一样:传入数据,接收事件(props in / emits out)。
提交成功就关窗,写 close() 很直觉。可一旦内容内部能直接操作 close,弹层就变成了内容的一等公民:完成路径不再是「向父级报告业务结果」,而是「调用层的生命周期」。内容不再是可被任意宿主使用的普通组件,而是寄生在弹层上的组件------第三节的硬标准当场作废。
所以:
框架 / 宿主使用内容的方式,必须与其它使用方相同。
关层属于唤起层(instance);内容只 emit 业务事件。弹层用
closeOn决定哪些事件触发关层------那是宿主策略,不是内容在调环境。
多宿主只是这条契约的自然结果:同一声 success,弹层选择关,对话选择留在气泡里刷新,页内选择提示成功------内容侧写法不变。
落到代码上:内容只负责 emit('ok');默认哪些事件该关,仍可跟内容走------在 defineLayer 里声明 closeOn(与标题一样,是外侧配置),本体拿不到 close:
vue
<script setup>
import { defineLayer } from 'vue-layerx'
defineLayer({
content: { closeOn: ['ok'] },
})
const emit = defineEmits(['ok'])
</script>
<template>
<p>...表单...</p>
<button @click="emit('ok')">确定</button>
</template>
(确定按钮若在容器 footer,仍用上一节的 LayerTemplate 投递;关层接线本身与「按钮画在哪」无关。)
text
content.emit('ok') → closeOn 接线 → instance.close()
因此:open / close 属于实例;defineLayer 不提供 close。
五、树在主应用外:运行时还要交的税
编排谈完了。命令式还有一件事没法绕开:弹层这棵树,往往不在 当前页面组件的子树里------挂到 body、另起一小段运行时,都是常见做法。于是至少要同时回答三件事:
- 生命周期 :何时挂上、关了是否卸掉、再
open是否重挂(close与unmount不是一回事) - 上下文 :内容仍要
inject主题、i18n 等,树漂出去之后链不能断 - DevTools:排障时层树最好看得见、点得动
它们和上一节「内容不调 close」不是一类问题------后者是组件契约,这里是树漂在主应用外之后的运行时契约。只设计了漂亮的 open()、却不管这些,真实项目会先在主题、插件或排障上翻车。
vue-layerx 里,我把前两笔税收在一起想,而不是各做一套补丁。
LayerHost:生命周期与上下文的交汇点。 弹层实例需要知道「挂在谁的世界里」------这个宿主侧的 setup 实例,就是 Host:带着 appContext / provides,也决定了内容 inject 能接到哪条链。useLayer 在组件 setup 里创建时会自动绑当前 Host;模块顶层单例则要在 App / ConfigProvider 子树里手动 bindHost() 一次。打开、关闭、卸载,都围绕这份 Host 绑定来跑------上下文不是外挂选项,而是和挂载生命周期绑在同一条线上的。
用 createApp 解决 DevTools。 早期用 render() 硬借主应用 context,层树在 DevTools 里常常看不见,或选中就报错。后来改成独立 createApp(LayerApp) 挂载,DevTools 里能看到 LayerApp / LayerView;同时通过 Host 桥接 provides(保留这棵小 App 自己的身份,而不是整棵偷走主应用),这样 inject 不断、DevTools 也可用。细节见仓库 changelog。
运行时走廊收窄之后,形态上就会出现:
bindHost/ Host 桥接、close与unmount分工、以及createApp挂载层树------不是多出来的 API,而是这三笔税交完之后的落点。
六、如何思考渐进式接入
前面推的是目标态:容器 × 内容已拆、唤起命令式、编排与运行时税交齐。真实项目里却常见另一面------UserDialog.vue 里已经嵌死 <el-dialog>,短期内拆不开文件,调用方却已经想 open() / close()。
渐进式接入,我是这么想的:
- 目标态不变。 最终仍应是
BaseDialog + UserForm;渐进不是另立一套「永久正确」的模型。 - 不要为存量再开一条管线。 若搞
createLayerUnited、两套类型、两套合并规则,绿场和存量会永远分叉,迁移成本反而更大。 - 单体先当「内容」,而不是当「容器」。 容器和业务还粘在一起时,整颗旧组件在模型里仍是 content;用「无容器」表示外面不再包第二层容器。这样以后拆出
UserForm,角色方向是对的------若一开始把UserDialog建成容器,迁移会反着来。 - 先命令式,再拆文件。 同一套
use/open(必要时加 adapter)先让唤起用起来;等有空再把容器和内容拆开,调用方尽量少改。
所以 vue-layerx 的渐进路径不是「降低理想」,而是:在共用一条走廊的前提下,允许暂时走「无外层容器 + 单体当内容」。 这不是对理想的背叛,而是接入策略。
至于拍平渲染、为何没有对称的「无内容」标记等实现细节,是另一段设计故事,感兴趣可看 容器与内容未拆分。
七、收束:为什么我说这是「推出来的」
把整条线叠在一起:
text
唤起被当成子组件显隐 → 问题出在模型
唤起应像导航 → 命令式 open/close(内容仍声明式)
AI:弹层常动态唤起 → 命令式调度更紧迫
打开对象要复用、要换容器 → 拆成容器 × 内容 → create / use / open
AI:内容要进对话也进弹层 → 容器 × 内容拆分更紧迫
三角色拆开 → 三处配置 → 必须合并
标题/操作区跟内容最紧 → 内容要能配容器(标题 + 插槽)
插槽主路径 → 用模板投递,不靠 JSX
内容保持普通组件 → 与宿主只 props/emit;不注入 close
树在主应用外 → LayerHost 统筹生命周期与上下文;createApp 兼顾 DevTools
存量要能渐进 → 目标态不变;单体先当内容;共用一条管线
我买账不了的捷径,多半卡在其中一环:纯 MessageBox 撑业务、父页焊死 Dialog、只 open 焊死体、另造 Modal 逼迁移、每次 use(容器, 内容) 钉不住项目默认、只让调用方写标题/footer、主推 JSX 填插槽、内容里直接 close()、不管上下文与 DevTools、为存量另开管线或把单体建成「容器」导致迁移反着来。
所以 vue-layerx 现在这副样子------工厂、composable、实例、配置合并、LayerTemplate、closeOn、bindHost / unmount------不是为了显得架构优雅,而是上述约束同时成立时,走廊已经很窄了。
在「唤起命令式、内容可复用、不换已有容器、编排可合并、插槽跟内容走、运行时可桥接可调试、存量可渐进」一起成立时,我认为这就是解,而不是众多风格里随便挑的一种。换皮换名可以,主结构很难逃出这条走廊。
什么时候不必上这套
说最优解,也要说边界,否则像硬广。
- 就地一个确认框、和当前页强绑定、没有复用诉求 → 声明式
ElDialog+v-model往往更简单 - 只要固定文案的确认 / 提示 →
MessageBox就够 - 「弹层」其实是复杂多步向导且强依赖当前页插槽树 → 先评估是不是真该当「导航」建模
- 弹层全是页面写死的、内容也从不进对话或其他宿主 → 前面说的两股紧迫性没那么高,不必为概念而拆
vue-layerx 针对的是:反复出现、想在多种宿主里复用、又想在任意(含动态)调用点打开的业务弹层------中后台如此,AI 页面往往更甚。换场景硬套,一样会拧巴。
写在最后
如果只记住这条线:
- 问题:唤起被写成了子组件显隐。
- 主张:唤起必须命令式(像路由);内容仍是普通组件。动态唤起会把这层再打急。
- 推进:拆容器 × 内容(多宿主再打急拆分);交编排税(合并、内容配容器、模板插槽、props/emit);交运行时税(LayerHost、createApp / DevTools);渐进接入(目标态不变、单体先当内容)。
- 落地:vue-layerx 是按这条走廊推出来的,不是先画 API 再补故事。
若你也中过弹窗样板和焊死复用的毒,不妨拿这张清单对照自己的方案:是在解唤起模型,还是只在换一种写法堆样板。
文档与仓库:
- GitHub:vue-layerx
- 文档:简介 · 设计决策 · 实例与 bindHost · 用模板填写插槽 · 容器与内容未拆分
- 掘金:企业级命令式弹窗方案:三行代码适配已有 Dialog