命令式 Toast 在组件展示系统里重复弹出——Provider 多次挂载是根因
在给组件库写文档展示页时,我遇到了一个诡异的现象:点一下「触发 Toast」按钮,屏幕上同时弹出了两个一模一样的通知。再点一次,又是两个。把 states 里的 demo 多写几条,数量变成了三个、四个……
问题不在 Toast 组件本身,而在于文档展示系统的渲染机制和命令式 overlay 的工作方式之间存在一个致命的错配。
现象
一个基于 createToastManager() 的命令式 Toast,通过 toast({ title, tone }) 在任意位置触发。组件展示页中将 <ToastProvider> 直接写在 states[].render() 里,每个 demo state 内部都包含 Provider+Viewport。
结果:点击触发按钮,Toast 弹出 2–N 个完全相同的副本,叠在右上角。
根因
命令式 overlay 与声明式 overlay(Tooltip / Dialog)有本质区别:
- 声明式:Provider、触发器、内容在同一棵 JSX 子树里,各自独立,showcase 可以在
state.render()里返回完整的<Tooltip><Trigger/><Content/></Tooltip>,互不干扰。 - 命令式:业务代码调用
toast(...)函数,背后订阅的是一个全局 pub/sub 单例 manager;而负责渲染气泡的 Viewport 挂在 Provider 下。只要页面上存在 N 个 Provider,每个 Provider 的 Viewport 都会订阅同一个 manager,toast()一调用,N 个 Viewport 同时响应,弹出 N 份副本。
文档渲染器通常会把 states[0] 渲染两次(顶部 preview 块 + 完整 gallery 的第一项),再把全部 states 渲染一遍。所以哪怕只有一个 state 包含 Provider,它也已经挂载了两次。
文档岛组件会把 states[0] 渲染 两次(preview + gallery),再渲染所有 states。只要任意一个 state 里包含 Provider,页面上就存在 2–N 个 Viewport 同时订阅同一 manager。每次 toast() 调用,所有 Viewport 都会响应,弹出等量副本。
正解
三步拆开「触发」与「渲染」的耦合:
第一步:模块级全局单例 manager
// toast.ts
import * as Toast from "@base-ui-components/react/toast";
const manager = Toast.createToastManager();
export function toast(opts: Toast.ToastOptions) {
return manager.add({ ...opts });
}manager 是 pub/sub 单例,SSR 安全。触发方不需要是 Provider 的 DOM 祖先,任何地方调用 toast() 都能被唯一的 Viewport 接收到。
第二步:在 section layout 里单点挂载 Provider
// app/components/layout.tsx
import { ToastProvider } from "@/components/toast/provider";
export default function ComponentsLayout({ children }) {
return (
<>
{children}
<ToastProvider /> {/* 整个 section 只挂载一次 */}
</>
);
}选 section layout 而非根 layout 的理由:根 layout 是最多人共享的文件,改动影响面大;section layout 随 SPA 导航存活、不重复挂载,且将 Toast 知识封装在组件文档区域内。
第三步:showcase state 里只放触发按钮
states: [
{
name: "danger",
render: () => (
<Button onClick={() => toast({ tone: "danger", title: "操作失败,请重试" })}>
触发 danger Toast
</Button>
),
},
],State 内部绝不出现 Provider / Viewport,只负责触发。
createToastManager() 放在模块作用域(单例);<ToastProvider> 挂在 section layout(单次挂载);showcase state 只含触发按钮。无论文档渲染器把 state 渲染几次,Viewport 永远只有一个。
验证口径
- 触发「连发 3 个 Toast」按钮 → 屏幕上恰好出现 3 条,不是 6 条或 9 条。
- 在不同组件文档页之间导航 → 不出现残留或重复的 Viewport。
- Playground / renderWithProps 区域的按钮同样生效(同一个 Provider 服务整个 section)。
命令式 overlay 的「空态」什么都不渲染。截图验收时先触发、再截图,否则只能看到空白。
一句话外卖
命令式 overlay 的 Provider 必须全局单点挂载;一旦放进可被多次渲染的 showcase state,pub/sub 机制会让每个 Viewport 都响应同一次调用,副本数量等于 Provider 挂载次数。