在 React 的世界裡,不起眼的單例模式(singleton)名聲不太好。它經常被貶低為一種混亂的全局狀態捷徑,難以追踪,更難測試。但如果我告訴你,單例模式並非你想像中的架構災難呢?如果我向你展示它實際上功能強大、輕量級且實現起來異常簡單呢?你或許會覺得我瘋了,但我即將說服你,單例模式並非罪魁禍首。
在 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 (
<div>
<span>{singletonData || 'N/A'}</span>
<button onClick={handleRefresh}>Manual Refresh</button>
</div>
)
}
如果你以為我說的「單例模式易於實現」是指這種做法,那我理解你的困惑。這種實現方式並不好,過去也並非如此。讓我來給你展示一種更優雅的方法。
import { useState, useEffect, useCallback } 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 (
<div>
<span>{singletonData || 'N/A'}</span>
</div>
)
}
這樣一來,程式碼已經清晰得多,更易讀,也不容易出錯。不過,要讓事件發生,還需要對單例模式做一些修改,我們稍後會談到這一點。
雖然現在已經完全可以使用了,但我們也可以運用一些技巧和竅門,使其感覺像是原生 React 狀態。
類別是 JavaScript 的原生特性,因此無需擔心額外的套件臃腫問題。您只需定義類,初始化它,即可開始使用。
信不信由你,你每天使用的語言中有很多部分都是可以擴展的類,例如Error 、 Array 、 Map甚至HTMLElement 。有了這麼多現成的類,如果我們發現想要使用某種行為,就不必重寫程式碼或為此開發庫。我們只需擴充原生類,它就會在瀏覽器中等著我們。
這就是它的賣點:類別功能強大,因為我們可以擴展原生行為;類別很輕便,因為它們內建於引擎中;類別易於實現,因為文件就是 Web 規範。
我之前提到過希望單例物件能夠觸發事件。我們可以擴展EventTarget來實現這一點。但是,如果您使用的是 TypeScript(我希望您使用的是 TypeScript),其原生實作可能會感覺有些不穩定。我之前寫過文章,介紹如何讓EventTarget更具類型安全性,以確保訊息傳遞的健全性。
https://dev.to/link2twenty/type-safe-customevents-better-messaging-with-native-apis-2dol
讓我們設想一個不一定需要解決的問題,只是為了展現我們的能力。我選擇的問題是 Toast 管理員。鑑於Sonner和Toastify都已經存在且非常出色,我們當然不需要從零開始建置,但實際的演示會讓整個過程更加順利。
建置通知系統是對我們單例架構的完美測試。它需要能夠從應用程式的任何部分存取,必須能夠處理自己的計時器,並且能夠在不與特定元件樹耦合的情況下觸發 UI 更新。
如同先前討論的,我們將擴展我的TypedEventTarget類,它本身是基於原生EventTarget類別建構的。我們需要一個提示框清單、一個新增提示框的方法、一個提前移除提示框的方法,以及一個定時器,用於在經過足夠長的時間後移除提示框。此外,我們還需要在提示框清單發生變更時觸發事件。很簡單。
首先,我們來定義一些類型。我這裡用的是 TypeScript,但你不一定要用 TypeScript;如果你願意,可以跳過這部分。
export interface Toast {
id: string;
message: string;
type: "info" | "success" | "loading" | "error";
action?: {
label: string;
callback: () => void;
};
}
type ToastEvents = {
changed: void;
};
現在我們已經確定了類型,知道了 Toast 物件是什麼樣子以及會觸發哪些事件。接下來讓我們來設定類別。我們知道它將繼承TypedEventTarget ,並且會有一些需要隱藏的私人內部實作。
class ToastManager extends TypedEventTarget<ToastEvents> {
private _toasts: Toast[] = [];
private _timers = new Map<string, number>();
}
這是一個好的開始,但是我們的_toasts屬性是私有的,這意味著我們無法從類別外部存取它,目前每次更新它時我們都必須手動觸發一個事件。幸好有 getter 和 setter 方法可以解決這個問題。
get toasts() {
return this._toasts;
}
private set toasts(value: Toast[]) {
this._toasts = [...value];
this.dispatchEvent("changed");
}
現在我們可以讀取toasts屬性,甚至可以在內部更新它,但我們仍然無法從外部控制這個類別。我們需要加入一些方法。
// add or update a toast item
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;
};
// remove a toast and its timer
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);
}
};
// remove a timer
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就派上用場了。它允許我們訂閱外部資料來源,並定義一個函數來取得該資料來源的狀態快照,從而自動處理同步問題。
首先,我們需要建立要傳遞給鉤子的函數。
import { toastManager } from '@/singletons/toastManager';
// Add an event listener
const subscribe = (callback: () => void) => {
const ac = new AbortController();
toastManager.addEventListener("changed", callback, {
signal: ac.signal,
});
return () => ac.abort();
};
// Get the state
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。我還沒有把它做成一個功能齊全的產品,它的外觀也肯定不會贏得任何獎項,但請盡情欣賞這個演示。
{% codesandbox toast-singleton-9c5kwd
我說服你了嗎?還是你仍然反對單例模式?或許你原本就是它的支持。我很樂意在評論區繼續討論。你可能會驚訝地發現,撰寫本文時新推出的TanStack Hotkeys實際上也採用了類似的工作方式,它將單例控制器連接到 React 或其他任何函式庫。
感謝閱讀!如果您想與我聯繫,這是我的BlueSky和LinkedIn個人資料。歡迎來打個招呼 😊
原文出處:https://dev.to/link2twenty/react-singletons-arent-as-evil-as-you-think-44m8