Frontend-разработчик компании Merkeleon в обзоре делится опытом работы с TradingView.
Библиотека TradingView — сложный механизм с несколькими способами использования. Чтобы быть ведущей библиотекой для отображения биржевых сделок, стоит предоставить основательную базу для интеграции с другими решениями. На мой взгляд, такая интеграция включает:
Frontend-разработчик на React часто выбирает среди доступных решений в интернете: какое из них самое подходящее. Если бы передо мной такой выбор стоял: библиотека TradingView либо что-то другое, все равно бы остановился на этом продукте. В их документации детально описывается, как работать с библиотекой. Это говорит об ответственном подходе разработчиков, поскольку даже отдаленные от разработки библиотеки программисты справятся с установкой. Поэтому разработчикам, которые интересуются в том числе и как создать биржу криптовалют, рекомендуется рассмотреть использование данного продутка.
Стоит отметить, что до этого я не использовал более простых решений интеграции графиков, например, через виджеты TradingView. Знакомство с продуктом началось сразу с основной библиотеки графиков. В обзоре TradingView расскажу про то, как это было.
Так получилось, что библиотеку TradingView уже подключали коллеги ранее, до меня. Я учился с ней работать в процессе. Мой путь изучения и поддержки интеграции TradingView состоял из следующих основных итераций:
Поддержка ранее интегрированной версии библиотеки была для меня простой. Я следовал главному правилу в dev-комьюнити: работает — не трогай :). Такой подход спасал не всегда, и приходилось такие задачи решать. Одной из задач, с которой столкнулся при работе с TradingView, был поиск решения по реколоризации самого графика.
При внедрении программного обеспечения крайне важно учитывать брендирование под корпоративные цвета заказчика. Кажется, пустяк. Тем не менее чтобы решить эту задачку, предстоит разобраться в предоставляемом API для настройки цветовой схемы графика TradingView. Затем найти подходящий ключ, который отвечает за нужный компонент UI в их библиотеке и позволяет менять цвет.
На тот момент я не понимал, как работает библиотека данных. Не знал, что такое UDF-endpoints. Что часть заботы по отрисовке данных лежит на backend-команде. Что есть отдельный проект, который позволяет вносить изменение и в механизм, и в работу с данными UDF-endpoints. В этом еще предстояло разобраться.
С переходом на новый дизайн и обновлением frontend-базы в 2022 году использовать старую версию библиотеки стало бессмысленно. Поэтому, мы обновили TradingView до новой версии (апрель 2022 года). Кстати, об обновлениях и всех произведенных улучшениях разработчики библиотеки своевременно сообщают в блоге TradingView.
В процессе работы над обновлением порадовала лицензия и многоуровневая система доступа к GitHub-репозиторию, который уже на тот момент имелся в Merkeleon. Это говорит о правильном подходе к контролю и ограничении доступов в одном из самых узнаваемых в трейдерском комьюнити элементе интерфейса.
В репозитории я увидел кардинальные изменения. Пришлось вникать, как пользоваться библиотекой. Обновлять библиотеку начал, опираясь на старый пример. Получилось на процентов 90. Из-за нескольких устаревших блоков пришлось более глубоко погружаться в чтение документации, чем я изначально предполагал. Признатьcя, изучение описанного функционала заняло много времени. Но я и не рассчитывал, что обновлять библиотеку будет просто. Спасибо разработчикам TradingView за отличные примеры. В них показано, как интегрировать библиотеку на разных платформах, включая React. Это упростило работу и помогло при миграции старого кода.
После запуска библиотеки TradingView стояли две серьезные задачи: решить вопрос с брендированием (реколоризацией) и разобраться, почему график ничего не рисует.
Чтобы решить задачу с реколоризацией, или перекрашиванием цвета, требовался механизм, который позволил бы кастомизировать исходную цветовую схему. После всех обновлений исходная цветовая схема разделилась на 2 направления: поле для графика, то есть то, что меняет пользователь, и обрамление графика, проще говоря, панель с кнопками.
Для первого случая есть свойство overrides, которое позволяет переназначать переменные графика при необходимости. Однако моя задача была не в том, чтобы постоянно использовать однотипный набор цветов, а в том, чтобы помимо базового брендирования криптобиржи стали доступными следующие 2 важные опции:
Досконально изучив, как работает график TradingView, и понимая, что где-то в библиотеке хранятся настроенные пользователем данные, я ознакомился с LocalStorage. Тогда и возникла идея, которая легла в реализацию. При первом входе TradingView раскрашен в цвета бренда. Когда пользователь меняет цвета, они уже не относятся к оформлению. Недоступно и переключение темы со светлой на темную. При этом, если клиент хочет, пользовательские настройки сбрасываются после ребрендинга.
Разобравшись в структуре данных из LocalStorage и найдя закономерности между названиями свойств в LocalStorage и в overrides-свойствах графика, я разработал план. Для работы со стилями команда Merkeleon решила использовать styled-components. Мы имели полный доступ к цветовым переменным со стороны JS. Также помог функционал hook-ов современных версий React. Хоть и через весьма сложный алгоритм работы с данными и их обновлением, зато достаточно оптимизировано, без лишних проблем с производительностью. Тут стоит сказать спасибо разработчикам TradingView, что они реагируют на изменение в LocalStorage и используют данные в приоритете именно из него :).
Далее шла работа с CSS. Из-за использования как таковой генерации CSS у нас на стороне JS задача тоже стала весьма интересной и реализовалась не без хитростей 🙂 Одна из проблем, которая просто так не давала через styled-components “пропихнуть” CSS переменные была в том, что сама библиотека внутри основного <div> в DOM рендерит <iframe>. Поэтому нужно было придумать, как в этот <iframe> пропихнуть подготовленные данные (со стороны CSS нет возможности как-либо вмешиваться в работу <iframe>, а вот со стороны JS…), подготовка которых не составляла проблем. Пришлось использовать head.insertAdjacentElement в <iframe>. Не бывает в нашей работе безкостыльных решений :).
Помогло. Но возникла другая проблема на нашей стороне. У TradingView свой механизм загрузки графика, который рендерит preloader, а затем <iframe>. <iframe> подчиняется CSS переменным. Из-за этого на нашей стороне возникали задержки в применении цветов: сразу шла стандартная палитра TradingView, а после применялась наша. В решении этой задачи помогли callbacks, которые предоставляет TradingView. Что делаем: до выполнения onChartReady мы показываем свой preloader, который рендерится поверх графика. Когда график подготовлен на стороне библиотеки, свой preloader убираем. К этому моменту наши подготовленные данные для CSS доставлены в <iframe>, и библиотека успевает на них отреагировать.
Далее разберемся, почему не рисуется график. Предстояла работа с UDF-endpoints, точнее с отдельным проектом в GitHub рядом с библиотекой (далее JS UDF). В JS UDF нужно было добавить прокидывание необходимых заголовков в header HTTP запроса с нашей стороны. Безопасное прокидывание должно срабатывать при каждом вызове графиком наших endpoint-ов.
Не сразу понял, что существует еще один проект с исходным кодом. Вместо этого нашёл уже собранный код для JS UDF в том же репозитории. Тогда открыл унифицированный JS, нашёл там упоминание fetch и механизм пробрасывания headers. Предстояло понять, какую из переменных изменить в момент выполнения кода. В унифицированном коде это непросто. Но сработало. Снова порадовала библиотека: структура данных не поменялась. Обновил библиотеку до свежей версии (март 2022 года). Итоговая интеграция для наших потребностей представлена ниже.
import React, {useEffect, useCallback, useContext, useRef, useMemo, useState} from "react"; import styled from "styled-components"; import {createItemCache} from ""; // функция кэширование данных (Merkeleon) import {useIsTablet} from ""; // hook, сообщающий Tablet ли девайс относительно размеров области отображения сайта (Merkeleon) import {loadScript} from ""; // функция загрузки скриптов (Merkeleon) import Preloader from ""; // компонента preloader-а (Merkeleon) import {ThemeContext, themeDarkValue} from ""; // набор импортов для работы с темами (Merkeleon) import {sort} from ""; // функция сортировки (Merkeleon) import {widget} from "libraries/TradingView/charting_library/charting_library.esm.js"; import type {ThemeType} from ""; // типизация для данных с темами (Merkeleon) import type {CustomizationDesignColorsType} from ""; // типизация цветовых переменных (Merkeleon) import type { IChartingLibraryWidget, LanguageCode as TradingViewLanguageCode, ChartingLibraryWidgetOptions, Overrides, ResolutionString, } from "TradingView/charting_library/charting_library.d"; type AvailableLanguageType = TradingViewLanguageCode; type ComponentPropsType = { availableLocale: AvailableLanguageType; selectedCurrencyPair: string | undefined; }; // start: Магия с цветами const defaultCSSVariablesMetadata: { [tradingViewColorVariable: string]: keyof CustomizationDesignColorsType; } = { "--tv-color-platform-background": "color1", "--tv-color-pane-background": "color2", "--tv-color-pane-background-secondary": "color3", "--tv-color-toolbar-button-background-hover": "color4", "--tv-color-toolbar-button-background-secondary-hover": "color5", "--tv-color-toolbar-button-text": "color6", "--tv-color-toolbar-button-text-hover": "color7", "--tv-color-toolbar-button-text-active": "color8", "--tv-color-toolbar-button-text-active-hover": "color9", "--tv-color-item-active-text": "color8", "--tv-color-toolbar-toggle-button-background-active": "color10", "--tv-color-toolbar-toggle-button-background-active-hover": "color5", }; const defaultSettingsOverridesMetadata: { [tradingViewColorVariable: string]: keyof CustomizationDesignColorsType; } = { "paneProperties.background": "color2", "paneProperties.vertGridProperties.color": "color2", "paneProperties.horzGridProperties.color": "color2", "scalesProperties.textColor": "color6", "paneProperties.crossHairProperties.color": "color6", "mainSeriesProperties.candleStyle.upColor": "color11", "mainSeriesProperties.candleStyle.downColor": "color12", "mainSeriesProperties.candleStyle.borderColor": "color11", "mainSeriesProperties.candleStyle.borderUpColor": "color11", "mainSeriesProperties.candleStyle.borderDownColor": "color12", "mainSeriesProperties.hollowCandleStyle.upColor": "color11", "mainSeriesProperties.hollowCandleStyle.downColor": "color12", "mainSeriesProperties.hollowCandleStyle.borderColor": "color11", "mainSeriesProperties.hollowCandleStyle.borderUpColor": "color11", "mainSeriesProperties.hollowCandleStyle.borderDownColor": "color12", "mainSeriesProperties.haStyle.upColor": "color11", "mainSeriesProperties.haStyle.downColor": "color12", "mainSeriesProperties.haStyle.borderColor": "color11", "mainSeriesProperties.haStyle.borderUpColor": "color11", "mainSeriesProperties.haStyle.borderDownColor": "color12", "mainSeriesProperties.barStyle.upColor": "color11", "mainSeriesProperties.barStyle.downColor": "color12", "mainSeriesProperties.lineStyle.color": "color11", "mainSeriesProperties.areaStyle.color1": "color11", "mainSeriesProperties.areaStyle.color2": "color12", "mainSeriesProperties.areaStyle.linecolor": "color11", }; const getColorThemeByTheme = ( value: { [tradingViewColorVariable: string]: keyof CustomizationDesignColorsType; }, theme: ThemeType | undefined ): {[tradingViewColorVariable: string]: string} | undefined => { if (!theme) return; return Object.keys(value).reduce( (acc: {[tradingViewColorVariable: string]: string}, tradingViewColorVariable) => { const tradingViewColorValue = value[tradingViewColorVariable]; const colorValue = theme[tradingViewColorValue]; if (!colorValue) return acc; acc[tradingViewColorVariable] = colorValue; return acc; }, {} ); }; const tradingViewCustomizationColorLocalStorageKey = "Merkeleon.LocalStorageKey1"; const useNeedResetTradingViewLocalStorageCache = () => { const {themeDark, themeLight} = useContext(ThemeContext) || {}; const customizationColorsCacheJSON = useMemo(() => { if (!themeDark || !themeLight) return; const customizationColorKeySet: Set<keyof CustomizationDesignColorsType> = new Set([ ...Object.values(defaultCSSVariablesMetadata), ...Object.values(defaultSettingsOverridesMetadata), ]); const customizationColorKeyUniqArr = [...customizationColorKeySet]; sort(customizationColorKeyUniqArr); const customizationColorsCache = customizationColorKeyUniqArr.reduce( (acc: {[colorKey: string]: [string, string]}, colorKey) => { const darkValue = themeDark[colorKey]; const lightValue = themeLight[colorKey]; if (!darkValue || !lightValue) return acc; acc[colorKey] = [lightValue, darkValue]; return acc; }, {} ); return JSON.stringify(customizationColorsCache); }, [themeDark, themeLight]); const tradingViewChartPropertiesJSON = window.localStorage.getItem( tradingViewCustomizationColorLocalStorageKey ); if ( typeof customizationColorsCacheJSON !== "undefined" && customizationColorsCacheJSON !== tradingViewChartPropertiesJSON ) { window.localStorage.setItem( tradingViewCustomizationColorLocalStorageKey, customizationColorsCacheJSON ); return true; } return false; }; const useTradingViewCSSVariables = () => { const {themeDark, themeLight, currentTheme} = useContext(ThemeContext) || {}; const cssVariablesDark: | { [tradingViewColorVariable: string]: string; } | undefined = useMemo( () => getColorThemeByTheme(defaultCSSVariablesMetadata, themeDark), [themeDark] ); const cssVariablesLight: | { [tradingViewColorVariable: string]: string; } | undefined = useMemo( () => getColorThemeByTheme(defaultCSSVariablesMetadata, themeLight), [themeLight] ); const cssVariables = currentTheme === themeDarkValue ? cssVariablesDark : cssVariablesLight; if (!cssVariables) return; return Object.keys(cssVariables).reduce((acc, CSSVariableKey) => { acc += `${CSSVariableKey}: ${cssVariables[CSSVariableKey]};`; return acc; }, ""); }; const tradingViewChartPropertiesLocalStorageKey = "tradingview.chartproperties"; const useTradingViewOverrides = () => { const {themeDark, themeLight, currentTheme} = useContext(ThemeContext) || {}; const needResetTradingViewLocalStorageCache = useNeedResetTradingViewLocalStorageCache(); const mutateSettingOverrides = useCallback((currentOverrides: Overrides | undefined) => { if (!currentOverrides) return; const tradingViewChartPropertiesJSON = window.localStorage.getItem( tradingViewChartPropertiesLocalStorageKey ); try { const tradingViewChartProperties = JSON.parse(tradingViewChartPropertiesJSON || ""); Object.keys(currentOverrides).forEach(overridesKey => { const overridesPath = overridesKey.split("."); let currentValue = tradingViewChartProperties[overridesPath[0]]; if (currentValue) { let depth = 1; while (overridesPath.length !== depth && typeof currentValue !== "string") { const layerValue = currentValue[overridesPath[depth]]; if (!layerValue) break; currentValue = layerValue; depth++; } } if (typeof currentValue === "string") currentOverrides[overridesKey] = currentValue; }); } catch { window.localStorage.removeItem(tradingViewChartPropertiesLocalStorageKey); } }, []); const upgradeTradingViewChartPropertiesLocalStorage = useCallback( (currentOverrides: Overrides | undefined) => { if (!currentOverrides) return; const tradingViewChartPropertiesJSON = window.localStorage.getItem( tradingViewChartPropertiesLocalStorageKey ); try { const tradingViewChartProperties = JSON.parse(tradingViewChartPropertiesJSON || ""); Object.keys(currentOverrides).forEach(overridesKey => { const overridesPath = overridesKey.split("."); let currentLayer = tradingViewChartProperties[overridesPath[0]]; if (currentLayer) { let depth = 1; while (overridesPath.length - 1 !== depth) { const layerValue = currentLayer[overridesPath[depth]]; if (!layerValue) break; currentLayer = layerValue; depth++; } if (typeof currentLayer === "object") currentLayer[overridesPath[depth]] = currentOverrides[overridesKey]; } }); const tradingViewChartPropertiesJSONNew = JSON.stringify(tradingViewChartProperties); window.localStorage.setItem( tradingViewChartPropertiesLocalStorageKey, tradingViewChartPropertiesJSONNew ); } catch { window.localStorage.removeItem(tradingViewChartPropertiesLocalStorageKey); } }, [] ); const settingOverridesDark: Overrides | undefined = useMemo( () => getColorThemeByTheme(defaultSettingsOverridesMetadata, themeDark), [themeDark] ); const settingsOverridesLight: Overrides | undefined = useMemo( () => getColorThemeByTheme(defaultSettingsOverridesMetadata, themeLight), [themeLight] ); const getHasItBeenUpdatedColorFromTradingViewSettings = useCallback(() => { const tradingViewChartPropertiesJSON = window.localStorage.getItem( tradingViewChartPropertiesLocalStorageKey ); try { const tradingViewChartProperties = JSON.parse(tradingViewChartPropertiesJSON || ""); const defaultSettingsOverrideKeys = Object.keys(defaultSettingsOverridesMetadata); for (let index = 0; index < defaultSettingsOverrideKeys.length - 1; index++) { const defaultSettingsOverrideKey = defaultSettingsOverrideKeys[index]; const overridesPath = defaultSettingsOverrideKey.split("."); let currentValue = tradingViewChartProperties[overridesPath[0]]; if (currentValue) { let depth = 1; while (overridesPath.length !== depth && typeof currentValue !== "string") { const layerValue = currentValue[overridesPath[depth]]; if (!layerValue) break; currentValue = layerValue; depth++; } } if (typeof currentValue === "string") { const valueDark = settingOverridesDark?.[defaultSettingsOverrideKey]; const valueLight = settingsOverridesLight?.[defaultSettingsOverrideKey]; if (currentValue === valueDark || currentValue === valueLight) { continue; } else { return true; } } } } catch { // } return false; }, [settingOverridesDark, settingsOverridesLight]); const currentOverridesByTheme = currentTheme === themeDarkValue ? settingOverridesDark : settingsOverridesLight; if (needResetTradingViewLocalStorageCache) { upgradeTradingViewChartPropertiesLocalStorage(currentOverridesByTheme); } else { if (getHasItBeenUpdatedColorFromTradingViewSettings()) { mutateSettingOverrides(settingOverridesDark); mutateSettingOverrides(settingsOverridesLight); } } return currentOverridesByTheme; }; // end: Магия с цветами // start: Использование TradingView const defaultInterval = "60" as ResolutionString; const defaultTradingViewOptions: Omit< ChartingLibraryWidgetOptions, "container" | "interval" | "locale" | "symbol" | "overrides" | "datafeed" > = { fullscreen: true, autosize: true, library_path: "", time_frames: [ {text: "1D", resolution: "5" as ResolutionString, description: "1 day"}, {text: "3D", resolution: "15" as ResolutionString, description: "3 days"}, {text: "6D", resolution: "30" as ResolutionString, description: "6 days"}, {text: "12D", resolution: "60" as ResolutionString, description: "12 days"}, {text: "3M", resolution: "480" as ResolutionString, description: "3 month"}, {text: "6M", resolution: "1D" as ResolutionString, description: "6 month"}, ], drawings_access: {}, // studies_overrides: {}, enabled_features: [], disabled_features: [""], charts_storage_url: "", user_id: "", client_id: "", charts_storage_api_version: "1.1", }; const tradingViewIntervalLocalStorageKey = "ex-tv-interval"; const TradingViewStyled = styled.div` grid-area: trading-view; position: relative; height: 40rem; background-color: ${props => props?.theme?.["color2"]}; border-radius: 0.8rem; box-sizing: border-box; border: 1px solid; border-color: ${(props): string => props?.theme?.["color13"] || ""}; `; const TradingViewContainerStyled = styled.div<{$isLoading?: boolean}>` height: 100%; box-sizing: border-box; // fixes browser bug with border-radius overflow border: ${props => (props.$isLoading ? `1px solid ${props?.theme?.["color2"]}` : "")}; border-radius: 0.8rem; overflow: hidden; iframe { height: 100% !important; } `; const LoaderContainerStyled = styled.div` display: flex; align-items: center; justify-content: center; position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-color: ${props => props?.theme?.["color2"]}; border-radius: 0.8rem; `; const TradingView: React.FunctionComponent<ComponentPropsType> = ({ availableLocale, selectedCurrencyPair, }) => { const tradingViewContainerRef = useRef<HTMLDivElement | null>(null); const [isTradingViewLoading, setTradingViewLoading] = useState(false); const isTablet = useIsTablet(); const [isLoadedUDFScriptState, setIsLoadedUDFScriptState] = useState<boolean>(); useEffect(() => { let isRemoved = false; loadScript(UDF_SCRIPT_PATH).then(() => { if (!isRemoved) { setIsLoadedUDFScriptState(true); } }); return () => { isRemoved = true; }; }, []); const datafeed = useMemo(() => { if (!isLoadedUDFScriptState) return; return new window.Datafeeds.UDFCompatibleDatafeed("/api/udf", 300000); }, [isLoadedUDFScriptState]); const { set: setTradingViewActivatedCache, get: getTradingViewActivatedCache, clear: clearTradingViewActivatedCache, } = useMemo(() => { return createItemCache<boolean>(false); }, []); const { set: setTradingViewIntervalCache, get: getTradingViewIntervalCache, clear: clearTradingViewIntervalCache, } = useMemo(() => { return createItemCache<ResolutionString>( (window.localStorage.getItem(tradingViewIntervalLocalStorageKey) as ResolutionString) || defaultInterval ); }, []); const { set: setTradingViewWidgetCache, get: getTradingViewWidgetCache, clear: clearTradingViewWidgetCache, } = useMemo(() => { return createItemCache<IChartingLibraryWidget>(); }, []); const [hasSelectedCurrencyPairValueState, setHasSelectedCurrencyPairValueState] = useState<boolean>(Boolean(selectedCurrencyPair)); useEffect(() => { const selectedCurrencyPairValueNew = Boolean(selectedCurrencyPair); if (selectedCurrencyPairValueNew !== hasSelectedCurrencyPairValueState) setHasSelectedCurrencyPairValueState(selectedCurrencyPairValueNew); }, [selectedCurrencyPair]); const overrides = useTradingViewOverrides(); const changeTradingViewOverrides = useCallback(() => { const tradingViewWidgetCache = getTradingViewWidgetCache(); const tradingViewActivatedCache = getTradingViewActivatedCache(); if (tradingViewWidgetCache && tradingViewActivatedCache) { if (overrides) tradingViewWidgetCache.applyOverrides(overrides); } }, [overrides]); const cssVariables = useTradingViewCSSVariables(); const changeTradingViewCSSVariables = useCallback(() => { const tradingViewWidgetCache = getTradingViewWidgetCache(); const tradingViewActivatedCache = getTradingViewActivatedCache(); if (tradingViewWidgetCache && tradingViewActivatedCache) { const tvContainer = tradingViewContainerRef?.current; if (tvContainer) { const TVIframe = tvContainer.childNodes[0] as HTMLIFrameElement; const iframeContent = TVIframe.contentDocument; if (iframeContent) { const style = document.createElement("style"); style.type = "text/css"; style.id = ""; style.innerText = `html{${cssVariables}}`; iframeContent.getElementById("")?.remove(); iframeContent.head.insertAdjacentElement("beforeend", style); } } } }, [cssVariables]); useEffect(() => { if (!tradingViewContainerRef || !tradingViewContainerRef.current) return; if (!hasSelectedCurrencyPairValueState) return; if (!isLoadedUDFScriptState) return; if (!datafeed) return; const tradingViewWidgetOld = getTradingViewWidgetCache(); if (tradingViewWidgetOld) { tradingViewWidgetOld.remove(); clearTradingViewWidgetCache(); setTradingViewActivatedCache(false); } setTradingViewLoading(true); const tradingViewIntervalCache = getTradingViewIntervalCache(); const tradingViewWidget = new widget({ ...defaultTradingViewOptions, datafeed, container: tradingViewContainerRef.current, interval: tradingViewIntervalCache, locale: availableLocale, symbol: selectedCurrencyPair, preset: isTablet ? "mobile" : undefined, }); setTradingViewWidgetCache(tradingViewWidget); tradingViewWidget.onChartReady(() => { tradingViewWidget .chart() .onIntervalChanged() .subscribe(null, (interval: ResolutionString) => { setTradingViewIntervalCache(interval); window.localStorage.setItem(tradingViewIntervalLocalStorageKey, interval); }); setTradingViewActivatedCache(true); changeTradingViewOverrides(); changeTradingViewCSSVariables(); setTradingViewLoading(false); }); return () => { tradingViewWidget.remove(); clearTradingViewWidgetCache(); }; }, [isLoadedUDFScriptState, hasSelectedCurrencyPairValueState, availableLocale, isTablet]); useEffect(changeTradingViewOverrides, [changeTradingViewOverrides]); useEffect(changeTradingViewCSSVariables, [changeTradingViewCSSVariables]); useEffect(() => { const tradingViewWidgetCache = getTradingViewWidgetCache(); const tradingViewActivatedCache = getTradingViewActivatedCache(); if (tradingViewWidgetCache && tradingViewActivatedCache) { const tradingViewIntervalCache = getTradingViewIntervalCache(); if (!selectedCurrencyPair || !tradingViewIntervalCache) return; tradingViewWidgetCache.setSymbol(selectedCurrencyPair, tradingViewIntervalCache, () => { // }); } }, [selectedCurrencyPair]); useEffect(() => { return () => { clearTradingViewIntervalCache(); clearTradingViewActivatedCache(); }; }, []); return ( <TradingViewStyled> <TradingViewContainerStyled $isLoading={isTradingViewLoading} ref={tradingViewContainerRef} /> {isTradingViewLoading && ( <LoaderContainerStyled> <Preloader /> </LoaderContainerStyled> )} </TradingViewStyled> ); }; export default TradingView;
Одним обновлением не обошлось. Выяснилось, что упустил две детали: в TradingView нет всех языков, которые Merkeleon предлагает клиенту. Почему это желательно? График BTC USD отслеживают пользователи из разных стран. Им важно понимать, как пользоваться библиотекой TradingView. И еще одна загвоздка: ранее добавили функцию отрисовки графиков в режиме реального времени. Она перестала работать.
Языки. С ними все элементарно. Если в TradingView нет нужного языка, показываем график на английском. Покопавшись в типизации библиотеки, нашел enum с возможными языками. Нужно придумать решение, которое при обновлении библиотеки проверяло, обновилась ли типизация. Поскольку клиенты могут подключать любые языки, мы должны знать, есть ли у клиента выбранный пользователем язык.
Создал контрольную карту для регулярной проверки наличия языка у TradingView c типом Required<{[localeKey in TradingViewLanguageCode]: null}>. Такое решение занимает память, поскольку создаётся новый Instance. Он существует, пока проект открыт в браузере. Этот подход дает полный контроль над изменениями языковой базы в библиотеке. А также помогает верифицировать выбранный пользователем язык. Обновление до версии 2023 года показало, что решение работает: добавили новый язык, и код сработал.
import type {LanguageCode as TradingViewLanguageCode} from "libraries/TradingView/charting_library/charting_library.d"; import {widget} from "libraries/TradingView/charting_library/charting_library.esm.js"; type AvailableLanguageType = TradingViewLanguageCode; type AvailableLanguagesMapType = Required<{[localeKey in AvailableLanguageType]: null}>; const FALLBACK_LOCALE = "en"; const availableLanguagesMap: AvailableLanguagesMapType = { ar: null, zh: null, cs: null, da_DK: null, ca_ES: null, nl_NL: null, en: null, et_EE: null, fr: null, de: null, el: null, he_IL: null, hu_HU: null, id_ID: null, it: null, ja: null, ko: null, fa: null, pl: null, pt: null, ro: null, ru: null, sk_SK: null, es: null, sv: null, th: null, tr: null, vi: null, no: null, ms_MY: null, zh_TW: null, }; const availableLanguages = Object.keys(availableLanguagesMap); const getAvailableLocale = (locale) => { if (!availableLanguages.includes(locale)) return FALLBACK_LOCALE as AvailableLanguageType; return locale as AvailableLanguageType; } const tradingViewWidget = new widget({ ...anyProps, locale: availableLocale, });
RealTime отрисовка. А вот восстановить отрисовку графиков в режиме реального времени оказалось куда сложнее. На этом этапе узнал, что в общем доступе с исходным кодом существует проект, который собирает JS UDF скрипт.
Сделаем так, чтобы во время сделки срабатывал CustomEvent. JS UDF его отлавливает и отвечает. Для решения задачи потребовалось изучить новый репозиторий, характеристики и технические условия. Это долго. Зато понял, как дальше работать с репозиторием. А еще узнал, что TradingView не дает перерисовывать Bar через скрипт. Работать можно только с последним. Но нужно следить, когда обновить, а когда нарисовать новый по переданным в onTick данным о совершенной сделке. Этим и занимался в отдельном проекте с исходным кодом JS UDF скрипта.
Детально изучив исходники для JS UDF скрипта, увидел, что в TradingView предусматриваются инструменты для решения задачи с заголовками в header. Хотя она решается и без доступа к исходникам.
После изменений пришло время обновлять библиотеку до апрельской версии 2023 года. После обновления внутри типизации появились комментарии с пояснением свойств, которые есть еще и в отдельном проекте JS UDF скрипта. И это плюс. Кроме того, отмечу бесперебойную совместимость и отсутствие серьезных проблем со взаимодействием обновлений с написанной частью нашего функционала, а точнее с кастомизациями в JS UDF.
Здесь я описал опыт работы с широким функционалом инструмента TradingView, его плюсами, гибкостью. В дополнение я бы выделил и несколько рекомендаций, связанных со спецификой использования библиотеки в нашем продукте:
Следующая задача — улучшить работу с графиком с нашей стороны. Для этого понадобится разобраться с механизмом запоминания сделанных пользователем чертежей, добавленных комментариев на графике, и сохранять, их для проследующего отображения при вынужденных перерендерах. Также буду вносить очередной пакет изменений в JS UDF, чтобы независимо от торгов, без вмешательства в backend, открывать свечу в начатом timeframe. Но об этом расскажу в другой раз.
Автор
Ведущий frontend-инженер в Merkeleon.com (React/Redux — Middle+++) и backend-разработчик (.Net — Middle-).
О Merkeleon
Компания Merkeleon работает с 2008 года. С тех пор компания предоставляет бизнесам программные решения для электронных закупок и торговых площадок с несколькими продавцами. На протяжении 10 лет компания также разрабатывает программное обеспечение для криптобизнеса.