React:单例并不像你想的那样邪恶
Source: Dev.to
(请提供您希望翻译的正文内容,我将按照要求保留链接、格式和技术术语,仅翻译正文部分。)
单例的怀疑论
从历史上看,如果你想在 React 中从单例获取数据,通常需要等到应用因其他原因重新渲染。你可能见过手动同步按钮或轮询来弥补这一缺口,但那种做法很少令人满意。
import { useState, useEffect, useCallback } from 'react';
import SomeSingleton from '@/singletons/some';
export function ReactElement() {
const [singletonData, setSingletonData] = useState(SomeSingleton.data || null);
/**
* Sync singleton and state
*/
const handleRefresh = useCallback(() => {
setSingletonData(SomeSingleton.data || null);
}, []);
// Trigger refresh every 5 seconds
useEffect(() => {
const interval = window.setInterval(handleRefresh, 5000);
return () => window.clearInterval(interval);
}, [handleRefresh]);
return (
<>
{singletonData || 'N/A'}
<button onClick={handleRefresh}>Manual Refresh</button>
</>
);
}
如果这就是你在我说“单例很容易实现”时想到的情形,我能理解你的困惑。这并不是实现单例的好办法,过去也是如此。下面让我展示一种更优雅的做法。
import { useState, useEffect } from 'react';
import SomeSingleton from '@/singletons/some';
export function ReactElement() {
const [singletonData, setSingletonData] = useState(SomeSingleton.data || null);
// Update the state as soon as things change in the singleton
useEffect(() => {
const ac = new AbortController();
SomeSingleton.addEventListener(
'change',
({ detail }) => {
setSingletonData(detail);
},
{ signal: ac.signal }
);
return () => ac.abort();
}, []);
return (
<>
{singletonData || 'N/A'}
</>
);
}
这个版本已经明显更简洁、更易读,也不太容易落后。不过,它需要在单例内部做一些工作来触发事件——我们稍后会讲到这点。
即使现在已经可以完美使用,还有一些技巧可以让它的使用感受更像原生的 React 状态。
类的简洁性
类是 JavaScript 的原生特性,因此无需担心额外的包体积。你只需定义类、实例化它,然后就可以使用了。
信不信由你,语言中你每天可能都会用到的许多部分本身就是可以被扩展的类,例如 Error、Array、Map,甚至是 HTMLElement。拥有如此多的现成类意味着,当我们看到想要使用的行为时,无需重新实现或引入库——只需扩展原生类,它就在浏览器中等着我们使用。
这就是核心观点:类之所以强大,是因为我们可以扩展原生行为;之所以轻量,是因为它们内置于引擎;之所以易于实现,是因为文档就是 Web 规范本身。
类型化目标事件
之前我提到过希望我们的单例能够触发事件。我们可以扩展 EventTarget 来实现这一点。然而,如果你在使用 TypeScript(我希望你在使用),原生实现可能会显得有些宽松。我之前写过一篇关于如何让 EventTarget 更加类型安全的文章,以确保你的消息传递保持健壮。
一个待解决的问题
让我们想出一个不一定必须解决的问题,仅仅是为了展示我们的能力。我选择的例子是Toast 管理器。考虑到已经有 sonner 和 react‑toastify 这两款优秀的实现,我们当然不需要从零开始构建,但有一个具体的演示会让过程更加顺畅。
构建一个通知系统是检验我们单例架构的绝佳案例。它需要能够在应用的任何位置被访问,必须自行管理计时器,并且应当能够在不与特定组件树耦合的情况下触发 UI 更新。
单例
如前所述,我们将扩展一个 TypedEventTarget 类,而该类本身是基于原生的 EventTarget 构建的。我们需要:
- 一个 toast 列表,
- 一个添加 toast 的方法,
- 一个提前移除 toast 的方法,
- 一个在足够时间后自动移除 toast 的计时器,
- 以及每当 toast 列表变化时触发的事件。
类型(TypeScript)
export interface Toast {
id: string;
message: string;
type: "info" | "success" | "loading" | "error";
action?: {
label: string;
callback: () => void;
};
}
type ToastEvents = {
changed: void;
};
现在我们已经有了类型,知道 toast 对象的结构以及会触发哪些事件。接下来我们来搭建类。
类骨架
class ToastManager extends TypedEventTarget {
private _toasts: Toast[] = [];
private _timers = new Map<string, number>();
}
这已经是一个不错的开始,但 _toasts 是私有的,这意味着我们无法在类外部访问它。我们将公开一个只读的 getter,并在后面的章节实现其余方法。
Getter 与 Setter 救场
get toasts() {
return this._toasts;
}
private set toasts(value: Toast[]) {
this._toasts = [...value];
this.dispatchEvent("changed");
}
现在我们可以读取 toasts 属性,甚至在内部更新它,但仍然无法在类外部控制它。我们需要添加一些方法。
// 添加或更新 toast 项
add = (
toast: Omit<Toast, "id"> & { id?: string },
duration = 3000
) => {
const id = toast.id ?? Math.random().toString(36).substring(2, 9);
this.clearTimer(id);
const newToast = { ...toast, id };
const exists = this.toasts.some((t) => t.id === id);
if (exists) {
this.toasts = this.toasts.map((t) =>
t.id === id ? newToast : t
);
} else {
this.toasts = [...this.toasts, newToast];
}
if (duration > 0) {
const timer = window.setTimeout(() => this.remove(id), duration);
this._timers.set(id, timer);
}
return id;
};
// 移除 toast 及其计时器
remove = (id: string) => {
this.clearTimer(id);
const index = this.toasts.findIndex(
({ id: _id }) => _id === id
);
if (index >= 0) {
this.toasts = this.toasts.filter(
({ id: _id }) => _id !== id
);
}
};
// 清除计时器
private clearTimer(id: string) {
if (this._timers.has(id)) {
clearTimeout(this._timers.get(id));
this._timers.delete(id);
}
}
最后,我们实例化该类并导出。
export const toastManager = new ToastManager();
我不知道你怎么想,但这段代码并不算多。TypeScript 的加入让我们在不引入沉重库的情况下,仍然拥有自动补全和类型检查的安全网。
连接
当我之前向你展示如何使用 useEffect 连接到单例时,我提到这并不像是 React 的自然部分。useSyncExternalStore 正是在这里发挥作用。它允许我们定义对外部源的订阅以及获取该状态快照的函数,帮我们处理同步。
首先,我们需要创建传递给 Hook 的函数。
import { toastManager } from '@/singletons/toastManager';
// 添加事件监听器
const subscribe = (callback: () => void) => {
const ac = new AbortController();
toastManager.addEventListener('changed', callback, {
signal: ac.signal,
});
return () => ac.abort();
};
// 获取状态
const getSnapshot = () => toastManager.toasts;
现在我们可以在组件内部把它们组合起来。
import { useSyncExternalStore } from 'react';
export default function ToastContainer() {
const toastList = useSyncExternalStore(subscribe, getSnapshot);
return (
<ul>
{toastList.map(({ id, message }) => (
<li key={id}>- {message}</li>
))}
</ul>
);
}
这是一种相对简化的实现,但它演示了核心原理。我们可以完整访问单例内部的数据,并且每当内部状态更新时都会触发 React 的渲染周期。通过使用 useSyncExternalStore,我们确保 UI 始终与真实数据源保持同步,而无需手动管理状态变量或担心闭包过时的问题。
演示
就是这样:一个 toast 管理器单例,在需要时向 React 提供数据,使得可以在应用的任何位置控制和监视 toast。我并未将其做成功能完整的产品,而且外观也绝不会获奖,但请欣赏这个演示。
结束语
我说服你了吗,还是你仍然反对单例?也许你已经是粉丝了。我很乐意在评论中继续讨论。你可能会惊讶地发现,最新的 TanStack Hotkeys 实际上以类似的方式工作,使用连接到 React 的单例控制器,甚至可以用于任何其他库。