PunchSpain mourns after latest train tragedy kills 69ESPNEvery Premier League team ranked by bang-for-buck: What if they spent like Man City?Bollywood HungamaBhumi Pednekar reunites with Akshay Kumar for Priyadarshan’s next comedyInquirerMMDA suspends coding scheme on Monday, Nov. 2UN NewsAfghanistan opium production jumps fivefold as farmers find ways round Taliban banDaily MaverickEl Salvador to send over 2,000 soldiers to Haiti for rescue while elections postponedEl ComercioEstados Unidos revela su hoja de ruta para Venezuela: reformas, vacancia de Delcy Rodríguez y eleccionesZDF heuteAktuelle Pressemitteilungen des ZDFFotogramas"Perfecta para ver en familia en Halloween": La película con la que vuelve a los cines el personaje más entrañable de la animación 'stop motion'Business AMDuitse elitesoldaten van KSK bereiden zich voor op mogelijke oorlog met RuslandSoompiSon Suk Ku And Nagayama Eita’s Opposing Detective Styles Complicate Their Partnership In “The Blue Road”Egypt IndependentHeal the victim, heal the world – how Sri Sri Ravi Shankar reclaims lives in war zones, prisons, and rural communities
The Daily Newsstand · Free, Always
Thursday, October 29, 2026

Как React прерывает рендер: конкурентный рендеринг изнутри

Translate

Начиная с React 18 рендер можно прервать: срочные обновления обгоняют несрочные, даже если React уже взялся за несрочное. Если задуматься, вопросов много:

— Как React посреди рендера отдаёт поток браузеру, чтобы тот обработал ввод, а потом продолжает с того же места? — Почему до React 16 он так не умел? — Кто решает, какие обновления срочные, и почему большинство рендеров не прерывается вообще? — Что происходит с незакоммиченными обновлениями, когда пришло что-то срочное? — И главное, как обновления, которые выстраиваются в очередь, не путаются в порядке, если срочное пришло позже, но должно обогнать несрочные?

Команда React сознательно не нагружает этим разработчиков: в анонсе React 18 конкурентность названа деталью реализации — в общих чертах понимать её стоит, а знать, как она устроена, необязательно. И это разумно: пользоваться startTransition можно, не заглядывая под капот. Но мне стало интересно, и я полез в исходники React 19.

Всё разбираем на одном простом примере — поиске по списку.

function Search() {
const [inputQuery, setInputQuery] = useState("");
const [listQuery, setListQuery] = useState(«");

const onChange = (e) => {
const { value } = e.target;
setInputQuery(value);
startTransition(() => setListQuery(value));
};

return <>
<input value={inputQuery} onChange={onChange} />
<List listQuery={listQuery} />
</>;
}

const List = memo(({ listQuery }) =>
filterProducts(listQuery).map((p) =>
<Row key={p.id} item={p} />));

Поле ввода обновляется сразу, а список — через startTransition.

1. Как React обновляет интерфейс

Пару слов, чтобы мы вкладывали один и тот же смысл в термины.

У React два дерева: current - соответствует тому, что сейчас на экране, и WIP-дерево (work-in-progress) — его React строит, когда что-то меняется.

React не правит current на месте. Он строит рядом WIP-дерево: вызывает компоненты, сравнивает их новый результат с тем, что лежит в current, и отмечает, что поменялось. Это и есть рендер. В React это слово не про рисование: работа идёт в памяти, DOM и экран не меняются.

Когда WIP-дерево готово, React применяет найденные изменения к DOM, и WIP-дерево становится новым current. Это коммит. После него браузер перерисовывает страницу.

Узлы двух деревьев связаны попарно ссылкой alternate: обходя WIP-дерево, React по этой ссылке находит парный узел в current и берёт оттуда прошлое состояние хуков и прошлый результат рендера. Узлы WIP-дерева обычно даже не создаются с нуля. После коммита деревья меняются ролями: WIP-дерево становится current, а прежнее current идёт в дело при следующем рендере: из его узлов React и строит новое WIP-дерево, перезаписывая их новыми данными.

Что является причиной рендера? Отдельный компонент чаще всего перерендеривается по одной из трёх причин:

— изменилось его состояние; — перерендерился родитель; — изменился контекст.

А как же пропсы? Их изменение — не отдельная причина: новые пропсы появляются, только когда перерендерился родитель.

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

— самый первый рендер; — внешний стор в useSyncExternalStore; — данные, которых компонент ждал в Suspense; — forceUpdate у классового компонента; — в режиме разработки StrictMode, который вызывает компоненты дважды, и Fast Refresh.

Их оставим в стороне — дальше достаточно проследить путь одного обновления состояния.

Путь такой. Срабатывает сеттер из useState или dispatch из useReducer - разницы нет: внутри React useState сделан через useReducer, поэтому сеттер — это тот же dispatch, и путь у обновления один и тот же.

  1. Запрос. Сеттер ничего не меняет на экране сразу — он только создаёт запрос на обновление (дальше для краткости — просто обновление). Обновление встаёт в очередь обновлений своего хука. У каждого функционального компонента есть список хуков (связный список; у классов устроено иначе), а у каждого хука — своя такая очередь: тоже связный список, но кольцевой и с указателем на хвост. Ещё обновление получает приоритет — он зависит от того, откуда вызван сеттер: из обработчика клика, из startTransition, из таймера. В коде React приоритет называется lane. Но в очереди приоритет роли не играет: новое обновление всегда встаёт в хвост. Очередь — общий объект для пары узлов в current и WIP-дереве, поэтому обновление не потеряется, даже если WIP-дерево выбросят (такое бывает — об этом ниже).

  2. Планирование. Приоритет есть не только у обновления, но и у каждого узла дерева. У узла два таких поля: lanes - приоритеты обновлений, которые ждут в самом компоненте, и childLanes - приоритеты обновлений, которые ждут в его дочерних компонентах на любой глубине. React записывает приоритет обновления в lanes компонента, а потом поднимается по родительским компонентам до корня и добавляет этот приоритет в childLanes каждого. Вызывать компоненты — то есть рендерить — React при этом сразу не начинает. React ждёт, пока закончится текущий код — например, весь обработчик клика, — чтобы все обновления из него попали в один рендер. И только потом решает, что делать: самое срочное (клик, ввод с клавиатуры) рендерит тут же, а остальное отдаёт планировщику — отдельному пакету React, который решает, когда эту работу выполнить.

  3. Рендер. За один рендер React берёт не все ожидающие обновления, а только самые срочные из них — остальные подождут следующего рендера. React обходит дерево от корня. По childLanes он видит, в каких ветках ждут обновления с этим приоритетом, и спускается только туда, а остальные пропускает. По lanes находит сами компоненты с такими обновлениями. Их и их дочерние компоненты React вызывает заново. Новый результат сравнивается с тем, что лежит в current, и собирается в WIP-дерево. Экран всё это время не меняется.

  4. Коммит. Изменения из готового WIP-дерева применяются к DOM за один раз, без пауз, и WIP-дерево становится current. Поэтому пользователь никогда не видит интерфейс наполовину обновлённым. Дальше браузер рисует страницу.

Теперь, что такое конкурентный рендеринг. Без него React обрабатывает обновления по очереди: взял пачку (несколько обновлений подряд он объединяет в одну), довёл до экрана, взял следующую. Пока идёт тяжёлое обновление, всё остальное ждёт, и интерфейс «замерзает». Тяжёлое обновление блокирует поток — а в нём же браузер обрабатывает ввод и рисует страницу. Поэтому, пока обновление не закончится, ни нажатие клавиши, ни перерисовка не произойдут.

В конкурентном рендеринге у обновлений появляется приоритет. Срочное — например, ввод с клавиатуры — может обогнать несрочное, даже если то уже наполовину посчитано: React выбрасывает недоделанное WIP-дерево и сначала рендерит срочное. Выбросить его безопасно — экран во время рендера не меняется. В итоге поле ввода отзывается мгновенно, а тяжёлый список догоняет чуть позже. Вся эта конкурентность — в том, когда React начинает рендер и как его прерывает. Коммит же всегда идёт целиком, без пауз.

2. Пример: поиск по списку

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

Задача: символы в поле должны появляться мгновенно, даже если список перестраивается долго. Поэтому текст хранится дважды — для поля и для списка — и обновляется с разным приоритетом.

Это тот же код, что во вступлении, — повторю его здесь, чтобы не бегать туда-сюда.

function Search() {
const [inputQuery, setInputQuery] = useState("");
const [listQuery, setListQuery] = useState(«");

const onChange = (e) => {
const { value } = e.target;
setInputQuery(value);
startTransition(() => setListQuery(value));
};

return <>
<input value={inputQuery} onChange={onChange} />
<List listQuery={listQuery} />
</>;
}

const List = memo(({ listQuery }) =>
filterProducts(listQuery).map((p) =>
<Row key={p.id} item={p} />));

Оба состояния — просто строки, записать строку ничего не стоит. И найти товары тоже быстро: filterProducts в List - просто поиск текста в названиях товаров, он укладывается в пару миллисекунд. Дорого другое — отрендерить Row на каждый найденный товар, а их тысячи.

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

startTransition - функция из React: обновления, сделанные внутри неё, становятся несрочными. Вот что она меняет для пользователя.

Без startTransition. Пользователь нажал клавишу — и страница перестаёт отвечать, пока не перестроится весь список. Только потом символ появляется в поле. Если быстро напечатать «123», React построит три списка подряд: для «1», «12» и «123».

С startTransition. Символ появляется в поле сразу, а под ним пока остаётся старый список. Если пришёл новый символ, а список ещё строится, недостроенный список выбрасывается. Поэтому, если печатать быстрее, чем строится список, промежуточные списки так и не появятся — React сразу построит список для «123».

Отсюда и два состояния с одним и тем же текстом. Без startTransition хватило бы одного. А так у них разный приоритет: inputQuery обновляется сразу, listQuery - когда получится.

А memo нужен, чтобы срочный рендер поля ввода не тащил за собой список: без него List рендерился бы заново при каждом рендере Search, даже с тем же listQuery.

Дальше по ходу статьи следим за четырьмя вещами:

— inputQuery - текст в поле ввода; — listQuery - текст, по которому строится список; — на экране — то, что сейчас в DOM; — WIP-дерево — то, что React считает в памяти, но ещё не показал.

3. Как React останавливает рендер

Смотрим на момент, когда пользователь напечатал «1»: поле уже показывает «1», а список для «1» React пока несрочно строит в памяти — на экране под полем всё ещё полный список.

— inputQuery: "1"; — listQuery: "", а "1« ждёт несрочного рендера; — на экране: в поле «1», под ним все товары, без фильтра; — WIP-дерево: в поле «1», под ним товары с «1» в названии — готова половина

JavaScript не умеет приостанавливать функцию: пока она выполняется, браузер не может ни обработать клик, ни нарисовать кадр. Единственный способ отдать управление — закончить функцию, то есть return. Ровно это React и делает.

WIP-дерево React строит по одному компоненту. После каждого он спрашивает планировщика — отдельный пакет React, который решает, когда работать, — «не пора ли уступить?». На одну порцию работы планировщик даёт 5 мс — это просто константа в его коде (frameYieldMs = 5). При 60 кадрах в секунду кадр длится около 16 мс, так что браузеру остаётся время обработать ввод и перерисовать страницу. На 120-герцовом мониторе кадр — около 8 мс, а порция всё те же 5 мс: частоту экрана планировщик не учитывает.

Компоненты React обрабатывает в цикле: взял компонент, обработал, спросил планировщика, взял следующий. Если планировщик ответил «пора уступить», React просто не берёт следующий компонент — цикл заканчивается, и функция, которая рендерит всё дерево, выходит через return, хотя React прошёл только часть дерева.

Дальше функция React, которую вызвал планировщик, возвращает не пустой результат, а саму себя — колбеком, который надо вызвать, чтобы продолжить рендер. Планировщик сохраняет этот колбек, отправляет асинхронное сообщение сам себе и тоже делает return. Для сообщения он использует MessageChannel - встроенный в браузер канал с двумя концами: что отправлено в один, приходит в другой. Оба конца у планировщика. Сообщение не приходит сразу: браузер ставит его в очередь как макротаску, а между задачами браузер обрабатывает ввод и, если подошло время кадра, рисует его. Стек вызовов пустеет, ввод обработан, потом приходит сообщение, и планировщик вызывает сохранённый колбек — новый вызов, новый стек.

А откуда React знает, с какого компонента продолжить, если стек исчез? Позиция в дереве хранится в обычной переменной workInProgress снаружи — она переживает паузу.

Такой подход называют кооперативной многозадачностью: код никто не прерывает силой, он сам регулярно уступает управление. В React его ещё называют time slicing — нарезкой работы на порции по времени.

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

Если дочерних компонентов просто много — как тысячи Row в нашем List, - работа уже нарезана: каждый Row отдельный компонент, и React может уступить браузеру между любыми двумя. Конечно можно еще виртуализировать.

А если один компонент сам по себе считает 200 мс — например, сортирует огромный массив прямо в теле, — 5-миллисекундные порции не помогут: этот компонент рендерится целиком. startTransition тоже не спасёт: работа станет несрочной, но когда до неё дойдёт очередь, все 200 мс пройдут одним куском. Такой расчёт надо убирать из рендера:

— Web Worker — отдельный поток в браузере: код в нём выполняется параллельно и не мешает ни React, ни обработке ввода. Считаться быстрее он не станет, но интерфейс не замрёт. — Нарезать расчёт самому — считать порциями и между ними отдавать управление браузеру, например через setTimeout. По сути, повторить вручную то, что React делает с компонентами.

4. Почему до React 16 так было нельзя

Смотрим на тот же момент, когда React строит дерево в памяти. Почему его вообще можно бросить на полпути?

До React 16 дерево обходилось рекурсией: функция обрабатывает компонент и вызывает себя для каждого дочернего компонента. Этот механизм так и называют — stack reconciler. Где ты сейчас в дереве, знал только стек вызовов. А стек не отложишь: вышел из функции — позиция потеряна. Поэтому всё дерево обходилось целиком, без остановок.

В React 16 обход переписали. У каждого компонента появился объект — fiber, с тремя ссылками: на первый дочерний компонент (child), на следующий соседний (sibling) и на родительский (return). Дерево стало связным списком, по которому идут обычным циклом: есть дочерний — вниз, нет — к соседнему, нет соседнего — вверх к родительскому и дальше к его соседнему. Чтобы помнить позицию, хватает одной переменной — той самой workInProgress.

Вывод для практики. Представьте: React успел вызвать половину Row для «1», и тут пользователь напечатал «2». Недоделанное WIP-дерево React выбрасывает и строит заново — уже для «12». Получается, многие Row вызваны дважды, а результат первого вызова на экран так и не попал. Поэтому в теле компонента не должно быть действий, которые влияют на что-то снаружи. Например, если отправлять запрос прямо в теле компонента, запросов уйдёт столько, сколько раз React его вызвал, — в том числе для выброшенных рендеров. То же с подписками и с изменением внешних переменных.

Такому коду место в эффектах или в обработчиках событий. Эффекты запускаются только после коммита — то есть только для того, что React действительно применил к DOM. А обработчики срабатывают на действие пользователя, а не на рендер.

5. Кто решает, что срочно — и что вообще прерывается

Смотрим на момент между вызовом сеттера и рендером: компоненты React ещё не вызывает, а решает, когда это делать.

Каждое обновление получает приоритет по тому, откуда пришло. В нашем onChange:

— setInputQuery вызван прямо в обработчике ввода — срочный; — setListQuery вызван там же, но внутри startTransition - несрочный.

Главное, что легко упустить: по 5 мс React режет только несрочные рендеры.

— Целиком, без пауз: клик, ввод с клавиатуры, скролл, движение мыши, обычный setState после fetch или по таймеру. — Порциями по ~5 мс, с паузами: startTransition, useDeferredValue, повторы Suspense.

Приоритеты внутри первой группы разные: клик срочнее скролла, а скролл срочнее setState после fetch. От этого зависит, как скоро рендер начнётся. Но начавшись, он идёт без пауз.

Кто сравнивает приоритеты? setState не рендерит сразу. Он ставит микротаску через queueMicrotask - в обычную очередь микротасок. Она выполнится, как только закончится текущий код, раньше любых макротасок и отрисовки. Микротаска одна на все setState подряд: оба вызова в нашем onChange разберёт одна и та же микротаска.

В микротаске React берёт самое срочное из ожидающего:

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

Для одного корня — того, что создаёт createRoot, обычно это всё приложение, — React держит у планировщика только одну задачу. Когда приходит более срочное обновление — например, скролл посреди недостроенного списка, — React в микротаске сам отменяет старую задачу и ставит новую, с другим приоритетом.

Вывод для практики. Обычный setState с большим ответом сервера рендерится одним куском и может подвесить ввод. Если рендер после загрузки тяжёлый — оберните обновление в startTransition: работы меньше не станет, но её можно будет прервать. Ну, и, опять же, можно уменьшить саму работу: виртуализировать список или загружать данные порциями.

6. Что происходит с недоделанным WIP-деревом

Смотрим на момент, когда список для «1» посчитан в памяти наполовину — и тут пользователь печатает «2».

— inputQuery: "1" → "12"; — listQuery: "", а "1" и "12« ждут несрочного рендера; — на экране: в поле «1», под ним все товары, без фильтра; — WIP-дерево: в поле «1», под ним товары с «1» в названии — готова половина

Срочный setInputQuery("12") рендерится в микротаске. Но WIP-дерево посчитано для несрочного приоритета, и в нём уже применён listQuery = "1«. Доделать его — значит заставить ввод ждать список. Вырезать из него listQuery - нельзя, он уже разошёлся по половине дерева.

Поэтому React сравнивает: WIP-дерево посчитано не для того приоритета, что нужен сейчас? Тогда оно не нужно — выбрасываем. Новое WIP-дерево строится с нуля — от current, дерева, которое соответствует тому, что на экране. Срочный рендер идёт без пауз до конца, коммит — и поле показывает «12».

Сразу после этого React запускает несрочную работу заново, тоже с нуля. И тут бонус: все ожидающие transition-обновления React рендерит одной пачкой. В очереди listQuery лежат и «1», и «12» — значит, считается сразу список для «12». Список для «1» не появится на экране вообще.

— inputQuery: "12"; — listQuery: "12«; — на экране: в поле «12», под ним товары с «12» в названии; — WIP-дерево: нет

Выбрасывать WIP-дерево безопасно: пока идёт рендер, экран не меняется, а в DOM попадает только законченный результат.

Вывод для практики. Промежуточные значения несрочного состояния могут так и не попасть на экран. Не стройте логику на том, что каждое значение listQuery будет отрисовано, — например, аналитику «пользователь увидел результаты для X» в эффекте на listQuery.

7. Как обновления не путаются в порядке

Смотрим на путь от сеттера до рендера: как обновления встают в очередь хука и в каком порядке рендер их применяет.

Добавим на страницу кнопку «Очистить». Она сбрасывает оба состояния, и оба — срочно, без startTransition:

<button onClick={() => { setInputQuery(""); setListQuery(«"); }}>
Очистить
</button>

Пользователь напечатал «1», и список для «1» уже на экране. Потом он допечатал «2», и setListQuery("12«) ждёт своего несрочного рендера. Не дождавшись, пользователь жмёт «Очистить».

В очереди хука listQuery теперь два обновления:

обновление

приоритет

setListQuery("12")

несрочный

setListQuery("")

срочный

Срочный рендер. React просматривает всю очередь по порядку и у каждого обновления проверяет приоритет. Несрочный setListQuery("12") по приоритету не подходит — перепрыгиваем. Срочный setListQuery("") берем в работу. На экране: пустое поле и полный список. Правильно.

Несрочный рендер. Теперь подходит и setListQuery("12"). После срочного рендера listQuery уже "". Если бы React просто применил setListQuery("12") поверх этого, listQuery стал бы "12": setListQuery("12") не смотрит на прежнее значение, а просто заменяет его. И список внезапно отфильтровался бы по тексту, который пользователь уже стёр. Баг.

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

— memoizedState - текущее значение состояния, его и видит компонент; — baseState - база: значение состояния, с которого следующий рендер начнёт применять обновления; — baseQueue - все обновления, начиная с первого пропущенного, в том порядке, в каком вызывались сеттеры: и пропущенные, и уже применённые. Следующий рендер применит их к базе по порядку.

Проследим, как менялись эти поля с самого начала у хука listQuery - того useState, в котором хранится текст для списка.

Начало. Поле пустое, на экране полный список:

— memoizedState: ""; — baseState: «"; — baseQueue: пусто

Напечатана «1». Сам сеттер эти поля не меняет — он только ставит обновление в очередь. Поля пересчитываются, когда React вызывает компонент и хук разбирает свою очередь. Первым это делает срочный рендер от setInputQuery("1"): оба состояния, inputQuery и listQuery, объявлены в одном компоненте — Search, и React вызывает его целиком. Хук listQuery находит в очереди несрочное обновление setListQuery("1") и пропускает его. На первом пропуске React фиксирует базу — значение до пропуска, "", - и откладывает это обновление в baseQueue. С этого момента baseState не следует за memoizedState, пока пропущенное не применят:

— memoizedState: ""; — baseState: ""; — baseQueue: setListQuery("1«) (несрочное)

Несрочный рендер для «1». React начинает с базы "" и применяет setListQuery("1«). Пропусков нет, поэтому baseQueue очищается, а в baseState React записывает то же, что в memoizedState. Список для «1» на экране:

— memoizedState: "1"; — baseState: "1«; — baseQueue: пусто

Напечатана «2». Всё как с «1»: срочный рендер поля пропускает несрочное обновление setListQuery("12"), база фиксируется на "1", а само обновление уходит в baseQueue:

— memoizedState: "1"; — baseState: "1"; — baseQueue: setListQuery("12«) (несрочное)

Нажата «Очистить». Кнопка вызывает setListQuery("") без startTransition - это срочное обновление. Срочный рендер снова перепрыгивает через setListQuery("12"), а setListQuery("") применяет. Но setListQuery("") идёт в очереди после пропущенного setListQuery("12"), поэтому тоже попадает в baseQueue - хоть и уже применён.

В этом и смысл правила: baseQueue хранит обновления в исходном порядке вызовов, независимо от приоритета. Несрочный рендер прогонит их от базы по порядку — так, как если бы приоритетов не было вовсе. Только применённому setListQuery(«") React в baseQueue ставит приоритет 0 (NoLane). Нулевой приоритет React считает подходящим для любого рендера, так что такое обновление больше никогда не пропустят. В исходниках так и написано: «это обновление будет показано, мы не хотим его откатывать» (This update is going to be committed so we never want uncommit it).

Поля после клика:

— memoizedState: ""; — baseState: "1"; — baseQueue: setListQuery("12") (несрочное), setListQuery("") (приоритет 0)

После несрочного рендера. React начинает не с memoizedState, а с базы и прогоняет baseQueue по порядку: "1" → "12" → "". Итог — "", как будто всё шло по порядку с самого начала.

— memoizedState: ""; — baseState: «"; — baseQueue: пусто

В коде React это называют rebasing — как в git: отложенные изменения заново накатываются на базу.

Вывод для практики. Порядок вызовов setState сохраняется, даже если у них разный приоритет, — ничего делать не нужно. Но между рендерами на экране может быть промежуточное состояние: «срочные уже применены, несрочные — ещё нет».

8. Приоритеты — это биты

Смотрим на путь от сеттера до рендера: как React помечает, какая работа ждёт и насколько она срочная.

Приоритет в React — не число 1, 2, 3, а бит в 31-битном числе. Такие биты называются lanes («полосы»). Чем младше бит, тем срочнее: клик и ввод, непрерывные события вроде скролла, обычные обновления, 14 бит под transition, биты под Suspense и фоновую работу.

Набор приоритетов — одно число, поэтому всё считается одной операцией:

— входит ли обновление в рендер: (set & lane) === lane; — объединить наборы:a | b; — самый срочный: lanes & -lanes, младший бит.

Отсюда и то, что нулевой приоритет подходит любому рендеру: (set & 0) === 0 верно при любом set.

Группы и ротация. Для transition в React не один бит, а целая группа: 10 бит под startTransition и ещё 4 под useDeferredValue. Так же устроены повторы Suspense — тоже 4 бита. Когда React выбирает, что рендерить, он берёт сразу все ожидающие биты группы — поэтому все transition-обновления и рендерятся одной пачкой.

Зачем тогда несколько бит, а не один? Чтобы различать transition между собой. Например, если один из них ждёт данных в Suspense, React помечает его бит как приостановленный и рендерит остальные, не дожидаясь его.

Биты в группе раздаются по кругу. Каждое новое событие со startTransition получает следующий бит, а после последнего React возвращается к первому. Внутри одного события бит один: два startTransition в одном обработчике попадут в один бит. А если по кругу выпал бит, который ещё занят старым transition, новый просто разделит его со старым — и рендериться они будут вместе.

Биты помогают и не обходить всё дерево. Когда меняется компонент, React поднимается от него к корню и ставит каждому предку бит «внизу есть работа» (childLanes). При рендере ветку без нужного бита React пропускает целиком, не заходя внутрь.

Почему 31, а не 32. Старший, 32-й бит в JavaScript означает минус, и React его просто не занимает: любой набор приоритетов — положительное число. Объяснения этому в исходниках я не нашёл — видимо, так просто проще, чем следить за знаком.

Итог

— Как остановить рендер? React рендерит по одному компоненту и после каждого спрашивает планировщика, не пора ли уступить. Если пора — выходит через return и оставляет планировщику колбек, чтобы потом продолжить. Планировщик отправляет сам себе сообщение и тоже выходит — стек пустеет. Сообщение приходит отдельной задачей (макротаской), а между задачами браузер успевает обработать ввод и нарисовать кадр. Только потом планировщик вызывает колбек. — Почему только с React 16? Раньше React обходил дерево рекурсией, и где он сейчас в дереве, помнил только стек вызовов: выйти посередине значило потерять место. В React 16 у каждого компонента появился объект-узел — fiber — со ссылками на первый дочерний компонент, соседний и родительский. По таким ссылкам дерево обходится обычным циклом, а текущий узел хранится в одной переменной. Её можно оставить как есть, выйти и потом продолжить с того же места. — Что прерывается? Только несрочные рендеры: обновления из startTransition, useDeferredValue и повторы Suspense. Рендер после клика, ввода или обычного setState идёт целиком, без пауз. — Что с недоделанным WIP-деревом? Выбрасывается; несрочное считается заново, все transition — одной пачкой. — Как не путается порядок? База + отложенная очередь: несрочный рендер переигрывает обновления по порядку вызова. — Что такое приоритет? Бит в 31-битной маске (lane).

Что это значит для вашего кода:

— Если после обновления тяжёлый рендер, оберните это обновление в startTransition. Иначе React отрендерит всё одним куском, и ввод будет ждать. — Если тяжёлый расчёт сидит в одном компоненте, нарезка по 5 мс не поможет. Выносите такой расчёт из рендера — например, в Web Worker. — Не делайте в теле компонента запросов, подписок и изменений внешних переменных. React может вызвать компонент и выбросить результат, а эти действия всё равно произойдут. — Не рассчитывайте, что каждое промежуточное значение несрочного состояния дойдёт до экрана: недостроенный рендер может быть выброшен.

За кадром осталось немало. Что будет, если несрочный рендер прерывают снова и снова, — список так и не дойдёт до экрана? Как useDeferredValue показывает старое значение, пока считается новое? И как transition держит на экране старый интерфейс, пока Suspense ждёт данных? Об этом — в следующий раз.

Если что-то осталось непонятным или хочется копнуть какую-то часть глубже — пишите в комментариях.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.