在Next中实现全局轻量提示
一般项目中都会用到提示组件Toast,很多项目可能直接引入antd等库了。但是我当时只是一个小项目,我就懒得在Next项目中引,心里想这才几行代码呀,很多样式网上还是现成的,这不是十几分钟就搞定了,看我搞一个。结果发现踩了不少坑......特此记录。
1、初步方案
创建 Toast 组件 (Toast.tsx)
'use client';
import { useEffect } from 'react';
import { IoCheckmarkCircle, IoCloseCircle, IoInformationCircle } from 'react-icons/io5';
interface ToastProps {
message: string;
type?: 'success' | 'error' | 'info';
duration?: number;
onClose: () => void;
}
export default function Toast({ message, type = 'info', duration = 2000, onClose }: ToastProps) {
useEffect(() => {
const timer = setTimeout(() => {
onClose();
}, duration);
return () => clearTimeout(timer);
}, [duration, onClose]);
const icons = {
success: <IoCheckmarkCircle className="w-5 h-5" />,
error: <IoCloseCircle className="w-5 h-5" />,
info: <IoInformationCircle className="w-5 h-5" />,
};
const colors = {
success: 'bg-green-500',
error: 'bg-red-500',
info: 'bg-blue-500',
};
return (
<div className="fixed top-4 right-4 animate-slide-in" style={{ zIndex: 99999 }}>
<div
className={`${colors[type]} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 min-w-[280px] max-w-md`}
>
{icons[type]}
<span className="flex-1">{message}</span>
<button onClick={onClose} className="hover:opacity-70 transition-opacity" aria-label="关闭">
✕
</button>
</div>
</div>
);
}
支持 3 种类型:success、error、info,固定在右上角显示默认 2 秒自动消失,带图标、关闭按钮和滑入动画效果。
是吧,就这么点代码,这不轻轻松松。开开心心用上,然后就发现bug了:页面如果进行了跳转再提示,无法看到toast的提示。
思考了一下,如果都从这个页面跳到别的页面去了,还想要提示的话,就得使用全局状态管理或者URL 参数来传递消息。
2、改进方案
1.0版本
想了想我先用URL参数来传递消息,主要是实现简单,无需复杂状态管理,加一个ToastManage.tsx写一点代码就行了。
核心代码:
useEffect(() => {
// 检查 URL 参数
const checkUrlParams = () => {
const message = searchParams.get('toast');
const type = searchParams.get('toast_type') as 'success' | 'error' | 'info' | null;
if (message) {
setToast({
message,
type: type || 'info',
});
// 清除 URL 参数(不刷新页面)
const url = new URL(window.location.href);
url.searchParams.delete('toast');
url.searchParams.delete('toast_type');
window.history.replaceState({}, '', url.toString());
return true;
}
return false;
};
}, [pathname]); // 添加 pathname 依赖,确保路由变化时重新检查
挺好的,就是用了一段时间后,发现新的问题:**有的需求并不是跳转到固定某个页面,而是返回上一级,也就是调用router.back(),无法携带参数!**所以又没办法提示了。得了,又得改进。
2.0版本
想了想在不大改的情况下使用 sessionStorage 应该是最简单的了,流程如下:
(1) 通过 sessionStorage 保存消息;
(2) router.back() 返回到上一页 ;
(3) ToastManager 读取 sessionStorage 并显示 Toast;
(4) 为了保留兼容性,留下检查 URL 参数的代码。
(5) 没有跳转的,直接显示 Toast,这个最简单。
然后写完刚一测试,就发现新bug:如果用户直接浏览器输入地址,从一个C页面跳到B页面进行操作(按正常步骤应该从A页面进入B页面)。操作以后成功返回上一页但没有消息提示,然后点进A页面又突然跳出来了提示。
毕竟我们永远无法预知用户的神奇操作。
不过也理清了逻辑漏洞:sessionStorage 会一直存在直到被读取,所以如果返回的上一页没有 ToastManager,消息就会保留到下一个有 ToastManager 的页面才显示。应该改进逻辑,让消息在返回后立即显示,而不是依赖目标页面的 ToastManager。
核心代码如下:
useEffect(() => {
// 检查 sessionStorage(用于 router.back() 场景)
const checkSessionStorage = () => {
const sessionToast = sessionStorage.getItem('toast');
if (sessionToast) {
try {
const parsed = JSON.parse(sessionToast);
if (parsed.message) {
setToast({
message: parsed.message,
type: parsed.type || 'info',
});
// 立即清除,避免在其他页面再次显示
sessionStorage.removeItem('toast');
return true;
}
} catch {}
}
return false;
};
// ...
// 优先检查 sessionStorage,然后检查 URL 参数
if (!checkSessionStorage()) {
checkUrlParams();
}
}, [searchParams,pathname]); // 添加 pathname 依赖,确保路由变化时重新检查
用了一段时间后,发现还有bug:**有时候还是看不到提示,又不好定位原因。**看来该优化一个最终版本了。
3、最终版本
为了实现全局、跨页面的通知,加入事件总线并进行订阅,便于进行统一的、稳定的消息提示。优点就是任意位置都能发送通知,无需在组件树中层层传递 props,实现轻量,易于维护。
src/app/components/Toast.tsx — 单个 Toast 展示组件(客户端),如上。
src/app/components/ToastManager.tsx — Toast 管理器,负责在应用中监听并渲染 Toast,订阅事件总线 + 监听 window 事件,以及读取 URL 上的 toast/toast_type 参数 (记得把 ToastManager 在 layout 根节点引入)
'use client';
import { useEffect, useState } from 'react';
import { useSearchParams, usePathname } from 'next/navigation';
import Toast from './Toast';
import { subscribe, ToastPayload } from '@/lib/toastBus';
export default function ToastManager() {
const searchParams = useSearchParams();
const pathname = usePathname();
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'info' } | null>(null);
useEffect(() => {
const handleToast = (payload: ToastPayload | null) => {
if (payload?.message) {
setToast({ message: payload.message, type: (payload.type as 'success' | 'error' | 'info') || 'info' });
}
};
// 检查 URL 参数
const message = searchParams.get('toast');
const type = searchParams.get('toast_type') as 'success' | 'error' | 'info' | null;
if (message) {
handleToast({ message, type: type || 'info' });
// 清除 URL 参数(不刷新页面)
const url = new URL(window.location.href);
url.searchParams.delete('toast');
url.searchParams.delete('toast_type');
window.history.replaceState({}, '', url.toString());
}
// 监听全局总线与 window 事件
const onPayload = (payload: ToastPayload) => handleToast(payload);
const unsubscribe = subscribe(onPayload);
const onWindowToast = (e: Event) => handleToast((e as CustomEvent<ToastPayload>).detail);
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
window.addEventListener('app:toast', onWindowToast as EventListener);
}
return () => {
unsubscribe();
if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') {
window.removeEventListener('app:toast', onWindowToast as EventListener);
}
};
}, [searchParams, pathname]);
if (!toast) return null;
return <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />;
}
src/lib/toastBus.ts — 简易全局事件总线,提供 subscribe 和 emit:
'use client';
export type ToastType = 'success' | 'error' | 'info';
export type ToastPayload = { message: string; type?: ToastType };
type Listener = (payload: ToastPayload) => void;
const listeners = new Set<Listener>();
export function subscribe(listener: Listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function emit(payload: ToastPayload) {
listeners.forEach((l) => l(payload));
// 通过 window 事件广播,确保跨组件边界可靠传递
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') {
try {
window.dispatchEvent(new CustomEvent<ToastPayload>('app:toast', { detail: payload }));
} catch {
// 忽略异常
}
}
}
目标:保证无论消息通过哪种方式触发(内存总线、window 事件、或导航后的 URL 参数),都能被统一渲染成 Toast。
4、使用示例
- 通过事件总线,在任意客户端文件中发布提示:
import { emit as showToast } from '@/lib/toastBus';
showToast({ message: '保存成功', type: 'success' });
- 通过导航携带(导航后目标页面显示并清除参数)
const target = new URL('/posts', window.location.origin);
target.searchParams.set('toast', '删除成功');
target.searchParams.set('toast_type', 'success');
router.replace(target.pathname + target.search);
- 外部触发(等价,通常由
toastBus.emit做桥接)
window.dispatchEvent(new CustomEvent('app:toast', { detail: { message: '外部触发', type: 'info' } }));
5、结语
其实还有更多可以优化的地方,比如:
- Toast 支持 duration,不过 ToastManager 当前未将 payload 中的 duration 字段透传,可在 toastBus 的 ToastPayload 中添加 duration 字段,并在 ToastManager 传入 Toast。
- 样式当前使用 Tailwind CSS,若需要主题化或动画,还要抽出样式。
- 可以把 toast 封装成快捷 API(例如
toast.success())。 - ......
谁能想到,我一开始就是写一个简单的,不用想太多的提示组件呢......果然学无止境到处都是知识点。