frontend · advanced · 8d
React-фича под нагрузкой
Выкати одну настоящую production-фичу на React — живой совместный дашборд активности — а потом эксплуатируй её: оптимистичные правки, стриминговые обновления, бюджет на кадр, полную доступность и разбор инцидента, когда render-шторм замораживает вкладку.
Результат
Задеплоенный real-time дашборд активности: RSC-оболочка с клиентскими островами, живые обновления по стриму, оптимистичные мутации с откатом, виртуализированная лента событий, пройденный аудит axe + клавиатура, отслеживаемый бюджет кадра/INP и написанный пост-мортем render-шторма.
Этапы
0/8 · 0%- 01Очерти фичу: поверхность, бюджеты, non-goals
До любого JSX реши, что такое этот дашборд и чем он не является. Он read-heavy и движим обновлениями: много зрителей, ровный поток событий, несколько интерактивных мутаций. Реши render-модель заранее — что есть серверно-отрендеренная статичная оболочка, а что обязано быть интерактивным клиентским островом, — потому что этот выбор задаёт стоимость гидрации и time-to-interactive. Выпиши пользовательские бюджеты (INP < 200 мс на клик фильтра, p75 LCP < 2.5 с, без потерянных кадров пока стримится лента) и явные non-goals, чтобы не вылизывать админку, которую никто не просил.
Критерии готовности- У тебя есть карта компонентов, помечающая каждую область как серверную оболочку или клиентский остров, с однострочной причиной на остров.
- Ты выписал 3 пользовательских бюджета (цель по INP, цель по LCP, цель по кадру при стриминге) и минимум два явных non-goal.
- 02Спроектируй владение состоянием: сервер, клиент и граница
Реши, где живёт каждый кусок состояния, до того как напишешь хук, потому что неправильный владелец и порождает render-шторм в последнем этапе. Серверное состояние (история событий, текущий снимок) принадлежит слою фетча/кэша; клиентское состояние (какой фильтр активен, какая строка в фокусе) — это локальный UI; живой поток питает store, который читают многие компоненты. Выбери примитив синхронизации — кэш запросов или внешний store через useSyncExternalStore — и проведи границу сериализации: то, что пересекает её от серверного компонента к клиентскому острову, должно быть сериализуемым, иначе функции, инстансы классов и Date тихо ломают гидрацию.
Критерии готовности- У каждого куска состояния ровно один владелец (серверный кэш, локальный UI или общий store), выписанный, и ты можешь назвать тот, в который пишет живой поток.
- Ты перечислил, что пересекает границу сервер→клиент, и подтвердил, что всё сериализуемо (никаких функций или Date по проводу).
- 03Построй RSC-оболочку, острова и путь живых обновлений
Отрендери оболочку дашборда на сервере с первым снимком, уже вшитым в HTML, затем гидрируй только интерактивные острова, чтобы страница была полезна до парсинга JS. Подключи живой транспорт (SSE или WebSocket) к общему store и сводь входящие события в снимок — именно тут кусаются упорядочивание, дедуп и reconnect/backfill. Держи поток вне render-blocking пути: событие должно обновлять store и давать перерендериться только подписанным строкам, а не перерендеривать всё дерево на каждое сообщение.
Критерии готовности- Серверно-отрендеренная оболочка показывает полезный первый снимок до гидрации, и только помеченные острова отгружают клиентский JS.
- Живые события обновляют store и перерендеривают только подписанные строки (ты подтвердил это в Profiler, а не всё дерево), и reconnect добирает пропущенные события без дублей.
- 04Добавь оптимистичные мутации с честным откатом
Senior-дашборд даёт действовать — подтвердить событие, заглушить источник, закрепить строку — и ощущается мгновенным. Реализуй это через React 19 Actions (useActionState / useOptimistic), чтобы UI применял изменение сразу, а потом сверялся с результатом сервера. Сложное — это отказ: сервер отклоняет, или живое событие приходит в полёте и конфликтует. Откатись к настоящему состоянию без мерцания, покажи отказ пользователю и сделай операцию идемпотентной, чтобы ретрай или дублирующее событие не применились дважды.
Критерии готовности- Действие применяется оптимистично и откатывается к истинному серверному состоянию при отказе, с показом отказа пользователю (а не проглатыванием).
- Повторённая или продублированная мутация идемпотентна — не применяется дважды, и конфликтующее живое событие сходится к одному консистентному состоянию.
- 05Протестируй асинхронную поверхность и виртуализируй ленту
Риск дашборда — это асинхронность и объём. Тестируй с места пользователя: замокай поток и эндпоинт мутации через MSW, гоняй взаимодействия через user-event и проверяй то, что видит пользователь после того, как оптимистичная запись разрешилась или упала, — а не внутреннее состояние. Затем виртуализируй ленту событий, чтобы 10k строк рендерили только видимое окно: наивный список перекладывает весь DOM на каждый тик потока и роняет INP, а виртуализированный держит число закоммиченных узлов плоским. Замерь разницу, а не предполагай её.
Критерии готовности- Тесты покрывают путь оптимистичного успеха и отклонённого отката через MSW + user-event, проверяя видимый вывод, и идут зелёными одной командой.
- Лента виртуализирована: число закоммиченных DOM-узлов держится примерно постоянным от 100 до 10k строк, и ты записал улучшение INP относительно наивного списка.
- 06Сделай доступным: семантика, фокус, live-регионы
Real-time дашборд — минное поле доступности: контент обновляется без действия пользователя, фокус может выдернуться при сдвиге строк, а виртуализированная лента прячет узлы, которых ждёт скринридер. Используй настоящую семантику (списки, кнопки, заголовки — а не div'ы с onClick), осознанно управляй фокусом, чтобы оптимистичное обновление или смена фильтра не бросили пользователя клавиатуры, и анонсируй значимые изменения через ARIA live-регион, не спамя на каждый тик потока. Композитные виджеты (меню фильтров, действия строки) требуют полной работы с клавиатуры и корректных ролей.
Критерии готовности- Аудит axe-core проходит на дашборде без critical/serious нарушений, и ты можешь управлять всей фичей — фильтр, действие на строке, закрытие меню — только клавиатурой.
- Значимые обновления анонсируются через polite live-регион (а не каждый тик потока), и фокус сохраняется или перемещается осознанно при оптимистичных обновлениях и сменах фильтра.
- 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-выводе.
- 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 и показывай ненавязчивый индикатор устаревания вместо поломки фичи.