从"包裹器"到"确认按钮"——一个组件的三次重构
背景
后台管理系统中,"危险操作需要二次确认"是最高频的交互模式。表格操作列的删除、禁用,批量操作的批量删除,详情页的注销账号——这些场景都需要 tooltip 提示 + popconfirm 确认 + 按钮三者配合。
用 Ant Design Vue 原生写法,每个地方都要写三层嵌套 + 手动互斥控制:
<a-tooltip :visible="popVisible ? false : undefined" title="删除该记录">
<a-popconfirm v-model:visible="popVisible" title="确定删除?" @confirm="onDelete">
<a-button icon="delete" danger />
</a-popconfirm>
</a-tooltip>
ButtonConfirm 就是为了消灭这段重复代码而生的。
V1:slot 包裹器(89bb3e2)
设计思路: 做一个通用包裹器,用 slot 接收任意子元素,外面套上 tooltip 和 popconfirm。
<dbButtonConfirm needConfirm confirmContent="确定删除?" tooltip="删除">
<a-button type="primary" danger>删除</a-button>
</dbButtonConfirm>
Props:
needConfirm:默认false,需要手动开启disabled:独立的禁用状态- 无按钮相关属性,按钮由 slot 传入
模板结构: 4 个 v-if 分支处理 tooltip/popconfirm 的组合:
1. tooltip && needConfirm && !disabled → tooltip > popconfirm > span > slot
2. needConfirm && !disabled → popconfirm > span > slot
3. tooltip → tooltip > span(@click) > slot
4. else → span(@click) > slot
问题:
needConfirm默认false——组件叫"确认按钮",却默认不确认- 按钮通过 slot 传入,组件无法控制按钮的事件链
<span>包裹导致布局问题@click事件会冒泡穿透,绕过 popconfirm 确认流程
V2:内置 Button + @click 防穿透(1030048)
核心改进: 不再用 slot 包裹外部按钮,改为内置 dbButton 渲染。
<!-- V1: slot 包裹 -->
<dbButtonConfirm needConfirm confirmContent="确定删除?">
<a-button danger>删除</a-button>
</dbButtonConfirm>
<!-- V2: 内置 Button,继承全部按钮属性 -->
<dbButtonConfirm danger confirmContent="确定删除?" @confirm="onDelete">
删除
</dbButtonConfirm>
为什么必须内置 Button?
因为只有控制了按钮本身,才能从机制上解决 @click 穿透问题:
inheritAttrs: false—— 阻止外部属性直接落到内部元素safeAttrscomputed —— 过滤掉所有on开头的事件监听器- 开发环境
console.error—— 检测到@click时提醒开发者用@confirm
移除 needConfirm prop: 通过 confirmContent 是否存在自动推导——有内容就确认,没有就不确认。理由是"组件名叫确认按钮就必须确认"。
模板结构简化为 2 个分支:
1. tooltip → tooltip > popconfirm > Button
2. else → popconfirm > Button
解决的问题:
- 消灭了
<span>包裹,按钮渲染正确 @click被彻底屏蔽,只能通过@confirm接收回调- 继承 dbButton 全部能力(type/danger/icon/size/appearance 等)
- API 表面更简洁,一个组件替代三层嵌套
遗留问题:
- 移除
needConfirm后,无法动态控制"这次点击要不要弹确认框" - 需要确认和不需要确认的场景,开发者被迫用
v-if/v-else在dbButton和dbButtonConfirm之间切换
V3:handleVisibleChange 拦截模式(f9d404c)
核心改进: 重新引入 needConfirm prop,但默认值改为 true,且实现方式完全不同。
V1 vs V3 的 needConfirm:
| V1 | V3 | |
|---|---|---|
| 默认值 | false(需要手动开启) | true(默认就确认) |
| 实现方式 | v-if 控制是否渲染 popconfirm | handleVisibleChange 拦截是否弹出 |
false 时行为 | 点击 span 直接 emit | 拦截 popconfirm 弹出,直接 emit |
关键设计:参考 antd 官方的 visibleChange 模式
const handleVisibleChange = (visible: boolean) => {
if (!visible) {
confirmVisible.value = false
return
}
if (props.needConfirm) {
confirmVisible.value = true // 正常弹出确认框
} else {
emits('confirm') // 跳过确认,直接触发
}
}
popconfirm 始终存在于 DOM 中,但通过 handleVisibleChange 在弹出瞬间拦截。needConfirm: false 时,popconfirm 根本不会展示,直接走 @confirm 回调。
解决了什么实际问题?
同一个按钮,根据业务状态动态决定是否需要确认:
<!-- 一个组件覆盖两种情况,无需 v-if/v-else -->
<dbButtonConfirm
icon="delete"
danger
:needConfirm="record.status !== 'draft'"
confirmContent="确定删除该记录?"
@confirm="onDelete(record)"
/>
草稿状态点击直接删除,已发布状态弹确认框。同一个 @confirm 回调,业务只需控制一个布尔值。
三个版本的对比
V1(包裹器)
┌──────────────────────────────┐
│ dbButtonConfirm │
│ ├─ tooltip (可选) │
│ ├─ popconfirm (可选) │
│ └─ <span> │
│ └─ <slot> ← 外部按钮 │ ← 无法控制事件链
└──────────────────────────────┘
V2(内置 Button)
┌──────────────────────────────┐
│ dbButtonConfirm │
│ ├─ tooltip (可选) │
│ ├─ popconfirm (始终渲染) │
│ ├─ safeAttrs (过滤 @click) │
│ └─ <Button> ← 内置渲染 │ ← 完全控制事件链
└──────────────────────────────┘
V3(handleVisibleChange)
┌──────────────────────────────┐
│ dbButtonConfirm │
│ ├─ tooltip (可选) │
│ ├─ popconfirm (始终渲染) │
│ ├─ handleVisibleChange │ ← 拦截弹出,动态决定流程
│ ├─ safeAttrs (过滤 @click) │
│ └─ <Button> ← 内置渲染 │
└──────────────────────────────┘
最终运行时流程
点击按钮
│
▼
needConfirm?
│
├── true ──► 弹出 popconfirm
│ │
│ ┌────┴────┐
│ ▼ ▼
│ 确认 取消
│ │ │
│ ▼ ▼
│ emit confirm emit cancel
│
└── false ──► 直接 emit confirm
设计总结
| 迭代 | 关键决策 | 解决的问题 |
|---|---|---|
| V1 | slot 包裹任意元素 | 基础功能可用 |
| V2 | 内置 Button + inheritAttrs: false | @click 防穿透、消灭 span 包裹 |
| V3 | handleVisibleChange 拦截 | 一个组件覆盖"需确认"和"不需确认"两种场景 |
最终的 dbButtonConfirm 是一个真正的按钮组件,不是包裹器。它继承了 dbButton 的全部能力,内置了 tooltip/popconfirm 互斥处理和 @click 防穿透机制,通过 needConfirm 动态控制确认流程,让开发者用一个组件、一个 @confirm 回调覆盖所有操作按钮场景。