Бывает, что фича готова и её пора передавать в тестирование, но при этом не покидает ощущение, что что-то не так. Однажды внутреннее чутьё меня не обмануло и привело в исходники React.

Меня зовут Денис Кондратьев — я фронтенд-разработчик в Точка Банк. В статье поговорим, как работает приоритетный рендеринг в React, что такое проблема разрыва и разберём реальный кейс на примере корпоративного мессенджера.

Проблема с голосовыми

Всё началось с простой задачи — добавить голосовые сообщения в наш корпоративный мессенджер. Готовая фича должна была выглядеть так:

Пользователь записывает голосовое сообщение → Оно отображается в чате в виде аудиоплеера → Плеер показывает прогресс прослушивания

При этом у сообщения есть тред — отдельный экран с комментариями. По клику он загружает данные с бэкенда и, пока идёт запрос, крутится спиннер.

После написания кода я решил проверить основные сценарии. На первый взгляд всё работало как надо: голосовые сообщения записывались и воспроизводились, плеер показывал прогресс. Но после нажатия на тред интерфейс неожиданно завис в состоянии загрузки.

Судя по логам, бэкенд ответил со статусом 200, то есть данные пришли быстро, но спиннер всё равно продолжал крутиться. Тред открылся, только когда аудио проигралось до конца. Я решил разобраться, в чём причина.

Попытки дебага

Первое, что я сделал — пошёл проверять свой исходный код. Начал с функции, которая обновляет значение таймера воспроизведения голосового сообщения.

start(): void {
   this.rafId = requestAnimationFrame(() => {
       this.setProgress(this._webApiPlayer.currentTime);
       this.start();
   });
}

Дальше посмотрел на компонент отображения: он получает текущее время через хук, переводит его в прогресс и передаёт в визуальный бегунок.

constcurrentTime = useAudioPlayerProgress(audioController);

const progress = 100 / (duration / currentTime);

return <AudioPlayer progress={progress} />;

Затем — компонент активного треда, который обёрнут в Suspense и показывает спиннер, пока загружаются данные.

return (
   <Suspense fallback={fallback}>
       <ActiveThread />
   </Suspense>
);

На первый взгляд всё выглядело правильно: таймер обновлялся через requestAnimationFrame, данные для треда приходили, а Suspense показывал спиннер во время загрузки. То есть код выглядел слишком хорошо, чтобы быть причиной ошибки.

Я поискал ответы в интернете, спросил у нейросетей, но никаких зацепок не нашёл. Стало понятно, что нужно копать глубже. Пришлось идти в исходный код React, ставить инструкции дебаггера и смотреть, что происходит внутри.

Как работает React

Чтобы понять, в чём была причина, нужно разобраться, как React работает под капотом. Не буду пересказывать всю документацию — только то, что реально пригодится в нашем кейсе.

Если коротко, процесс рендеринга в React состоит из трёх этапов:

  • Фаза планирования. React получает обновления состояния, складывает их в очередь и решает, какие важнее.

  • Фаза рендера. Работает с виртуальным DOM, строит и обновляет дерево компонентов.

  • Фаза коммита. Взаимодействует с реальным DOM, т.е. интерфейсом пользователя.

Важно, что фаза рендера может быть прервана в любой момент ради более срочной задачи. То есть React постоянно принимает решения, какую задачу сделать сейчас, а какую можно отложить.

Чтобы понять, как устроен этот механизм, нужно заглянуть чуть глубже в виртуальный DOM. Он представлен в виде дерева специальных объектов — Fiber.

Каждый такой объект содержит несколько групп полей:

  • Основные свойства: tag, key, type, elementType, stateNode.

  • Взаимосвязи в дереве: return, child, sibling.

  • Работа с обновлениями: pendingProps, memoizedProps, memoizedState.

  • Приоритеты: lanes, childLanes.

Алгоритм обхода дерева тоже не совсем привычный. React не идёт рекурсивно в глубину, как мы привыкли. Сначала он спускается к первому потомку (child), затем проходит соседние узлы (sibling), а чтобы вернуться к родителю, использует поле return. Благодаря этому он может в любой момент остановиться и продолжить с того же места.

Чтобы React решал, какую задачу выполнять следующей, у каждого Fiber есть два поля:

  • lanes — приоритет задач в текущем узле.

  • childLanes — приоритет задач у потомков.

Когда React доходит до очередного Fiber, он делает простую проверку. Если в lanes есть работа — выполняет её. Если более приоритетная работа, например, реакция на действия пользователя, находится в childLanes — спускается к потомкам.

Именно этот механизм позволяет постоянно переключаться между задачами. И в нашем случае это сыграло ключевую роль.

Лейн (lane)

Приоритет

Функция или компонент

Sync Lane

Очень высокий

Обработчики событий useSyncExternalStore/useState

Default Lane

Высокий

Обновление без приоритета useState

Transition Lane

Средний

Функция startTransition

Retry Lane

Низкий

Механизмы Suspense

Idle Lane

Низкий

requestIdleCallback

Offscreen Lane

Низкий

Offscreen API

Гонка приоритетов

Так выглядел мой тестовый проект, на котором я дебажил React. Это наше виртуальное DOM-дерево: для удобства я убрал HTML-элементы и оставил только самые важные компоненты. Внизу добавил условные обозначения приоритетов:

  • зеленый — низкий приоритет;

  • красный — высокий приоритет;

  • синий — работа выполняется в данный момент.

Когда мы нажимаем кнопку «воспроизвести», меняется состояние таймера и бегунок начинает двигаться. Это приводит к обновлению компонента таймера. Поскольку внутри используется Sync External Store, оно получает высокий приоритет.

После этого React помечает красным не только сам компонент, но и всю родительскую ветвь. Это нужно, чтобы он понимал, куда идти и где обновлять компонент.

Так как других задач в данный момент нет, React берёт её в работу и спускается по дереву сверху вниз, начиная с root-компонента.

На каждом узле он проверяет Lanes и ChildLanes: если в lanes работы нет (то есть стоит значение NoLane), узел пропускается, и React идёт дальше. Так он доходит до каунтера и обновляет значение.

Важно, что на этом этапе изменения ещё не видны пользователю — всё происходит только в виртуальном DOM.

После завершения работы React поднимается обратно вверх по дереву, используя поле return, доходит до корневого узла и завершает фазу рендера.

Затем наступает фаза коммита, и только в этот момент пользователь видит изменения: ползунок в интерфейсе начинает двигаться.

Этот цикл повторяется много раз, пока таймер не закончится.

Теперь добавим второе действие: когда пользователь нажимает на тред, начинается загрузка данных, и по умолчанию отображается спиннер через Suspense. Такие обновления имеют низкий приоритет, поэтому соответствующая ветка помечается зелёным и задача попадает в очередь.

Так как в этот момент других задач нет, React берёт её в работу и снова начинает обход дерева сверху вниз.

Но в процессе выполнения возникает новая задача — обновление таймера. Она имеет более высокий приоритет, поэтому React делает следующее:

  • приостанавливает текущую (низкоприоритетную) работу;

  • возвращает её обратно в очередь;

  • переключается на выполнение высокоприоритетной задачи.

Дальше он снова проходит по дереву, обновляет таймер и завершает эту задачу.

После этого React возвращается к отложенной задаче с низким приоритетом, снова начинает обход и снова прерывается следующим обновлением таймера. Этот цикл повторяется до тех пор, пока голосовое сообщение не проиграется до конца.

И только когда таймер дойдёт до 100% и новые высокоприоритетные обновления перестанут приходить, React наконец сможет спокойно завершить низкоприоритетную задачу.

Тогда наш тред появится на экране.

Что в итоге произошло

Можно сказать, что в интерфейсе произошёл разрыв. Обычно этот термин используют в графическом программировании для обозначения визуального несогласования.

Если посмотреть на issues в репозитории React, можно найти похожие описания. В частности, там говорится, что при использовании Concurrent Mode, startTransition или Suspense React может приостанавливать текущую работу, чтобы переключиться на более приоритетные задачи. Именно это мы и наблюдали в нашем кейсе.

Для этого даже есть специальный термин — голодание (starvation). Это состояние, когда задачи с низким приоритетом постоянно откладываются или не выполняются вовсе из-за непрерывного потока более приоритетных задач.

Вот что об этом пишет коллега из команды React:

Обсудили с командой. Все переходы запутываются в текущей реализации. Запланирован проект по решению этой проблемы, но после релиза есть более срочные дела, поэтому мы рассчитываем вернуться к ней не раньше второй половины этого года. Очевидно, что проблема станет ещё более актуальной по мере того, как конкурентные функции начнут внедряться.

Комментарий был создан ещё в 2022 году, но до сих пор ничего не изменилось.

Как убрать разрыв

Когда стало понятно, в чём причина, мы начали перебирать варианты, как решить проблему. Идей было много, но почти у каждой находились серьёзные ограничения.

  • Отказаться от React там, где нужна скорость. Теоретически это решает проблему, практически — тянет за собой много последствий. Мы используем готовую дизайн-систему, все компоненты которой написаны на React. Отказ от него означал бы, что часть интерфейса придётся переписывать, а потом ещё и поддерживать.

  • Отказаться от Suspense. У него низкий приоритет, и именно он постоянно проигрывает в конкуренции с обновлениями таймера. Но вместе с Suspense мы теряем удобную модель работы с асинхронностью, в том числе error boundaries и всплытие ошибок по дереву.

  • Использовать CSS. Это позволило бы вынести обновление таймера из React и убрать конкуренцию приоритетов. Но тогда нужно синхронизировать состояния между CSS и JavaScript. К тому же такой подход плохо сочетается с нашей дизайн-системой.

  • Использовать React Spring. Этот вариант выглядел перспективно, но так как сроки были ограничены, мы решили от него отказаться.

Были и другие радикальные идеи, типа canvas, SVG или SolidJS. Но, думаю, тут комментарии излишни. В нашем случае это выглядело как чрезмерное усложнение ради одной конкретной проблемы. Нам нужно было решение, которое впишется в текущую систему и не сломает остальную часть приложения.

Что мы сделали в итоге

В документации React в подобных ситуациях рекомендуют использовать useSyncExternalStore, то есть переводить обновления в максимально высокий приоритет (Sync Lane).

Но в нашем случае это не решало проблему, а наоборот — усугубляло её. В чате уже используется Suspense с низким приоритетом. И добавление ещё большего количества высокоприоритетных задач означало бы, что низкоприоритетная работа продолжит откладываться.

Тогда я решил копнуть в сторону React Scheduler. О нём довольно мало информации в сети, но идея звучит обнадёживающе: если низкоприоритетная задача слишком долго не выполняется, планировщик должен повысить её приоритет.

На практике всё оказалось не так радужно. Я снова начал записывать голосовые, но так и не дождался загрузки треда. Scheduler в нашем сценарии ничем не помог.

В итоге из всех вариантов сработал только один. Мы не стали повышать приоритет медленной задачи, а понизили приоритет быстрой. Предвосхищая вопросы — да, так можно было ?

Для этого мы обернули обновление состояния в startTransition. Под капотом это тот же useState, но с другим приоритетом: обновления переходят в Transition Lane (средний приоритет), а значит таймер и Suspense соревнуются почти на равных. И наконец всё заработало.

return useRxBind(
   playerProgress$.pipe(throttleTime(throttle)),
   startTransition:true
);

useRxBind — это кастомный хук, который подписывается на RxJS-стрим и синхронизирует его значение с состоянием React. Флаг startTransition: true говорит хуку оборачивать обновления состояния в startTransition, тем самым переводя их в Transition Lane.

В дополнение мы немного притормозили обновления таймера через throttle, что ещё сильнее снизило нагрузку.

В итоге:

  • голосовые сообщения продолжили нормально воспроизводиться;

  • тред стал открываться сразу, даже во время проигрывания.

Ирония в том, что на поиск причины ушло около двух недель, а само решение заняло две минуты ?

Комментарии (0)