做命令式弹窗工具应该考虑什么——我为什么把 vue-layerx 设计成这样

134 阅读16分钟

中后台写多了,弹窗这块的“拧巴”大家应该都不陌生:一页三个 Dialog、三套 visible,表单和容器焊死,想复用又动不了。于是很容易冒出一个直觉的念头——能不能像 ElMessageBox 那样,一行 open() 搞定所有业务弹窗?

我想过,也做过。但做完之后我越来越确信:一行 open() 只是直觉,不是理想。

业务弹层和轻提示(Toast/Message)完全不是一类东西;如果只是粗暴地把“声明式”改成“命令式挂载”,原本的拧巴往往会换一种更隐蔽的形式重新组合。

我是 vue-layerx 的作者。这篇文章不是一篇枯燥的 API 教程,而是想跟掘金的朋友聊透:在设计一个命令式弹窗工具时,我们到底在对抗什么?以及为什么 vue-layerx 最终长成了现在这副样子。

它不是我先画好一套“好看的 API”再倒推理由,而是被各种边界问题死死逼迫、约束一步步收窄解空间后的必然结果。在我看来,理想的形态就是这条由编排和运行时交织而成的约束走廊

下文将沿着这条线展开: 核心痛点 → 唤起为何必须命令式 → 为何要强拆容器与内容 → 编排税 → 运行时税 → 渐进接入 → 收束到 vue-layerx。


一、 问题:我们的拧巴究竟从哪来?

在 Vue 的世界里,“正统”的弹窗写法大概是这样的:

<template>
  <ElDialog v-model="visible" title="编辑用户">
    <UserForm @success="onSuccess" />
  </ElDialog>
</template>

这种写法本身没有错:它和组件树的生命周期一致,好调试,数据流直观。在单个弹窗、就地打开的场景下,它甚至就是最优解。

然而,随着页面变多、变深,噩梦就来了:

  • 样板代码线性膨胀:N 个弹层 ≈ N 套 visible 变量 + N 段重复的模板。
  • 调用位点极其别扭:打开点可能在表格行、甚至极深的子树里,为了开个弹窗,你要么往上狂抛 event,要么把整个 Dialog 塞进子组件(导致组件职责进一步下沉和混乱)。
  • 容器和内容死死焊牢:某天产品要求 UserForm 表单在页内嵌一份、弹层里再开一份,你会发现它已经和 ElDialog 焊在同一个文件里了,根本拆不动。

另一头,Message / MessageBox 这种命令式调用虽然爽,但 UI 和交互基本固定,根本撑不起带校验、带复杂状态的业务表单。

大家常常把这个痛点总结为“声明式样板太多,我要命令式”。我觉得这只说对了一半。

💡 更根本的问题在于:我们长久以来都把「唤起弹层」当成了「渲染一个普通的子组件」。 核心模型选错了,后面怎么打补丁都是拧巴的。


二、 为什么唤起必须做成命令式?

先说核心结论:

业务弹层的唤起,应该做成命令式;而弹层的内容,必须保持普通的声明式组件。 命令式指的是“去打开谁”这个动作,而不是把整棵 UI 树都改成用函数去写。

为什么?因为弹层的唤起,本质上更像路由导航,而不是“在当前节点下多挂载一个子节点”。

写过 Vue Router 的人都习惯把两件事分开:

  1. 页面内容:普通的声明式组件,负责业务 UI。
  2. 导航/唤起:通过 router.push(...)<RouterLink> 这种命令式/半命令式手段触发。

几乎没人会要求:把每个可能跳到的页面,都先在当前父页面的模板里挂好,再用一堆 v-if 去切换“当前在哪一页”。大家都承认,“去哪里”是导航调度问题,不是“多渲染一个子节点”的问题。

弹层其实完美契合这种模型:

维度路由(Vue Router)业务弹层(我主张的模型)
实际内容页面组件(Page)业务内容组件(表单、详情、筛选面板)
承载容器布局组件 / <RouterView>Dialog / Drawer / Popup
唤起方式router.push()layer.open()
当前状态当前路由匹配(Route Context)开没开、当前打开的是哪个内容实例
旁路配置route.meta内容组件旁的层默认声明(如 defineLayer

硬把唤起写进父组件的 v-model,等于把整个 router 的逻辑塞进每一个具体页面。

反过来想:内容层继续当它的普通组件;唤起层像导航一样天然命令式。 这样模型就顺了。

延伸:AI 动态页面带来的爆发压力

这股模型压力在如今的 AI 交互页面上表现得尤为迫切。在对话流中,AI 助手可能随时需要用户确认或者填写表单——开哪个层、什么时候开,完全是运行时根据 AI 的意图动态决定的,而不是页面初始就写死的三个 Dialog。预挂载 + visible 的模式应付固定中后台已经够呛,在面对这种动态唤起的场景时直接瘫痪。命令式调度在这里成了绝对的刚需。

这是我做 vue-layerx 时的第一原则:唤起归导航(命令式),内容归组件(声明式)。


三、 光有 open() 还不够:必须强拆「容器」与「内容」

如果故事停在上一节,我们很容易做出类似 open(UserDialog) 的设计——仅仅是把焊死体换成函数打开。样板代码是少了,但内容复用的问题原封不动

所以下一刀必须切向本质:被打开的那坨东西,到底是什么?

业务弹层里其实一直混杂着两样职责完全不同的东西:

  • 内容(Content):关心业务本身。比如字段、校验、提交接口(如 UserForm)。
  • 容器(Container):关心怎么弹出。比如显隐、遮罩、标题栏、展开动画(如 ElDialog / ElDrawer)。

如果焊在一个 UserDialog.vue 里,短期内最省事。但需求稍微一变就穿帮:

  1. 同一份表单,页内要嵌,弹层也要开 → 内容想复用,却死活拖着一个 Dialog 框。
  2. 同一份内容,PC 端用 Dialog,移动端或窄屏用 Drawer → 改的明明是容器,却不得不去动业务内容文件。

同样的,在 AI 页面下,由于同一块业务 UI 既可能直接嵌在对话气泡流里,也可能以弹层形式出现。宿主在变,内容不该变;如果内容和 Dialog 焊死,就只能被迫维护两份高度相似的代码。

🛠️ 拆开,不是为了多造两个概念,而是为了让“换容器”和“复用内容”变成两件互相独立、互不干扰的事。

这里有一个硬标准:内容组件必须保持纯粹——它应该只认 props in / emits out。无论是页内嵌、对话流还是弹层,它的写法都完全一样,只是宿主在变。

顺带产生了一个红利:项目里通常已经有了成熟的 Element / Antd 或者团队自研的 BaseDialog容器根本不需要重新造,工具也不该强迫项目换栈。我们缺的不是又一个 UI 组件库,而是一个优雅的中间编排层

于是,vue-layerx 的基础形态落成了这样:

import { createLayer } from 'vue-layerx'
import { ElDialog } from 'element-plus'
import UserForm from './UserForm.vue'

// 1. 钉住项目级的容器承载
export const useDialog = createLayer(ElDialog)

// 2. 绑定业务内容
const dialog = useDialog(UserForm)

// 3. 执行唤起
dialog.open()

三步调用,精准对应了三个维度的决策。有人可能会问:为什么不一步到位写成 useDialog(ElDialog, UserForm)

因为“用哪套容器、容器的全局默认表现”通常是项目级全局级的约定;而“打开哪份内容”是具体功能级的绑定。并成一次调用,全局默认就失去了落脚点,也很难沉淀出团队统一的 useDialoguseDrawer 快捷工具。


四、 拆开之后:我们在编排上还要交哪些“税”?

如果工具的设计只讲到 create → use → open,很多开源库也能讲圆。真正决定最终复杂形态的,是拆开之后必须同时满足的编排与契约约束。

1. 三个角色,三处配置的合并

容器与内容拆开后,场上出现了三个角色,而且它们各自都“有资格”提配置要求:

  • 容器/项目级:要求统一宽度、默认点击遮罩不关闭(写在 createLayer 里)。
  • 内容级:这份业务被打开时,天生就应该叫“新建用户”,且提交成功后自动关窗(写在内容组件旁)。
  • 调用方:这次调用比较特殊,临时把标题改改,加宽一点(写在 open() 传参里)。

容器、内容、调用方一拆开,配置就必然落在三处。没有一套严密的合并编排机制,拆分在实际业务中就等同于不可用。

vue-layerx 的配置合并优先级设计非常直观,越靠近“当次执行”权重越高:

open() 当次配置>useDialog 实例配置>defineLayer 内容默认>createLayer 全局/容器默认\text{open() 当次配置} > \text{useDialog 实例配置} > \text{defineLayer 内容默认} > \text{createLayer 全局/容器默认}

2. 内容要能反向配置容器(标题与配置的就近原则)

一份 UserForm 表单,无论在哪个页面被弹起,它的标题通常都是固定的。如果这些配置只能写在调用方的 open({ title: '...' }) 里,配置契约就会散落各处,换个入口调用就容易漏掉。

因此,内容必须有能力声明自己的容器默认表现(标题、操作区插槽等)。vue-layerx 提供了 defineLayer 宏,它只在这块内容被当作弹层打开时才会生效:

<!-- UserForm.vue -->
<script setup>
import { defineLayer } from 'vue-layerx'

defineLayer({
  props: { title: '编辑用户', width: '480px' },
})
</script>

3. 插槽的跨时空投递:不能把 JSX 当作唯一主路径

当内容组件需要往容器的 #footer 插槽投递按钮时,很多库会逼着开发者用 JSX 或 h() 函数去写。但考虑到团队协作,很多人日常只习惯写 SFC 模板,强制使用 JSX 会带来极高的协作割裂感。

为了实现“多人协作 + 模板语法”,vue-layerx 引入了 LayerTemplate。它通过拿到的 layer 上下文,直接把标准 SFC 模板里的一块 UI,魔术般地投递到外部容器的同名插槽中:

<!-- UserForm.vue -->
<script setup>
import { defineLayer, LayerTemplate } from 'vue-layerx'
const layer = defineLayer()
</script>

<template>
  <div class="form-body">...表单主体...</div>
  
  <!-- 自动投递到外部 ElDialog 的 footer 插槽 -->
  <LayerTemplate :to="layer" name="footer">
    <el-button>取消</el-button>
    <el-button type="primary">确定</el-button>
  </LayerTemplate>
</template>

4. 严守边界:不要让弹层成为内容的一等公民

前文钉死了“内容必须是普通组件”。那么提交成功后,内容组件该怎么关闭弹窗?

最直觉的想法是给内容组件注入一个 close() 方法。但我坚决否定了这种做法。

一旦内容内部能够直接调用 close(),弹层就变成了内容的一等公民。这意味着该组件的生命周期与弹层框架强绑定了,它不再是一个能被任意宿主(如页内、对话流)直接复用的普通组件。

💡 合理的逻辑应该是:内容只管做好本职工作并向外“报告业务结果”,而关窗动作是宿主(外部容器)接收到报告后的反应。

在 vue-layerx 中,内容组件只需要纯粹地触发 emit('success')。至于关不关窗,由外层的 closeOn 契约来接线。这同样可以写在 defineLayer 侧配置中,但本体绝对触碰不到 close 实例:

<!-- UserForm.vue -->
<script setup>
import { defineLayer } from 'vue-layerx'

defineLayer({
  content: { closeOn: ['success'] }, // 声明收到 success 事件时自动销毁层
})
const emit = defineEmits(['success'])
</script>

内容组件 emit('success')  ➔  closeOn 契约拦截  ➔  自动触发实例的 close()

这种设计下,open/close 永远属于外部实例,内容组件保持了绝对的纯净与自由。


五、 树在主应用外:运行时还要交的税

当使用命令式调用时,弹层这棵 DOM 树往往脱离了当前页面的渲染树(通常直接挂在 body 下)。这带来了三个逃不掉的运行时问题:

  1. 生命周期:什么时候挂载?关闭(close)和销毁(unmount)怎么优雅分工?
  2. 上下文(Context)漂移:内容组件如果需要 inject 全局的主题、i18n、路由环境,树漂出去后祖先链条断了怎么办?
  3. DevTools 隐形:排查问题时,这些动态挂载的组件在 Vue DevTools 里能不能看得见、点得动?

如果不管这些,设计得再漂亮的 API 都会在真实项目的主题切换、国际化插件或者日常排障中轰然崩塌。

vue-layerx 对此交出了两笔坚实的运行时税:

  • 引入 LayerHost 概念:弹层实例必须知道自己“挂在谁的世界里”。在组件 setup 内创建时,它会自动绑定当前的 Host,从而完美桥接主应用的 appContextprovides上下文不是外挂的 option,而是和挂载生命周期完全长在一起的线。
  • 用独立 createApp 兼顾 DevTools:早期很多库用 render() 函数硬借上下文,导致组件在 DevTools 里直接“隐形”。vue-layerx 后来改成通过独立的 createApp(LayerApp) 进行轻量挂载,并在内部做 Host 属性桥接。既保留了上下文链路,又让整个层树在 DevTools 里清晰可见、可被正常调试。

六、 谈谈真实项目中的“渐进式接入”

前面我们推导的都是理想的目标态:容器与内容解耦、全命令式编排。但现实往往是残酷的——项目里已经堆了大量老的 UserDialog.vue(里面 <el-dialog> 和表单死死粘在一起),短期内根本不可能有排期去重构文件。

对于渐进式接入,我的思考原则是:

  1. 目标态不能妥协:不能为了存量妥协,另立一套背离理想态的永久模型。
  2. 别为存量开辟第二条管线:如果搞出两套类型、两套合并规则,绿地项目和存量老债就会永远分叉,迁移成本反而更高。
  3. 把单体先降级当作“内容”来看待:当老组件的容器和业务粘在一起时,在我们的模型里,它整颗依然只属于 content。我们用一个“无外层容器(no-container)”的标记来跑这条管线。

这样一来,以后有时间把老组件内部的 ElDialog 剥离时,它的角色方向依然是对的。如果一开始错把老组件当成“容器”接入,未来的重构迁移方向就会完全反过来。

// 渐进式接入:直接把未拆分的单体组件当作内容配置定义
import { defineLayer, LayerNoContainer } from 'vue-layerx' 

defineLayer({ 
  component: LayerNoContainer 
})

这种策略允许项目先享受命令式唤起的爽快,有空时再平滑拆分文件,调用方的代码几乎不需要做任何修改。关于这一点的实现细节,我也记录在了文档的 容器与内容未拆分 一章中。


七、 收束:为什么我说这套形态是“推出来的”?

如果我们把前文的所有推导逻辑线平铺叠放在一起:

唤起被写成子组件显隐 ──> 【根源】模型选错了
唤起应该像路由导航 ──> 【解】命令式唤起(open/close)
AI 场景下的高频动态调用 ──> 更加迫切的命令式调度需求
打开的内容要复用/换容器 ──> 【解】强拆容器与内容 (createLayer -> use -> open)
AI 场景下内容进气泡也进弹窗 ──> 更加迫切的容器与内容拆分需求
三个角色同时在场 ──> 【解】必须建立四级配置合并编排机制
标题与基本属性跟内容最紧密 ──> 【解】内容要能配置容器 (defineLayer)
多人协作且拒绝割裂感 ──> 【解】放弃用 JSX 写插槽,引入 LayerTemplate 模板投递
确保内容组件的绝对纯净 ──> 【解】不允许注入 close,依靠 closeOn 契约接线
DOM 树漂在主应用树之外 ──> 【解】LayerHost 统筹上下文,独立 createApp 兼顾 DevTools
存量代码需要低成本过渡 ──> 【解】目标态不变,支持单体当内容共用一条管线接入

市面上很多让我买账不了的“捷径”方案,多半是在其中某一环翻了车:要么是用纯 MessageBox 强撑业务表单;要么是只管 open 却不管上下文丢失和 DevTools 崩溃;要么是图省事在内容里直接调用 close 毁掉了复用性;或者为了迎合插槽主推 JSX 导致团队协作成本飙升。

所以,vue-layerx 目前展现出来的这副样子——工厂函数、Composable、配置合并、LayerTemplatecloseOnbindHost——不是为了显得架构多优雅而刻意为之,而是在上述所有约束同时成立时,解空间被逼进了一条极窄的走廊。

换皮换名都可以,但底层的核心主结构,很难逃出这条推导出来的窄道。


什么时候你不需要用它?

既然是聊架构,就必须诚实地划清边界,否则就成了自嗨的硬广。在以下场景中,你完全不需要上这套工具:

  • 就地一个简单的确认框,和当前页面强绑定,这辈子没有任何复用诉求 ──> 直接用声明式 ElDialog + v-model 最简单、最直观。
  • 只需要固定文案的纯提示或简单确认 ──> 原生的 ElMessageBox 绝对是首选。
  • 弹层本身是个超级复杂的向导,且极度依赖当前页面的局部局部组件树 ──> 建议先评估它是不是真的适合被当成“独立导航”来建模。

vue-layerx 核心狙击的是:在项目中反复出现、有强烈的多宿主复用诉求、且希望在任意调用点(包括动态/异步流中)被一行命令唤起的复杂业务弹层。 这在中后台系统中是家常便饭,而在如今的 AI 交互页面中更是不可或缺。


写在最后

如果这篇文章只能带走一个核心认知,我希望是这一条:

把弹窗的“唤起”当成路由导航去建模(命令式调度),把弹窗的“内容”还给普通的业务组件(声明式渲染)。

如果你也曾深受弹窗样板代码乱飞、组件焊死无法复用的痛苦,不妨拿着文中这几条“税单”去对照一下自己现有的方案。工具可以换,但面对的本质矛盾大家其实都是同一份。

欢迎移步仓库和文档交流: