← назад к разделу

Вы меняете состояние, и на экране появляется новое число. Между этими двумя событиями React делает работу, о которой большинство разработчиков не думает, пока она не начнёт мешать: список дёргается при удалении элемента, компонент перерисовывается по двадцать раз, console.log печатает дважды, а значение после setState оказывается старым. Все эти странности объясняются одной моделью. Разберём её по шагам: от функции компонента до реального DOM.

Компонент как чистая функция

Функциональный компонент это функция от пропсов и состояния к описанию интерфейса. React ждёт от неё чистоты в математическом смысле: одинаковые входы дают одинаковый результат, а побочных действий внутри нет. Запрос к серверу, запись в localStorage, изменение внешней переменной прямо в теле компонента нарушают это правило, и для них есть эффекты и обработчики событий.

Чистота нужна не ради красоты. React оставляет за собой право вызвать компонент сколько угодно раз и в любой момент: чтобы сравнить результат с прошлым, чтобы подготовить рендер заранее, чтобы прервать его и начать заново. StrictMode в разработке намеренно вызывает каждый компонент дважды, и если второй вызов что-то ломает, компонент нечистый. Отсюда же правило про неизменяемость: состояние не мутируют, а заменяют новым объектом, иначе React не увидит, что оно изменилось.

Virtual DOM: описание, а не экранспросят на собеседовании

Функция компонента возвращает не DOM-узлы, а лёгкие объекты-описания: элемент такого типа с такими пропсами и такими детьми. JSX это синтаксис для их создания, который Babel или TypeScript превращают в вызовы функций. Дерево таких описаний и называют Virtual DOM.

Зачем лишний слой? Менять настоящий DOM дорого: каждое изменение может заставить браузер пересчитать раскладку. А создавать объекты JavaScript дёшево. Поэтому React строит новое описание целиком, сравнивает его со старым и применяет к DOM только разницу. Важно понимать, что «Virtual DOM быстрее DOM» неверная фраза: прямое точечное изменение DOM всегда быстрее, чем сравнение деревьев плюс то же изменение. Virtual DOM покупает не скорость, а возможность писать компонент как чистую функцию и не думать, что именно поменялось.

Согласование: как React сравнивает деревьяспросят на собеседовании

Сравнение двух деревьев в общем случае стоит кубического времени, и React заменяет его эвристикой из двух правил. Первое: если тип элемента изменился, div стал span или один компонент другим, всё поддерево выбрасывается и строится заново вместе с состоянием. Второе: дети сравниваются по позиции, а в списках по ключу.

Отсюда роль key. Без ключей React сопоставляет элементы списка по индексу: удалили первый элемент, и React решит, что первый элемент изменил содержимое, а последний исчез, и обновит все узлы по очереди. С ключами он понимает, что элемент с таким ключом просто пропал, а остальные остались на месте, и переставит узлы. Индекс массива как ключ работает ровно до первой перестановки или удаления: состояние полей ввода, анимации и фокус перескочат на чужой элемент.

const items = [{ id: 7, name: "Чай" }, { id: 3, name: "Кофе" }];
const good = items.map((item) => <li key={item.id}>{item.name}</li>);
const bad = items.map((item, index) => <li key={index}>{item.name}</li>);

Смена ключа у одного элемента это команда «считай его новым»: состояние сбросится, эффекты выполнятся заново. Этим пользуются намеренно, чтобы сбросить форму при смене записи.

Две фазы: render и commitспросят на собеседовании

Работа React делится на две фазы. В фазе render он вызывает компоненты и строит новое дерево, в фазе commit применяет изменения к DOM и запускает эффекты. Фаза render чистая и может быть прервана или выброшена, фаза commit синхронная и неделимая: пользователь не увидит полуобновлённый экран.

Эффекты тоже разложены по фазам. useLayoutEffect выполняется синхронно сразу после изменения DOM, до того как браузер нарисует кадр: в нём измеряют размеры и двигают элементы без мерцания. useEffect выполняется после отрисовки, не задерживая кадр: запросы, подписки, логирование. Классовые методы жизненного цикла легли на эту же схему: componentDidMount и componentDidUpdate это эффект, componentWillUnmount его функция очистки.

Fiber: работа, которую можно прерватьспросят на собеседовании

До 2017 года рендер был рекурсивным вызовом и не мог остановиться на середине: большое дерево блокировало поток, и ввод пользователя ждал. Fiber это переписанное ядро, где дерево компонентов превращено в связный список единиц работы. Каждая единица, волокно, это один компонент с его пропсами, состоянием и ссылкой на DOM-узел, и React обрабатывает их по одной, проверяя между ними, не пора ли отдать поток браузеру.

Из этого выросли возможности React 18: конкурентный рендер, где срочное обновление, например ввод в поле, прерывает несрочное, например фильтрацию длинного списка; useTransition и useDeferredValue, которые помечают обновление как несрочное; Suspense, который показывает запасной интерфейс, пока данные или код грузятся. Все они держатся на том, что фаза render чистая и её можно выбросить без последствий.

Что вызывает перерисовкуспросят на собеседовании

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

React.memo добавляет сравнение пропсов: компонент пропускает рендер, если ни один проп не изменился по ссылке. Это работает, пока пропсы стабильны: объект или функция, созданные в теле родителя, каждый раз новые, и memo их не спасёт без useMemo и useCallback. Отсюда правило из статьи про производительность: сначала профайлер, потом точечная мемоизация, а не memo на всём подряд.

Батчинг и «асинхронный» setStateспросят на собеседовании

setState не меняет состояние на месте: он ставит обновление в очередь и помечает компонент к перерисовке. Поэтому строка после setCount(count + 1) видит старое значение, а два вызова setCount(count + 1) подряд увеличивают счётчик на один. Нужно опереться на предыдущее значение, передают функцию: setCount((c) => c + 1).

Несколько обновлений в одном обработчике React склеивает в одну перерисовку, это батчинг. С React 18 он работает везде: в обработчиках событий, в промисах, в таймерах. Так что три setState подряд дадут один рендер, и спорить о порядке бессмысленно. Если обновление нужно применить синхронно, например измерить DOM сразу после, есть flushSync, и это редкий инструмент.

Коротко

  • Компонент это чистая функция от пропсов и состояния; React вправе вызывать её сколько угодно раз, а StrictMode делает это дважды в разработке.
  • Virtual DOM это дерево описаний, по разнице которых React точечно меняет DOM; он покупает не скорость, а модель «описываю, а не меняю».
  • Согласование: другой тип элемента означает пересоздание поддерева; дети сравниваются по позиции, в списках по key, индекс как ключ ломается при перестановке.
  • Две фазы: render чистая и прерываемая, commit синхронная; useLayoutEffect до кадра, useEffect после.
  • Fiber разбил рендер на прерываемые единицы работы, на нём стоят конкурентный рендер, useTransition, useDeferredValue и Suspense.
  • Перерисовку вызывают своё состояние, родитель и контекст; memo сравнивает пропсы по ссылке и бесполезен без стабильных пропсов.
  • setState ставит обновление в очередь, обновления батчатся в один рендер; для зависимости от прошлого значения передают функцию.

Что почитать дальше