news 2026/8/6 15:21:19

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
React 用 Portal + Context 实现全局 Toast:一次调用、自动消失与队列管理

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

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

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

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

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

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(用来精确移除)、类型和文案:

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 位置走。

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:

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:

function App() { return ( <ToastProvider> <YourRoutes /> </ToastProvider> ); }

业务里随手就能用:

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 管「谁能调」,数组本身就是那条队列。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/6 15:20:04

SuperRDP完全指南:3步解锁Windows远程桌面多用户并发连接

SuperRDP完全指南&#xff1a;3步解锁Windows远程桌面多用户并发连接 【免费下载链接】SuperRDP Super RDPWrap 项目地址: https://gitcode.com/gh_mirrors/su/SuperRDP 你是否曾经因为Windows远程桌面只能让一个人连接而烦恼&#xff1f;或者作为Windows家庭版用户&…

作者头像 李华
网站建设 2026/8/6 15:18:53

揭秘上海网站建设公司案例背后的真实逻辑与选对团队的避坑指南

在这个数字化浪潮席卷全球的今天,作为一家在上海打拼的企业老板或者市场负责人,你最大的焦虑可能不是产品不够好,也不是服务不够优,而是你的品牌在互联网上“失语”。你明明在业内有着口碑,技术领先,服务贴心,但在百度一搜,连个正经的官网都找不着;好不容易找到了,打…

作者头像 李华
网站建设 2026/8/6 15:14:43

Godot 4回合制游戏:JSON数据驱动角色与宠物动态生成实战

1. 项目概述&#xff1a;为什么选择数据驱动&#xff1f;做回合制游戏&#xff0c;尤其是带有角色收集和养成元素的&#xff0c;最怕的是什么&#xff1f;是策划一拍脑袋想改个数值&#xff0c;程序员就得吭哧吭哧改半天代码&#xff0c;然后重新编译、测试。更怕的是&#xff…

作者头像 李华
网站建设 2026/8/6 15:14:22

2024年网站建设行业资讯深度解析:中小企业如何利用低成本策略实现数字化突围与品牌升级

在这个数字化浪潮席卷全球的时代,很多中小企业主和创业者经常问我一个非常实际的问题:“到底要不要做个网站?做了网站真的有用吗?”说实话,如果是在十年前,我会毫不犹豫地告诉你:必须做,而且要做大的。但站在2024年的节点上,看着周围不断更新的行业资讯,我发现这个问…

作者头像 李华
网站建设 2026/8/6 15:13:32

STM32平衡小车电机驱动实战:TB6612驱动JGB-520直流减速电机

1. 项目缘起&#xff1a;从“会动”到“能站”的第一步 做平衡小车&#xff0c;听起来挺酷&#xff0c;但第一步往往就卡在“让轮子听话地转起来”这个看似简单的问题上。很多新手朋友一上来就想调PID、搞姿态解算&#xff0c;结果发现连最基本的电机正反转、调速都搞不定&…

作者头像 李华
网站建设 2026/8/6 15:12:32

深度解析BIThesis表格与矩阵行间距控制:5种高级调优策略实战指南

深度解析BIThesis表格与矩阵行间距控制&#xff1a;5种高级调优策略实战指南 【免费下载链接】BIThesis &#x1f4d6; 北京理工大学非官方 LaTeX 模板集合&#xff0c;包含本科、研究生毕业设计模板及更多。&#x1f389; &#xff08;更多文档请访问 wiki 和 release 中的手册…

作者头像 李华