React:单例并不像你想的那样邪恶

发布: (2026年3月5日 GMT+8 02:43)
10 分钟阅读
原文: Dev.to

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 的原生特性,因此无需担心额外的包体积。你只需定义类、实例化它,然后就可以使用了。

信不信由你,语言中你每天可能都会用到的许多部分本身就是可以被扩展的类,例如 ErrorArrayMap,甚至是 HTMLElement。拥有如此多的现成类意味着,当我们看到想要使用的行为时,无需重新实现或引入库——只需扩展原生类,它就在浏览器中等着我们使用。

这就是核心观点:类之所以强大,是因为我们可以扩展原生行为;之所以轻量,是因为它们内置于引擎;之所以易于实现,是因为文档就是 Web 规范本身。

类型化目标事件

之前我提到过希望我们的单例能够触发事件。我们可以扩展 EventTarget 来实现这一点。然而,如果你在使用 TypeScript(我希望你在使用),原生实现可能会显得有些宽松。我之前写过一篇关于如何让 EventTarget 更加类型安全的文章,以确保你的消息传递保持健壮。

一个待解决的问题

让我们想出一个不一定必须解决的问题,仅仅是为了展示我们的能力。我选择的例子是Toast 管理器。考虑到已经有 sonnerreact‑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 的单例控制器,甚至可以用于任何其他库。

感谢阅读!如果你想联系我,这里是我的 BlueSkyLinkedIn 个人资料。来打个招呼吧 😊

0 浏览
Back to Blog

相关文章

阅读更多 »