普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月26日首页

从"包裹器"到"确认按钮"——一个组件的三次重构

作者 刀疤
2026年3月26日 17:24

从"包裹器"到"确认按钮"——一个组件的三次重构

背景

后台管理系统中,"危险操作需要二次确认"是最高频的交互模式。表格操作列的删除、禁用,批量操作的批量删除,详情页的注销账号——这些场景都需要 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 && !disabledtooltip > popconfirm > span > slot
2. needConfirm && !disabledpopconfirm > span > slot
3. tooltiptooltip > span(@click) > slot
4. elsespan(@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 穿透问题:

  1. inheritAttrs: false —— 阻止外部属性直接落到内部元素
  2. safeAttrs computed —— 过滤掉所有 on 开头的事件监听器
  3. 开发环境 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-elsedbButtondbButtonConfirm 之间切换

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> ← 内置渲染    │  ← 完全控制事件链
└──────────────────────────────┘

V3handleVisibleChange)
┌──────────────────────────────┐
│ 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 回调覆盖所有操作按钮场景。

❌
❌