
简介这是一份面向Web前端开发者与数据可视化初学者的JS绘图组件学习资源围绕柱状图、饼图等常见图表的实现展开帮助读者在数据分析、报告展示与交互场景中快速把复杂数据转化为直观图形。压缩包共51个文件约286KB以27个html示例页面、12张jpg效果图、9个xml配置、1个pdf说明文档、1个txt使用指南和1个js核心脚本为主html与js便于直接运行调试pdf与txt补充配置选项和API调用方法xml与jpg则辅助理解主题样式与渲染效果。资源涵盖柱状图、饼图、折线图等多类示例并附带基础、自定义配色等多种主题方便对照学习数据绑定、样式定制、交互高亮与多轴支持等关键特性。目前已有172人学习下载适合希望快速上手图表组件、理解其目录结构与二次开发思路的开发者参考。1. 从零手写 js 绘图组件柱状图、饼图到底该怎么落地前端做数据可视化很多人第一反应是引一个几百 KB 的图表库配置项写一大坨最后发现样式改不动、打包体积还爆炸。其实柱状图、饼图这类基础图形用原生 Canvas 或 SVG 手写一个 js 绘图组件代码量远比想象中小可控性却高得多。我做过一个内部用的轻量绘图组件核心逻辑不到 400 行覆盖柱状图、饼图、折线图三种形态打包后 gzip 不到 6KB在移动端低端机上跑起来也不掉帧。这篇文章就围绕「js 绘图组件实现了柱状图、饼图等」这个方向把坐标系怎么建、数据怎么映射、Canvas 和 SVG 怎么选、参数怎么暴露、坑在哪里一步步拆开讲清楚。适合两类人一是想自己造轮子、彻底搞懂图表渲染原理的前端二是项目里只需要基础图表、不想被重型库绑架的开发者。读完你能拿到一套可复现的最小实现也能判断什么场景该自己写、什么场景老老实实引库。2. 坐标系与数据映射绘图组件的地基怎么打图表渲染的本质是把数据空间里的数值映射到屏幕空间的像素坐标。这一步没做对后面画什么都是歪的。柱状图和饼图虽然形态不同但底层都依赖同一套映射逻辑区别只在于柱状图用直角坐标系饼图用极坐标系。2.1 直角坐标系柱状图的四步映射柱状图的核心是确定绘图区plot area和值域domain。绘图区是去掉坐标轴标签、标题之后真正画柱子的矩形区域值域是数据的最小值到最大值。我一般按这四步走第一步确定画布尺寸和 padding。padding 要预留出坐标轴文字的空间否则标签会被裁掉。第二步算出绘图区的宽高。第三步把数据值线性映射到绘图区高度。第四步处理负值和零基线。// 直角坐标系映射把数据值转成像素坐标 function createScale(domainMin, domainMax, rangeMin, rangeMax) { // 防止除零所有数据相同时给一个默认跨度 const span domainMax - domainMin || 1; return function (value) { // 线性插值value 在 domain 中的比例映射到 range const ratio (value - domainMin) / span; return rangeMin ratio * (rangeMax - rangeMin); }; } // 用法数据 0~100 映射到绘图区底部 300 到顶部 20 const yScale createScale(0, 100, 300, 20); console.log(yScale(0)); // 300底部 console.log(yScale(100)); // 20顶部 console.log(yScale(50)); // 160中间这段代码的关键在于rangeMin和rangeMax的顺序。屏幕坐标 y 轴向下增长所以数据最大值对应像素最小值顶部这就是为什么传的是300, 20而不是20, 300。很多人第一次写会搞反结果柱子全部倒挂。span || 1是防御性写法当所有数据相等时避免除以零此时所有柱子高度一致视觉上也合理。参数上domainMin和domainMax不要直接用数据的最小最大值建议留 5% 到 10% 的余量否则最高柱会顶到画布边缘很难看。我一般用Math.min(...data) * 0.95和Math.max(...data) * 1.05如果数据含负值零基线要单独处理柱子从零线往上下两个方向画。2.2 极坐标系饼图的弧度与角度换算饼图没有 x/y 轴它把每个数据项映射成一段圆弧。核心公式是每项占比乘以 2π 得到弧度再累加起始角度。这里有个容易翻车的点——Canvas 的arc方法角度单位是弧度起始方向是三点钟方向顺时针为正。而人看饼图习惯从十二点钟方向开始所以起始角要减去 π/2。// 饼图角度计算把数据数组转成每项的起始角和结束角 function computePieAngles(data) { const total data.reduce((sum, v) sum v, 0); let currentAngle -Math.PI / 2; // 从十二点钟方向开始 return data.map((value) { const angle (value / total) * Math.PI * 2; // 该项占的弧度 const start currentAngle; const end currentAngle angle; currentAngle end; // 累加给下一项 return { value, start, end }; }); } // 用法 const angles computePieAngles([30, 50, 20]); angles.forEach((a, i) { console.log(第${i}项: ${a.start.toFixed(2)} - ${a.end.toFixed(2)}); });total为 0 时要单独处理直接返回空数组否则每项都是 NaN。currentAngle用闭包外的变量累加保证每段圆弧首尾相接不会出现缝隙。实际渲染时ctx.arc(cx, cy, radius, start, end)配合ctx.lineTo(cx, cy)就能画出扇形。如果要做环形图甜甜圈在arc之后再反向画一个内圆即可用ctx.arc(cx, cy, innerRadius, end, start, true)的逆时针参数收口。2.3 Canvas 还是 SVG选型要看交互需求这是绕不开的选型问题。我的经验是数据点少、需要频繁交互hover 高亮、点击跳转、CSS 动画选 SVG数据点多、追求渲染性能、不需要复杂 DOM 交互选 Canvas。维度CanvasSVG渲染方式像素位图立即模式矢量 DOM保留模式大数据量优几千点无压力差DOM 节点爆炸事件绑定需手动命中检测每个元素可直接绑定样式控制只能靠代码CSS 可控制导出清晰度需处理 devicePixelRatio天然矢量柱状图如果柱子超过 200 根我倾向 Canvas饼图通常就几项到十几项SVG 更省心hover 效果直接写 CSS 就行。如果团队里有人要改样式SVG 的门槛低得多。选型没有绝对对错关键是提前想清楚交互复杂度和数据规模。3. 用 Canvas 手写柱状图组件从构造函数到 render选好 Canvas 之后组件的结构要设计清楚。我一般用一个构造函数或者 class把配置、数据、渲染方法分开避免所有逻辑堆在一个函数里。3.1 组件骨架与配置项设计配置项要暴露哪些我的原则是影响布局和视觉的必须可配纯内部计算的不要暴露。核心配置包括画布容器、宽高、数据、颜色、padding、是否显示坐标轴、动画时长。class BarChart { constructor(options) { // 合并默认配置调用方只需传关心的项 this.config Object.assign({ width: 600, height: 400, padding: { top: 20, right: 20, bottom: 40, left: 50 }, colors: [#4e79a7, #f28e2b, #e15759], showAxis: true, animation: 300, }, options); this.data options.data || []; this.canvas document.createElement(canvas); // 处理高清屏模糊按 devicePixelRatio 放大画布 const dpr window.devicePixelRatio || 1; this.canvas.width this.config.width * dpr; this.canvas.height this.config.height * dpr; this.canvas.style.width this.config.width px; this.canvas.style.height this.config.height px; this.ctx this.canvas.getContext(2d); this.ctx.scale(dpr, dpr); // 缩放回来后续按逻辑像素画 options.container.appendChild(this.canvas); } }Object.assign做默认配置合并调用方只传data和container就能跑。devicePixelRatio那段是血泪经验——不处理的话在 Retina 屏上画出来的线条和文字全是模糊的很多人以为是字体问题其实是画布分辨率没跟上。ctx.scale(dpr, dpr)之后所有绘制代码仍然按 CSS 像素写不用到处乘 dpr。3.2 render 方法柱子、坐标轴、标签的绘制顺序绘制顺序很重要先画坐标轴和网格线再画柱子最后画标签。因为后画的会覆盖先画的标签必须在最上层。render() { const { ctx, config, data } this; const { width, height, padding, colors } config; ctx.clearRect(0, 0, width, height); // 每次重绘先清空 const plotW width - padding.left - padding.right; const plotH height - padding.top - padding.bottom; const maxVal Math.max(...data.map(d d.value)) * 1.1; // 留 10% 余量 const yScale createScale(0, maxVal, padding.top plotH, padding.top); // 1. 画坐标轴 if (config.showAxis) { ctx.strokeStyle #ccc; ctx.beginPath(); ctx.moveTo(padding.left, padding.top); ctx.lineTo(padding.left, padding.top plotH); // y 轴 ctx.lineTo(padding.left plotW, padding.top plotH); // x 轴 ctx.stroke(); } // 2. 画柱子 const barCount data.length; const slotW plotW / barCount; // 每根柱子占的槽宽 const barW slotW * 0.6; // 柱子实际宽度留 40% 间隙 data.forEach((item, i) { const x padding.left slotW * i (slotW - barW) / 2; const y yScale(item.value); const h padding.top plotH - y; ctx.fillStyle item.color || colors[i % colors.length]; ctx.fillRect(x, y, barW, h); // 3. 画数值标签 ctx.fillStyle #333; ctx.font 12px sans-serif; ctx.textAlign center; ctx.fillText(item.value, x barW / 2, y - 5); }); }slotW是每根柱子分到的水平空间barW取槽宽的 60%剩下 40% 作为间隙这个比例视觉上比较舒服柱子不会挤在一起也不会太散。yScale用的是第 2 章那个映射函数注意rangeMin传的是底部像素、rangeMax传的是顶部像素。标签画在柱子顶部上方 5px 处textAlign设为 center 让文字水平居中于柱子。3.3 动画与重绘requestAnimationFrame 的正确用法直接渲染是静态的加个生长动画体验会好很多。核心是用requestAnimationFrame按时间进度插值柱子高度。animate() { const duration this.config.animation; const startTime performance.now(); const draw (now) { // 计算进度 0~1用 easeOut 缓动让动画更自然 let progress Math.min((now - startTime) / duration, 1); progress 1 - Math.pow(1 - progress, 3); // easeOutCubic this.renderWithProgress(progress); if (progress 1) requestAnimationFrame(draw); }; requestAnimationFrame(draw); }performance.now()比Date.now()精度高适合做动画计时。easeOutCubic让柱子先快后慢地长出来比线性动画自然。renderWithProgress里把每根柱子的高度乘以progress即可。注意动画期间不要重复调用animate用一个标志位或者取消上一次的 rAF否则快速切换数据时会叠加多套动画画面会抖。4. 饼图组件的实现细节扇形、标签与交互命中饼图看起来比柱状图简单但标签排布和交互命中检测是两个真正的难点处理不好体验会很差。4.1 扇形绘制与环形图变体扇形绘制依赖第 2 章的computePieAngles。圆心取画布中心半径取宽高较小值的一半再留点边距。renderPie() { const { ctx, config, data } this; const cx config.width / 2; const cy config.height / 2; const radius Math.min(config.width, config.height) / 2 - 40; const angles computePieAngles(data.map(d d.value)); angles.forEach((seg, i) { ctx.beginPath(); ctx.moveTo(cx, cy); // 从圆心出发 ctx.arc(cx, cy, radius, seg.start, seg.end); ctx.closePath(); // 回到圆心形成闭合扇形 ctx.fillStyle config.colors[i % config.colors.length]; ctx.fill(); }); }moveTo(cx, cy)加closePath()是画扇形的标准套路缺了moveTo会从上一个路径的终点连线过来图形就乱了。环形图只需在填充后用背景色再画一个内圆盖住中心ctx.beginPath(); ctx.arc(cx, cy, innerRadius, 0, Math.PI * 2); ctx.fillStyle #fff; ctx.fill();。innerRadius一般取radius * 0.6左右。4.2 标签防重叠引导线与位置计算饼图标签最怕挤在一起。我的做法是标签放在扇形中线的延长线上超过一定角度范围就往外拉引导线。// 计算标签锚点取扇形中间角度向外延伸 function getLabelPosition(cx, cy, radius, start, end) { const mid (start end) / 2; const x cx Math.cos(mid) * (radius 20); const y cy Math.sin(mid) * (radius 20); // 根据左右半区决定文字对齐方向 const align Math.cos(mid) 0 ? left : right; return { x, y, align }; }mid是扇形中间角度Math.cos和Math.sin把角度转成方向向量。radius 20让标签离扇形有一点距离。align根据标签在左半区还是右半区决定左半区文字右对齐、右半区左对齐这样文字不会压到饼图上。如果两个标签 y 坐标差小于 14px一行文字高度就要做垂直方向的错位简单做法是排序后依次下推。4.3 点击命中检测用角度和半径判断Canvas 没有 DOM 事件点击哪个扇形要自己算。思路是把点击坐标转成相对于圆心的极坐标先判断距离是否在半径内再判断角度落在哪个扇形的区间。canvas.addEventListener(click, (e) { const rect canvas.getBoundingClientRect(); const x e.clientX - rect.left - cx; const y e.clientY - rect.top - cy; const dist Math.sqrt(x * x y * y); if (dist radius) return; // 点在圆外忽略 // atan2 返回 -π~π归一化到 0~2π 再比较 let angle Math.atan2(y, x); if (angle 0) angle Math.PI * 2; const normalized angles.map(a ({ start: a.start 0 ? a.start Math.PI * 2 : a.start, end: a.end 0 ? a.end Math.PI * 2 : a.end, value: a.value, })); const hit normalized.find(a angle a.start angle a.end); if (hit) console.log(点击了值, hit.value); });getBoundingClientRect拿到画布在页面中的位置减去它才是画布内坐标。Math.atan2返回的角度范围是 -π 到 π而扇形角度是从 -π/2 开始累加的可能为负所以要统一归一化到 0 到 2π 再比较。这个命中检测是 O(n) 的饼图项数少完全够用不需要空间索引。5. 避坑与排查绘图组件最容易翻车的五个地方这一章是我踩过的坑合集每条都按现象、原因、解决来写照着排查能省不少时间。5.1 高清屏模糊画布尺寸和 CSS 尺寸不一致现象在 Mac 或者高分屏手机上画出来的线条、文字发虚像蒙了一层雾。原因Canvas 的width/height属性是位图实际像素CSS 的width/height是显示尺寸两者不相等时浏览器会拉伸位图。解决按devicePixelRatio放大位图尺寸再用ctx.scale(dpr, dpr)把绘制坐标系缩回来如第 3 章代码所示。注意scale只需调用一次重复调用会累积放大。5.2 数据全相等时柱子消失或撑满现象所有数据值一样时柱子要么高度为 0要么顶满整个画布。原因值域domainMax - domainMin为 0映射函数除以零得到 Infinity 或 NaN。解决在createScale里用|| 1兜底同时在计算maxVal时如果最大值等于最小值手动给一个基准值比如maxVal maxVal || 1。这个坑在展示「全部为 0」的初始数据时特别容易触发。5.3 重绘时旧图形残留现象数据更新后重新渲染旧柱子还在和新柱子叠在一起。原因Canvas 是立即模式不清空的话新绘制会覆盖在旧像素上。解决每次render开头调用ctx.clearRect(0, 0, width, height)。注意清空要用逻辑尺寸因为已经scale过了。如果背景不是白色用fillRect填充背景色代替clearRect。5.4 文字标签超出画布被裁切现象最高的柱子顶部数值标签看不见或者饼图边缘的标签被切掉。原因padding 预留不够或者标签位置计算时没考虑文字自身宽高。解决柱状图顶部 padding 至少留 20px 给标签饼图标签位置计算时根据align方向额外偏移文字宽度。一个实用技巧是先用ctx.measureText(text).width量出文字宽度再决定锚点位置。5.5 频繁重绘导致移动端卡顿现象数据实时刷新时页面滚动卡顿、发热。原因每来一条数据就全量重绘且没有做帧率控制。解决用requestAnimationFrame节流把多次数据更新合并到一帧内渲染数据量大时考虑只重绘变化区域脏矩形或者降级为 Canvas 离屏渲染。我一般会加一个scheduleRender方法用标志位保证一帧内只渲染一次。6. 进阶技巧让绘图组件真正好用的一招写到这儿柱状图和饼图的基本盘已经稳了。最后分享一个让组件从「能用」到「好用」的关键技巧把渲染逻辑和坐标计算彻底分离做成可测试的纯函数。我早期写组件时坐标计算和ctx调用混在一起改一个间距要翻半天代码还没法写单元测试。后来我把所有「数据到坐标」的换算抽成不依赖 Canvas 的纯函数比如createScale、computePieAngles、getLabelPosition它们输入数字、输出数字跟 DOM 和 Canvas 完全无关。这样一来我可以在 Node 环境里直接跑测试验证边界情况数据全相等、含负值、只有一项、空数组。渲染层只负责拿着算好的坐标去调ctx逻辑薄得像一层皮。// 纯函数可以脱离浏览器测试 const assert require(assert); const scale createScale(0, 100, 300, 20); assert.strictEqual(scale(0), 300); assert.strictEqual(scale(100), 20); assert.strictEqual(scale(50), 160); // 空数组和单项的边界 assert.deepStrictEqual(computePieAngles([]), []); const single computePieAngles([42]); assert.strictEqual(single[0].end - single[0].start, Math.PI * 2);这套分离带来的好处在组件要支持新图形时特别明显。加折线图时我只需要写一个新的坐标映射和绘制函数复用的还是那套 scale 和布局计算。加交互时命中检测也是纯计算不碰渲染。维护成本直线下降。另一个习惯是所有对外暴露的配置项我都写一个validateConfig函数做校验宽高必须是正数、数据必须是数组、颜色数组不能为空。校验失败时抛出带明确信息的错误而不是让组件在渲染时莫名其妙地画出空白。这个习惯帮我省了很多「为什么图不显示」的排查时间——大部分时候是调用方传错了参数早报错早解决。如果你打算把这个组件用在生产环境我的建议是先别急着堆功能把柱状图和饼图这两个基础形态打磨到边界情况都不出错再考虑加动画、加交互、加主题。基础映射逻辑稳了后面加什么都是顺水推舟。希望帮到你。本文还有配套的精品资源点击获取