简介:这是一份利用JavaScript与HTML5 Canvas实现的K线图组件,面向需要在网页或移动端加入蜡烛图走势展示的前端开发者,覆盖从基础绘制到移动端手势交互的完整实现思路。整体压缩包仅11KB,共3个文件,其中两个JavaScript脚本分别承担K线核心逻辑与触摸手势识别,一个HTML文件作为开箱即用的演示入口。目前已有3015人学习或下载,资源小巧且依赖极少,无需复杂配置,适合在没有构建工具的轻量场景下直接使用。组件支持左右滑动、双指缩放、长按显示十字光标等典型K线交互,所有手势事件集中在一个方法内,方便不熟悉手势库的开发者替换成原生触摸事件。整体代码结构清晰,可直接嵌入现有页面,也适合学习Canvas绘图、触摸事件处理和图表交互设计,对前端图表开发有参考价值。
1. JavaScript实现K线图:为什么前端工程师逃不掉这笔债
如果你做过金融、量化交易或任何带行情看板的项目,迟早会遇到一个需求:用JavaScript实现K线图。它不是普通图表里的折线或柱状,而是一套自带数据结构、坐标映射、缩放平移、指标叠加的迷你渲染引擎。用现成库能顶一阵,但产品一旦要求“像客户端一样拖拽流畅”“手机端不发热”,你就得自己动手。
这篇文章按我从零搭建的完整路径来讲:先理清数据模型与坐标换算,再在Canvas上画出蜡烛和网格,然后做十字光标与缩放交互,最后给出几个高频翻车现场和上线前的自测清单。适合已经会JavaScript基础语法、想避开API黑匣子,真正掌控渲染细节的读者。跟着走一遍,哪怕以后用ECharts,你也知道背后发生了什么。
2. 先想清楚:K线图的数据结构与坐标系换算
2.1 K线数据的基本组成:OHLC与成交量
K线图的核心不是“画线”,而是“解析数据”。每个蜡烛由开盘价(Open)、最高价(High)、最低价(Low)和收盘价(Close)构成,简称OHLC。有的场景还要带上成交量和时间戳,时间戳是绝对关键字段,因为后续的缩放和平移完全依赖它。
常见做法是后端返回类似下面的数组结构:
// 每根K线对象,时间戳统一使用毫秒,避免类型混淆 const rawBars = [ { time: 1640966400000, open: 3500.5, high: 3550.0, low: 3490.0, close: 3542.0, volume: 23500 }, { time: 1641052800000, open: 3542.0, high: 3600.0, low: 3531.0, close: 3595.5, volume: 28900 }, // ... ];这里有个关键约定:时间戳必须是秒或毫秒统一后的数值,最好在数据层就转成毫秒,而不是渲染层再猜。我吃过一次亏,后端给秒,前端习惯性以为毫秒,结果最后一根K线跑到未来去了。
拿到原始数据后不要直接存进画图模块,而是先按时间升序排序并去重。因为真实接口常分页返回,顺序可能打乱,或者某根K线重复推送。排序和去重用JavaScript函数处理很轻松,但必须放在业务层做一次,不能让渲染层每次都去检查。
2.2 把时间戳映射到像素:分类轴与时间轴的取舍
K线图横轴有两种映射方式:一种是按K线索引画“分类轴”,每根蜡烛间距固定;另一种是按时间戳均匀映射。金融客户端大多使用前者,因为周末、节假日没有K线,时间轴若按真实时间跨度画,周一和周五的间距会不一样,蜡烛分布明显失衡。
我推荐的方案是“索引为主、时间标签为辅”:我们只维护一个显示范围内的K线索引区间,比如第100根到第200根,然后根据画布宽度平均布点。这样缩放、平移都只操作索引区间,不需要反复计算日期与坐标的映射关系。
class KLineChart { constructor(canvas, bars) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.bars = bars; // 排好序、去重后的K线数组 this.startIndex = 0; // 当前显示区间的起始索引 this.endIndex = bars.length - 1; // 当前显示区间的结束索引 this.pointsPerBar = 8; // 每根K线占用的像素宽度 this.padding = { top: 20, bottom: 30, left: 60, right: 16 }; } // 根据画布宽度反推当前能显示多少根K线 get visibleCount() { const innerWidth = this.canvas.width - this.padding.left - this.padding.right; return Math.floor(innerWidth / this.pointsPerBar); } }注意:这种方案下,缩放的本质就是改变pointsPerBar,取值范围一般在3到30之间。当pointsPerBar小于3时,蜡烛会重叠得没法看;大于30时,每根K线的宽度大得惊人,适合月线级别展示。实际项目中,我一般限制在4到24这个区间。
2.3 用Canvas还是SVG:选型理由
这是一个绕不开的判断题。SVG的优点是DOM化,每个蜡烛都能独立绑定事件,适合内部有大量点击需求、数据量又小的场景。但K线图动不动几千根K线,SVG会产生几千个节点,拖拽和缩放时浏览器都要重排重绘,卡顿感非常明显。
Canvas则是一次性画到位,对引擎来说是“像素点阵”,没有节点压力,性能上限高。代价是实现十字光标、点击命中检测都要自己算坐标,增加了不少代码量。考虑到K线图的核心诉求是流畅的平移缩放,我90%的项目都选Canvas。
// 初始化时处理高分屏,否则canvas会模糊 initCanvas() { const dpr = window.devicePixelRatio || 1; const rect = this.canvas.getBoundingClientRect(); this.canvas.width = rect.width * dpr; this.canvas.height = rect.height * dpr; this.ctx.scale(dpr, dpr); this.width = rect.width; this.height = rect.height; }这段代码是所有Canvas绘制的起点。如果不做dpr处理,在Retina屏幕下画出来的K线会笔刷模糊,文字像蒙了一层纱。注意getBoundingClientRect必须在CSS样式确定后调用,否则拿到的宽高是0。
3. 用Canvas绘制第一版K线图:从网格到蜡烛
3.1 初始化画布和绘制坐标
有了数据结构后,我们开始真正的绘制。每次重绘其实都要走一遍完整流程:清屏、算边界、画网格、画蜡烛、画文本。我把这个流程放在一个render方法中,因为Canvas没有“局部更新”的概念,一次交互就是全量重绘。
render() { const ctx = this.ctx; const { width, height } = this; const innerTop = this.padding.top; const innerBottom = height - this.padding.bottom; const innerHeight = innerBottom - innerTop; // 清屏 ctx.clearRect(0, 0, width, height); // 画背景 ctx.fillStyle = '#1f2227'; ctx.fillRect(0, 0, width, height); // 计算当前可视区间内的价格最大值和最小值 const visibleBars = this.bars.slice(this.startIndex, this.endIndex + 1); let maxPrice = -Infinity; let minPrice = Infinity; for (const bar of visibleBars) { if (bar.high > maxPrice) maxPrice = bar.high; if (bar.low < minPrice) minPrice = bar.low; } // 上下留白5%的余地,让蜡烛不贴边 const padRange = (maxPrice - minPrice) * 0.05; maxPrice += padRange; minPrice = Math.max(0, minPrice - padRange); this.maxPrice = maxPrice; this.minPrice = minPrice; this.priceTop = innerTop; this.priceBottom = innerBottom; this.drawGrid(ctx, innerTop, innerBottom, maxPrice, minPrice); this.drawCandles(ctx, visibleBars, innerTop, innerBottom, maxPrice, minPrice); }注意maxPrice和minPrice是每次根据可见数据动态计算的,不是全量数据的最大最小。因为K线拉升到近期成交密集区时,如果还按历史最高最低画,价格波动会被压缩成一条线,完全看不到结构。
3.2 用Canvas绘制网格和价格刻度
网格线不需要太多,一般画横线4到6条,纵线根据图表宽度适当插入。横线对应价格刻度,纵线对应时间分隔。实现时注意先画线再写字,让文字浮在网格线上方。
drawGrid(ctx, innerTop, innerBottom, maxPrice, minPrice) { const gridCount = 5; const priceRange = maxPrice - minPrice; ctx.strokeStyle = '#353a41'; ctx.lineWidth = 1; ctx.fillStyle = '#8a919b'; ctx.font = '11px monospace'; for (let i = 0; i <= gridCount; i++) { const y = innerTop + (innerBottom - innerTop) * (i / gridCount); const price = maxPrice - priceRange * (i / gridCount); ctx.beginPath(); ctx.moveTo(this.padding.left, y); ctx.lineTo(this.width - this.padding.right, y); ctx.stroke(); // 价格文本放在左侧留白区域内 ctx.textAlign = 'right'; ctx.fillText(price.toFixed(price < 10 ? 2 : 0), this.padding.left - 6, y + 4); } }网格的绘制看起来很基础,但有个藏得很深的坑:lineWidth如果是1,在高分屏且没有dpr缩放的情况下,线会渲染成两像素的模糊条。解决办法就是前面提到的dpr缩放,加上这里明确设置lineWidth = 1,这样在ctx.scale(dpr, dpr)之后实际等效于物理像素1像素。
3.3 绘制蜡烛:阳线、阴线、影线
蜡烛的绘制分两部分:先画影线(最高价到最低价的垂直线),再画实体(开盘价到收盘价的矩形)。实体是否填充,取决于它是阳线还是阴线。国内习惯红涨绿跌,美股相反,这里我用红涨绿跌,因为面向A股场景更多。
drawCandles(ctx, visibleBars, innerTop, innerBottom, maxPrice, minPrice) { const candleWidth = this.pointsPerBar * 0.7; // 实体宽度占点距的70% const priceToY = (price) => { return innerTop + (innerBottom - innerTop) * (maxPrice - price) / (maxPrice - minPrice); }; visibleBars.forEach((bar, idx) => { // 注意这里用“渲染起始点 + 索引*pointsPerBar”,不能用idx直接乘 const x = this.padding.left + idx * this.pointsPerBar + this.pointsPerBar / 2; const isUp = bar.close >= bar.open; const color = isUp ? '#ef5350' : '#26a69a'; ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 1; // 影线 ctx.beginPath(); ctx.moveTo(x, priceToY(bar.high)); ctx.lineTo(x, priceToY(bar.low)); ctx.stroke(); // 实体 const yOpen = priceToY(bar.open); const yClose = priceToY(bar.close); const yTop = Math.min(yOpen, yClose); const height = Math.max(1, Math.abs(yOpen - yClose)); // 防止开盘收盘相同导致不可见 if (isUp) { // 阳线实体为空心矩形,描边而不是填充 ctx.strokeRect(x - candleWidth / 2, yTop, candleWidth, height); } else { ctx.fillRect(x - candleWidth / 2, yTop, candleWidth, height); } }); }有几个参数值得反复校准。candleWidth占点距的比例,我习惯70%以上,小于50%看起来稀疏;但如果数据密集到每根K线只有3像素宽,实体宽度应最小压到2像素,避免一重绘就闪烁。另一个经验是:如果开盘等于收盘,实体高度为0,系统不会画任何图形,视觉上只有一根影线,这是合理的,表示十字星。
4. 交互与缩放:用JavaScript事件驱动重绘
4.1 维护可视区间实现平移与缩放
Canvas自身的绘制是无状态的,真正的状态在数据索引区间。平移就是同时增减startIndex和endIndex,缩放则改变pointsPerBar并重新计算可见数量。我通常把鼠标拖拽距离转换成“移动了多根K线”,而不是像素。
// 鼠标拖拽平移 onMouseDown(e) { this.dragging = true; this.dragStartX = e.clientX; this.dragStartStartIndex = this.startIndex; } onMouseMove(e) { if (!this.dragging) return; const deltaPixel = e.clientX - this.dragStartX; const deltaBars = Math.round(deltaPixel / this.pointsPerBar); const newStart = this.dragStartStartIndex - deltaBars; // 向右拖 => 回看更早 this.startIndex = Math.max(0, Math.min(newStart, this.bars.length - this.visibleCount - 1)); this.endIndex = this.startIndex + this.visibleCount - 1; this.render(); } onMouseUp() { this.dragging = false; }注意这里平移的方向要和直觉一致:向右拖拽,图表内容应该向右移动,等于查看更早的数据,所以startIndex要变小,也就是-= deltaBars。我自己第一次做时反号了,辛辛苦苦调半天才发现只是代码符号问题。
滚轮缩放是另一个容易出错的地方。我想做到“以鼠标所在位置为中心缩放”,这意味着缩放前后鼠标下的那一根K线不能跳走。实现方法:记录缩放前鼠标对应的K线索引,缩放后让该索引仍然位于鼠标位置。
onWheel(e) { e.preventDefault(); const rect = this.canvas.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const innerLeft = this.padding.left; // 鼠标位置对应的K线索引(允许小数) const hoverIndex = this.startIndex + (mouseX - innerLeft) / this.pointsPerBar; // 调整每根K线所占像素宽度 const factor = e.deltaY > 0 ? 1.15 : 0.85; this.pointsPerBar = Math.min(24, Math.max(3, this.pointsPerBar * factor)); // 缩放后反算startIndex,让hoverIndex保持在同一鼠标位置 this.startIndex = Math.floor(hoverIndex - (mouseX - innerLeft) / this.pointsPerBar); this.startIndex = Math.max(0, Math.min(this.startIndex, this.bars.length - this.visibleCount - 1)); this.endIndex = this.startIndex + this.visibleCount - 1; this.render(); }这段代码看起来只有三行核心逻辑,但如果不理解“以索引为中心”的换算,很容易写出“从中间往外扩”的错误版本。滚轮事件必须调用preventDefault,否则页面会跟着滚动。
4.2 实现十字光标与Tooltip
十字光标是K线图的标配,用户需要知道鼠标所在位置对应哪根K线以及当天的开高低收。实现时不能靠Canvas去“检测”,而是用数学反算鼠标在哪根K线范围。
onMouseMoveForHover(e) { const rect = this.canvas.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; // 反算K线索引 const index = Math.floor((mouseX - this.padding.left) / this.pointsPerBar); if (index < 0 || index >= this.visibleCount) { this.hoverIndex = -1; this.render(); return; } const barIndex = this.startIndex + index; const bar = this.bars[barIndex]; this.hoverIndex = barIndex; this.drawCrosshair(mouseX, mouseY, bar); }十字光标的绘制本质是一条横线和一条纵线,纵线对齐到K线中心点,横线对齐到鼠标Y坐标。Tooltip则要画一个带背景的圆角矩形,内部显示OHLC和成交量。
注意这里用mouseY作为横线坐标、mouseX作为竖线坐标,而OHLC文本会浮在K线右侧,需要考虑边界。如果鼠标在右下角,Tooltip矩形可能会画出画布,所以要做翻转判断。常见做法是取画布宽度的一半作为分界,靠右时Tooltip向左偏移。
4.3 性能优化:只绘制可视区域内的数据
很多新手把bars数组全量画一遍,结果数据量到1万根时,每次拖拽都要循环整个数组,卡到无法忍受。优化非常简单:只对startIndex到endIndex范围内的数据做绘制。
上面代码里我们已经用slice取了visibleBars,但注意slice也会产生新数组,频繁交互时会增加GC压力。更优的做法是直接用for (let i = this.startIndex; i <= this.endIndex; i++),不切片。我这里为了代码可读性用了切片,实际接入时建议改掉。
另一个性能细节是避免反复设置字体和颜色。Canvas的ctx.font和ctx.fillStyle设置,在有些浏览器里开销比画线还高。我把它们尽量提到循环外,没变化的样式不重复赋值。
5. K线图避坑指南:五个典型翻车现场
5.1 现象:缩放到最小级别时蜡烛挤成一团,分辨不出影线
原因:pointsPerBar设为3时,实体宽度还是按70%计算,只有2.1像素,加上影线可能完全重叠。看起来是一片黑斑。
解决:设置一个最小实体宽度阈值。当pointsPerBar小于4时,强制candleWidth为1到2像素,并且跳过当前周期绘制影线的逻辑,因为影线已经包含在实体高度的信息中。实际验证后,我还加入了“当点距小于4时,只画实体不画影线”的规则,视觉清晰度明显提升。
5.2 现象:滚轮缩放时,鼠标永远跑不到想看的K线上,越缩越偏
原因:缩放后重新计算startIndex时,没有考虑到缩放前后数据变动导致的索引漂移。常见错误是缩放后直接用鼠标的clientX换算索引,但实际上这次换算使用的pointsPerBar已经是新的了,方程变形了。
解决:严格按照上一节代码,先算出缩放前的hoverIndex,用它乘旧的pointsPerBar、减innerLeft得像素偏移,再除以新的pointsPerBar得到新的startIndex。核心是把鼠标下的K线索引当锚点,而不是把鼠标坐标当锚点。
5.3 现象:十字光标在Canvas上留下残影,拖拽时图像发花
原因:Canvas的重绘没有先清屏。我们的render开头有clearRect,但如果十字光标是独立在render之外另画的,或者clearRect的尺寸是CSS宽度而非画布物理宽度,就会留下残影。
解决:把十字光标也纳入render流程,并保证每次重绘先从0,0到canvas.width, canvas.height清屏。另外注意canvas.width = rect.width这种赋值会重置Canvas所有状态,包括缩放变换,所以必须在initCanvas里最后执行ctx.scale(dpr, dpr),并且不能在后续重绘里再次赋值canvas.width,否则dpr状态丢失。
5.4 现象:实时推送新K线后,图表整体跳一下,或者最新点直接穿透右边界
原因:实时数据更新时,我们往往直接修改bars数组长度,但没有重算endIndex。如果原来显示的是最后100根,数据更新后最新K线在endIndex之外,视觉上就会“看不到最新数据”。
解决:更新数据前先判断是否处于“固定尾部”状态。我定义了一个followLatest布尔值,当用户拖拽离开最新数据时置false;默认true。在数据插入时,如果followLatest为true,则设置endIndex = bars.length - 1,startIndex = endIndex - visibleCount + 1。这样图表就像聊天窗口一样自动跟随最新价格。
5.5 现象:手机浏览器上K线图卡顿,一触屏就白屏
原因:手机端的touchmove事件默认会触发页面滚动,而且requestAnimationFrame没有管理,同一个时间片可能触发多次重绘。
解决:给canvas添加touch-action: none样式,阻止浏览器接管手势;然后在touchmove回调里用e.preventDefault()。更重要的是把重绘逻辑包一个requestAnimationFrame锁,保证一个动画帧内最多执行一次:
let rafId = null; function scheduleRender() { if (rafId) return; rafId = requestAnimationFrame(() => { this.render(); rafId = null; }); }这样能大幅减少重绘频率,同时对性能也有帮助。注意touch-action这个CSS属性不是所有浏览器都兼容,但主流移动端全部支持,可以放心用。
6. 进阶:把K线图升级成你真正能上线的样子
6.1 叠加均线:MA5/MA10/MA20的计算与绘制
裸K线图只能看价格轨迹,交易者普遍还想看到均线。均线的计算是移动平均,需要遍历可见区间的数据,从当前K线往前数N根。可以一次性预计算好每个周期的MA值,存在数组里,绘制时直接取数。
// 预计算MA均线数组,period为均线周期 function computeMA(bars, period) { const result = new Array(bars.length).fill(null); let sum = 0; for (let i = 0; i < bars.length; i++) { sum += bars[i].close; if (i >= period) sum -= bars[i - period].close; if (i >= period - 1) result[i] = sum / period; } return result; }绘制时在蜡烛层之上,把MA数组当前索引位置的点连成折线。注意MA数据中可能包含null值,连线时要跳过空值,否则会从起点拖一根线到第二个有效点。我处理方式是记录下个有效索引,用moveTo重新初始化。
6.2 数据更新策略:增量追加与节流重绘
实时行情中,最后一根K线常常在变动,比如每500毫秒推送一次最新价。如果每次都全量重绘,CPU占用会很高。我会将更新分成两种:高频的“只重绘最后一根K线”和低频的“重绘整个可视区”。
高频更新的方案是暂存一个lastBar,在render里直接用它重新绘制最后一根蜡烛和最新价格标签,其他区域不动。这个优化可以结合scheduleRender的节流逻辑,做到每秒最多重绘4次。很多浏览器甚至支持requestAnimationFrame在后台标签页自动暂停,这反而是好事。
6.3 用模拟数据做自测清单:这6个场景必须过
上线前我会跑一遍固定测试脚本。数据用随机生成器构造,但要包含几种特殊形态:涨停一字板(开=高=收=低)、大幅跳空(开盘价远高于前日收盘价)、复权前后价格突变、极端值(价格几近归零或单日暴涨一倍)、空数据、单根数据。
每个场景我检查四个点:一是不崩不报错;二是缩放后价格标签不与价格线重叠;三是十字光标在非数据区域时正确隐藏;四是窗口resize后图表正确填充。这套清单帮我揪出过不少只在周末数据断档时出现的bug,也避免过上线后用户在极端行情下看到空白的尴尬。
最后说说我的习惯:K线图这种组件,我永远把它独立成模块,不散落在业务页面里。所有Canvas操作封装在类方法中,对外只暴露setData、setVisibleRange、onHover回调。这样无论是接入WebSocket实时推送,还是把图表嵌入到React/Vue组件中,都能保持界面的干净。每次重写时,最宝贵的经验不是代码本身,而是那几张自己总结的坐标换算公式和边界条件表格。
希望帮到你,从第一根蜡烛开始,画出真正能扛住生产环境的那张图。
本文还有配套的精品资源,点击获取