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

Начиная с 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, и путь у обновления один и тот же.
Запрос. Сеттер ничего не меняет на экране сразу — он только создаёт запрос на обновление (дальше для краткости — просто обновление). Обновление встаёт в очередь обновлений своего хука. У каждого функционального компонента есть список хуков (связный список; у классов устроено иначе), а у каждого хука — своя такая очередь: тоже связный список, но кольцевой и с указателем на хвост. Ещё обновление получает приоритет — он зависит от того, откуда вызван сеттер: из обработчика клика, из
startTransition, из таймера. В коде React приоритет называется lane. Но в очереди приоритет роли не играет: новое обновление всегда встаёт в хвост. Очередь — общий объект для пары узлов вcurrentи WIP-дереве, поэтому обновление не потеряется, даже если WIP-дерево выбросят (такое бывает — об этом ниже).Планирование. Приоритет есть не только у обновления, но и у каждого узла дерева. У узла два таких поля:
lanes- приоритеты обновлений, которые ждут в самом компоненте, иchildLanes- приоритеты обновлений, которые ждут в его дочерних компонентах на любой глубине. React записывает приоритет обновления вlanesкомпонента, а потом поднимается по родительским компонентам до корня и добавляет этот приоритет вchildLanesкаждого. Вызывать компоненты — то есть рендерить — React при этом сразу не начинает. React ждёт, пока закончится текущий код — например, весь обработчик клика, — чтобы все обновления из него попали в один рендер. И только потом решает, что делать: самое срочное (клик, ввод с клавиатуры) рендерит тут же, а остальное отдаёт планировщику — отдельному пакету React, который решает, когда эту работу выполнить.Рендер. За один рендер React берёт не все ожидающие обновления, а только самые срочные из них — остальные подождут следующего рендера. React обходит дерево от корня. По
childLanesон видит, в каких ветках ждут обновления с этим приоритетом, и спускается только туда, а остальные пропускает. Поlanesнаходит сами компоненты с такими обновлениями. Их и их дочерние компоненты React вызывает заново. Новый результат сравнивается с тем, что лежит вcurrent, и собирается в WIP-дерево. Экран всё это время не меняется.Коммит. Изменения из готового 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 теперь два обновления:
обновление | приоритет |
|---|---|
| несрочный |
| срочный |
Срочный рендер. 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 ждёт данных? Об этом — в следующий раз.
Если что-то осталось непонятным или хочется копнуть какую-то часть глубже — пишите в комментариях.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.