拓冰建站拓冰建站
首页 / 资讯中心 / 正文

用JavaScript手写K线图:Canvas实现数据可视化与交互的完整指南

简介面向前端开发者与金融图表需求场景分享一套由纯JavaScript实现的K线图交互方案基于H5 Canvas完成绘制无需后端与额外配置双击kline.html即可在浏览器中直接运行。资源重点解决移动端行情图的交互体验问题支持左右滑动、手势缩放、长按显示十字光标适用于股票、期货、数字货币等实时走势的快速原型搭建或嵌入现有项目。压缩包共3个文件包含2个JS与1个HTML其中kline.js为核心绘图与交互逻辑hammer-min.js负责统一处理移动端触摸手势HTML文件为可直接运行的演示页面包体仅11KB轻量易读。资源已有3013人学习下载注释与模块划分清晰尤其是手势监听集中在kline.js底部的bindListener方法内便于替换或扩展事件方案适合具备一定前端基础的开发者学习Canvas绘图、事件封装与移动端适配技巧。 先说结论用纯 JavaScript 实现 K 线图没有你想象中那么难而且一旦自己写过一次你会突然看明白所有现成图表库那些密密麻麻的配置项到底在干什么。我是在做一个小型量化回测工具时产生这个念头的——项目本身只需要展示几千根 K 线和几个均线指标但翻遍现有图表库要么体积太大、要么定制成本极高为几个简单功能引入上百 KB 的依赖实在不划算。于是决定用原生 Canvas 从零写一套组件过程中踩了不少坑也总结出了一些可复用的套路。这篇文章会围绕“用 JavaScript 实现 K 线图”这条主线把我在实际项目里的完整思路拆给你看从数据模型怎么设计、坐标系怎么换算、蜡烛图和均线怎么绘制到缩放拖拽交互、十字光标提示、性能优化的具体做法最后再分享几个没人提醒过我的深坑。无论你是前端开发者、量化爱好者还是想给个人博客加一个行情组件的独立开发者这篇文章应该都能让你少走几个弯路。1. 先想清楚为什么自己动手写而不是直接用现有图表库在动笔写代码之前我花了大概一个晚上认真对比了 ECharts、Chart.js、Lightweight Charts 这些库。后面两个其实是做 K 线图很成熟的方案尤其是 TradingView 出品的 Lightweight Charts性能和功能都很强。那为什么还要自己写因为我的需求比较“刁钻”需要自定义一套交易信号标识逻辑需要把回测的买卖点直接画在 K 线图上还要支持自定义指标算法。用现成库的同学应该懂当产品的个性化需求多到一定程度你花在“读懂库的配置体系”和“绕过库的限制”上的时间早就超过了自己写一个的时间。另外还有一个技术考量K 线图本身的核心逻辑非常固定无非是“数据 → 坐标 → 绘制”这条链路不像复杂的业务系统有大量分支。一旦你理解了坐标映射和数据裁剪这两件事剩下都是细节。这就像一个厨师学会了炒青菜的基本功后面炒什么菜都心里有底。自己实现的优势不只是“可控”和“轻量”更重要的是运行时性能。我的场景里传输的数据量不大但需要频繁重绘每秒钟都可能刷新最新价只绘制可视区域内 K 线的做法性能和响应体验都很理想。1.1 需求拆解画一个 K 线图本质上是哪几件事我当时把需求拆成了四个核心子问题你写代码之前最好也这样做一轮数据结构从接口拿到原始 OHLC开盘、最高、最低、收盘数据之后用什么结构存储、怎么预处理才能让后续绘制和交互都顺手坐标映射真实世界的价格和时间如何映射到 Canvas 的像素坐标这是整个组件的地基。绘制过程蜡烛图、均线、成交量、十字光标这些视觉元素分别怎么画绘制顺序是什么才能避免互相遮挡交互反馈滚轮缩放、鼠标拖拽平移、鼠标移动查看数值这些交互事件如何触发重绘、如何保证操作符合直觉你把这四件事分别想清楚再往里面填代码思路会非常顺畅。我见过不少人一上来就写drawCandle函数结果画着画着发现坐标系统全乱了最后推翻重来就是因为少了“先设计数据层和坐标层”这一步。1.2 现有开源方案的技术盲区这不是说现成库不好而是你用了别人封装好的东西往往会把底层的“数据裁剪”“坐标转换”“绘制算法”当成黑盒。举个例子很多库默认会把你传入的几千根 K 线全量渲染如果你的数据量大到几十万根图表直接就卡了。而真正好的 K 线图实现几乎都有一个“只画看得见的部分”的机制——代码里把可视区域之外的数据过滤掉只用几行代码就解决了性能隐患。这种底层逻辑你不自己动手写一次很难有切身体会。2. 数据模型的建立与预处理很多绘制问题的根源都在这里K 线图的原生数据模型其实很简单每根 K 线就是一个对象。实战中我习惯用数组来存储并且字段名尽量保持和常见交易所接口一致方便对接各种数据源// 单根 K 线数据结构 const candle { time: 1609459200000, // 时间戳单位毫秒 open: 100.5, // 开盘价 high: 108.2, // 最高价 low: 98.1, // 最低价 close: 105.6, // 收盘价 volume: 238476 // 成交量 };这里有一个很多人容易忽略的预处理步骤按时间升序排序。接口偶尔会乱序返回数据如果不排序画出来的 K 线顺序会错乱。我在排序时直接用时间戳相减简单可靠const klineData rawData.sort((a, b) a.time - b.time);接下来说存储。由于我们需要频繁访问“第几根到第几根”的数据以及进行区间切片我建议直接把数据放在普通数组里并且维护一个当前可视窗口的起始和结束索引。这样在缩放和拖拽时只需要修改这两个索引不用动原始数据。窗口索引就是 K 线图交互里最核心的状态let viewport { startIndex: 0, // 可视区域第一根 K 线的索引 endIndex: 499, // 可视区域最后一根 K 线的索引 maxCount: 500 // 当前可视区域内最多展示多少根 };有的方案会直接用“数据总量窗口大小”去计算但维护 startIndex 和 endIndex 最直观。你的endIndex - startIndex 1得到了可视范围内的 K 线数量这个数字在坐标映射时特别有用。2.1 时间轴的刻度索引K 线图里最重要的一组坐标K 线图的 X 轴和普通折线图有个很大的区别每个刻度点代表一根 K 线而不是一个等距的时间单位。比如日线数据周末没有 K 线如果你用实际时间间隔毫秒做横轴坐标就会出现周末那一大段空白图表非常难看。所以我采用的是“索引即坐标”的策略。第i根 K 线在 X 轴上的位置等于startIndex i然后把这个逻辑索引线性映射到 Canvas 宽度上。这一步理解透了后面画蜡烛图、十字光标、拖拽平移都会非常顺。2.2 价格域的处理最高价和最低价不能直接拿来用价格坐标的映射相对简单就是取可视区域内所有 K 线的最高价和最低价然后线性映射到 Canvas 的高度范围。但这里藏着一个坑如果你直接把最大值和最小值映射到 Canvas 上下边缘价格最高点和最低点会紧贴边框视觉上非常压抑。我的做法是在上下各加上 5% 的留白空间。这样一个简单的处理图表的呼吸感立刻就不一样了。计算方式是这样const rawMax Math.max(...visibleData.map(d d.high)); const rawMin Math.min(...visibleData.map(d d.low)); const padding 0.05; // 上下留白比例 const maxPrice rawMax (rawMax - rawMin) * padding; const minPrice rawMin - (rawMax - rawMin) * padding;注意如果你后面要画均线MA最高价和最低价的统计范围必须把均线的值也纳入。因为均线的值有可能超出当前所有 K 线的最高低价范围不处理的话均线会被画出绘图区。我一开始就忽略了这个问题导致均线在顶部被裁掉一截排查了好一阵。2.3 归一化坐标转换函数整个绘图的精髓数据处理之后最好把“数据坐标转屏幕坐标”的逻辑封装成一个小函数。我在项目里是用了一个全局的priceScale和timeScale对象来管理两套映射这样所有绘制元素蜡烛、均线、背景网格、十字光标都统一通过它们去做坐标换算// 时间轴映射 function timeToX(index, chart) { const { width, padding } chart; const count chart.viewport.endIndex - chart.viewport.startIndex 1; const slotWidth (width - padding.left - padding.right) / count; return padding.left (index - chart.viewport.startIndex) * slotWidth slotWidth / 2; } // 价格映射 function priceToY(price, chart) { const { height, padding, maxPrice, minPrice } chart; const chartHeight height - padding.top - padding.bottom; const ratio (maxPrice - price) / (maxPrice - minPrice); return padding.top ratio * chartHeight; }这里的slotWidth表示每根 K 线分配到的横向宽度。K 线的实体宽度一般取slotWidth * 0.7左右剩下的给两侧留出间隔。这个比例调起来很微妙太小了 K 线挤在一起太大了又显得稀稀拉拉。0.6 到 0.8 是一个比较舒服的范围。3. 蜡烛图的 Canvas 绘制OHLC 四个数字如何变成一根标准 K 线一切准备就绪终于到了画图环节。Canvas 绘图的第一步是正确设置高分辨率适配否则在高 DPI 屏幕上画出来的图会发虚。我用了经典的三行代码处理const dpr window.devicePixelRatio || 1; canvas.width canvas.offsetWidth * dpr; canvas.height canvas.offsetHeight * dpr; ctx.scale(dpr, dpr);后续所有绘图逻辑里的坐标都直接使用 CSS 像素不需要再关心物理像素。这个细节在笔记本和 4K 屏上效果差异非常大建议一开始就处理好。3.1 单根 K 线的绘制细节实体、影线和颜色规则绘制蜡烛图的核心逻辑不复杂就是先画实体矩形再画上下影线。这里有一个容易被新手忽视的点绘制顺序。因为影线实际要穿过实体如果你先画影线再画实体影线在实体范围内的部分会被实体颜色覆盖这样影线看起来就是从实体上下沿伸出去的更干净。实际顺序应该是先画影线再画实体。具体绘制时我这样处理function drawCandle(ctx, x, candle, slotWidth, chart) { const { open, high, low, close } candle; const isRising close open; // 收阳线 const color isRising ? #e74c3c : #2ecc71; // 国内常红涨绿跌 const bodyWidth Math.max(1, slotWidth * 0.7); const xLeft x - bodyWidth / 2; const yHigh priceToY(high, chart); const yLow priceToY(low, chart); const yOpen priceToY(open, chart); const yClose priceToY(close, chart); ctx.strokeStyle color; ctx.lineWidth 1; // 先画影线 ctx.beginPath(); ctx.moveTo(x, yHigh); ctx.lineTo(x, yLow); ctx.stroke(); // 再画实体 const bodyTop Math.min(yOpen, yClose); const bodyHeight Math.max(1, Math.abs(yOpen - yClose)); ctx.fillStyle color; ctx.fillRect(xLeft, bodyTop, bodyWidth, bodyHeight); }这里有一个国内与国外配色习惯的差异国内行情软件通常“红涨绿跌”欧美正好相反。我在代码里默认用了红涨绿跌但组件提供了upColor和downColor的配置项让使用方自行决定这是一个很实用的扩展点。注意实体高度为 0 的情况如果开盘价等于收盘价bodyHeight会变成 0直接画矩形会看不见实体。这时候我用Math.max(1, ...)强制给一个最小高度同时用bodyTop - bodyHeight的方式保证矩形方向正确。这个细微的边界处理在看一字板行情时非常重要。3.2 均线MA的绘制与数据边界问题均线的绘制思路和蜡烛图完全不同它需要先把一个周期内所有均线点算出来然后连成一条折线。算均线的逻辑很简单但有一个性能和正确性兼顾的问题如果你对每一根 K 线都重复计算前 N 根的平均值时间复杂度是 O(n*N)数据量大的时候会有明显卡顿。我用的方法是滑动窗口累加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].close; if (i period) sum - data[i - period].close; if (i period - 1) result[i] sum / period; } return result; }这个累加算法的原理很好理解移动窗口时加一个新值、减一个旧值始终维护这个周期的总和最后除以来个数就得到均值。复杂度从 O(n*N) 降到 O(n)在几千根 K 线、多个均线周期的场景下体感差距非常明显。绘制折线的部分要注意跳过null值前 N-1 根没有均线值并且用ctx.beginPath()在新的线段开始时重置路径function drawMA(ctx, data, maArray, period, color, chart) { ctx.strokeStyle color; ctx.lineWidth 1.5; ctx.beginPath(); let started false; for (let i chart.viewport.startIndex; i chart.viewport.endIndex; i) { const value maArray[i]; if (value null) continue; const x timeToX(i, chart); const y priceToY(value, chart); if (!started) { ctx.moveTo(x, y); started true; } else { ctx.lineTo(x, y); } } ctx.stroke(); }为了性能我只计算可视范围内的数据点而不是全量遍历原始数组。因为图表的本质逻辑是“只看得到窗口内的数据”窗口之外的绘制既浪费 CPU 也没有意义。3.3 背景网格给你一个整洁不打眼的坐标系网格线的价值是提供价格参考系但它不应该喧宾夺主。我的实现是每 50 像素画一条横线和一条竖线颜色用非常浅的灰色线的宽度设为 1。为了让网格线恰好落在像素点上、避免模糊我对y做了Math.round(y) 0.5处理。这个 0.5 偏移是 Canvas 绘制 1 像素纯色线的经典技巧否则画出来的线会又宽又虚。很多人在网上找的 K 线图Demo里网格线颜色很深、虚线密密麻麻看起来非常“程序员审美”。我建议网格线统一用#f0f0f0这一类颜色保证它在屏幕上是“背景”而不是“前景”。4. 坐标轴与刻度让价格和时间信息从画布里“浮现”出来如果只画蜡烛和均线图表的可读性等于零。你的眼睛需要坐标轴刻度来定位“现在大约多少钱”“这是哪一天”。坐标轴绘制是 K 线图里视觉细节最密集的地方。4.1 价格轴从 minPrice 到 maxPrice 的均匀分割价格轴的做法是选取一个合适的步长间隔让整个高度范围内均匀分布 58 个刻度。步长的计算有个通用算法叫“nice number”我会直接给出实现它能保证步长是 1、2、5、10 这类“好数”乘上 10 的整数次幂function niceStep(rawStep) { const pow Math.pow(10, Math.floor(Math.log10(rawStep))); const fraction rawStep / pow; let niceFraction; if (fraction 1.5) niceFraction 1; else if (fraction 3) niceFraction 2; else if (fraction 7) niceFraction 5; else niceFraction 10; return niceFraction * pow; }然后从maxPrice向下取整到步长的整数倍逐个画出刻度线。每个刻度还要在右侧标注价格数字并且把文字对齐方式设置为canvas.textAlign left让它从右边留白处开始绘制。我在实际绘制时发现如果你把所有价格数字直接贴在 K 线主图右侧当图表宽度较小、或者 K 线接近最右侧时数字会遮挡最后一根 K 线。所以价格轴我单独留出axisWidth比如 60 像素把价格数字放在这部分区域。这样主绘图区和价格标签区就干净地分开了。4.2 时间轴聪明的刻度显示策略时间轴的刻度相对难做因为不是每个时刻都值得标注。我的策略是计算出大约每隔 80120 像素显示一个时间标签。然后从这个标签密度反推“每隔几根 K 线显示一次时间标签”。比如可视区域内一共 150 根 K 线图表宽度 900 像素那么大约每 6 根显示一个标签比较合适。const labelStep Math.ceil(100 / slotWidth); // 大约每100px一个标签然后在每个标签位置用Date对象格式化时间。这里要注意格式化函数不要每次都 new 一个 Date而是在循环里复用同一个实例const date new Date(); // 在循环内 date.setTime(klineData[i].time); const label ${date.getMonth() 1}/${date.getDate()};标签文字的锚点设置为middle水平居中在对应的刻度位置。X 方向同样要后移半个标签宽度避免第一个和最后一个标签溢出画布。这个细节不处理两端标签会“出血”非常难看。4.3 十字光标提高读图效率的小组件十字光标可以说是所有 K 线图交互组件里投入产出比最高的一项。它由一条竖直虚线、一条水平虚线和一个价格/时间信息面板组成。实现思路分成两步第一步在mousemove事件里处理鼠标坐标并通过timeToX的反向计算找到鼠标位置对应的 K 线索引function xToIndex(mouseX, chart) { const { padding, viewport } chart; const count viewport.endIndex - viewport.startIndex 1; const slotWidth (chart.width - padding.left - padding.right) / count; const offset mouseX - padding.left; const index Math.floor(offset / slotWidth) viewport.startIndex; return Math.max(viewport.startIndex, Math.min(viewport.endIndex, index)); }第二步在这个索引对应的 X 坐标、鼠标移动的 Y 坐标处绘制虚线和信息卡片。信息卡片里展示 OHLC 数值。为了视觉可读我用一个半透明背景的矩形作为信息面板右上角或者跟随鼠标位置防止卡片超出画布。十字光标最核心的体验在于对齐和反馈。我随手加了一个效果当鼠标停留在某个 K 线附近时那个 K 线实体稍微微高亮一下。这个微交互只用了两行代码但整个组件的质感一下子就不一样了。5. 交互缩放与拖拽从“能看”到“能用”的关键一跃绘图做到这里你已经有了一个“静态 K 线图”。但要让它真正可用必须加上两个核心交互滚轮缩放和拖拽平移。如果没有这两个交互你只能看固定的一屏数据而 K 线图的精髓恰恰在于“放大看细节缩小看趋势”。5.1 滚轮缩放以鼠标锚点为中心视觉上非常自然缩放的核心逻辑是滚动滚轮时改变 viewport 里的数据可视范围同时尽量让鼠标所指的那根 K 线在缩放前后保持位置不变。很多初学者做缩放时只是简单地把可视范围扩大或缩小结果发现鼠标指的那根 K 线在缩放后“跑掉”了体验很不自然。我的实现是这样的先算出鼠标当前指向的数据索引anchorIndex再围绕这个锚点调整可视区域的大小。下面是核心代码canvas.addEventListener(wheel, (e) { e.preventDefault(); const rect canvas.getBoundingClientRect(); const mouseX e.clientX - rect.left; const anchorIndex xToIndex(mouseX, chart); const oldCount chart.viewport.endIndex - chart.viewport.startIndex 1; const factor e.deltaY 0 ? 1.15 : 1 / 1.15; const newCount Math.round(oldCount * factor); const maxCount data.length; const clampedCount Math.min(maxCount, Math.max(10, newCount)); // 以 anchorIndex 为锚点重新计算 startIndex 和 endIndex let targetStart anchorIndex - (anchorIndex - chart.viewport.startIndex) * (clampedCount / oldCount); chart.viewport.endIndex Math.round(targetStart clampedCount - 1); chart.viewport.startIndex Math.round(targetStart); // 边界约束 if (chart.viewport.startIndex 0) { chart.viewport.startIndex 0; chart.viewport.endIndex clampedCount - 1; } if (chart.viewport.endIndex data.length) { chart.viewport.endIndex data.length - 1; chart.viewport.startIndex data.length - clampedCount; } drawAll(); }, { passive: false });这里clampedCount设置了下限 10 根、上限总数据量。下限是为了避免把图放大到只剩一两根 K 线那样蜡烛会宽得离谱上限是为了防止缩小时超出原始数据范围露出空白区域。这两个约束缺一不可。5.2 拖拽平移按下、移动、松开的经典三件套拖拽平移的逻辑比缩放更简单。核心就是在mousedown时记录鼠标的起始 X 坐标和当时的startIndex在mousemove里计算鼠标在 X 方向的位移再换算成移动了多少根 K 线let dragState null; canvas.addEventListener(mousedown, (e) { dragState { startX: e.clientX, startIndex: chart.viewport.startIndex }; }); window.addEventListener(mousemove, (e) { if (!dragState) return; const dx e.clientX - dragState.startX; const deltaIndex Math.round(dx / slotWidth); let newStart dragState.startIndex - deltaIndex; const maxStart data.length - (chart.viewport.endIndex - chart.viewport.startIndex 1); newStart Math.max(0, Math.min(maxStart, newStart)); chart.viewport.startIndex newStart; chart.viewport.endIndex newStart (chart.viewport.endIndex - chart.viewport.startIndex 1) - 1; drawAll(); }); window.addEventListener(mouseup, () { dragState null; });注意这里我把mousemove和mouseup事件挂在了window上而不是canvas上。原因在于如果你拖得很快鼠标经常“跑出”画布范围如果事件只在 canvas 上监听就会出现拖到一半图表卡住的情况。挂在 window 上能保证整个拖拽过程是平滑的。平移方向这里非常容易搞反。直觉上“往右拖应该是看更早的数据还是更晚的数据”我的推导是鼠标往右移时dx为正newStart减小可视区域往更早的 K 线方向移动。你仔细想一下交易软件的行为往右拖看到的是左侧更早的行情。这个方向我用了一个小技巧来验证——先把数据末尾设置成“最新数据在右侧”那往右拖必然要显示左侧的更早数据逻辑就顺了。5.3 触摸屏适配一个你可能忽略的高频场景现在很多用户是在平板上看行情图的。鼠标的wheel、mousedown在触摸屏上全部无效所以我在代码里还做了移动端touch事件的支持。思路是监听touchstart、touchmove、touchend并处理event.touches[0]的位置。额外再做了双指缩放的支持原理跟上文锚点缩放完全一致只是锚点变成两个手指的中点。双指缩放基础代码如下canvas.addEventListener(touchstart, (e) { if (e.touches.length 1) { dragState { startX: e.touches[0].clientX, startIndex: chart.viewport.startIndex }; } else if (e.touches.length 2) { const dist Math.abs(e.touches[0].clientX - e.touches[1].clientX); pinchState { startDist: dist, oldCount: chart.viewport.endIndex - chart.viewport.startIndex 1 }; } });双指缩放的手势识别很简单记录两只手指的横向距离作为初始距离然后在移动过程中不断计算新距离与初始距离的比值再把这个比值换算成数据范围的变化。触摸端这十来行代码能把你的组件从“仅桌面端可用”提升到“移动端也顺手”值得加。6. 性能优化与那些不查文档根本不知道的坑K 线图组件在几千根数据时性能问题还不明显但一旦上到上万根甚至几十万根绘制效率立刻就成了用户体验的分水岭。这一节我会分享几个极其实用的性能手段还有我实际踩过的坑。6.1 只绘制可视区间的数据最重要的性能底线任何图表库性能的核心都是“可见性裁剪”。我的drawAll函数里所有的循环都是这样的模式for (let i chart.viewport.startIndex; i chart.viewport.endIndex; i) { // 绘制第 i 根K线 }无论数据总量是一万还是十万实际绘制的只是窗口内的几十到几百根 K 线。这是所有后续性能手段的前提。另外由于canvas绘制没有“增量更新”一说每次数据变化都要清屏重绘。为了不让重绘产生闪烁我在绘制前调用ctx.clearRect把画布清理干净然后所有元素一次性绘制完成。这样既避免了残留重影也保证了画面的一致性。6.2 requestAnimationFrame 节流别再让图表疯狂重绘了如果你直接把滚动事件里的重绘逻辑同步执行浏览器会以远超屏幕刷新率的速度疯狂绘制反而导致卡顿。正确做法是用requestAnimationFrame做节流把同一帧内的多次请求合并成一次绘制let rafId null; function requestDraw() { if (rafId) return; rafId requestAnimationFrame(() { drawAll(); rafId null; }); }然后在所有事件回调里把drawAll()替换成requestDraw()。这段代码看似简单但带来的性能提升非常实在——尤其是在鼠标快速移动或滚轮连续滚动时它保证了绘制次数与屏幕刷新率对齐图表丝滑流畅。6.3 高清屏适配被问烂了却依然有人踩坑的高频问题前面提过Canvas 绘制文字和线条在 Retina 屏上最容易发虚。因为一个 CSS 像素实际上由多个物理像素组成Canvas 默认按 CSS 像素绘制字体和线条就出现了明显的锯齿。修复方法就是canvas.width clientWidth * devicePixelRatio然后ctx.scale(devicePixelRatio, devicePixelRatio)。这个方案底层是把 Canvas 的物理像素提高数倍再用缩放矩阵让所有绘图坐标自动落在物理像素上。我检查了很多网上的 K 线图 Demo有些框架甚至没做这个适配放到 4K 屏上一看全是“毛边”。6.4 我踩过的几个坑坐标取整、边界判断、与外部框架的兼容以下是排除掉大半天才解决的几个问题分享出来省得你重走弯路坑一坐标必须取整。带小数的坐标在 Canvas 上绘制 1px 线条时会出现半透明混色看起来像线条变粗了。解决办法是Math.round()后再绘制必要时加 0.5 偏移。尤其是网格线不处理非常明显。坑二缩放时 endIndex 可能越界。你的endIndex必须时刻满足不超过data.length - 1反过来startIndex不小于 0。我写了两个边界约束函数并把它们放在所有可能改动 viewport 的地方统一调用function clampViewport() { const count chart.viewport.endIndex - chart.viewport.startIndex 1; if (chart.viewport.startIndex 0) { chart.viewport.startIndex 0; chart.viewport.endIndex count - 1; } if (chart.viewport.endIndex data.length) { chart.viewport.endIndex data.length - 1; chart.viewport.startIndex data.length - count; } }坑三Canvas 在外部框架里的兼容问题。如果你在 Vue 或 React 项目里使用建议把组件封装成一个独立的 class对外暴露setData()、setViewport()、destroy()几个方法。内部的事件监听要在destroy()时全部移除否则组件切换后内存泄漏、事件重复触发排查起来会很痛苦。我在 Vue 项目里实践时直接把这个图表对象绑定到组件的onMounted生命周期里卸载时调用destroy()整个体验非常干净。我的实现是框架无关的完全是一套原生 JavaScript所以你在 Vue、React 甚至 Angular 里都能直接复用不需要做任何框架层面的适配。7. 组件设计如何把它封装成一个可复用的 K 线图模块绘图和交互都做完之后最后一步是把零散代码整理成一个干净、好用的模块。我的组件采用了一个很朴素的类设计思路对外只暴露极少的配置项避免使用方陷入“配置地狱”。class KLineChart { constructor(canvas, options {}) { this.canvas canvas; this.ctx canvas.getContext(2d); this.data []; this.maPeriods options.maPeriods || [5, 10, 20]; this.upColor options.upColor || #e74c3c; this.downColor options.downColor || #2ecc71; this.viewport { startIndex: 0, endIndex: 0, maxCount: options.maxCount || 200 }; this.initEvents(); } setData(rawData) { this.data rawData.sort((a, b) a.time - b.time); this.viewport.endIndex Math.min(this.data.length - 1, this.viewport.maxCount - 1); this.draw(); } destroy() { // 移除所有事件 } }使用方式非常简洁const chart new KLineChart(document.getElementById(kline), { maPeriods: [5, 10, 20], upColor: #e74c3c, downColor: #2ecc71 }); chart.setData(fetchedData);如果你在 Vue 项目里使用在onMounted中初始化在onUnmounted中调用destroy()就可以了。配合上数据接口一个像模像样的行情图表组件就落地了。后面有时间我打算在这个基础组件上继续扩展成交量副图、MACD/KDJ 指标叠加、以及区间选择工具。其实思路都是一样的先想清楚数据层再处理坐标映射最后用 Canvas 把元素画出来。如果你也正在研究 K 线图实现希望这篇分享能给你一些参考。代码中没有用到任何有门槛的设计模式每一行都很直白照着敲一遍应该就能跑起来。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门