尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从零封装Canvas绘图组件:柱状图、饼图与折线图实战

从零封装Canvas绘图组件:柱状图、饼图与折线图实战 简介一套面向Web前端开发者的JS绘图组件学习资料包重点展示柱状图、饼图、折线图等常见图表的实现方式。压缩包内共有51个文件以27个HTML示例、12张JPG效果图、9个XML配置文件为主另附JScharts.pdf、readme.txt及jscharts.js源文件整体大小约286KB。示例涵盖basic-blue-theme、custom-red-theme、custom-rainbow-theme等主题样式柱状图部分包含单轴多轴配置饼图支持扇区划分、标签指针与动态交互可直接通过浏览器打开演示。文档对配置选项、API调用方法做了说明readme提供安装步骤与常见问题解答方便开发者对照源码理解图表初始化、数据绑定与样式定制逻辑。已有171人学习下载适合需要快速掌握数据可视化基础并动手实践的初级前端人员。1. 先定选型再写代码一个 JS 绘图组件到底要解决什么问题很多前端新手拿到“js绘图组件实现了柱状图、饼图等”这个需求时第一反应是去 npm 搜 echarts然后照着官方示例复制一份配置。但真实业务里最常翻车的恰恰是这个环节——图表库给你的是一套完整的配置生态而你要做的往往只是在一个固定业务域里反复渲染几种图表。如果直接从“引入 echarts”开始你很快会发现改主题、加交互、做自适应、处理大数据量每件事都在和库的默认行为较劲。这里说的绘图组件是指用原生 Canvas 或 SVG 自己封装一套轻量图表渲染层能按数据画柱状图、饼图、折线图并且暴露统一的 setData、resize、on 事件接口。它适合数据展示形态固定、需要深度定制交互、或者不想为几张基础图引入上百 KB 依赖的场景。下面这套方案以 Canvas 2D 为例所有代码可以直接落在一个 HTML 文件里跑通再逐步拆成模块。2. 从零画一个柱状图Canvas 坐标换算与四步绘制流程2.1 为什么不用 div 撑高度而是选 Canvas 2D柱状图最简单的实现方式确实是 div CSS 动态 height但组件化之后你会遇到三个绕不开的痛点一是数据量上来之后 DOM 节点数飙升一次渲染上万根柱子时布局引擎会明显掉帧二是图表通常需要叠加坐标轴、网格线、提示框、动画DOM 方案要维护的嵌套结构越来越复杂三是后续做折线图、饼图时div 方案几乎没有可复用的绘制逻辑。Canvas 2D 把所有绘制收敛到一个画布里用同一套坐标换算逻辑控制不同图表的绘制路径这才具备“组件”的形态。选 Canvas 而不是 SVG核心权衡在两点Canvas 绘制性能更高特别是元素超过几百个时SVG 的 DOM 事件绑定和单个元素样式控制更方便。对于柱状图、饼图、折线图这类几何图形规律、交互主要靠点击命中和 hover 提示的场景Canvas 加手动命中检测完全够用而且能统一处理。2.2 先搭绘制骨架坐标系、边距与刻度计算绘图组件的第一原则是所有绘制函数只接收“逻辑坐标”不做任何像素计算。你需要先定义好绘图区的内边距把 Canvas 的像素区域转换成图表数据区域。下面是最小骨架代码class Chart { constructor(canvas, options {}) { this.canvas canvas; this.ctx canvas.getContext(2d); this.options Object.assign({ padding: { top: 20, right: 20, bottom: 40, left: 50 }, colors: [#4e79a7, #f28e2b, #e15759, #76b7b2] }, options); this.data []; this.width canvas.width; this.height canvas.height; } // 把数据坐标映射到 Canvas 像素坐标 getPlotArea() { const { top, right, bottom, left } this.options.padding; return { x: left, y: top, width: this.width - left - right, height: this.height - top - bottom }; } // 数值映射dataValue - 绘图区内的 x 像素位置 getXPosition(index, total, plotWidth) { const bandWidth plotWidth / total; return this.options.padding.left index * bandWidth bandWidth * 0.1; } // 数值映射dataValue - 柱子高度对应的 y 像素位置 getYPosition(value, maxValue, plotHeight) { const ratio value / maxValue; return this.options.padding.top plotHeight * (1 - ratio); } clear() { this.ctx.clearRect(0, 0, this.width, this.height); } }这套骨架的关键是把“内边距”当作坐标系偏移量所有柱子、折线点、扇形的绘制都基于 getPlotArea 得到的绘图区宽高而不是直接操作 canvas.width。getXPosition 和 getYPosition 是两个最重要的纯函数前者把第 index 个数据项映射到绘图区横向位置后者把数值映射成纵向像素——注意 y 轴方向是反的数据值越大像素值越小。后面柱状图、折线图、面积图都复用它。2.3 柱状图主体绘制从数据到矩形路径有了坐标映射函数画柱状图其实只剩三步算出每根柱子的位置调用 fillRect 画出矩形再补上坐标轴和刻度线。这里直接给完整实现drawBarChart(data) { const { width: plotWidth, height: plotHeight } this.getPlotArea(); const total data.length; const maxValue Math.max(...data.map(d d.value), 1); // 先画网格线和 y 轴刻度 const gridCount 5; for (let i 0; i gridCount; i) { const value (maxValue / gridCount) * i; const y this.getYPosition(value, maxValue, plotHeight); this.ctx.strokeStyle #e8e8e8; this.ctx.lineWidth 1; this.ctx.beginPath(); this.ctx.moveTo(this.options.padding.left, y); this.ctx.lineTo(this.options.padding.left plotWidth, y); this.ctx.stroke(); this.ctx.fillStyle #666; this.ctx.font 12px sans-serif; this.ctx.textAlign right; this.ctx.fillText(Math.round(value), this.options.padding.left - 10, y 4); } // 画柱状图主体 const barWidth (plotWidth / total) * 0.6; data.forEach((item, index) { const x this.getXPosition(index, total, plotWidth); const y this.getYPosition(item.value, maxValue, plotHeight); const height plotHeight - (y - this.options.padding.top); this.ctx.fillStyle this.options.colors[index % this.options.colors.length]; this.ctx.fillRect(x, y, barWidth, height); // 柱顶数值标签 this.ctx.fillStyle #333; this.ctx.textAlign center; this.ctx.fillText(item.value, x barWidth / 2, y - 6); }); // x 轴分类标签 data.forEach((item, index) { const x this.getXPosition(index, total, plotWidth) barWidth / 2; this.ctx.fillStyle #666; this.ctx.textAlign center; this.ctx.fillText(item.label, x, this.height - this.options.padding.bottom 16); }); }逻辑说明网格线绘制里先算 maxValue 除以网格数得出每个刻度对应的数值再用 getYPosition 把“刻度值”换算成 y 像素坐标这样网格线和柱子共用同一套比例不会出现刻度线位置和柱子顶端对不上的问题。柱子主体的高度不是直接等于 item.value而是plotHeight - (y - padding.top)——因为 y 是从上往下算的像素坐标需要减掉顶部内边距才是真正的柱高。绘制顺序也很关键先画网格线和刻度背景层再画柱子数据层最后画标签文字层这样文字不会被柱子遮住。2.4 在浏览器里跑通最小示例上面的代码不是完整文件你还需要一串胶水代码把它驱动起来。建一个 HTML 文件把 Chart 类和 drawBarChart 方法粘贴进去然后在 body 末尾加一段初始化脚本const canvas document.getElementById(myChart); canvas.width 600; canvas.height 400; const chart new Chart(canvas); chart.data [ { label: 周一, value: 120 }, { label: 周二, value: 200 }, { label: 周三, value: 150 }, { label: 周四, value: 280 }, { label: 周五, value: 180 } ]; chart.clear(); chart.drawBarChart(chart.data);参数说明canvas.width 和 canvas.height 设置的是画布像素分辨率不是 CSS 尺寸。如果你在样式表里给 canvas 设了 width: 100%会导致画布被拉伸变模糊这是 Canvas 绘图最常见的玄学问题。正确做法是 JS 里设置宽高CSS 里不设或者用width: auto。属性名也很重要Chart 实例的 data 属性是数组数组元素的 label 和 value 字段被绘制函数依赖如果你在真实项目里接的是接口数据记得做一个 map 转换不要直接传原始 JSON。跑通这个最小示例后柱状图的核心逻辑你已经完全掌握了——剩下的折线图、饼图都是换一种“数据到像素”的映射规则。3. 饼图与折线图同一套渲染管线的三个关键抽象3.1 饼图扇形的数据映射从百分比到弧度饼图的数学基础是每个数据项的数值占总数量的比例乘以 2π就是对应扇形的弧度。和柱状图的差异在于饼图不需要坐标轴所有绘制都围绕圆心展开。具体实现时需要维护一个累计角度变量逐个画出扇形路径并填充。这里给出饼图的核心绘制方法drawPieChart(data) { const centerX this.width / 2; const centerY this.height / 2; const radius Math.min(this.width, this.height) * 0.35; const total data.reduce((sum, d) sum d.value, 0); let startAngle -Math.PI / 2; // 从12点方向开始 data.forEach((item, index) { const sliceAngle (item.value / total) * Math.PI * 2; const endAngle startAngle sliceAngle; this.ctx.beginPath(); this.ctx.moveTo(centerX, centerY); this.ctx.arc(centerX, centerY, radius, startAngle, endAngle); this.ctx.closePath(); this.ctx.fillStyle this.options.colors[index % this.options.colors.length]; this.ctx.fill(); this.ctx.strokeStyle #fff; this.ctx.lineWidth 2; this.ctx.stroke(); // 计算标签位置扇形中间角度半径 0.7 处 const labelAngle startAngle sliceAngle / 2; const labelRadius radius * 0.7; const labelX centerX Math.cos(labelAngle) * labelRadius; const labelY centerY Math.sin(labelAngle) * labelRadius; this.ctx.fillStyle #fff; this.ctx.font bold 14px sans-serif; this.ctx.textAlign center; this.ctx.textBaseline middle; this.ctx.fillText( ${Math.round((item.value / total) * 100)}%, labelX, labelY ); startAngle endAngle; }); }逻辑说明扇形的弧度和百分比的关系是线性映射item.value / total得到占比乘以 2π 得到弧度。起始角设为-Math.PI / 2是为了让第一个扇形从 12 点方向开始而不是从 3 点方向——这是饼图约定俗成的视觉起点。标签位置的算法是扇形角平分线的方向向量乘以 0.7 倍半径这样百分比文字落在扇形中心附近不会贴在边缘。边缘的白色 stroke 线制造出扇形之间的分隔缝隙这是饼图视觉清晰度的关键细节。参数说明radius 取 Math.min 是为了防止在窄长画布里溢出。如果容器宽高差距大这个逻辑会按较短边决定半径保证饼图始终完整可见但缺点是空余区域会比较多。另一种做法是让 radius 等于Math.min(width, height) * 0.5再给标签留出空间但那样小扇形的标签很容易压到画布边缘所以 0.35 到 0.4 之间是安全范围。3.2 折线图的绘制逻辑点、线、路径复用折线图比柱状图简单因为它不需要管柱子宽度只需要把每个数据点映射成坐标然后用 moveTo 和 lineTo 连起来。但它有一个特有的视觉需求如果点之间是直线连接数据变化剧烈时图形会显得很“生硬”。这里先给基础版本再给平滑处理drawLineChart(data) { const { width: plotWidth, height: plotHeight } this.getPlotArea(); const maxValue Math.max(...data.map(d d.value), 1); const total data.length; const stepX plotWidth / (total - 1); const points data.map((item, index) ({ x: this.options.padding.left index * stepX, y: this.getYPosition(item.value, maxValue, plotHeight) })); // 先画面积填充可选 this.ctx.beginPath(); points.forEach((p, i) { if (i 0) this.ctx.moveTo(p.x, p.y); else this.ctx.lineTo(p.x, p.y); }); this.ctx.lineTo(points[points.length - 1].x, this.options.padding.top plotHeight); this.ctx.lineTo(points[0].x, this.options.padding.top plotHeight); this.ctx.closePath(); this.ctx.fillStyle rgba(78, 121, 167, 0.15); this.ctx.fill(); // 画折线本体 this.ctx.beginPath(); points.forEach((p, i) { if (i 0) this.ctx.moveTo(p.x, p.y); else this.ctx.lineTo(p.x, p.y); }); this.ctx.strokeStyle this.options.colors[0]; this.ctx.lineWidth 2; this.ctx.stroke(); // 画数据点 points.forEach(p { this.ctx.beginPath(); this.ctx.arc(p.x, p.y, 4, 0, Math.PI * 2); this.ctx.fillStyle #fff; this.ctx.fill(); this.ctx.strokeStyle this.options.colors[0]; this.ctx.lineWidth 2; this.ctx.stroke(); }); }逻辑说明折线图的数据点横坐标由stepX plotWidth / (total - 1)计算这样第一个点落在绘图区左缘、最后一个点落在右缘不会像柱状图那样留出分类间距。面积填充是在折线路径之后继续 lineTo 到右下角和左下角然后 closePath 形成闭合区域用半透明填充色叠加出面积图的层次感。数据点的白色填充加描边是为了在折线和面积底色之间把每个数据点“抠”出来提升可读性。这个实现里最关键的设计决策是points 数组被计算一次然后被三个绘制阶段面积、折线、点分别遍历。这种“先算坐标再分绘制阶段”的思路是 Canvas 绘图组件区别于直接写死绘图代码的核心抽象——你不需要每次画到一半重新计算坐标。3.3 统一 setData 与自动渲染调度组件化最关键的一步是把“传入数据”和“绘制过程”解耦。也就是说外部只调用 setData组件内部根据当前图表类型自动选择对应的绘制函数。这里给出接口设计class Chart { setData(data) { this.data data; this.autoRender(); } setType(type) { this.type type; // bar | pie | line this.autoRender(); } autoRender() { this.clear(); if (!this.data || this.data.length 0) return; switch (this.type) { case bar: this.drawBarChart(this.data); break; case pie: this.drawPieChart(this.data); break; case line: this.drawLineChart(this.data); break; } } }调用方式变成这样const chart new Chart(canvas, { type: bar }); chart.setData([ { label: 产品A, value: 340 }, { label: 产品B, value: 260 } ]); // 想切换图表类型时 chart.setType(pie);这套接口的优点是调用方不需要关心绘制细节只需要维护 data 数组和 type 字符串。需要注意的坑是setType 之后必须调用 autoRender否则画布上还保留着上一个图表类型的像素。另外data 为空时一定要直接 return否则 Math.max(...[]) 会得到 -Infinity导致 y 坐标全部变成 NaN画布上出现一条黑线——这是 Canvas 图表最容易踩的“数据为空导致绘制异常”问题。3.4 多图表混用如何用同一 data 渲染不同视图真实业务里经常遇到一个需求同一个数据集用户点按钮切换柱状图、折线图、饼图三种视角。有了上面的抽象你只需要保证不同图表类型对 data 数组的字段要求一致——所以一开始设计时我建议统一用{ label, value }结构不要为不同类型定义不同的字段名。折线图可能还需要一个时间维度那就加一个可选字段time绘制时如果存在就做 x 轴映射否则退回 index 映射。// 统一数据适配层把任意接口数据转成组件内部格式 function adaptData(rawData, valueKey value, labelKey label) { return rawData.map(item ({ label: item[labelKey], value: Number(item[valueKey]) || 0 })); }这个适配函数解决的是标准化问题。后端返回的数据可能是{ name: 电量, count: 300 }你只需要adaptData(data, count, name)就能转成组件能消费的格式。Number() 包装是为了防止字符串数字参与运算时出现隐式类型转换的意外|| 0 则把 NaN 和 null 兜底为 0。4. 组件化封装事件交互、自适应尺寸与动画4.1 鼠标悬浮高亮手动命中检测算法Canvas 不像 SVG 那样每个图形都是独立 DOM它只有一张位图所以鼠标交互全靠“命中检测”。柱状图的命中检测逻辑是拿到鼠标的 Canvas 坐标遍历所有柱子判断鼠标是否落在柱子的矩形范围内。饼图更复杂一些需要判断鼠标距离圆心的距离是否小于半径再判断角度落在哪个扇区。class Chart { onMouseMove(callback) { this.canvas.addEventListener(mousemove, (e) { const rect this.canvas.getBoundingClientRect(); const scaleX this.canvas.width / rect.width; const scaleY this.canvas.height / rect.height; const mx (e.clientX - rect.left) * scaleX; const my (e.clientY - rect.top) * scaleY; const hit this.hitTest(mx, my); if (hit hit.index ! this.lastHoverIndex) { this.lastHoverIndex hit.index; callback(hit, e); } }); } hitTest(mx, my) { if (this.type bar) { const { width: plotWidth } this.getPlotArea(); const total this.data.length; const barWidth (plotWidth / total) * 0.6; for (let i 0; i this.data.length; i) { const x this.getXPosition(i, total, plotWidth); const maxValue Math.max(...this.data.map(d d.value), 1); const y this.getYPosition(this.data[i].value, maxValue, this.getPlotArea().height); const height this.getPlotArea().height - (y - this.options.padding.top); if (mx x mx x barWidth my y my y height) { return { index: i, type: bar, item: this.data[i] }; } } } // pie 和 line 的命中检测逻辑类似分别用角度判断和点到线段距离 return null; } }这个 hitTest 实现里第一个坑是坐标换算e.clientX是浏览器视口坐标而 canvas 的内部坐标和 CSS 坐标只有在宽高一致时才相等。如果 canvas 有 CSS 缩放必须通过 getBoundingClientRect 拿到实际显示尺寸做比例换算。第二个坑是柱状图的柱子顶部留了 6px 的数值标签文字点击文字不应该触发柱子选中所以命中检测只检查矩形区域不检查文字区域。命中检测的触发时机是最后一个需要考虑的问题不要每次 mousemove 都重新绘制整个图表那会导致 Canvas 闪烁和性能浪费。正确做法是记录 lastHoverIndex只有 hover 的 index 变化时才重绘并且重绘时对 hover 项做特殊样式处理比如柱子颜色加深、透明度降低。4.2 让柱状图跟随鼠标高亮交互状态与重绘实现 hover 高亮最常见的做法是维护一个this.hoverIndex状态在 autoRender 时读取这个状态并改变对应柱子颜色。这样你不需要单独写“高亮绘制函数”只需要在绘制柱状图的循环里加一个条件判断。drawBarChart(data) { // ...前文代码省略... data.forEach((item, index) { // ... if (index this.hoverIndex) { this.ctx.fillStyle this.hoverColor(index); } else { this.ctx.fillStyle this.options.colors[index % this.options.colors.length]; } // ... }); } hoverColor(index) { const base this.options.colors[index % this.options.colors.length]; // 简单的变暗处理把颜色转成 RGB 再乘 0.7 return this.adjustBrightness(base, 0.7); }实现 hoverColor 时有个隐藏细节Canvas 的 fillStyle 可以是十六进制字符串也可以是 rgb() 字符串、rgba() 字符串。如果你统一用 hex如#4e79a7变暗时需要拆成 RGB 三个分量再乘系数。这个逻辑容易写成另一个独立工具函数做成纯函数方便单元测试。4.3 窗口缩放后图表变形监听 resize 与重新渲染Canvas 绘图的特殊性在于绘制时如果分辨率canvas.width和 CSS 显示尺寸不一致图形会模糊如果 window 缩放后不重新设置 canvas.width画布内容会被拉伸或压缩变形。组件要处理的自适应逻辑其实是三个步骤的循环class Chart { resize(width, height) { this.width width; this.height height; this.canvas.width width; this.canvas.height height; this.autoRender(); } bindResize() { window.addEventListener(resize, () { const rect this.canvas.parentElement.getBoundingClientRect(); this.resize(rect.width, rect.height); }); } }这里有一个重要的细节resize 时用父容器的宽高而不是 window 的宽高。因为组件通常是嵌入某个页面区域用 window.innerWidth 会导致图表混乱性地超出容器或者缩进不齐。父容器 getBoundingClientRect 能拿到实际可用的布局宽高这是自适应尺寸的推荐做法。第二个细节是 devicePixelRatio在高分屏Retina 屏上canvas.width 设成 CSS 像素宽高会导致文字发虚、线条模糊。标准做法是把 canvas.width 设为rect.width * window.devicePixelRatio同时用 CSS 尺寸把 canvas 压回原本大小。不过这个处理牵涉到所有坐标换算会让代码复杂度上一个台阶通常只在图表质量要求高时才做。4.4 动画的两种实现CSS 过渡与 requestAnimationFrame图表动画有两种常见诉求新数据出现时的入场动画以及数据更新时的过渡动画。最简单可用的入场动画是用 requestAnimationFrame 控制一个 progress 变量从 0 渐变到 1绘制函数里所有高度、半径都乘上 progress。比如柱状图的柱子高度从 0 长到目标值。animateTo(targetData, duration 400) { const startTime performance.now(); const tick (currentTime) { const elapsed currentTime - startTime; const progress Math.min(elapsed / duration, 1); // 0 - 1 this.renderProgress progress; this.autoRender(); if (progress 1) { requestAnimationFrame(tick); } }; requestAnimationFrame(tick); }配合 autoRender 的实现改造绘制时所有数值乘this.renderProgress。比如 drawBarChart 里柱子高度用height * this.renderProgress饼图的结束角度用startAngle sliceAngle * this.renderProgress。这种方式的优点是不需要写任何过渡组件纯靠一个 progress 因子控制所有几何形状的插值。避坑提醒requestAnimationFrame 动画期间如果用户缩放了窗口会触发 resize 导致 autoRender此时 renderProgress 可能已经被重置动画会出现闪烁。解决方法是把 progress 作为动画状态保存在实例上resize 时不要重置它或者在 resize 时重新从当前动画位置启动。5. 避坑指南Canvas 绘图组件最常见的 5 个翻车现场5.1 文字模糊Canvas 宽高和 CSS 尺寸不一致现象柱状图上的文字和线条看起来有毛边、虚化特别是在高分屏上尤其明显。 原因canvas.width 默认是 300CSS 里给 canvas 设了width: 100%导致画布内容被拉伸到容器宽度每个像素被放大显示文字自然发虚。 解决要么始终保持 canvas.width 等于 CSS 显示尺寸要么按 devicePixelRatio 缩放画布并用 CSS 约束显示尺寸。我一般的做法是function setupCanvas(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; }这段代码的 ctx.scale(dpr, dpr) 是整个 Canvas 高清绘制的核心画布物理像素是 CSS 像素的 dpr 倍但坐标系统仍按 CSS 像素来算最终输出会清晰很多。5.2 数据为空时的 -InfinityMath.max 的隐性灾难现象组件收到空数组时控制台不报错但画布上出现一条从左上角到右下角的黑色斜线。 原因Math.max(...[])在 JS 里返回-Infinity。绘制柱状图时maxValue变成-InfinitygetYPosition 里做除法得到的坐标就变成NaNCanvas 把 NaN 坐标的绘制路径连接到原点附近形成一条诡异的斜线。 解决在 autoRender 开头加空数据判断已经在前文代码里出现过了。另外在做 maxValue 计算时永远加上, 1作为兜底——Math.max(...values, 1)保证至少是 1避免出现 0 值数据和空数据的极端情况。5.3 高频 mousemove 导致性能卡顿每次都重绘全图现象鼠标在图表区域内快速移动时明显感到掉帧、CPU 占用高。 原因mousemove 事件的触发频率远高于屏幕刷新率通常是 60 到 120 次/秒而重绘一次图表涉及多条路径的填充和描边叠加起来就吃满了 CPU。 解决三个手段并用。第一记录 lastHoverIndex只有 hover 目标变化时才重绘第二用事件节流函数包一层把 mousemove 的触发频率降到 30Hz 以内第三如果图表元素多把背景网格、坐标轴这些不随 hover 变化的部分用离屏 Canvas 缓存起来重绘时只重绘前景部分。5.4 饼图小扇区标签重叠百分比数字挤成一团现象数据项很多且某些数值很小时比如占比 1%饼图上对应的百分比文字会摞在一起无法阅读。 原因标签位置固定在扇区中心而扇区宽度小于文字宽度时文字必然溢出并遮盖相邻扇区。 解决加一个最小角度阈值判断对于占比小于 5% 的扇区把标签移到扇区外侧并绘制一条引线连接到扇区边缘。引线用 moveTo 和 lineTo 画标签文字放在引线末端。这个处理很常见但很多人不做算是一眼区分专业组件和业余组件的细节之一。5.5 切换图表类型后画布残留旧内容现象从柱状图切换到饼图时画布上还能隐隐约约看到之前柱状图的网格线和坐标轴文字。 原因setType 之后如果没有调用 clear()旧像素依然留在画布上。虽然 autoRender 里的 clearRect 会清一次但如果切换前没有设置 data或者切换时触发了异常导致绘制中断就会出现残留。 解决setType 和 setData 内部都调用 autoRender而 autoRender 的第一步永远是 clear()。另外clearRect 必须用传入的宽高乘以 dpr 后的值否则高分屏上只能清掉一部分区域。6. 性能优化与验收用 5000 点数据和缩放交互验证组件到了这一步绘图组件的基本功能和交互已经完整。最后一件事是用真实场景的压力测试确认它能扛住业务数据量。这里给出两个验证方向大数据量渲染和连续交互响应。6.1 5000 点折线图的渲染优化给折线图喂 5000 个数据点如果发现帧率下降最直接的优化是降采样不是每帧都画全部 5000 个点而是按画布像素宽度每隔几个点取一个。标准算法是“最小最大保形降采样”——在每个像素桶内保留最大值和最小值这样既能保证折线不会丢失视觉峰值又能大幅减少绘制路径点。function downsampleMinMax(data, threshold) { if (data.length threshold) return data; const bucketSize Math.ceil(data.length / threshold); const result []; for (let i 0; i data.length; i bucketSize) { const bucket data.slice(i, i bucketSize); const localMax Math.max(...bucket.map(d d.value)); const localMin Math.min(...bucket.map(d d.value)); result.push({ label: bucket[0].label, value: localMax }); result.push({ label: bucket[0].label, value: localMin }); } return result; }这个降采样函数的核心是把 5000 点压缩成大约 1000 点threshold 参数但每个像素桶内的峰值被完整保留视觉上不会出现“尖峰被抹平”的问题。6.2 连续交互的验证方法模拟 resize 与 hover验证自适应尺寸和交互稳定性时用脚本模拟比手动拖窗口更高效。可以在控制台里循环执行 resize 和 setData// 在浏览器控制台直接执行压力测试 let scale 1; setInterval(() { scale scale 1 ? 0.6 : 1; chart.resize(600 * scale, 400 * scale); chart.setData(generateData(200)); }, 800);观察 10 秒内是否出现闪烁、坐标错位、内存持续增长。内存问题用 Chrome DevTools 的 Performance 面板录制看渲染前后的 JS Heap 趋势。如果堆内存只涨不降说明 Canvas 事件监听器或者动画回调没有正确释放。6.3 我踩过的最后一个坑重绘时不释放旧的 ctx 状态常规绘图结束时顺手把 fillStyle 和 strokeStyle 重置成默认值否则下一个图表类型绘制时会继承上一个的样式。很多人会漏掉这个导致饼图的白色描边变成了柱状图的颜色。我的习惯是在 clear() 方法里顺手重置clear() { this.ctx.clearRect(0, 0, this.width, this.height); this.ctx.fillStyle #000; this.ctx.strokeStyle #000; this.ctx.lineWidth 1; this.ctx.font 12px sans-serif; }这套绘图组件的完整思路到这里就闭环了从坐标映射到三种图表的绘制从交互命中到自适应和动画再到性能优化和踩坑复盘。做成模块后柱子、扇形、折线本质上都是数据到像素的映射函数。你完全可以在这个骨架基础上加雷达图、仪表盘甚至混排组合图。希望帮到你。本文还有配套的精品资源点击获取
返回列表