В сети много статей и докладов, которые описывают React Fiber, но, к сожалению, они сейчас уже не актуальны. Заметив это, я решил разобраться и актуализировать информацию. Моими основными помощниками были исходники и отладчик, поэтому здесь вы увидите множество ссылок на код из репозитория React. Теперь я хочу поделиться результатами своей работы с вами.
Тема разделена на две статьи. Первая статья рассказывает о процессе обновления и внесения изменений в DOM. Вторая статья посвящена реализации не блокирующего рендеринга - Concurrent React. Данная статья является второй. Первая доступна по ссылке.
Если вы не читали первую статью, то рекомендую ее прочитать, так как описанный в ней механизм является основополагающим для реализации Concurrent React.
React Fiber
React Fiber решает две основные задачи:
Инкрементальный процесс рендеринга — способность разделять работу рендера на части. Здесь концепция и реализация во многом не поменялись, хотя и претерпели некоторые изменения. Как устроен инкрементальный процесс рендеринга, я рассказываю в предыдущей статье.
Возможность не блокирующего рендеринга, при котором рендер не должен блокировать взаимодействия пользователя с сайтом и отображение анимации. Здесь все наоборот: концепция долго разрабатывалась и видоизменялась. Важно заметить, что реализация первого аспекта, инкрементального рендеринга, является базой для реализации второго.
Рендеринг в React синхронный, и если нужно рендерить много элементов, то этот процесс может занять большое количество времени, в течении которого браузер подвисает, и пользователь не сможет коммуницировать с нашей страницей. Далее мы разберем, как команда React решает эту задачу.
История
Кажется, будет полезным посмотреть на историю развития Concurrent React для того, чтобы провести границу между старыми статьями и докладами, которые описывали предполагаемый процесс асинхронного рендеринга, и современной реализацией.
В момент выпуска React Fiber говорилось, что будет приоритизация задач. Менее приоритетные откладываем с помощью requestIdleCallback
, более приоритетные выполняем при помощи requestAnimationFrame
. Благодаря этому будет реализован не блокирующий рендеринг, когда мы можем выполнять более приоритетные задачи (анимация или ввод в input) и откладывать менее приоритетные.
Например, об этом рассказывает Lin Clark в докладе A Cartoon Intro to Fiber - React Conf 2017.
Но первый результат работы был показан только лишь в 2018 году: Dan Abramov - Beyond React 16. Тогда Dan показал прототип современного startTransition
. Кстати, пример, используемый в докладе, можно посмотреть вот тут. Тогда он это назвал Async Rendering.
Далее в том же 2018 году было название Concurrent React, озвученное Andrew Clark в докладе Concurrent Rendering in React.
Позже, в 2019, это переросло в Concurrent Mode, и впервые за все это время предоставили экспериментальный пакет, с помощью которого можно было потрогать Concurrency.
И наконец, в 2021 году анонсировали 18 версию React и вместе с ней Concurrent Features. То есть спустя много лет выступлений и показов того, над чем они работают, мы наконец-то смогли это использовать с 18 версии React. И то, какой вид это приобрело, во многом отличается от того, какой был анонсирован при выпуске React Fiber.
Проведем черту между старой теорией не блокирующего рендеринга и современной реализацией:
В современном коде React не используется requestIdleCallback
и requestAnimationFrame
для приоритезации задач. Также нет внутренней логики по приоритезации обновлений за вас. Есть возможность вручную пометить прерываемые и несрочные обновления с помощью Concurrent Features.
Concurrent Features
React до сих пор является синхронным по дефолту, но добавились методы, которые помогут реализовать Concurrency. То есть теперь можно решать, какие обновления нельзя прерывать, а какие можно и отложить, не требующие немедленной отрисовки. Зачем нам прерывать? Во-первых, чтобы дать возможность выполниться более приоритетным обновлениям. Во-вторых, чтобы дать возможность браузеру выполнить отрисовку и не блокировать страницу.
Concurrent Features:
startTransition / useTransition
useDeferredValue
Suspense
К примеру, я могу разделить обновления с высоким приоритетом и с низким, обернув обновление с низким приоритетом в startTransition
:
function handleChange(e){
setHighPriorityUpdate(e.target.value);
React.startTransition(() => {
setLowPriorityUpdate(e.target.value)
})
}
Давайте разберем как это работает изнутри.
React Concurrent
Как я уже говорил, по дефолту рендеринг в React осталось непрерывным и синхронным. Как происходит рендеринг, я подробно описывал в предыдущей статье. Для этого используется цикл workLoopSync, который пробегается по всем узлам дерева workInProgress и выполняет рендеринг в функции performUnitOfWork
. Рендеринг - это процесс обновления компонентов и вычисления изменений между предыдущим и текущим рендерами для последующего внесения изменений в DOM.
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
В случае рендеринга, вызванного с помощью Concurrent Features, ситуация немного отличается. Рендеринг выполняется в цикле workLoopConcurrent.
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
Отличие от workLoopSync
заключается в том, что вызывается проверка shouldYield - должны ли мы уступить. Если уступать не должны, то выполняем дальше работу по рендерингу узла. Но если shouldYield
вернет true
, то мы должны прерывать рендеринг. Давайте теперь разберем, в какой момент это происходит.
function shouldYield(): boolean {
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
return false;
}
return true;
}
timeElapsed
- это время выполнения текущего рендеринга. frameInterval
- это константа, которая задается в конфигурационном файле и равно 5 по дефолту. То есть React дает 5мс на выполнение рендеринга, а затем этот процесс приостанавливается.
Почему 5мс?
Я могу только предположить, что это связано с рекомендациями RAIL в части анимации https://web.dev/rail/#animaciya-sozdavajte-kadr-za-10-ms.
Для того чтобы понять, что происходит, когда мы прерываем работу по рендерингу, надо подняться выше по call stack к функции performWorkUntilDeadline.
const performWorkUntilDeadline = () => {
if (scheduledHostCallback !== null) {
const currentTime = getCurrentTime();
startTime = currentTime;
let hasMoreWork = true;
try {
hasMoreWork = scheduledHostCallback(currentTime);
} finally {
if (hasMoreWork) {
schedulePerformWorkUntilDeadline();
} else {
scheduledHostCallback = null
}
}
}
};
Мы можем заметить, что тут участвует переменная hasMoreWork
, которая сообщает о том, есть ли работа по рендерингу, которую необходимо совершить. Значение этой переменной зависит от результата выполнения scheduledHostCallback
, внутри которого вызывается наш знакомый workLoopConcurrent
. И если мы вспомним workLoopConcurrent
и shouldYield
, то станет понятно, когда hasMoreWork
будет равен true
или false
. Если в результате работы по рендерингу прошло более 5мс, то shouldYield
вернет true
, и мы должны прервать цикл workLoopConcurrent
. Если мы прервали цикл, но при этом еще осталась работа по рендерингу, то переменная hasMoreWork
будет равна true
, иначе false
.
Что же происходит дальше? Если работа еще есть, то мы планируем ее выполнить с помощью функции schedulePerformWorkUntilDeadline, иначе очищаем данные. Далее посмотрим на функцию планирования новой работы:
if (typeof localSetImmediate === 'function') {
// Node.js and old IE.
schedulePerformWorkUntilDeadline = () => {
localSetImmediate(performWorkUntilDeadline);
};
} else if (typeof MessageChannel !== 'undefined') {
// DOM and Worker environments.
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
} else {
// We should only fallback here in non-browser environments.
schedulePerformWorkUntilDeadline = () => {
localSetTimeout(performWorkUntilDeadline, 0);
};
}
localSetImmediate
выполнится в Node.js среде, MessageChannel
используется в воркерах и в современных браузерах. Он работает точно так же, как и setTimeout с параметром 0, только нет минимальной задержки в 4мс.
При помощи schedulePerformWorkUntilDeadline
мы можем отложить очередное выполнение performWorkUntilDeadline
и дать браузеру перерисовать страницу. Таким образом, React реализует рендеринг, который не блокирует перерисовку содержимого окна браузера, и пользователь не замечает подлагиваний страницы.
Lanes
Concurrent features также позволяют прервать текущий рендеринг для выполнения другого, запущенного синхронным обновлением. Для определения приоритетности используются lanes, значение которого хранится в каждой Fiber Node. Значение в lanes
хранятся в двоичном виде. Таким образом, у каждой Fiber Node может содержаться не одно значение lane
. lanes
выставляются в момент выполнения beginWork
. Также заполняется поле childLanes
в момент всплытия в completeWork. В итоге у Root есть информация о значениях lanes
всех его потомков. Для понимания, что такое beginWork
и completeWork
, предлагаю обратиться к первой статье этой серии.
С помощью функции getNextLanes мы определяем наиболее приоритетный рендеринг, который необходимо выполнить. Это определение происходит на этапе рендера Root узла. На этом этапе мы либо продолжаем работу по рендерингу с того места, где остановились в прошлый раз, либо прерываем текущий рендеринг и выполняем более приоритетный.
Подводя итог по всему вышесказанному, можно отметить следующее:
Concurrent Features помогают отметить обновления как прерываемые и несрочные.
Рендеринг мы разбиваем на интервалы по 5мс, между которыми происходит перерисовка браузера и проверка на существование срочных обновлений.
Если пришло срочное обновление, то мы прерываем текущих процесс рендеринга и начнем его заново после выполнения срочного рендеринга.
Таким образом, долгий процесс рендеринга не заблокирует браузер пользователя, и он сможет продолжить взаимодействовать с интерфейсом.
Профилирование
Теперь предлагаю посмотреть как то, что мы разобрали работает на практике. За основу я взял пример из доклада Dan Abramov в 2018 году.
Данный пример состоит из input, при вводе в который обновляются графики. Чем больше символов мы введем, тем тяжелее графики будут отрисовываться.
Метод ввода выглядит так:
handleChange = e => {
const value = e.target.value;
const { strategy } = this.state;
switch (strategy) {
case 'sync':
this.setState({value});
break;
case 'concurrent':
this.setState({ inputValue: value });
startTransition(() => {
this.setState({ graphicValue: value });
});
break;
default:
break;
}
};
В случае синхронного рендеринга, результат профилирования выглядит таким образом:
По этому результату мы можем сказать, что рендеринг был непрерывным и занял 21мс. Перерисовка содержимого окна браузера началась после выполнения React всей работы по рендеру.
Далее результат профилирования с использованием Concurrent Features. Если еще раз посмотреть на код функции handleChange
выше, то можно заметить, что в режиме Concurrent обновление значений для input (inputValue) и для графиков (graphicValue) выполняет раздельно. Таким образом, обновление значения в input будет непрерывным и синхронным, а обновление значения для графиков и обновление самих графиков - прерывно и не срочно (из-за того, что обернуто в startTransition
).
Можно заметить, что теперь процесс рендеринга разбит на части примерно 5-10мс и между ними происходит перерисовка содержимого окна браузера.
Здесь я увеличил предыдущий результат, чтобы было заметно, как сначала выполняется синхронный рендеринг (обновление input), а затем выполняется рендеринг в режиме Concurrent.
Полезные материалы
React Concurrency, Explained: What useTransition and Suspense Hydration Actually Do
Видео
ТекстОчень рекомендую данный доклад. Также в нем указаны недостатки Concurrent React, я не буду повторяться и рекомендую вам посмотреть доклад или прочитать статью, либо перейти к разделу Drawbacks
What happened to concurrent "mode"?
Рассказывается то, почему отказались от идеи Concurrent Mode и перешли на Concurrent Features