最近在作交易所项目里的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