Главная / Статьи

Девять распространённых паттернов, вызывающих ненужную перерисовку React

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

Издатель: REACTAPP.TOP16 мин чтения3640 слов

Источники: 9 React Habits That Quietly Cause Unnecessary Re-Renders

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

Долгое время казалось разумным считать чрезмерную перерисовку проблемой, которую должен решить сам React. Когда компонент обновлялся чаще, чем следовало, первым делом пытались обернуть его в функцию memo, или использовать useMemo и useCallback. Если интерфейс всё равно оставался медленным, к той же процедуре применяли следующий уровень компонентов, в надежде, что React в конечном итоге стабилизируется.

Такого рода исправления иногда делали графики профилирования более привлекательными, но редко делали структуру основных компонентов более понятной. Объекты типа State по-прежнему находились на более высоких уровнях дерева, чем это было необходимо; значения контекста по-прежнему собирались вместе, производные данные по-прежнему дублировались, ссылки оставались нестабильными, а функции Effects продолжали запускать дополнительные обновления сразу после своего выполнения. Мемоизация фактически использовалась для защиты интерфейса от потока данных, который был шире, чем требовалось.

Само по себе повторное отрисовывание не является ошибкой. React предназначен для повторного вызова функций компонентов каждый раз, когда могут измениться их входные данные, и многие компоненты настолько «дешевые», что блокировка каждого безвредного отрисовывания приведет к большей сложности, чем позволит сэкономить.

Настоящий вопрос не в том, отрисовывался ли компонент снова. Вопрос в том, почему произошло это отрисовывание и какие действия были выполнены после того, как React его вызвал.

Большинство избежимых повторных обработок связаны с привычками, которые кажутся безвредными, пока компонент небольшой. Их издержки проявляются только позже, когда компонент увеличивается в размерах, его дочерние элементы становятся тяжелее или быстро меняющиеся значения начинают делать недействительной большую часть страницы.

1. Хранение часто меняющегося состояния слишком высоко в иерархии

Перенос состояния к общему предку — один из первых паттернов, которые осваивают разработчики React. Когда два компонента зависят от одного и того же значения, передача ответственности за него ближайшему общему родителю помогает поддерживать синхронизацию и избегать дублирования источников информации.

Проблема возникает, когда каждый элемент локального состояния взаимодействия переносится к компоненту уровня страницы только потому, что этот компонент имеет доступ ко всему.

Поле поиска сохраняет своё значение текста на панели управления. Флаг открытия/закрытия выпадающего списка находится рядом с данными таблицы. Состояние наведения отслеживается сеткой карточек. Поля внутренней формы модального окна управляются экраном, который его открывает. Каждая нажатие клавиши или незначительное взаимодействие заставляет этого верхнего уровня ответственности перерисовывать интерфейс.

Поскольку компонент страницы каждый раз возвращает свои дочерние элементы в новом виде, обновление текста поиска может привести к повторной обработке графиков, таблиц, навигации и диалоговых окон, хотя их фактические данные не изменились.

Это тоже не повод прятать каждый элемент состояния как можно глубже. Состояние должно находиться на самой мелкой границе, которая всё ещё требует координации соответствующих компонентов. Фильтр, от которого зависят URL, таблица и кнопка экспорта, вероятно, действительно должен находиться над этими областями. Однако наличие открытой подсказки в данный момент обычно не требует размещения её там.

Это различие особенно важно для значений, которые обновляются быстро. Ввод текста, перетаскивание элементов, отслеживание положения курсора, прогресс анимации и динамические поля формы могут генерировать множество обновлений в секунду. Если разместить одно из таких элементов над ресурсоемким поддеревом, то чисто локальное взаимодействие превратится в нагрузку для всей страницы.

Перенос состояния ближе к компонентам, которые им фактически пользуются, обычно способствует более прямому улучшению производительности, чем использование мемоизации для всей страницы, поскольку у React нет родителя, который мог бы снова сделать состояние недействительным, как только этот родитель больше не управляет данным состоянием.

2. Использование одного контекста для несвязанных значений

Контекст существует для решения конкретной проблемы: позволить потомкам-компонентам читать общие значения без необходимости передавать каждому компоненту между ними параметры, с которыми тот не работает.

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

Часто бывает, что один контекст на уровне всего приложения хранит информацию о текущем пользователе, теме, количестве непрочитанных уведомлений, видимости боковой панели, регионе настройки, флагах функций, разрешениях и нескольких функциях действий. Это кажется упорядоченным — один поставщик, один пользовательский хук для его использования.

Но с точки зрения обновлений эти значения не несут единой ответственности. Количество уведомлений может обновляться несколько раз в минуту; тема может оставаться неизменной в течение месяцев. Видимость боковой панели — это локальное состояние интерфейса, в то время как информация об авторизованном пользователе отражает саму сессию. Сведение их вместе означает, что каждый потребитель фактически подписывается на изменения, которые его совершенно не интересуют.

Часто поставщик также пересоздает свое значение контекста при каждой отрисовке:

function AppProvider({ children }) {
  const [theme, setTheme] = useState("light");
  const [notifications, setNotifications] = useState([]);
  const [sidebarOpen, setSidebarOpen] = useState(false);

  const value = {
    theme,
    setTheme,
    notifications,
    setNotifications,
    sidebarOpen,
    setSidebarOpen,
  };

  return (
    <AppContext.Provider value={value}>
      {children}
    </AppContext.Provider>
  );
}

Каждый раз, когда поставщик перерисовывается, value становится совершенно новым объектом. Потребители воспринимают это как изменение по ссылке, даже если конкретное поле, которое они читают, на самом деле не изменилось.

Хранение в памяти значения, переданного провайдеру, может предотвратить повторную отрисовку, вызванную обновлением не связанных родительских элементов, но оно не позволяет разделить значение контекста на независимые части. Если меняются уведомления, весь объединенный объект по-прежнему считается измененным.

Лучшим подходом является разделение контекста в соответствии с принципами ответственности и частоты обновлений, а не ради удобства наличия одного файла провайдера. Конфигурация, которая редко меняется, может находиться в одном контексте. Данные, которые быстро обновляются в реальном времени, могут иметь собственный контекст. Состояние локального макета, возможно, вообще не требует использования контекста на уровне всего приложения.

Контекст — это не просто механизм передачи значений; он также служит для распространения обновлений. То, как формируется значение провайдера, определяет, насколько далеко может распространиться каждое изменение.

3. Пересоздание объектов, массивов и функций, когда действительно важна идентичность

Создание нового значения при каждой отрисовке — это обычное поведение JavaScript. Создание встроенного объекта, отфильтрованного массива или новой функции-стрелки обычно стоит совсем немного, поэтому попытки избежать этого не принесут никакой пользы.

Ситуация усложняется, когда какая-то последующая логика зависит от идентичности значения, а не только от его содержимого.

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

Два объекта могут хранить идентичные данные, но при этом оставаться двумя разными объектами в памяти. Если дочерний элемент получает совершенно новый объект настроек при каждой отрисовке родителя, у него нет способа узнать, что на самом деле в этих настройках ничего не изменилось.

Распространённой реакцией является автоматическое оборачивание каждого объекта в useMemo, а каждой функции — в useCallback. Однако это лишь замена одной инстинктивной привычки на другую. Мемоизация не бесплатна — у неё есть собственные затраты на обработку, своя массив зависимостей для поддержания и собственные расходы на эксплуатацию. Она оправдывает себя только тогда, когда стабильный ссылочный элемент позволяет избежать повторной обработки дорогостоящих операций или использовать их повторно.

Значения, которые никогда не меняются, часто можно определять совсем вне компонента. Вместо того чтобы ради порядка объединять несколько свойств в один объект, дочерний компонент может просто получать отдельные примитивные значения напрямую. Функцию следует стабилизировать только тогда, когда она используется в составе операции Effect, подписки или механизма мемоизации, где идентичность действительно влияет на поведение.

Настоящее испытание заключается в том, чтобы наблюдать за последствиями использования нового источника данных. Если ничто дальше в цепочке не зависит от этого, повторное вычисление значения при каждой отрисовке не имеет никаких последствий. Однако если новый объект приводит к пересозданию большой таблицы или к разрыву и повторному установлению подписки, то идентичность этого объекта становится частью условий производительности компонента, и с ней следует обращаться соответственно.

4. Хранение вычисленных значений и их обновление с помощью эффектов

Одним из наименее очевидных источников дополнительной отрисовки является хранение состояния для того, что можно было бы просто вычислить на основе существующих параметров или состояния.

Рассмотрим компонент, который хранит полный список записей, текущий текст фильтра, а также отдельно хранится отфильтрованный список. Или форму, которая отслеживает свои поля и ошибки валидации, а также отдельный флаг canSubmit. Или компонент списка, который хранит как выбранный идентификатор, так и второе значение состояния с соответствующим выбранным объектом.

Поскольку эта дублируемая информация может потерять синхронизацию, разработчики добавляют эффект для её обновления:

function Products({ products }) {
  const [search, setSearch] = useState("");
  const [visibleProducts, setVisibleProducts] = useState(products);

   useEffect(() => {
    setVisibleProducts(
      products.filter((product) =>
        product.name.toLowerCase().includes(search.toLowerCase())
      )
    );
  }, [products, search]);
  // ...
}

Вот что происходит при изменении параметра search: сначала компонент отрисовывается с устаревшим отфильтрованным списком, который всё ещё находится в состоянии; затем React фиксирует эту отрисовку, после чего срабатывает эффект, обновляет состояние и запланировывает ещё одну отрисовку.

Эта вторая отрисовка возможна только потому, что результат вычислений сохраняется так, будто это независимая часть состояния.

Если visibleProducts вычисляется непосредственно во время отрисовки, он автоматически остается синхронизированным, и лишняя отрисовка полностью исчезает. Когда логика фильтрации действительно требует много ресурсов, можно обернуть её вычисления в мемоизатор, ключом к которому служат исходные данные. Важно понимать, что такой мемоизатор хранит лишь результат вычислений — он никогда не должен превращать этот результат в отдельный, самостоятельно изменяемый источник данных.

Эта практика имеет последствия, выходящие за рамки просто лишних отрисовок. Производные состояния могут временно показывать значения, противоречащие исходным данным, а объединение нескольких эффектов может привести к цепной перерисовке. Устранение избыточных состояний обычно влечёт за собой устранение нескольких эффектов синхронизации одновременно.

5. Подписка на больше данных из внешнего хранилища, чем действительно необходимо

Библиотеки состояния, основанные на селекторах, позволяют выполнять очень целенаправленные обновления, но только в том случае, если компоненты действительно выбирают узкие, конкретные значения.

Часто используемым упрощением является подписка на весь хранилище данных или на один крупный объект уровня функциональности, после чего извлекается лишь одно поле для использования. Это приводит к тому, что компонент перерисовывается каждый раз, когда меняется что-либо внутри этого более общего объекта — даже поля, к которым он вообще не обращается.

Эта проблема характерна не только для библиотек управления состоянием. Она встречается также при использовании кэшей для загрузки данных, систем на основе observable и ручно реализованных механизмов подписки. Чтение данных из большого источника исключительно ради удобства заставляет компонент реагировать на гораздо больше изменений, чем это необходимо.

Вопрос о том, до какого объема состояния технически может обращаться компонент, не является тем же самым вопросом, что и вопрос о том, какой объем этого состояния должен запускать его перерисовку.

6. Нестабильные ключи, которые тихо приводят к повторной загрузке вместо обновления

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

Нестабильные ключи могут тихо превращать то, что должно быть простым обновлением, в повторные загрузки.

Если ключ получен из случайного значения, текущей даты и времени или поля, которое меняется во время редактирования элемента, React интерпретирует это как полное исчезновение старого элемента. Поскольку каждая отрисовка создаёт новую идентичность, предыдущий дочерний элемент удаляется, а на его место загружается совершенно новый.

Часто видимые симптомы появляются ещё до того, как кто-либо заметит проблему с производительностью. Поле ввода текста теряет фокус в процессе ввода, раскрытые строки снова закрываются, анимации начинаются заново с самого начала, а состояние локальных компонентов исчезает. Проблема с производительностью становится серьёзной, когда перезагружаемые компоненты выполняют более тяжёлую работу по настройке — инициализацию редакторов, диаграмм, обозревателей, подписок, медиаплееров или сетевых запросов.

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

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

Существуют обоснованные случаи, когда намеренное изменение ключа является правильным решением, поскольку это заставляет React сбросить всю вложенную структуру. Это должно быть осознанным выбором, так как он означает уничтожение всего того, что React ранее сохранял для этой вложенной структуры. Неумышленная перерисовка гораздо дороже обычной, и её легко упустить, если выражение ключа на первый взгляд кажется невинным.

7. Поддержание загруженных тяжелых интерфейсов, когда они находятся вне поля зрения

Компонент может продолжать выполнять свои функции, даже если он невидим.

Вкладки, ящики, модальные окна и складываемые панели часто остаются вставленными, но визуально скрытыми с помощью CSS, поскольку такой подход сохраняет внутреннее состояние и позволяет интерфейсу мгновенно снова открыться. Для легковесного контента такой компромисс часто является разумным.

Однако для более крупных интерфейсов нахождение вставленным означает, что они могут продолжать отслеживать контекст и внешние хранилища, принимать новые параметры, перерисовываться, выполнять вычисления и поддерживать свои эффекты в активном состоянии — при этом пользователь не может увидеть результата. Панель управления может сохранять несколько графиков в полностью активном состоянии за вкладками, на которые сейчас никто не смотрит. Редактор, спрятанный в скрытой панели, может продолжать обрабатывать обновления документа на фоне. Диалоговое окно, которое было визуально закрыто, всё ещё может хранить большую структуру форм вместе с логикой проверки.

Ваше приложение несет реальные затраты из-за этого, поскольку React продолжает считать такой отросток частью соответствующих путей отрисовки. Установка свойства display: none влияет только на то, как браузер располагает элементы — она не влияет на то, будет ли React продолжать управлять этим отростком и обновлять его.

Тем не менее, полное удаляемое каждого скрытого элемента интерфейса также не всегда является правильным решением. Пересоздание тяжелого редактора с нуля каждый раз, когда пользователь меняет вкладку, может снизить общую скорость работы, а также удалить состояние пользователя, которое должно сохраняться. Важно принимать осознанные решения, исходя из жизненного цикла компонента и фактической стоимости его обработки.

Один из вариантов — отложить установку тяжелого панеля до момента его первого активирования. Те элементы, которые действительно должны оставаться видимыми даже при отсутствии панеля, могут размещаться в более маленьком специальном контейнере, в то время как сама структура интерфейса исчезает. Некоторые продукты находят компромисс, оставляя установленными только самые недавно использованные панели и снимая остальные.

Настоящий вопрос не в том, отображается ли что-то на экране в данный момент. Вопрос в том, должно ли это элемент продолжать получать обновления и реагировать на них, даже когда он находится вне экрана.

8. Перезапуск эффектов из-за нестабильных зависимостей

Эффект, зависящий от определенного значения, будет очищаться и перезапускаться каждый раз, когда это значение изменяется — и это совершенно правильно, если внешняя связь, которую представляет этот эффект, действительно изменилась.

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

Рассмотрим компонент, который создаёт объект с параметрами и передаёт его в пользовательский хук, который затем использует этот объект в качестве зависимости для эффекта. Поскольку родительский компонент создаёт совершенно новый объект при каждой отрисовке, эффект постоянно разрывает свою связь с ним и вновь её устанавливает. Аналогичным образом, обработчик событий, создаваемый заново при каждой отрисовке и используемый в качестве зависимости для эффекта-прослушивателя, приводит к тому, что этот прослушиватель постоянно удаляется и снова подключается. Объект конфигурации запроса может запускать новый запрос каждый раз, даже если ни одно из его значимых полей на самом деле не изменилось.

Такой дополнительный нагрузки не всегда проявляется в виде проблем с отрисовкой. Вместо этого она проявляется в виде дублирующихся сетевых запросов, быстрого открытия и закрытия соединений, мерцания интерфейса или исчезновения и появления обработчиков событий. Что ещё хуже, при повторном запуске эффектов часто сами вызываются функции установки состояния, что приводит к дополнительным операциям отрисовки и маскировке первоначальной причины проблемы.

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

Лучшим решением будет присвоить зависимости стабильную идентичность, когда именно она действительно имеет значение. В зависимости от ситуации вы можете создать объект непосредственно внутри самого Effect из примитивных значений, сделать кэш обратного вызова стабильным, чтобы его не пересоздавали при каждой отрисовке, или понять, что эта логика вообще не должна находиться в Effect, и перенести её в обработчик событий.

Effect должен перезапускаться потому, что внешняя система действительно требует новой синхронизации, а не потому, что при отрисовке случайно был создан ещё один функционально идентичный объект.

9. Замена состояния на новый объект, представляющий то же самое

В зависимости от того, как React сравнивает значения, иногда он может пропустить перерисовку, если следующее состояние совпадает с текущим. Однако для объектов и массивов передача React новой ссылки заставляет состояние казаться отличным, даже если его фактическое содержимое остаётся прежним.

Такая практика часто встречается в обработчиках событий и логике хранилища. Фильтр сбрасывается до совершенно нового объекта, который на самом деле эквивалентен тому, что уже было. Редьюсер возвращает новый объект состояния для действия, которое ничего не изменило. Ответ от опроса копируется в новый массив, хотя каждый элемент внутри него остаётся неизменным.

У React нет универсального способа самостоятельно обнаруживать глубокое семантическое совпадение. Если объект, управляющий состоянием, возвращает новый объект, React считает это достаточно значимым обновлением для обработки.

Чтобы избежать этого, редьюсеры должны возвращать существующий объект состояния, когда действие на самом деле ничего не меняет. Функции изменения состояния могут сравнить важные значения перед заменой всего структурированного объекта. Слои, отвечающие за нормализацию входящих данных, могут сохранять те же ссылки на записи, которые не изменились, вместо того чтобы каждый раз заново создавать всю коллекцию.

Все это не означает, что необходимо выполнять глубокое сравнение при каждом обновлении состояния. Глубокое сравнение может оказаться более затратным, чем проблема, которую оно пытается предотвратить, к тому же оно вводит ещё одно понятие «равенства», которое теперь нужно последовательно соблюдать. Более устойчивым подходом является естественное сохранение идентичности объектов каждый раз, когда основные данные на самом деле не изменились.

Структурное общее использование ресурсов позволяет React и всем связанным с ним компонентам, использующим механизм memoization, отличать настоящие изменения от повторного использования тех же данных. Новый ссылочный элемент должен сигнализировать о новом состоянии — а не просто о новой выделенной памяти, содержащей те же значения.

Цель не в устранении повторных отрисовок

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

Повторная обработка небольшого, простого компонента часто дешевле, чем создание и поддержка сложной схемы мемоизации вокруг него. Обновление контекста, затрагивающее лишь несколько легковесных потребителей, вообще может не оправдывать каких-либо архитектурных изменений. Решения о производительности должны основываться на реальной стоимости взаимодействия, на анализе производительности и на масштабе, который вы действительно ожидаете, а не на общем стремлении избегать отрисовки там, где это только возможно.

Вышеописанные привычки опасны именно потому, что они незаметно расширяют объем того, что обновляется. Чрезмерное использование состояния превращает небольшое локальное взаимодействие в операцию, затрагивающую всю страницу. Широкий контекст и подписки на хранилище распространяют несвязанные изменения на компоненты, которые вовсе не должны были о них знать. Новосозданные ссылки заставляют стабильные входные данные выглядеть иначе при каждой перерисовке. Производное состояние в сочетании с эффектами может вызывать каскад дополнительных обновлений. Нестабильные ключи препятствуют повторному использованию элементов, вынуждая полностью перерисовывать их. Скрытые поддеревья продолжают выполнять работу, недоступную для просмотра, а состояние, структурно идентичное предыдущему, постоянно рассматривается как совершенно новое.

Мемоизация действительно может помочь, когда эти основные взаимосвязи правильно поняты. Она работает наилучшим образом, когда границы компонентов, принадлежность состояния и идентификаторы ссылок уже сами по себе имеют смысл. Без такой основы мемоизация превращается лишь в защитный слой, окружающий граф обновлений, который по-прежнему остается чрезмерно большим.

Самый полезный вопрос относительно производительности React — это не «как мне предотвратить отрисовку этого компонента?»

Это «что на самом деле изменилось, кто несет ответственность за это изменение и почему от этого компонента снова потребовали переоценить свою работу?»

Как только у вас появляются четкие ответы на эти вопросы, значительная часть ненужных повторных отрисовок обычно исчезает сама собой — без превращения дерева компонентов в клубок кэшей и функций сравнения.