零依赖Canvas绘图组件:柱状图与饼图的实现解析
简介一套面向Web前端开发者和数据可视化初学者的绘图组件包基于JScharts库演示如何在网页中实现柱状图、饼图以及折线图等常见图表可应用于数据分析报告、业务监控看板等需要直观展示数据的场景。压缩包共51个文件大小约286KB其中包含27个HTML示例页面、12张JPG效果截图、9个XML配置文件、1份PDF说明文档、1个TXT使用指南以及核心JavaScript脚本目录下另有完整源码和多个主题示例结构清晰便于按需检索。目前已有172人学习下载。PDF文档详细讲解了柱状图的数据绑定、自定义样式、多轴支持以及饼图的扇区划分、标签指针、动态效果等交互特性TXT指南提供安装步骤与常见问题解答JavaScript源码可供二次开发参考示例涵盖默认主题、自定义红色、彩虹等多种风格的柱状图、饼图、折线图。开发者对照示例可以快速部署到自身项目显著降低图表开发与选型成本。1. 轻量 JS 绘图组件柱状图饼图之外的另一种选择接手某个后台管理系统的数据看板时我发现项目里引入的图表库占了打包体积的四分之一实际用到的只有柱状图和饼图。于是我用原生 JavaScript 重写了一个 Canvas 绘图组件把柱状图、饼图的完整链路控制在一个很小的体积内不依赖任何第三方运行时库配置项也精简到看一遍文档就能上手。这个项目适合两类人一类是只需要少量图表、想给依赖瘦身的前端开发者另一类是刚开始接触 Canvas 图形学、想搞清楚一个绘图组件从配置到像素到底怎么走的学习者。它把坐标计算、Canvas 绘制、动画调度、事件命中拆成了清晰的模块柱状图和饼图只是最外层的能力内部每一个模块都能单独取出来抄进自己的项目里。2. 架构与目录零依赖的 Canvas 渲染设计2.1 技术选型为什么是原生 JS Canvas做这种轻量绘图组件技术选型上常见有三个方向。SVG 的优点是 DOM 化、文本和事件处理方便缺点是数据点多了以后 DOM 节点膨胀性能明显下降。WebGL 性能最好但整套着色器、缓冲区的复杂度对单页看板来说是杀鸡用牛刀。Canvas 2D 处在两者中间几十个柱子、十几个扇区完全能轻松扛住绘制代码又足够底层面向像素控制适合做组件内核。这个项目选择原生 JS Canvas 2D还有一个重要原因是零依赖。引入一个重型图表库光加载框架就要几十 KB而自己控制渲染层之后组件可以打包成独立的模块放到 React、Vue 或者纯 HTML 页面里都不需要额外的适配层。我一般会把这类组件写成 UMD 风格浏览器里用 script 标签直接加载打包器里走 import这样复用性最好。2.2 目录结构与模块职责拿到源码之后第一件事不是读代码而是看目录。这个项目的目录结构是这样的src/ index.js # 对外入口导出 Chart 构造函数 core/ Chart.js # 组件主类初始化、渲染调度、动画管理 scale.js # 坐标轴刻度算法所有图表类型的地基 utils.js # DPR 适配、颜色解析、DOM 辅助函数 shape/ bar.js # 柱状图绘制模块 pie.js # 饼图绘制模块 examples/ index.html # 柱状图示例 pie.html # 饼图示例 multi.html # 同一页面混合多个图表示例这个分层思路有一个明确原则所有图表类型共享坐标刻度、画布适配、动画调度这些公共能力每一种图形只负责把自己的矢量画出来。这样新增图表类型时不需要动 core 目录只往 shape 目录加一个文件、在入口注册一下即可。2.3 配置驱动的数据格式组件的调用方式走的是配置驱动跟 ECharts 的 option 思路接近但刻意精简了很多。最小可用的配置是这样const chart new Chart(#container, { type: bar, // 图表类型bar | pie data: [ { label: 前端, value: 86 }, { label: 后端, value: 64 }, { label: 测试, value: 42 }, ], width: 400, height: 300, colors: [#4e8cff, #6bc46b, #f5a623], });核心字段就几个type 决定分派到哪个绘制模块data 统一用 { label, value } 的结构colors 是颜色序列width 和 height 是画布尺寸。为什么不支持嵌套的 xAxis、yAxis 配置对象因为设计目标是覆盖真实项目中 80% 的基础图表场景复杂配置项只会增加阅读负担。如果需要更细的控制后文会讲如何扩展。2.4 快速预览拿到源码十分钟看到效果源码目录里没有复杂的构建流程没有 webpack 配置也不需要 npm install 拉依赖。examples 下的 HTML 文件直接引用了 src 目录的 ES Module 文件所以只需要起一个静态服务cd chart-component python3 -m http.server 8080然后浏览器访问 http://localhost:8080/examples/index.html 就能看到柱状图渲染结果。不要直接用 file:// 协议打开因为 ES Module 在 file:// 下会被浏览器安全策略拦截这个限制跟组件本身无关是浏览器的规则。我一开始偷懒直接双击 HTML 文件结果控制台报跨域错误这一点先给读者排掉。3. 核心实现拆解从 options 配置到屏幕像素的完整链路3.1 组件的初始化入口与渲染分派Chart 构造函数的职责不是画图而是做四件事解析容器、创建 canvas、注册事件、调用 render 分派。核心代码大致如下// core/Chart.js class Chart { constructor(container, options) { this.container typeof container string ? document.querySelector(container) : container; this.options Object.assign({}, defaultOptions, options); this.initCanvas(); this.render(this.options.data); } initCanvas() { this.canvas document.createElement(canvas); this.dpr window.devicePixelRatio || 1; // 用 getBoundingClientRect 拿容器真实尺寸而不是读 css 样式字符串 const rect this.container.getBoundingClientRect(); this.width rect.width; this.height rect.height; // 物理像素 CSS 像素 x dpr这一步解决高清屏发虚问题 this.canvas.width this.width * this.dpr; this.canvas.height this.height * this.dpr; this.canvas.style.width this.width px; this.canvas.style.height this.height px; this.ctx this.canvas.getContext(2d); this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); this.container.appendChild(this.canvas); } render(data) { // 先复位变换再清屏避免上一次 setTransform 的残留导致清不干净 this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); this.ctx.clearRect(0, 0, this.width, this.height); // 通过 type 查表分派到对应绘制模块 const drawer drawerMap[this.options.type]; if (!drawer) { console.warn([chart] unknown type: ${this.options.type}); return; } drawer(this.ctx, data, this.options, this.getLayout()); } getLayout() { return { width: this.width, height: this.height }; } }这段代码里有两个值得注意的点。第一是 getBoundingClientRect 获取容器尺寸它返回的是布局后的真实像素值比读取 style.width 或 offsetWidth 更可靠尤其当容器用 flex 或百分比布局时。第二是 setTransform 在 render 开头和 initCanvas 里各出现一次作用完全不同——初始化时是建立 dpr 缩放坐标系render 时是清屏前确保坐标系正确。3.2 刻度计算一切图表的地基柱状图的 Y 轴最大值、饼图的百分比换算底层都依赖同一个函数computeScale。它的作用是把一组原始数据映射到一组“好看”的坐标刻度。所谓好看就是刻度的步长尽量是 1、2、5 乘以 10 的幂次而不是 3.7 或者 8.2 这种奇怪数字。// core/scale.js export function computeScale(maxValue, tickCount 5) { // 兜底数据全是 0 时不能让区间变成 [0, 0]否则柱子高度全部为 0 if (maxValue 0) maxValue 10; const roughStep maxValue / tickCount; // 取 10 的对数得到数量级然后把步长归一到 1~10 之间 const magnitude Math.pow(10, Math.floor(Math.log10(roughStep))); const normalized roughStep / magnitude; let niceStep; if (normalized 1) niceStep 1; else if (normalized 2) niceStep 2; else if (normalized 5) niceStep 5; else niceStep 10; niceStep * magnitude; return { max: Math.ceil(maxValue / niceStep) * niceStep, step: niceStep, }; }参数说明maxValue 是这组数据的最大值tickCount 是期望的刻度条数默认 5。函数先算出一个粗略步长再把步长“修正”到 1/2/5 系列。例如最大值是 86tickCount 为 5roughStep 是 17.2magnitude 是 10normalized 是 1.72落在 (1, 2] 区间所以 niceStep 2最终 max 被向上取整到 90。刻度显示为 0、20、40、60、80。如果直接用 86/517.2 去画线Y 轴标签会全是小数观感极差这就是这个函数存在的意义。3.3 柱状图的绘制实现柱状图的核心是柱子的定位。每个柱子的 X 坐标需要根据数据索引和画布宽度均分柱子的高度需要根据 value 与刻度最大值的比例换算成像素。代码如下// shape/bar.js export function drawBars(ctx, data, opts, layout) { const { padding 20, barColor #4e8cff, radius 2 } opts; const chartW layout.width - padding * 2; const chartH layout.height - padding * 2; // 取最大值算比例而不是用 computeScale 的 max因为柱子高度只看数据极值 const max Math.max(...data.map((d) d.value)); const groupWidth chartW / data.length; const barWidth Math.min(groupWidth * 0.6, 48); data.forEach((item, i) { const x padding i * groupWidth (groupWidth - barWidth) / 2; const h (item.value / max) * chartH; const y layout.height - padding - h; ctx.beginPath(); // roundRect 是新 API老浏览器不支持需要手动 fallback if (typeof ctx.roundRect function) { ctx.roundRect(x, y, barWidth, h, radius); } else { ctx.rect(x, y, barWidth, h); } // 支持单个颜色和颜色数组两种配置 ctx.fillStyle Array.isArray(barColor) ? barColor[i % barColor.length] : barColor; ctx.fill(); }); }逻辑说明groupWidth 是每个数据点分到的水平区间宽度barWidth 取 groupWidth 的 60%目的是让柱子之间留出空隙。柱子的 X 坐标是 padding 加上 i*groupWidth 再加上区间内居中偏移Y 坐标是底部对齐用容器高度减去柱子高度。这里有一个细节柱子高度用的是数据最大值而不是 computeScale 返回的修正最大值因为柱子如果按 90 去算86 的柱子不会顶到顶部视觉上会留白——这是柱状图和折线图的区别柱状图需要柱子顶到顶才有“满”的感觉。3.4 饼图与扇区角度计算饼图的数学核心是角度累计从 12 点方向开始每一项按 value 占比分配角度每次画完一个扇区把起始角度向后推进。代码如下// shape/pie.js export function drawPie(ctx, data, opts, layout) { const cx layout.width / 2; const cy layout.height / 2; const radius Math.min(layout.width, layout.height) / 2 - opts.padding; const total data.reduce((sum, d) sum d.value, 0); // 从 -90 度开始让第一个扇区位于 12 点方向 let startAngle -Math.PI / 2; data.forEach((item, i) { // 角度占比 数值占比 x 2π const angle (item.value / total) * Math.PI * 2; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, radius, startAngle, startAngle angle); ctx.closePath(); ctx.fillStyle opts.colors[i % opts.colors.length]; ctx.fill(); // 累加起始角度保证扇区首尾相接 startAngle angle; }); }参数说明cx 和 cy 是饼图圆心的像素坐标radius 是半径取画布宽高的较小值的一半再减去 padding防止扇区溢出边界。angle 是当前扇区的弧度值arc 方法的参数依次是圆心 x、圆心 y、半径、起始角度、结束角度。canvas 的 arc 默认按顺时针方向绘制所以从 -90 度开始累加扇区会依次均匀排布。整个绘制用三段路径完成先 moveTo 圆心再 arc 画弧线最后 closePath 闭合回圆心。这段代码有一个隐藏的边界情况当某项数据为 0 时angle 为 0arc 不会画任何东西但 moveTo 和 closePath 仍然执行不会报错。这说明即使数据出现空值渲染也不会崩。3.5 动画补间计算与调度防抖动画部分是这个组件最容易写乱的地方。常见做法是用 requestAnimationFrame 做补间但最常翻车的一点是连续触发 update 时上一次动画还没跑完新动画又启动了两套补间互相覆盖图表就出现乱跳。这个组件的处理是把动画调度收敛到一个方法里// core/Chart.js 中的动画调度 animate(from, to, duration, onFrame) { const start performance.now(); // 关键防抖新动画启动前取消上一帧动画 if (this._rafId) { cancelAnimationFrame(this._rafId); } const tick (now) { const progress Math.min(1, (now - start) / duration); // easeOutCubic先快后慢的缓动曲线 const eased 1 - Math.pow(1 - progress, 3); const current from (to - from) * eased; onFrame(current); if (progress 1) { this._rafId requestAnimationFrame(tick); } else { this._rafId null; } }; this._rafId requestAnimationFrame(tick); }动画方法接收四个参数from 是起始值to 是目标值duration 是毫秒级的时长onFrame 是每帧回调。performance.now 提供高精度时间戳比 Date.now 更平滑。缓动函数用 easeOutCubic会让动画开头快、结尾慢视觉上比线性动画自然得多。实际使用时柱状图的数据变化是把 from 和 to 分别传入旧值和新值然后每帧重算柱子高度并重绘饼图则是把扇区的角度作为补间对象。4. 实战避坑Canvas 绘图组件最常见的五个问题4.1 高 DPI 屏幕下图表发虚现象在 Retina 屏或部分 Windows 高分屏上图表文字和线条边缘发虚、模糊像被放大过一样。原因canvas 的 width 和 height 属性是物理像素而 CSS 样式里的宽高是逻辑像素。当二者相等时高分屏会用多个物理像素去渲染一个 CSS 像素导致模糊。解决创建 canvas 时读 devicePixelRatio把画布物理尺寸乘以 dpr再用 setTransform 缩放坐标系。代码在 3.1 节已经给出关键是 initCanvas 里那两行this.canvas.width this.width * this.dpr; this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);从那以后我每次写 Canvas 组件都会先确认 dpr 适配是写在上面的而不是等用户截图说模糊再回去改。4.2 容器尺寸变化后图形重叠现象浏览器窗口缩放或侧边栏折叠后柱状图跟饼图挤在一起坐标轴和数据错位。原因canvas 的 width 和 height 只在初始化时设置过一次容器尺寸变了以后canvas 物理尺寸没变内部坐标系还是旧的重绘后所有元素都按旧坐标画在新位置上。解决监听 ResizeObserver 或 window 的 resize 事件在回调里重新读取 getBoundingClientRect重新设置 canvas 宽高和 setTransform再调用一次 renderconst observer new ResizeObserver(() { const rect this.container.getBoundingClientRect(); this.width rect.width; this.height rect.height; this.canvas.width this.width * this.dpr; this.canvas.height this.height * this.dpr; this.render(this.options.data); }); observer.observe(this.container);注意 ResizeObserver 触发频率很高如果数据量大可以在回调里加一层 requestAnimationFrame 节流。4.3 全 0 数据和极端值让坐标轴塌掉现象数据全部为 0 时Y 轴最大值为 0柱状图所有柱子都在底边看起来像没数据。原因computeScale 里 maxValue 为 0 时用 0 去除任何数都是 0整个比例换算失效。另一个极端是数据有巨大的离群值比如其他值是几十突然一个值是 10000所有正常柱子变成肉眼不可见的高度。解决computeScale 里已经有 maxValue 0 时兜底为 10 的逻辑针对离群值常见做法是截断处理或把最大值按修正刻度向上取整后允许柱子超出画布范围——大多数图表库也是这样做的不主动缩数据。4.4 饼图百分比加起来不等于 100%现象饼图扇区都画完了但 tooltip 里各项百分比加起来是 99.9% 或 100.1%。原因JavaScript 浮点运算的累计误差尤其是 .1 和 .2 这种无法用二进制精确表示的小数。解决显示百分比时不要每项单独 toFixed(1)而是在最后把余差加到最大的一项上const percentages data.map((d) (d.value / total) * 100); const rounded percentages.map((p) Math.round(p * 10) / 10); const diff 100 - rounded.reduce((a, b) a b, 0); // 把因四舍五入产生的差值补给占比最大的扇区 const maxIndex rounded.indexOf(Math.max(...rounded)); rounded[maxIndex] diff;这样能保证展示值加起来严格等于 100。绘制扇区本身不受影响因为画图用的是原始 value 而不是四舍五入后的百分比。4.5 连续 update 造成动画互相打架现象快速切换筛选条件时柱状图高度乱跳有时停在中间值不动。原因每次 update 都启动一个新的 requestAnimationFrame 循环旧的循环没被取消。新旧两个动画循环同时存在各自把 onFrame 值写进同一个渲染函数最终状态取决于最后一个写入的帧而不是动画完成后的值。解决在 animate 方法入口处取消上一次动画代码已经在 3.5 节给出。核心是这一行if (this._rafId) cancelAnimationFrame(this._rafId);动画结束后把 _rafId 置为 null判断是否还有进行中的动画就看它是否为 null。这个模式可以推广到所有 Canvas 交互动画不只是图表组件。5. 本地运行与参数调优把示例改造成自己想要的图表5.1 典型的失败尝试直接双击 HTML这个项目没有构建步骤不代表可以直接双击运行。examples 下的 HTML 文件用 ES Module 的 import 语法引入 src 目录而浏览器在 file:// 协议下会阻止模块加载控制台报的错一般是“Access to script at file://... from origin null has been blocked”。第一次跑项目遇到这个问题很正常解决方案是起一个本地静态服务任何一款都行python3 -m http.server 8080如果机器上装的是 Node.js也可以直接npx serve .然后浏览器打开 http://localhost:8080/examples/index.html。注意端口号改成实际输出的端口。这个项目没有 npm install 的过程因为零依赖node_modules 目录都不存在。5.2 高频配置项速查表这个组件的配置项刻意做得很薄按照下图这张表去调整参数基本能覆盖日常使用配置项可选值 / 示例作用默认值typebar | pie指定图表类型决定分派到哪个绘制模块必选data[{ label, value }]图表数据源统一结构必选colors[#4e8cff, #6bc46b]颜色序列柱状图按索引取色饼图按扇区顺序取色[#4e8cff]padding20图表内容边缘到画布边缘的间距单位 px20animation{ duration: 400 }动画时长单位 ms设为 0 可关闭400barColor#4e8cff 或数组柱状图专用覆盖默认取色逻辑同 colors[0]radius2柱状图圆角半径单位 px2改配置的方式有两种。调用时直接传new Chart(#app, { type: bar, data: [], colors: [#ff6b6b, #4ecdc4, #ffe66d], animation: { duration: 800 }, });或者在源码的 core/Chart.js 里修改 defaultOptions 对象让全局生效。我一般建议把业务相关的配置放调用处把样式类的放 defaultOptions。5.3 数据接入从接口响应到图表数据真实项目里后端返回的数据几乎不会长成 { label, value } 的样子。以某个统计接口为例返回的是// GET /api/skill/ranking // { data: [ { name: Vue, count: 86 }, ... ] }代码里要做的只是字段映射fetch(/api/skill/ranking) .then((res) res.json()) .then((json) { const formatted json.data.map((item) ({ label: item.name, value: item.count, })); chart.update(formatted); });这里有个容易踩的坑后端返回的 value 可能是字符串类型尤其是从数据库读出来的 count 字段容易变成 86 而不是 86。字符串参与 Math.max 时会被隐式转换但参与数组排序或某些比较运算时会错乱。稳妥的做法是在格式化时加一道 Number 转换value: Number(item.count)5.4 调试 Canvas 绘图组件的三板斧第一个技巧是给 canvas 加临时边框确认画布实际边界在哪里。很多布局问题都是容器本身高度塌陷导致的图表顶到画布外了this.canvas.style.border 1px solid red;第二个技巧是把 animation.duration 调大比如调到 2000ms动画慢下来之后能看到柱子从底到顶的补间过程定位动画计算错误会直观很多。第三个技巧是在 computeScale 函数里打断点或加 console.log看输入的 maxValue、输出的 niceStep。刻度算法是整个组件的黑匣子多数坐标轴异常都能在这里找到原因。调试结论不要保留在源码里改完之后记得清掉。6. 二次开发用已有能力扩展一个新的雷达图类型6.1 新增 graphType 的完整步骤扩展新的图表类型不需要改动绘制入口只需要四步。第一步在 shape 目录新建 radar.js实现绘制函数。第二步在 Chart.js 入口处注册到 drawerMapimport { drawRadar } from ./shape/radar.js; drawerMap.radar drawRadar;第三步在 defaultOptions 里补上 radar 相关的默认配置项比如 radarMax、radarAxisColor。第四步写一个 radar.html 示例验证。绘制函数的核心是角度与坐标的转换export function drawRadar(ctx, data, opts, layout) { const cx layout.width / 2; const cy layout.height / 2; const radius opts.radarRadius || 100; const max opts.radarMax || Math.max(...data.map((d) d.value)); data.forEach((item, i) { // 每个维度的角度均分 360 度从 12 点方向开始偏移 const angle -Math.PI / 2 (i * Math.PI * 2) / data.length; // 极坐标转直角坐标 const x cx Math.cos(angle) * radius; const y cy Math.sin(angle) * radius; // 坐标轴网格线、数据多边形都在这里绘制 ctx.lineTo(x, y); }); }这里复用的是 computeScale 里 common 的坐标系约定、dpr 适配和动画调度这几个模块在 core 目录里被所有图表类型共享所以新类型的核心代码只有图形轮廓这一部分。6.2 验证方法扩展完成后的验证方式是拿一份五维数据跑图形对比比如技能评估数据五个维度值分别是 80、60、70、90、50。如果雷达图的多边形没有闭合检查是否缺少 closePath如果图形方向歪了检查角度偏移是否从 -90 度开始。从那以后我每次扩展组件的新类型都会先跑一遍全 0 数据和全相同值数据来验证边缘情况这几乎成了固定动作。希望你也能从这个项目里拿到能直接落地的代码不管是图表本身还是结构拆分的思路希望帮到你。本文还有配套的精品资源点击获取