ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

轻量JS绘图组件:Canvas/SVG取舍与柱状图饼图落地避坑

轻量JS绘图组件:Canvas/SVG取舍与柱状图饼图落地避坑

简介:这份 JS 绘图组件资料包面向需要在前端页面快速搭建数据可视化的开发者与学习者,围绕柱状图、饼图、折线图等常见图表,提供可直接参考的轻量级实现与示例。压缩包共 51 个文件,约 286KB,以 27 个 html 示例页面为主体,配合 12 张 jpg 效果图、9 个 xml 配置或数据文件,以及 pdf 手册、txt 说明和 1 个 js 核心脚本,便于对照查看图表渲染效果、配置方式与数据结构。已有 174 人学习,适合作为课程设计、后台报表或小型可视化项目的入门素材。其价值在于把核心绘图逻辑、主题样式示例和基础使用说明集中在一起,读者可据此了解柱状图、饼图、折线图的参数组织、样式定制与交互展示思路,并借助示例页面快速改造成自己的图表页面,减少从零摸索的成本。

1. 三个图表撑起一个后台页:为什么我留下这套 js 绘图组件

上个月帮某公司改一个后台周报页,需求很简单:一张订单量柱状图、一张渠道占比饼图、一张趋势折线。按习惯先找现成的图表库,结果装完一量,打包产物直接多出一大截,构建时间也跟着涨。翻回来找轻量方案,最后留下来的是这套 js 绘图组件——它把柱状图、饼图这些最常用的图形做成了独立模块,配置项不多,源码拆得干净,能直接照着改绘制逻辑。它不解决复杂的三维图、关系图,也不做拖拽缩放那种重型交互,但对于“一页放三张图、样式要跟设计稿对得上”的场景,够用且好改。适合两类人:一是要快速出图、不想被重型图表库绑住的前端,二是想搞明白柱状图和饼图到底怎么一笔一笔画出来的开发者。

2. 渲染底座:Canvas 与 SVG 的取舍,定了后面所有写法

拿一个图表组件,先别急着调配置,看一眼它是画在 Canvas 上还是往 DOM 里塞 SVG。这一条决定后面所有的调试手段:Canvas 画完就只剩像素,出问题只能靠坐标和参数复盘;SVG 每个扇区、每根柱子都是真实节点,可以选中、可以看样式、可以挂事件。两者的差别不在“谁更先进”,而在你的图表要面对什么。

2.1 Canvas 与 SVG 在这个组件里的分工

这类轻量绘图组件默认多半走 Canvas 2D,原因很实在:柱状图、饼图的绘制指令少,一次重绘就是几十个路径,Canvas 反而是最省事的路径;而且做入场动画时,逐帧重绘整张图的开销远小于逐帧改几十个 DOM 节点的属性。代价是事件要自己算——鼠标点在哪个扇区,得靠极坐标反查,不能靠 DOM 冒泡。

SVG 的优势在另一头:元素数量少但交互复杂时,比如每个柱子要挂 tooltip、要能 Tab 键聚焦、要能用 CSS 过渡改颜色,直接操作节点比在 Canvas 上模拟焦点管理系统轻松得多。判断标准我给一条:图表上的元素是否需要被无障碍工具或自动化测试“看见”。需要,就选 SVG 路线;只是给人眼看,Canvas 更划算。

维度Canvas 2DSVG
首次绘制开销低,一次路径批处理中,节点创建有成本
频繁重绘(动画)整图重画,可控改属性触发样式重算,易掉帧
元素级事件需手写命中检测原生支持
无障碍与自动化基本不可见节点可读
导出图片直接 toDataURL需序列化后转图片

组件的默认渲染器走 Canvas,遇到需要导出、需要无障碍的页面时,我一般先问一句:这两条诉求哪个是真需求。多数后台页面其实只要一张能截图的图,Canvas 完全够。

2.2 目录结构与一个能跑起来的最小页面

拿到包先别读源码,先看目录。这类组件的结构大同小异,对照着看能省半小时:

chart-kit/ ├── src/ │ ├── core/ # 画布初始化、像素比适配、重绘调度 │ ├── charts/ # bar.js / pie.js / line.js,一个文件对应一种图 │ ├── scale/ # 刻度计算、数值到坐标的映射 │ └── theme/ # 配色、字号、间距,改这里比改绘制代码安全 ├── dist/ │ ├── chart.js # 未压缩,断点调试看这个 │ └── chart.min.js ├── demo/ │ └── index.html # 每种图的配置样例,改参数最快的地方 └── README.md

charts/是重点,一种图的绘制逻辑集中在一个文件里,改柱子的圆角、改饼图的引导线长度,都在这层。scale/单独抽出来说明作者想过刻度计算的复用问题——柱状图和折线图共用同一套 y 轴刻度逻辑,这个拆法是对的。

最小页面这样起步(构造名以包里导出的为准,示例里叫MiniChart):

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8" /> <style> /* 容器必须有确定高度:组件的尺寸是从父容器读的,高度塌陷会读到 0 */ #chart { width: 640px; height: 360px; } </style> </head> <body> <div id="chart"></div> <script src="./dist/chart.js"></script> <script> const chart = new MiniChart(document.getElementById('chart'), { type: 'bar', data: { categories: ['一月', '二月', '三月'], series: [{ name: '订单量', data: [120, 200, 150] }] }, options: { padding: [24, 16, 32, 48], // 上 右 下 左,左下要给轴标签留够 showLegend: true, animation: false // 调试期先关动画,看清静态版式 } }); </script> </body> </html>

padding的四个值按上、右、下、左的顺序给,左值不够时长刻度数字会被裁掉一半,这是最常见的“图看着怪”来源。animation: false是调试期的习惯:动画开着的时候肉眼看不出柱子是不是差了 2 像素,关掉之后版式问题一目了然。data.categories和series是内部中间结构,后面的柱状图和饼图都基于它。

2.3 实例生命周期:init、setOption、resize、destroy

组件暴露的方法不多,实际用起来只有四个需要记住:

// 1) 初始化后可随时换配置,内部会做一次浅合并 chart.setOption({ options: { showLegend: false } }); // 2) 换数据不要重新 new,直接传新数据,省掉重复的容器测量 chart.setData({ categories: ['四月'], series: [{ name: '订单量', data: [180] }] }); // 3) 容器尺寸变了要主动通知,组件不监听窗口 chart.resize(); // 4) 页面销毁前调一次,把挂在上面的监听和定时器摘掉 chart.destroy();

setData和setOption分开是有意的:数据变了要重算刻度和比例尺,配置变了往往只影响绘制阶段。如果你的改动同时涉及两者,先setOption再setData,避免用旧刻度去画新数据。

resize()需要手动调,这点经常被吐槽,但换成组件自己监听 window 的话,一个页面十张图就是十个监听器,谁都不想接这个锅。常见做法是外面套一层ResizeObserver,只观察图表容器而不是整个窗口——侧边栏折叠、Tab 切换这类不触发 window resize 的场景,只有观察容器才收得到。destroy()在单页应用里必须调,路由切走后旧实例还挂在 resize 观察器上,页面切回来会发现两张图叠在一起,这种问题排查起来非常费劲。

3. 柱状图落地:数据契约、刻度映射与三种柱子形态

柱状图的绘制本身不难,难在数据进来之前的形状不统一,和刻度算出来的数字不好看。这一章把这两件事拆开做:先把输入归一化,再把刻度算“圆整”,最后才是画柱子。

3.1 数据契约:三种输入形态与归一化函数

实际项目里传进来的数据至少有三种写法:纯数字数组、{label, value}对象数组、已经分好序列的{categories, series}。组件的绘制层不该关心这些差异,所以在入口处统一成一种中间结构,后面只认这一种。这层归一化值得单独写,别塞在绘制函数里:

function normalize(input) { // 形态一:单序列数字数组 [12, 30, 8] if (Array.isArray(input) && typeof input[0] === 'number') { return { categories: input.map((_, i) => String(i + 1)), series: [{ name: '默认', data: input.slice() }] }; } // 形态二:对象数组 [{ label: '一月', value: 12 }] if (Array.isArray(input) && input[0] && 'value' in input[0]) { return { categories: input.map(d => d.label), series: [{ name: '默认', data: input.map(d => Number(d.value) || 0) }] }; } // 形态三:已经是 { categories, series },只做数值兜底 return { categories: input.categories.slice(), series: input.series.map(s => ({ name: s.name || '', // 接口返回的 "120" 这种字符串数字要转掉;null / NaN / undefined 一律归零 data: s.data.map(v => (Number.isFinite(+v) ? +v : 0)) })) }; }

三个分支的顺序不能调。先判typeof input[0] === 'number'是因为纯数组最容易识别;第二个分支用'value' in input[0]而不是input[0].value判断,原因是后者遇到value: 0会被当成假值漏掉,饼图里“某一项是 0”是合法数据,这个坑很隐蔽。

最后那个Number.isFinite(+v)的兜底是血泪经验:后端接口在缺数据时返回null,+null是0看似没事,但返回""时+""也是0,而返回"-"时+"-"是NaN。NaN一旦进了刻度计算,整张图会变成空白画布,而且控制台一声不响——图表不报错,只是不画,这是最难受的一类问题。

3.2 刻度与坐标映射:nice 步长、零基线、留白

原始数据最大值是 187 的时候,把 y 轴顶端定在 187 会得到一堆带小数的刻度,读起来别扭。常规做法是把刻度间隔取成 1、2、5 乘以 10 的整数次幂,这样出来的刻度都是人能一眼读的整数:

// 把原始最大最小值扩成"圆整"的刻度序列 function niceTicks(min, max, count = 5) { if (min === max) max = min + 1; // 全部数据相等时兜底,否则步长算出 0 会死循环 const span = (max - min) / count; const mag = Math.pow(10, Math.floor(Math.log10(span))); const norm = span / mag; // 1 / 2 / 5 / 10 四档,向下取整到最近的档位,保证刻度数是整数 const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10) * mag; const niceMin = Math.floor(min / step) * step; const niceMax = Math.ceil(max / step) * step; const ticks = []; // 加 step * 1e-9 是为了吃掉浮点累加误差,否则最后一项经常差一点点画不出来 for (let v = niceMin; v <= niceMax + step * 1e-9; v += step) { ticks.push(Number(v.toFixed(10))); } return { ticks, niceMin, niceMax }; }

count是期望的刻度段数,不是最终刻度个数——niceTicks(0, 187, 5)会给出 0、50、100、150、200 五条线加顶端的 200,实际段数刚好。mag那一步取的是量级,norm归一化到 1 到 10 之间再选档,这套写法在图表的刻度计算里很通用。

柱状图有一个额外约束:基线必须是 0。折线图可以把 y 轴下限抬到 80 来突出波动,柱状图不行——柱子长度直接编码数值大小,基线不在 0 上,2 和 3 的柱子长度差会看起来像 2 和 10,属于会误导人的画法。所以传给niceTicks的min一律取Math.min(0, ...data),除非你有意做正负双向柱。

映射函数是一行线性变换,但方向别写反(Canvas 的 y 轴向下):

const plotTop = padding[0]; const plotBottom = height - padding[2]; const plotHeight = plotBottom - plotTop; const y = v => plotBottom - ((v - niceMin) / (niceMax - niceMin)) * plotHeight;

plotBottom对应niceMin(通常就是 0),plotTop对应niceMax。数值越大,y 越小,柱子往上长,这个方向搞反的话整张图会上下颠倒,且看起来“像那么回事”,容易被忽略。

3.3 分组柱、堆叠柱与柱宽/圆角的参数改法

单序列柱状图只要把类目带宽平均分就行,多序列就得分组或堆叠。分组是平行摆放,堆叠是累加基线,两种的画法差别就在基线的算法:

const bandWidth = plotWidth / categories.length; // 每个类目占的横向带宽 // —— 分组柱:同一类目下的 N 根柱子平分带宽 —— const inner = bandWidth * 0.72; // 组内可用宽度,留 28% 给类目之间的视觉间隙 const barWidth = (inner / series.length) * 0.9; // 组内柱间再留 10% 缝 const groupX = i => plotLeft + i * bandWidth + (bandWidth - inner) / 2; series.forEach((s, si) => { categories.forEach((_, i) => { const v = s.data[i] || 0; const x = groupX(i) + si * (inner / series.length); const yTop = y(v); ctx.fillRect(x, yTop, barWidth, y(0) - yTop); // 高度用基线减柱顶,负数自动反向 }); }); // —— 堆叠柱:基线逐层累加 —— let base = 0; series.forEach(s => { const v = s.data[i] || 0; const y0 = y(base), y1 = y(base + v); ctx.fillRect(x, y1, barWidth, y0 - y1); // 高度取两段基线之差 base += v; });

inner和barWidth里那两个百分比系数,是这类组件最常见的可调参数,给个对照方便你按数据量调:

参数作用常用区间什么时候动它
categoryGap类目之间的留白比例0.2 ~ 0.4类目只有三四组时调大,柱子显瘦不糊
barGap组内柱之间的缝0.1 ~ 0.3序列超过 4 条时调到 0.1,否则柱子细得看不清
barRadius柱顶圆角0 ~ 4柱宽小于 20px 时别加,圆角会吃掉柱子高度观感
stack是否堆叠false各项之和本身有意义时才开

圆角这里有一个注意事项:用roundRect或者手写圆角路径时,圆角半径不能超过柱宽的一半,否则中间那段会被挤成负长度,某些浏览器会画出反向的缺口。稳妥写法是const r = Math.min(barRadius, barWidth / 2, barHeight / 2),三个值取最小。这个保护条件我一般直接写在绘制函数里,不去指望调用方传对。

堆叠柱还要额外处理一个问题:如果某个序列里出现负数,y0 - y1会变成负值,柱子会画到反方向去。规范做法是把正负分开累加,正数向上堆、负数向下堆,两条基线各走各的。数据里可能出现负值时,这一点必须补,否则视觉上会和分组柱共用一个基线一样错乱。

4. 饼图与环形图:角度累加、标签避让和命中判定

饼图看起来是最简单的一种图,实际上坑比柱状图多。柱状图的错误一眼能看出来,饼图的错误往往是“少了一块”“多出一点点”“标签叠在一起”,不仔细看会当成设计如此。

4.1 从占比到弧度:起始角、方向与 0 值扇区

饼图的核心就三行:算总和、按比例转弧度、累加起始角。但每一行都有约定要定死。

const total = data.reduce((s, d) => s + Math.max(0, d.value), 0); const cx = width / 2; const cy = height / 2; const r = Math.min(width, height) / 2 - 28; // 半径留 28px 给外部标签 let start = -Math.PI / 2; // 从 12 点方向起笔,顺时针 data.forEach(d => { const ratio = total === 0 ? 0 : Math.max(0, d.value) / total; const angle = ratio * Math.PI * 2; ctx.beginPath(); ctx.moveTo(cx, cy); // 先连到圆心,画出来才是扇形不是弓形 ctx.arc(cx, cy, r, start, start + angle); ctx.closePath(); ctx.fillStyle = colorOf(d); ctx.fill(); start += angle; });

起始角-Math.PI / 2是 Canvas 里 12 点方向的弧度值,从 12 点开始顺时针排是财务和报表场景的通行约定。如果从 3 点方向(弧度 0)起笔,第一块扇区会歪在右边,跟图例对不上。

Math.max(0, d.value)过滤负数:饼图表达的是占比,负占比没有意义,直接把负值当 0 处理比抛错更符合业务预期。total === 0的判断必须写在除号之前,全部为 0 的数据会得到0/0 = NaN,NaN传给arc不会报错,只会整圈什么都不画,又是一次静默失败。

moveTo(cx, cy)这一句经常被漏掉。少写它的时候,第二块扇区会从上一块扇区的终点直接连过来,画出一条诡异的弦,整个图形看起来像被切了一刀的圆。这个现象在只有一块数据时反而正常,所以测试数据一定要用三条以上。

4.2 标签与引导线:位置不够时怎么退让

占比低于 5% 的扇区,标签塞进扇形内部会挤成一团,所以常规做法是把标签挪到外侧,用一条折线引导回来。位置按扇区中角判断左右:

const mid = start + angle / 2; // 扇区中角,标签沿这个方向出去 const dir = Math.cos(mid) >= 0 ? 1 : -1; // 右半边为 1,左半边为 -1 const p1 = [cx + Math.cos(mid) * r, cy + Math.sin(mid) * r]; // 扇区边缘 const p2 = [cx + Math.cos(mid) * (r + 16), cy + Math.sin(mid) * (r + 16)]; // 拐点 const p3 = [p2[0] + dir * 20, p2[1]]; // 水平段终点 // 上下边界夹一下,避免标签跑出画布 const ty = Math.min(Math.max(p3[1], 12), height - 12); ctx.strokeStyle = '#c8ccd4'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(p1[0], p1[1]); ctx.lineTo(p2[0], p2[1]); ctx.lineTo(p3[0], ty); ctx.stroke(); ctx.textAlign = dir > 0 ? 'left' : 'right'; // 右侧文字左对齐往外推,左侧反过来 ctx.textBaseline = 'middle'; ctx.fillText(`${d.label} ${(d.value / total * 100).toFixed(1)}%`, p3[0] + dir * 4, ty);

dir用Math.cos(mid)判断,是因为 Canvas 里角度从 3 点方向起算,余弦为正对应右半边,这个判断比用 x 坐标比较更稳——扇区跨过 12 点时,x 坐标会先增后减,用坐标比较会误判。

上下两个标签撞在一起是更麻烦的情况。稳妥做法是把同侧标签按 y 排序后做一次「最小间距推挤」:从下往上扫一遍,如果相邻两个 y 差小于行高(比如 16px),就把上面那个往上顶。这段逻辑不长,但不做的话,数据里有两个相邻的 2% 小项时,标签必然重叠,交付时一眼就被看出来。

4.3 环形图中心文案与扇区交互回调

环形图跟饼图共用一套角度逻辑,区别只有内半径。画的时候从外圈画到内圈,方向要反过来,最后closePath才是正确的环带:

const innerR = r * 0.58; // 内半径占外半径的比例,0.55 ~ 0.62 之间视觉最稳 ctx.beginPath(); ctx.arc(cx, cy, r, start, start + angle); ctx.arc(cx, cy, innerR, start + angle, start, true); // 最后一个参数 true 表示逆时针画回来 ctx.closePath(); ctx.fill();

innerR太小环带会很细,鼠标悬停时命中区域也跟着变窄,用户体验差;太大又不像环形图像个甜甜圈。0.58 是试出来的经验值,字体较大的场景可以到 0.62,中心要放两行文字的压到 0.55。

中心文案直接fillText居中画,两行的话用textBaseline: 'middle'配合cy ± 行高/2。

交互这块,Canvas 没有元素可挂事件,只能在容器的mousemove里反查极坐标:

container.addEventListener('mousemove', e => { const rect = container.getBoundingClientRect(); // 注意减去容器偏移,不是 e.offsetX 就万事大吉 const px = e.clientX - rect.left; const py = e.clientY - rect.top; const dx = px - cx, dy = py - cy; const dist = Math.hypot(dx, dy); if (dist > r || dist < innerR) { active = -1; return; } // 环外和中心空洞都不命中 let ang = Math.atan2(dy, dx) + Math.PI / 2; // 对齐 12 点起笔 if (ang < 0) ang += Math.PI * 2; let acc = 0; for (let i = 0; i < data.length; i++) { const a = (data[i].value / total) * Math.PI * 2; if (ang >= acc && ang < acc + a) { active = i; break; } acc += a; } redraw(); // 只重绘,不重建实例 });

Math.atan2(dy, dx)返回的是 -π 到 π,加π/2平移之后取模,才能和绘制时的起始角对齐。这一行如果漏了if (ang < 0) ang += Math.PI * 2,落在 12 点左侧的扇区永远命中不到,表现为“右边能高亮、左边没反应”,第一次遇到会怀疑是浏览器的问题。

getBoundingClientRect()而不是offsetX:容器有边框或者被 CSS transform 缩放时,offsetX给的是未经缩放的坐标,缩放过的页面会整体偏移。这个坑在做大屏适配时必踩。

5. 避坑与排查:五个能让图表当场翻车的点

前面几章讲的是怎么画对,这一章讲的是画完之后为什么看着不对。这五条是我在这套组件上按出现频率排的,基本覆盖了日常九成的“图有问题”。

5.1 首屏图表发虚:像素比没乘

现象:图表在普通屏幕上没问题,一到高分屏(笔记本、手机)整张图边缘发毛,文字像糊了一层,截图放大更明显。

原因:Canvas 的 CSS 尺寸和位图尺寸是两个概念。只设了canvas.width = 640,在 2 倍屏上这个 640 会被拉伸到 1280 个物理像素去显示,等于每一像素被放大了一倍,必然发虚。

解决:位图尺寸乘上devicePixelRatio,再用ctx.scale把坐标系缩回逻辑像素,绘制代码一行都不用改:

const dpr = window.devicePixelRatio || 1; const { width, height } = container.getBoundingClientRect(); canvas.style.width = width + 'px'; canvas.style.height = height + 'px'; canvas.width = Math.round(width * dpr); // 位图真实像素 canvas.height = Math.round(height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 之后所有坐标都按逻辑像素写

用setTransform而不是ctx.scale(dpr, dpr),原因是scale是累加的,每次 resize 都调一遍会缩成 4 倍、8 倍。setTransform是覆盖式设置,重绘多少次都稳定。

5.2 切换数据后图例残留:只清了画布没重置状态

现象:筛选项一变,柱子更新了,但上一份数据的图例还在,颜色也对不上;多切几次之后图例条目越来越多。

原因:重绘时只做了clearRect,图例数组、色板游标这些实例状态还留着上一轮的值。clearRect清的是像素,不是内存里的变量。

解决:把「重置状态」和「清除像素」写成一个reset(),重绘前固定调用一次:

function reset() { ctx.clearRect(0, 0, width, height); // 逻辑坐标,因为已经 setTransform 过 legendItems.length = 0; // 用 length = 0 清数组,保留引用 colorCursor = 0; activeIndex = -1; }

数组用length = 0而不用= [],是因为外部可能持有这个数组的引用(比如图例 DOM 重渲染时闭包捕获了它),换新数组会让外部拿到一个永远不更新的旧数组。这个细节在把图例拆成独立 DOM 节点时特别关键。

5.3 容器隐藏时初始化,宽度读到 0

现象:图表放在弹窗或者未激活的 Tab 里,打开时一片空白,切到别的 Tab 再切回来又正常了。

原因:display: none的元素getBoundingClientRect()返回全 0,组件按 0 宽高算出的比例尺全是NaN,画出来什么都没有。切 Tab 触发了一次 resize,尺寸才被重新读到,所以看起来“切一下就好了”。

解决:初始化前判一次尺寸,为 0 就挂到可见性变化上再初始化:

function mount() { const { width, height } = container.getBoundingClientRect(); if (width === 0 || height === 0) { // 弹窗/隐藏 Tab,等真正可见了再画 const io = new IntersectionObserver(entries => { if (entries[0].intersectionRatio > 0) { io.disconnect(); mount(); } }); io.observe(container); return; } chart = new MiniChart(container, config); }

IntersectionObserver比监听父级 CSS 变化稳,因为隐藏的原因可能是弹窗、Tab、折叠面板,逐个适配不现实。

5.4 饼图少一块或整圈铺满:浮点累加与全零数据

现象:三种情况会撞上。一是数据总和是 0,整圈画满一个颜色;二是最后一块扇区差一点点合不拢,留一条细缝;三是某个值为 0 的项居然占了一小块。

原因:总和为 0 时0/0得NaN,arc拿到NaN不报错,行为不可预期;浮点累加start += angle每一轮都有极小误差,十几块之后累积可见;值为 0 时角度是 0,理论上不画,但moveTo(cx, cy)加arc起止角相同仍会留下一个像素级的点。

解决:三个地方各加一道保护。总和为 0 时画一个灰色空心圆加“暂无数据”,不进入扇区循环;循环结束后把最后一块的终止角强制设成-Math.PI / 2 + Math.PI * 2,不用累加值;角度小于1e-6的扇区直接continue跳过绘制。

5.5 拖拽缩放窗口后卡顿:resize 里同步重绘

现象:拖动浏览器窗口边缘时,图表区域明显掉帧,多张图同时存在时整个页面跟着卡。

原因:resize事件触发频率极高,每触发一次就整套重算刻度、重建路径、重画一遍,一秒钟能画几十次,直接把主线程占满。

解决:用requestAnimationFrame合并同一帧内的多次调用,只保留最后一次:

let rafId = 0; const scheduleResize = () => { if (rafId) return; // 本帧已经排过队了,直接丢弃 rafId = requestAnimationFrame(() => { rafId = 0; chart.resize(); }); }; observer = new ResizeObserver(scheduleResize); observer.observe(container);

if (rafId) return这个早退是关键,没有它每次回调都会排一个新帧,等于没合并。另外resize()内部如果重建了 Canvas,记得destroy()前先observer.disconnect(),否则观察器还指着已卸载的节点,控制台会持续报错。

6. 收口写法:把柱状图和饼图封成一套可复用配置模板

这套组件用久了会发现,真正重复的不是绘制代码,是配置。一个后台系统里十几张图,配色、字号、留白、图例位置基本一致,每处都抄一遍迟早会不一致。我一般封一个工厂函数把公共配置吃掉:

// 只暴露和业务相关的三个入参,其余走默认值 function createChart(el, type, data) { return new MiniChart(el, { type, data: normalize(data), // 复用第 3 章的归一化 options: { padding: [24, 16, 32, 48], color: ['#4a7cf7', '#38b6a0', '#f2a33c', '#e2604f', '#8f6fd0'], // 与设计稿对齐的色板 fontSize: 12, showLegend: true, legendPosition: 'bottom', animation: type !== 'pie' // 饼图关动画,扇区动起来容易看错占比 } }); }

饼图关动画这条是我吃过亏的:扇区做展开动画时中途停下来截图,比例看起来和最终值差很多,评审会上被当成数据算错。同一份配置里把色板的颜色数量定死,超过数量的序列会循环取色,避免出现第 6 条序列没有颜色直接画成透明。

上线前我会把这三项走一遍,比看代码快得多:

检查项通过标准常见不通过表现
边界数据全 0、单条、超 20 条、含负数都能画出来空白画布、扇区合不拢、柱子反向
尺寸变化侧栏折叠、窗口缩放、弹窗打开各试一次图形发虚、标签被裁、图例错位
销毁重建路由切走再切回,连续三次图例翻倍、事件重复触发、控制台报错

这三项里最容易被省掉的是第三项,而它恰恰是单页应用里问题最多的。从那以后我每接一个图表需求,都会在提交前把「全 0 数据」和「切走三次再回来」这两个动作强制走一遍,宁可多花五分钟,也不想再在演示现场看着一张空白的图解释“这是数据的问题”。希望帮到你。

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

返回列表