Десятикратное улучшение производительности React-приложения +18


AliExpress RU&CIS

image


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


Около года назад в Techgoise я получил возможность поработать с большим React-приложением. Мы получили (унаследовали) готовую кодовую базу, внесли основные правки и начали добавлять в приложение новые интересные возможности.


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


В данной статье я расскажу о том, как нам удалось добиться уменьшения этой цифры с 1,5 Гб до 150 Мб, что, как следствие, привело к улучшению производительности почти в 10 раз, и мы больше никогда не сталкивались с Ошибкой.


Поиск узких мест в производительности


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


1. Профилирование компонентов с помощью расширения для Google Chrome


Flamegraph (что условно можно перевести как граф в форме языков пламени), предоставляемый названным инструментом, является довольно информативным, но его анализ занимает много времени. Он помогает определить, сколько времени занимает рендеринг каждого компонента в приложении. Цветные полоски позволяют с первого взгляда понять, рендеринг какого компонента выполняется дольше всего. Точное время можно увидеть на самой полоске (если для этого хватает места) или при наведении на нее курсора. Ранжированная диаграмма позволяет расположить компоненты по времени рендеринга от большего к меньшему.


2. Снимки используемой памяти в Firefox


Данный инструмент предоставляет снимок "кучи", которая используется текущей вкладкой браузера. Древовидное представление снимка показывает следующие вещи:


  1. Объекты: объекты JavaScript и DOM, такие как функции, массивы или, собственно, объекты, а также типы DOM, такие как Window и HTMLDivElement.
  2. Скрипты: источники JavaScript-кода, загружаемые страницей.
  3. Строки.
  4. Другое: внутренние объекты, используемые SpiderMonkey.

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


3. Пакет why-did-you-render


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


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


Как же нам удалось решить эту задачу?


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


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


1. Удаление встроенных функций


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


import Child from 'components/Child'

const Parent = () => (
 <Child onClick={() => {
   console.log('Случился клик!')
 }} />
)

export default Parent

В нашем коде имеется встроенная функция. С такими функциями сопряжено 2 главных проблемы:


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

В основном, это связано с тем, что в данном случае метод "передается по ссылке", поэтому на каждом цикле рендеринга создается новая функция и изменяется ссылка на нее. Это присходит даже при использовании PureComponent или React.memo().


Решение: выносим встроенные функции из рендеринга компонента.


import Child from 'components/Child'

const Parent = () => {
 const handleClick = () => {
   console.log('Случился клик!')
 }

 return (
   <Child onClick={handleClick} />
 )
}

Это позволило снизить расход памяти с 1,5 Гб до 800 Мб.


2. Сохранение состояния при отсутствии изменений хранилища Redux


Как правило, для хранения состояния мы используем хранилище Redux. Предположим, что мы повторно обращаемся к API и получаем те же данные. Должны ли мы в этом случае обновлять хранилище? Короткий ответ — нет. Если мы это сделаем, то компоненты, использующие такие данные будут повторно отрисованы, поскольку изменилась ссылка на данные.


В унаследованной кодовой базе для этого использовался такой хак: JSON.stringify(prevProps.data) !== JSON.stringify(this.props.data). Однако, на нескольких страницах он не давал желаемого эффекта.


Решение: перед обновлением состояния в хранилище Redux проводим эффективное сравнение данных. Мы обнаружили два хороших пакета, отлично подходящих для решения этой задачи: deep-equal и fast-deep-equal.


Это привело с уменьшению Цифры с 800 до 500 Мб.


3. Условный рендеринг компонентов


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


import { useState } from 'react'
import { Modal, Button } from 'someCSSFramework'

const Modal = ({ isOpen, title, body, onClose }) => {
 const [open, setOpen] = useState(isOpen || false)

 const handleClick =
   typeof onClose === 'function'
     ? onClose
     : () => { setOpen(false) }

 return (
   <Modal show={open}>
     <Button onClick={handleClick}>x<Button>
     <Modal.Header>{title}</Modal.Header>
     <Modal.Body>{body}</Modal.Body>
   </Modal>
 )
}

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


Решение: рендеринг таких компонентов по условию (условный рендеринг). Также можно рассмотреть вариант с "ленивой" (отложенной) загрузкой кода таких компонентов.


Это привело к снижению расхода памяти с 500 до 150 Мб.


Перепишем приведеный выше пример:


import { useState } from 'react'
import { Modal, Button } from 'someCSSFramework'

const Modal = ({ isOpen, title, body, onClose }) => {
 const [open, setOpen] = useState(isOpen || false)

 const handleClick =
   typeof onClose === 'function'
     ? onClose
     : () => { setOpen(false) }

 // условный рендеринг
 if (!open) return null

 return (
   <Modal show={open}>
     <Button onClick={handleClick}>x<Button>
     <Modal.Header>{title}</Modal.Header>
     <Modal.Body>{body}</Modal.Body>
   </Modal>
 )
}

4. Удаление ненужных await и использование Promise.all()


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


Обычно, для получения начальных данных мы обращаемся к API. Представьте, что для инициализации приложение требуется получить данные от 3-5 API, как в приведенном ниже примере. Методы get... в примере связанны с соответствующими запросами к API:


const userDetails = await getUserDetails()
const userSubscription = await getUserSubscription()
const userNotifications = await getUserNotifications()

Решение: для одновременного выполнения запросов к API следует использовать Promise.all(). Обратите внимание: это будет работать только в том случае, когда ваши данные не зависят друг от друга и порядок их получения не имеет значения.


В нашем случае это увеличило скорость начальной загрузки приложения на 30%.


const [
 userSubscription,
 userDetails,
 userNotifications
] = await Promise.all([
 getUserSubscription(),
 getUserDetails(),
 getUserNotifications()
])

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


Заключение


Итак, для повышения производительности React-приложения необходимо придерживаться следующих правил:


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

Спустя 3 недели разработки (включая тестирование), мы, наконец, развернули продакшн-версию приложения. С тех пор мы ни разу не сталкивались в ошибкой "Aw! Snap".


Благодарю за внимание и хорошего дня!




Облачные серверы от Маклауд быстрые и безопасные.


Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!





Комментарии (13):

  1. Alexandroppolus
    /#23147664 / +4

    Я так и не понял, что поменялось в "решении 1" )) Функция как создавалась на лету, так и создается. Про useCallback ни слова..


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

    • NookieGrey
      /#23148148

      Да, в функциональных компонентах нужен useCallback, в оригинале классовый метод создаётся при инициализации, только не ясно как в ФК это поможет уменьшить память, скорее наоборот должно увеличить, скорость перерендера — возможно и то нужно смотреть на реальные примеры

      в оригинале нет ни какой логики onClose, это зачем-то добавил автор перевода

  2. faiwer
    /#23148198

    Зря вы это перевели. Статья очень низкого качества. Пункт 1 так вообще полная чушь. Пункт 4 не про "await" а про запуск в параллели, вместо последовательности (await там остался). Пункт 2 смешной… Они и правда использовали JSON.stringify(prevProps.data) !== JSON.stringify(this.props.data)? Ух… Не удивительно что они ускорили проект. После такого то.


    В общем статья какой-то мусор (я про оригинал).

  3. NookieGrey
    /#23148232

    Да и ретурнуть null лучше не перед jsx, а в месте вызова самого компонента

    {isOpen && <MyModal>}

  4. kahi4
    /#23148516

    1. Некорректный пример (как написали выше). Самый крутой способ это когда можно вынести функцию из компонента, но доступен далеко не всегда. Дальше начинается вопрос с useCallback и тут очень все непросто, покуда сам useCallbakc не бесплатен и вопрос что дешевле: перерисовать компонент или прибегнуть к хукам совсем не прост.

    2. Спасибо, капитан, мы учтём что нудно каждый раз думать обновление каких данных должно приводить к перерисовки. Впрочем, это отличный способ заняться преждевременной оптимизацией: при изменении бизнес требований часто нужны другие части для обновления. А Json stringify это победа. Гораздо лучше заранее правильно проектировать данные, все эти deep compare абсолютно всегда стрельба себе в ногу без исключений.

    3. Прощайте анимашки, хех. Это я к тому что обычно библиотеки требуют такой апи (привет antd) не просто так, в противном случае это копитанство.

    4. великолепный перевод, в котором полностью потерян смысл. На первый взгляд появляется ощущение что async await накладывает дополнительные расходы (что оно делает, но это экономия на спичках. Да даже не спичках, а что еще дешевле?), но по тексту просто рекомендация грузить данные параллельно. Это, кончено, хорошо, но не всегда просто или вообще возможно, а ещё очередное капитанство и вообще не имеет отношения к реакту, а скорее про js или вообще про программирование как таковое.

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

    • Alexandroppolus
      /#23148952

      3. Прощайте анимашки, хех. Это я к тому что обычно библиотеки требуют такой апи (привет antd) не просто так

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

      • kahi4
        /#23150862

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

        хм, было бы здорово если бы в браузере была какая-то апишка типа «сделать скриншот дивчика и показывать его пока идёт анимашка», но вот нет.

        • faiwer
          /#23151256

          хм, было бы здорово если бы в браузере была какая-то апишка типа «сделать скриншот дивчика и показывать его пока идёт анимашка», но вот нет.

          Я в порядке бреда как-то что-то подобное реализовал. Алгоритм был примерно такой:


          • render запоминаем innerHTML как строку
          • на unmount восстанавливаем вручную
          • запускаем нужные анимации
          • по окончанию анимации убиваем

          Получилось неплохо. Использовал в диалогах. Понятное дело что этот HTML макет после innerHTML = savedHTML был не интерактивным. Ну и понятно, что не все возможные случаи это покрывает. Но получилось довольно дёшево и сердито. Пожалуй стоит поиграться с этой идеей ещё разок. Главное преимущество получалось в том, что из-за анимации закрытия не приходилось усложнять никак с этим не связанный код.

          • kahi4
            /#23151448

            Ну можно сделать сильно проще используя CloneNode. Только не уверен что вызванный этим пересчёт лейаута не приведёт к большему падению производительности. Я бы в идеале хотел бы использовать тот факт, что контент дивчика и так уже отправлен на видеокарту как текстурка, и достаточно просто не трогать контент, а анимировать средствами видеокарты (translate свойства), было бы практически бесплатно

            • faiwer
              /#23151764

              Про cloneNode я тоже думал, но, кажется, ЕМНИП, проблема была в том, что его пришлось бы вызывать при каждом рендере превентивно. Точной причины почему уже не помню, кажется что-то связанно с тем что на момент unmount-а клонировать уже поздно, т.к. HTML уже не тот. Видимо какие-то контексты и условный рендеринг мешали.

  5. alfaslash
    /#23149452

    Предположим, что мы повторно обращаемся к API и получаем те же данные. Должны ли мы в этом случае обновлять хранилище? Короткий ответ — нет. Если мы это сделаем, то компоненты, использующие такие данные будут повторно отрисованы, поскольку изменилась ссылка на данные.

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

  6. bano-notit
    /#23152936

    прим. пер.: я позволил себе немного изменить код приводимых примеров,

    Не стоило. Вообще. Совсем