前言
- 之前在证券行业,接触过移动端K线图的需求;
- H5 K线图,支持无限左右滑动,支持样式自定义;
- 纯canvas制作,不借助任何第三方图表库。
- 如果对 canvas 基础的 api 不熟悉, 建议先去看 canvas 基础知识
一、先看最终的效果
1、GIF动图如下
2、支持样式自定义
用可以屏幕取色器,获取东方财富的配色 codeinword.com/eyedropper
图一、图二,是参考东方财富 黑白皮肤的配色, 图三是参考腾讯自选股的配色。
3、开源代码
注意:以上的 demo 还有一些 bug, 没时间修复, 另外预览地址是直接在 github 上部署的, 所以最好通过 vpn 科学上网, 否则可能访问不了。
二、canvas 注意事项
1、整数坐标,会导致模糊
canvas 在画线段, 通常会出现以下代码:
js
cxt.moveTo(x1, y1);
cxt.lineTo(x2, y2);
cxt.lineWidth = 1;
cxt.stroke();
假设上面的两个点是(1,10)和(5,10),那么画出来的实际上是一条横线,
理论上横线的粗度是1px,且该横线被 y=10 切成两半,
上半部分粗度是 0.5px, 下半部分粗度也是 0.5px,
这样横线的整体粗度才会是 1px。
但是 canvas 不是这样处理的, canvas 默认线条会与整数对齐,
也就是横线的上部分不会是 y=9.5px, 而是 y=9px;
横线的下半部分也不是 y=10.5px, 而是 y=11px;
从而横线的粗度看起来不是1px,而是2px。
并且由于粗度被拉伸,颜色也会被淡化,那怎么解决这个问题呢?
处理方式也很简单, 通过 cxt.translate(0.5, 0.5) 将坐标往下移动 0.5 个像素,
然后接下来的所有点, 都保证是整数即可, 这样就能保证不会被拉伸。
典型的代码如下:
js
cxt.translate(0.5, 0.5);
cxt.moveTo(Math.floor(x1), Math.floor(y1));
cxt.lineTo(Math.floor(x2), Math.floor(y2));
cxt.lineWidth = 1;
cxt.stroke();
在我的代码中, 也体现了类似的处理。
2、如何处理高像素比带来的模糊
设备像素比越高,理论上应该越清晰,因为原来用一个小方块来渲染1px, 现在用2个小方块来渲染,应该更清晰才对,但是canvas不是这样的。
例如,通过js获取父容器 div 的宽度是 width, 这时候如果设置 canvas.width = width,在设备像素比为2的时候, canvas 画出来的宽度为css对应宽度的一半, 如果强制通过 css 将 canvas 宽度设置为 width, 则 canvas 会被拉长一倍, 导致出现锯齿模糊。
注意了吗?上面所说的 canvas.width=width 与 css 设置的 #canvas { width: width } 起到的效果是不一样的。不要随便通过 css 去设置 canvas 的宽高, 容易被拉伸变形或者导致模糊。
通用的处理方式是:
js
//初始化高清Canvas
function initHDCanvas() {
const rect = hdCanvas.getBoundingClientRect();
//设置Canvas内部尺寸为显示尺寸乘以设备像素比
const dpr = window.devicePixelRatio || 1;
hdCanvas.width = rect.width * dpr;
hdCanvas.height = rect.height * dpr;
//设置Canvas显示尺寸保持不变
hdCanvas.style.width = rect.width + 'px';
hdCanvas.style.height = rect.height + 'px';
//获取上下文并缩放
const ctx = hdCanvas.getContext('2d');
ctx.scale(dpr, dpr);
}
三、样式配置
为了方便样式自定义, 我独立出一个默认的配置对象 defaultKlineConfig, 参数的含义如下图所示,其实下图这个风格的标注, 是通过 excalidraw 这个软件画的, 也是 canvas 做的开源软件, 可见 canvas 在前端可视化领域的重要性, 这个扯远了,打住。

如上图, 整个canvas 画板, 分成 5 部分,
每一部分的高度, 都可以设置,
其中主图和副图的高度,是通过比例来计算的:
mainChartHeight = restHeight * mainChartHeightPercent
其中,restHeigh 是画板总高度 height 减去其他几部分计算的, 如下:
restHeight = height - lineMessageHeight - tradeMessageHeight - xLabelHeight
十字交叉线的颜色, X轴 与 Y轴 的 tooltip 背景色、字体大小的参数如下:

四、均线计算
从上面的图可以看出, 需要画 5日均线、10日均线、20日均线, 成交量快线(10日)、成交量慢线(20日) 但是, 接口没有给出当日的均线值, 需要自己计算。
5日均线 = (过去4个成交日的收盘价总和 + 今日收盘价)/ 5
10日均线 = (过去9个成交日的收盘价总和 + 今日收盘价)/ 10
20日均线 = (过去19个成交日的收盘价总和 + 今日收盘价)/ 20
成交量快线 = (过去9日成交量 + 今日成交量)/ 10
成交量慢线 = (过去19日成交量 + 今日成交量)/ 20
所以, 当获取 lmt(一屏的蜡烛图个数)个数据时, 为了计算均线, 需要至少将前 19 个(我的代码写20)数据都获取到。当前一个均线已经获取到, 下一个均线就不需要再累加20个值再得平均数, 可以省一点计算:
今日20日均线值 = (昨日均线值 * 20 - 前面第20个的收盘价 + 今日收盘价)/ 20;
五、分层渲染
为了减少重绘,提高性能,可以将K线图做分层渲染。那分几层合适?我认为是三层。
- 第一层, 不动层
- 第二层,变动层
- 第三层,交互层
不动层
首先, 网格是固定的, 也就是说,当页面拖拽、或者长按出现十字交叉的时候,底部的网格线是不变的,如果每次拖拽,都需要重绘网格,那这个其实是没有必要的开销,可以将网格放在最底层,一次性绘制后,就不要再重绘。
变动层
由于拖拽的时候,蜡烛柱体,均线,Y轴刻度, X轴刻度, 都需要重绘, 这一块是无法改变的事实, 所以, 变动层放在中间层,也是最繁忙的一层,并且该层不响应触摸事件,触摸事件交给交互层。
交互层
交互层监听触摸事件:当页面快速滑动, 则响应拖拽事件, 即K线图的时间线会左右滑动;当用户长按之后才滑动, 则出现十字交叉浮层。
交互层的好处是, 当响应十字交叉浮层时, 只需要绘制横线、竖线、对应X轴和Y轴的值,而不需要重绘蜡烛柱体和均线, 可以减少重绘,最大程度减少渲染压力。
六、基础几何绘制
网格线
首先计算出主图的高度 this.mainChartHeight, 将主图从上到下等分为4部分,再在左右两边画出竖线,形成主图的网格,副图是成交量图, 只需画一个矩形边框即可,用 strokeRect 即可画出。
js
//画出网格线
private drawGridLine() {
//获取配置参数
const { gridColor, lineMessageHeight, xLabelHeight, width, height } = this.config;
//画出K线图的5条横线
const split = this.mainChartHeight / 4;
this.canvasCxt.beginPath();
this.canvasCxt.lineWidth = 0.5;
this.canvasCxt.strokeStyle = gridColor;
for (let i = 0; i <= 4; i++) {
const splitHeight = Math.floor(split * i) + lineMessageHeight!;
this.drawLine(0, splitHeight, width, splitHeight);
}
//画出K线图的2条竖线
this.drawLine(0, lineMessageHeight!, 0, lineMessageHeight! + this.mainChartHeight);
this.drawLine(width, lineMessageHeight!, width, lineMessageHeight! + this.mainChartHeight);
//画出成交量的矩形
this.canvasCxt.strokeRect(
0,
height - xLabelHeight! - this.subChartHeight,
width,
this.subChartHeight,
);
}
//画出两个点形成的直线
private drawLine(x1: number, y1: number, x2: number, y2: number) {
this.canvasCxt.moveTo(x1, y1);
this.canvasCxt.lineTo(x2, y2);
this.canvasCxt.stroke();
}
画各类均线
1、首先计算出一屏的股价最大值 max , 股价最小值 min ,成交量最大值 maxAmount。
2、当某一个点的均线为 value, 根据最大值、最小值、索引index, 计算出坐标点(x, y), 画均线的时候, 第一个点用 moveTo(x0, y0),其他点用 lineTo(xn yn), 最后 stroke 连起来即可。
3、当然, 每一条线设置下颜色, 即 stokeStyle。
js
//画出各类均线
private drawLines(max: number, min: number, maxAmount: number) {
//将宽度分成n个小区间, 一个小区间画一个蜡烛, 每个区间的宽度是 splitW
const splitW = this.config.width / this.config.lmt!;
//画一下5日均线
this.canvasCxt.beginPath();
this.canvasCxt.strokeStyle = this.config.ma5Color;
this.canvasCxt.lineWidth = 1;
let isTheFirstItem = true;
for (
let i = this.startIndex;
i < this.arrayList.length && i < this.startIndex + this.config.lmt!;
i++
) {
const index = i - this.startIndex;
let value = this.arrayList[i].ju5;
if (value === 0) {
continue;
}
const x = Math.floor(index * splitW + 0.5 * splitW);
const y = Math.floor(
((max - value) / (max - min)) * this.mainChartHeight + this.config.lineMessageHeight!,
);
if (isTheFirstItem) {
this.canvasCxt.moveTo(x, y);
isTheFirstItem = false;
} else {
this.canvasCxt.lineTo(x, y);
}
}
this.canvasCxt.stroke();
}
画出蜡烛柱体
当收盘价大于等于开盘价, 选用上面左边红色的样式; 当收盘价小于开盘价, 选用上面右边绿色的样式。
以红色蜡烛为例, 最高点 A(x0, y0),最低点是 B(x1, y1),
高度 height、宽度 width 都是相对于坐标轴的,
红色矩形左上角的顶点是 D(x, y)。
为了画出红色蜡烛, 先后顺序别搞混:
- AB 这条竖线,通过 moveTo,lineTo 画出来;
- 定义一个矩形 cxt.rect(x, y, width, heigth);
- 通过 fill 填充白色背景, 同时覆盖后面的红色竖线;
- 再通过 stroke 描出红色边框
按照上面这个顺序, 竖线会被覆盖掉,同时,白色填充不会挤压红色边框, 如果先 stroke 再 fill,容易出现白色填充覆盖红色边框,可能会变模糊,或者使得红色变淡,及其不友好,所以按照我上面的顺序,可以减少不必要的麻烦。
画出文字
canvas 画出文字, 典型的代码如下
js
this.canvasCxt.beginPath();
this.canvasCxt.font = `${this.config.yLabelFontSize}px "Segoe UI", Arial, sans-serif`;
this.canvasCxt.textBaseline = 'alphabetic';
this.canvasCxt.fillStyle = this.config.yLabelColor;
注意textBaseline 默认对齐方式是 alphabetic, 但 middle 往往更好用, 能实现垂直居中,但我发现垂直居中也不是很居中,所以会特意加减1、2个像素;
当然还有个textAlign, 能实现水平对齐方式, 左右对齐都可以, 例如上图最左、最右的时间标签。
七、交互设计
根据上面的GIF动图, 可以知道, 本次做的移动端 K 线图, 最重要的两个交互是:
- 快速拖拽,K线图随时间轴左右滑动
- 长按滑动,出现十字交叉tooltip
上面的交互,其实是比较复杂的,所以需要先设计一个简单的数据结构:
- 首先页面存放一个列表 arrayList
- 保存一个数字标识 startIndex,表示当前屏幕从 startIndex 开始画蜡烛图
当用户往右快速拖拽时, startIndex 根据用户拖拽的距离, 适当变小; 当用户往左快速拖拽时, startIndex 根据用户拖拽的距离, 适当变大。
那 arrayList 到底多长合适, 因为股票可能有十几年的数据, 甚至上百年的数据, 我不能一次性拉取这个股票的所有数据吧?
当然,站在软件性能、消耗等角度,也不应该一次性拉取所有的数据, 我的答案是 arraylist 最多保存5屏的数据量,用户看到的屏幕, 应该是接近中间这一屏,也就是第3屏的数据, 左右两边各保存2屏数据,这样,用户拖拽的时候,可以比较流畅,而不是每次拖拽都要等拉取数据再去渲染。
那什么时候拉取新的数据呢? 用户触摸完后,当startIndex左边的数据少于2屏,开始拉取左边的数据; 用户触摸完后,当startIndex右边的数据少于2屏,开始拉取右边的数据;
那如果用户一直往右拖拽, 是不是就一直往左边添加数据, 这个 arraylist 是不是会变得很长?
当然不是,例如,当我往 arraylist 的左边添加数据的时候,startIndex 也会跟着变动, 因为用户看到的第一条柱体,在 arraylist 的索引已经变了。当我往 arraylist 的某一边添加数据后, arraylist 的另一边如果数据超过 2 屏, 要适当裁掉一些数据, 这样 arraylist 的总数, 始终保持在 5 屏左右,就不会占用太多的存放空间。
总体思想是, 从 startIndex 开始渲染屏幕的第一条柱体, 当前屏幕的左右两边, 都预留2屏数据,防止用户拖拽出发频繁拉取数据, 导致卡顿; 同时也控制了 arraylist 的长度, 这是虚拟列表的变形,这样设计,可以做一个高性能的k线图。
八、触摸事件解耦
根据上面的分析:
- 快速拖拽, K线图左右移动
- 长按再滑动, 出现十字交叉tooltip
以上两种拖拽,都在 touchmove 事件中触发, 那怎么区分开呢? 典型的 touchstart、 touchmove 、 touchend 解耦如下:
js
let timer = null;
let startX = 0;
let startY = 0;
let isLongPress = false;
canvas.addEventListener('touchstart', (e) => {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
isLongPress = false;
timer = setTimeout(() => {
isLongPress = true;
// 显示十字光标hover
showCrossHair(e);
}, 500);
});
canvas.addEventListener('touchmove', (e) => {
if (isLongPress) {
// 长按移动时更新十字光标位置
updateCrossHair(e);
} else {
// 快按拖动时移动K线图
clearTimeout(timer);
moveKLineChart(e);
}
});
canvas.addEventListener('touchend', () => {
clearTimeout(timer);
if (isLongPress) {
// 长按结束隐藏十字光标
hideCrossHair();
}
isLongPress = false;
});
// 关闭十字光标
function hideCrossHair() {
// 隐藏逻辑
}
根据上面的框架, 再详细补充下代码就可以了。 然后再在 touchend 事件中, 新增或减少 arraylist 的数据量。
九、性能优化
其实, 做到上面的设计,性能已经很好了,可以监控帧率来看下滑动的流畅程度。
总结下为什么高新能:
分层渲染
将K线图画在3个canvas上。
- 不动层只需要绘画一次;
- 变动层根据需要而变动;
- 交互层独立出来,不会影响其它层,变动层的大量蜡烛柱体、均线等也不会受交互层的影响
离屏预渲染
当需要在K线上标注一些icon时, 这些 icon 可以先离屏渲染, 需要的时候, 再copy到变动层对应的位置,这样比临时抱佛脚去画,要省很多时间,也能提高新能。
设置数据缓冲区
就是屏幕只渲染一屏数据, 但是在当前屏的左右两边,各缓存了2屏数据, 超过5屏数据的时候,及时裁掉多余的数据, 这样arraylist的数据量始终保持在5屏, 控制了数据量,有效的控制了占用空间。
节流防抖
touchmove 会很频繁触发, 可通过节流来控制,减少不必要的渲染。
十、部署到GitHub Pages
1、安装gh-pages包
js
npm install --save-dev gh-pages
2、package.json 添加如下配置
注意, Stock 这个需要对应github的仓库名
js
{
"homepage": "https://fhrddx.github.io/Stock",
"scripts": {
"predeploy": "npm run build",
"deploy": "gh-pages -d build"
}
}
3、运行部署命令
arduino
npm run build
npm run deploy

最后, 访问上面的链接(注意,在国内可能要开vpn)
这样, github pages 部署成功, 访问上面链接, 可以看到如下效果。
