前端二次确认弹窗的实战设计与用户体验优化

若彤 安全 阅读 1,530
赞 18 收藏
二维码
手机扫码查看
反馈

又踩坑了,二次确认弹窗被绕过

上周上线一个后台管理功能,用户点击“删除”按钮后要弹出二次确认。结果 QA 测试时直接按回车键就删了,根本没弹窗。我当场懵了——这不就等于没做二次确认?

前端二次确认弹窗的实战设计与用户体验优化

一开始我以为是事件绑定的问题,查了下代码,发现用的是最朴素的写法:

<button onclick="deleteItem()">删除</button>

然后在 deleteItem 里调了个 confirm('确定删除?')。本地测试点鼠标没问题,但 QA 用键盘 Tab 切到按钮再按回车,confirm 虽然弹出来了,但**回车键同时触发了默认的表单提交行为**,导致请求照样发出去了。更糟的是,有些浏览器(比如 Safari)在 confirm 弹窗时回车会直接确认,但页面逻辑已经跑完了。

折腾了半天,发现原生 confirm 根本扛不住

我第一反应是:换掉原生 confirm,用自定义弹窗。于是搞了个基于 Promise 的模态框组件,点击“确认”才 resolve。但问题来了——**怎么阻止原始点击/回车事件继续冒泡?**

试了第一版方案:在按钮上加 event.preventDefault(),但发现如果用户点了“取消”,后续操作就卡住了,因为 preventDefault 把所有默认行为都拦了,包括按钮本身的聚焦状态切换,体验很怪。

又试了第二版:把删除逻辑从按钮 onclick 拆出来,改成先弹窗,弹窗确认后再手动触发删除。但这样代码耦合太强,每个需要二次确认的地方都要写两套逻辑,维护起来头疼。

这里我踩了个大坑:以为只要异步等弹窗结果就行,忽略了**事件上下文丢失**的问题。比如用户快速连点两次,第一次弹窗还没关,第二次又触发了新弹窗,结果两个请求都发出去了。

核心代码就这几行:用指令式封装 + 防重入

最后我搞了个通用的 useConfirm Hook(项目用 React),核心思路是:把“是否允许执行”和“执行动作”解耦,同时加个锁防止重复触发

先看最终用法:

const handleDelete = useConfirm({
  message: '确定要删除这个项目吗?',
  onConfirm: () => api.delete('/item/123')
});

模板里直接绑定:

<button onClick={handleDelete}>删除</button>

实现细节如下(关键部分都写了注释):

import { useState, useCallback } from 'react';

function useConfirm({ message, onConfirm }) {
  const [isConfirming, setIsConfirming] = useState(false);

  const handleClick = useCallback(async (event) => {
    // 防止重复点击:如果正在确认中,直接 return
    if (isConfirming) return;

    // 阻止默认行为(比如表单提交),但只在需要时
    event.preventDefault();
    event.stopPropagation();

    setIsConfirming(true);

    try {
      // 这里用原生 confirm 简化 demo,实际项目用自定义弹窗
      const confirmed = window.confirm(message);
      
      if (confirmed && typeof onConfirm === 'function') {
        await onConfirm();
      }
    } finally {
      // 无论确认还是取消,都要释放锁
      setIsConfirming(false);
    }
  }, [message, onConfirm, isConfirming]);

  return handleClick;
}

但等等,这里还有个隐藏问题:**原生 confirm 是阻塞式的,虽然能防住回车绕过,但在现代 SPA 里体验很差**(整个页面卡住)。所以我后来替换成自定义弹窗,用状态管理:

// 自定义弹窗版本(简化版)
function useConfirm({ message, onConfirm }) {
  const [pendingAction, setPendingAction] = useState(null);

  const showConfirm = useCallback(() => {
    setPendingAction({ message, onConfirm });
  }, [message, onConfirm]);

  const handleConfirm = useCallback(async () => {
    if (pendingAction?.onConfirm) {
      try {
        await pendingAction.onConfirm();
      } finally {
        setPendingAction(null);
      }
    }
  }, [pendingAction]);

  const handleCancel = useCallback(() => {
    setPendingAction(null);
  }, []);

  // 暴露给全局的确认框控制
  window.showGlobalConfirm = showConfirm;

  // 返回一个包装后的点击处理器
  return useCallback((event) => {
    event.preventDefault();
    event.stopPropagation();
    showConfirm();
  }, [showConfirm]);
}

然后在 App 根组件里挂个全局弹窗:

{pendingAction && (
  <CustomModal
    message={pendingAction.message}
    onConfirm={handleConfirm}
    onCancel={handleCancel}
  />
)}

踩坑提醒:这三点一定注意

  • 别忘 stopPropagation:尤其在表格行内有多个操作按钮时,事件冒泡可能导致父级 click 被触发,造成意外行为。
  • 锁必须 finally 释放:我一开始只在 confirm 后设 isConfirming=false,结果用户点取消时锁没释放,按钮就永久 disabled 了。
  • 键盘可访问性:自定义弹窗要处理 Esc 关闭、焦点锁定(trap focus),否则对无障碍用户不友好。不过我们项目暂时没强要求,先用原生 confirm 顶着,后续再优化。

另外,如果你用 Vue 或原生 JS,思路也一样:**拦截原始事件 → 弹窗 → 用户确认后手动执行回调**。关键是那个“锁”机制,不然高频点击会出大事。

不是最优解,但够用

说实话,这套方案在极端情况下还是有瑕疵。比如用户开多个标签页,或者网络超时导致 onConfirm 卡住,锁可能一直不释放。但考虑到我们后台系统并发量低,且删除操作本身幂等,暂时没深究。

也有同事建议用装饰器模式:

@withConfirm('确定删除?')
async function deleteItem() {
  // ...
}

但装饰器在 JS 里还没正式落地,Babel 配置又麻烦,小项目没必要搞这么重。

所以目前这个 hook 方案,改起来快、侵入性低、还能复用,我觉得值了。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如怎么优雅处理异步确认中的错误重试?或者如何统一管理全局确认弹窗的状态?这些我还在摸索中。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论