Обложка статьи «Оптимизация графиков Recharts»

Оптимизация графиков Recharts

Рассказывает Иван, Senior Developer в Noveo

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

Предыстория

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

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

Recharts

После поиска возможных вариантов и их анализа было принято решение об использовании библиотеки Recharts.

Recharts — это библиотека на основе D3.js, позволяющая строить графики с использованием HTML, SVG и CSS. Главными преимуществами Recharts является то, что она «заточена» под React, и в ней используется компонентный подход, обеспечивающий хорошую модульность. Реализованные на Recharts графики достаточно легко комбинировать и переиспользовать. А также, что очень важно, библиотека позволяет достаточно сильно модифицировать стандартные элементы и переопределять внутренние обработчики.

Библиотека достаточно популярная и имеет большое комьюнити.

Оптимизация графиков

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

Первый график — столбиковая диаграмма, Bar chart. В представленной реализации график позволяет выбирать range (участок от-до), и в стейте компонента происходит достаточно много изменений, приводящих к ререндерам.

Первая оптимизация

Выяснилось (c помощью Chrome devtools и React devtools), что ререндер компонента сетки графика и самого графика достаточно дорогой. Он длится порядка 20 мсек на шустрой машине и существенно больше на «медленном» железе.

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

Вынесли стейт в отдельный «дочерний» компонент, где отрисовываются только сами «бары» (для таких целей Recharts предоставляет Customized, где можно реализовать свой компонент, используя svg). Компонент с графиком, осями и сеткой стал при этом stateless и отрисовывался теперь только при первоначальном (initial) рендере. Сами бары находятся теперь внутри дочернего компонента со стейтом и реализованы с помощью элементов svg rect.

От дефолтного тултипа при оптимизации пришлось отказаться, был использован кастомный тултип, который подходил для использования с кастомным графиком на svg rect. В дальнейшем этот тултип также использовался при оптимизации других графиков.

Вторая оптимизация

Отказались от замыканий в пользу data-атрибутов. Recharts использует свой вариант обработчиков для hover, click и других событий на элементах графика, добавляя в обработчик, помимо нативного event, еще и индекс выбранного элемента, дату для выбранной точки и т.д.

На рисунке console.log arguments обработчика.

1 элемент — это данные графика, которые Recharts добавляет в каждый обработчик.

2 — index элемента.

3 — нативный эвент.

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

Индекс было решено передавать через data-атрибут. Это позволило использовать один нативный обработчик, который не пересоздается при ререндерах, не использует замыканий и не добавляет лишней информации, только нативный ивент.

Третья оптимизация + детали разработки Line chart (big data)

Рассмотрим другой тип графиков — обычную линейную диаграмму, Line Chart.

Что в нем такого особенного, что пришлось его оптимизировать? Объем данных: выборка за 20+ лет, количество точек в разных кейсах от 10 до 50 тысяч. Помимо этого, в графике нужно было реализовать zoom по колесу мыши и перемещение влево-вправо по оси X.

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

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

Recharts построен на базе библиотеки D3.js, где есть такое понятие, как domain (область). По сути это тот диапазон значений, который будет отображаться на графике. То есть если мы установим domain для оси Х: [1047686400, 1071446400]  (unix timestamp), то получим такой вид:

Это как раз то, что нам нужно: будем управлять domain при прокрутке колеса мыши и получим zoom. Но тут есть нюанс: мы не можем просто увеличивать или уменьшать размер области пропорционально шагу прокрутки колеса мыши — нам нужна
«точка отсчета». Если мы хотим приблизить участок в конце таймлайна и ставим туда курсор, то мы ожидаем, что зум будет именно в этой области, а не относительно центра оси.

Реализация зума в коде выглядела приблизительно так:

Для перемещения влево/вправо использовался похожий принцип, только вместо изменения размеров domain смещается влево/вправо пропорционально перемещению курсора при зажатой клавише мыши.

В целом график в таком виде уже был в рабочем состоянии, но перформанс проседал на «медленном» железе именно при ресайзе и перемещении окна графика (~9 fps):

Мы решили попробовать нарисовать линию самостоятельно с помощью Customized-компонента, как и в предыдущем примере. При каждом изменении domain запускается расчет нового svg-path.

Здесь намеренно используется for, на данный момент он все еще значительно быстрее функциональных аналогов forEach, map, reduce, и т.д. (смотрите статью). Кажется, что для десятков тысяч точек это слишком дорогая операция, но на практике это достаточно быстро. В production mode при таком подходе fps не падает ниже 27-30 в моменты ресайза, при среднем значении 55-60 (до оптимизации fps 8-10).

Но, к сожалению, при таком подходе нам снова приходится отказаться от встроенных тултипов и указателя выбранной точки на графике. Главная сложность заключается именно в расчете позиции точки указателя на графике. Нам нужно по позиции курсора определить ближайшую точку и «примагнититься» к ней. В JavaScript нет built-in инструментов для проведения hit-test, мы не можем получить координаты пересечения двух отрисованных линий (иначе можно было бы схитрить — нарисовать нормаль из позиции курсора, но примагничивания к точке мы бы не получили).

Нам остается аналитический расчет и поиск ближайшей точки самостоятельно.
Для начала нужно вычислить координаты курсора относительно холста и посчитать соответствующее значение по оси X, исходя из текущего домена (к счастью, у нас линейный scale и это обычная пропорция). Это будет текущее значение по оси X для точки курсора. А дальше начинается самое интересное — поиск в массиве ближайшего значения точки, к которой мы собираемся «примагничиваться».

Все было бы достаточно просто для небольших массивов (до 1к элементов). Но при количестве точек до 50k (кроме того, была задача на аналогичный график сразу с 2 линиями) хотелось выбрать что-то оптимальнее простого перебора. И такой алгоритм нашелся.

Тут важно заметить, что мы имеем дело с уже отсортированным массивом — значения по оси x всегда возрастают.

Бинарный поиск — самый быстрый поиск для отсортированных списков. Не очень часто применяемый во frontend-разработке алгоритм оказался для этой задачи оптимальным решением.

Максимальное количество итераций log2N, то есть для 50k элементов всего 16 итераций.

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

Точку на графике реализовали также через Customized-компонент и svg circle, а направляющие линии — через обычный div с рамкой с 1 стороны.

Вертикальная направляющая линия:

Тултип и точка на графике (ну и плюс направляющие) в итоге работают достаточно шустро, как и ресайз области и drag окна по оси X.

Замечание

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

И на наш взгляд, прекрасно, что Recharts дает такую возможность «допилить напильником» в узких местах.

Достигнутый результат

Немного статистики по производительности после оптимизаций первого графика:

Замедляем CPU в 6 раз для наглядности (в Chrome devTools) и сравниваем.

До — длительность таски при ререндере — 162 мс (легко посчитать — около 6 fps):

После — длительность 31 мс (32 fps, достойно, учитывая, что CPU замедлен x6):

И на общем графике — слева до, справа после оптимизации:

Разница в загрузке CPU и длине цикла отрисовки внушительная.

Статистика после оптимизаций второго графика

Общая шкала ререндеров до и после оптимизации (слева до):

Попробуйте сами.

Заключение

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

Далее речь пойдет в первую очередь о библиотеках, подходящих для использования в проектах на React. Если в вашем проекте используются преимущественно «стандартные» типы графиков со средним размером данных и умеренными требованиями к перформансу, Recharts — ваш выбор.

Если кастомизаций слишком много и каждый второй график вам придется «собирать с нуля», возможно, стоит посмотреть на библиотеки, дающие больше свободы и более низкоуровневые компоненты. Например, VX.

Когда инструмент выбран правильно, но оптимизировать все таки нужно — сначала стоит разобраться в причинах проблем (console.time и chrome devTools + react devTools вам в помощь).

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

Экспериментируйте, и все получится!

Хинт для программистов: если зарегистрируетесь на соревнования Huawei Honor Cup, бесплатно получите доступ к онлайн-школе для участников. Можно прокачаться по разным навыкам и выиграть призы в самом соревновании.

Перейти к регистрации