Strona główna / Artykuły / Interfejsy użytkownika do handlu responsywne z SSE, wirtualizacją oraz budżetem na 20 rynków

Interfejsy użytkownika do handlu responsywne z SSE, wirtualizacją oraz budżetem na 20 rynków

Skoordynuj wirtualizowane wiersze React, centralny planer subskrypcji SSE oraz zewnętrzną bazę cen, aby otwarte zamówienia utrzymywały aktualne ceny bez przekraczania limitów połączeń.

1870 słów

Księga transakcji może zawierać tysiące instrumentów, podczas gdy na ekranie widnieje zaledwie kilka wierszy. Ceny są przekazywane nieprzerwanie, użytkownik przewija treść, a otwarty ticket zamówienia może nadal wymagać aktualnych cen po tym, jak rynek wyjdzie poza obszar widoczny na ekranie.

Ty problemy dotyczą różnych aspektów:

Scrolling changes visibility.
Subscription policy changes network demand.
SSE changes quote data.
React changes what is displayed.

Należy przypisać każdemu problemowi jednego odpowiedzialnego. Poniższe fragmenty przedstawiają takie podział; są to jedynie elementy architektury, a nie pełny klient do handlu.

Zdefiniuj zasady przed wyborem implementacji

Załóżmy, że backend pozwala na maksymalnie 20 identyfikatorów rynków przy jednym połączeniu SSE. W takim przypadku zasady wyglądają następująco:

  • Jeden aktywny EventSource na każdą kartę przeglądarki
  • Nigdy nie więcej niż 20 różnych identyfikatorów rynków na tym połączeniu
  • Otwarty ticket zamówienia ma wyższy priorytet niż zwykłe wiersze listy
  • Potrzeby mogą ulegać zmianie bez konieczności usuwania rejestracji użytkownika
  • Ceny w czasie rzeczywistym znajdują się poza stanem komponentu React
  • Przychodzące dane są weryfikowane przed modyfikacją magazynu
  • Ścieżki wysyłania ponownie odczytują najnowszą cenę, a nie tylko tę ostatnio wyświetloną
  • Nieaktualne lub odłączone ceny nie mogą być używane do wysyłania
  • Architektura

    Virtualized list ─── visible and nearby market IDs ──┐
                                                       ├─► Subscription manager
    Open order ticket ─── selected market ID ───────────┘             │
                                                                     ▼
                                                              One SSE connection
                                                                     │
                                                              Validated messages
                                                                     ▼
                                                                 Quote store
                                                                /           \
                                                               ▼             ▼
                                                      React snapshots   Order validation
    

    Komponenty React deklarują potrzeby i odczytują zrzuty stanu. Nie otwierają ani nie zarządzają soketami.

    1. Magazyn aktualnych cen niezależnie od React

    Weryfikowane ceny wyglądają tak:

    type Quote = Readonly<{
      marketId: string;
      bid: string;       // Decimal strings preserve wire precision.
      ask: string;
      version: number;
      staleAt: number;   // Server expiry timestamp in milliseconds.
      tradable: boolean;
    }>;
    

    Zrzuty stanu ujawniają zarówno gotowość, jak i zawartość danych:

    type QuoteSnapshot =
      | { state: "waiting"; quote: null }
      | { state: "fresh" | "stale"; quote: Quote };
    

    Powierzchnia magazynu pozostaje mała:

    interface QuoteStore {
      receive(quote: Quote): void;
    
      // Immediate state, used by commands.
      readLatest(marketId: string): QuoteSnapshot;  // Cached visual snapshots, used by React.
      select(marketId: string): {
        subscribe(notify: () => void): () => void;
        getSnapshot(): QuoteSnapshot;
      };  invalidate(marketIds: readonly string[]): void;
      retain(marketIds: readonly string[]): void;
    }
    

    Logika odbierania porównuje wersje przed przyjęciem aktualizacji:

    function receiveQuote(next: Quote) {
      const previous = latestQuotes.get(next.marketId);
    
      // This example assumes strictly increasing quote versions.
      if (previous && next.version <= previous.version) return;  latestQuotes.set(next.marketId, next);  // Independent of whether another price message arrives.
      scheduleQuoteExpiry(next.marketId, next.staleAt);  // Ordinary prices can wait for the next visual publication.
      scheduleVisualPublication();
    }
    

    Tajemniki wygaśnięcia, anulowanie oraz rozsyłanie informacji do subskrybentów odbywa się bezpośrednio w sklepie. Historia nie jest nieskończona – przechowywana jest tylko najnowsza cena za każdy rynek:

    Market A, version 10
            ↓ replaced
    Market A, version 11
            ↓ replaced
    Market A, version 12
    

    Jeśli serwer ponownie wykorzystuje przestrzenie wersji między różnymi typami ram, reduktor musi połączyć dane zgodnie z tym kontraktem.

    2. Przypisz 20 subskrypcji centralnie

    Klienci zgłaszają swoje zapotrzebowanie z określoną priorytetowością:

    type Demand = {
      marketIds: readonly string[];
      priority: number;
    };
    
    const PRIORITY = {
      orderTicket: 0,
      visible: 1,
      nearby: 2,
    } as const;const MAX_MARKETS = 20;
    

    Planista usuwa duplikaty i zachowuje najwyższą priorytetowość dla każdego rynku:

    function planSubscriptions(
      demands: readonly Demand[],
      activeIds: readonly string[],
    ): string[] {
      const priorities = new Map<string, number>();
    
      for (const demand of demands) {
        for (const marketId of demand.marketIds) {
          priorities.set(
            marketId,
            Math.min(
              priorities.get(marketId) ?? Infinity,
              demand.priority,
            ),
          );
        }
      }  if (priorities.size === 0) return [];  const active = new Set(activeIds);  const requested = [...priorities.keys()].sort(
        (left, right) =>
          priorities.get(left)! - priorities.get(right)! ||
          Number(active.has(right)) - Number(active.has(left)),
      );  return [...new Set([...requested, ...activeIds])]
        .slice(0, MAX_MARKETS)
        .sort();
    }
    

    Gdy zapotrzebowanie przekracza dwadzieścia rynków, nadmiarowe wpisy pozostają widoczne i w stanie oczekiwania. Interfejs nie może udawać, że zapewnia im aktualne pokrycie.

    3. Aktualizuj zapotrzebowanie bez restartowania efektu React

    Każdy klient ma ustalonego właściciela:

    function useMarketDemand(
      marketIds: readonly string[],
      priority: number,
    ) {
      const [owner] = useState(() =>
        subscriptionManager.createOwner(),
      );
    
      const key = JSON.stringify([...new Set(marketIds)]);
      const stableIds = useMemo<string[]>(
        () => JSON.parse(key),
        [key],
      );  useEffect(() => {
        owner.update({
          marketIds: stableIds,
          priority,
        });
      }, [owner, stableIds, priority]);  useEffect(() => {
        return () => owner.dispose();
      }, [owner]);
    }
    

    Zmiany ID aktualizują tego właściciela na miejscu; usunięcie elementu oczyszcza go.

    Szybkie zmiany popytu są grupowane w ramach ustalonego okna harmonogramowania:

    let reconciliationTimer:
      ReturnType<typeof setTimeout> | undefined;
    
    function scheduleReconciliation() {
      if (reconciliationTimer !== undefined) return;  reconciliationTimer = setTimeout(() => {
        reconciliationTimer = undefined;
        reconcileSubscriptions();
      }, 150);
    }
    

    Ponieważ okno jest stałe, a nie zmienne, ciągłe przewijanie nie może wiecznie odkładać procesu synchronizacji. Pierwsze połączenie oraz końcowe rozłączenie mogą nadal nastąpić natychmiast.

    4. Bezpieczne zastąpienie połączenia SSE

    Nowe parametry zapytania oznaczają nowy obiekt EventSource. Należy zamknąć poprzedni strumień przed otwarciem następnego:

    let source: EventSource | undefined;
    let generation = 0;
    let clearWatchdog: (() => void) | undefined;
    
    function replaceConnection(marketIds: string[]) {
      const currentGeneration = ++generation;  clearWatchdog?.();
      source?.close();
      source = undefined;  quoteStore.retain(marketIds);
      quoteStore.invalidate(marketIds);  if (marketIds.length === 0 || !navigator.onLine) return;  const query = new URLSearchParams();  for (const marketId of marketIds) {
        query.append("marketId", marketId);
      }  const nextSource = new EventSource(
        `/api/v1/stream?${query.toString()}`,
      );  source = nextSource;
      const permittedIds = new Set(marketIds);  const watchdog = createSilenceWatchdog(() => {
        if (currentGeneration !== generation) return;    nextSource.close();
        reconcileSubscriptions({ force: true });
      });  clearWatchdog = watchdog.stop;
      watchdog.reset();  nextSource.addEventListener("quote", event => {
        if (currentGeneration !== generation) return;    // Parses JSON and validates it against the feed contract.
        const quote = parseQuoteMessage(event);    if (!quote || !permittedIds.has(quote.marketId)) {
          quoteStore.invalidate(marketIds);
          return;
        }    watchdog.reset();
        quoteStore.receive(quote);
      });  nextSource.addEventListener("heartbeat", () => {
        if (currentGeneration === generation) {
          watchdog.reset();
        }
      });  nextSource.onerror = () => {
        if (currentGeneration !== generation) return;    quoteStore.invalidate(marketIds);    if (nextSource.readyState === EventSource.CLOSED) {
          watchdog.stop();
          reportConnectionError();
        }    // Recoverable failures are retried by native EventSource.
      };
    }
    

    Narzędzia do monitorowania, analizy i sprawdzania stanu połączenia znajdują się w oddzielnych modułach. Licznik generacji pomaga usunąć starsze zdarzenia z przestarzałych sokietów. Natywny EventSource próbuje ponownie nawiązać połączenie w przypadku błędów, które da się naprawić; wywołanie metody .close() kończy tę instancję. Szczegóły dotyczące zachowania przeglądarki znajdują się w przewodniku MDN na temat wykorzystania zdarzeń wysyłanych przez serwer.

    Słuchacze działające online/offline oraz te obsługujące cykl życia strony znajdują się w zakresie menedżera: przejście w tryb offline unieważnia zapytania i zamyka sokiet; przywrócenie połączenia następuje poprzez ponowne połączenie z aktualnym żądaniem. Nowe połączenie czeka na nowe zapytania przed umożliwieniem wysłania danych.

    5. Wirtualizacja listy i wyświetlanie jej widoku

    Wirtualizacja ogranicza zmontowany DOM; planer subskrypcji oddzielnie ogranicza rynki przesyłane strumieniowo. Dzięki TanStack Virtual widoczne wiersze mogą mieć wyższy priorytet niż sąsiadujące z nimi wiersze o większej szerokości:

    function MarketList({
      markets,
      onOpenTicket,
    }: {
      markets: Array<{ marketId: string; name: string }>;
      onOpenTicket(marketId: string, side: "BUY" | "SELL"): void;
    }) {
      const [viewport, setViewport] =
        useState<HTMLDivElement | null>(null);
    
      const virtualizer = useVirtualizer({
        count: markets.length,
        getScrollElement: () => viewport,
        getItemKey: index => markets[index]!.marketId,
        estimateSize: () => 72,
        overscan: 2,
      });  const rows = virtualizer.getVirtualItems();
      const range = virtualizer.range;  const visibleIds = range
        ? markets
            .slice(range.startIndex, range.endIndex + 1)
            .map(market => market.marketId)
        : [];  const nearbyIds = rows.map(
        row => markets[row.index]!.marketId,
      );  useMarketDemand(visibleIds, PRIORITY.visible);
      useMarketDemand(nearbyIds, PRIORITY.nearby);  return (
        <div
          ref={setViewport}
          role="region"
          aria-label="Markets"
          tabIndex={0}
          style={{ height: 560, overflow: "auto" }}
        >
          <div
            style={{
              height: virtualizer.getTotalSize(),
              position: "relative",
            }}
          >
            {rows.map(row => (
              <div
                key={row.key}
                style={{
                  position: "absolute",
                  top: 0,
                  left: 0,
                  width: "100%",
                  height: 72,
                  transform: `translateY(${row.start}px)`,
                }}
              >
                <MarketRow
                  market={markets[row.index]!}
                  onOpenTicket={onOpenTicket}
                />
              </div>
            ))}
          </div>
        </div>
      );
    }
    

    Przykłady te zakładają stałą wysokość wierszy; w przypadku zmiennych wysokości konieczne jest pomiary. Wirtualizator montuje wiersze; polityka subskrypcji pozostaje własnością aplikacji (patrz dokumentacja React dla TanStack Virtual). Skupienie klawiatury powinno również zapewniać dostępność skupionego wiersza, nawet gdy ten opuści zwykłe okno renderowania.

    6. Renderowanie każdego rynku za pomocą jego własnego zrzutu

    function useQuote(marketId: string) {
      const selection = useMemo(
        () => quoteStore.select(marketId),
        [marketId],
      );
    
      return useSyncExternalStore(
        selection.subscribe,
        selection.getSnapshot,
      );
    }
    

    Dopóki w tym rynku nie nastąpi jakaś istotna zmiana, funkcja select nadal zwraca tę samą referencję snapshotu – dokładnie to, czego React oczekuje od zewnętrznego magazynu używanego z useSyncExternalStore.

    const MarketRow = memo(function MarketRow({
      market,
      onOpenTicket,
    }: {
      market: { marketId: string; name: string };
      onOpenTicket(marketId: string, side: "BUY" | "SELL"): void;
    }) {
      const snapshot = useQuote(market.marketId);
      const quote = snapshot.quote;
    
      const available =
        snapshot.state === "fresh" && quote?.tradable;  return (
        <article>
          <strong>{market.name}</strong>      <button
            disabled={!available}
            onClick={() => onOpenTicket(market.marketId, "SELL")}
          >
            Sell · {quote?.bid ?? "—"}
          </button>      <button
            disabled={!available}
            onClick={() => onOpenTicket(market.marketId, "BUY")}
          >
            Buy · {quote?.ask ?? "—"}
          </button>      {snapshot.state !== "fresh" && (
            <span>Prices updating</span>
          )}
        </article>
      );
    });
    

    Zmiana ceny na Rynku A informuje jedynie odczytującego dane z tego rynku – nie zastępuje ona całej tablicy rynków w głównej liście. Przyciski otwierają zgłoszenie; nie realizują zamówień w tle.

    Zgłoszenie określa własne żądanie:

    function OrderTicket({ marketId }: { marketId: string }) {
      useMarketDemand([marketId], PRIORITY.orderTicket);
    
      const snapshot = useQuote(marketId);  // Render quantity, side, current quote, review and submit controls.
      // ...
    }
    

    Przewijanie tabeli nie anuluje żądania zawartego w zgłoszeniu. Usunięcie zgłoszenia usuwa tylko jego właściciela; widoczny wiersz lub pobliska pozycja mogą nadal wymagać tego samego instrumentu.

    Sprawdź aktualną cenę tuż przed zakupem lub sprzedażą

    To, co zostało ostatnio narysowane na wierszu, może o kilka chwil opóźnić autorytatywny sklep. Dlatego ścieżka wysyłki jest ponownie następująca:

    async function submitOrder(intent: {
      clientOrderId: string; // Stable for retries of this exact intent.
      marketId: string;
      side: "BUY" | "SELL";
      quantity: string;
      reviewedVersion: number;
    }) {
      const snapshot = quoteStore.readLatest(intent.marketId);
      const quote = snapshot.quote;
    
      if (
        !navigator.onLine ||
        snapshot.state !== "fresh" ||
        !quote ||
        !quote.tradable ||
        Date.now() >= quote.staleAt
      ) {
        throw new Error("Wait for a fresh, tradable quote.");
      }  if (quote.version !== intent.reviewedVersion) {
        throw new Error("The quote changed. Review it again.");
      }  const limitPrice =
        intent.side === "BUY" ? quote.ask : quote.bid;  const response = await fetch("/api/v1/orders", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          ...intent,
          type: "LIMIT",
          limitPrice,
        }),
      });  return parseOrderResponse(response);
    }
    

    Odpowiedzi są analizowane pod kątem akceptacji, odrzucenia lub niejednoznaczności. Ponowna próba realizacji tego samego zamiaru wykorzystuje pierwotny identyfikator zamówienia klienta. Autoryzacja, weryfikacja ceny, akceptacja oraz uzupełnienie zamówienia pozostają decyzjami serwera. Limity cenowe jedynie ograniczają dozwolone sposoby realizacji zamówień – nigdy nie gwarantują ich uzupełnienia.

    Dwie decyzje dotyczące czasu

    Decyzja Cel
    Okno subskrypcji ~150 ms Zgrupowanie szybkich zmian w widoku przed zastąpieniem połączenia
    Interwał publikacji wizualnej ~100 ms Ograniczenie częstotliwości ponownego renderowania standardowych cen

    Przysyłane wiadomości nadal natychmiast aktualizują lokalną bazę danych. Funkcja dostępności omija zwykłe opóźnienia wizualne. Okno 150 ms nie ma na celu spowolnienia przewijania, kliknięć ani cen już dostarczanych przez aktywną połączenie.

    Pamięć i oczyszczanie

    Kasza katalogowa (na przykład React Query) wymaga własnych zasad przechowywania danych. Lista wirtualizowana może wyświetlać dziesięć wierszy, jednocześnie przechowując w pamięci tysiące załadowanych rekordów. Trzeba trzymać te funkcje oddzielnie: wirtualizacja odpowiada za wyświetlaną interfejs użytkownika, planer za pojemność subskrypcji, baza danych za stan ofert, a React rysuje istotne zrzuty ekranu.

    Powiązane materiały