Первую часть публикации читайте здесь.

Типы компонентов и согласование

Как описано на странице «Согласование» в официальной документации, 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».

Комментарии (0)