Эмми Руссо и Андерс Вуд | май 2020 г.

Обзор

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

tl;dr

Проблема

Мы внедрили Context React, чтобы хранить состояние нашей функции в одном месте, и useContext React Hooks, чтобы использовать его во всех компонентах функции. Мы обнаружили, что ловушка заключается в том, что любой компонент, потребляющий состояние с useContext, будет повторно отображаться при обновлении ЛЮБОЙ части состояния контекста. Это привело к тому, что компоненты были полностью отделены друг от друга, что привело к повторному рендерингу друг друга. В тех случаях, когда эти повторные рендеры были дорогими, память в браузерах пользователей накапливала следы JS Heap порядка гигабайт.

разрешение

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

Подход

Приняв следующие меры, мы смогли решить проблему:

  • Идентификация: поиск конкретных пользовательских экземпляров проблемы с помощью отчетов клиентов и сеансов LogRocket.
  • Воспроизвести: локально создать конкретную среду, в которой проявляется проблема.
  • Диагностика: воздействуйте на локальную среду, пока мы не поймем, ПОЧЕМУ возникает проблема.
  • Исправить: изучите соответствующую информацию, обсуждающую проблему, и примените решение.

Мы потратили значительную часть времени на части Идентификация и Воспроизведение. Хотя процесс ни в коем случае не был линейным, было крайне важно окончательно завершить эти этапы. Как только мы смогли легко воссоздать проблему, элементы Диагностика и Исправить довольно быстро встали на свои места.

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

Детс

Недавно мы перестроили наш опыт редактирования активов, ключевой элемент нашей платформы. В нашем обновлении мы перестроили старое модальное окно, которое было обработано Rails, чтобы вместо этого использовать хуки React и API Brandfolder. Поскольку мы больше не отправляем данные формы в большом количестве, нам нужен был способ определить, какие части актива были отредактированы, чтобы избежать отправки ненужных обновлений. Для этого требовалось сложное состояние, чтобы мы могли непрерывно сравнивать начальное состояние актива и изменения, внесенные пользователем. Было важно сохранить состояние вместе на верхнем уровне компонента, потому что отдельные подкомпоненты могут влиять друг на друга.

Поскольку актив можно редактировать несколькими способами, отображение модального окна актива также сложное, состоящее примерно из 30 подкомпонентов, которые иногда глубоко вложены друг в друга. Учитывая потребность в общем состоянии для многих компонентов, мы решили использовать хуки React Context и useContext, чтобы избежать детализации реквизита.

Деты: определить

После запуска нового интерфейса редактирования мы начали периодически получать отчеты о том, что процесс редактирования работает медленно и зависает. Сделав снимок производительности с помощью вкладки Производительность Chrome DevTools в рабочей среде, мы увидели значительный рост кучи JavaScript в браузере.

Нам нужно было очень конкретное воспроизведение замедления. Хотя мы могли в какой-то степени вызвать всплеск кучи локально, мы не смогли создать надежное воспроизведение, пока не изучили случаи замедления на LogRocket. LogRocket позволил нам изучить несколько случаев проблем с производительностью, возникающих в производственной среде, и определить все возможные причины (действия пользователя, функции ресурсов, свойства ресурсов, браузер и т. д.).

Нам было известно по крайней мере об одном конкретном клиенте, который столкнулся с проблемой производительности, и мы начали с изучения вкладки сетевого трафика и производительности своего сеанса. В этом случае мы увидели значительный всплеск памяти (до 3500 МБ). Учитывая это, мы искали сеансы LogRocket со средним объемом памяти более 1000 МБ, чтобы попытаться выявить общие черты.

Мы обнаружили, что замедление часто происходило при редактировании ресурсов со значительными метаданными (теги и вложения) и что объем памяти обычно увеличивался, когда пользователь редактировал раздел описания модального окна.

Деты: Воспроизводить

Как только мы узнали, что для воспроизведения ошибки требуется большое количество тегов или вложений, мы создали локальную среду с 3000 тегами. Используя монитор производительности Chrome, мы смогли наблюдать всплеск памяти, аналогичный по величине пользовательским вариантам, в режиме реального времени при редактировании частей модального окна.

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

Deets: диагностика

Исключение возможностей

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

Исключить эту возможность оказалось довольно просто — мы заменили компонент WYSIWYG простым управляемым элементом textarea. Как только мы это сделали, мы по-прежнему наблюдали всплеск памяти, поэтому мы смогли исключить потенциальные проблемы с редактором jQuery WYSIWYG как основной причиной проблемы с производительностью.

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

Задайте правильный вопрос

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

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

Чтобы получить четкое представление о процессе рендеринга, мы добавили оператор console.log к большинству подкомпонентов в модальном окне редактирования и начали вводить описание. Каждый раз, когда мы добавляли один символ, мы видели в консоли, что каждый подкомпонент был отрендерен.

Деты: исправить

Улучшить работу с Google

В конечном итоге мы использовали метод useMemo React Hooks для управления повторным рендерингом деревьев компонентов. Весь этот процесс и поиски привели нас к тому, что мы обратились к Google с точно отточенным запросом useContext реагировать, предотвращать повторную визуализацию дочерних элементов, который привел к нашему решению. Первым результатом поиска является выпуск React, где Дэн Абрамов предлагает три способа борьбы с нашей конкретной ошибкой.

Мы выбрали вариант 3 (useMemo) с долгосрочным планом использования варианта 1 (использование нескольких контекстов).

const AttachmentList: FunctionComponent = () => {
  const { dispatch, state } = useContext(assetModalContext);
  const { attachments } = state.editState;
  return useMemo(() => (
    {(attachments.map((attachment, i) => (
      <AttachmentListItem
        attachment={attachment}
        dispatch={dispatch}
        index={i}
        key={attachment.key}
      />
    )))}
), [attachments, dispatch]);
};

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

Дополнительная информация

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

Причина, по которой это привело к нашему конкретному опыту, изложена Дэном Абрамовым более очевидным образом в этом React GitHub Issue.

useContext не позволяет вам подписаться на часть значения контекста (или какой-либо запоминаемый селектор) без полного повторного рендеринга