简介这份资源面向需要在前端或移动端实现行情图表的开发者提供了一套基于原生 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.beginPathmoveTolineTostroke实体用fillRect。这里有个性能细节如果每根蜡烛都单独beginPath和stroke8000 根就是 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, ...)防止缩放到超过数据总量。平移用mousedownmousemove实现记录按下时的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 渲染都会先搭好离屏缓存和分层结构再填业务逻辑省下的调试时间远超前期投入。希望帮到你。本文还有配套的精品资源点击获取