【首发于 个人我的博客】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 及以上,常青藤浏览器不用多说早就都支持了,因此除非你还有必须兼容老古董的需求,放心用好了。
交易所的这个项目,应该算是近年来接手的比较大的一个项目了,涉及的东西不少,其中很多以前都没接触过,都是现学现卖。过程当中遇到了很多的坑,也有了不小的成长。后续我还会分享一些其余方面遇到的坑。