open atlas
← Все проекты

frontend · advanced · 8d

React-фича под нагрузкой

Выкати одну настоящую production-фичу на React — живой совместный дашборд активности — а потом эксплуатируй её: оптимистичные правки, стриминговые обновления, бюджет на кадр, полную доступность и разбор инцидента, когда render-шторм замораживает вкладку.

Это капстоун по React: возьми всё со spine-треков и выкати одну фичу так, как её реально выкатывает senior. Живой дашборд активности выглядит как список — но это серверно-отрендеренная оболочка, гидрирующая клиентские острова, поток обновлений, воюющий с твоим render-путём, оптимистичные записи, которые должны чисто откатываться, и тысячи строк, которые должны скроллиться на 60 fps, не теряя пользователя клавиатуры. Ты очертишь фичу, спроектируешь владение состоянием, построишь острова и живой путь, протестируешь асинхронную поверхность, удержишь бюджет кадра, пройдёшь аудит доступности, задеплоишь с полевой телеметрией, а затем переживёшь и разберёшь render-шторм.

Результат

Задеплоенный real-time дашборд активности: RSC-оболочка с клиентскими островами, живые обновления по стриму, оптимистичные мутации с откатом, виртуализированная лента событий, пройденный аудит axe + клавиатура, отслеживаемый бюджет кадра/INP и написанный пост-мортем render-шторма.

Этапы

0/8 · 0%
  1. 01Очерти фичу: поверхность, бюджеты, non-goals

    До любого JSX реши, что такое этот дашборд и чем он не является. Он read-heavy и движим обновлениями: много зрителей, ровный поток событий, несколько интерактивных мутаций. Реши render-модель заранее — что есть серверно-отрендеренная статичная оболочка, а что обязано быть интерактивным клиентским островом, — потому что этот выбор задаёт стоимость гидрации и time-to-interactive. Выпиши пользовательские бюджеты (INP < 200 мс на клик фильтра, p75 LCP < 2.5 с, без потерянных кадров пока стримится лента) и явные non-goals, чтобы не вылизывать админку, которую никто не просил.

    Критерии готовности
    • У тебя есть карта компонентов, помечающая каждую область как серверную оболочку или клиентский остров, с однострочной причиной на остров.
    • Ты выписал 3 пользовательских бюджета (цель по INP, цель по LCP, цель по кадру при стриминге) и минимум два явных non-goal.
  2. 02Спроектируй владение состоянием: сервер, клиент и граница

    Реши, где живёт каждый кусок состояния, до того как напишешь хук, потому что неправильный владелец и порождает render-шторм в последнем этапе. Серверное состояние (история событий, текущий снимок) принадлежит слою фетча/кэша; клиентское состояние (какой фильтр активен, какая строка в фокусе) — это локальный UI; живой поток питает store, который читают многие компоненты. Выбери примитив синхронизации — кэш запросов или внешний store через useSyncExternalStore — и проведи границу сериализации: то, что пересекает её от серверного компонента к клиентскому острову, должно быть сериализуемым, иначе функции, инстансы классов и Date тихо ломают гидрацию.

    Критерии готовности
    • У каждого куска состояния ровно один владелец (серверный кэш, локальный UI или общий store), выписанный, и ты можешь назвать тот, в который пишет живой поток.
    • Ты перечислил, что пересекает границу сервер→клиент, и подтвердил, что всё сериализуемо (никаких функций или Date по проводу).
  3. 03Построй RSC-оболочку, острова и путь живых обновлений

    Отрендери оболочку дашборда на сервере с первым снимком, уже вшитым в HTML, затем гидрируй только интерактивные острова, чтобы страница была полезна до парсинга JS. Подключи живой транспорт (SSE или WebSocket) к общему store и сводь входящие события в снимок — именно тут кусаются упорядочивание, дедуп и reconnect/backfill. Держи поток вне render-blocking пути: событие должно обновлять store и давать перерендериться только подписанным строкам, а не перерендеривать всё дерево на каждое сообщение.

    Критерии готовности
    • Серверно-отрендеренная оболочка показывает полезный первый снимок до гидрации, и только помеченные острова отгружают клиентский JS.
    • Живые события обновляют store и перерендеривают только подписанные строки (ты подтвердил это в Profiler, а не всё дерево), и reconnect добирает пропущенные события без дублей.
  4. 04Добавь оптимистичные мутации с честным откатом

    Senior-дашборд даёт действовать — подтвердить событие, заглушить источник, закрепить строку — и ощущается мгновенным. Реализуй это через React 19 Actions (useActionState / useOptimistic), чтобы UI применял изменение сразу, а потом сверялся с результатом сервера. Сложное — это отказ: сервер отклоняет, или живое событие приходит в полёте и конфликтует. Откатись к настоящему состоянию без мерцания, покажи отказ пользователю и сделай операцию идемпотентной, чтобы ретрай или дублирующее событие не применились дважды.

    Критерии готовности
    • Действие применяется оптимистично и откатывается к истинному серверному состоянию при отказе, с показом отказа пользователю (а не проглатыванием).
    • Повторённая или продублированная мутация идемпотентна — не применяется дважды, и конфликтующее живое событие сходится к одному консистентному состоянию.
  5. 05Протестируй асинхронную поверхность и виртуализируй ленту

    Риск дашборда — это асинхронность и объём. Тестируй с места пользователя: замокай поток и эндпоинт мутации через MSW, гоняй взаимодействия через user-event и проверяй то, что видит пользователь после того, как оптимистичная запись разрешилась или упала, — а не внутреннее состояние. Затем виртуализируй ленту событий, чтобы 10k строк рендерили только видимое окно: наивный список перекладывает весь DOM на каждый тик потока и роняет INP, а виртуализированный держит число закоммиченных узлов плоским. Замерь разницу, а не предполагай её.

    Критерии готовности
    • Тесты покрывают путь оптимистичного успеха и отклонённого отката через MSW + user-event, проверяя видимый вывод, и идут зелёными одной командой.
    • Лента виртуализирована: число закоммиченных DOM-узлов держится примерно постоянным от 100 до 10k строк, и ты записал улучшение INP относительно наивного списка.
  6. 06Сделай доступным: семантика, фокус, live-регионы

    Real-time дашборд — минное поле доступности: контент обновляется без действия пользователя, фокус может выдернуться при сдвиге строк, а виртуализированная лента прячет узлы, которых ждёт скринридер. Используй настоящую семантику (списки, кнопки, заголовки — а не div'ы с onClick), осознанно управляй фокусом, чтобы оптимистичное обновление или смена фильтра не бросили пользователя клавиатуры, и анонсируй значимые изменения через ARIA live-регион, не спамя на каждый тик потока. Композитные виджеты (меню фильтров, действия строки) требуют полной работы с клавиатуры и корректных ролей.

    Критерии готовности
    • Аудит axe-core проходит на дашборде без critical/serious нарушений, и ты можешь управлять всей фичей — фильтр, действие на строке, закрытие меню — только клавиатурой.
    • Значимые обновления анонсируются через polite live-регион (а не каждый тик потока), и фокус сохраняется или перемещается осознанно при оптимистичных обновлениях и сменах фильтра.
  7. 07Задеплой и наблюдай поле: RUM, web vitals, бюджеты

    Лабораторные числа врут; полевые — нет. Выкати фичу через сборку своего стримингового фреймворка, затем собирай Real User Monitoring — p75 LCP, INP, CLS из настоящих сессий — и заведи бюджеты из этапа 1 в дашборд с алертом, когда p75 INP пересекает 200 мс. Code-split-бандл держит JS острова вне начальной загрузки; подтверди, что сплит реально попал в build-вывод. Смысл — находить регрессии с настоящих устройств и сетей, а не с твоего быстрого ноутбука.

    Критерии готовности
    • Фича задеплоена, и RUM-дашборд показывает полевые p75 LCP, INP и CLS, привязанные к твоим бюджетам, с алертом на бюджет INP.
    • Интерактивный остров вынесен code-split'ом из начальной загрузки, и ты подтвердил границу чанка в реальном build-выводе.
  8. 08Переживи render-шторм, затем напиши пост-мортем

    Трафик скачет, поток событий идёт с 5 до 500 сообщений/сек, и вкладка замерзает: INP лезет за секунду, кадры теряются, кулер воет. Обнаружь это по своему RUM (INP p75 рвёт бюджет) и флеймграфу Profiler. Корневая причина почти никогда не «слишком много событий» — это context-значение или селектор store, из-за которого каждое сообщение перерендеривает всё дерево, нестабильный колбэк, ломающий мемоизацию, или невиртуализированная ветка, которую ты пропустил. Смягчи вживую (коалесцируй обновления потока в батченый/транзишен-коммит, сузь подписку, стабилизируй значение), затем напиши пост-мортем. Фикс — это изменение render-пути, а не «затротлить сервер».

    Критерии готовности
    • Ты воспроизвёл шторм, прогнав поток на пиковой частоте, и зафиксировал всплеск INP и потерянные кадры в Profiler / RUM.
    • Ты смягчил это на render-пути (батченые/транзишен-коммиты, суженная подписка или стабилизированное значение) и показал возврат INP под 200 мс на той же частоте событий.
    • Твой пост-мортем называет триггер, корневую причину на render-пути, радиус поражения, фикс и одну превенцию, которая не «зарейтлимить поток».
    Самопроверка

    Вставь корневую причину из пост-мортема и пункт превенции; senior-ревьюер проверяет, что назван механизм на render-пути (слишком широкая подписка / нестабильное значение / отсутствие батчинга), а не только симптом (высокий INP).

Рубрика

Джуниор Миддл Сеньор
Размещение RSC и границ Suspense Страница использует 'use client' на высоком уровне, отгружая большую часть дерева компонентов в клиентский бандл; сервер рендерит немногим больше скелета. Оболочка — Server Component, доставляющий первый снапшот в HTML; интерактивные острова вынесены на листья с явными границами 'use client', а Suspense оборачивает асинхронные данные, чтобы контент стримился, а не блокировал оболочку. Ты можешь назвать, что пересекает границу сериализации (никаких функций, инстансов классов или Date по проводу) и показать в Profiler, что живое событие перерендеривает только подписанный остров, а не всё дерево. Интерактивный остров вынесен code-split'ом, и граница чанка видна в реальном build-выводе.
Получение данных на сервере/клиенте и устранение водопадов Данные запрашиваются в useEffect после монтирования; пользователь видит спиннер пока запрос выполняется, и дочерние запросы начинаются лишь после рендера родителя. Начальный снапшот запрашивается на сервере и приходит в HTML; живой поток гидрирует клиентский store и сводит входящие события в снапшот без водопадов и дублей. Оптимистичные мутации используют React 19 Actions (useOptimistic) — UI отражает изменение немедленно, откатывается к истинному серверному состоянию при отказе без мерцания, и повторённая или продублированная мутация идемпотентна. Конкурентное живое событие, пришедшее в ходе незавершённого действия, сходится к одному консистентному состоянию.
Разбивка кода и стоимость гидрации Весь код компонентов попадает в начальный бандл независимо от расположения выше или ниже сгиба; гидрация блокирует интерактивность до парсинга всего JS. Острова вынесены code-split'ом, их JS не входит в начальную загрузку; виртуализированная лента держит число закоммиченных DOM-узлов примерно постоянным от 100 до 10k строк, и у тебя зафиксировано улучшение INP по сравнению с наивным списком. Полевой RUM (p75 LCP, INP, CLS) собирается и привязан к бюджетам из этапа очертания; алерт срабатывает, когда p75 INP пересекает 200 мс. Ты можешь назвать, какие чанки бандла попали в build-вывод и почему граница сплита именно здесь, а не в другом месте.
Корневая причина render-шторма и исправление Всплеск потока вызывает заметную задержку; пост-мортем говорит «слишком много событий», и смягчение — троттлинг потока. Флеймграф Profiler показывает, какой компонент перерендеривался на каждое сообщение; ты сужаешь подписку на store или стабилизируешь context-значение для исправления, и INP падает ниже 200 мс при той же частоте событий. Пост-мортем называет механизм на render-пути (слишком широкая context-подписка / нестабильный колбэк, ломающий мемоизацию / отсутствие батчинга/транзишенов) и превенция — структурное изменение, а не «зарейтлимить сервер». Ты также воспроизвёл шторм, прогнав поток на пиковой частоте, и зафиксировал всплеск INP как в RUM, так и в Profiler.
Эталонный разбор (спойлер)

Граница сериализации RSC: через границу сервер→клиент могут пройти только JSON-сериализуемые значения — простые объекты, массивы, строки, числа, null и React-элементы. Функции, инстансы классов, Date, Map и Set не могут; их передача молча ломает гидрацию или бросает исключение в рантайме. Граница также задаёт JS-бюджет: всё выше неё — нулевая стоимость на клиенте.

Безглючные живые обновления: событие живого потока должно обновлять только ячейки store, которые изменились, и вызывать перерендер только в компонентах, подписанных именно на эти ячейки. Context-значение, меняющее форму на каждое событие, перерендерит каждого потребителя — классическая корневая причина render-шторма. Предпочитай внешний store (Zustand, useSyncExternalStore) с подписками уровня селектора широкому context-значению.

Жизненный цикл оптимистичной мутации: применить сразу → дождаться сервера → сверить. Трудные случаи — отказ (откат к снапшоту до действия без мерцания) и конкурентные живые события (живое обновление, пришедшее в ходе незавершённого действия, должно сходиться к одному консистентному состоянию — что требует идемпотентности действия и признания живых событий истиной после завершения действия).

Полевая производительность против лабораторной: лабораторные числа (Lighthouse, локальный Profiler) измеряют одно быстрое устройство на быстрой сети. Полевой RUM собирает реальные p75-значения на медленных устройствах и нестабильных сетях. Фича с баллом 100 в Lighthouse всё равно может превышать бюджеты INP в продакшене, если подписка на store слишком широка или эффект срабатывает синхронно на горячем пути.

Сделай по-сеньорски

  • Добавь presence и живые курсоры (кто смотрит, кто действует) поверх того же транспорта, не создавая второй риск render-шторма.
  • Сделай разрешение конфликтов явным: когда двое действуют на одной строке, определи и реализуй правило слияния (last-write-wins или в стиле CRDT), а не отдавай решение потоку.
  • Сохраняй и восстанавливай клиентское состояние вида (фильтры, позиция скролла, закреплённые строки) между перезагрузками и вкладками без hydration mismatch.
  • Добавь режим graceful-degradation: когда поток упал, переходи на polling и показывай ненавязчивый индикатор устаревания вместо поломки фичи.

Навыки

RSC + client islandsreal-time state syncoptimistic mutationslist virtualizationaccessibility (ARIA + focus)frame/INP budgetingfield RUMrender-storm debugging

Рекомендуемый стек

React 19a streaming SSR framework (Next.js App Router or equivalent)Server-Sent Events or WebSocket transportTanStack Query or an external store (Zustand/Redux/useSyncExternalStore)a virtualization library (TanStack Virtual or react-virtuoso)axe-core + Testing Library + Playwrighta RUM / web-vitals collector