React 用 Portal + Context 实现全局 Toast:一次调用、自动消失与队列管理

React 用 Portal + Context 实现全局 Toast:一次调用、自动消失与队列管理

做后台系统时,几乎每个页面都要弹提示:保存成功、网络错误、权限不足。如果每个页面都自己维护一个 visible 状态、写一段 setTimeout 关闭,重复代码满天飞,还经常出现「多个提示叠在一起互相覆盖」的 bug。

我们要的效果很朴素:任何组件里,一行 toast.success('保存成功') 就能在页面右上角弹出提示,3 秒后自己消失,多个提示自动排队叠放。这篇就从零把它搭出来,顺带讲清 Portal 和 Context 各自解决的到底是什么问题。

朴素写法的坑:提示被 overflow 裁掉了

先看很多人第一版会怎么写------在页面组件里放一个提示 div:

jsx 复制代码
function Page() {
  const [msg, setMsg] = useState('');
  return (
    <div style={{ overflow: 'hidden' }}>
      {/* 一堆内容 */}
      {msg && <div className="toast">{msg}</div>}
    </div>
  );
}

两个问题立刻暴露:

  1. 提示 div 是页面 DOM 的子节点,一旦某个祖先设了 overflow: hiddentransform,提示要么被裁掉一半,要么定位基准跑偏,position: fixed 都不一定救得回来。
  2. 每个页面都要自己维护 msg 状态和关闭逻辑,换个页面就得重写一遍。

第一个问题交给 Portal :把提示的真实 DOM 渲染到 body 下,彻底逃出祖先的 overflow 和层叠上下文。第二个问题交给 Context :把「弹提示」这个能力做成全局服务,任何组件 useToast() 就能拿到。

第一步:用 Context 承载 toast 队列

先定义状态形状。每条 toast 有唯一 id(用来精确移除)、类型和文案:

jsx 复制代码
import { createContext, useCallback, useContext, useRef, useState } from 'react';

const ToastContext = createContext(null);

let seed = 0; // 模块级自增,保证 id 全局唯一,不依赖数组长度

export function ToastProvider({ children }) {
  const [toasts, setToasts] = useState([]);

  const remove = useCallback((id) => {
    setToasts((list) => list.filter((t) => t.id !== id));
  }, []);

  const push = useCallback((type, message, duration = 3000) => {
    const id = ++seed;
    setToasts((list) => [...list, { id, type, message }]);
    // 到点自动移除;返回 id 方便调用方提前手动关
    setTimeout(() => remove(id), duration);
    return id;
  }, [remove]);

  return (
    <ToastContext.Provider value={push}>
      {children}
      <ToastViewport toasts={toasts} onClose={remove} />
    </ToastContext.Provider>
  );
}

这里有个容易踩的点:id 千万别用 toasts.lengthDate.now() 。数组长度会因为删除而重复;Date.now() 在同一毫秒内连发两条会撞车。用一个模块级自增变量最稳。

第二步:用 Portal 把视图渲染到 body

ToastViewport 负责把所有提示画到屏幕右上角。关键就是 createPortal------它让 React 组件的 DOM 挂到指定容器,但事件冒泡、Context 读取仍按 React 组件树走,而不是按真实 DOM 位置走。

jsx 复制代码
import { createPortal } from 'react-dom';

function ToastViewport({ toasts, onClose }) {
  // SSR 阶段没有 document,直接不渲染,避免 hydration 报错
  if (typeof document === 'undefined') return null;

  return createPortal(
    <div
      style={{
        position: 'fixed', top: 16, right: 16,
        display: 'flex', flexDirection: 'column', gap: 8, zIndex: 9999,
      }}
    >
      {toasts.map((t) => (
        <div
          key={t.id}
          onClick={() => onClose(t.id)}
          style={{
            padding: '10px 16px', borderRadius: 6, color: '#fff', cursor: 'pointer',
            background: t.type === 'error' ? '#e5484d' : '#30a46c',
            boxShadow: '0 4px 12px rgba(0,0,0,.15)',
          }}
        >
          {t.message}
        </div>
      ))}
    </div>,
    document.body // 挂到 body,逃出任何祖先的 overflow/transform
  );
}

多个提示天然「排队叠放」,因为它们只是 flex 列里的兄弟节点,新的往下加,旧的到点自己删------队列管理不用额外写逻辑,数组本身就是队列。

第三步:封装一个好用的 Hook

直接暴露 push(type, message) 不够顺手,包一层语义化 API:

jsx 复制代码
export function useToast() {
  const push = useContext(ToastContext);
  if (!push) {
    // 忘了套 Provider 时,给一个明确报错,而不是让 push 是 null 后面才崩
    throw new Error('useToast 必须在 ToastProvider 内使用');
  }
  return {
    success: (msg, d) => push('success', msg, d),
    error: (msg, d) => push('error', msg, d),
  };
}

那句 throw 很值:漏套 Provider 是高频错误,提前抛一句人话,比等到调用时报 push is not a function 好排查得多。

在应用根部套上 Provider:

jsx 复制代码
function App() {
  return (
    <ToastProvider>
      <YourRoutes />
    </ToastProvider>
  );
}

业务里随手就能用:

jsx 复制代码
function SaveButton() {
  const toast = useToast();
  const onSave = async () => {
    try {
      await api.save();
      toast.success('保存成功');
    } catch (e) {
      toast.error('保存失败:' + e.message);
    }
  };
  return <button onClick={onSave}>保存</button>;
}

一个隐蔽的性能坑:Provider 重渲染拖垮全页

上面 ToastContext.Provider value={push} 传的是 push,它被 useCallback 固定了引用,所以 toasts 变化时,消费 useToast() 的业务组件不会 跟着重渲染------它们只依赖 push,不依赖 toasts

如果图省事把 value={``{ push, toasts }} 一起传下去,那每弹一条提示,全站 useToast() 的组件都会重渲染。把「触发能力」和「列表数据」分开:能力给业务组件,数据只留给 viewport 自己消费。这也是 Context 拆分的通用原则------变化频繁的数据别和稳定的能力混在一个 value 里。

小结

  • Portal 解决「位置」 :把提示 DOM 挂到 body,逃出祖先的 overflow: hiddentransformz-index 层叠陷阱,同时保持 React 树的事件与 Context 语义。
  • Context 解决「触达」:把弹提示做成全局能力,任何组件一行调用,不用层层传 props。
  • 队列 = 数组 :新提示 push 进数组,setTimeout 到点按 id filter 掉,叠放和自动消失都是数组的自然行为。
  • id 用模块级自增 ,别用 length 或时间戳;value 里分离能力与数据 ,避免全站无谓重渲染;SSR 下判空 document,避免 hydration 崩。

一句话记忆:Portal 管「画在哪」,Context 管「谁能调」,数组本身就是那条队列。

相关推荐
嘟嘟07171 小时前
摘要 手把手实现路由鉴权守卫,逐行拆解 ProtectRoute、localStorage、Navigate state、useLocation、FormDat
javascript
HjhIron1 小时前
React Router 通关指南:从入门到鉴权,一个项目全搞定
前端
kyson_1 小时前
如何做一款自适应的数据大屏:`vw/vh`、`rem`、`scale` 应该怎么选?
前端
雪隐1 小时前
WPF + MVVM 实战系列02-告别 INPC 手写时代,做个体面的现代 WPF 人
前端·后端·c#
肉肉不吃 肉1 小时前
无渲染组件和组合式函数
前端·javascript·vue.js
其美杰布-富贵-李1 小时前
01 从项目结构开始认识 Vue 3
javascript·vue.js·ecmascript
未来智慧谷1 小时前
从 Atlas 关停复盘:AI 应用的三种载体形态怎么选(独立应用 / 浏览器扩展 / 桌面宿主)
前端·人工智能·ai·架构·浏览器
sugar__salt1 小时前
前端路由技术详解:从传统多页到 React SPA 的完整进化之路
前端·javascript·react.js·前端框架
Canace2 小时前
Harness Engineering 到底在做什么
前端·人工智能·ai编程