最近在作交易所項目裏的K線圖,得些經驗,與你們分享。
代碼居多,流量預警!!!!
點贊 收藏 不迷路。css
準備開始吧html
page
|--kLine // k線內容文件夾
|--|--api // 須要使用的方法
|--|--|--datafees.js // 定義了一些公用方法
|--|--|--dataUpdater.js // 更新時調用的內容
|--|--|--socket.js // websocket方法
|--|--index.js // 本身代碼開發
|--|--index.scss // 樣式開發
複製代碼
datafees.js加入以下代碼react
/**
* JS API
*/
import React from 'react'
import DataUpdater from './dataUpdater'
class datafeeds extends React.Component {
/**
* JS API
* @param {*Object} react react實例
*/
constructor(self) {
super(self)
this.self = self
this.barsUpdater = new DataUpdater(this)
this.defaultConfiguration = this.defaultConfiguration.bind(this)
}
/**
* @param {*Function} callback 回調函數
* `onReady` should return result asynchronously.
*/
onReady(callback) {
// console.log('=============onReady running')
return new Promise((resolve) => {
let configuration = this.defaultConfiguration()
if (this.self.getConfig) {
configuration = Object.assign(this.defaultConfiguration(), this.self.getConfig())
}
resolve(configuration)
}).then(data => callback(data))
}
/**
* @param {*Object} symbolInfo 商品信息對象
* @param {*String} resolution 分辨率
* @param {*Number} rangeStartDate 時間戳、最左邊請求的K線時間
* @param {*Number} rangeEndDate 時間戳、最右邊請求的K線時間
* @param {*Function} onDataCallback 回調函數
* @param {*Function} onErrorCallback 回調函數
*/
getBars(symbolInfo, resolution, rangeStartDate, rangeEndDate, onDataCallback) {
const onLoadedCallback = (data) => {
data && data.length ? onDataCallback(data, { noData: false }) : onDataCallback([], { noData: true })
}
this.self.getBars(symbolInfo, resolution, rangeStartDate, rangeEndDate, onLoadedCallback)
/* eslint-enable */
}
/**
* @param {*String} symbolName 商品名稱或ticker
* @param {*Function} onSymbolResolvedCallback 成功回調
* @param {*Function} onResolveErrorCallback 失敗回調
* `resolveSymbol` should return result asynchronously.
*/
resolveSymbol(symbolName, onSymbolResolvedCallback, onResolveErrorCallback) {
return new Promise((resolve) => {
// reject
let symbolInfoName
if (this.self.symbolName) {
symbolInfoName = this.self.symbolName
}
let symbolInfo = {
name: symbolInfoName,
ticker: symbolInfoName,
pricescale: 10000,
}
const { points } = this.props.props
const array = points.filter(item => item.name === symbolInfoName)
if (array) {
symbolInfo.pricescale = 10 ** array[0].pricePrecision
}
symbolInfo = Object.assign(this.defaultConfiguration(), symbolInfo)
resolve(symbolInfo)
}).then(data => onSymbolResolvedCallback(data)).catch(err => onResolveErrorCallback(err))
}
/**
* 訂閱K線數據。圖表庫將調用onRealtimeCallback方法以更新實時數據
* @param {*Object} symbolInfo 商品信息
* @param {*String} resolution 分辨率
* @param {*Function} onRealtimeCallback 回調函數
* @param {*String} subscriberUID 監聽的惟一標識符
* @param {*Function} onResetCacheNeededCallback (從1.7開始): 將在bars數據發生變化時執行
*/
subscribeBars(symbolInfo, resolution, onRealtimeCallback, subscriberUID, onResetCacheNeededCallback) {
this.barsUpdater.subscribeBars(symbolInfo, resolution, onRealtimeCallback, subscriberUID, onResetCacheNeededCallback)
}
/**
* 取消訂閱K線數據
* @param {*String} subscriberUID 監聽的惟一標識符
*/
unsubscribeBars(subscriberUID) {
this.barsUpdater.unsubscribeBars(subscriberUID)
}
/**
* 默認配置
*/
defaultConfiguration = () => {
const object = {
session: '24x7',
timezone: 'Asia/Shanghai',
minmov: 1,
minmov2: 0,
description: 'www.coinoak.com',
pointvalue: 1,
volume_precision: 4,
hide_side_toolbar: false,
fractional: false,
supports_search: false,
supports_group_request: false,
supported_resolutions: ['1', '15', '60', '1D'],
supports_marks: false,
supports_timescale_marks: false,
supports_time: true,
has_intraday: true,
intraday_multipliers: ['1', '15', '60', '1D'],
}
return object
}
}
export default datafeeds
複製代碼
dataUpdater加入以下代碼webpack
class dataUpdater {
constructor(datafeeds) {
this.subscribers = {}
this.requestsPending = 0
this.historyProvider = datafeeds
}
subscribeBars(symbolInfonwq, resolutionInfo, newDataCallback, listenerGuid) {
this.subscribers[listenerGuid] = {
lastBarTime: null,
listener: newDataCallback,
resolution: resolutionInfo,
symbolInfo: symbolInfonwq
}
}
unsubscribeBars(listenerGuid) {
delete this.subscribers[listenerGuid]
}
updateData() {
if (this.requestsPending) return
this.requestsPending = 0
for (let listenerGuid in this.subscribers) {
this.requestsPending++
this.updateDataForSubscriber(listenerGuid).then(() => this.requestsPending--).catch(() => this.requestsPending--)
}
}
updateDataForSubscriber(listenerGuid) {
return new Promise(function (resolve, reject) {
var subscriptionRecord = this.subscribers[listenerGuid];
var rangeEndTime = parseInt((Date.now() / 1000).toString());
var rangeStartTime = rangeEndTime - this.periodLengthSeconds(subscriptionRecord.resolution, 10);
this.historyProvider.getBars(subscriptionRecord.symbolInfo, subscriptionRecord.resolution, rangeStartTime, rangeEndTime, function (bars) {
this.onSubscriberDataReceived(listenerGuid, bars);
resolve();
}, function () {
reject();
});
});
}
onSubscriberDataReceived(listenerGuid, bars) {
if (!this.subscribers.hasOwnProperty(listenerGuid)) return
if (!bars.length) return
const lastBar = bars[bars.length - 1]
const subscriptionRecord = this.subscribers[listenerGuid]
if (subscriptionRecord.lastBarTime !== null && lastBar.time < subscriptionRecord.lastBarTime) return
const isNewBar = subscriptionRecord.lastBarTime !== null && lastBar.time > subscriptionRecord.lastBarTime
if (isNewBar) {
if (bars.length < 2) {
throw new Error('Not enough bars in history for proper pulse update. Need at least 2.');
}
const previousBar = bars[bars.length - 2]
subscriptionRecord.listener(previousBar)
}
subscriptionRecord.lastBarTime = lastBar.time
console.log(lastBar)
subscriptionRecord.listener(lastBar)
}
periodLengthSeconds =(resolution, requiredPeriodsCount) => {
let daysCount = 0
if (resolution === 'D' || resolution === '1D') {
daysCount = requiredPeriodsCount
} else if (resolution === 'M' || resolution === '1M') {
daysCount = 31 * requiredPeriodsCount
} else if (resolution === 'W' || resolution === '1W') {
daysCount = 7 * requiredPeriodsCount
} else {
daysCount = requiredPeriodsCount * parseInt(resolution) / (24 * 60)
}
return daysCount * 24 * 60 * 60
}
}
export default dataUpdater
複製代碼
socket.js加入以下代碼(也可使用本身的websocket模塊)git
class socket {
constructor(options) {
this.heartBeatTimer = null
this.options = options
this.messageMap = {}
this.connState = 0
this.socket = null
}
doOpen() {
if (this.connState) return
this.connState = 1
this.afterOpenEmit = []
const BrowserWebSocket = window.WebSocket || window.MozWebSocket
const socketArg = new BrowserWebSocket(this.url)
socketArg.binaryType = 'arraybuffer'
socketArg.onopen = evt => this.onOpen(evt)
socketArg.onclose = evt => this.onClose(evt)
socketArg.onmessage = evt => this.onMessage(evt.data)
// socketArg.onerror = err => this.onError(err)
this.socket = socketArg
}
onOpen() {
this.connState = 2
this.heartBeatTimer = setInterval(this.checkHeartbeat.bind(this), 20000)
this.onReceiver({ Event: 'open' })
}
checkOpen() {
return this.connState === 2
}
onClose() {
this.connState = 0
if (this.connState) {
this.onReceiver({ Event: 'close' })
}
}
send(data) {
this.socket.send(JSON.stringify(data))
}
emit(data) {
return new Promise((resolve) => {
this.socket.send(JSON.stringify(data))
this.on('message', (dataArray) => {
resolve(dataArray)
})
})
}
onMessage(message) {
try {
const data = JSON.parse(message)
this.onReceiver({ Event: 'message', Data: data })
} catch (err) {
// console.error(' >> Data parsing error:', err)
}
}
checkHeartbeat() {
const data = {
cmd: 'ping',
args: [Date.parse(new Date())]
}
this.send(data)
}
onReceiver(data) {
const callback = this.messageMap[data.Event]
if (callback) callback(data.Data)
}
on(name, handler) {
this.messageMap[name] = handler
}
doClose() {
this.socket.close()
}
destroy() {
if (this.heartBeatTimer) {
clearInterval(this.heartBeatTimer)
this.heartBeatTimer = null
}
this.doClose()
this.messageMap = {}
this.connState = 0
this.socket = null
}
}
export default socket
複製代碼
init = () => {
var resolution = this.interval; // interval/resolution 當前時間維度
var chartType = (localStorage.getItem('tradingview.chartType') || '1')*1;
var locale = this.props.lang; // 當前語言
var skin = this.props.theme; // 當前皮膚(黑/白)
if (!this.widgets) {
this.widgets = new TradingView.widget({ // 建立圖表
autosize: true, // 自動大小(適配,寬高百分百)
symbol:this.symbolName, // 商品名稱
interval: resolution,
container_id: 'tv_chart_container', // 容器ID
datafeed: this.datafeeds, // 配置,即api文件夾下的datafees.js文件
library_path: '/static/TradingView/charting_library/', // 圖表庫的位置,我這邊放在了static,由於已經壓縮過
enabled_features: ['left_toolbar'],
timezone: 'Asia/Shanghai', // 圖表的內置時區(經常使用UTC+8)
// timezone: 'Etc/UTC', // 時區爲(UTC+0)
custom_css_url: './css/tradingview_'+skin+'.css', //樣式位置
locale, // 語言
debug: false,
disabled_features: [ // 在默認狀況下禁用的功能
'edit_buttons_in_legend',
'timeframes_toolbar',
'go_to_date',
'volume_force_overlay',
'header_symbol_search',
'header_undo_redo',
'caption_button_text_if_possible',
'header_resolutions',
'header_interval_dialog_button',
'show_interval_dialog_on_key_press',
'header_compare',
'header_screenshot',
'header_saveload'
],
overrides: this.getOverrides(skin), // 定製皮膚,默認無蓋默認皮膚
studies_overrides: this.getStudiesOverrides(skin) // 定製皮膚,默認無蓋默認皮膚
})
var thats = this.widgets;
// 當圖表內容準備就緒時觸發
thats.onChartReady(function() {
createButton(buttons);
})
var buttons = [
{title:'1m',resolution:'1',chartType:1},
{title:'15m',resolution:'15',chartType:1},
{title:'1h',resolution:'60',chartType:1},
{title:'1D',resolution:'1D',chartType:1},
];
// 建立按鈕(這裏是時間維度),並對選中的按鈕加上樣式
function createButton(buttons){
for(var i = 0; i < buttons.length; i++){
(function(button){
let defaultClass =
thats.createButton()
.attr('title', button.title).addClass(`mydate ${button.resolution === '15' ? 'active' : ''}`)
.text(button.title)
.on('click', function(e) {
if (this.className.indexOf('active')> -1){// 已經選中
return false
}
let curent =e.currentTarget.parentNode.parentElement.childNodes
for(let index of curent) {
if (index.className.indexOf('my-group')> -1 && index.childNodes[0].className.indexOf('active')> -1) {
index.childNodes[0].className = index.childNodes[0].className.replace('active', '')
}
}
this.className = `${this.className} active`
thats.chart().setResolution(button.resolution, function onReadyCallback() {})
}).parent().addClass('my-group'+(button.resolution == paramary.resolution ? ' active':''))
})(buttons[i])
}
}
}
}
複製代碼
initMessage = (symbolInfo, resolution, rangeStartDate, rangeEndDate, onLoadedCallback) => {
let that = this
//保留當前回調
that.cacheData['onLoadedCallback'] = onLoadedCallback;
//獲取須要請求的數據數目
let limit = that.initLimit(resolution, rangeStartDate, rangeEndDate)
//若是當前時間節點已經改變,中止上一個時間節點的訂閱,修改時間節點值
if(that.interval !== resolution){
that.interval = resolution
paramary.endTime = parseInt((Date.now() / 1000), 10)
} else {
paramary.endTime = rangeEndDate
}
//獲取當前時間段的數據,在onMessage中執行回調onLoadedCallback
paramary.limit = limit
paramary.resolution = resolution
let param
// 分批次獲取歷史(這邊區分了歷史記錄分批加載的請求)
if (isHistory.isRequestHistory) {
param = {
// 獲取歷史記錄時的參數(與所有主要區別是時間戳)
}
} else {
param = {
// 獲取所有記錄時的參數
}
}
this.getklinelist(param)
}
複製代碼
import throttle from 'lodash/throttle'
this.initMessage = throttle(this.initMessage, 1000);
socket.on('message', this.onMessage(res.data))
// 渲染數據
onMessage = (data) => { // 經過參數將數據傳遞進來
let thats = this
if (data === []) {
return
}
// 引入新數據的緣由,是我想要加入緩存,這樣在大數據量的時候,切換時間維度能夠大大的優化請求時間
let newdata = []
if(data && data.data) {
newdata = data.data
}
const ticker = `${thats.symbolName}-${thats.interval}`
// 第一次所有更新(增量數據是一條一條推送,等待所有數據拿到後再請求)
if (newdata && newdata.length >= 1 && !thats.cacheData[ticker] && data.firstHisFlag === 'true') {
// websocket返回的值,數組表明時間段歷史數據,不是增量
var tickerstate = `${ticker}state`
// 若是沒有緩存數據,則直接填充,發起訂閱
if(!thats.cacheData[ticker]){
thats.cacheData[ticker] = newdata
thats.subscribe() // 這裏去訂閱增量數據!!!!!!!
}
// 新數據即當前時間段須要的數據,直接餵給圖表插件
// 若是出現歷史數據不見的時候,就說明 onLoadedCallback 是undefined
if(thats.cacheData['onLoadedCallback']){ // ToDo
thats.cacheData['onLoadedCallback'](newdata)
}
//請求完成,設置狀態爲false
thats.cacheData[tickerstate] = false
//記錄當前緩存時間,即數組最後一位的時間
thats.lastTime = thats.cacheData[ticker][thats.cacheData[ticker].length - 1].time
}
// 更新歷史數據 (這邊是添加了滑動按需加載,後面我會說明)
if(newdata && newdata.length > 1 && data.firstHisFlag === 'true' && paramary.klineId === data.klineId && paramary.resolution === data.resolution && thats.cacheData[ticker] && isHistory.isRequestHistory) {
thats.cacheData[ticker] = newdata.concat(thats.cacheData[ticker])
isHistory.isRequestHistory = false
}
// 單條數據()
if (newdata && newdata.length === 1 && data.hasOwnProperty('firstHisFlag') === false && data.klineId === paramary.klineId && paramary.resolution === data.resolution) {
//構造增量更新數據
let barsData = newdata[0]
//若是增量更新數據的時間大於緩存時間,並且緩存有數據,數據長度大於0
if (barsData.time > thats.lastTime && thats.cacheData[ticker] && thats.cacheData[ticker].length) {
//增量更新的數據直接加入緩存數組
thats.cacheData[ticker].push(barsData)
//修改緩存時間
thats.lastTime = barsData.time
} else if(barsData.time == thats.lastTime && thats.cacheData[ticker] && thats.cacheData[ticker].length){
//若是增量更新的時間等於緩存時間,即在當前時間顆粒內產生了新數據,更新當前數據
thats.cacheData[ticker][thats.cacheData[ticker].length - 1] = barsData
}
// 通知圖表插件,能夠開始增量更新的渲染了
thats.datafeeds.barsUpdater.updateData()
}
}
複製代碼
getBars = (symbolInfo, resolution, rangeStartDate, rangeEndDate, onLoadedCallback) => {
const timeInterval = resolution // 當前時間維度
this.interval = resolution
let ticker = `${this.symbolName}-${resolution}`
let tickerload = `${ticker}load`
var tickerstate = `${ticker}state`
this.cacheData[tickerload] = rangeStartDate
//若是緩存沒有數據,並且未發出請求,記錄當前節點開始時間
// 切換時間或幣種
if(!this.cacheData[ticker] && !this.cacheData[tickerstate]){
this.cacheData[tickerload] = rangeStartDate
//發起請求,從websocket獲取當前時間段的數據
this.initMessage(symbolInfo, resolution, rangeStartDate, rangeEndDate, onLoadedCallback)
//設置狀態爲true
this.cacheData[tickerstate] = true
}
if(!this.cacheData[tickerload] || this.cacheData[tickerload] > rangeStartDate){
//若是緩存有數據,可是沒有當前時間段的數據,更新當前節點時間
this.cacheData[tickerload] = rangeStartDate;
//發起請求,從websocket獲取當前時間段的數據
this.initMessage(symbolInfo, resolution, rangeStartDate, rangeEndDate, onLoadedCallback);
//設置狀態爲true
this.cacheData[tickerstate] = !0;
}
//正在從websocket獲取數據,禁止一切操做
if(this.cacheData[tickerstate]){
return false
}
// 拿到歷史數據,更新圖表
if (this.cacheData[ticker] && this.cacheData[ticker].length > 1) {
this.isLoading = false
onLoadedCallback(this.cacheData[ticker])
} else {
let self = this
this.getBarTimer = setTimeout(function() {
self.getBars(symbolInfo, resolution, rangeStartDate, rangeEndDate, onLoadedCallback)
}, 10)
}
// 這裏很重要,畫圈圈----實現了往前滑動,分次請求歷史數據,減少壓力
// 根據可視窗口區域最左側的時間節點與歷史數據第一個點的時間比較判斷,是否須要請求歷史數據
if (this.cacheData[ticker] && this.cacheData[ticker].length > 1 && this.widgets && this.widgets._ready && !isHistory.isRequestHistory && timeInterval !== '1D') {
const rangeTime = this.widgets.chart().getVisibleRange() // 可視區域時間值(秒) {from, to}
const dataTime = this.cacheData[ticker][0].time // 返回數據第一條時間
if (rangeTime.from * 1000 <= dataTime + 28800000) { // true 不用請求 false 須要請求後續
isHistory.endTime = dataTime / 1000
isHistory.isRequestHistory = true
// 發起歷史數據的請求
this.initMessage(symbolInfo, resolution, rangeStartDate, rangeEndDate, onLoadedCallback)
}
}
}
複製代碼
onLoadedCallback(this.cacheData[ticker])
或者this.datafeeds.barsUpdater.updateData()
去更新數據。二進制傳輸數據github
websocket在傳輸數據的時候是明文傳輸,並且像K線上的歷史數據,通常數據量比較大。爲了安全性以及更快的加載出圖表,咱們決定使用二進制的方式傳輸數據。web
yarn add pako -S
if (res.data instanceof Blob) { // 看下收到的數據是否是Blob對象
const blob = res.data
// 讀取二進制文件
const reader = new FileReader()
reader.readAsBinaryString(blob)
reader.onload = () => {
// 首先對結果進行pako解壓縮,類型是string,再轉換成對象
data = JSON.parse(pako.inflate(reader.result, { to: 'string' }))
}
}
複製代碼
差很少了canvas