【首發於 個人我的博客】javascript
前兩天公司領導竟然提到個人博客,說我最近懶了,不更新了……css
趁放假,趕忙更新一輪……等等,何時這變成工做了?html
今天我們說個比較特別的—— TradingView,這是一個專業的圖表庫,專門作 K 線圖的,而 K 線圖是股票、基金等交易所必備的同樣東西。項目自己是免費的,但並不開源,官方提供了託管在 Github 上的私有庫,開發者只需向官方提交一些必要的信息,就能夠獲取到訪問權限。主倉庫包含了壓縮後的庫文件,以及簡單的數據接入案例;Wiki 中提供了開發文檔,同時還在其它的倉庫中提供了一些上手案例。前端
前端經常使用的幾個圖表庫,像 ECharts、DataV 其實都支持繪製基本的 K 線圖(有的稱之爲蠟燭圖,叫法不一樣而已),配合柱狀圖和折線圖,還能繪製成交量、MA 等指標。TradingView 做爲一款專業級的行業產品,除了前面提到的這些圖表,還提供了大量的專業測量工具,供專業的投資者和分析師使用,這些若是所有由開發者自行去實現,會須要花費大量的精力,這種一攬子打包的方案,無疑是它最吸引人的地方。java
最近公司正在進行中的一個項目,就是一款數字資產的交易所,競品調研時候就發現,同行們幾乎無一例外的都選擇了這個圖表庫,連火幣、FCoin 等行業風向標級別的大廠都選擇了這款圖表庫,可見其在行業當中的權威性,以及近乎壟斷的地位。也正由於如此,咱們也開始着手研究它。git
專業歸專業,但這畢竟是針對特定行業特定需求開發的東西,有不少的專業概念、術語、作法咱們都不懂,得現學。官方雖然以 Wiki 的形式在 Github 中提供了文檔,但文檔的質量很是通常,看上去方方面面都覆蓋到了,但字裏行間充斥着大量晦澀難懂的概念,對參數的註解也是殘缺不齊,不少操做上的細節都沒有提到,閱讀體驗很是糟糕。雖然項目官網提供了中文的選項,圖表庫自己也支持多語言,可是文檔卻只有英文的(雖然就我我的而言,語言自己並不構成壓力;但若是你須要,這裏 有一份別人整理的中文版的,還包含了基於 UDF 方案的視頻教程,做者來自 TradingView 項目組,是一位資深的開發者。爲了講解方便,這裏會用到其中的一些圖,感謝 做者 )。github
相比 ECharts、DataV 這種萬事俱備,只要填數據、配參數的「民用級」圖表庫,TradingView 的上手難度要高很多,它須要開發者按照其制定的規則,自行實現一套數據源 API,官方雖然對於每個 API 的做用、參數都給出了說明,但一些關鍵的點並無解釋清楚,不少開發者(包括我,和我接觸過的一些同行)在看過文檔後仍是沒能很好的理解「這 tm 到底該怎麼用」。寫這篇博客,就是但願可以爲解決這個問題作一點貢獻,讓後來者可以輕鬆一些。canvas
先說明一點,這篇博客並不會手把手教你一步一步搭建出整套東西。我假定你至少是先看過一遍官方的文檔,並有了初步的嘗試以後,遇到問題,求助於搜索引擎,而後纔來到的這裏。後端
這篇博客更像是一個 FAQ,根據我本身踩坑的經歷,把一些比較很差懂的東西,按我我的的理解分享給各位。api
因此若是你期望這篇博客可以讓你不用去看官方文檔就可以徹底掌握 TradingView,輕鬆把 K 線畫出來,那麼對不起,要讓你失望了。
TradingView 裏有一些比較專業的概念,不太好懂,但很是重要,這裏簡單說明一下。
Symbol 直譯過來叫「象徵、符號」,這裏引伸爲「商品」。K 線表現的是價格的變化趨勢,至因而什麼東西的價格,能夠是股票,能夠是貨幣,也能夠是任何同樣商品,TradingView 爲了通用,提供了這麼一個抽象的概念。一個 Symbol 就是一個 JS 對象,描述了商品的一些屬性(名稱、價格小數位、支持的時間分辨率、交易開放時間等,具體請參考官方文檔),圖表庫會根據 Symbol 的定義,來決定改獲取怎樣的數據。
商品名稱的固定格式爲 「EXCHANGE:SYMBOL」,SYMBOL 表明商品,例如一支股票、一個交易對;EXCHANGE 是交易所的名稱,同一商品在不一樣交易所可能會有不一樣的價格,所以須要進行區分。
Resolution 直譯過來叫「分辨率」,這裏指 K 線圖中相鄰兩條柱子之間的時間間隔,我沒研究過專業術語是否是就是用的這個詞,不過我的感受這就是一種說法,你用別的詞也能表達這個意思,只不過 TradingView 選擇了這個詞。
Study 直譯過來叫「學習、研究」,這裏解釋爲「指標」,例如成交量、均線,以及其餘各類分析指標。開發者能夠經過 TradingView 提供的 API 自行添加。
圖表本體,特指 K 線圖及相關的各項指標,不包含工具欄。一個圖表實例能夠包含多個指標
小部件,和 Android 上的 Widget 相似。Widget 能夠看作是一個容器,主要是一些工具欄,以及留給繪製真正圖表的一塊區域,不含圖表本體。一個 Widget 能夠包含多個圖表實例
功能集,Widget 配置選項中的一部分,用於定製圖表庫的一些功能(包括顯示與否、樣式)。
覆蓋,Widget 配置選項中的一部分,用於定製圖表庫的樣式(主要是圖表各部分的顏色)。整個圖表庫由外層 DOM 結構和內部多個 canvas 組成,所以樣式相關的設置也分爲兩部分,這裏是用於 canvas 部分的設置,另外還有一個 custom_css_url
屬性用於指定一個 css 文件,其中能夠覆蓋 DOM 部分的樣式。具體的能夠結合官方文檔,以及 Chrome DevTool 來定位。
數據源,也就是接下來要講的東西。它是 TradingView 獲取、處理數據的方法集合,也是 TradingView 數據接入的核心所在,須要用戶本身實現。它能夠是一個 Class 的實例,也能夠就是一個簡單的對象。
建立圖表庫實例並不難,看過文檔和上手案例的應該都能懂,難的在於怎麼把數據給填進去。相信絕大部分爲 TradingView 頭疼的朋友都是卡在了這裏,只要數據接通了,剩下的都是小問題。
TradingView 之所能通用,在於它作到了數據和表現分離,圖表庫自己只提供表現的部分,無論你有什麼樣的數據,只要能整理成指定的格式填進去,就行。說白了,須要開發者自行實現一個適配器。
TradingView 提供了兩種獲取數據的方式,基於 HTTP 的方案(UDF,Universal Data Feed,主倉庫中的演示案例就是用的這種),和基於 WebSocket 的方案(JS API)。
不管採用哪一種方案,就數據而言均可以分爲兩部分:截止到目前爲止的歷史數據,以及以後新生成的數據。
這套方案很是簡單,前端部分已經定義好,只要照着案例中提供的演示代碼接入接口就能夠了(演示代碼是用 TypeScript 寫的,有一點點額外的認知成本,不過問題不大),主要工做在於後端,須要按照要求提供相應的查詢接口,其中最核心的就是獲取指定商品、指定分辨率、指定時間範圍的數據,具體格式參考官方文檔便可。這裏咱們就不展開了。
輪詢——咱們知道是一種有效但很是不推薦的作法(除非環境不支持 WebSocket,那隻能用它),由於不少時候是輪不到新數據的,很是浪費性能。咱們更但願的是每當有新數據到來時,可以主動通知咱們,這也就引出了下面的方案。
官方文檔對各個 API 都進行了描述,其中必備的有 onReady()
、resolveSymbol()
、getBars()
、subscribeBars()
、unsubscribeBars()
,剩下的根據須要自行實現,這裏咱們只說最基本的使用。前兩個沒什麼難度,咱們重點來看下後面幾個。(這裏咱們以 DataFeed 類的實例方法的形式來實現,你也能夠簡單建立一個包含這些函數的 JS 對象)
這個接口專門用於獲取歷史數據,即當前時刻以前的數據。TradingView 會根據 Resolution 從當前時刻開始往前劃定一個時間範圍,嘗試獲取這個時間範圍內,指定 Symbol 指定 Resolution 的數據。出於性能考慮,TradingView 只獲取可見範圍內的數據,超出可見範圍的數據會隨着圖表的拖拽、縮放而分段延遲加載。
這部分的實現代碼比較多,咱們一步步來,先來實現一個發送數據的內部函數:
getBars (symbolInfo, resolution, from, to, onHistoryCallback, onErrorCallback, firstDataRequest) {
function _send (data) {
// 按時間篩選
const dataInRange = data.length
? data.filter(n => n.time >= from && n.time <= to)
: []
// 沒有數據就返回 noData
const meta = {
noData: !dataInRange.length
}
// 有數據,則整理成圖表庫要求的格式
const bar = [...dataInRange]
// 觸發回調
onHistoryCallback(bar, meta)
}
}
複製代碼
咱們把這個函數做爲 getBars()
的內部函數,其中 from
、to
、onHistoryCallback
是 API 提供的參數,data
是咱們獲取到的數據,(bar, meta)
是 TradingView 要求的固定格式。
這個函數負責調用回調函數,把咱們獲取到的數據傳給圖表。接下來,咱們來獲取數據(演示代碼,一些涉密、兼容的代碼已經省略,只保留最基本的、可公開的邏輯):
getBars (symbolInfo, resolution, from, to, onHistoryCallback, onErrorCallback, firstDataRequest) {
function _send (data) {
// ...
}
// 一個簡單的工具函數,實現倒序查找
// 能夠簡單理解爲 Array.prototype.findIndex 的倒序版本
// 後面會用到
function _findLastIndex (arr, fn) {
for (var i = arr.length - 1; i >= 0; i--) {
if (fn(arr[i])) return i
}
return -1
}
// 出於數據共享的須要
// 咱們把獲取到的數據放到 Redux 裏
// 先嚐試從 Redux 獲取現有數據
const existingData = store.getState().kChartData || []
// 若是 Redux 中已有數據,則直接讀取
if (existingData.length) {
_send(existingData)
return
}
// 若是 Redux 中沒數據,則經過 WebSocket 加載
// 咱們的設計是歷史數據和實時更新都走 WebSocket
// 首次推送歷史數據,後續推送更新
// 因此同一交易對、分辨率,只會發起一個 WebSocket 請求
// 先判斷功能支持度
// 這裏咱們用 WebWorker 把 WebSocket 的邏輯獨立到主線程以外
// 以達到性能優化的目的,這個後面再詳述。
if (!window.Worker) return
// 限制 Worker 單例
const hasWSInstance = !!window.kChartWorker
window.kChartWorker = window.kChartWorker || new window.Worker('./worker-kchart.js')
// WebWorker 數據推送回調
window.kChartWorker.onmessage = e => {
const { data = {} } = e
// 當有數據推送時
if (data.kChartData) {
// 獲取已有數據
const kChartData = store.getState().kChartData
// 增量更新
for (const item of data.kChartData) {
// 由於 K 線的數據是按時間順序排列的,
// 數據的更新都在末端,因此倒序搜索更快
const idx = _findLastIndex(kChartData, n => n.time === item.time)
idx < 0
? kChartData.push(item)
: kChartData[idx] = { ...kChartData[idx], ...item }
}
// 把新數據記錄到 Redux
const promise = new Promise((resolve, reject) => {
store.dispatch(setKChartData(kChartData))
resolve({
full: kChartData, // 最新的完整數據
updates: data.kChartData // 本輪更新的內容
})
})
promise.then(res => {
// dataInited 是咱們自定義的一個變量
// 用來區分首次推送和後續推送
// 初始爲 false,首次推送後置爲 true
if (this.dataInited) {
// 如非首次推送
// 對全局 K 線訂閱列表中的每一個訂閱者(後面詳述)
window.kChartSubscriberList = window.kChartSubscriberList || []
for (const sub of window.kChartSubscriberList) {
// 按交易對、分辨率篩選
if (sub.symbol !== this.symbol) return
if (sub.resolution !== resolution) return
// 經過回調函數推送數據
if (typeof sub.callback !== 'function') return
// 圖表庫一次只能增長一條數據,或更新離如今時間最近的一條歷史數據
// 而咱們的推送數據是個數組,可能會包含不止一條數據
// 因此這裏要逐個推送
for (const update of res.updates) {
sub.callback(update)
}
}
} else {
// 首次推送
_send(res.full)
this.dataInited = true
}
})
}
}
// 準備 WebWorker 消息
// 只有當沒有現成數據的時候纔會執行到這裏
// 所以只有在初始化、切換交易對/分辨率的時候
// 纔會發起 WebSocket 請求
const msg = {
// action 表示行爲目的
// init 爲初始化
// restart 爲切換交易對/分辨率
// 對應不一樣的 WebSocket 操做
action: hasWSInstance ? 'restart' : 'init',
symbol: symbolInfo,
resolution: resolution,
url: WEBSOCKET_URL
}
// 發送 WebWorker 消息
window.kChartWorker.postMessage(msg)
}
複製代碼
到這裏,咱們已經成功獲取到歷史數據,並把實時更新的推送發送給了各個訂閱者(雖然理論上可能始終只有一個訂閱者,但從系統設計角度,咱們仍是按照多個來設計)。
WebSocket 的具體操做和 TradingView 其實沒有關係,你能夠選擇任何你熟悉的方式,這裏咱們就不贅述,只是告知發起的時機和回調的處理方式。
getBars()
其實還好,一旦搞清楚了其工做機制,其實沒什麼特別難的,更多的是數據結構的設計以及性能方面的優化。相信令不少人費解的是下面這個函數。
文檔中說這個函數是用來訂閱 K 線數據的,再加上「getBars()
的 onHistoryCallback
回調僅一次調用」,這兩句話誤導了很多人,覺得 getBars()
只會被調用一次,獲取完歷史數據就結束了,實時推送的獲取須要在 subscribeBars()
裏實現。事實上,這裏只是增長一個訂閱者,把添加更新數據的回調函數存到外層,回調函數的調用實際是在前面 getBars()
裏完成的。至關於這個函數只是排個隊,全部數據的獲取和分發都在 getBars()
裏進行。
subscribeBars (symbolInfo, resolution, onRealtimeCallback, subscriberUID, onResetCacheNeededCallback) {
// 限制單例
window.kChartSubscriberList = window.kChartSubscriberList || []
// 避免重複訂閱
const found = window.kChartSubscriberList.some(n => n.uid === subscriberUID)
if (found) return
// 添加訂閱
window.kChartSubscriberList.push({
symbol: symbolInfo,
resolution: resolution,
uid: subscriberUID,
callback: onRealtimeCallback
})
}
複製代碼
這個函數對每一個 Symbol + Resolution 的組合都會調用一次,把對應的識別信息和回調函數傳遞到訂閱列表,當推送數據到達時,會遍歷訂閱列表,找到符合條件的訂閱者,調用其回調函數傳遞數據。其實就是個基本的「觀察者模式」。
瞭解完 subscribeBars()
,那其實 unsubscribeBars()
也就很明白了,簡單帶過:
unsubscribeBars (subscriberUID) {
window.kChartSubscriberList = window.kChartSubscriberList || []
const idx = window.kChartSubscriberList.findIndex(n => n.uid === subscriberUID)
if (idx < 0) return
window.kChartSubscriberList.splice(idx, 1)
}
複製代碼
建立完 widget 實例以後,就能夠經過特定的方法獲取 chart 實例,而後經過特定方法更新 Symbol 和 Resolution,更新操做會以新的參數從新觸發以前提到的幾個函數。從這個角度看,這幾個函數就有點像是生命週期函數,描述了獲取數據、訂閱更新等一列的操做發生的時機,有開發者決定何時該作什麼事。
this.widget = new window.TradingView.widget(widgetOptions)
this.widget.onChartReady(() => {
this.chart = this.widget.chart()
// 設置圖表類型(好比分時圖和常規的蠟燭圖的類型就不同)
this.chart.setChartType(chartType)
// 切換 Symbol
this.chart.setSymbol(symbol, callback)
// 切換 Resolution
this.chart.setResolution(resolution, callback)
})
複製代碼
onReady()
和 resolveSymbol()
這兩個函數,它們的回調函數必須異步調用,別問爲何,人家要求的。在使用 WebSocket 的過程當中,咱們用到了 WebWorker 進行性能優化。
當交易頻率達到必定的程度,WebSocket 會頻繁向客戶端推送數據,若是把這部分邏輯直接放到 React 組件中,一有新數據就去 setState()
,那麼頁面立馬就會被卡得死死的(慘痛的教訓)。原理也很簡單,間隔時間極短的 setState()
會被緩存起來,合併成一次去更新,以減小沒必要要的計算和渲染,若是數據持續頻繁地灌進來,就會攢下一大堆的更新沒有被 commit,組件始終進入不了下一輪的 render;加上每次新數據進來都須要和老數據進行增量合併,高頻率高負荷的計算會佔用主線程的資源,致使沒有足夠的運算資源用於頁面渲染,頁面也就卡死了。
明白了這一點,那麼方案也就出來了,就是把這些計算密集型的任務從主線程裏拿出去,交給併發線程,也就是 WebWorker,去執行。
但光是把計算交出去還不夠,雖然主線程的計算負載下來了,但更新仍是很頻繁。
科學數據顯示,人眼的視覺停留時間大約在 0.1 秒左右,也就是說,即使真的讓頁面上的數字一秒變化個十幾回甚至更多,人眼也根原本不及看清楚,從使用的角度來說,1 秒變化個 4-5 次已是極限了,即使 0.5 秒更新一次也徹底不影響,因此大可沒必要按照 WebSocket 數據推送的頻率去更新頁面,咱們徹底能夠創建一個緩衝帶,把 WebSocket 推送過來的數據緩存到一個數組裏,每隔固定時間間隔去檢查數組是否有內容,有就通知主線程更新,沒有就啥也別作,這樣就在性能和效果之間找到了一個平衡點。
有些人會關心 WebWorker 的兼容性問題,畢竟通常的 H5 頁不多會用到這個,不太熟。WebWorker 的瀏覽器兼容狀況和 WebSocket 大體相同,至少在咱們關心的範圍內,是一致的,都是 IE 10 及以上,常青藤瀏覽器不用多說早就都支持了,因此除非你還有必須兼容老古董的需求,放心用好了。
交易所的這個項目,應該算是近年來接手的比較大的一個項目了,涉及的東西不少,其中很多以前都沒接觸過,都是現學現賣。過程當中遇到了很多的坑,也有了不小的成長。後續我還會分享一些其餘方面遇到的坑。