Когда мемоизация в React действует против вас: почему меньшее количество отрисовок замедляет приложение
Когда мемоизация в React дает обратный эффект: функции memo, useMemo и useCallback добавили нагрузку и устаревшие замыкания к крупной странице. Перемещение состояния и профилирование потока решили проблему.
Пример исправления неправильной проблемы
Представьте большую страницу, которая перерисовывается каждый раз, когда кто-то вводит данные в фильтр. На ней находятся таблицы, диаграммы, краткие сводки и несколько интерактивных панелей, а инструмент React Profiler отображает длинный список компонентов. Это кажется доказательством того, что React выполняет слишком много работы.
Обычно применяют стандартные решения: компоненты оборачиваются в функцию memo, обработчики — в useCallback, вычисления — в useMemo, а селекторы получают пользовательские функции сравнения. Если компонент всё равно перерисовывается, его свойства стабилизируются дополнительно. Объекты, у которых меняется ссылка, мемоизируются. Неудачные сравнения заменяются более сложными.
В конечном итоге инструмент профилирования действительно показывает меньшее количество отрисовок компонентов. Однако приложение не становится быстрее. Некоторые интеракции становятся менее отзывчивыми, некоторые экраны потребляют больше памяти, а у некоторых компонентов отображаются устаревшие значения из-за того, что для сохранения стабильности ссылки была проигнорирована зависимость. Отладка также становится сложнее, поскольку производительность теперь зависит от кэшей и правил сравнения, расположенных по всей структуре приложения. Команда настроила тот показатель, который был легко виден, а не тот, который действительно важен для пользователей.
Использование мемоизации не было ошибкой, ведь React предоставляет такие инструменты для решения реальных проблем. Ошибка заключалась в убеждении, что прекращение рендеринга всегда лучше, чем его разрешение. Рендеринг требует усилий, как и принятие решения о его пропуске. Когда такое решение подразумевает пересоздание массивов зависимостей, сравнение больших количеств свойств, хранение старых значений и выполнение специальной логики кэширования, оптимизация может оказаться дороже, чем работа, от которой она должна была защитить.
Что на самом деле означает «ненужный» рендеринг
Первая ошибка заключалась в определении ненужного рендеринга. Компонент появлялся в профиллере, хотя его окончательный DOM-вывод фактически не менялся, поэтому рендеринг считался пустой тратой ресурсов. Это игнорирует тот факт, насколько дешевыми бывают многие компоненты.
Возьмём функциональный компонент, который принимает несколько примитивных атрибутов и возвращает простую дерево элементов. Он выполняется мгновенно, React сравнивает полученный результат с предыдущим и практически не изменяет DOM, поэтому блокировка такого вызова не приносит почти никакой пользы.
У версии с мемоизацией есть своя задача. React должен сохранять предыдущие атрибуты, принимать новые и сравнивать их. При наличии нескольких атрибутов, среди которых могут быть объекты, массивы или функции, сравнение может часто терпеть неудачу или становиться настолько ресурсоёмким, что сводит на нет любые преимущества. Количество рендеров фактически стало показателем производительности, и чем ниже оно, тем лучше, даже если причиной задержки были не пропущенные рендеры.
Это легкая ловушка, поскольку инструмент профилирования делает процесс отрисовки видимым, в то время как другие затраты остаются скрытыми. Сравнение свойств, выделение памяти в JavaScript, сохранение в кэше, селекторы хранилища, формирование макета, процесс рисования и сторонние библиотеки могут сильно влиять на взаимодействие с компонентом, в то время как количество отрисовок привлекает всё внимание. Стоит предотвращать повторную отрисовку, когда компонент выполняет значимую работу, а его входные данные часто остаются неизменными. Не стоит этого делать только потому, что такое произошло.
Каковы реальные затраты функции memo
Люди часто описывают функцию memo как механизм, который предотвращает повторную отрисовку дочернего компонента, если свойства не изменились. Однако в этом описании упускается один важный шаг: React сначала должен сравнить старые свойства с новыми, прежде чем сможет что-либо пропустить.
При использовании примитивных атрибутов сравнение стоит недорого. Однако при наличии сложных контрактов компонентов оценить затраты гораздо сложнее. Передавались объекты строк, массивы конфигурации, модели разрешений, коллекции обратных вызовов и вложенные опции. Обертка получателя в memo не стабилизировала ни одного из этих значений; она лишь добавляла ещё один этап, на котором React проверял их стабильность.
Большинство таких проверок проваливались, поскольку родительский компонент пересоздавал хотя бы один референс во время отрисовки. React всё равно сравнивал значения, а затем отрисовывал компонент, так что усилия были добавлены, но ничего не устранено.
Затем использовались пользовательские функции сравнения, предназначенные для более тщательной проверки значений. Компонент таблицы проверял свои столбцы, отмеченные строки, параметры страницирования, факт загрузки данных и сами строки. Такой функция сравнения становилась второй копией модели зависимостей компонента, и она могла сработать некорректно в обоих случаях: тщательное сравнение требовало времени в реальном времени, тогда как небрежное могло пропустить необходимую обработку данных.
Теперь существовала новая граница корректности. Любой фактор, влияющий на отображение, должен был находить свое отражение в функции сравнения, поэтому добавление нового параметра означало необходимость изменения как самого компонента, так и логики его кэширования. Компоненты, которые ранее отображались корректно по умолчанию, оставались в таком состоянии только до тех пор, пока специально написанная функция сравнения безошибочно описывала их свойства.
Глубокое сравнение
Когда поверхностное сравнение слишком часто терпит неудачу, глубокое сравнение кажется очевидным следующим шагом. Если два объекта конфигурации содержат одинаковые значения, зачем разные ссылки должны принуждать к отрисовке? Сравните содержимое и пропустите его, если оно структурно идентично.
Это работает в демо-примерах с небольшими объектами. В реальном приложении некоторые свойства содержат сотни или тысячи значений, и глубокое сравнение проходит по массивам, проверяет вложенные объекты и повторяет всё это при каждой обновлении родительского элемента. Защищённый компонент может отрисовать лишь несколько строк или вернуть сторонний компонент с собственной системой мемоизации для стабильных поддеревьев. Проверка на равенство может стоить дороже, чем вызов самого компонента.
Глубокое сравнение также масштабируется в зависимости от размера входных данных, а не от размера изменений. Одно небольшое обновление состояния может заставить сравнитель пройтись по всему набору данных, чтобы доказать, что большая его часть не изменилась.
Структура тоже не всегда является надежной основой для сравнения. Функции, даты, карты, экземпляры классов и объекты библиотек могут иметь поведение или идентичность, которую обычная функция глубокого сравнения равенства не может интерпретировать, поэтому даже кажущееся полным сравнение может сохранять устаревшее поведение. Команда отказалась от простой модели ссылок в React в пользу собственной системы семантического сравнения равенства, так и не определив, что означает равенство для каждого типа данных.
Более эффективным решением редко бывало более строгое сравнение нестабильных объектов. Необходимо было естественным образом сохранять их идентичность, когда основные данные не изменились, сузить контракт свойств или переместить процедуру обновления ближе к компоненту, который ими управляет.
Хранение значений в кэше
Затем пошли значения. Фильтрованные массивы, объекты конфигурации, форматированные метки, таблицы поиска и простые вычисления получали функцию useMemo после того, как компоненты, с которыми они связаны, уже были мемоизированы.
Некоторые из них действительно этого заслуживали. Сортировка большого набора данных или создание сложной модели могут оправдать кэширование, если входные данные меняются реже, чем обновляется родительский компонент. Однако многие случаи не оправдывали этого. Соединение двух строк или создание небольшого объекта требует меньше ресурсов, чем вся структура вокруг них: компонент всё равно формирует массив зависимостей, React всё равно сравнивает каждый элемент с предыдущим, предыдущий результат всё равно занимает память, и разработчикам по-прежнему приходится проверять, полна ли список.
Код также становился визуально более сложным. Значение, которое раньше могло быть выражено одним выражением, теперь оказывалось обернутым в хук и массив зависимостей.
const label = useMemo(
() => `${firstName} ${lastName}`,
[firstName, lastName]
);
Во время выполнения кэш практически ничего не добавлял, что указывало на то, что вычисления не настолько важны, чтобы их оптимизировать. Что ещё хуже, использование useMemo повсюду затрудняло понимание потока данных. Разработчики сохраняли стабильность ссылок на объекты не потому, что их создание было дорогостоящим, а потому, что другие компоненты с мемоизацией зависели от их идентичности. Каждая оптимизация требовала новой, в результате возникала иерархия кэшей, которая функционировала только до тех пор, пока все связи оставались неразорванными.
Мемоизация наиболее эффективна тогда, когда позволяет сохранить дорогостоящие вычисления. В данном случае речь шла о сохранении ссылок, необходимых для других форм мемоизации, что представляет собой совершенно другую цель.
Обратные вызовы и устаревшие замыкания
Обратные вызовы, создаваемые во время отрисовки, каждый раз получают новую идентичность. Передаваемые мемоизированным дочерним компонентам, эти новые ссылки приводят к тому, что сравнение в дочернем компоненте терпит неудачу. В качестве решения почти каждый обработчик оборачивали в useCallback.
Это уменьшило частоту изменений ссылок, но привело к увеличению длины списков зависимостей. Обработчик сохранения зависел от черновика, выбранной записи, текущего пользователя, прав доступа, сервиса уведомлений и функции мутации, и любое изменение в любом из этих элементов всё равно приводило к созданию нового функции обратного вызова. Функция обратного вызова редко оставалась стабильной достаточно долго, чтобы её можно было повторно использовать. Дочерний элемент отрисовывался в любом случае, а родительский элемент получал ещё один хук и ещё один контракт зависимости.
Разработчики иногда удаляют зависимости, чтобы сохранить стабильность обратных вызовов, что приводит к появлению устаревших замыканий. Обратный вызов продолжает использовать старую версию данных, устаревшее значение разрешения или ранее выбранный запись, поскольку сохранение идентичности становится важнее, чем сохранение текущего поведения. Такой код может пройти тесты на начальном этапе взаимодействия и начать давать ошибки только после изменения состояния без пересоздания обратного вызова. Такие ошибки трудно отловить, поскольку функция существует и работает без ошибок; она просто оперирует старыми данными. Идентичность обратного вызова оптимизировалась за счёт свежести данных.
Лучший вопрос заключается в том, действительно ли ребенку с самого начала нужен стабильный кallback. Для дешевого ребенка, который не мемоизируется, useCallback приносит мало пользы. Если кallback используется для подписки или дорогого мемоизированного объекта, стабильность имеет значение, и его зависимости часто можно уменьшить путем изменения владельца, передачи более узкой операции или размещения логики ближе к состоянию, которое она читает. Функция с фиксированной идентичностью по своей природе не является быстрее; она полезна лишь тогда, когда она используется повторно в значимых целях.
Кэши используют память
Кэширование позволяет избежать повторных вычислений путем хранения предыдущих результатов, но это хранение требует ресурсов памяти. Небольшое мемоизированное значение редко само по себе становится проблемой с памятью. Однако в крупном приложении множество компонентов могут хранить массивы, карты, преобразованные записи, сформатированные модели и замыкания, которые сохраняют дополнительные данные.
Это одна из причин, по которой один вид просмотра профиля не может полностью описать производительность. Результаты кэшированных вычислений могут исчезнуть из временной шкалы отрисовки, но в то же время способствовать росту использования памяти и более длительным паузам позже. То, будет ли это выгодно, зависит от размера результата, затрат на его повторное создание, частоты повторного использования и времени, в течение которого компонент остается активным. Большая производная модель, кэшируемая для редко открываемого панеля, может стоить дороже, чем её повторное создание при необходимости. Кэши — это не бесплатное хранилище для старых данных. Они заменяют вычисления на сохранённое состояние и добавляют дополнительную логику для аннулирования данных.
Настоящая проблема заключалась в месте хранения состояния
Ключевым выводом стало то, что процесс повторной отрисовки зависел от места хранения состояния. Постоянно меняющийся фильтр находился в компоненте страницы, который также отрисовывал графики, сводки, большую таблицу и несколько панелей. Каждое нажатие клавиши приводило к повторной отрисовке страницы и пересозданию параметров для всех дочерних элементов.
Защита деталей кода с помощью мемоизации подразумевала стабилизацию их массивов, объектов и функций-обратных вызовов, а также пользовательских селекторов и операций сравнения, при этом архитектура по-прежнему основывалась на одном общем границе обновления. Перемещение состояния фильтра рядом с таблицей устранило большую часть проблем, и остальная часть страницы больше не нуждалась в защите.
В других местах происходило то же самое: состояние диалоговых форм находилось в компоненте экрана, состояние наведения существовало над всей коллекцией карточек, один контекст хранил значения с совершенно разными частотами обновления, а глобальный селектор хранилища возвращал больше данных, чем требовалось его потребителю. Мемоизация лишь маскировала проблемы, связанные с выбором способов управления данными, из-за которых небольшие изменения распространялись слишком далеко.
После ужесточения этих ограничений многие слои мемоизации стали бесполезными. Компоненты, не входящие в цепочку обновлений, перестали отрисовываться, поскольку состояние больше не находилось над ними, а дочерние компоненты получили более простые требования к работе, так как родительский компонент больше не контролировал их детали.
Меньше отрисовок, та же нагрузка на браузер
Даже в тех случаях, когда мемоизация позволяла избежать отрисовок в React, интеракции иногда оставались медленными, поскольку основной проблемой был не React.
Библиотека для построения графиков продолжала выполнять дорогостоящие императивные обновления. Большое дерево DOM всё ещё требовало перерасчёта макета при изменении размеров. Один CSS-эффект приводил к масштабным перерисовкам, синхронная запись в хранилище блокировала основной поток, а обработчик ввода анализировал большой документ до появления следующего символа. Компоненты привлекали внимание лишь потому, что именно их измерял профилятор, и по мере снижения затрат на отрисовку становилась видна работа браузера, происходящая на фоне.
Производительность React — это лишь часть общей производительности фронтенда, а не её замена. Даже при использовании мемоизации изменение зависимостей функции Effect может заставить сторонний компонент пересоздать себя заново. Игнорирование отрисовки родительского элемента не сокращает время декодирования слишком большого изображения. Стабильный кallback не поможет избавиться от проблем с лейаутом, возникающих при чтении и записи данных о размерах элементов в неправильном порядке. Необходимо проследить весь процесс через JavaScript, отрисовку в React, изменения в DOM, расчёт стилей, формирование лейаута, отрисовку и сетевые операции, поскольку ускорение одного этапа может оставить весь процесс без изменений. React оставался бездействующим, пока браузер продолжал работать интенсивно.
Код, который труднее изменить
Проблема с производительностью была лишь половиной проблемы; приложение также стало труднее в обслуживании.
Каждая граница компонента порождала новые вопросы. Защищён ли компонент с помощью мемоизации? Какие атрибуты требуют стабильных ссылок? Покрывает ли пользовательский компаратор новый атрибут? Была ли какая-то величина намеренно исключена из списка зависимостей? Приведёт ли изменение структуры одного объекта к недействительности нескольких кэшей? Для выполнения обычных задач по разработке функционала требовалось понимание правил оптимизации, которые не имели никакого отношения к поведению продукта.
Представьте, что разработчик добавляет поле в объект конфигурации и случайно вызывает массовую перерисовку. Коллега «исправляет» это с помощью более тщательного сравнения. Позже кто-то добавляет функцию в объект и обнаруживает, что компаратор считает две явно разные конфигурации равными. Код был настроен под свою текущую структуру, и любое изменение нарушало тонко сбалансированную сеть ссылок и массивов зависимостей.
Архитектура с хорошей производительностью позволяет дешевле вносить обычные изменения и не требует, чтобы каждое изменение соблюдало скрытые предположения кэша. Это не означает, что оптимизированный код обязательно должен оставаться простым. Некоторые дорогостоящие операции действительно оправдывают использование специализированных механизмов мемоизации и тщательного управления ссылками. Сложность должна быть пропорциональна получаемой выгоде и сосредотачиваться в известных «горячих точках», тогда как здесь она была распределена повсюду на основании предположения, что любая отрисовка когда-нибудь может стать «горячей точкой».
Измерение взаимодействий изменило решение
Переломным моментом стало профилирование полных пользовательских взаимодействий вместо подсчёта количества отрисовок.
Были зафиксированы действия, которые пользователи считали медленными, и проанализировано, куда уходит время. Несколько компонентов отрисовывались часто, но практически не использовались. Доминировали некоторые вычисления и вспомогательные элементы от третьих сторон, а пользовательские сравнения регистрировались как настоящая работа JavaScript. В некоторых случаях удаление мемоизации приводило к незначительно большему количеству отрисовок, но к меньшему общему времени выполнения.
Сначала это казалось неправильным, поскольку принятая модель мышления рассматривала меньшее количество повторных отрисовок как прогресс. Браузеру не важно, сколько раз вызываются функции компонентов; важно то, сколько работы выполняется до следующей отрисовки экрана.
С тех пор изменения оценивались по общей стоимости взаимодействия. Отвечало ли входное сообщение быстрее? Оставался ли основной поток свободным? Оставалась ли память стабильной в течение длительных сессий? Улучшались ли версии для производства, а не только для разработки? Исправления также стали более локальными: одну дорогостоящую трансформацию, один крупный поддерево или одну нестабильную конфигурацию библиотеки можно было устранить напрямую, в то время как остальная часть приложения могла отказаться от защитных механизмов мемоизации. Профилирование не приводило к полной переработке — оно лишь помогало команде не стремиться к видимым цифрам, которые не влияли на пользовательский опыт.
Что осталось
Не все функции memo, useMemo и useCallback были удалены, поскольку это привело бы к повторению той же ошибки в обратном направлении.
Мемоизация продолжала применяться к дорогостоящим компонентам, свойства которых в основном не менялись. Преобразования, оказавшиеся дорогостоящими и используемые при частом отрисовывании родительских компонентов, оставались в кэше, а функции-возвраты сохраняли стабильность там, где их идентичность имела значение для подписок, интеграций с сторонними сервисами или четко определенных границ мемоизации.
Что исчезло: кэши для тривиальных вычислений, пользовательские функции сравнения, выполняющие обширные структурные проверки, а также нежелание позволять дешевым компонентам отрисовываться. Часто меняющееся состояние перемещалось ближе к интерфейсам, которые им пользовались, а значения контекста с малым количеством общего разделялись. Результатом не стало минимальное возможное количество отрисовок. Это была модель обновлений, которую легче было понять, при этом оптимизации применялись там, где это было обосновано. Это сделало приложение быстрее и удобнее для изменений.
Отрисовка — не враг
Всё началось с вполне разумного инстинкта: наблюдать за происходящей работой и пытаться её остановить. Ошибка заключалась в том, что каждая обработка считалась потерей ресурсов, а каждая пропущенная обработка — успехом.
Мемоизация сравнивает входные данные, хранит значения, отслеживает зависимости и должна оставаться корректной при изменении кода. Реализация собственных правил равенства может обойтись дороже, чем одна обработка. Стабильные обратные вызовы могут замораживать устаревшее состояние. Результаты, сохранённые в кэше, занимают память. А клубок оптимизаций может сделать структуру компонентов сложной для изменения без какой-либо заметной для пользователя пользы.
Обработка данных в React часто происходит довольно быстро. Однако слабая привязка к состоянию, многократные дорогостоящие вычисления, обширные подписки, обновления от сторонних источников и особенности макета браузера зачастую играют гораздо более важную роль. Самым эффективным решением является сохранение низкой стоимости простых операций: следует перестать оборачивать обычные компоненты в кэши и сосредоточиться на тех немногих операциях, которые действительно требуют много ресурсов. Оптимизация должна снижать общую стоимость взаимодействия, а не просто количество вызовов функций React. Ускорение появляется тогда, когда вопрос смещается с того, как предотвратить отрисовку в React, на то, почему каждое обновление становится таким дорогостоящим.
Связанные материалы
- Как хранилище браузера тайно повреждает данные пользователей и как этого избежать — Потеря обновлений, устаревшие снимки состояния, изменения структуры данных и утечки информации с общедоступных устройств могут повредить данные localStorage без каких-либо ошибок. Шаблоны для определения владельца, версионирования и проверки данных.
- Снимки памяти агента, навыки, процесс устаревания и ограничения по веткам — Четыре дополнения к встроенному слою памяти в виде векторной базы данных для создания программных агентов: переносимые снимки с возможностью слияния, активация навыков, механизм устаревания с проверкой конфликтов и память, ограниченная по веткам.