资讯详情 轻量JS绘图组件:Canvas/SVG取舍与柱状图饼图落地避坑
📅 2026/11/5 8:07:21
简介这份 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.mdcharts/是重点一种图的绘制逻辑集中在一个文件里改柱子的圆角、改饼图的引导线长度都在这层。scale/单独抽出来说明作者想过刻度计算的复用问题——柱状图和折线图共用同一套 y 轴刻度逻辑这个拆法是对的。最小页面这样起步构造名以包里导出的为准示例里叫MiniChart!DOCTYPE html html langzh-CN head meta charsetutf-8 / style /* 容器必须有确定高度组件的尺寸是从父容器读的高度塌陷会读到 0 */ #chart { width: 640px; height: 360px; } /style /head body div idchart/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 /htmlpadding的四个值按上、右、下、左的顺序给左值不够时长刻度数字会被裁掉一半这是最常见的“图看着怪”来源。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)的兜底是血泪经验后端接口在缺数据时返回nullnull是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通常就是 0plotTop对应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 NaNNaN传给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得NaNarc拿到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 数据」和「切走三次再回来」这两个动作强制走一遍宁可多花五分钟也不想再在演示现场看着一张空白的图解释“这是数据的问题”。希望帮到你。本文还有配套的精品资源点击获取