用闭包单例实现消息通知中心,关键在于延迟初始化、隔离dom依赖、防止模块重载导致实例漂移;必须首次调用时创建并复用同一实例,避免多次初始化造成监听器丢失或消息不互通。

直接说结论:用闭包单例实现消息通知中心,关键不是“怎么写一个 getInstance”,而是控制初始化时机、隔离 DOM 依赖、防止模块重载导致的实例漂移——否则你在 Vue 组件里调用两次 getNotifier(),可能拿到两个不互通的实例。
为什么不能在模块顶层 new 一个 NotificationCenter 实例
SPA 中通知中心往往要操作 DOM(比如挂载 toast 容器到 document.body),如果在模块顶部就执行 new NotificationCenter(),很可能遇到 document.body is null 错误(尤其 SSR 或测试环境)。更隐蔽的问题是:Webpack HMR 或 Jest 的 jest.resetModules() 会重新执行模块代码,instance 变量被重置,后续调用又生成新实例,旧实例的监听器、队列、计时器全丢。
正确做法是把实例创建完全推迟到首次调用时,并确保构造逻辑只跑一次:
- 用
let instance声明,不用var(避免变量提升污染) - 构造函数内部检查
document.body是否可用,不可用则延迟到DOMContentLoaded后再建 - 不要在闭包外层做任何副作用操作(如
init()调用、addEventListener绑定)
闭包单例必须返回同一个对象引用,而不是每次 new 一个新对象
常见错误写法:getInstance() { return { show() {}, hide() {} }; } —— 每次都返回新对象,r === r2 是 false,根本不算单例。这会导致监听器注册到不同实例、消息发给了“幽灵实例”、已打开的 toast 无法被统一关闭。
必须缓存并复用首次创建的对象:
- 用
instance || (instance = createInstance())这种短路赋值,确保只初始化一次 - 方法必须用传统函数声明(
show() { ... }),不能用箭头函数(否则this指向错误,拿不到实例内部状态) - 状态(如未读数、队列数组)必须挂在实例自身上,而不是闭包变量里——闭包变量共享会掩盖多实例问题
示例片段:
const createNotifier = () => {
let instance;
const createInstance = () => ({
queue: [],
show(msg) {
this.queue.push(msg);
// 实际渲染逻辑...
return this; // 支持链式调用
},
clear() {
this.queue = [];
return this;
}
});
return {
getInstance() {
if (!instance) {
instance = createInstance();
}
return instance;
}
};
};
const { getInstance } = createNotifier();
如何让通知中心在 Vue/React 组件中安全使用且不触发重复初始化
ESM 模块天然支持单例,但前提是“只 import 一次”。如果你在 10 个组件里各自 import { getInstance } from './notifier',没问题;但若有人写了 import notifier from './notifier'(默认导出没封装好)或用了 pnpm 的硬链接、symlink,就可能引入多个闭包副本。
规避方式很实在:
- 入口文件(如
main.js)中提前调用一次getInstance(),强制初始化 - 导出时只暴露
getInstance,绝不导出构造函数或instance变量本身 - 在通知中心内部加防护:比如首次初始化后写
globalThis.__NOTIFIER_INIT__ = true,后续检测到就抛错提示“重复初始化” - Vue 组合式 API 中,不要在
setup()里反复调用getInstance(),直接在模块顶层取一次,然后provide/inject下去
容易被忽略的边界:SSR 和服务端渲染中的 document 与 window
在 Nuxt 或 Next.js 等 SSR 场景下,document 和 window 在服务端是 undefined。闭包单例不会自动感知运行环境,所以构造函数里直接操作 document.body.appendChild 必然报错。
解决方案不是“加一堆 typeof window !== 'undefined' 判断”,而是把 DOM 相关逻辑彻底解耦:
- 核心实例只管消息队列、事件分发、状态管理
- 渲染层(如 toast 组件)在客户端 mount 后才调用
notifier.on('show', renderToast) - 服务端调用
getInstance()是安全的,它只返回纯 JS 对象,不触发任何副作用
换句话说:单例可以存在,但“通知展示”这个动作必须延迟到浏览器环境确认之后。










