☰
JS绘图组件从零实现:SVG与Canvas取舍及柱状图饼图避坑指南
2026/10/14 18:11:05 网站建设 项目流程

简介:面向Web前端开发者和数据可视化初学者,这份JS绘图组件资源包专注于解决网页端柱状图、饼图及折线图等图表的快速绘制与交互设计问题,适合需要在项目中快速集成图表或学习组件原理的读者。包内共51个文件,压缩包约286KB,以27个网页示例页面为主,搭配12张效果图、9个主题配置、1份详解文档、1份使用指南及1份绘图脚本源码。其中详解文档系统讲解了柱状图的数据绑定、自定义样式、悬停交互、多轴支持,以及饼图的扇区划分、标签指针、动画过渡和切片交互等特性;使用指南提供了安装步骤、示例代码与常见问题说明,示例目录则按柱状图、饼图、折线图分类,并包含蓝色、红色、彩虹等多种主题示例,可对照运行进行二次开发。已有172人学习下载,对于刚接触前端图表库或需要快速产出报表页面的开发者,这是一份轻量实用的入门参考资料。

1. js绘图组件,柱状图饼图背后的硬功夫到底是什么

先说一个反直觉的结论:很多场景下,你不需要再往项目里塞一个重型图表库。js绘图组件这个方向,真正的价值恰恰在于用不多的代码把柱状图、饼图这类基础图形做好做透,同时把包体体积、渲染性能、定制自由度握在自己手里。如果你做的是内网报表、监控大屏、数据中台这类项目,数据量不大但交互逻辑千奇百怪,一个自研的轻量绘图组件往往比通用库更顺手。

这篇文章要解决的是这些问题:图表组件该用 SVG 还是 Canvas 做渲染层,最小可用的柱状图和饼图怎么写,配置项怎么设计才能让团队其他人愿意用,以及高分屏模糊、resize 失效、饼图角度错位这些高频坑怎么绕过去。适合正在选型或准备自研绘图组件的开发者,也适合想让现有图表代码更可控、更好维护的团队参考。

2. 先想清楚渲染方案:SVG 与 Canvas 的取舍决定组件上限

2.1 两种渲染方式的本质区别

js绘图组件底层无非两条路:SVG 或者 Canvas。SVG 是保留模式渲染,你创建的每个矩形、每个路径都是一个真实存在的 DOM 节点,浏览器帮你维护场景图,你改属性它就重绘对应节点。Canvas 是立即模式渲染,你调用绘图 API 把像素画上去,画完就没了,下一次重绘得从头再来。

这个区别直接决定了后续所有设计。SVG 的每个图形元素都能单独绑定事件,图表上的柱子和扇区天然支持 hover、点击、移入移出,不用自己算坐标;缺点是节点数量上去之后 DOM 操作开销明显变大,两千个柱子就会让页面有点喘。Canvas 画一万个柱子都没问题,因为绘图指令是直接发给底层图形库的,但它不保留图形对象,事件得自己做命中检测,鼠标在哪根柱子上要自己拿坐标去比对。

饼图在这两种方案里还有一个隐藏差异:SVG 用 path 的 arc 命令画扇区,天然支持 CSS 过渡做动画;Canvas 的 arc API 画扇形需要自己闭合路径,动画得靠逐帧重绘。这个差异在选型阶段就会被放大。

2.2 选型判据:数据量、交互密度、维护成本

我一般会用三条标准快速判断。第一,单图最大数据量。柱状图超过 500 根柱子、折线图超过 1000 个点,直接选 Canvas,不然图表交互会从流畅变成卡顿。第二,交互密度。需要每个柱子都有独立 tooltip、点击跳转、图例联动,SVG 是更好的底子,事件系统白送。第三,团队维护能力。Canvas 代码更接近绘制逻辑本身,对数学基础有一定要求;SVG 更接近写 HTML,新手上手成本低。

实际项目里最常见的组合是:小数据量、复杂交互用 SVG,大数据量、高刷新率用 Canvas。还有一种折中方案是外层容器用 SVG 承载文本和图例,主图形用 Canvas 绘制,各取所长。这个方案对组件架构能力要求更高,但能把两边的优势都用上。

2.3 常见做法:把渲染层与业务层分离

很多自研绘图组件翻车,不是因为画图代码写不好,而是因为渲染逻辑和业务逻辑搅在一起。正确的分层是:核心渲染层只接收标准化的数据结构和配置对象,负责把图形画出来;业务层负责把接口返回的原始数据转换成绘图组件认识的数据格式,比如把后端给的百分比字段换算成饼图的扇区角度,把时间字符串解析成坐标轴刻度。

这样拆的好处是组件可以复用到多个项目里。某公司内部接了四五个数据平台,每个平台的接口字段都不一样,但绘图组件本体一个版本都没动过,变的全是适配层的映射逻辑。另外,渲染层不直接操作 DOM 事件、不直接访问业务变量,也让单元测试变得可行——传一组数据进去,检查画布上的输出是否符合预期。

3. 从零实现一个柱状图 + 饼图组件:最小可运行版本

3.1 柱状图:数据处理与坐标系变换

先写柱状图,它是最容易理解绘图流程的图形。核心工作是把数据值映射成画布上的像素坐标,术语叫坐标系变换。拿一组销售数据举例,月份是类别轴,销售额是数值轴,我们要把月份放在 x 方向等距排列,把销售额按比例映射到 y 方向的高度。

function drawBarChart(canvas, data, options = {}) { const ctx = canvas.getContext('2d'); const { width, height } = canvas; const padding = options.padding || { top: 40, right: 20, bottom: 40, left: 60 }; // 计算绘图区:画布尺寸减去四周留白 const plotWidth = width - padding.left - padding.right; const plotHeight = height - padding.top - padding.bottom; // 找到数据最大值,用于归一化 const maxValue = Math.max(...data.map(d => d.value), 1); // 等分 x 轴,留出柱子之间的间距 const barWidth = plotWidth / data.length * 0.6; const gap = plotWidth / data.length * 0.4; // 清空画布并设置背景 ctx.clearRect(0, 0, width, height); ctx.fillStyle = '#f8f9fa'; ctx.fillRect(0, 0, width, height); // 绘制网格线和 y 轴刻度 ctx.strokeStyle = '#e0e0e0'; ctx.lineWidth = 1; for (let i = 0; i <= 4; i++) { const ratio = i / 4; const y = padding.top + plotHeight * (1 - ratio); ctx.beginPath(); ctx.moveTo(padding.left, y); ctx.lineTo(width - padding.right, y); ctx.stroke(); // 左侧刻度文字 ctx.fillStyle = '#666'; ctx.font = '12px sans-serif'; ctx.textAlign = 'right'; ctx.fillText(Math.round(maxValue * ratio).toString(), padding.left - 8, y + 4); } // 绘制柱子 data.forEach((item, index) => { const x = padding.left + index * (barWidth + gap); const barHeight = (item.value / maxValue) * plotHeight; const y = padding.top + plotHeight - barHeight; ctx.fillStyle = item.color || '#4e79a7'; ctx.fillRect(x, y, barWidth, barHeight); // x 轴标签 ctx.fillStyle = '#666'; ctx.font = '12px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(item.label, x + barWidth / 2, height - padding.bottom + 18); }); }

这段代码的要点集中在两个地方。第一是留白,padding 四个值决定了绘图区范围,图表的边距问题本质上都是 padding 没设计好。第二是坐标换算,barHeight 等于数值与最大值的比值乘以绘图区高度,这个比例关系是所有统计图表的共同基础。柱子间距通过 barWidth 和 gap 控制,0.6 和 0.4 是经验值,柱子占一格宽度的六成,剩下四成作为空隙。

调用这个函数的方式很简单:拿到 canvas 元素,把数据和配置传进去。但这样做有个明显问题——组件化不够,每画一张图都要手动处理 canvas 元素和上下文。下一节把柱状图和饼图统一封装。

3.2 饼图:扇区绘制与百分比计算

饼图的数学基础是扇区角度计算。一个整圆对应 2π 弧度,某个类别的占比乘以 2π 就是它对应的扇区角度。Canvas 的 arc 方法画圆弧时需要指定起始角和结束角,注意角度是从三点钟方向开始、顺时针计算的,所以通常把起始角设为 -90 度,让第一个扇区从十二点方向开始。

function drawPieChart(canvas, data, options = {}) { const ctx = canvas.getContext('2d'); const { width, height } = canvas; const radius = options.radius || Math.min(width, height) / 2 - 20; const centerX = width / 2; const centerY = height / 2; // 计算总数值,用于百分比换算 const total = data.reduce((sum, d) => sum + d.value, 0); // 起始角从 -PI/2 开始,即十二点方向 let startAngle = -Math.PI / 2; // 先画所有扇区的底色加边框 data.forEach((item) => { const angle = (item.value / total) * Math.PI * 2; const endAngle = startAngle + angle; ctx.beginPath(); ctx.moveTo(centerX, centerY); ctx.arc(centerX, centerY, radius, startAngle, endAngle); ctx.closePath(); ctx.fillStyle = item.color || '#76b7b2'; ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke(); startAngle = endAngle; }); // 第二遍绘制阴影高亮效果,模拟立体感 startAngle = -Math.PI / 2; data.forEach((item) => { const angle = (item.value / total) * Math.PI * 2; const endAngle = startAngle + angle; ctx.save(); ctx.beginPath(); ctx.moveTo(centerX, centerY + 1); ctx.arc(centerX, centerY + 1, radius - 2, startAngle, endAngle); ctx.closePath(); ctx.fillStyle = 'rgba(0,0,0,0.05)'; ctx.fill(); ctx.restore(); startAngle = endAngle; }); return { centerX, centerY, radius, total }; }

饼图最容易被忽略的细节是扇区边界。如果直接用 fill 画扇区,相邻扇区之间可能出现细小的白色缝隙或颜色重叠,这是因为 Canvas 的抗锯齿算法在边界处做了半透明过渡。解决办法是给每个扇区加一圈与背景色一致的描边,画完填充后补一条 stroke,视觉上就能把边界缝补掉。上面代码里 strokeStyle 设成了白色,如果你的容器背景不是白色,记得改成实际背景色。

返回值的 centerX、centerY、radius、total 后续做 tooltip 和选中交互时会用到。扇区点击命中检测本质上是判断鼠标位置是否在圆内,以及按下时落在哪个角度范围内,这几个返回参数可以直接复用。

3.3 把两个图封装成一个组件:配置项驱动

有了两个绘制函数还不够,做一个绘图组件要统一入口。我通常的做法是设计一个简单的工厂函数,根据传入的 type 字段分发到对应的绘制函数,同时做配置合并、容器尺寸读取、实例缓存这些通用逻辑。

class SimpleChart { constructor(container, type, data, config = {}) { this.container = typeof container === 'string' ? document.querySelector(container) : container; // 优先读取容器 CSS 尺寸,保证自适应 const rect = this.container.getBoundingClientRect(); this.canvas = document.createElement('canvas'); this.canvas.width = rect.width; this.canvas.height = rect.height; this.container.appendChild(this.canvas); this.type = type; this.data = data; this.config = Object.assign({ padding: { top: 40, right: 20, bottom: 40, left: 60 }, radius: null, animation: false }, config); this.draw(); } updateData(newData) { this.data = newData; this.draw(); } resize() { const rect = this.container.getBoundingClientRect(); this.canvas.width = rect.width; this.canvas.height = rect.height; this.draw(); } draw() { if (this.type === 'bar') { drawBarChart(this.canvas, this.data, this.config); } else if (this.type === 'pie') { drawPieChart(this.canvas, this.data, this.config); } } }

构造函数的第一个参数接收选择器字符串或 DOM 元素,内部统一转成元素引用。尺寸读取用的是 getBoundingClientRect,它能拿到元素在页面上的实际渲染尺寸,比读取 style.width 更可靠,因为后者拿不到百分比宽度下的计算值。ctx 没有在构造函数里缓存,因为每次 resize 或更新数据时画布尺寸会变,而改变 canvas 的 width 或 height 属性会重置整个上下文状态,所以每次都在 draw 内部重新获取。

使用方式就一行:new SimpleChart('#mount', 'bar', salesData)。这样柱状图和饼图共用一套生命周期,调用方只需要关心数据长什么样,不用关心背后的绘图逻辑。

4. 参数与主题设计:组件能不能被团队用起来看这里

4.1 核心配置项:颜色、间距、字体、动画

一个绘图组件想要被团队接受,配置项设计比绘图算法更重要。配置项太少,定制场景全得改源码;配置项太多,调用方要传一坨对象,光看文档就劝退了。我常用的做法是分组设计,一组管数据映射,一组管视觉样式,一组管交互行为,每层都有合理的默认值。

配置分组配置项可选值范围默认值说明
数据映射valueKey字符串'value'数据对象中数值字段的键名
数据映射labelKey字符串'label'数据对象中标签字段的键名
样式colors数组内置色板按顺序分配给每个图形元素
样式padding对象各边 40px绘图区四边留白
样式fontFamily字符串'sans-serif'刻度与标签字体
交互animation布尔false是否在更新时执行过渡动画
交互showTooltip布尔true是否启用悬停提示
生命周期onResize函数内置 resize容器尺寸变化时执行的回调

字段映射这组配置最容易被忽略,但后端的接口字段千奇百怪,有的叫 value,有的叫 count,有的叫 num。有 valueKey 做映射,调用方只需要 data 属性改名就能接入,不用在组件内部硬编码。colors 配置建议用数组而不是单个色值,因为柱状图和饼图都要按序取色,单色配置只能做到整体换肤,做不了数据系列区分。

4.2 自适应与容器尺寸:ResizeObserver 的正确用法

Canvas 绘图组件最常见的自适应坑是窗口 resize 与容器 resize 混为一谈。窗口变了容器不一定变,比如左侧折叠菜单收起导致图表容器变宽,这时候 window 的 resize 事件完全感知不到。正确做法是用 ResizeObserver 直接监听容器元素的尺寸变化。

function observeResize(component) { // 为空则跳过观察 if (!component.container) return; const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { const { width, height } = entry.contentRect; // 尺寸确实变化了才触发重绘,避免死循环 if (width !== component.lastWidth || height !== component.lastHeight) { component.lastWidth = width; component.lastHeight = height; component.resize(); } } }); resizeObserver.observe(component.container); component._resizeObserver = resizeObserver; }

这里有个细节:ResizeObserver 的回调会在初始 observe 时立即触发一次,如果直接调用 resize 方法,会跟构造函数里的首次绘制重复。所以代码里加了 lastWidth 和 lastHeight 的缓存比较,只有尺寸真的变化才重绘。另一个细节是销毁时一定要调用 disconnect,否则组件卸载后观察器还挂在已经被移除的 DOM 节点上,轻则内存泄漏,重则控制台报错。

4.3 主题与多实例隔离

多图表页面最常见的 bug 是样式串了。组件 A 设置了蓝色主题,组件 B 跟着变蓝,排查半天发现是配置对象在初始化时被存进了全局变量。解决方法是深浅拷贝隔离配置,深拷贝的配置对象各自独立,互不影响引用。

function deepMerge(base, extra) { const result = Array.isArray(base) ? [...base] : { ...base }; for (const key of Object.keys(extra || {})) { if (extra[key] && typeof extra[key] === 'object' && !Array.isArray(extra[key])) { result[key] = deepMerge(result[key] || {}, extra[key]); } else { result[key] = extra[key]; } } return result; }

这个 deepMerge 函数做的事是递归合并配置对象,默认配置和用户配置逐层合并,嵌套对象不会被整体覆盖。主题这块我一般把默认配置抽成一个 getDefaultConfig 函数,每次创建实例时调用返回全新的对象,再跟用户自定义配置合并。这样两个实例各持一份独立配置,改一个不会影响另一个。

5. 避坑指南:绘图组件最常见的 5 个翻车现场

5.1 高分屏模糊:单位像素不等于物理像素

现象:图表在普通屏上清晰,在 MacBook 或高分屏手机上文字发虚、线条发糊,像是被拉伸过。

原因:Canvas 画布默认按 CSS 像素渲染,而高分屏设备上一物理像素不等于一 CSS 像素。浏览器要用约 2 个物理像素去显示 1 个 CSS 像素的内容,图像就相当于被放大了一倍,自然模糊。

解决:绘制前把画布的实际分辨率调整为物理像素尺寸。window.devicePixelRatio 能拿到缩放比,把 canvas.width 设为 CSS 宽度的 2 倍,再通过 ctx.scale(2, 2) 让后续绘制操作都在缩放后的坐标系里进行。注意这个操作必须在绘制之前完成,因为改变 canvas 尺寸会清空画布。代码参考:

function setupHiDPI(canvas, cssWidth, cssHeight) { const dpr = window.devicePixelRatio || 1; canvas.width = cssWidth * dpr; canvas.height = cssHeight * dpr; canvas.style.width = cssWidth + 'px'; canvas.style.height = cssHeight + 'px'; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); return ctx; }

还有一点容易被忽略:图片导出时 toDataURL 会按照实际像素尺寸导出,所以加分屏适配之后导出图片反而更清晰,这是个副产品的好处。

5.2 resize 不生效:监听对象错了

现象:容器宽度变化后图表不变形也不重绘,刷新页面才恢复正常。或者监听 window resize 后图表乱跳,不由分说地重绘。

原因:前面讲过的老问题,window resize 和容器 resize 不是一回事。左边菜单折叠、浏览器窗口缩放但不改变容器尺寸、iframe 嵌套场景,window 事件都拿不到准确的容器尺寸变化。

解决:统一改用 ResizeObserver。另外要在图表销毁时调用 disconnect,否则旧实例的观察器会一直触发重绘,页面上出现肉眼可见的重复绘制,DevTools 性能面板能看到 draw 调用频率极高。监听容器尺寸变化后还需要加入防抖,因为拖拽分割面板的过程中 resize 事件会密集触发,每次都重绘会卡顿,一般设个 150ms 的防抖窗口足够。

5.3 饼图角度错位:起始角与方向约定

现象:第一个扇区没有从十二点钟方向开始,或者数据顺序明明是 A、B、C,画出来却是 C、B、A,扇区顺序跟数据源不一致。

原因:Canvas arc 方法的起始角 0 代表三点钟方向,角度顺时针增长。如果直接传起始角 0 开始画第一个扇区,视觉上就会从水平向右的位置开始。顺序问题则是因为某些浏览器或绘图库对 canvas 坐标系的 y 轴方向约定不一致,Canvas 默认 y 轴向下,角度生长方向因此变成视觉上的顺时针。

解决:固定约定起始角为 -Math.PI / 2,即十二点方向;每个扇区结束时把结束角赋值给下一个扇区的起始角,确保首尾相接。如果数据更新后扇区变化有动画需求,需要在角度计算时保持同一个累计变量,不能在动画中间重新计算百分比。还有一个调试技巧:把扇区边框描边改成高对比色,开一圈就能看清每个扇区的边界和顺序,方便快速定位错位。

5.4 动画卡顿:requestAnimationFrame 之外的坑

现象:柱状图从 A 数据更新到 B 数据时,柱子先跳到中间值再跳到目标值,过程肉眼可见地闪了一下;或者饼图动画转得飞快,一帧就结束了。

原因:requestAnimationFrame 是在每次重绘前执行回调,但如果回调里每次都重新计算全量数据、重新创建 Canvas 上下文、重新设置所有样式,单帧计算量大,浏览器要抽帧。另一个常见原因是动画状态没有在初始化时重置,上一次动画还没跑完,新数据又触发了一次动画,两条动画逻辑互相打架。

解决:动画用独立的 requestAnimationFrame 循环驱动,每次只更新当前帧的插值比例 t,所有图形元素的位置、角度都用 t 线性插值生成。确保每次动画启动前把累计角度或累计高度重置到当前值,而不是初始值。方法是用 setState 保存当前图形状态,动画只是从当前状态向目标状态过渡。

5.5 文字溢出与重叠:测量宽度和省略策略

现象:饼图的图例文字过长,超出画布右边界被截断。柱状图的 x 轴标签在数据类别多时挤成一团,竖着排又太高。

原因:Canvas 的 fillText 方法不会自动换行,也不会自动省略,所有文字都按完整字符串绘制。刻度标签没有做文本宽度测量,标签之间空间不够就重叠。

解决:绘制前用 ctx.measureText 测量文本宽度,当单个标签宽度超过柱间距就做省略处理,尾部加省略号。图例部分同理,超过绘图区宽度就截断。竖排文本尽量少用,视觉上不美观。通常做法是保持横排,通过每隔一个标签显示一个来减轻拥挤。字体设置要在 measureText 之前完成,因为不同字体度量不同,先量后画才能保持一致。

6. 进阶用法:大数据集渲染、导出图片与无障碍优化

6.1 大数据集的分层绘制策略

数据量大到几万条时,Canvas 的性能优势才能真正体现,但硬画也会掉帧。常见做法是先降采样再绘制:把全量数据按区间分成若干桶,每桶取最大值和最小值,用两根柱之间的竖线表示这个区间的波动范围。这种画法叫 min-max 聚合,视觉上保留数据走势,绘制指令数从几万降到几百。实现上要注意桶的数量跟画布宽度挂钩,柱状图每个像素对应一个桶已经足够细腻,超过就没有意义。

6.2 导出图片与真实项目经验

Canvas 导出图片用 toDataURL 就行,一行代码,但硬要说注意点,是跨域图片资源要先设置 crossOrigin 属性并加载完成,否则 canvas 被污染,toDataURL 会直接抛 SecurityError。如果图表里嵌了外部图片素材做背景或图标,导出前先处理这个问题。我更常做的是把 PNG 转成 Blob 再下载,因为大图数据 URL 太长,前端内存会吃紧。

6.3 无障碍优化:给 SVG 一点耐心

Canvas 绘制的图表对屏幕阅读器完全不透明,它只是一个位图。花小成本也能改善:在 Canvas 旁边放一个隐形的 SVG 副本,其中包含图形的语义化描述和对应的表格结构,屏幕阅读器按顺序朗读后,视觉障碍用户可以拿到近似的数据感知。这个方法适合对可访问性有硬性要求的企业采购类项目,不需要花大力气重写渲染逻辑,维护成本也低。

做绘图组件这么多年,我最大的体会是基础图形做深比追求花哨效果更有价值。柱状图做好坐标轴、间距、数据更新,饼图做好角度、边界、图例联动,组件就能覆盖百分之八十的日常需求。希望这篇笔记对你选型或自研有自己的判断,用得顺手这套思路,也希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询