
简介这份资源面向需要在网页或移动端展示行情走势的前端开发者与数据可视化学习者提供一套基于原生JavaScript与H5 Canvas的K线图实现方案可解决图表绘制、交互操作等常见需求。压缩包共3个文件包含2个js脚本与1个html页面整体仅11KB其中kline.js承担K线绘制与手势逻辑hammer-min.js负责触摸事件支持kline.html为可直接运行的演示入口。目前已有3019人学习下载说明该方案在同类实现中具备一定参考价值。代码无需任何配置双击页面即可查看效果支持左右滑动、手势缩放以及长按显示十字光标移动端触摸事件集中在kline.js底部的bindListener方法内便于替换或二次改造。读者可借此理解Canvas绘制K线的坐标计算、数据映射与交互绑定思路并在此基础上扩展指标、样式或接入真实行情数据。1. 从一根蜡烛说起为什么前端要自己画K线图去年帮一个做量化策略的朋友改他的交易面板他甩过来一个需求把后端返回的 OHLC 数据画成能缩放、能拖拽、能叠加均线的K线图。我第一反应是找现成的图表库试了三四个要么体积太大要么定制均线的时候被配置项绕晕要么在移动端缩放卡得没法看。最后干脆自己用 Canvas 撸了一个代码不到八百行打包出来十几KB缩放拖拽丝滑均线想怎么算就怎么算。这就是今天要拆的这个 JavaScript 实现K线图的项目——它不依赖任何图表库纯原生 JS Canvas把K线的绘制、坐标映射、交互缩放、十字光标、均线计算全流程走了一遍。适合谁适合那些被重型图表库折磨过、想搞懂K线底层绘制逻辑、或者需要在嵌入式面板里塞一个轻量行情图的前端开发者。js 写K线图这件事难点从来不在画矩形而在坐标系换算和交互状态管理这个项目把这两块讲透了。2. 项目结构与技术选型为什么是 Canvas 而不是 SVG2.1 目录结构与模块划分拿到源码先别急着跑花两分钟把目录扫一遍后面改起来心里有底。这个项目的结构很扁平没有花里胡哨的构建配置就是一个能直接丢进浏览器跑的原生工程kline-chart/ ├── index.html # 入口页面一个 canvas 容器 控制按钮 ├── src/ │ ├── main.js # 初始化入口绑定事件、启动渲染循环 │ ├── chart.js # 核心类 KLineChart管理状态与渲染调度 │ ├── renderer.js # 绘制层蜡烛、坐标轴、网格、十字光标 │ ├── scale.js # 坐标映射数据索引 - 像素坐标 │ ├── indicator.js # 指标计算MA、EMA、成交量 │ └── interaction.js # 交互滚轮缩放、拖拽平移、hover 十字线 ├── data/ │ └── sample.json # 模拟的 OHLC 数据约 500 根 └── style.css # 画布容器与按钮样式这个划分方式很务实。chart.js只管状态和调度不碰具体绘制renderer.js只负责把数据画出来不关心数据怎么来的scale.js是纯函数模块输入数据范围和画布尺寸输出像素映射关系。这种职责分离的好处是你想换一套指标只动indicator.js想改交互手感只动interaction.js不会牵一发动全身。2.2 技术选型Canvas 2D 的取舍为什么不用 SVG 或者 ECharts这是我在拆这个项目时问自己的第一个问题。答案藏在数据量里500 根K线每根蜡烛由影线和实体两部分组成加上均线、网格、坐标轴文字DOM 节点轻松破两千。SVG 在这种量级下每次缩放拖拽都要重排重绘移动端直接掉帧。Canvas 是一块画布所有东西画上去就是像素重绘成本只跟画布尺寸有关跟数据量关系不大。项目里用的是 Canvas 2D context没有上 WebGL。有人会问 500 根够不够其实 2D context 在 2000 根以内都能保持 60fps再往上才需要考虑分层渲染或者 WebGL。这个项目的定位是轻量行情面板2D 完全够用。另一个取舍是没做虚拟滚动——所有数据一次性画完靠scale.js里的可视区间裁剪来控制实际绘制范围。这个思路比虚拟滚动简单得多效果也不差。提示如果你的数据超过 5000 根建议在renderer.js里加一层可视区间判断只绘制当前视口内的蜡烛能省掉大量无效绘制。2.3 数据格式约定项目对数据格式有明确约定每一条记录包含时间戳和四个价格// data/sample.json 中的单条记录结构 { t: 1704067200000, // 时间戳毫秒 o: 42150.5, // 开盘价 open h: 42380.0, // 最高价 high l: 42010.2, // 最低价 low c: 42290.8, // 收盘价 close v: 128.45 // 成交量 volume }字段名用了单字母缩写这是行情数据的常见做法能显著减小 JSON 体积。t是毫秒时间戳渲染时按需格式化成MM-DD HH:mm。v是成交量单独画在底部区域。这里有个容易翻车的点如果后端返回的是秒级时间戳直接new Date(t)会得到 1970 年必须在main.js里统一乘 1000。我一般会在数据入口处加一个 normalize 函数把时间戳、价格精度、字段缺失都处理掉避免脏数据流进渲染层。3. 核心渲染链路从数据到蜡烛的坐标映射3.1 坐标映射模块 scale.jsK线图最核心的数学问题就一个怎么把第 i 根蜡烛的价格 p 映射到画布上的像素坐标 (x, y)。这个项目把映射逻辑单独抽成了scale.js里面维护两个线性映射索引到 X 轴价格到 Y 轴。// scale.js —— 坐标映射核心 export class Scale { constructor(canvasWidth, canvasHeight, padding) { this.w canvasWidth; this.h canvasHeight; this.pad padding; // { top, right, bottom, left } } // 根据可视区间和价格范围计算 X/Y 映射参数 update(viewStart, viewEnd, priceMin, priceMax) { this.viewStart viewStart; // 可视区间起始索引 this.viewEnd viewEnd; // 可视区间结束索引 this.priceMin priceMin; this.priceMax priceMax; // 绘图区实际宽高去掉内边距 this.plotW this.w - this.pad.left - this.pad.right; this.plotH this.h - this.pad.top - this.pad.bottom; // 每根蜡烛占用的像素宽度 this.candleW this.plotW / (viewEnd - viewStart); // 价格每单位对应的像素高度 this.priceScale this.plotH / (priceMax - priceMin); } // 索引 - X 像素取蜡烛中心 xOf(index) { return this.pad.left (index - this.viewStart 0.5) * this.candleW; } // 价格 - Y 像素注意 Y 轴向下价格越高 Y 越小 yOf(price) { return this.pad.top (this.priceMax - price) * this.priceScale; } // 反向X 像素 - 索引用于 hover 定位 indexOf(x) { return Math.floor((x - this.pad.left) / this.candleW) this.viewStart; } }这段代码是整个项目的骨架。update方法在每次数据范围或视口变化时被调用重新计算candleW和priceScale两个关键比例。xOf里加0.5是为了让蜡烛画在格子中间而不是左边缘这个细节不做的话蜡烛会挤在一起。yOf里用priceMax - price是因为 Canvas 的 Y 轴向下增长而价格向上增长必须翻转。indexOf是反向映射十字光标和 tooltip 都靠它定位当前是哪根蜡烛。参数说明padding一般设{ top: 20, right: 60, bottom: 30, left: 10 }右边留宽一点放价格刻度底部留给时间轴。viewStart和viewEnd是浮点数也没关系缩放时会产生小数索引xOf照样能算出正确像素位置这也是为什么缩放能做到平滑而不是一格一格跳。3.2 蜡烛绘制 renderer.js有了映射关系画蜡烛就是纯体力活。但这里有几个容易忽略的细节涨跌颜色、影线宽度、实体最小高度。// renderer.js —— 绘制单根蜡烛 export function drawCandle(ctx, scale, candle, index) { const x scale.xOf(index); const halfW Math.max(1, scale.candleW * 0.35); // 实体半宽最小 1px const isUp candle.c candle.o; const color isUp ? #26a69a : #ef5350; // 涨绿跌红 const yHigh scale.yOf(candle.h); const yLow scale.yOf(candle.l); const yOpen scale.yOf(candle.o); const yClose scale.yOf(candle.c); ctx.strokeStyle color; ctx.fillStyle color; ctx.lineWidth 1; // 1. 画影线最高到最低的一条竖线 ctx.beginPath(); ctx.moveTo(x, yHigh); ctx.lineTo(x, yLow); ctx.stroke(); // 2. 画实体开盘到收盘的矩形 const bodyTop Math.min(yOpen, yClose); const bodyH Math.max(1, Math.abs(yClose - yOpen)); // 最小 1px避免十字星消失 ctx.fillRect(x - halfW, bodyTop, halfW * 2, bodyH); }逻辑说明先算 X 和实体半宽halfW用candleW * 0.35是为了蜡烛之间留空隙视觉上更透气。涨跌判断用c o平盘归为涨。影线是一条从最高到最低的竖线实体是开盘收盘之间的矩形。bodyH用Math.max(1, ...)是关键——当开盘等于收盘时十字星高度为 0 就看不见了强制 1px 保证可见。这个坑我在实盘面板上踩过某天行情横盘一堆十字星全消失排查半天才发现是高度为 0。参数说明颜色值可以抽成配置项方便换主题。halfW的最小值 1 是防止数据量极大时蜡烛宽度小于 1px 导致实体消失。如果要做空心蜡烛涨空心跌实心把fillRect换成strokeRect即可但要注意线宽会占用额外像素。3.3 渲染调度与分层项目没有用requestAnimationFrame循环常驻渲染而是采用「脏标记 按需重绘」的策略。chart.js里维护一个dirty标志任何状态变化缩放、平移、数据更新都置为 true然后在requestAnimationFrame回调里检查只有脏了才重绘。// chart.js —— 渲染调度 export class KLineChart { constructor(canvas, data) { this.ctx canvas.getContext(2d); this.data data; this.dirty true; this.scale new Scale(canvas.width, canvas.height, PADDING); this.viewStart Math.max(0, data.length - 100); // 默认显示最后 100 根 this.viewEnd data.length; this.loop(); } markDirty() { this.dirty true; } loop () { if (this.dirty) { this.render(); this.dirty false; } requestAnimationFrame(this.loop); }; render() { const { ctx } this; ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); // 计算可视区间内的价格极值 const [min, max] this.priceRange(); this.scale.update(this.viewStart, this.viewEnd, min, max); this.drawGrid(); this.drawCandles(); this.drawIndicators(); this.drawAxis(); this.drawCrosshair(); } }这个调度方式比常驻渲染省电得多尤其在移动端。priceRange只遍历可视区间内的数据不扫全量这是性能关键。render里的绘制顺序有讲究网格在最底层蜡烛盖在网格上指标线盖在蜡烛上坐标轴和十字光标在最顶层。顺序错了会出现指标线被蜡烛遮挡的尴尬情况。注意clearRect必须用画布实际尺寸不能用 CSS 尺寸。如果画布做了高清屏适配canvas.width cssWidth * dpr这里也要同步乘 dpr否则会模糊。4. 交互与指标缩放、拖拽、均线怎么接4.1 滚轮缩放与拖拽平移交互模块interaction.js处理三类事件滚轮缩放、鼠标拖拽、hover 十字线。缩放的核心是改变viewStart和viewEnd同时保证缩放中心在鼠标位置这样手感才自然。// interaction.js —— 滚轮缩放 export function bindWheel(canvas, chart) { canvas.addEventListener(wheel, (e) { e.preventDefault(); const rect canvas.getBoundingClientRect(); const mouseX e.clientX - rect.left; // 鼠标位置对应的数据索引作为缩放锚点 const anchorIndex chart.scale.indexOf(mouseX); const factor e.deltaY 0 ? 1.1 : 0.9; // 向下滚缩小向上滚放大 const range chart.viewEnd - chart.viewStart; const newRange Math.max(20, Math.min(chart.data.length, range * factor)); // 保持锚点索引在视口中的相对位置不变 const ratio (anchorIndex - chart.viewStart) / range; chart.viewStart anchorIndex - ratio * newRange; chart.viewEnd chart.viewStart newRange; // 边界钳制防止拖出数据范围 if (chart.viewStart 0) { chart.viewEnd - chart.viewStart; chart.viewStart 0; } if (chart.viewEnd chart.data.length) { chart.viewStart - chart.viewEnd - chart.data.length; chart.viewEnd chart.data.length; } chart.markDirty(); }, { passive: false }); }逻辑说明anchorIndex是鼠标下的数据索引缩放时这个索引在视口中的相对位置ratio保持不变这样鼠标指着哪根蜡烛缩放后那根蜡烛还在鼠标下面。factor用 1.1 和 0.9 而不是 1.2 和 0.8是为了让缩放步进更细腻。newRange限制在 20 到数据总量之间防止缩到只剩一两根或者缩到看不见。边界钳制那段是血泪经验——不钳制的话用户能把图表拖到空白区域看起来像卡死了。拖拽平移更简单记录mousedown时的startX和viewStartmousemove时算出像素偏移量除以candleW换算成索引偏移更新视口即可。关键是mousemove里要判断是否处于拖拽状态否则会和 hover 十字线冲突。4.2 均线指标计算与绘制indicator.js里实现了 MA 和 EMA 两个最常用的均线。MA 是简单移动平均EMA 是指数移动平均后者对近期价格权重更高反应更灵敏。// indicator.js —— 均线计算 export function calcMA(data, period) { const result new Array(data.length).fill(null); let sum 0; for (let i 0; i data.length; i) { sum data[i].c; if (i period) sum - data[i - period].c; if (i period - 1) result[i] sum / period; } return result; } export function calcEMA(data, period) { const result new Array(data.length).fill(null); const k 2 / (period 1); let prev null; for (let i 0; i data.length; i) { if (i period - 1) continue; if (prev null) { // 首值用前 period 根的简单平均初始化 let sum 0; for (let j i - period 1; j i; j) sum data[j].c; prev sum / period; } else { prev data[i].c * k prev * (1 - k); } result[i] prev; } return result; }calcMA用了滑动窗口sum在循环里加一个减一个时间复杂度 O(n)比每次重新求和的 O(n*period) 快得多。calcEMA的首值用简单平均初始化这是常见做法避免从第一根就开始递推导致前期偏差过大。两个函数都返回与数据等长的数组前period-1个位置是null绘制时跳过。绘制均线时遍历结果数组遇到null就moveTo断开遇到有效值就lineTo连线。多条均线用不同颜色区分一般 MA5 白色、MA10 黄色、MA20 紫色。这里有个性能优化点均线只在数据变化时重算一次缓存起来缩放平移时直接复用不要每帧重算。4.3 十字光标与 tooltip十字光标是行情图的基本素养。mousemove时记录鼠标位置在render最后画两条虚线一条水平一条垂直垂直线的 X 对齐到最近蜡烛的中心水平线的 Y 跟随鼠标。同时在右上角或鼠标附近画一个 tooltip显示当前蜡烛的 OHLC 和成交量。// renderer.js —— 十字光标 export function drawCrosshair(ctx, scale, chart, mouse) { if (!mouse) return; const index scale.indexOf(mouse.x); if (index chart.viewStart || index chart.viewEnd) return; const candle chart.data[index]; const cx scale.xOf(index); const cy scale.yOf(candle.c); ctx.save(); ctx.setLineDash([4, 4]); ctx.strokeStyle #888; ctx.lineWidth 1; // 垂直线对齐蜡烛中心 ctx.beginPath(); ctx.moveTo(cx, scale.pad.top); ctx.lineTo(cx, scale.h - scale.pad.bottom); ctx.stroke(); // 水平线跟随鼠标 Y ctx.beginPath(); ctx.moveTo(scale.pad.left, mouse.y); ctx.lineTo(scale.w - scale.pad.right, mouse.y); ctx.stroke(); ctx.restore(); // 右侧价格标签 const price scale.priceMax - (mouse.y - scale.pad.top) / scale.priceScale; drawPriceTag(ctx, scale, price, mouse.y); }setLineDash画虚线save/restore保证不影响后续绘制。垂直线用cx而不是mouse.x这样光标会吸附到蜡烛中心视觉上更整齐。右侧价格标签根据鼠标 Y 反算出价格画一个小背景框加文字。tooltip 的内容用index从数据里取注意边界判断index超出可视区间就不画。5. 避坑与常见问题排查5.1 蜡烛挤成一团或间距过大现象数据量变化时蜡烛要么重叠在一起看不清要么稀疏得像孤岛。原因candleW是根据可视区间动态算的如果viewStart和viewEnd是整数且区间很小candleW会很大区间很大时又很小。解决在scale.update里对candleW做上下限约束比如Math.max(2, Math.min(30, candleW))超出范围时通过调整视口区间来适配而不是让蜡烛宽度无限变化。5.2 高清屏下线条模糊现象在 Retina 屏或缩放 125% 的显示器上蜡烛边缘和文字发虚。原因Canvas 的width/height是逻辑像素没有乘以devicePixelRatio。解决初始化时设置canvas.width cssWidth * dprcanvas.height cssHeight * dpr然后ctx.scale(dpr, dpr)之后所有绘制坐标仍用逻辑像素。注意clearRect要用缩放后的尺寸或者干脆用ctx.save/restore包住缩放。5.3 缩放时视口越界导致空白现象快速滚动滚轮缩小图表突然变成一片空白怎么拖都回不来。原因viewStart变成负数或viewEnd超过数据长度priceRange遍历空区间返回[Infinity, -Infinity]映射计算出 NaN。解决在缩放和平移的每一步都做边界钳制确保viewStart 0且viewEnd data.length。另外在priceRange里加保护如果区间为空就返回上一帧的极值。5.4 均线在数据更新后错位现象实时推送新数据时均线整体偏移一根或几根。原因均线数组是缓存的新数据到来后没有重新计算或者计算时用了旧的数据长度。解决数据更新时清空均线缓存重新调用calcMA/calcEMA。如果数据是追加式的可以只计算新增部分但要注意滑动窗口的sum需要从正确的位置继续。我一般会在数据层加一个版本号版本变了就全量重算简单可靠。5.5 移动端触摸事件与滚轮冲突现象在手机上双指缩放没反应或者页面跟着滚动了。原因只绑定了wheel事件移动端不触发touchmove没有preventDefault被浏览器默认滚动拦截。解决同时绑定touchstart/touchmove/touchend用e.touches.length判断单指拖拽还是双指缩放双指时计算两指距离变化比例作为缩放因子。所有触摸事件都要{ passive: false }并调用preventDefault。6. 二次开发把静态图改成实时行情面板这个项目目前是静态数据渲染但真实场景往往需要接 WebSocket 实时推送。改造思路不复杂核心是处理好数据追加和视口跟随。第一步在main.js里把fetch(data/sample.json)换成 WebSocket 连接// main.js —— 实时数据接入 const ws new WebSocket(wss://your-feed.example.com/kline); const chart new KLineChart(canvas, []); ws.onmessage (evt) { const msg JSON.parse(evt.data); if (msg.type snapshot) { // 首次全量快照 chart.setData(msg.data); } else if (msg.type update) { // 增量更新更新最后一根或追加新根 chart.upsertCandle(msg.data); } };upsertCandle需要判断时间戳如果和最后一根相同就替换当前K线未收盘如果更大就追加并触发均线重算。这里有个关键决策追加新数据时视口要不要自动右移我的做法是判断用户是否处于「跟随模式」——如果视口右边界原本就贴着数据末尾就自动跟随如果用户手动拖到了历史区域就不打扰他。这个判断用一个布尔值isFollowing记录拖拽时置 false双击或点「回到最新」按钮时置 true。第二步处理渲染性能。实时推送频率高的时候比如每秒多笔不能每来一笔就重绘。常见做法是攒批用一个缓冲区收集 100ms 内的更新定时器触发时一次性合并到数据里再重绘。这样既保证实时感又不会把 CPU 跑满。第三步加一个「加载更多历史」的机制。当用户拖到最左边时触发 HTTP 请求拉取更早的数据插入到数组头部。注意插入后所有索引都会偏移viewStart/viewEnd要同步加上插入的数量否则视口会跳。这个偏移量我一般叫offset在scale里统一处理所有索引计算都加上它。改造点原实现实时版做法数据来源静态 JSONWebSocket HTTP 分页更新方式一次性 setDataupsert 批量合并视口策略固定显示末尾跟随模式 手动锁定均线缓存初始化算一次数据版本号驱动重算性能保护无100ms 攒批 可视区间裁剪最后说个我踩过的坑实时更新时如果新价格超出了当前视口的priceMaxY 轴不会自动扩展蜡烛会画到画布外面。解决方法是每次重算priceRange时留 5% 的上下余量并且当最新价接近边缘时平滑调整priceMin/priceMax不要突变否则视觉上会跳。从那以后我每次接实时数据源都强制走一遍「极值余量 平滑过渡」的检查再也没出现过蜡烛跑出画布的情况。希望帮到你。本文还有配套的精品资源点击获取