Первую часть публикации читайте здесь.
Типы компонентов и согласование
Как описано на странице «Согласование» в официальной документации, React пытается эффективно выполнять повторный рендеринг, по возможности повторно используя существующее дерево компонентов и структуру DOM. Если заставить React отрендерить тот же самый тип компонента или HTML-узел в том же месте дерева, React повторно использует имеющееся представление и при необходимости актуализирует его, вместо того чтобы перестраивать его с нуля. Это значит, что React будет поддерживать жизнеспособность экземпляров компонента до тех пор, пока вы будете запрашивать рендеринг этого типа компонента в том же самом месте. Для классовых компонентов повторно используется тот же самый реальный экземпляр вашего компонента. У функционального компонента нет такого же «настоящего» экземпляра, как у классового, но мы можем рассматривать <MyFunctionComponent />
как некий аналог «экземпляра» в том смысле, что «компонент этого типа отображается в этом месте и по-прежнему существует».
Так как же React узнает, когда и как на самом деле изменяется результат рендеринга?
Логика рендеринга React сначала сравнивает элементы по полю type
, применяя оператор идентичности ===
к ссылкам. Если элемент, находившийся в каком-то конкретном месте, сменит свой тип на другой, например <div>
сменится на <span>
или <ComponentA>
на <ComponentB>
, React ускорит процесс сравнения, предполагая наличие изменений во всем дереве. В результате React уничтожит всю существующую секцию дерева компонентов, включая DOM-узлы, и перестроит ее с нуля с новыми экземплярами компонентов.
Следовательно, нельзя создавать новые типы компонентов во время рендеринга! Когда вы создаете новый тип компонента, ему присваивается другая ссылка, из-за чего React будет многократно уничтожать и перестраивать дерево дочернего компонента.
Короче говоря, не делайте так:
function ParentComponent() {
// This creates a new `ChildComponent` reference every time!
function ChildComponent() {}
return <ChildComponent />
}
Вместо этого пишите определение компонентов отдельно:
// This only creates one component type reference
function ChildComponent() {}
function ParentComponent() {
return <ChildComponent />
}
Ключи и согласование
Есть и другой способ, которым React идентифицирует «экземпляры» компонента, — через псевдопроп key
. При этом key
с точки зрения React представляет собой инструкцию и никогда не передается в реальный компонент. Он рассматривает key
как уникальный идентификатор, помогающий различать отдельные экземпляры типа компонента.
В основном ключи применяются в списках рендеринга. Ключи здесь играют особо важную роль, если вы рендерите данные, которые могут каким-то образом изменяться, например если добавляются или удаляются записи в списке либо изменяется их порядок. В частности, ключи должны представлять собой какие-то уникальные идентификаторы, по возможности производные от ваших данных, — прибегайте к индексации ключей по типу массивов только в крайнем случае!
Вот наглядный пример, почему это так важно. Предположим, я рендерю список из десяти компонентов <TodoListItem>
, индексируя ключи как массив. React видит десять элементов с ключами 0...9
. Теперь давайте удалим элементы 6 и 7, а потом добавим три новые записи с конца. Отрендерятся элементы с ключами 0...10
. С точки зрения React все выглядит так, словно я добавил только одну новую запись с конца, так как элементов в списке было 10, а стало — 11. React с радостью повторно использует существующие DOM-узлы и экземпляры компонентов. Но это значит, что <TodoListItem key={6}>
теперь отрендерится с записью, которая была передана в элемент списка под номером 8. То есть экземпляр компонента по-прежнему жив, но он получает в качества пропа не тот объект данных, что раньше. Такое приложение может работать, но могут быть и неожиданные последствия. Вдобавок теперь React должен будет обновить еще несколько элементов списка, чтобы изменить их текст и другое содержимое DOM, поскольку существующие элементы списка должны отображать не те данные, что раньше. В этих обновлениях нет необходимости, поскольку ни один из этих элементов списка не претерпел изменений.
Если бы вместо этого для каждого элемента списка мы использовали key={todo.id}
, React однозначно бы понял, что мы удалили два элемента и добавили три новых. Тогда он бы уничтожил два удаленных экземпляра компонента и их связанные DOM-узлы, а затем создал три новых экземпляра объекта вместе с DOM. Такой подход лучше, чем излишнее обновление компонентов, которые на самом деле остались прежними.
Ключи как идентификаторы экземпляров компонентов полезны не только в списках. Можно добавить key к любому компоненту React в любой момент времени, чтобы однозначно его идентифицировать, после чего изменение ключа приведет к тому, что React удалит старый экземпляр компонента вместе с DOM и создаст вместо них новые. Такой функционал часто применяется для создания комбинаций из списка и формы с дополнительными данными, когда форма отображает данные, связанные с текущим выбранным элементом списка. Рендеринг <DetailForm key={selectedItem.id}>
приведет к тому, что React уничтожит и пересоздаст форму при смене выбранного элемента, исключая любые проблемы с устаревшим состоянием формы.
Пакетный рендеринг и синхронность
По умолчанию каждый вызов setState()
вынуждает React начинать новый проход рендеринга в синхронном режиме и возвращать результат. С другой стороны, в React также предусмотрен некий автоматический механизм оптимизации в виде пакетного рендеринга. При этом множественные вызовы setState()
ставятся в очередь и выполняются как единый проход рендеринга, обычно с небольшой задержкой.
В документации React упоминается, что «обновления состояния могут быть асинхронными». Это отсылка к поведению пакетного рендеринга. React автоматически формирует очередь из обновлений состояния, которые происходят в обработчиках событий React. В связи с тем что обработчики событий React составляют довольно большую часть кода во многих приложениях на React, можно сказать, что большинство обновлений состояния в том или ином приложении рендерятся в пакетном режиме.
Пакетный рендеринг в React для обработчиков событий реализован путем их обертывания во внутреннюю функцию unstable_batchedUpdates. React отслеживает все обновления состояния, поставленные в очередь во время выполнения unstable_batchedUpdates, а затем применяет их за один проход рендеринга. Такой механизм хорошо работает с обработчиками событий, потому что React заранее точно знает, какие обработчики нужно вызывать для конкретных событий.
Чтобы лучше понять эту концепцию, можно представить внутренние процессы React в виде следующего псевдокода:
// PSEUDOCODE Not real, but kinda-sorta gives the idea
function internalHandleEvent(e) {
const userProvidedEventHandler = findEventHandler(e);
let batchedUpdates = [];
unstable_batchedUpdates( () => {
// any updates queued inside of here will be pushed into batchedUpdates
userProvidedEventHandler(e);
});
renderWithQueuedStateUpdates(batchedUpdates);
}
С другой стороны, любые обновления состояния, внесенные в очередь за пределами текущего мгновенного стека вызовов, не отрендерятся одним пакетом.
Рассмотрим эту ситуацию на конкретном примере.
const [counter, setCounter] = useState(0);
const onClick = async () => {
setCounter(0);
setCounter(1);
const data = await fetchSomeData();
setCounter(2);
setCounter(3);
}
Код приведет к выполнению трех проходов рендеринга. На первом проходе в один пакет объединяются функции setCounter(0)
и setCounter(1)
, так как они обе выполняются в оригинальном стеке вызовов обработчика событий, то есть внутри вызова unstable_batchedUpdates()
.
Но вызов setCounter(2)
происходит после await
. Значит, оригинальный синхронный стек вызовов закончился, и вторая половина функции выполняется заметно позднее в полностью отдельном стеке вызова цикла событий. Из-за этого React на последнем этапе выполнит целый проход рендеринга в синхронном режиме внутри вызова setCounter(2)
, закончит проход и вернется из setCounter(2)
.
То же самое произойдет с вызовом setCounter(3)
, поскольку он также выполняется за пределами оригинального обработчика событий, а значит — за пределами очереди пакетного рендеринга.
В пределах жизненного цикла этапа фиксации предусмотрено несколько специальных методов: componentDidMount
, componentDidUpdate
и useLayoutEffect
. По большому счету они нужны для выполнения дополнительной логики после рендеринга, но до того, как браузер успеет визуализировать результат. В частности, чаще всего они применяются в следующем сценарии:
впервые отрендерить компонент с частичными, неполными данными;
измерить реальный размер настоящих DOM-узлов на странице в пределах жизненного цикла этапа фиксации;
установить определенное состояние в компоненте на основе этих измерений;
мгновенно повторить рендеринг с обновленными данными.
В такой ситуации нам не нужно, чтобы пользователь увидел начальный «частично» отрендеренный интерфейс — он должен отобразиться только в своем «полном» виде. Браузер пересчитает структуру DOM, получившую изменения, но ничего не выведет на экран, поскольку JS-скрипт по-прежнему выполняется и блокирует цикл событий. Следовательно, вы можете произвести несколько мутаций DOM, таких как div.innerHTML = "a"; div.innerHTML = b";
, при этом "a
" никогда не отобразится.
По этой причине React всегда выполняет рендеринг в жизненном цикле этапа фиксации в синхронном режиме. И если вы реализуете обновление по типу описанного переключения с «частичного» на «полное» отображение, на экран будет выведено только «полное» содержимое.
И наконец, насколько я знаю, обновления состояния в колбэках useEffect тоже ставятся в очередь и сбрасываются в конце этапа пассивных эффектов, как только все колбэки useEffect
будут выполнены.
Стоит отметить, что API unstable_batchedUpdates
экспортируется публично, но при этом:
исходя из пометки unstable в названии, функция не считается стабильной и не относится к официально поддерживаемому API React;
с другой стороны, по словам команды React, «это самый стабильный из всех „нестабильных“ API, и половина кода Facebook полагается на эту функцию»;
в отличие от остальных базовых API React, экспортируемых пакетом react,
unstable_batchedUpdates
является специализированным API механизма согласования и не входит в состав пакета react. На самом деле он экспортируется изreact-dom
иreact-native
. То есть другие механизмы согласования, наподобиеreact-three-fiber
илиink
, скорее всего, не экспортируют функциюunstable_batchedUpdates
.
В новом конкурентном режиме React будет всегда применять обновления в пакетном режиме — всегда и везде.
Нетипичные ситуации во время рендеринга
React дважды рендерит компоненты внутри тега <StrictMode>
в среде разработки. Таким образом, частота выполнения логики рендеринга не будет совпадать с количеством реальных проходов рендеринга, при этом вы не можете полагаться на функцию console.log()
, чтобы подсчитать во время рендеринга реальное число проходов. Вместо этого можно воспользоваться либо профилировщиком React DevTools Profiler, чтобы отследить и подсчитать общее количество рендерингов, прошедших этап фиксации, либо добавить логирование в хук useEffect
или в жизненный цикл componentDidMount/Update
. Таким образом, вывод в лог будет происходить только тогда, когда React действительно завершает проход рендеринга и фиксирует его.
В обычных обстоятельствах никогда не следует ставить в очередь обновление состояния непосредственно внутри логики рендеринга. Другими словами, вполне допустимо создать колбэк для щелчка мышью, вызывающий setSomeState()
при щелчке, но нельзя вызывать setSomeState()
в процессе рендеринга.
Однако есть и исключение. Функциональные компоненты могут вызывать setSomeState()
напрямую во время рендеринга, если такие вызовы условные и не выполняются при каждом рендеринге этого компонента. Это своего рода эквивалент getDerivedStateFromProps из классовых компонентов, но только для функциональных компонентов. Если функциональный компонент поставит в очередь обновление состояния во время рендеринга, React немедленно применит обновление состояния и повторно отрендерит в синхронном режиме только этот компонент, а затем продолжит работу. Если компонент будет постоянно ставить в очередь обновления состояния и вынуждать React повторно рендерить их, React прервет цикл после заданного числа повторных попыток и выдаст ошибку (в настоящий момент — 50 попыток). Этот подход позволяет мгновенно форсировать обновление значения состояния на основе изменения пропа, не требуя повторного рендеринга с вызовом setSomeState()
внутри useEffect
.
Третья (последняя) часть выйдет через пару дней.
Материал подготовлен в рамках курса «React.js Developer».