简介:这份资源面向需要在前端或移动端实现行情图表的开发者,提供了一套基于原生 JavaScript 与 H5 Canvas 的 K 线图绘制方案,可解决金融、数字货币等场景下的行情可视化需求。压缩包共 3 个文件,包含 2 个 js 脚本与 1 个 html 页面,整体约 11KB,其中 kline.js 承担核心绘图与交互逻辑,hammer-min.js 负责移动端手势识别,html 文件作为可直接运行的演示入口。功能上支持左右滑动、手势缩放以及长按显示十字光标,手势触摸事件集中在 kline.js 底部的 bindListener 方法内,不熟悉 Hammer.js 的开发者可自行替换为原生事件。无需任何配置,双击打开 kline.html 即可查看效果,便于快速集成到自有项目中。目前已有 3019 人学习下载,适合希望低成本掌握 Canvas 图表交互实现思路的前端开发者参考。
1. 用 Canvas 手搓 K 线图:为什么我不推荐直接上 ECharts
前阵子帮一个做量化交易的朋友改他的行情面板,他原本用某图表库渲染 K 线,数据量一上到 8000 根就开始卡顿,缩放拖拽像在拖水泥。我把他那套依赖全删了,用原生 Canvas 重写了一遍,同样的数据量帧率稳在 55 以上。这件事让我意识到,很多人一提到「JavaScript 实现 K 线图」就条件反射去找现成库,却从没想过 K 线图本质上就是一堆矩形加几条线,自己画反而更可控。
这份资源就是围绕「用原生 JS + Canvas 从零实现 K 线图」展开的,包含完整的绘制逻辑、坐标映射、交互处理和性能优化思路。它解决的不是「画一根蜡烛」这种玩具问题,而是真实行情场景下的数据映射、视口裁剪、十字光标吸附、缩放平移这些硬骨头。适合两类人:一是想彻底搞懂 K 线渲染原理的前端,二是被图表库性能和定制性折磨过的交易系统开发者。如果你只想快速贴个图交差,那直接用库没问题;但如果你想掌控每一根蜡烛的像素,往下看。
2. 坐标系与数据映射:K 线图的骨架怎么搭
2.1 为什么 K 线图的核心是「映射」而不是「画图」
新手写 K 线图最容易犯的错,是拿到数据就开始ctx.fillRect,画到一半发现价格 68000 和 0.00012 画出来一样高。K 线图的本质是两层映射:第一层把「时间索引」映射到 X 轴像素,第二层把「价格区间」映射到 Y 轴像素。这两层映射没搭好,后面所有交互都是空中楼阁。
先说 X 轴。K 线数据通常是等时间间隔的数组,索引 0 到 N-1 对应从左到右的位置。但真实行情里会有停牌、休市导致的时间空洞,如果你按时间戳线性映射,K 线会稀稀拉拉。常见做法是按「数据点索引」映射,而不是按真实时间戳,这样每根蜡烛间距均匀,视觉上更整齐。代价是时间轴不是严格线性的,但对日内交易图来说可以接受。
Y 轴更关键。价格映射需要先算出当前视口内所有可见 K 线的最高价和最低价,然后留出上下各 5% 到 10% 的 padding,再把[minPrice, maxPrice]线性映射到[canvasHeight, 0](注意 Y 轴是反的,价格越高像素值越小)。这里有个坑:如果你用全局最高最低价,缩放后价格波动会被压扁成一条直线,所以必须用「当前视口可见数据」的动态极值。
// 计算可见区域的 Y 轴映射参数 function computeYScale(visibleData, canvasHeight, paddingRatio = 0.08) { let minPrice = Infinity; let maxPrice = -Infinity; // 只遍历可见数据,这是性能关键 for (let i = 0; i < visibleData.length; i++) { const bar = visibleData[i]; // 注意:影线的高低价也要纳入,否则影线会被裁掉 if (bar.high > maxPrice) maxPrice = bar.high; if (bar.low < minPrice) minPrice = bar.low; } // 防止所有价格相同导致除以零 if (maxPrice === minPrice) { maxPrice += 1; minPrice -= 1; } const padding = (maxPrice - minPrice) * paddingRatio; const top = maxPrice + padding; const bottom = minPrice - padding; const range = top - bottom; // 返回一个把价格转成像素 Y 的函数 return { top, bottom, toY: (price) => canvasHeight - ((price - bottom) / range) * canvasHeight, }; }这段代码里paddingRatio控制上下留白,我一般设 0.08,太小影线贴边,太大蜡烛显得矮胖。toY返回的是像素坐标,注意公式里用canvasHeight -是因为 Canvas 的 Y 轴向下增长,而价格向上增长,必须翻转。这个函数会被每根蜡烛调用 4 次(开高低收),所以它必须足够轻,不要在里面做任何 DOM 操作或对象创建。
2.2 从数据到蜡烛:一根 K 线的绘制拆解
一根标准 K 线由三部分组成:实体(开盘价到收盘价的矩形)、上影线(最高价到实体顶部)、下影线(实体底部到最低价)。阳线(收盘 > 开盘)通常用红色或空心,阴线用绿色或实心,这是国内习惯,欧美反过来,所以颜色要可配置。
绘制顺序有讲究:先画影线再画实体,否则影线会盖住实体边缘。影线用ctx.beginPath+moveTo+lineTo+stroke,实体用fillRect。这里有个性能细节:如果每根蜡烛都单独beginPath和stroke,8000 根就是 8000 次路径操作,非常慢。优化做法是把所有影线合并成一条路径,一次性 stroke。
function drawCandles(ctx, data, xScale, yScale, options) { const { candleWidth, upColor, downColor, wickColor } = options; const halfWidth = candleWidth / 2; // 第一遍:合并所有影线为一条路径 ctx.beginPath(); ctx.strokeStyle = wickColor; ctx.lineWidth = 1; for (let i = 0; i < data.length; i++) { const bar = data[i]; const x = xScale.toX(i) + halfWidth; // 蜡烛中心 X const highY = yScale.toY(bar.high); const lowY = yScale.toY(bar.low); ctx.moveTo(x, highY); ctx.lineTo(x, lowY); } ctx.stroke(); // 一次性画完所有影线 // 第二遍:逐根画实体 for (let i = 0; i < data.length; i++) { const bar = data[i]; const x = xScale.toX(i); const openY = yScale.toY(bar.open); const closeY = yScale.toY(bar.close); // 实体高度至少 1px,否则十字星会消失 const bodyTop = Math.min(openY, closeY); const bodyHeight = Math.max(Math.abs(closeY - openY), 1); ctx.fillStyle = bar.close >= bar.open ? upColor : downColor; ctx.fillRect(x, bodyTop, candleWidth, bodyHeight); } }candleWidth需要根据缩放级别动态计算,我一般用Math.max(1, Math.floor(viewportWidth / visibleCount) - 1),减 1 是留间隙。Math.max(..., 1)保证十字星(开盘等于收盘)不会因为高度为 0 而消失,这是血泪经验,曾经排查了半天为什么某些 K 线不见了。影线合并路径这个技巧在数据量大时能提升 3 到 5 倍渲染速度,因为减少了 Canvas 状态切换和路径提交次数。
3. 交互实现:缩放、平移与十字光标
3.1 缩放与平移:视口管理的正确姿势
K 线图的交互核心是「视口」概念:你看到的永远只是全部数据的一个窗口,缩放改变窗口大小,平移改变窗口起点。视口用两个变量描述:startIndex(起始数据索引)和visibleCount(可见数量)。所有绘制都基于这两个值裁剪数据。
滚轮缩放时,直觉是「以鼠标位置为中心缩放」,也就是鼠标指向的那根 K 线在缩放后应该还在鼠标下方。实现方法是先算出鼠标对应的数据索引,缩放后调整startIndex让这个索引仍然对齐鼠标 X 坐标。
// 滚轮缩放:以鼠标位置为锚点 function handleWheel(event, viewport, totalCount, canvasWidth) { event.preventDefault(); const rect = event.target.getBoundingClientRect(); const mouseX = event.clientX - rect.left; // 鼠标当前指向的数据索引 const anchorIndex = viewport.startIndex + (mouseX / canvasWidth) * viewport.visibleCount; // 缩放因子:向上滚放大(可见变少),向下滚缩小 const zoomFactor = event.deltaY < 0 ? 0.9 : 1.1; let newCount = Math.round(viewport.visibleCount * zoomFactor); // 边界限制:最少 10 根,最多全部数据 newCount = Math.max(10, Math.min(totalCount, newCount)); // 反推新的 startIndex,让 anchorIndex 仍在鼠标位置 const newStart = Math.round(anchorIndex - (mouseX / canvasWidth) * newCount); // 防止越界 viewport.startIndex = Math.max(0, Math.min(totalCount - newCount, newStart)); viewport.visibleCount = newCount; }zoomFactor用 0.9 和 1.1 而不是 0.5 和 2,是为了让缩放手感平滑,一次滚轮不会跳太多。Math.max(10, ...)限制最少显示 10 根,否则缩到 1 根时整个图会崩。Math.min(totalCount, ...)防止缩放到超过数据总量。平移用mousedown+mousemove实现,记录按下时的startX和startIndex,移动时按像素差换算成索引差即可,注意要Math.round避免累积浮点误差。
3.2 十字光标与 Tooltip:吸附逻辑与性能陷阱
十字光标是 K 线图的灵魂功能,但也是最容易写出性能问题的部分。常见错误是在mousemove里重绘整个图表,导致鼠标一动就掉帧。正确做法是分层 Canvas:主图层画 K 线,交互层画十字光标和 Tooltip,鼠标移动只重绘交互层。
吸附逻辑是让十字光标的竖线自动对齐到最近的蜡烛中心,而不是跟随鼠标像素。这样用户看到的永远是某根具体 K 线的数据。
// 交互层:绘制十字光标和 Tooltip function drawCrosshair(ctx, mouseX, mouseY, viewport, data, xScale, yScale) { ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); // 计算吸附的蜡烛索引 const rawIndex = viewport.startIndex + (mouseX / ctx.canvas.width) * viewport.visibleCount; const snapIndex = Math.round(rawIndex); if (snapIndex < 0 || snapIndex >= data.length) return; const bar = data[snapIndex]; const snapX = xScale.toX(snapIndex - viewport.startIndex) + xScale.candleWidth / 2; // 竖线 ctx.beginPath(); ctx.strokeStyle = '#888'; ctx.setLineDash([4, 4]); // 虚线 ctx.moveTo(snapX, 0); ctx.lineTo(snapX, ctx.canvas.height); ctx.stroke(); // 横线跟随鼠标 Y ctx.beginPath(); ctx.moveTo(0, mouseY); ctx.lineTo(ctx.canvas.width, mouseY); ctx.stroke(); ctx.setLineDash([]); // 恢复实线,否则影响后续绘制 // Tooltip 显示 OHLC 数据 const price = yScale.toPrice(mouseY); // 需要反向映射函数 ctx.fillStyle = 'rgba(0,0,0,0.75)'; ctx.fillRect(snapX + 10, mouseY - 40, 140, 70); ctx.fillStyle = '#fff'; ctx.font = '12px monospace'; ctx.fillText(`开: ${bar.open}`, snapX + 18, mouseY - 22); ctx.fillText(`高: ${bar.high}`, snapX + 18, mouseY - 8); ctx.fillText(`低: ${bar.low}`, snapX + 18, mouseY + 6); ctx.fillText(`收: ${bar.close}`, snapX + 18, mouseY + 20); }setLineDash画完虚线后必须重置为[],否则后面所有线条都变虚线,这个坑我踩过不止一次。yScale.toPrice是toY的反函数,用于把鼠标 Y 坐标转回价格显示。Tooltip 的位置要做边界检测,靠近右边缘时应该往左偏移,否则会超出画布。交互层用独立的 Canvas 叠在主图层上方,mousemove只清空交互层,主图层完全不动,这样即使 8000 根 K 线,鼠标移动也是 60 帧满帧。
4. 避坑与排查:那些让我加班到凌晨的细节
4.1 高 DPI 屏幕模糊:devicePixelRatio 的坑
现象:在 MacBook 或 4K 显示器上,K 线边缘发虚,线条像蒙了一层雾。原因:Canvas 的width和height属性是物理像素,而 CSS 的width和height是逻辑像素,高 DPI 屏幕下两者比例是 2:1 或 3:1,不处理就会拉伸模糊。解决:初始化时按devicePixelRatio放大 Canvas 物理尺寸,再用ctx.scale缩放绘图坐标系。
function setupHiDPICanvas(canvas, cssWidth, cssHeight) { const dpr = window.devicePixelRatio || 1; canvas.width = cssWidth * dpr; canvas.height = cssHeight * dpr; canvas.style.width = cssWidth + 'px'; canvas.style.height = cssHeight + 'px'; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); // 之后所有绘图按逻辑像素走 return ctx; }注意ctx.scale会累积,如果每次 resize 都调用而不重置变换矩阵,缩放会翻倍。正确做法是 resize 时先ctx.setTransform(1, 0, 0, 1, 0, 0)重置,再重新 scale。
4.2 数据量大时卡顿:别在 draw 里做排序和查找
现象:数据超过 5000 根后,每次重绘要 200ms 以上。原因:在绘制循环里做了data.filter、data.slice或者每次都重新计算极值。解决:视口变化时才重新计算可见数据切片和极值,绘制循环只做纯像素计算。另外,for循环比forEach和map快得多,绘制热路径上不要用高阶函数。
4.3 缩放后 K 线错位:浮点索引的累积误差
现象:反复缩放平移后,K 线位置和十字光标对不上,偏差越来越大。原因:startIndex用浮点数存储,每次加减都累积误差。解决:startIndex始终用整数存储,缩放时先算浮点结果再Math.round,平移时按整数步进。如果确实需要亚像素平滑,用单独的offsetX浮点变量做渲染偏移,但索引本身保持整数。
4.4 影线被裁掉:极值计算漏了影线
现象:某些 K 线的上影线或下影线超出画布顶部或底部被切掉。原因:计算 Y 轴极值时只用了开盘收盘价,忘了最高最低价。解决:极值遍历必须包含bar.high和bar.low,这个错误在快速迭代时特别容易犯,因为实体太显眼,影线容易被忽略。
4.5 内存泄漏:事件监听没解绑
现象:页面切换几次后越来越卡,内存占用持续上涨。原因:mousemove、wheel、resize监听器绑定在window或document上,组件销毁时没移除。解决:把所有监听器引用存起来,在销毁钩子里removeEventListener,或者用AbortController统一取消。
5. 进阶技巧:用离屏 Canvas 把渲染压到 16ms 以内
当数据量到万级、还要叠加均线、成交量、指标时,单层 Canvas 会吃力。我的做法是三层结构:底层用离屏 Canvas 缓存静态的 K 线主体,只在视口变化时重绘;中层画均线和指标;顶层画十字光标。离屏 Canvas 的好处是它不参与页面合成,绘制结果可以drawImage一次性贴到主画布。
// 离屏缓存:视口不变时复用 class CandleCache { constructor(width, height) { this.offscreen = document.createElement('canvas'); this.offscreen.width = width; this.offscreen.height = height; this.ctx = this.offscreen.getContext('2d'); this.lastKey = ''; // 缓存键:视口 + 数据版本 } render(data, viewport, xScale, yScale, options) { // 缓存键变化才重绘 const key = `${viewport.startIndex}-${viewport.visibleCount}-${data.version}`; if (key === this.lastKey) return this.offscreen; this.ctx.clearRect(0, 0, this.offscreen.width, this.offscreen.height); drawCandles(this.ctx, data.slice( viewport.startIndex, viewport.startIndex + viewport.visibleCount ), xScale, yScale, options); this.lastKey = key; return this.offscreen; } } // 主渲染循环 function render(mainCtx, cache, data, viewport, xScale, yScale, options) { const cached = cache.render(data, viewport, xScale, yScale, options); mainCtx.clearRect(0, 0, mainCtx.canvas.width, mainCtx.canvas.height); mainCtx.drawImage(cached, 0, 0); // 一次性贴图,极快 // 再画均线、十字光标等动态层 }data.version是数据更新时递增的版本号,用来让缓存失效。drawImage贴离屏 Canvas 几乎不耗时,因为不涉及路径计算。实测 10000 根 K 线,视口不变时每帧渲染从 45ms 降到 3ms 以内。另一个技巧是用requestAnimationFrame节流mousemove,把多次鼠标事件合并到一帧里处理,避免高频重绘。
验证渲染性能的方法很简单:在render函数首尾打performance.now(),连续跑 100 帧取平均。如果超过 16ms,就要检查是不是在热路径里做了对象创建、数组操作或者字符串拼接。我习惯在开发阶段常驻一个帧率显示,一旦掉到 50 以下就立刻排查。从那以后我每次写 Canvas 渲染都会先搭好离屏缓存和分层结构,再填业务逻辑,省下的调试时间远超前期投入。希望帮到你。
本文还有配套的精品资源,点击获取