Какова реальная стоимость отрисовки в React на основном потоке
Render против commit в React: почему невидимая работа render всё ещё конкурирует с вводом на основном потоке и что на самом деле экономит технология мемоизации.
Сам по себе процесс «рендеринга» в React не изменяет того, что отображается в браузере. Компонент, который выполняется один раз, и тот же компонент, который выполняется сотню раз, могут казаться идентичными пользователю приложения. Они кажутся идентичными потому, что браузер обновляется только тогда, когда на этапе сохранения изменений происходит запись в DOM, а один лишь рендеринг никогда не гарантирует такую запись. Так почему же столько рекомендаций по оптимизации производительности React сосредоточено на избегании рендерингов — useCallback (сохранение идентичности функции между рендерингами), useMemo (сохранение вычисленного значения между рендерингами), React.memo (пропуск рендеринга дочернего элемента, если параметры не изменились), а также на сокращении объема обновлений состояния?
Эта противоречивость реальна: может показаться, что оптимизируется то, чего пользователь никогда бы не заметил.
Если рендеринг никогда не влияет на браузер, то на что же он на самом деле тратит ресурсы?
Две фазы, скрытые внутри одной обработки отрисовки
Люди часто называют весь цикл обновления «обработкой отрисовки». React разделяет этот цикл на две фазы с разными задачами. В одной фазе создается описание следующего интерфейса. В другой фазе решается, должно ли это описание привести к реальным изменениям в DOM.
Обе фазы запускаются по тем же четырем триггерам: обновление состояния, изменение свойства, повторная обработка отрисовки родителя или изменение значения контекста (значение из Context API, считанное без передачи через свойства). Различия между фазами заключаются в том, что они делают далее и какие затраты сопряжены с их выполнением.
Что происходит внутри первой фазы, которая всегда выполняется, когда React планирует обновление?
Разбор фазы отрисовки
Когда React решает, что требуется обновление, он снова вызывает функцию компонента сверху вниз. Выполняются все операторы, находящиеся в её теле: вычисления, циклы, создание объектов прямо в коде.
Затем React оценивает JSX после оператора return. JSX (синтаксис <div>...</div>) — это не HTML, и он сам по себе никогда не попадает в браузер. Во время сборки Babel или компилятор TypeScript преобразуют его в вызовы функций — раньше это был React.createElement, а сейчас чаще используется современный помощник jsx(). Именно эти вызовы формируют описание компонента.
Результат такой обработки обычно называют виртуальным DOM; внутреннее название в React — дерево элементов. Это обычный объект JavaScript, описывающий желаемый интерфейс — это не настоящий HTML, а не динамический узел DOM.
Рассмотрим небольшой компонент:
function Greeting({ name }) {
const message = `Hello, ${name}`;
return (
<div>
<h3>{message}</h3>
</div>
);
}
Изменение значения name заставляет React снова вызвать функцию Greeting. Строка-шаблон для message также выполняется повторно. В результате компиляции JSX формируется дерево объектов, похожее на следующее:
{
"type": "div",
"props": {
"children": {
"type": "h3",
"props": { "children": "Hello, Akshat" }
}
}
}
Этот объект представляет собой обновленный виртуальный DOM функции Greeting. Структура остается в памяти — не происходит никаких изменений DOM, отрисовки или перерасчёта макета. Что будет использовать это дерево далее?
Разбор этапа применения изменений
React сравнивает новое дерево элементов с предыдущим — происходит процесс согласования, при котором определяются фактические изменения. В реальный DOM записываются только те различия, которые были обнаружены в результате этого сравнения. Если различий нет, ничего не записывается.
Вернёмся к Greeting и предположим, что значение name меняется с одной строки на другую. В предыдущей структуре текст приветствия хранился в элементе h3; в новой структуре — обновлённый текст. Структура остаётся прежней — div, в котором находится h3 — поэтому инструмент сравнения фиксирует лишь одно отличие: этот текстовый узел. На этапе коммита обновляется только этот текст; вся остальная часть структуры оставляется без изменений.
Это единственный этап, в котором участвует реальный браузер, и именно поэтому это единственный этап, способный заставить пересчитать макет (пересчитать геометрию) или перерисовать элементы. Если изменить достаточно контента, браузер вынужден будет выполнить эту работу заново. Если ничего не менять, он этого делать не будет.
Вот ключевой пример для дальнейшего обоснования. Если name не меняется при повторном запуске, новая дерево структур соответствует старой, процедура сопоставления не находит никаких различий, и операция сохранения данных остается бесполезной. Никакой записи в DOM, никакого изменения макета, никакой отрисовки — ничего, что можно было бы заметить глазами пользователя. Однако фаза отрисовки — вызов функции, пересчёт значения message, создание новой дерево объектов — всё равно выполнялась полностью мгновение ранее.
Если отрисовка может завершиться без видимых следов, сколько ресурсов потребовалось на её выполнение?
Фаза отрисовки может выполняться и при этом ничего не менять
Именно здесь дискуссия становится осмысленной. Рендер, дающий идентичный результат, всё равно потребляет реальные ресурсы: происходит реальный вызов функции, осуществляются реальные выделения памяти для каждого узла в дереве, выполняется реальная работа по сопоставлению данных, в ходе которой просматривается дерево, прежде чем сделать вывод о отсутствии изменений. Ничего из этого не отображается на экране. Однако всё это всё равно происходит.
Именно эта пропасть — реальная работа, остающаяся невидимой, — объясняет, почему утверждения «рендеры не имеют значения» и «рендеры имеют огромное значение» могут быть верными в отношении разных аспектов. Рендеры действительно не определяют то, что видит пользователь; за это отвечает коммит. Рендеры имеют значение для чего-то другого, что не связано с пикселями.
Что же это за что-то другое?
Основной поток не заботится о том, видна ли выполняемая работа
Этим «чем-то» является основной поток JavaScript: общая очередь, которая выполняет задачи по одной. Этот же поток запускает ваш JavaScript, рассчитывает макет и обрабатывает события ввода — клики, прокрутку, нажатия клавиш.
Браузеры генерируют новый кадр примерно каждые 16,7 миллисекунды, чтобы достичь частоты 60 кадров в секунду. Всё, что необходимо для определенного кадра — логика этапа отрисовки, синхронизация данных, сохранение изменений в DOM, расчет макета, отрисовка — должно уместиться в этот временной лимит; к тому же этап отрисовки не получает приоритета только потому, что его результат может быть проигнорирован. Он использует ту же очередь, которую пользователь ощущает во время взаимодействия.
Будьте точны: не каждый шаг обработки фрейма в браузере выполняется на этой потоковой оси. Растеризация (преобразование команд нарисования в пиксели) и композиция (собирание слоев в окончательный фрейм) часто происходят в другом месте, именно поэтому предварительно растеризованный слой может оставаться доступным для прокрутки, пока основной поток занят. Сам этап отрисовки и событие, которое его запустило, по-прежнему выполняются на основном потоке. Отдельные потоки композитора объясняют определённую плавность работы при нагрузке, но они не устраняют затрат на этап отрисовки.
Одна ненужная операция отрисовки может стоить долю миллисекунды. Когда это начинает ощущаться пользователем?
Почему этап отрисовки всё равно влияет на производительность
Потому что он редко выполняется всего один раз. Когда родительский элемент перерисовывается, React по умолчанию выполняет этап отрисовки для каждого дочернего элемента, даже если его параметры не изменились, за исключением случаев, когда дочерний элемент обёрнут в React.memo.
function Dashboard() {
const [searchTerm, setSearchTerm] = useState('');
const products = useProducts(); // 200 items
return (
<div>
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<ProductList products={products} />
</div>
);
}
function ProductList({ products }) {
return (
<div>
{products.map((product) => (
<ProductRow key={product.id} product={product} />
))}
</div>
);
}
Введите один символ в поле поиска, и значение searchTerm обновится. Ожидается, что панель управления снова начнет работу — ее вывод изменился. Также снова начнут работать ProductList и все 200 компонентов ProductRow под ним; это происходит не потому, что изменились их параметры (нажатие клавиши не связано с данными продуктов), а потому, что дочерние элементы перерисовываются при изменении родительских, если только что-то не остановит эту цепную реакцию.
Каждый из этих 200 вызовов представляет собой реальную выполнимость функции, реальное дерево элементов для каждой строки и реальную проверку, в результате которой выясняется, что ни одной из строк не требуется запись в DOM. Одно нажатие клавиши — это незначительная операция. Поле поиска в режиме реального времени при нормальной скорости ввода запускает такие серии вызовов несколько раз в секунду на том же потоке, который должен обрабатывать следующее нажатие клавиши.
Это сценарий, для которого предназначены useMemo, useCallback и React.memo — так что же они на самом деле защищают?
Что на самом деле защищают useMemo, useCallback и React.memo
React.memo оборачивает компонент и пропускает его этап отрисовки, когда новые параметры поверхностно идентичны предыдущим (=== для каждого параметра). Обернув ProductRow, нажатие клавиши на панели управления больше не приводит к 200 вызовам отрисовки; React сравнивает параметры один раз на каждую строку и прекращает работу, когда ссылка на product не меняется.
useMemo хранит результаты вычислений между перерисовками, так что ресурсоемкие операции внутри тела компонента выполняются заново только при изменении указанных зависимостей. useCallback делает то же самое, но для идентичности функций. Обычно он используется не столько для экономии ресурсов на создание функции, сколько для защиты мемоизированного потомка: новая функция при каждой перерисовке означает новый ссылочный адрес, а новый адрес нарушает поверхностную проверку React.memo на то, что получает этот проп.
Ни один из этих инструментов не влияет на этап сохранения изменений. Сохранение уже контролируется процессом сопоставления, который определяет наличие реальных различий. Если бы что-либо в любом случае не появилось на экране, эти инструменты не влияют на то, произойдет ли запись в DOM. Они экономят ресурсы, сокращая вычисления на этапе перерисовки — время, тратимое на основной поток, даже когда результат остается неизменным.
Они тоже не бесплатны. Сравнение, выполняемое функцией React.memo, и поиск в кэше, осуществляемый функцией useMemo, требуют затрат на обработку при каждом вызове, поэтому использование таких механизмов для дешевых компонентов, которые используются редко, может привести к суммарным потерям: необходимость платить за дополнительные расходы ради защиты кода, который изначально не требовал сложной обработки.
Как это влияет на пользователей продукта?
Затраты на отрисовку влияют на производительность той среды, которую действительно ощущает пользователь
Ничто в фазе отрисовки само по себе не меняет пиксель — это положение всегда было верным. Компонент, который выполняется один раз или сто раз, может выглядеть одинаково, потому что именно сохранение состояния, а не процесс отрисовки, определяет то, что появляется на экране.
Отрисовка по-прежнему не является бесплатной, потому что она невидима. Это реальная работа в той же однопоточной очереди, что и обработка макета, запись изменений в DOM, а также каждое нажатие кнопки, прокрутка и нажатие клавиши. Избегание ненужной работы на этапе отрисовки в этой очереди — особенно когда обновление родительского элемента распространяется по глубокому списку дочерних элементов или когда ввод текста и прокрутка вызывают быструю серию обновлений — позволяет освободить миллисекунды, необходимые для взаимодействия с интерфейсом.
Люди редко говорят о той скрытой составляющей. Уменьшение количества отрисовок никогда не было конечной целью. Цель заключается в том, чтобы оставить достаточно времени из общего бюджета примерно 16,7 мс на выполнение операций записи изменений и обработку ввода, которые пользователь может заметить.