Інтерфейси торгівлі з адаптивною відповіддю, які використовують SSE, віртуалізацію та бюджет для 20 ринків.
Координувати віртуалізовані рядки React, центральний планувальник підписок SSE та зовнішній сховище цін, щоб відкриті замовлення мали актуальні ціни без перевищення лімітів з’єднань.
Журнал торгівлі може відображати тисячі інструментів, проте на екрані видно лише кілька рядків. Котирування надходять безперервно, користувач прокручує список, і навіть після того, як ринок виходить з поля зору, відкрита заявка на замовлення все ще може потребувати актуальних цін.
Ці проблеми мають різні підходи до вирішення:
Scrolling changes visibility.
Subscription policy changes network demand.
SSE changes quote data.
React changes what is displayed.
Вкажіть кожній проблемі окремого власника. Наведені нижче уривки ілюструють цей розподіл; це архітектурні елементи, а не повний клієнт для торгівлі.
Визначте правила перед вибором способу реалізації
Припустимо, що бекенд дозволяє максимум 20 ідентифікаторів ринку на одне з’єднання SSE. Тоді правила будуть такими:
- Один активний об’єкт
EventSourceна кожну вкладку браузера - Не більше 20 різних ідентифікаторів ринку на цьому з’єднанні
- Відкрита заявка на замовлення має вищий пріоритет, ніж звичайні рядки списку
- Потреби можуть змінюватися без необхідності скасування реєстрації користувача
Архітектура
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 оголошують запити та читають знімки стану. Вони не відкривають та не керують сокетами.
1. Сховище поточних цитат працює незалежно від React
Перевірені цитати виглядають так:
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;
}>;
Знімки стану відображають готовність до обробки та сам контент даних:
type QuoteSnapshot =
| { state: "waiting"; quote: null }
| { state: "fresh" | "stale"; quote: Quote };
Об’єм сховища даних залишається невеликим:
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;
}
Логіка прийому порівнює версії перед тим, як прийняти оновлення:
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();
}
Таймери закінчення терміну, скасування та розсилка інформації підписникам знаходяться безпосередньо в магазині. Історія даних не є необмеженою — зберігається лише останній котирування для кожного ринку:
Market A, version 10
↓ replaced
Market A, version 11
↓ replaced
Market A, version 12
Якщо сервер повторно використовує простори версій для різних типів фреймів, редюсер повинен об’єднувати дані відповідно до цього контракту.
2. Централізоване розподілення 20 підписок
Користувачі оголошують про свої потреби з визначенням пріоритету:
type Demand = {
marketIds: readonly string[];
priority: number;
};
const PRIORITY = {
orderTicket: 0,
visible: 1,
nearby: 2,
} as const;const MAX_MARKETS = 20;
Планувальник усуває дублікати та зберігає найвищий пріоритет для кожного ринку:
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();
}
Коли кількість запитів перевищує двадцять ринків, зайві записи залишаються видимими у статусі очікування. Інтерфейс не повинен вдавати, що вони мають активне покриття.
3. Оновлення запитів без перезапуску ефекту React
Кожен користувач має стабільного власника:
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 оновлюють цього власника на місці; видалення елемента очищує його.
Швидкі зміни попиту обробляються протягом фіксованого вікна планування:
let reconciliationTimer:
ReturnType<typeof setTimeout> | undefined;
function scheduleReconciliation() {
if (reconciliationTimer !== undefined) return; reconciliationTimer = setTimeout(() => {
reconciliationTimer = undefined;
reconcileSubscriptions();
}, 150);
}
Оскільки вікно є фіксованим, а не змінним, безкінечне прокручування не може вічно відкладати процес узгодження. Початкове підключення та остаточне роз’єднання все одно можуть відбутися миттєво.
4. Безпечна заміна підключення SSE
Нові параметри запиту означають новий об’єкт EventSource. Перед відкриттям наступного потоку необхідно закрити попередній:
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.
};
}
Функції контролю стану, парсер та інструменти відстеження статусу підключення знаходяться у окремих модулях. Лічильник поколінь видаляє запізнілі події з застарілих сокетів. Вбудований EventSource автоматично підключається знову у разі виправних помилок; виклик методу .close() завершує цю інстанцію. Для ознайомлення з поведінкою браузера дивіться посібник MDN щодо використання подій, надсиланих сервером.
Підслуховуючі елементи для режиму онлайн/офлайн та життєвого циклу сторінки знаходяться на рівні менеджера: вихід у офлайн скасовує запити та закриває сокет; після відновлення з’єднання відбувається повторна підключення з урахуванням поточних вимог. Нове з’єднання чекає на нові запити перед тим, як дозволити надсилання даних.
5. Віртуалізуйте список та відображайте його видиму область
Віртуалізація обмежує обсяг DOM-дерева; планувальник підписок окремо обмежує кількість стрімованих ринків. За допомогою TanStack Virtual видимі рядки можуть мати вищий пріоритет порівняно з сусідніми рядками, які знаходяться поза межами видимої області:
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>
);
}
У цих прикладах передбачається фіксована висота рядків; для змінної висоти необхідні вимірювання. Віртуалізатор монтує рядки; політика підписок залишається у власності додатку (див. документацію TanStack Virtual для React). Фокус клавіатури також має зберігати видимий рядок доступним, навіть коли він виходить за межі звичайного вікна відображення.
6. Відображайте кожен ринок через його власний знімок
function useQuote(marketId: string) {
const selection = useMemo(
() => quoteStore.select(marketId),
[marketId],
);
return useSyncExternalStore(
selection.subscribe,
selection.getSnapshot,
);
}
Доки щось значуще не зміниться на цьому ринку, функція select продовжуватиме повертати ту саму посилання на знімок даних — саме цього очікує React від зовнішнього сховища, яке використовується з 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>
);
});
Зміна котирування на Ринку А сповіщає лише читача цього ринку — вона не замінює всю масиву ринків у батьківському списку. Кнопки відкривають заявку; вони не ставлять замовлення без подальших дій.
У заявці вказується власна вимога:
function OrderTicket({ marketId }: { marketId: string }) {
useMarketDemand([marketId], PRIORITY.orderTicket);
const snapshot = useQuote(marketId); // Render quantity, side, current quote, review and submit controls.
// ...
}
Прокручування сітки не скасовує вимоги заявки. Видалення заявки усуває лише її власника; видимий рядок або сусідній запис можуть все ще потребувати того самого інструменту.
Обов’язково перевірте актуальні котирування безпосередньо перед купівлею чи продажем
Те, що було останньо намальовано у рядку, може на мить відставати від авторитетного магазину. Тому шлях для надсилання даних знову виглядає так:
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);
}
Відповіді аналізуються на предмет прийняття, відхилення чи неоднозначності. Повторна спроба виконання певної дії використовує початковий ідентифікатор замовлення клієнта. Авторизація, перевірка цитати, прийняття та виконання замовлень залишаються рішеннями сервера. Обмеження цін лише визначають, які операції дозволені — вони ніколи не гарантують виконання замовлення.
Два рішення щодо таймінгу
| Рішення | Мета |
|---|---|
| Вікно підписки приблизно 150 мс | Об’єднати швидкі зміни вікна перегляду перед заміною з’єднання |
| Інтервал візуальної публікації приблизно 100 мс | Обмежити частоту перерендерингу звичайних цін |
Вхідні повідомлення все ще миттєво оновлюють поточний сховище. Функція «skip availability flip» усуває звичайну візуальну затримку. Вікно у 150 мс не призначене для уповільнення прокрутки, кліків чи відображення цін, які вже надходять через активне з’єднання.
Пам’ять та очищення
Кеш каталогу (наприклад, React Query) потребує власних правил зберігання даних. Віртуалізований список може відображати десять рядків, при цьому все ще зберігаючи кеш із тисячами завантажених записів. Треба розділяти функції: віртуалізація відповідає за відображення інтерфейсу, механізм планування — за обсяг підписки, сховище — за стан даних, а React відображає лише ті знімки, які є важливими.