Универсальный компонент для графиков на React + D3.js
Привет! Меня зовут Лёня, я фронтенд-разработчик в hh.ru. Как-то мы рисовали графики на React с использованием библиотеки D3.js и столкнулись с одной проблемой. В наш существующий компонент потребовалось добавить функциональность, которая вообще не влезала в текущую реализацию. Было проще рядом написать похожий компонент, чем дорабатывать старый. Статья о том, как мы искали решение для универсального компонента графиков и пробовали разные способы передачи данных в React.
Как всё было
Сначала мы нарисовали вот такой график, это был обычный LineChart. На оси OX располагаются даты, на OY какие-то значения.
Всё это довольно просто делается стандартными средствами D3.js. Никаких сложностей у нас не возникло. Затем бизнес-заказчик попросил рисовать не одну, а две линии на одном графике. А дизайнер постарался и нарисовал всяких красивостей: ключевые точки, всплывающие тултипы, градиенты etc.
На тот момент у нас не было времени переписывать старый компонент для графиков, поэтому мы написали рядом еще один.
А потом ситуация повторилась еще раз. И в итоге мы получили целых три похожих компонента для отрисовки линейных графиков. Мы прожили с ними почти два года. Затем бизнес вновь пришел к нам и попросил нарисовать еще графиков. В этот раз мы никуда не спешили и решили провести рефакторинг старого кода: избавиться от копипаста и сделать удобный, масштабируемый компонент.
Список требований
За два года использования мы выяснили основные требования, которым должен удовлетворять наш компонент для графиков. Прежде чем начать рефакторинг, мы составили из них список.
Вот что у нас вышло:
График должен быть резиновым по ширине и фиксированным по высоте;
На одном графике можно отрисовывать несколько линий;
Линии должны легко настраиваться. У нас должна быть возможность задавать им цвет, стиль, градиент и другие свойства;
Мы должны иметь возможность рисовать оси графиков и подписи к ним. Или не рисовать их вообще;
По наведению на график должен отображаться тултип с информацией о ближайшей точке;
Еще хотелось рисовать дополнительные сущности: это могут быть сетка на графике, какие-то дополнительные ключевые точки, закрашенные области etc.
Также мы поняли, как с технической точки зрения должен выглядеть наш компонент, чтобы он был удобным и легко расширяемым. Должен быть родительский компонент LineChart, в который в качестве пропсов мы прокидываем список точек и другие дополнительные свойства. Далее в этот компонент мы вкладываем отдельные элементы графика: линии, оси, тултипы и другое. У каждого такого элемента могут быть свои пропсы.
Решение проблемы
Во-первых, для D3 надо подготовить данные. Во-вторых, на их основе и размерах графика нужно сделать функции для создания осей. Ну и в-третьих, всё это необходимо прокинуть в дочерние компоненты.
// под капотом getPreparedData, getXAxis, getYAxis нативные методы D3.js
const chartData = getPreparedData(data);
const xAxis = getXAxis(chartData, width);
const yAxis = getYAxis(chartData, heigh);
Решение «в лоб» получилось не очень удачным. При таком подходе логика предобработки данных находится во внешнем компоненте и будет повторяться из раза в раз при использовании компонента. Нам же хотелось инкапсулировать логику в самом LineChart. Что мы и сделали.
const LineChart = ({ data, width, height, children }) => {
const chartData = getPreparedData(data);
const xAxis = getXAxis(chartData, width);
const yAxis = getYAxis(chartData, height);
return
}
Однако теперь надо было как-то передать эти данные дочерним компонентам. Первым делом мы подумали о render-функции.
const LineChart = ({ data, width, height, renderContent }) => {
const chartData = getPreparedData(data);
const xAxis = getXAxis(chartData, width);
const yAxis = getYAxis(chartData, height);
return
}
(
<>
// ...
>
)}
/>
Таким образом внутренние компоненты получили доступ к данным, но их всё равно каждый раз приходится вручную прокидывать в дочерние компоненты. Это не слишком удобно.
Было бы классно, если бы компонент LineChart добавлял общие пропсы в дочерние компоненты. А помочь в этом нам может, например, React.cloneElement. Получился вот такой код:
}
AxisComponent={ }
TooltipComponent={ }
/>
const LineChart = ({
data, width, height, LineComponent, AxisComponent, TooltipComponent
}) => {
const chartData = getPreparedData(data);
const xAxis = getXAxis(chartData, width);
const yAxis = getYAxis(chartData, heigh);
return (
<>
{React.cloneElement(LineComponent, {chartData, xAxis, yAxis})}
{React.cloneElement(AxisComponent, {chartData, xAxis, yAxis})}
{React.cloneElement(TooltipComponent, {chartData, xAxis, yAxis})}
// ...
>
)
}
Логика инкапсулирована, общие пропсы прокидываются автоматически. Но использование компонента всё еще не было удобным, поскольку для каждой новой сущности на графике приходилось заводить отдельный prop в родительском компоненте.
Мы стали искать другое решение и вспомнили про React Context. Переписали код и вот что вышло:
const LineChart = ({ data, width, height }) => {
const chartData = getPreparedData(data);
const xAxis = getXAxis(chartData, width);
const yAxis = getYAxis(chartData, height);
return (
)
}
Логика предобработки данных по-прежнему находится внутри LineChart. С помощью React Сontext мы пробрасываем нужные данные дочерним компонентам. Сами дочерние компоненты, например, компонент Line для отрисовки линий, имеют как свои независимые пропсы, так и используют данные из контекста.
const Line = ({ color }) => {
const { chartData } = useContext(ChartContext);
return (
);
};
Таким образом, мы получили именно тот формат компонента, который хотели. К тому же в него легко добавлять новые сущности, которые автоматически будут получать доступ к данным, необходимым для отрисовки на D3.js.
Итоги
Возможно у нас и не получилось сделать универсальный компонент для графиков на все случаи жизни. Не исключено, что однажды к нам вновь придет бизнес и попросит нарисовать на графике еще много разных штук. Например, красную линию прозрачного цвета или линию в виде котенка. Ну что ж, тогда и будем что-то с этим делать.
Зато, несомненно, нам удалось сделать наш компонент более гибким и универсальным. К тому же, в процессе рефакторинга мы избавились от повторяющегося кода, рассмотрели различные варианты реализации, их плюсы и минусы и лучше разобрались, как использовать D3.js.
Из своего опыта я могу порекомендовать не создавать сразу универсальный компонент на все случаи жизни. Велика вероятность того, что вы не сможете предугадать абсолютно всё и вам-таки придется переписывать ваш код. Лучше пожить некоторое время с тем, что есть. Собрать бизнес и технические требования. И уже на основе этого продумать архитектуру и провести рефакторинг.
На этом у меня всё. Рассказывайте о собственных решениях похожих проблем в комментах. Было бы интересно узнать, что вы используете для графиков в своих проектах.
Спасибо!