Галоўная / Артыкулы

Інтэрфейсы для традынгу, якія адаптуюцца пад разныя умовы, з падтрымкай SSE, віртуалізацыі і бюджэтам для 20 рынкаў

Скоордынавайце віртуалізаваныя радкі React, центральны планашчык падпісоў SSE і зовнішній сховішч для квот, ўпэўніваючыся таму, што адкрытыя заявкі будуць паставляць актуальныя цены без перакрыцча лімітав прыўязей.

Выдавец: REACTAPP.TOP8 хв чытання1870 слоў

Крыніцы: **Building a Responsive Trading UI with SSE, Virtualization, and React**

У табліцы для аналізу транзакцый можа быць перыяктыя інструменты, пры тым на экране паказваецца толькі калькі рэядоў. Котырыя з’являюцца без перапынення, апытак ужо можа скролляць, і для адкрытага замовлення можа заставацца неабходнае актуальная цена пасля таго, як рынак выйдзе за межы вікна прэгляду.

Этыя проблемы маюць разныя падходы да рашэння:

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

Надаць кожнай проблеме адного ведача. Наводзімыя ніжэй фрагменты паказваюць такое раздзеленне; гэта архітэктурныя элементы, а не цэлы кліент для трынкавання.

З’явіце правілы перш чым выбраце спосаб ўтрымання

Праўільна будзе выйсці з таго, што бэкенд дазволяе максымум 20 ідэнтыфікатораў рынкаў на адной з’язку SSE. Тады правілы стаюць такімі:

  • Адны актыўны EventSource на кожную вікно браузера
  • Ніколі не больш чым 20 разных ідэнтыфікатораў рынкаў на гэтай з’язку
  • Адкрытае замовленне мае вышэйшы прыорытэт чым звычныя рэяды спіску
  • Патронак можа змяніцца без неабходнасці адменавання рэгістрацыі корыстніка
  • Актуальныя котыранцы зберагуюцца парад у стану компонента React
  • Прыходзячыя дадзеныя пераканальваюцца прытаму, прытым якія змянююць хранэлку
  • Шляхі для адправкі перачытаюць найновейшы котыранец, а не толькі той, які быў недавэльна прадстаўлены
  • Котыранцы, якія выйшлі з часу або былі перыяктаваны, не можна выкарыстоўваць для адправкі
  • Архітэктура

    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 мс Обмежэнне частоты перзапісу звычайных цен

    Уваходзячыя паведамленні як і раней адразу апдэююць актуальны склад. Можлівасць выкарыстоўвання паведамленняў не спрачывае звычнага візуальнага затрымкі. Вакно у 150 мс не прызначана для спамтавлення прасування, клікаў чы выказвання цэнаў, якія ужо надходзяць праз актывае з’ўязанне.

    Памяць і чыстка

    Кэш каталагу (напрыклад, React Query) патрэбуе сабой правіла зберагчыць даныя. Віртуалізаваныя спісы могу паказваць дзесяць радоў, пры тым залішаючысь у кэшы тыясячы завантажаных запісаў. Хавайце задачы разнымі: віртуалізацыя адпаведае за паказаную інтэрфейсную частку, планавальнік — за магчымасць падпіску, склад — за стан квоты, а React атрыбуе значэння тым кадрам, якія маюць значэнне.