前端知库

怎样实现一个全局 Toast/Message 组件?

全局提示要能在任意代码里一行调用,所以把“提示队列”做成和界面无关的 store,提供 show、close 和 subscribe,再由一个挂在根节点的容器订阅它并渲染。还要处理同时显示的数量上限、自动关闭与悬停暂停、重复关闭,以及用 role="status" 让读屏软件播报。

参考答案

简答

点“保存”以后右上角冒出“保存成功”,三秒后自动消失,这就是 Toast。它和普通组件最大的不同是调用方式:业务代码希望在任何地方写一行 toast.success('保存成功') 就能弹出来,包括接口拦截器这种根本不在组件里的地方,而不是每个页面都放一个组件、自己管显示状态。

所以我会把它拆成两层。一层是和界面无关的 store,里面存着当前要显示的提示列表,对外提供 show、close 和 subscribe;另一层是挂在应用根节点的容器组件,订阅 store,列表一变就重新渲染。show 返回一个 id,调用方可以拿它提前关闭,比如“上传中”在上传完成后换成“上传成功”。

细节上要处理几件事:同时显示的条数要有上限,多出来的排队;自动关闭的计时在鼠标悬停时暂停;关闭要能重复调用而不出错;位置用固定定位,层级要高于页面内容和弹窗。

无障碍也要考虑。提示区域用 role="status",读屏软件会在用户空闲时播报,而且这个容器要在页面加载时就存在,不能和内容一起插入。错误提示不要几秒就消失,用户可能来不及读,重要的错误应该留着让用户手动关闭。

为什么要把状态放在组件外面

如果 Toast 是普通组件,就得在每个页面里放一个,用 useState 控制显示。可是接口拦截器里的统一错误提示、工具函数里的提示,都拿不到组件的状态。

所以把提示列表放进一个独立的 store。它只是普通的 JS 对象,任何地方都能 import 调用。界面层只负责订阅它、渲染它。这样调用方不关心界面用的是 React 还是 Vue,测试 store 时也不需要渲染任何东西。

先写与界面无关的队列

下面的 store 最多同时显示 max 条,多出来的进等待队列,有一条关闭就从队列里补上一条。

JavaScript
// 与界面无关的 Toast 队列:最多同时显示 max 条,多出来的排队
function createToastStore({ max = 3 } = {}) {
  let nextId = 1;
  let visible = [];
  const waiting = [];
  const listeners = new Set();

  const notify = () => listeners.forEach((listener) => listener(visible));

  function show(message, { type = 'info', duration = 3000 } = {}) {
    const toast = { id: nextId++, message, type, duration };
    if (visible.length < max) visible = [...visible, toast];
    else waiting.push(toast);
    notify();
    return toast.id; // 返回 id,调用方可以提前关闭
  }

  function close(id) {
    const before = visible.length;
    visible = visible.filter((toast) => toast.id !== id);
    if (visible.length === before) return; // 已经关闭过,忽略重复调用
    if (waiting.length > 0) visible = [...visible, waiting.shift()];
    notify();
  }

  function subscribe(listener) {
    listeners.add(listener);
    return () => listeners.delete(listener);
  }

  return { show, close, subscribe };
}

const store = createToastStore({ max: 2 });
let current = [];
store.subscribe((list) => { current = list; });

const first = store.show('保存成功', { type: 'success' });
store.show('网络较慢');
store.show('已复制');
console.log(current.map((toast) => toast.message).join('|')); // 保存成功|网络较慢

store.close(first);
store.close(first); // 重复关闭不会多放出一条
console.log(current.map((toast) => toast.message).join('|')); // 网络较慢|已复制

上限是 2,所以第三条“已复制”进了等待队列,第一次输出只有两条。关闭第一条后,“已复制”被补上来。

为什么要特意处理重复关闭?因为一条提示可能同时被自动计时器和用户点击关闭。如果 close 不检查,第二次调用也会从等待队列里多放出一条,显示的条数就超过上限了。

visible 每次变化都生成新数组,而不是原地修改。这是为了让界面层能用引用比较判断是否需要重新渲染,React 的 useSyncExternalStore 就要求快照在没有变化时保持同一个引用,见《useSyncExternalStore 为什么要求稳定的快照?》。

界面层怎样订阅和渲染

在应用根节点放一个容器,订阅 store,用 Portal 渲染到 body 下,避免被某个父元素的 overflow: hidden 裁掉:

JSX
function ToastViewport() {
  const toasts = useSyncExternalStore(subscribeToasts, getToasts);
  return createPortal(
    <div role="status" className="toast-viewport">
      {toasts.map((toast) => (
        <ToastItem key={toast.id} toast={toast} onClose={() => closeToast(toast.id)} />
      ))}
    </div>,
    document.body,
  );
}

ToastViewport 要在应用启动时就渲染,即使列表是空的。原因在后面的无障碍部分。

自动关闭的计时放在每一条 ToastItem 里:挂载时按 duration 开始计时,到时调用 onClose;鼠标移入或键盘焦点进入时暂停,离开时继续;卸载时清掉定时器。duration 为 0 表示不自动关闭。

层级是一个常见问题。Toast 要显示在弹窗上方,可是原生 dialog 用 showModal() 打开后处在浏览器的顶层(top layer),普通元素的 z-index 再大也盖不住它。一种办法是让 Toast 容器也用 Popover API,showPopover() 会把元素放进顶层,popover="manual" 不会因为点击外部或按 Esc 被关掉,也不会关掉别的 popover。顶层元素按进入的先后叠放,所以弹窗打开以后再显示的 Toast 会盖在弹窗上面。弹窗的挂载方式见《弹窗组件怎样设计?》。

读屏用户能知道提示出现了吗

视觉用户看到右上角冒出一条提示,读屏用户却可能完全不知道。所以容器要设成 live region,内容变化时读屏会自动播报。

role="status" 自带 aria-live="polite",读屏会等用户空闲时再播报,适合“保存成功”这类提示。要注意,MDN 说明读屏一般只播报 live region 里内容的变化,最可靠的方式是让容器在初始页面里就存在,再往里面添加内容。如果每次弹出 Toast 时才创建带 role="status" 的元素,第一条提示很可能不会被读出来。这也是为什么 ToastViewport 要一开始就渲染。

紧急的错误可以用 role="alert",它会打断当前播报。不要给 Toast 移动焦点,否则会打断用户正在做的操作。live region 的更多细节见《aria-live 怎样用于动态状态播报?》。

自动消失的时长也是无障碍问题。WCAG 的“时间可调整”要求,内容设定的时间限制要能关闭、调整或延长。所以包含重要信息或操作按钮(比如“撤销”)的提示,不要几秒就消失,至少要在悬停或获得焦点时暂停,错误信息最好一直显示到用户手动关闭。

常见追问

同一条提示被连续触发很多次怎么办?

比如网络断开时每个请求都报“网络异常”,屏幕上会叠一堆一样的提示。可以给 show 增加一个 key 参数,相同 key 的提示已经在显示时,不新增,只更新内容或重新开始计时。

Toast 和 Modal 怎样选?

Toast 用于不需要用户回应的反馈,比如“已保存”“已复制”,它不打断操作,过一会儿自己消失。需要用户确认或做选择的,比如“确定删除吗”,要用弹窗,因为用户必须回应才能继续。错误如果需要用户处理,也不应该只用一个几秒就消失的 Toast。