纯HTML Canvas图表插件:折线图、柱状图与饼图一次实现 简介统计折线图、柱状图与饼图一体的纯HTML图表插件面向Web前端初学者、需要快速产出数据报表的开发者。压缩包共1个文件为单个HTML文件大小约3KB浏览器直接打开即可体验。插件以原生HTML/CSS/JS结合第三方图表库实现用纯JS方法将表格数据绑定到图表模型在table表格中填写数据、刷新后自动生成图表支持折线图与柱状图一键切换也能将生成图表下载到本地表格可动态增加行列tr/td便于灵活增删数据。同时模块兼容Vue项目适合课程设计、运营报表、内部数据看板等场景复用。已有835人学习/下载。对想掌握图表库引入、数据组装与动态渲染的读者这是一份轻量直观的入门范例可直接借鉴表格联动、图表切换与导出思路零基础用户也能通过该实例快速理解在线图表生成的完整流程。1. 纯 html charts 插件解决什么问题三类基础统计图一次到位很多后台统计页面功能需求就是折线图看趋势、柱状图看对比、饼图看占比却经常被迫引入一套体积很大的图表框架。这份「统计折线图-柱状图-饼图纯html-charts插件」是另一种路子一份标准化的 Canvas 绘制代码用统一的数据入口输出三种图不依赖 Vue、React 或任何图表库页面加载时就少了很多不必要的资源。它对三类人特别实用想搞懂图表绘制原理的前端新手、需要给内部系统做轻量报表的开发者以及接手老项目、没法随便加依赖的维护者。下面从选型逻辑拆到代码实现再讲清楚高频踩坑点和扩展技巧。2. 绘制引擎选型与统一数据入口先定路线再写绘制代码2.1 Canvas、SVG 与纯 Div 的取舍为什么这个插件押注 Canvas图表渲染在浏览器里主要有三条路线Canvas、SVG、以及纯 CSS/Div。三者都能画出折线、柱状和饼图但维护成本和性能边界完全不同。SVG 把每个图形元素变成 DOM 节点样式可以用 CSS 直接控制事件系统也是现成的。图表里数据点少于几十个时体验很好但要画上千个点的折线图DOM 节点一多滚动和重绘都能感觉到卡顿。Canvas 是位图绘制模型整个图表最终就是一块画布画几千个点只是一次路径绘制性能和内存都占优代价是点选、悬浮这类交互都要自己写命中检测。纯 Div 做柱状图最直白一个 div 的高度就是柱子但折线的斜率、饼图的扇形都要硬靠 CSS 变换拼出来维护起来比较别扭。这个插件之所以把核心绘制放在 Canvas 上是基于三种图的使用场景判断的折线图数据点可到几百个SVG 的节点压力在这时就显现了柱状图在数据刷新时会整批重绘canvas 的 clearRect 加重绘比 DOM 差异更新更可预测饼图需要大量圆弧操作canvas 的原生 arc API 恰好覆盖。所以如果只做一张静态的、点数为两位数的小图表选 SVG 反而省事如果要同时支持三种图、还要扛住定时刷新Canvas 是更稳的底子。下面是三种方案在关键维度上的对比维度SVGCanvas纯 Div图形节点数每个元素一个节点整图一个节点每根柱子一个容器事件处理原生 DOM 事件需 isPointInPath 手动检测原生 DOM 事件高分辨率适配天然矢量清晰需按 devicePixelRatio 缩放依赖 CSS 尺寸设置大批量数据点节点多、卡顿风险高绘制命令批处理稳定不适合动画实现CSS/SMIL 均可requestAnimationFrameCSS transition 最方便提示如果你的实际场景只有柱状图且数据量小于三十项我更推荐直接用 div 写逻辑更直观动画还不用重绘整个 canvas。图表要同时覆盖三种图时退到 Canvas 才是性价比最高的选择。2.2 数据标准化一份输入三种图表共用再好的绘制引擎也得先解决一个问题用户的数据格式五花八门。有人传平行数组有人传对象数组还有人直接把后端拼好的字符串丢进来。这份插件在入口处就做了一层 normalize把数据统一成{ labels, values }两个数组后面的绘制函数只认这两样东西。这样做的好处是把数据清洗和绘图解耦。折线图关心趋势顺序柱状图关心分类对齐饼图关心占比三者的理解方式不同只要在入口处把它们变成同一份中间表达绘制函数就完全不需要知道数据的原始形态。项目里常见两种输入// 写法一平行数组——分类和数值一一对应 const categories [一月, 二月, 三月, 四月]; const values [120, 160, 140, 200]; // 写法二对象数组——字段名可能叫 name也可能叫 label const rows [ { name: 一月, value: 120 }, { name: 二月, value: 160 }, { name: 三月, value: 140 }, { name: 四月, value: 200 } ];入口处对应的标准函数如下function normalizeData(data) { if (Array.isArray(data)) { if (data.length 0) { return { labels: [], values: [] }; } // 纯数字数组自动生成“第 N 项”标签 if (typeof data[0] number) { return { labels: data.map((_, i) 第 (i 1) 项), values: data.map(Number) }; } // 对象数组兼容 name / label / 单独 value 字段 if (typeof data[0] object) { return { labels: data.map((item) item.name ?? item.label ?? 未命名), values: data.map((item) Number(item.value) || 0) }; } } // 已经传成 { labels, values } 的对象直接放行 if (data Array.isArray(data.labels) Array.isArray(data.values)) { return { labels: data.labels, values: data.values.map(v Number.isNaN(v) ? 0 : v) }; } return { labels: [], values: [] }; }这里每个分支都有具体原因。纯数字数组常见于接口直接把聚合结果透传回来此时没有分类名就按下标补一个。对象数组里name和label两种字段都存在用空值合并运算符??依次取避免后端字段命名不统一导致图表缺标签。Number(item.value) || 0会把字符串数字转成 number同时把 null、undefined 这类空值兜底成 0防止后面的减法和除法得到 NaN。注意Number(null)返回 0但Number(undefined)返回 NaN所以上面才显式补一个Number.isNaN(v) ? 0 : v的二次清洗。数据接口偶发缺字段时这一行能避免整张图白屏。2.3 坐标系转换与刻度生成线为什么不会飘Canvas 的原点在左上角x 轴向右增长y 轴向下增长。统计图恰恰相反我们希望数值越大画得越高也就是 y 方向上要“倒过来”。这个转换是绘制所有图表的共同底座插件里抽象成一个比例尺函数function buildScale(area, labels, values) { const maxRaw Math.max(...values); const minRaw Math.min(...values); const maxV maxRaw 0 ? maxRaw * 1.15 : 1; const minV minRaw 0 ? minRaw * 1.15 : 0; const range maxV - minV || 1; const innerW area.right - area.left; const innerH area.bottom - area.top; return { maxV, minV, // 横轴按类别序号等分首尾贴住绘图区左右边界 toX(i) { return area.left (i / (labels.length - 1)) * innerW; }, // 纵轴把数值反转成画布坐标 toY(v) { return area.bottom - ((v - minV) / range) * innerH; } }; }为什么 maxV 要乘 1.15图表最顶上的数据点如果刚好贴到绘图区上边缘视觉上会显得“顶头”数据点的圆点和柱顶的数字标签也没有地方放。乘一个固定比例等于给顶部预留 15% 的余量。minV 的分支逻辑更关键当数据全是正数时强制 minV 为 0这样柱状图才有真正的零基线当数据里有负数时才把范围扩展到负区。range maxV - minV || 1是防止数据全为同一个值比如全是 5时分母为零绘图直接静默失败。有了比例尺坐标网格的刻度就能按固定数量生成。典型做法是画 5 格也就是 6 条横线function genYTicks(scale, maxV, minV, tickCount 5) { const ticks []; const step (maxV - minV) / tickCount; for (let i 0; i tickCount; i) { const val minV step * i; ticks.push({ value: val, y: scale.toY(val) }); } return ticks; }tickCount 默认取 5 而不是 10是因为后台看板宽度通常在 800px 上下刻度文字要能放下又不过于密集。具体项目如果画布变宽可以把它调成和Math.floor(innerH / 50)联动保证每两条网格线之间至少有 50px 的垂直间距。刻度值通常是小数显示前再做一次四舍五入或保留一位小数避免出现很多位小数这种让人想砸屏幕的标签。3. 折线、柱状、饼图的绘制实现完整代码与参数解析3.1 折线图绘制网格、折线、数据点与倾斜标签四件套先把初始化封装成公共函数内部完成 DPR 适配三种图表共用function initCanvas(canvasId) { const canvas document.getElementById(canvasId); const dpr window.devicePixelRatio || 1; const rect canvas.getBoundingClientRect(); canvas.width Math.round(rect.width * dpr); canvas.height Math.round(rect.height * dpr); canvas.style.width rect.width px; canvas.style.height rect.height px; const ctx canvas.getContext(2d); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return { ctx, width: rect.width, height: rect.height }; }这里的做法是先取 CSS 尺寸再乘 DPR 设置画布物理像素最后用setTransform把绘图坐标恢复成 CSS 像素坐标。接下来折线图绘制function drawLineChart(canvasId, data, theme) { const { ctx, width, height } initCanvas(canvasId); const area theme.area || { left: 48, right: width - 24, top: 24, bottom: height - 40 }; const info normalizeData(data); const scale buildScale(area, info.labels, info.values); // 第一步横向网格 ctx.strokeStyle theme.gridColor; ctx.lineWidth 1; genYTicks(scale, scale.maxV, scale.minV).forEach((tick) { ctx.beginPath(); ctx.moveTo(area.left, tick.y); ctx.lineTo(area.right, tick.y); ctx.stroke(); // 左侧刻度数字 ctx.fillStyle theme.labelColor; ctx.textAlign right; ctx.textBaseline middle; ctx.fillText(Math.round(tick.value), area.left - 6, tick.y); }); // 第二步主折线 ctx.strokeStyle theme.lineColor; ctx.lineWidth theme.lineWidth; ctx.beginPath(); info.values.forEach((v, i) { const px scale.toX(i); const py scale.toY(v); i 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py); }); ctx.stroke(); // 第三步数据点 if (theme.showDots) { info.values.forEach((v, i) { ctx.beginPath(); ctx.arc(scale.toX(i), scale.toY(v), theme.pointRadius, 0, 2 * Math.PI); ctx.fillStyle theme.pointColor; ctx.fill(); ctx.strokeStyle #fff; ctx.lineWidth 1.5; ctx.stroke(); }); } // 第四步底部横轴标签 ctx.fillStyle theme.labelColor; ctx.textAlign center; info.labels.forEach((label, i) { ctx.fillText(label, scale.toX(i), area.bottom 16); }); }绘制的顺序是有讲究的。先画网格网格在折线下面形成背景参考再画折线确保主数据线浮在最上层数据点用白色描边包一圈能和折线颜色区分开在高密度折线里的辨识度会明显提升。底部标签的常见问题是分类名太长。比如“2024年第三季度”这种字符串在窄画布上会互相重叠。项目里的处理是横轴标签超过一定字符数时做旋转if (label.length 4) { ctx.save(); ctx.translate(scale.toX(i), area.bottom 6); ctx.rotate(-Math.PI / 4); ctx.fillText(label, 0, 0); ctx.restore(); }旋转后文字方向朝左上倾斜两个相邻标签之间的水平占用变小重叠问题就缓解了。参数上 theme 里常用的字段是 lineWidth默认 2数据密集时可调 1、pointRadius默认 3点太多时调 0 变成纯折线、gridColor默认浅灰深色主题下调成半透明白。这些是实际项目中我会反复调整的项。3.2 柱状图绘制柱宽算法与零基线柱状图的难点不在画矩形而在“柱子放在哪里、柱子多宽、负值怎么摆”。下面这段是核心绘制逻辑function drawBarChart(canvasId, data, theme) { const { ctx, width, height } initCanvas(canvasId); const area theme.area || { left: 48, right: width - 24, top: 24, bottom: height - 40 }; const info normalizeData(data); const scale buildScale(area, info.labels, info.values); const n info.labels.length; const slot (area.right - area.left) / n; const barWidth slot * (theme.barRatio ?? 0.62); const zeroY scale.toY(0); info.values.forEach((v, i) { const x area.left slot * i (slot - barWidth) / 2; const y scale.toY(v); ctx.fillStyle theme.palette[i % theme.palette.length]; if (v 0) { // 正数从零基线上方往下画到 y ctx.fillRect(x, y, barWidth, zeroY - y); } else { // 负数从零基线往下继续画 ctx.fillRect(x, zeroY, barWidth, y - zeroY); } // 柱顶数值负数的标签放柱子下方 ctx.fillStyle theme.labelColor; ctx.textAlign center; if (v 0) { ctx.fillText(v, x barWidth / 2, y - 4); } else { ctx.fillText(v, x barWidth / 2, zeroY 14); } }); }slot 是一个分类占用的水平宽度它等于绘图区宽度除以分类数。barWidth 取 slot 的 0.62 倍柱子之间自然留出 38% 的间距这是后台图表最常见的风格想要柱子更粗就调高到 0.78想要更稀疏就调到 0.5。x area.left slot * i (slot - barWidth) / 2的含义是先算出第 i 个分类的槽位起点再把柱子在槽内居中。这里必须依赖 2.3 节 buildScale 的 minV 逻辑。如果数据全为正数而 minV 被设成数据最小值zeroY 就不是真正的零基线正负柱子的视觉对齐关系会被破坏。柱高用zeroY - y而非area.bottom - y也是为了保证零基线存在时柱子和基线之间没有偏差。多系列柱状图比如同比两期数据是另一个常见扩展。做法是把 slot 再细分const seriesCount series.length; const barWidth slot / seriesCount * 0.8; series.forEach((serie, s) { const x area.left slot * i (slot - barWidth * seriesCount) / 2 s * barWidth; // 之后按同一逻辑画矩形 });这样两个柱子挤在一个分类槽里中间只留 20% 的填空视觉上是对齐的。不过系列多时颜色可辨识度下降一般不超过 3 个系列超过了我宁愿拆成多张子图。3.3 饼图绘制扇形拼接与标签防重叠饼图的核心是弧度和角度计算。数据总和为 total每个扇区占的弧度是value / total * 2π从 12 点方向开始逆时针排。这里给出一段核心绘制函数function drawPieChart(canvasId, data, theme) { const { ctx, width, height } initCanvas(canvasId); const area theme.area || { left: 0, right: width, top: 0, bottom: height }; const info normalizeData(data); const total info.values.reduce((sum, v) sum Math.max(0, v), 0); if (total 0) { ctx.fillStyle theme.labelColor; ctx.textAlign center; ctx.fillText(暂无数据, width / 2, height / 2); return; } const cx width / 2; const cy height / 2; const radius Math.min(width, height) / 2 - (theme.padding ?? 16); let startAngle -Math.PI / 2; info.values.forEach((v, i) { if (v 0) return; const sweep (v / total) * Math.PI * 2; // 画扇形先移动到圆心再画弧最后闭合路径 ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, radius, startAngle, startAngle sweep); ctx.closePath(); ctx.fillStyle theme.palette[i % theme.palette.length]; ctx.fill(); // 标签内半径 65% 处放白字 const mid startAngle sweep / 2; const pct (v / total) * 100; if (pct 5) { const lx cx Math.cos(mid) * radius * 0.65; const ly cy Math.sin(mid) * radius * 0.65; ctx.fillStyle #ffffff; ctx.font 12px sans-serif; ctx.textAlign center; ctx.textBaseline middle; ctx.fillText(pct.toFixed(1) %, lx, ly); } startAngle sweep; }); }moveTo(cx, cy)加上closePath()是画扇形的关键缺了前者画出来的是弓形缺了后者弧线不会自动闭合。起点-Math.PI / 2对应 12 点方向这是统计图表的默认约定如果想要从 3 点方向开始改成 0 即可。标签防重叠的阈值我用 5%。小于 5% 的扇区弧长太短白字放进内半径会被相邻扇区压住所以要么不显示要么用引线拉到圆外if (pct 5 pct 0) { const outR radius 16; const lx cx Math.cos(mid) * radius * 0.8; const ly cy Math.sin(mid) * radius * 0.8; const tx cx Math.cos(mid) * outR; const ty cy Math.sin(mid) * outR; ctx.strokeStyle #999; ctx.beginPath(); ctx.moveTo(lx, ly); ctx.lineTo(tx, ty); ctx.stroke(); }这条引线把百分比从扇区内部“请”到了圆外窄扇区间的标签重叠会好很多。如果你的数据里有多个 2% 左右的小扇区引线之间还是会打架那个量级的场景建议直接改用柱状图展示不要在饼图里强行塞标签。4. 使用排查与避坑清单canvas 图表的四个高频翻车现场4.1 高清屏模糊devicePixelRatio 没适配现象把页面放在高分屏上打开折线图文字边缘出现明显锯齿和重影线条粗细比设计稿看起来粗了一圈普通屏上又是正常的排除了代码逻辑问题。原因canvas 绘图使用 CSS 像素高分屏的物理像素是 CSS 像素的 1.5 或 2 倍。不处理 DPR 时浏览器只能把一张低分辨率位图放大显示插值算法就会产生模糊。解决初始化 canvas 时把画布实际尺寸乘上 devicePixelRatio并调用setTransform重置后再缩放。前面 3.1 节的 initCanvas 已经体现这个思路这里补充一个容易漏掉的细节canvas.width Math.round(rect.width * dpr); canvas.height Math.round(rect.height * dpr); canvas.style.width rect.width px; canvas.style.height rect.height px; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);用setTransform而不是scale是因为窗口 resize 时会反复调用 initCanvas如果每次都在旧 transform 上叠加scale(2,2)坐标会被不断放大图表位置越偏越远。setTransform 会先重置矩阵再应用当前 DPR这才是幂等的写法。4.2 窗口缩放后图表错位resize 没有统一重绘现象浏览器从最大化缩到一半柱状图直接变成一团乱线或者右侧一大片空白切换标签页再回来饼图变成椭圆。原因窗口 resize 触发时容器宽度变了但 canvas.width 还是旧值。逻辑坐标没有跟着容器重新计算所有 toX/toY 映射全部基于旧绘图区自然错位。还有一种情况是监听函数里只改了画布样式宽高没有重新设置 canvas.width导致绘图坐标系和 CSS 尺寸脱节。解决把「初始化 canvas 计算比例尺 绘制图表」都收进一个 render 函数resize 时只调用它function renderCharts() { drawLineChart(lineCanvas, lineData, theme); drawBarChart(barCanvas, barData, theme); drawPieChart(pieCanvas, pieData, theme); } let ticking false; window.addEventListener(resize, () { if (ticking) return; ticking true; requestAnimationFrame(() { renderCharts(); ticking false; }); });requestAnimationFrame 在这里比 setTimeout 更适合它跟浏览器渲染帧同步最多每帧重绘一次不会因为连续触发 resize 事件产生几十次无效绘制。如果图表的容器在选项卡里还需要在选项卡切换时补一次 render因为隐藏容器的 getBoundingClientRect 可能返回 0 宽高等到显示时再画才能拿到正确尺寸。4.3 日期轴标签全部叠成一团黑刻度没有抽稀现象一个长达 90 天的趋势图x 轴底部的日期标签从第 10 天开始就重叠在一起整条标签带变成一团黑看不清任何字。原因绘制代码对每个数据点都调用了一次 fillText。90 个标签挤在 700px 的宽度里文字必然叠到相邻标签上。叠压后文字笔画层叠看起来就是黑的。解决指定一个标签最小水平间距反过来计算显示间隔const minLabelWidth 70; // 一个标签至少占 70px const maxLabels Math.max(2, Math.floor((area.right - area.left) / minLabelWidth)); const step Math.ceil(labels.length / maxLabels); labels.forEach((label, i) { if (i % step ! 0) return; ctx.fillText(label, scale.toX(i), area.bottom 16); });step 的含义是每隔几条数据显示一个标签。90 天数据在 700px 画布上maxLabels 是 10step 是 9也就是说第 1、10、19、28 天……显示标签其他点只画线不写字。如果标签本身是“2025-03-01”这种长字符串建议先把月份和日期拆开只显示“03-01”抽稀后仍能保持可读性。4.4 柱状图柱子悬空数值轴起点没有归零现象一组数据全在 85 到 92 之间柱状图画出来后柱子底部悬在绘图区三分之一高度的地方下方一大片空白柱子之间的高度差异却被放大了看起来像是数据波动很大实际上只有 7 的差异。原因这是误用了数据最小值作为 y 轴起点。柱状图约定必须从零基线开始否则柱子的长度比例失真视觉上零线消失柱子看起来就像“飘在空中”。解决正数数据里 minV 固定为 0function getMinV(values) { const dataMin Math.min(...values); return dataMin 0 ? dataMin * 1.1 : 0; }如果确实需要突出 85 到 92 的差异正确的做法是画一个带截断标记的坐标系在零基线和 80 之间画两道斜线折断让读者知道中间被截断了。直接改 minV 虽然柱子变长了但误导性太强交付出去会被业务方质疑。注意折线图不受这个约束它本身不强调面积比例minV 可以取数据最小值来放大波动。所以这个坑只针对柱状图。5. 让插件更顺手的两个扩展技巧主题定制与图表导出5.1 深色主题与配色体系这个插件默认给你的是浅色主题交付到可视化大屏时通常要换深色。与其在绘制函数里到处改颜色不如把颜色全部收进 theme 配置const defaultTheme { area: { left: 48, right: 24, top: 24, bottom: 40 }, palette: [#4e79a7, #f28e2b, #e15759, #76b7b2, #59a14f], gridColor: #e8e8e8, labelColor: #666, lineColor: #4e79a7, lineWidth: 2, pointRadius: 3, showDots: true, barRatio: 0.62 }; const darkTheme { ...defaultTheme, gridColor: rgba(255,255,255,0.15), labelColor: #ccc, lineColor: #66c2a5, palette: [#66c2a5, #fc8d62, #8da0cb, #e78ac3, #a6d854] }; function createChart(canvasId, options) { const theme { ...defaultTheme, ...(options.theme || {}) }; const data normalizeData(options.data); if (options.type line) return drawLineChart(canvasId, data, theme); if (options.type bar) return drawBarChart(canvasId, data, theme); if (options.type pie) return drawPieChart(canvasId, data, theme); }统一 theme 的好处是换主题时改一个对象三种图表颜色体系一起变深色大屏和浅色后台之间切换只需要两个 theme 对象来回赋值。5.2 图表导出为图片交付图表后经常要把结果放进周报截图工具画出来总有边框最简单的是利用 canvas 自带的导出function exportChart(canvasId, fileName chart.png) { const canvas document.getElementById(canvasId); const link document.createElement(a); link.download fileName; link.href canvas.toDataURL(image/png); link.click(); }如果导出的是隐藏 tab 中的 canvas某些浏览器会导出空白图。我一般会在导出前强制把 canvas 的 display 设为 block 并调用一次 renderCharts导出后再还原保证像素真正被渲染过。验证方法也比较固定造一组包含 0、最大值、负数和 NaN 的边界数据分别导出三张图肉眼确认颜色、刻度、标签都没问题再放回业务数据跑一遍。从那以后我每次做图表插件或改主题都强制走一遍导出核对流程边界数据不过颜色和标签再好看也不会往业务环境里放。这套流程帮我挡了不少奇怪的线上问题希望帮到你。本文还有配套的精品资源点击获取