Reaktive Handels-Oberflächen mit SSE, Virtualisierung und einem Budget für 20 Märkte
Koordinieren Sie die virtualisierten Zeilen von React, einen zentralen Planer für SSE-Abonnements sowie einen externen Quellenlager, damit offene Tickets stets aktuelle Preise beibehalten, ohne die Verbindungslimits zu überschreiten.
Ein Handelsprotokoll kann Tausende von Instrumenten auflisten, während nur wenige Zeilen auf dem Bildschirm angezeigt werden. Die Kurse fließen kontinuierlich, der Benutzer scrollt, und ein offenes Order-Ticket benötigt möglicherweise weiterhin aktuelle Preise, nachdem der zugehörige Markt außerhalb des Sichtbereichs liegt.
Diese Anforderungen haben unterschiedliche Zeitmodelle:
Scrolling changes visibility.
Subscription policy changes network demand.
SSE changes quote data.
React changes what is displayed.
Weisen Sie jeder Anforderung einen einzigen Verantwortlichen zu. Die untenstehenden Auszüge skizzieren diese Aufteilung; es handelt sich dabei um architektonische Ansätze, nicht um einen vollständigen Handelsklienten.
Definieren Sie die Regeln, bevor Sie die Implementierung wählen
Nehmen wir an, der Backend erlaubt maximal 20 Markt-ID-Elemente pro SSE-Verbindung. Dann lautet die Richtlinie:
- Ein aktiver
EventSourcepro Browser-Tab - Niemals mehr als 20 unterschiedliche Markt-ID-Elemente auf dieser Verbindung
- Ein offenes Order-Ticket hat Vorrang vor gewöhnlichen Listenzeilen
- Die Nachfrage kann sich ändern, ohne dass die Registrierung des Clients aufgehoben werden muss
Die Architektur
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
React-Komponenten deklarieren Anfragen und lesen Snapshots. Sie öffnen keine Sockets und besitzen keine.
1. Der aktuelle Zustand der Zitate wird unabhängig von React gespeichert
Überprüfte Wire-Zitate sehen so aus:
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;
}>;
Snapshots geben sowohl den Bereitstellungsstatus als auch die Dateninhalt an:
type QuoteSnapshot =
| { state: "waiting"; quote: null }
| { state: "fresh" | "stale"; quote: Quote };
Die Oberfläche des Stores bleibt übersichtlich:
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;
}
Die Empfangslogik vergleicht die Versionen, bevor ein Update akzeptiert wird:
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();
}
Ablaufzeiten, Außer Kraft Setzen sowie die Übermittlung an Abonnenten finden direkt im Store statt. Die Historie ist nicht unbegrenzt – es werden nur die neuesten Kurse pro Markt gespeichert:
Market A, version 10
↓ replaced
Market A, version 11
↓ replaced
Market A, version 12
Falls der Server Versionsräume für verschiedene Frame-Typen wiederverwendet, muss der Reducer gemäß diesem Vertrag zusammenführen.
2. Vergabe der 20 Abonnements zentral
Verbraucher deklarieren ihre Nachfrage mit einer Priorität:
type Demand = {
marketIds: readonly string[];
priority: number;
};
const PRIORITY = {
orderTicket: 0,
visible: 1,
nearby: 2,
} as const;const MAX_MARKETS = 20;
Ein Planer beseitigt Duplikate und behält die höchste Priorität pro Markt bei:
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();
}
Wenn die Nachfrage mehr als zwanzig Märkte betrifft, bleiben die überschüssigen Einträge sichtbar als wartend. Die Benutzeroberfläche darf nicht vortäuschen, dass sie eine aktive Abdeckung bietet.
3. Aktualisierung der Nachfrage ohne Neustart eines React-Effekts
Jeder Verbraucher erhält einen festen Eigentümer:
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]);
}
ID-Änderungen aktualisieren diesen Eigentümer vor Ort; das Entfernen des Elements führt zur Bereinigung.
Die schnelle Änderung der Nachfrage wird innerhalb eines festgelegten Zeitfensters gebündelt:
let reconciliationTimer:
ReturnType<typeof setTimeout> | undefined;
function scheduleReconciliation() {
if (reconciliationTimer !== undefined) return; reconciliationTimer = setTimeout(() => {
reconciliationTimer = undefined;
reconcileSubscriptions();
}, 150);
}
Weil das Fenster fest ist und kein verschiebbares Debounce-Verfahren verwendet wird, kann das kontinuierliche Scrollen die Abstimmung nicht endlos hinauszögern. Die erste Verbindung herstellen sowie die abschließende Trennung können dennoch sofort ausgeführt werden.
4. Die SSE-Verbindung sicher ersetzen
Neue Abfragemethoden bedeuten einen neuen EventSource. Schließen Sie den vorherigen Stream, bevor Sie einen neuen öffnen:
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.
};
}
Hilfsfunktionen für Watchdog, Parser sowie Verbindungsstatus bleiben in separaten Modulen. Ein Generierungszähler entfernt späte Ereignisse aus veralteten Sockets. Der native EventSource stellt bei behobbaren Fehlern die Verbindung wieder her; durch Aufruf von .close() wird diese Instanz beendet. Weitere Informationen zum Browserverhalten finden Sie in MDNs Anleitung zur Verwendung von server-sent Events.
Online/Offline-Listener sowie Listener für den Lebenszyklus der Seite befinden sich im Scope des Managers: Der Offline-Modus macht die Anfragen ungültig und schließt den Socket; bei Wiederherstellung wird von der aktuellen Anfrage aus neu verbunden. Eine neue Verbindung wartet auf neue Anfragen, bevor sie das Senden ermöglicht.
5. Die Liste virtualisieren und ihren Ansichtsbereich angeben
Durch Virtualisierung wird der montierte DOM eingeschränkt; der Abonnementsplaner begrenzt hingegen die gestreamten Märkte separat. Mit TanStack Virtual können sichtbare Zeilen eine höhere Priorität als benachbarte Zeilen haben, die über den Ansichtsbereich hinausgehen:
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>
);
}
Diese Beispiele gehen von einer festen Zeilenhöhe aus; bei variablen Höhen sind Messungen erforderlich. Der Virtualisator montiert die Zeilen; die Abonnementsrichtlinien bleiben im Besitz der Anwendung (siehe die React-Dokumentation zu TanStack Virtual). Der Tastaturfokus sollte auch dann weiterhin eine fokussierte Zeile verfügbar halten, wenn diese den normalen Anzeigebereich verlässt.
6. Jeden Markt über seinen eigenen Snapshot darstellen
function useQuote(marketId: string) {
const selection = useMemo(
() => quoteStore.select(marketId),
[marketId],
);
return useSyncExternalStore(
selection.subscribe,
selection.getSnapshot,
);
}
Solange sich nichts Bedeutendes auf diesem Markt ändert, gibt select weiterhin denselben Snapshot-Referenzwert zurück – genau das, was React von einem externen Speicher erwartet, der mit useSyncExternalStore verwendet wird.
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>
);
});
Eine Änderung des Kurses auf Markt A informiert nur den Leser von Markt A – sie ersetzt nicht die gesamte Markets-Array des übergeordneten Lists. Buttons öffnen ein Ticket; sie erteilen keinen Auftrag stillschweigend.
Das Ticket gibt seinen eigenen Bedarf an:
function OrderTicket({ marketId }: { marketId: string }) {
useMarketDemand([marketId], PRIORITY.orderTicket);
const snapshot = useQuote(marketId); // Render quantity, side, current quote, review and submit controls.
// ...
}
Das Scrollen des Gitters hebt die Forderung des Tickets nicht auf. Das Ticket zu löschen entfernt lediglich den jeweiligen Eigentümer; eine sichtbare Zeile oder ein nahegelegenes Overscan-Eintrag könnte weiterhin dasselbe Instrument erfordern.
Überprüfen Sie den aktuellen Kurs unmittelbar vor Kauf oder Verkauf erneut
Was der letzte Aufruf gemalt hat, kann den autoritativen Store um einen Moment zurücklassen. Der Übertragungspfad lautet daher erneut:
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);
}
Die Antworten werden auf Annahme, Ablehnung oder Unklarheit analysiert. Ein erneuter Versuch derselben Aktion verwendet wieder den ursprünglichen Client-Order-Identifikator. Autorisierung, Prüfung von Konditionen, Annahme sowie Ausführung der Transaktion bleiben Entscheidungen auf Serverseite. Preislimits beschränken lediglich, welche Ausführungen zulässig sind – sie garantieren niemals eine erfolgreiche Ausführung.
Die beiden Zeitentscheidungen
| Entscheidung | Zweck |
|---|---|
| ~150 ms Abonnementfenster | Schnelle Änderungen im Anzeigebereich bündeln, bevor die Verbindung ersetzt wird |
| ~100 ms Intervall für visuelle Veröffentlichung | Häufigkeit der erneuten Darstellung normaler Preise begrenzen |
Eingehende Nachrichten aktualisieren den jeweiligen Speicher sofort. Die Anzeige der Verfügbarkeit umgeht die üblichen visuellen Verzögerungen. Das 150 ms Zeitfenster dient nicht dazu, das Scrollen, Klicken oder die bereits über die aktive Verbindung eingehenden Preise zu verlangsamen.
Speicher und Aufräumen
Ein Katalog-Cache (zum Beispiel React Query) benötigt eigene Regeln zur Aufbewahrung von Daten. Eine virtualisierte Liste kann zwar zehn Zeilen anzeigen, doch weiterhin Tausende geladener Datensätze im Cache speichern. Halten Sie die Aufgaben getrennt: Die Virtualisierung ist für die angezeigte Benutzeroberfläche verantwortlich, der Planer kümmert sich um die Abonnementskapazität, der Speicher verwaltet den Zustand der Angaben, und React zeichnet die relevanten Schnappschüsse auf.