Toast轻提示组件的实现原理与最佳实践

俊凤 Dev 组件 阅读 2,080
赞 14 收藏
二维码
手机扫码查看
反馈

又踩坑了,Toast 被遮挡还无法自动关闭

前几天在项目里加了个 Toast 轻提示,本来以为就是调个 UI 库的事,结果上线前测试时发现:有些页面弹出 Toast 后根本看不见,或者点了按钮后 Toast 一直挂在那里不消失。折腾了快一小时才搞定,这里记录下踩的几个坑。

Toast轻提示组件的实现原理与最佳实践

第一个坑:z-index 不够高,被 modal 盖住了

我一开始直接用的是团队封装的 Toast 组件,调用方式很简单:

Toast.show('操作成功');

但在一个带弹窗(modal)的页面里,Toast 弹出来后完全看不见。我第一反应是“是不是没触发?”,结果打开 DevTools 一看,DOM 元素明明在,就是被 modal 的背景层盖住了。

查了下 CSS,发现 modal 的 z-index 是 1000,而 Toast 的 z-index 只有 999。改!直接把 Toast 的 z-index 改成 9999,问题解决。但后来想想,这其实是个隐患——如果以后有人写了个 z-index 10000 的组件,Toast 又会被盖住。所以更好的做法是统一管理全局层级:

/* 全局 z-index 层级规范 */
.toast-layer {
  z-index: 10000;
}
.modal-overlay {
  z-index: 9000;
}
.dropdown {
  z-index: 8000;
}

虽然有点“暴力”,但至少可控。不过这个不是今天重点,真正让我头疼的是下一个问题。

第二个坑:Toast 没自动关闭,因为页面切换了

我们有个表单提交流程,用户点“提交”后显示“提交成功”,然后自动跳转到结果页。但测试发现,在某些低端机上,Toast 刚出来还没消失,页面就跳走了,导致 Toast 的定时器没执行,残留的 DOM 一直挂在 body 上。

我一开始的实现是这样的:

function showToast(message) {
  const div = document.createElement('div');
  div.className = 'toast';
  div.textContent = message;
  document.body.appendChild(div);

  setTimeout(() => {
    div.remove();
  }, 2000);
}

看起来没问题,对吧?但问题在于:如果在这 2000ms 内页面跳转(比如用 location.href 或 React Router 跳转),这个 setTimeout 的回调可能不会执行,或者执行时 DOM 已经被销毁,div.remove() 报错或无效。

我试过在 beforeunload 里清理,但单页应用(SPA)里根本不会触发这个事件。后来想到:能不能在组件卸载时手动清理?但 Toast 是全局的,和任何组件生命周期无关。

核心代码就这几行:用 WeakMap + 主动清理

折腾了半天,最后决定:每次创建 Toast 实例时,都把它存起来,然后提供一个 clearAllToasts() 方法,让路由跳转前可以手动清掉。

但更优雅的做法是——用一个全局的 Toast 管理器,内部维护一个实例列表,并在每次新 Toast 出现时自动清除旧的(或者保留多个,看需求)。我们项目里只需要一个 Toast,所以我做了个单例模式:

// toast.js
let currentToast = null;

export function showToast(message, duration = 2000) {
  // 先清除上一个
  if (currentToast) {
    clearTimeout(currentToast.timer);
    currentToast.element?.remove();
  }

  const element = document.createElement('div');
  element.className = 'toast';
  element.textContent = message;
  document.body.appendChild(element);

  const timer = setTimeout(() => {
    element.remove();
    currentToast = null;
  }, duration);

  currentToast = { element, timer };
}

// 如果需要强制清除(比如路由跳转前)
export function clearToast() {
  if (currentToast) {
    clearTimeout(currentToast.timer);
    currentToast.element?.remove();
    currentToast = null;
  }
}

这样,不管页面怎么跳,只要在路由守卫里调一下 clearToast(),就能确保干净。比如在 React 中:

useEffect(() => {
  return () => {
    clearToast(); // 组件卸载时清理
  };
}, []);

或者在 Vue 的 beforeRouteLeave 里调用。亲测有效,再也没出现残留问题。

顺便处理了第三个小问题:多次快速点击,Toast 闪烁

用户手快,连续点三次按钮,结果 Toast 闪三次,体验很差。其实上面的 currentToast 逻辑已经解决了这个问题——因为每次都会先删掉旧的,所以不会堆叠。但如果你允许多个 Toast 同时存在(比如顶部、底部各一个),就得用数组管理了:

const toasts = [];

export function showToast(message, duration = 2000) {
  const element = document.createElement('div');
  element.className = 'toast';
  element.textContent = message;
  document.body.appendChild(element);

  const timer = setTimeout(() => {
    element.remove();
    const index = toasts.findIndex(t => t.element === element);
    if (index > -1) toasts.splice(index, 1);
  }, duration);

  toasts.push({ element, timer });
}

export function clearAllToasts() {
  toasts.forEach(t => {
    clearTimeout(t.timer);
    t.element?.remove();
  });
  toasts.length = 0;
}

不过我们项目不需要这么复杂,单例就够了。

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

  • z-index 要足够高且统一管理,别临时改,容易冲突
  • 定时器必须可取消,否则 SPA 跳转时会残留 DOM
  • 快速重复调用要防抖或覆盖,别让用户看到“鬼畜”效果

另外,别忘了给 Toast 加个 pointer-events: none,避免挡住下面的按钮。我就吃过这个亏,Toast 消失前用户点不了其他地方,还以为页面卡了。

.toast {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: rgba(0, 0, 0, 0.7);
  color: white;
  padding: 8px 16px;
  border-radius: 4px;
  z-index: 10000;
  pointer-events: none; /* 关键! */
}

改完后测试了几轮,基本稳了。虽然还有个小问题:如果用户在 Toast 显示时锁屏,回来后可能看不到(因为已经超时移除了),但这属于边缘情况,产品说可以接受。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如有没有用 requestAnimationFrame 做更精确的控制?或者用 Web Component 封装?我都想听听。

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

暂无评论