尧图精选

从零手写Canvas绘图组件:柱状图与饼图的实现与性能优化

🕒 发布时间:2026/9/20 16:38:54 📁 来源:尧图网络
简介JS绘图组件资源包面向需要快速实现数据可视化的Web前端开发者重点解决柱状图、饼图等常见图表的绘制与交互问题适用于数据分析、报告展示、运营看板等场景。压缩包共51个文件以HTML示例页面、JS脚本、PDF说明文档及XML配置为主其中HTML用于演示图表效果JS提供核心绘图逻辑PDF和txt帮助理解用法整体体积仅286KB。组件在柱状图中支持数据绑定、自定义样式、悬停提示、多轴比较饼图则提供扇区划分、标签指针、动画过渡和切片交互等能力examples目录下设bar-charts、pie-charts、line-charts三类示例并提供basic-blue-theme、custom-red-theme、custom-rainbow-theme等多种主题模板便于对照学习与二次开发源码结构清晰开发者可深入理解图表组件的实现原理进而扩展出更适合业务需求的自定义图表。JScharts.pdf与readme.txt详细介绍了API调用方式和常见问题可帮助用户快速搭建图表环境目前已有170人学习下载适合Web前端初学者以及需要轻量集成图表的开发者参考。1. 为什么自研绘图组件而不是直接引 ECharts先说结论如果你的项目只需要柱状图、饼图这类基础图表引一个几十 KB 的绘图组件确实方便但当你需要定制交互、精简体积、或者图表要嵌进一个已经不小的前端工程里时自研一个轻量组件往往是更合适的路。我是在维护一个后台管理系统时产生这个想法的。系统里原本只是几个统计页面后来需求越加越多销量柱状图、渠道占比饼图、趋势折线图每个都要求配色和交互跟设计稿完全一致。ECharts 当然能实现但为了几个基础图表引入整套库产物体积上不划算而且默认样式和设计稿差异明显每次都要写大量配置去覆盖主题和样式时间成本都耗在“调配置”而不是“做功能”上。进一步想核心需求其实很朴素给我一份数据渲染出柱状图和饼图支持鼠标悬停显示数值、支持简单的动画过渡、忠实还原设计稿的配色。这些能力用原生 JavaScript 配合 Canvas 画出来代码量完全在可控范围内。我给自己定了一个边界不做复杂的 3D 效果、不做大数据量下的性能竞赛只把一维分类对比和占比构成这两类需求做到顺手、稳定、可扩展。事实证明这个选择是对的。组件写完以后不仅用在了这个后台系统里后来另一个活动页需要一种“每个柱子底部带光晕、点击跳转不同链接”的特殊效果直接在这个基础组件上扩展就完成了根本不用去翻图表库的文档研究如何 hack。所以这篇博文的受众很明确想了解图表底层渲染原理的人、需要在项目里自定义图表样式的前端工程师、或者单纯觉得“看懂了 ECharts 配置但不知道图是怎么画出来”的同学。我会把柱状图和饼图的实现思路、关键代码、踩过的坑逐步拆开讲。2. 坐标系抽象所有图表的地基别看这层薄薄的代码开始画柱子之前必须先解决一个基础问题数据值和画布像素之间怎么换算。大多数人不重视这一层直接写死在绘图函数里导致换一组数据就出乱码。我花了两天时间设计这层抽象整个组件的稳定性就是从这层开始的。2.1 数据到画布的映射函数一次定义终身复用柱状图、饼图看似不同但它们的底层都需要做一件事把数据坐标系中的某个值映射到 Canvas 像素坐标系中的某个点。比如你的数据里有 120 和 5000 两个值画布高度 400 像素那 120 应该画多高5000 又画多高这个换算如果不抽象出来每次画图都要重新推导。我定义了一个最小化的映射工具核心就两个函数// 线性映射把 domain [d0, d1] 中的值 v映射到 range [r0, r1] function linearScale(domain, range) { const [d0, d1] domain; const [r0, r1] range; const ratio (r1 - r0) / (d1 - d0 || 1); return (v) r0 (v - d0) * ratio; } // 用于分类轴把下标 i 映射到某个分类的起始位置和宽度 function bandScale(categories, range) { const [r0, r1] range; const step (r1 - r0) / categories.length; return { step, getPosition: (i) r0 i * step, getBandWidth: () step * 0.6, // 柱子占 band 的比例预留间距 }; }为什么一定要抽出这两个函数因为图表最核心的逻辑就是“数据和像素之间的对应关系”。如果你的图表只有一个固定数据源那确实可以直接写死但只要数据一变、容器大小一变没有这一层抽象就得重写整个绘图函数。我实际写的时候还加了一个niceTicks函数用于把坐标轴刻度取整成 0、5、10、50 这类人类友好的值而不是出现 3.3333 这种让人摸不着头脑的刻度。具体算法也不复杂先算理想刻度数再算出原始步长通过Math.pow(10, Math.floor(Math.log10(rawStep)))拿到基准量级然后找最接近的 1/2/5 倍数即可。2.2 从坐标轴开始搭骨架否则画完柱子发现没地方放很多初学者画柱状图时一上来就在画布中央画矩形画完之后发现四周没有留白、坐标轴没地方画、标签和柱子挤在一起。我的做法是先把画布的可用区域计算出来再在可用区域内绘制所有图形。具体来说我在组件初始化时预留了四个方向的边距const margin { top: 40, right: 30, bottom: 60, left: 60 }; const innerWidth canvas.width - margin.left - margin.right; const innerHeight canvas.height - margin.top - margin.bottom;之后所有坐标系换算都以innerWidth和innerHeight为基准。这样做的好处是图表主体和坐标轴天然分离后续如果要增加图例也只需要在顶层调整 margin完全不需要改动绘图核心逻辑。坐标轴的绘制我分了两层静态骨架层和动态数据层。静态骨架层只画坐标轴线、刻度和网格线动态数据层每次数据更新时重绘。这样“网格线在柱子下面、标签在柱子上面”这种层级关系就很好控制。这一层抽象完成之后柱状图和饼图就能共享同一套尺寸计算逻辑了。接下来进入正题。3. 柱状图的实现从数据到像素的换算逻辑柱状图是所有图表里最容易上手、但也最容易画得“看起来不对劲”的一种。很多文章会说“就是画几个矩形嘛”但实际上柱子宽度、间距、坐标轴刻度、数值标签这几个环节都有讲究画出来好不好看往往就差在这些细节上。3.1 柱宽与间距为什么不能直接拿像素当宽度第一个容易踩的坑就是柱子宽度的计算方式。如果你直接把柱子宽度写死为 20 像素那么数据分类少的时候柱子看起来细得可怜分类多的时候又会挤在一起。正确的做法是基于 bandScale 动态计算。function drawBars(data, config) { const { getPosition, getBandWidth } bandScale( data.map((d) d.label), [0, innerWidth] ); const barWidth getBandWidth() * 0.7; // 柱子占 band 的 70%留下 30% 作为间距 const yScale linearScale([0, maxValue], [innerHeight, 0]); data.forEach((item, index) { const x getPosition(index) (getBandWidth() - barWidth) / 2; const y yScale(item.value); const height innerHeight - y; ctx.fillStyle item.color || defaultColor; ctx.fillRect(x, y, barWidth, height); }); }注意这里的maxValue不一定是数据的最大值因为如果最大值恰好是 800而你希望刻度能到 1000那柱子最高的应该留出余量。我通常会把maxValue向上取整到“好看的刻度”比如数据最大值 763那么 y 轴的 domain 设为[0, 800]而不是[0, 763]。这样柱子不会顶到画布顶端观感会很不一样。柱子的圆角也是值得处理的细节。纯直角矩形在某些设计风格下会显得过于生硬。我提供了一个barRadius配置项用ctx.roundRect或者在低版本浏览器里手动画圆角路径。这里有个小坑如果柱子高度小于圆角半径的两倍绘制出的图形会非常怪异。所以我在绘制前做了保护逻辑const radius Math.min(config.barRadius, height / 2);保证任何数据下都不会出现绘制异常。3.2 让柱子“长出来”动画与 hover 的交互细节静态的柱状图做出来之后下一步就是加动画和鼠标交互。动画的核心逻辑非常简单记录一个从 0 到 1 的动画进度progress每一帧用requestAnimationFrame更新它然后把柱子高度的计算改成height * progress。关键代码如下let progress 0; function animate() { progress 0.06; if (progress 1) { progress 1; } drawBars(); if (progress 1) { requestAnimationFrame(animate); } } requestAnimationFrame(animate);为什么用requestAnimationFrame而不是setInterval因为前者会在浏览器下一次重绘之前执行回调帧率和屏幕刷新率同步动画更流畅而且页面切到后台时它会自动暂停节省性能。每次动画只改变 progress不涉及其他状态变更这是最简单可控的做法。hover 效果相对复杂一些。你需要监听 Canvas 元素的mousemove事件然后判断鼠标位置落在哪个柱子的范围内进而改变状态并重绘。判定逻辑其实就是反向的坐标换算用鼠标 x 减去左侧 margin再除以 bandWidth向下取整得到柱子下标用鼠标 y 判断是否在柱子范围内。canvas.addEventListener(mousemove, (e) { const rect canvas.getBoundingClientRect(); const mouseX e.clientX - rect.left - margin.left; const mouseY e.clientY - rect.top - margin.top; const index Math.floor(mouseX / bandWidth.step); // 边界判断鼠标必须落在该柱子的 band 内并且 y 坐标在柱子范围内 if (index 0 index data.length) { const x bandWidth.getPosition(index) (bandWidth.step - barWidth) / 2; if (mouseX x mouseX x barWidth mouseY yScale(data[index].value)) { hoverIndex index; } else { hoverIndex -1; } } else { hoverIndex -1; } drawBars(); });这里有个重要的细节Canvas 本身不像 DOM 那样有“鼠标事件命中元素”的概念你必须手动做几何计算。所以事件逻辑要尽量独立不要在事件回调里写具体的绘图代码只更新状态然后统一调drawBars()。这样无论动画还是 hover都只触发一次重绘避免状态不一致。hover 时一般还会顺手显示一个 tooltip。我实现了一个简单方案在 Canvas 内部直接绘制一个半透明的浮层矩形而不是创建额外的 DOM 元素。这样做的好处是不用担心 DOM 层级和定位问题缺点是 tooltip 里的文字如果太长可能会超出画布边界。所以我在绘制 tooltip 之前会先测量文本宽度如果x boxWidth canvas.width就把 tooltip 放到鼠标左侧。3.3 柱状图的常见坑负值、数据极差、刻度线错位单独把坑拿出来说是因为这些我全都实际踩过而且搜索引擎上直接给出的方案往往有遗漏。第一个坑是负值。数据里出现负值时y 轴零点不再在底部而是在某个中间位置。很多简化版教程完全没有考虑这种情况。我的处理方式是先扫描数据确认是否有负值若存在则把 domain 设为[minValue, maxValue]并用linearScale映射到[innerHeight, 0]。此时某个柱子的 y 坐标和高度都要分情况计算负值柱子从零点向下延伸正值柱子从零点向上延伸。第二个坑是数据极差特别大。比如有一个值 10000其他值都是几十那柱子高度会严重不平衡。这时候我通常会提供两种选择一是保持真实比例但提示用户数据不适合用柱状图二是在组件内部提供logScale选项用对数刻度来缓和差异。对数刻度的实现也很简单就是在映射前对数据取Math.log1p但坐标轴的刻度标签也要相应转换否则用户看不懂。第三个坑是刻度线和柱子的对齐。很多人画完柱子发现刻度线没有和柱子中心对齐看起来非常难受。原因在于分类轴的刻度标签应该落在 band 的中心点而不是 band 的起始位置。在绘制坐标轴刻度时x 坐标应该用getPosition(i) bandWidth.step / 2这样标签才会显示在两个刻度线的中央。4. 饼图的实现角度计算与中心点偏移数学底子必须补上饼图和柱状图完全不一样它不涉及坐标系映射而是把数据占比转换成扇形的角度。实现起来看似简单但有几个细节稍不注意就会出错比如角度含义、中心点偏移、小扇区的标签重叠。4.1 Canvas 绘制扇形的正确姿势从弧度到路径闭合Canvas 原生 API 里有一个ctx.arc(x, y, radius, startAngle, endAngle)方法可以画弧线。只画弧线你是看到不扇形的必须配合ctx.lineTo或ctx.closePath把起点连回圆心才能形成一个封闭的扇形区域。更省事的写法是把ctx.beginPath()之后先moveTo到圆心然后arc最后closePath。这样调用ctx.fill()时闭合路径会自动完成。绘制扇形的关键代码非常简单function drawSlice(cx, cy, radius, startAngle, endAngle, color) { ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, radius, startAngle, endAngle); ctx.closePath(); ctx.fillStyle color; ctx.fill(); }但我在这里必须提醒一个新手极易踩的大坑Canvas 的arc角度单位是弧度不是角度。而且是顺时针方向从 0 度开始对应的是 x 轴正方向时钟的三点钟位置。如果你把 90 度当成 Math.PI / 2 来写没问题但如果你直接把数值 90 传给arc那它其实会画到约 14 个弧度之外去。所以饼图的每个扇形角度必须把百分比转换成弧度const angle (value / total) * Math.PI * 2;如果不需要从 12 点钟方向开始绘制还可以给起始角度减一个Math.PI / 2。这是个常见的设计需求直接旋转起始角度即可。4.2 标签线与视觉中点偏移饼图“看起来居中”的秘诀饼图做完基本切片后我发现一个问题所有扇区都集中在圆心视觉上非常呆板而且一个小区块的标签根本放不下。于是做了两个增强hover 时扇区向外偏移以及扇区之间加分隔线。hover 偏移的逻辑很简单计算出扇形的中心角度然后在中心角度方向上把圆心的 x、y 坐标偏移一段距离。注意这里偏移的是圆心而不是扇形的路径数据。让圆心移动 10 像素整个扇形自然就跟着移动了视觉上就像“弹出来”一样。const midAngle startAngle angle / 2; const offsetX Math.cos(midAngle) * (hoverIndex index ? 10 : 0); const offsetY Math.sin(midAngle) * (hoverIndex index ? 10 : 0); drawSlice(cx offsetX, cy offsetY, radius, startAngle, endAngle, color);另一个问题是视觉中点的偏移。饼图如果只靠扇形本身区分占比当相邻两个扇区的颜色明度接近时用户很难分辨边界。我在每个扇形之间加了 2 像素左右的白色间隔线也就是把每个扇区的起始角度加一个微小的间隙。实际效果是每个扇区清楚分离高端感一下子就出来了。标签绘制是饼图里最麻烦的环节。如果扇区太小标签和引导线全挤在一起。我的策略是当扇区角度大于 15 度时在扇形内部显示数值文本小于 15 度时只画一条从扇形边缘引出到外部的引导线并把标签文本放在引导线另一端的固定位置。同时为了解决上下边缘标签互相遮挡的问题我还会在布局完成后检查两两标签的边界是否重叠如果重叠则强制把它们各自往外推一点。4.3 饼图的常见坑0 值、全 0 数据和中心文字叠加饼图的坑和柱状图完全不同我遇到的第一个坑就是数据里有 0 值。0 值扇区的角度是 0按理说不需要绘制。但如果你不做过滤ctx.arc的 startAngle 和 endAngle 相等某些浏览器仍然画出一个发丝粗细的线条看起来像一条视觉噪点。我一开始忽略了这个细节结果截图评审时被设计师一眼指出。我的解决方案是在计算扇区时就过滤掉 0 值数据并且数组长度变化后颜色分配要保持稳定不能让过滤前和过滤后的颜色对应关系乱掉。因此我在过滤前先给每个数据项分配好颜色索引过滤时保留这个索引。第二个坑是全 0 数据。此时total为 0计算占比会得到 NaN。我的处理是如果total 0直接把整圆画成浅灰色并绘制一条“暂无数据”文本。这种边缘情况在真实项目中经常遇到如果不处理页面就是一个巨大的空白用户根本不知道怎么回事。第三个坑是中心文字。很多需求要求在饼图中心显示“总数”或“占比”之类的文字。这本来很简单但如果你在绘制扇形之后直接绘制文字文字会被下一次重绘覆盖如果你在绘制扇形之前绘制文字又会背景遮住。正确的方式是把文字绘制放在所有扇形绘制完成之后并且不要忘了ctx.textAlign center和ctx.textBaseline middle否则文字位置怎么调都不对。5. 组件设计配置驱动、事件解耦以及如何留好扩展点如果只是为了画两个图前面几章已经足够。但要做成一个组件还要考虑外部怎么用它、后续怎么加新图表类型。这一章讲讲我如何设计对外 API 和内部架构。5.1 配置优先而非代码优先一份 config 描述整张图我参考了很多成熟图表库的 API 设计思路结论是外部接口应该以配置对象为主而不是暴露一堆 setter 方法。使用方只需要描述他想要什么图、数据是什么、长什么样剩余的都交给组件内部去处理。const barChart new Chart(canvasElement, { type: bar, data: [ { label: 一月, value: 320 }, { label: 二月, value: 450 }, ], options: { barColor: [#5470c6, #91cc75], showValue: true, animation: true, margin: { top: 40, right: 30, bottom: 60, left: 60 }, }, }); barChart.render();这份配置里包含了图表类型、数据和视觉选项三个维度。把配置和实现分开的好处非常明显团队里的其他同事不需要阅读组件源码只要看一眼配置示例就能上手而且配置驱动天然支持从服务端下发 JSON 来动态生成图表这在低代码平台里极其有用。5.2 事件解耦与扩展机制插件式注册未来图表类型事件方面我做了内部事件和外部事件的分层。内部事件如mousemove、mouseleave、click统一由组件实例内部监听外部订阅通过一个简单的on(eventName, handler)暴露。内部事件触发时把当前 hover 的数据项作为参数传给外部监听器。// 内部事件处理 canvas.addEventListener(mousemove, (e) { const hitData getHitData(e); if (hitData) { this.emit(itemHover, hitData); } }); // 对外暴露 on(eventName, handler) { if (!this._listeners[eventName]) { this._listeners[eventName] []; } this._listeners[eventName].push(handler); }为什么把事件独立出来因为实际使用中用户点了某根柱子之后要跳转页面、要更新其他图表、要弹出一个详情弹窗这些都是业务逻辑不应该写进绘图组件里。组件只负责“告诉业务方点的是哪个数据项”业务方自己决定怎么做。扩展新图表类型的机制我采用了一个简单的注册表。核心是定义统一的draw(data, region, config)接口每种图表类型实现自己的绘制逻辑然后通过Chart.register(line, LineChart)把实现注册进去。type字段里写了什么就查表找到对应的绘制器。const registry {}; class Chart { static register(type, drawClass) { registry[type] drawClass; } render() { const Drawer registry[this.type]; const drawerInstance new Drawer(this.ctx, this.data, this.options); drawerInstance.draw(); } }这套设计写完之后我后续扩展折线图时只花了半天时间实现一个LineDrawer注册进去然后跑一遍原来的测试用例即可。6. 性能优化与渲染细节从“能画”到“画得流畅”组件能正常画柱状图和饼图之后真正影响体验的是渲染细节。我在实际使用中发现几个问题逐一做了优化。6.1 按需重绘与单一绘制入口避免重绘风暴最容易出现的问题是“状态一多到处都在调绘制函数”。比如颜色改了调一次重绘、数据改了调一次重绘、鼠标移动又调一次重绘。后来我统一封装成“内部有一个 dirty 标记任何状态变更只把标记置为 true然后通过requestAnimationFrame统一调度重绘”。这个模式叫“单一绘制入口”。_dirty false; _rafId null; requestRender() { if (this._dirty) return; this._dirty true; this._rafId requestAnimationFrame(() { this._dirty false; this._doRender(); }); }好处是多次修改状态只触发一次真实绘制性能成倍提升。实际使用中发现这个模式对动画、hover 这种高频事件尤其有效。6.2 高分屏适配文字模糊不是设备问题是 DPI 问题不少人在 canvas 上画图后发图片截图出来总是模糊尤其在高分屏上特别明显。原因在于 CSS 像素和设备物理像素之间存在一个devicePixelRatio的缩放比。如果你只用canvas.width cssWidth那么物理屏上每一个 CSS 像素只有 2x2 甚至 3x3 的物理像素相当于用低分辨率渲染再拉伸自然发虚。解决办法也很简单让 canvas 的位图尺寸乘以devicePixelRatio然后通过 CSS 样式把显示尺寸固定为原来的大小最后在绘图前对所有坐标做缩放。const dpr window.devicePixelRatio || 1; canvas.width cssWidth * dpr; canvas.height cssHeight * dpr; canvas.style.width cssWidth px; canvas.style.height cssHeight px; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);这里有一个极易被忽略的坑ctx.setTransform(dpr, 0, 0, dpr, 0, 0)必须在每个绘制周期的开头执行。如果你在某次绘制中调用了ctx.clearRect或者之前有ctx.save/restore操作改变了变换矩阵没重置的话后续所有坐标都会偏移。我每次_doRender的第一步永远是ctx.setTransform(dpr, 0, 0, dpr, 0, 0)然后再ctx.clearRect(0, 0, cssWidth, cssHeight)。6.3 数据量变大时Canvas 的性能边界与折中方案不是说用 Canvas 就一定能扛住大数据量。我测试过当柱状图数据超过 1000 根柱子时每次重绘都要循环 1000 多次并绘制对应的矩形动画帧率会明显下降。如果这时候还有 hover 事件每次 mousemove 都触发一次全量重绘帧率会掉到不可接受的程度。针对这种情况我做了两级处理。第一级是把 hover 重绘的范围限制在“变化区域附近”。比如鼠标移到第 800 根柱子上时我只需要擦除并重绘第 800 根柱子附近的图形而不是清空整块画布。这个优化可以通过保存每个数据项的渲染区域坐标来实现命中 hover 时用ctx.clearRect只清那一小块。第二级是引入“降级策略”。当数据量超过一个阈值时自动关闭动画和背景网格线只保留基本柱形和数值标签保证核心信息可读性优先。这个阈值我设在 800 根柱子左右。降级后虽然少了些花哨的过渡效果但用户不会因为卡顿而反感。还有一个非常容易被忽略的细节在图表容器、窗口 resize 时需要重新读取容器尺寸更新内部区域计算并触发重绘。resize 事件不能用window.addEventListener(resize, this.render)这样直接绑定因为 resize 触发频率极高每次都重绘会卡顿。我用了一个 200ms 的防抖函数只在窗口大小变化结束后重新渲染。7. 实测效果与完整代码结构给想直接抄作业的人最后把整个组件的文件结构和最小可用代码放出来给需要快速落地的人一条捷径。组件目录结构如下src/ index.js // 入口Chart 类 scale.js // 线性映射和 band 映射 utils.js // 颜色分配、刻度取整、防抖等工具函数 renderers/ bar.js // 柱状图绘制器 pie.js // 饼图绘制器 events.js // 事件订阅与触发index.js里是核心类其他文件围绕它提供服务。这个结构不算复杂但足够支撑后续扩展。一个完整的最小柱状图 Demo 代码如下canvas idchart width600 height400/canvas script srcchart.js/script script const chart new Chart(document.getElementById(chart), { type: bar, data: [ { label: 北京, value: 320 }, { label: 上海, value: 450 }, { label: 广州, value: 280 }, { label: 深圳, value: 510 }, ], options: { showValue: true, animation: true, }, }); chart.render(); /script写到这里整个组件的核心内容基本讲完了。但如果你只是会抄代码不理解前几章讲的坐标映射和事件模型后面扩展起来仍然会碰壁。我一直认为抄作业可以但抄完一定要去想想为什么这样设计不然换个场景就会卡住。建议你把这段代码跑通之后试着加一个堆叠柱状图——你会发现只要把 bandScale 改成按比例分配高度代码几乎不用大改这就是抽象层带来的红利。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →