🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

在 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 的原生特性,因此無需擔心額外的套件臃腫問題。您只需定義類,初始化它,即可開始使用。

信不信由你,你每天使用的語言中有很多部分都是可以擴展的類,例如ErrorArrayMap甚至HTMLElement 。有了這麼多現成的類,如果我們發現想要使用某種行為,就不必重寫程式碼或為此開發庫。我們只需擴充原生類,它就會在瀏覽器中等著我們。

這就是它的賣點:類別功能強大,因為我們可以擴展原生行為;類別很輕便,因為它們內建於引擎中;類別易於實現,因為文件就是 Web 規範。

類型化目標事件

我之前提到過希望單例物件能夠觸發事件。我們可以擴展EventTarget來實現這一點。但是,如果您使用的是 TypeScript(我希望您使用的是 TypeScript),其原生實作可能會感覺有些不穩定。我之前寫過文章,介紹如何讓EventTarget更具類型安全性,以確保訊息傳遞的健全性。

https://dev.to/link2twenty/type-safe-customevents-better-messaging-with-native-apis-2dol

一個待解決的問題

讓我們設想一個不一定需要解決的問題,只是為了展現我們的能力。我選擇的問題是 Toast 管理員。鑑於SonnerToastify都已經存在且非常出色,我們當然不需要從零開始建置,但實際的演示會讓整個過程更加順利。

建置通知系統是對我們單例架構的完美測試。它需要能夠從應用程式的任何部分存取,必須能夠處理自己的計時器,並且能夠在不與特定元件樹耦合的情況下觸發 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 或其他任何函式庫。

感謝閱讀!如果您想與我聯繫,這是我的BlueSkyLinkedIn個人資料。歡迎來打個招呼 😊


原文出處:https://dev.to/link2twenty/react-singletons-arent-as-evil-as-you-think-44m8


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝26   💬2  
769
🥈
我愛JS
💬5  
16
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付