
简介一份基于JavaScript与HTML5 Canvas实现的K线图组件面向需要快速实现移动端行情展示的Web前端开发者也适合学习原生Canvas绘图者参考。采用纯前端方案双击kline.html即可运行无需任何配置。主体逻辑集中在kline.js中借助Hammer.js统一处理移动端触摸事件支持左右滑动、手势缩放、长按显示十字光标等交互相关监听集中在bindListener方法内方便熟悉原生事件的读者替换为自定义实现。压缩包共3个文件包含2个js脚本与1个入口html页面整体仅11KB结构精简适合直接嵌入现有项目也可作为Canvas绘图与手势交互的学习样例。资源中kline.js对绘图流程和事件处理的拆分较为清晰能帮助读者理解坐标换算、重绘机制以及手势抽象过程Hammer.js虽为第三方库但其引用位置集中便于按需替换。目前已有3016人学习是一个体量小但交互完整的K线图参考实现。1. K线图不用图表库原生JS实现的边界与取舍想在页面里画一份K线图很多人的第一反应是引一个图表库。但当你要做盘口级交互、自定义指标公式、接管数据推送更新频率的时候图表库反而成了黑匣子。这份JavaScript实现的K线图源码包从数据坐标映射到Canvas绘制全部自己控制适合需要精细控制渲染逻辑的前端和量化开发者。你不用先会ECharts只需懂基础JS和Canvas就能复现。它的边界也很清楚不做复杂指标计算专注把蜡烛图、均线、成交量和交互画对。2. 数据模型与坐标映射把OHLC数组换算成Canvas像素K线图的所有表现都建立在数据模型和坐标映射上。这里的核心不是“画”而是“算”先把一根根K线的OHLC转换成Canvas上的像素坐标后面画蜡烛、画均线、画十字光标才有基准。下面从数据结构讲起一步步落到一个可复用的映射函数。2.1 K线数据在JS里的组织方式数组、对象与时间戳大多数行情接口返回的JSON里每根K线是一个对象包含开高低收和成交量。拿到手后我一般不会直接丢给绘图函数而是先做一层标准化保证字段名和类型统一。// 标准化后的K线数据time统一用毫秒时间戳 const KLINE_DATA [ { time: 1654041600000, open: 10.2, high: 10.8, low: 10.1, close: 10.6, volume: 2300 }, { time: 1654045200000, open: 10.6, high: 10.9, low: 10.3, close: 10.4, volume: 1800 }, { time: 1654048800000, open: 10.4, high: 10.7, low: 10.2, close: 10.5, volume: 2100 }, ];这段结构里time必须用毫秒时间戳而不是2023-06-01这种字符串。字符串在后续排序、算间隔、定位十字光标时都要额外解析纯数字可以直接相减比较。open、high、low、close四价缺一不可均线计算依赖close蜡烛实体依赖open和close影线依赖high和low。volume是成交量副图用的如果只需要主图可以精简掉但建议保留。有些接口返回的是数组嵌套形如[time, open, high, low, close, volume]。这种情况我在解析层直接解构成对象function normalizeKLine(rows) { return rows.map(r ({ time: r[0], open: r[1], high: r[2], low: r[3], close: r[4], volume: r[5], })); }标准化之后再传给绘图模块而不是在渲染函数里做字段判断。这个习惯可以避免后续在Canvas代码里到处写item[1]这种可读性差的索引。2.2 坐标系转换价格到Y轴、时间到X轴的数学关系Canvas的坐标系和K线图的坐标方向是反的。Canvas原点在左上角Y轴向下增长而K线图里价格越高越靠上所以价格映射到Y坐标时要反向计算。用一个公式表达就是y chartBottom - ((price - minPrice) / (maxPrice - minPrice)) * chartHeightminPrice取当前可视区间内所有K线low的最小值maxPrice取所有high的最大值。为什么不用close来定范围因为影线会超出实体如果只看收盘价长影线会被裁掉蜡烛图就不完整。X轴方向有两种常见映射方式。一种是按索引等分一根K线占固定像素位周末休市不会暴露缺口所有蜡烛宽度一致性能也更好。另一种是按时间戳比例映射真实反映时间间隔但遇到隔夜和周末会出现大段空白数据稀疏时蜡烛挤在一起很难看。我处理的行情项目绝大多数用按索引等分只有日内分时图会用按时间映射。下文都以等分索引为例。2.3 一个最小可用的映射函数与留白参数把上面的原理落成代码就是一个遍历数组做坐标换算的函数。这里要处理两个细节左右留白和蜡烛半宽。如果不留白第一根K线的左半截和最后一根的右半截会被画布边缘切掉。function mapKLineToPixels(data, width, height, padding { top: 20, right: 70, bottom: 30, left: 10 }, candleWidth 8) { const innerLeft padding.left candleWidth / 2; const innerRight width - padding.right - candleWidth / 2; const innerTop padding.top; const innerBottom height - padding.bottom; const innerWidth innerRight - innerLeft; const innerHeight innerBottom - innerTop; const lows data.map(d d.low); const highs data.map(d d.high); const minPrice Math.min(...lows); const maxPrice Math.max(...highs); const priceGap maxPrice - minPrice || 1; return data.map((d, i) { const x innerLeft (i / (data.length - 1)) * innerWidth; const yOpen innerBottom - ((d.open - minPrice) / priceGap) * innerHeight; const yHigh innerBottom - ((d.high - minPrice) / priceGap) * innerHeight; const yLow innerBottom - ((d.low - minPrice) / priceGap) * innerHeight; const yClose innerBottom - ((d.close - minPrice) / priceGap) * innerHeight; return { ...d, x, yOpen, yHigh, yLow, yClose }; }); }函数里有两个关键参数padding控制绘图区四边空出的像素右侧留大一点是为了放价格轴标签candleWidth是蜡烛实体宽度参与左右边界的计算。priceGap || 1防止所有K线价格完全相同比如停牌数据时除数为零。这个函数每次渲染都会执行前期为了直观可以这样写后面数据量大了再优化成增量计算。映射做完后点与点之间就是纯粹的平面坐标关系。下一章基于这些坐标把蜡烛、均线和成交量真正画到画布上。3. Canvas绘制K线蜡烛、均线与成交量的渲染顺序坐标映射只是生成了“点”要变成屏幕上的K线图还要处理画布初始化、绘制顺序、颜色风格和线条粗细。这一章按实际渲染管线的顺序拆开讲每一段都能直接拼进渲染循环。3.1 绘制前的样板DPR缩放与坐标系基准Canvas在高分屏上直接按CSS像素画会出现线条发虚、文字模糊的情况。根本原因是Canvas的绘图缓冲区尺寸和设备物理像素不一致。我一般会在每次尺寸变化的入口先处理一次DPRfunction setupCanvas(canvas) { const rect canvas.getBoundingClientRect(); const dpr window.devicePixelRatio || 1; canvas.width Math.round(rect.width * dpr); canvas.height Math.round(rect.height * dpr); const ctx canvas.getContext(2d); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return { ctx, width: rect.width, height: rect.height }; }这里的核心是ctx.setTransform(dpr, 0, 0, dpr, 0, 0)。它把后续所有绘制命令的坐标系放大dpr倍之后代码里写ctx.fillRect(x, y, w, h)时参数仍然是CSS像素但实际落点是物理像素对齐的。注意不要用ctx.scale(dpr, dpr)去替代因为scale会叠乘重复调用时坐标会越来越偏setTransform每次都是重置更稳妥。每次调用setupCanvas后画布内容会被清空所以它应该放在渲染循环的最前面或者只在尺寸变化时调用避免和绘图函数耦合在一起。3.2 蜡烛图与影线的绘制实现蜡烛由实体和影线组成。实体是矩形影线是上下两条垂直线。绘制时先画影线再画实体这样实体可以遮盖掉影线穿过实体区域的线条视觉更干净。function drawCandles(ctx, points) { const BULL_COLOR #e05b4c; // 阳线红色 const BEAR_COLOR #3d9970; // 阴线绿色 const candleWidth 8; const half candleWidth / 2; ctx.lineWidth 1; points.forEach(p { const isBull p.close p.open; ctx.strokeStyle isBull ? BULL_COLOR : BEAR_COLOR; ctx.fillStyle ctx.strokeStyle; // 影线上影线从high画到实体顶下影线从实体底画到low ctx.beginPath(); ctx.moveTo(p.x, p.yHigh); ctx.lineTo(p.x, p.yLow); ctx.stroke(); // 实体 const bodyTop Math.min(p.yOpen, p.yClose); const bodyHeight Math.max(Math.abs(p.yOpen - p.yClose), 1); ctx.fillRect(p.x - half, bodyTop, candleWidth, bodyHeight); }); }isBull的判定用的是close open等于的情况算作阳线并给实体用了fillRect而不是strokeRect保证十字星形态也有一条可见的横线。bodyHeight最小取1避免开盘收盘价完全相同时实体消失。影线用moveTo和lineTo画是因为它本质是直线不需要矩形的面积概念还能在后续做虚线十字线时沿用同一套路径逻辑。颜色这块没有统一标准国内行情软件通常红涨绿跌欧美很多平台是绿涨红跌。这个值我习惯定义成常量而不是散落在代码里换主题时只需改两行。3.3 均线、成交量与网格的绘制参数均线是K线图上最常用的叠加指标。计算均线不是每帧都从头累加而是用滑动求和function computeMA(data, period) { const result new Array(data.length).fill(null); let sum 0; for (let i 0; i data.length; i) { sum data[i].close; if (i period) sum - data[i - period].close; if (i period - 1) result[i] sum / period; } return result; }period取5、10、20分别对应MA5、MA10、MA20。数组前面period - 1个元素没有足够的历史数据保持null绘制时跳过。均线的绘制就是把非空点按顺序连起来function drawMA(ctx, xs, maValues, color) { ctx.strokeStyle color; ctx.lineWidth 1.5; ctx.beginPath(); let started false; maValues.forEach((value, i) { if (value null) return; const x xs[i]; const y priceToY(value); started ? ctx.lineTo(x, y) : ctx.moveTo(x, y); started true; }); ctx.stroke(); }这里的priceToY和第二章的Y坐标换算是一路货色我通常把它抽成全局函数避免均线坐标和蜡烛坐标各算一遍导致对不齐。成交量副图放在主图下方高度按volume最大值归一化function drawVolume(ctx, points, volumeAreaHeight, minPrice, maxPrice) { const maxVolume Math.max(...points.map(p p.volume), 1); points.forEach(p { const barHeight (p.volume / maxVolume) * (volumeAreaHeight - 4); ctx.fillStyle p.close p.open ? #e05b4c : #3d9970; ctx.fillRect(p.x - 3, volumeAreaHeight - barHeight, 6, barHeight); }); }副图最好先绘制再画主图均线和蜡烛因为副图占用了底部空间后主图的价格范围需要考虑除掉副图高度否则主图和副图会叠在一起。网格则是最先画的底层元素用rgba(0,0,0,0.08)画水平线既不抢K线视觉又让价格定位有参考。到这里静态K线图已经能显示出来了。4. 交互设计缩放、平移、十字光标与数据定位静态图能看但没法“用”。K线图的实际价值在于缩放看细节、拖拽看历史、悬停看价格。这三类交互的核心不是事件本身而是事件和可视化区间visibleRange之间的换算。4.1 鼠标缩放以指针为锚点调整可视区间缩放的直觉是“鼠标指着哪根K线哪根K线就不要动”。实现这个效果的关键是算出锚点索引然后按锚点重新计算区间。const visibleRange { start: 0, end: 200 }; const totalCount KLINE_DATA.length; const MIN_COUNT 20; const MAX_COUNT totalCount; canvas.addEventListener(wheel, (e) { e.preventDefault(); const factor e.deltaY 0 ? 1.1 : 1 / 1.1; const count visibleRange.end - visibleRange.start; let newCount Math.round(count * factor); newCount Math.max(MIN_COUNT, Math.min(MAX_COUNT, newCount)); if (newCount count) return; const rect canvas.getBoundingClientRect(); const plotLeft padding.left candleWidth / 2; const plotWidth rect.width - padding.left - padding.right - candleWidth; const ratio Math.min(1, Math.max(0, (e.clientX - rect.left - plotLeft) / plotWidth)); const anchorIndex visibleRange.start ratio * count; let newStart Math.round(anchorIndex - ratio * newCount); if (newStart 0) newStart 0; let newEnd newStart newCount; if (newEnd totalCount) { newEnd totalCount; newStart totalCount - newCount; } visibleRange.start newStart; visibleRange.end newEnd; render(); }, { passive: false });ratio是鼠标在绘图区内的水平位置占比范围钳制在0到1之间。anchorIndex用浮点数计算避免提前取整引入累计误差。新区间算出后再做越界钳制数据不满一屏时MAX_COUNT等于totalCount新区间就是全部数据。passive: false必须写否则preventDefault不生效页面会在缩放K线图的同事跟着滚动。4.2 拖拽平移用位移差换算数据区间平移比缩放简单思路是记录拖拽起点对应的visibleRange每次mousemove用像素位移换算成数据条数变化。let dragStart null; let dragStartRange null; canvas.addEventListener(mousedown, (e) { dragStart { x: e.clientX }; dragStartRange { ...visibleRange }; }); document.addEventListener(mousemove, (e) { if (!dragStart) return; const dx e.clientX - dragStart.x; const count dragStartRange.end - dragStartRange.start; const plotWidth canvas.getBoundingClientRect().width - padding.left - padding.right - candleWidth; const indexDelta Math.round((dx / plotWidth) * count); let newStart dragStartRange.start - indexDelta; let newEnd dragStartRange.end - indexDelta; if (newStart 0) { newStart 0; newEnd newStart count; } if (newEnd totalCount) { newEnd totalCount; newStart newEnd - count; } visibleRange.start newStart; visibleRange.end newEnd; render(); }); document.addEventListener(mouseup, () { dragStart null; });注意鼠标向右拖dx 0时indexDelta为正newStart变小区间向历史方向移动这样图表的可视内容会跟随鼠标向右移动符合直觉。事件监听挂在document上而不是canvas上是为了防止鼠标快速移动时拖出画布后丢失事件这是一个很常见的坑。4.3 十字光标像素坐标反查K线索引与tooltip十字光标的本质是“像素坐标反算数据索引”。悬停时不重算整个数据集只反查当前索引对应的K线然后绘制十字线和标签。function pixelToIndex(clientX) { const rect canvas.getBoundingClientRect(); const plotLeft padding.left candleWidth / 2; const plotWidth rect.width - padding.left - padding.right - candleWidth; const ratio (clientX - rect.left - plotLeft) / plotWidth; const idx Math.round(visibleRange.start ratio * (visibleRange.end - visibleRange.start)); return Math.max(0, Math.min(totalCount - 1, idx)); }function drawCrosshair(ctx, idx, mouseY, width, height) { const point mappedPoints[idx]; if (!point) return; ctx.strokeStyle rgba(120, 120, 120, 0.6); ctx.lineWidth 1; ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(point.x, padding.top); ctx.lineTo(point.x, height - padding.bottom); ctx.stroke(); ctx.beginPath(); ctx.moveTo(padding.left, mouseY); ctx.lineTo(width - padding.right, mouseY); ctx.stroke(); ctx.setLineDash([]); }绘制十字光标要在render()主流程完成之后再叠加否则会被蜡烛和均线覆盖。setLineDash画完虚线后要恢复空数组不然影响后续绘制。这个模块里最耗时间的操作是mousemove事件里的连续渲染性能问题和解决方案放在第五章和第六章展开。5. 常见问题排查DPR、数据缺口与性能卡顿的处理经验Canvas K线图踩坑翻车的地方非常集中。这一章把我实际处理过的四个高频问题按“现象、原因、解决”记录一下你可以当作排查手册来用。5.1 线条发虚、字体发糊设备像素比没对齐现象同样一段绘制代码在普通屏上清晰在Retina屏上边缘模糊蜡烛实体边缘像被水洇过一样。原因Canvas的width和height属性如果等于CSS像素尺寸在高DPR屏幕上会用低分辨率缓冲区拉伸导致每个逻辑像素对应多个物理像素时采样不均。解决在渲染入口统一执行setupCanvas里的DPR逻辑。注意三个细节第一是用Math.round处理乘法结果避免小数像素第二是必须先设置canvas.width再调用getContext顺序不能反第三是每次重设尺寸后都要重新setTransform(dpr, 0, 0, dpr, 0, 0)否则坐标系错乱。5.2 缩放后K线位置跳变锚点公式或者浮点精度出了问题现象鼠标放在某根K线上滚动滚轮K线没有固定住而是向左或向右偏移几个身位越缩漂移越明显。原因最常见的有两种。一是锚点索引在计算过程中被整数化了比如先用Math.round(anchorIndex)再算newStart导致锚点本身偏移二是除以了错误的plotWidth没有扣除左右留白鼠标距离绘图区左边越远误差越大。解决锚点索引保持浮点数只在最后算newStart和newEnd时取整。plotWidth用rect.width - padding.left - padding.right - candleWidth。另一个经验是每轮缩放后打印visibleRange肉眼看出数据区间漂移时检查锚点公式和宽度定义通常一两行就能定位。5.3 数据量大时拖拽卡顿全量重绘加上事件风暴现象K线数据超过5000根后拖拽或缩放明显掉帧鼠标移动都跟不上。原因每次mousemove触发一次完整的render()而render()里又对全部数据做了映射、均线计算和绘制。这个成本是数据量的线性函数事件频率和数据量叠加后直接卡死主线程。解决第一步是把visibleRange内的数据切片只绘制可视部分第二步是给渲染包一层requestAnimationFrame合并同一帧内的多次事件触发。这两个优化的代码在第六章单独展开。第三步是拖拽期间的均线可以只算可视区间的不用重算全量。5.4 周末休市日期缺口K线宽度不一致或出现大段空白现象用时间戳按比例映射X坐标时周末两天没有K线图表中间出现一大块空白平时几小时没交易时也会这样。原因按真实时间戳等比缩放X轴时间间隔自然反映在像素间隔上休市时段就是空白。这在不间断的加密市场里没问题但在股票、期货这种有休市规则的场景里非常难看。解决默认按索引等分X坐标只跟K线序号有关跟time无关。时间信息只用在tooltip里展示。如果必须展示真实时间间隔可以单独在X轴刻度上用time格式化但绘图阶段始终用序号。这个取舍在渲染性能和视觉一致性上都更合理。5.5 tooltip位置飘移未考虑Canvas CSS缩放现象页面缩放或容器是百分比宽度时鼠标悬停对应的K线和tooltip位置对不上鼠标越靠右偏得越厉害。原因e.clientX是视口坐标Canvas元素实际显示的尺寸可能因为CSS缩放和getBoundingClientRect得到的逻辑尺寸不一致直接用clientX映射到Canvas内部坐标就错了。解决所有像素坐标换算都以getBoundingClientRect()为基准不要用canvas.offsetLeft这种相对量。封装一个getCanvasPoint(clientX, clientY)函数内部统一做clientX - rect.left然后交给pixelToIndex。只要这个入口统一后续所有交互都不会再犯这个问题。6. 进阶技巧按可视区间切片绘图万级K线保持60帧前面提到数据量大时卡顿真正的解法是让渲染复杂度只和可视K线数量挂钩而不是和总数据量挂钩。这一步做好几万根K线也能保持流畅。核心思想是visibleRange之内的数据才参与映射和绘制范围外的直接裁掉。在缩放、平移、初始渲染时都调用同一个入口先切片再绘图function getVisibleData(data, range) { const start Math.max(0, range.start); const end Math.min(data.length, range.end); return data.slice(start, end); }这里的slice返回的是引用不会复制数据但会将范围外的元素过滤掉后续的mapKLineToPixels、drawCandles、computeMA都只需要处理这个切片。切片后要注意均线计算需要切片之前的数据才能算出可视区前几根K线的完整值。我一般会让切片往前多取period根function getVisibleDataWithBufferedMA(data, range, period 20) { const start Math.max(0, range.start - period 1); const end Math.min(data.length, range.end); return data.slice(start, end); }可视区外的period根数据参与均线计算但不参与蜡烛绘制绘制时从range.start处开始这样可视区第一根K线的MA值是准确的不会出现均线在左边缘突然断头的问题。事件合并是另一个关键优化。滚轮和鼠标移动在一帧内可能触发几次甚至十几次每次都同步走完整渲染肯定浪费。用requestAnimationFrame把渲染合并到下一帧let rendering false; function requestRender() { if (rendering) return; rendering true; requestAnimationFrame(() { try { render(); } finally { rendering false; } }); }所有交互事件里把直接调用render()改成调用requestRender()高频事件就被自然削峰了。如果渲染里还有大量字符串格式化或颜色计算可以再套一层“变更标记脏检查”比如只标记isDirty truerequestAnimationFrame里判断脏标记再决定要不要重绘。最后说一个我自己的习惯约束。早期我总把优化放在功能全部做完之后结果每次都是等到数据量上来才回头重构代价比一开始就设计分层要大得多。自从那次把K线图在真机上拖成幻灯片之后我每次做Canvas可视化都会强制走一遍顺序先定数据模型和坐标映射处理DPR再写交互最后才做切片渲染和事件合并。这套源码包含完整的渲染循环和事件绑定你拿到后改一下数据源就能直接跑通整个流程。希望帮到你。本文还有配套的精品资源点击获取