
饼图这东西乍一看是所有图表里最好糊弄的一个——数据一塞颜色一分收工。真到业务里交付才会发现坑全在细节图太小挤在左上角图例跑到右边把画布撑出滚动条引导线交叉成蜘蛛网指示文字一半压在扇区上一半飞到容器外面tooltip 内容一长直接横向撑破屏幕。这篇就聊聊 echarts 饼图 Pie 的常用配置重点放在饼图大小、图例位置样式、指示文字样式这三块顺带把 tooltip 自动换行、labelLine 末尾小圆点偏移、vue3 里 pxtorem 对 echarts 不生效这几个高频问题一起收拾掉。适合已经能画出饼图、但被细节折磨过的人也适合刚接手后台报表和大屏项目的同学。下面所有写法按 ECharts 5.x 来个别属性在 4.x 名字不一样我会在对应位置标出来。1. 先把饼图的体型定下来radius 与 center 的组合拳饼图的大小和位置全压在series.pie的radius和center两个属性上别嫌少项目里图偏了图太小了环形太肥这类问题九成以上都是它俩没配好。radius管半径center管圆心坐标两个都支持数字和百分比混写。很多同学第一次看到配置里写radius: 70%会本能地以为这是容器宽度的 70%实际上饼图半径的百分比基准是容器短边折算出来的一个值最直观的验证办法就是把容器拉成一个特别扁的长方形你会看到圆还是正圆只是水平方向留了大片空白——因为半径永远跟着短边走。1.1 radius 的三种写法与各自的适用场景单值写法radius: 75%是最常用的实心饼图用这个就够了。数组写法radius: [45%, 70%]出环形图数组第一项是内半径挖空的洞第二项是外半径两个值的差值就是环的厚度。数字写法radius: 120是写死像素只在容器尺寸固定的情况下用比如给设计稿一比一还原某个固定尺寸的卡片但凡容器会随窗口变化写死像素迟早出事。这里有个我踩过的坑内半径别设太小。内半径如果只比 0 大一点点比如[5%, 70%]视觉上会变成一个中间有个小洞的实心饼看起来像渲染出了问题。想要清爽的环形观感内半径通常取外半径的 60% 到 75% 之间比如[40%, 65%]或者[50%, 72%]。反过来内半径太大也不行环太细的时候图例色块和扇区颜色对不上号读者需要来回比对才认得出哪个颜色是哪一项。外半径的建议区间是 60% 到 80%。低于 60% 会显得图很空整个容器中间缩着一小坨到 85% 以上指示文字和引导线基本没有落脚空间了标签会顶到容器边缘被裁掉。做环形图时更要注意外半径大了引导线的第一段会贴着容器边斜出去非常难看。1.2 center 的百分比陷阱与像素微调center: [50%, 50%]是默认值含义是圆心在容器宽高的 50% 处注意这里是宽和高分别计算不是取短边。所以在宽高比很大的容器里圆心仍然是几何中心不会跑偏这一点和radius的基准不一样很多人栽在这里。什么时候需要手动挪圆心最常见的是图例放在了右侧导致实际绘图区被压窄了圆看起来偏左。这时候可以把center改成[38%, 50%]之类的值把圆往左推让右边留给图例。另一种是标题占用了顶部空间圆需要整体下移改成[50%, 55%]。百分比不够精确的时候用像素值center: [220, 180]表示圆心距容器左上角横向 220px、纵向 180px。这种写法很像老式绝对定位可维护性差我的习惯是只在容器尺寸由 JS 动态算出来的场景下才用像素其它情况一律百分比。顺便提一句ECharts 5 的饼图其实也支持left / top / right / bottom / width / height这套类似 grid 的定位属性但和center混用容易互相打架建议二选一选定center就一直用center。1.3 容器尺寸变化时的自适应处理饼图不随窗口变化自己重排必须显式调用chart.resize()。最朴素的写法是监听window.resize然后调一次但实际项目里这么写会卡因为拖拽窗口的过程中 resize 会连续触发几十上百次每次重新布局整个 canvas 相当耗性能。我的做法是加一层节流用requestAnimationFrame包一下或者用 lodash 的throttle限制到 100ms 一次。比 resize 更常见的问题是图压根画不出来容器高度是 0。典型场景是用v-if切换 tab第一次 init 的时候容器还是display: noneECharts 拿到的宽高是 0画布尺寸就是 0等你切回来就只剩一片空白。处理办法有两个一是改用v-show让容器始终占位二是在切换完成后再调用chart.resize()因为resize()会重新读取容器尺寸。如果你用的是封装组件记得在组件卸载时dispose()不然路由来回切换几次内存里会堆一堆 canvas 实例页面上还会出现看不清来源的幽灵图形。2. 图例 legend 的位置、图标与文字全解析图例是饼图的第二张脸。饼图不像柱状图有条坐标轴帮忙解释读者认颜色全靠图例图例排得乱图基本就废了。图例的配置项看着杂其实可以拆成三块来记摆在哪、长什么样、显示什么内容。2.1 位置控制的四件套与 orient 的联动left / right / top / bottom四个属性决定图例这个盒子贴在容器的哪边orient决定盒子里面的项是横着排还是竖着排。右侧竖排是最省心的组合right: 10, top: middle, orient: vertical图例项从右往左的宽度不算太占地方饼图主体的横向空间也够。上方横排适合项目少的场景top: 0, left: center, orient: horizontal但超过六项就会换行或者被挤掉得配合type: scroll。left和right同时设不冲突那是用来把盒子撑开的left和center一起写则是left优先。我一般用left: center来水平居中用top: middle来垂直居中比算百分比直观。padding是图例盒子内边距[5, 10]表示上下 5px、左右 10px需要给图例和容器边缘拉开距离时用它。itemGap控制图例项之间的间隔默认 10项多的时候调小到 6 左右能省不少空间项少的时候调到 14、16 会显得更松快。2.2 图标形状、尺寸与单项定制icon支持circle、rect、roundRect、triangle、diamond、pin、arrow、none也能传path://...用 SVG 路径自定义。饼图默认的图标是circle如果你把itemStyle.borderRadius打开了扇区变成圆角矩形块图例图标最好也同步换成roundRect视觉语言才统一。itemWidth和itemHeight是图标尺寸默认 25 和 14。做小尺寸卡片图时我常改成 10 和 10配icon: circle看起来更精致。大屏项目相反可以放大到 16 和 16。单项定制是个容易被忽略的能力legend.data数组里的元素不一定是字符串也可以是对象形如{ name: 华东, icon: circle, textStyle: { color: #ff6b6b } }。这在某一项需要突出显示或者某一项想要禁用点击的场景下特别有用。整组图例的样式则统一写在legend.textStyle里fontSize、color、fontWeight、padding都在这。图例被点掉之后的颜色由inactiveColor控制默认是浅灰深色背景的项目一定要改成半透明白不然点掉的项会变成一块看不清的暗斑。2.3 formatter 显示数值与百分比图例里只显示名字信息量太小很多业务方希望图例直接带数值或者占比。legend.formatter支持字符串模板和回调函数两种写法字符串模板里能稳定拿到的是{name}想显示{value}或者{d}在部分版本里表现不一致我踩过一次所以现在一律用回调。回调只有一个入参 name拿不到当前项的值这是个设计上的限制。解决办法是在外面维护一份 name 到 value 的映射const rawData [ { name: 直营, value: 4280 }, { name: 分销, value: 3160 }, { name: 代理, value: 1740 }, { name: 其他, value: 620 } ] const total rawData.reduce((sum, item) sum item.value, 0) const valueMap Object.fromEntries(rawData.map(i [i.name, i.value])) option { legend: { orient: vertical, right: 12, top: middle, formatter: (name) { const val valueMap[name] ?? 0 const pct total ? ((val / total) * 100).toFixed(1) : 0.0 return ${name} ${val} (${pct}%) }, textStyle: { fontSize: 12, color: #666 } }, series: [{ type: pie, data: rawData }] }注意Object.fromEntries在很老的浏览器上不支持如果你的项目还要兼容旧环境老老实实写个 for 循环建映射。另外 formatter 返回的字符串太长会把图例撑宽竖排图例尤其明显必要时把数值单位压缩成万或者只保留百分比。2.4 图例过多时的分页与滚动项目超过八项横排图例必然换行换行之后遮挡饼图竖排图例则会纵向溢出容器。这时候就该legend.type: scroll出场了。它会给图例加翻页按钮pageIconSize控制按钮大小默认 15pageIconColor和pageIconInactiveColor控制可用和禁用状态的颜色pageTextStyle控制页码文字样式pageButtonPosition: end可以让按钮固定在末尾而不是跟着内容跑。翻页按钮在深色大屏上经常看不见默认是深灰色记得改成白色或者主题色。还有一个隐藏参数是legend.scrollDataIndex可以指定初始显示从第几项开始做默认高亮最近几个月这类需求时用得上。3. 指示文字与引导线label 和 labelLine 的细节控制指示文字是饼图最容易翻车的部分。数据一多标签互相重叠、引导线交叉、文字被容器裁掉画面立刻显得很不专业。这块要拆成文字显示什么、文字摆在哪、引导线怎么连三件事分别处理。3.1 label 的内容格式化与位置取值label.show默认是 true但很多模板里会把它关掉只留 tooltip。我的建议是只要项目数不超过七个尽量保留标签因为读者不需要悬停就能读到数值信息获取成本低得多。label.formatter里的模板变量有{a}系列名、{b}数据项名、{c}数值、{d}百分比。{d}默认保留两位小数想改成一位得用回调回调参数里有name、value、percent、data、colorcolor是当前扇区的颜色用它来给文字上色可以做到文字与扇区同色效果很干净。label: { show: true, formatter: (p) ${p.name}\n${p.value} 万元 ${p.percent}%, color: #333, fontSize: 12, lineHeight: 16 }label.position在饼图里有三个可选值outside在扇区外侧用引导线连过去信息最清晰也最占地方inside直接把文字压在扇区上适合扇区面积大、项数少的场景一定要配color: #fff和合适的fontWeight否则深色扇区上的黑字基本看不清center是把所有标签都堆到圆心实际项目里用得很少一般只在圆环中间显示总量这种需求下才用它——而且这种需求更适合用graphic或者title手动放一个文字而不是让 label 都挤到中心。ECharts 5 还给 label 加了alignTo、edgeDistance、bleedMargin三个属性专门治标签溢出。alignTo: edge会把标签沿着容器边缘对齐edgeDistance默认25%bleedMargin默认10%是用来防止标签跑到画布外面的出血距离。做窄容器的时候把alignTo设成edge比手动去调labelLayout省事得多。3.2 labelLine 的三段结构与末尾小圆点偏移引导线由两段组成第一段从扇区边缘斜着往外走长度由labelLine.length控制第二段是拐弯之后的水平段长度由labelLine.length2控制文字就挂在第二段的末端。smooth控制拐弯处的圆滑程度取值 0 到 1设成 0.3 左右会比直角拐弯柔和很多minTurnAngle和maxSurfaceAngle是两个角度阈值用来避免相邻标签的引导线交叉得太难看。末尾小圆点是热词里提到的高频需求做法是给labelLine加symbol: circle和symbolSizelabelLine: { show: true, length: 14, length2: 18, smooth: 0.25, symbol: circle, symbolSize: [4, 4], lineStyle: { width: 1, type: solid } }这个能力是 ECharts 5.2 之后才有的4.x 版本没有labelLine.symbol需要自己用markPoint或者额外加一个 scatter 系列来画点非常麻烦建议直接升级版本。关于小圆点偏移实际表现通常是三种小圆点压在文字左边缘上、小圆点离文字太远悬在半空、小圆点跟着引导线跑到文字的右侧。原因在于引导线的终点是标签文字框的边缘而不是文字的基线起始位置所以当label.align是left、right或center时这个终点位置会有肉眼可见的差别。我一般按下面的顺序排查先看label.padding如果 padding 给得很大文字框边缘离文字本身就很远小圆点自然显得飘再把length2调小一点让第二段短一些最后才是上labelLayout.labelLinePoints从回调里拿到实际的三个点坐标手动把终点往里收几个像素labelLayout: (params) { const pts params.labelLinePoints if (!pts) return return { labelLinePoints: [ pts[0], pts[1], [pts[2][0] (pts[2][0] pts[1][0] ? -6 : 6), pts[2][1]] ] } }这段逻辑的意思是判断引导线第二段是往右走还是往左走往右就把终点往左收 6px往左就往右收 6px让小圆点刚好贴在文字外侧。这个数字根据字号和 padding 会有变化12px 字号下我一般用 4 到 8 之间。注意labelLayout的返回值只对当前这一项生效不要在里面做副作用操作比如弹 toast、改全局变量会被调用很多次。3.3 标签打架时的取舍策略avoidLabelOverlap默认就是 trueECharts 会自己挪一挪标签的位置尽量不重叠。但它的能力有限项目数超过十项基本就救不回来了。这时候有三个方向可以选一是把小占比数据合并成其他比如占比低于 3% 的全部归到一项饼图的项目数控制在六到八项二是把label.position改成inside牺牲一点可读性换布局干净三是干脆关掉 label只保留图例和 tooltip把图表本身做得干净利落。labelLayout.hideOverlap也值得开它会直接隐藏掉那些实在排不下位置的标签比让它们叠在一起糊成一团要体面。还有一个minShowLabelAngle默认是 0单位是角度设成 5 的意思是扇区角度小于 5 度就不显示标签这是我在小占比数据特别多的时候最常用的一个开关一行配置能解决掉大半重叠问题。另外说一个数据层面的经验饼图真的不适合表达超过八项的细分数据视觉上根本分不清。我在项目里遇到要展示二十个渠道的占比这种需求通常的做法是先按数值降序排完只保留前七项其余合并成其他然后在 tooltip 里把完整的明细列出来。这样主图清爽想看细节的人悬停就能看到双方的诉求都满足了。3.4 tooltip 自动换行与内容溢出tooltip 自动换行是另一个高频问题。ECharts 默认的 tooltip 样式里white-space是nowrap所以 formatter 返回的长文本会一路往右撑窄容器里直接横穿整个页面。最直接的解法是给extraCssText加样式tooltip: { trigger: item, confine: true, formatter: (p) div stylemax-width:200px${p.name}br/数量${p.value}br/占比${p.percent}%/div, extraCssText: white-space:normal;word-break:break-all;max-width:220px;line-height:18px; }extraCssText里的white-space: normal是让文本正常换行的关键word-break: break-all是防止超长英文或者没有空格的字符串把容器撑破。宽度上我一般给到 200 到 240再宽就不好控制位置了。confine: true会把 tooltip 限制在容器内部避免它贴到容器边缘时被裁掉一半代价是它会挤压自己的定位可能挡住鼠标所指的区域需要权衡。如果需要结构化一点的展示可以自己拼一个两列布局的 HTML 表格左列名称右列数值text-align: right对齐右列看起来比一行行br/舒服得多。做深色主题时记得在extraCssText里同时覆盖背景色、边框色和文字色backgroundColor、borderColor这几个顶层属性只影响最外层容器里面的 div 颜色还得自己给。4. 落地实战一份可直接复用的配置与排查手册前面拆得比较细这一节把常用的配置整合成一份可以直接复制粘贴的模板再把踩过的坑整理成一张速查表最后说说 vue3 项目里 pxtorem 对 echarts 不生效这个经典问题。4.1 完整配置模板这份模板对应的是右侧竖排图例 环形图 外侧指示文字带小圆点 tooltip 可换行这套最常见组合注释里标了每项的作用const rawData [ { name: 直营, value: 4280 }, { name: 分销, value: 3160 }, { name: 代理, value: 1740 }, { name: 其他, value: 620 } ] const total rawData.reduce((s, i) s i.value, 0) const colorList [#5B8FF9, #5AD8A6, #F6BD16, #E8684A] option { color: colorList, tooltip: { trigger: item, confine: true, formatter: (p) div stylemax-width:200px${p.name}br/数量${p.value}br/占比${p.percent}%/div, extraCssText: white-space:normal;word-break:break-all;max-width:220px;line-height:18px; }, legend: { type: scroll, // 项目多时自动分页 orient: vertical, right: 12, top: middle, itemWidth: 10, itemHeight: 10, itemGap: 12, icon: circle, formatter: (name) { const val rawData.find(i i.name name)?.value || 0 const pct total ? ((val / total) * 100).toFixed(1) : 0.0 return ${name} ${pct}% }, textStyle: { fontSize: 12, color: #5A6072 }, inactiveColor: #C2C8D4, pageIconColor: #5B8FF9, pageIconInactiveColor: #D9DEE8, pageTextStyle: { color: #5A6072, fontSize: 11 } }, series: [ { type: pie, radius: [48%, 68%], // 环形内径 48%外径 68% center: [38%, 50%], // 圆心左移给右侧图例让位 avoidLabelOverlap: true, minShowLabelAngle: 4, // 小于 4 度的扇区不显示标签 itemStyle: { borderColor: #fff, borderWidth: 2, // 扇区之间留白视觉更透气 borderRadius: 4 }, label: { show: true, position: outside, formatter: (p) {n|${p.name}}\n{v|${p.value}}, rich: { n: { fontSize: 12, color: #8A91A3, lineHeight: 16 }, v: { fontSize: 13, color: #303541, fontWeight: bold, lineHeight: 18 } } }, labelLine: { show: true, length: 12, length2: 16, smooth: 0.25, symbol: circle, symbolSize: [4, 4], lineStyle: { width: 1 } }, labelLayout: { hideOverlap: true, moveOverlap: shiftY }, emphasis: { scale: true, scaleSize: 6, label: { fontWeight: bold } }, data: rawData } ] }几个值得单独说的点。itemStyle.borderColor配borderWidth是环形图分块的常规做法用背景色当分割线比留缝隙更稳borderRadius让每块扇区变成圆角块风格更现代但它只在有 border 或者环形图上效果明显实心饼图上四块扇区粘在一起圆角会被相邻块盖住。rich富文本很适合做名称小字灰色 数值大字深色的两行排版比单纯用\n换行然后统一字号要好看不少注意rich里的lineHeight要给足不然两行文字会贴在一起。emphasis.scale在悬停时把扇区往外弹一点大屏上观感很好但项目数多的时候不建议开扇区会互相挤压。4.2 常见问题速查表现象大概率原因处理办法图表一片空白容器初始化时宽高为 0改用 v-show或在容器可见后调用 chart.resize()圆偏左或偏上center 被图例、标题挤占空间调 center 的百分比把圆心往空的一侧推指示文字被容器裁掉外半径过大 容器偏窄外半径降到 70% 以下或设 label.alignTo 为 edge标签互相重叠项目数超过 8 项合并小占比为其他开 minShowLabelAngle 和 hideOverlap引导线末尾小圆点飘length2 过长或 label 的 padding 过大缩短 length2或手动改 labelLinePoints 的终点引导线穿进扇区minTurnAngle 太小保持默认 90或把 smooth 调到 0.2 以下tooltip 撑破容器默认 white-space 是 nowrapextraCssText 加 white-space:normal 和 max-width图例被挤到看不见项目过多且未分页legend.type 设为 scroll调小 itemGap悬停后圆环缩不回去emphasis.scaleSize 设得过大降到 5 到 8 之间或关掉 scale深色主题下图例看不清inactiveColor 仍是默认灰改成半透明白同时覆盖 pageTextStyle 颜色这张表里的每一条基本都是我在项目里真实遇到过的其中图表一片空白出现的频率最高排查时不用怀疑配置写错了先打印一下容器的clientWidth和clientHeight十有八九是 0。4.3 vue3 里 pxtorem 对 echarts 不生效的原因与替代方案这个问题在热词里出现说明踩的人不少。先说结论postcss-pxtorem 这类插件的作用范围是 CSS 文件它是在构建阶段扫描.css、.scss、.vue文件里的style块把里面写死的 px 换算成 rem。ECharts 的图形是靠 canvas 画出来的配置里的fontSize: 12、radius: 120、itemGap: 10全都是 JavaScript 里的数字字面量压根不经过 postcss 的管道自然不会被动到。所以你会看到页面整体缩放正常唯独图表里的文字大小纹丝不动在大屏上特别扎眼。可选的方案有三种我按推荐程度排一下。第一种在 JS 里写一个换算函数思路和 postcss 保持一致。如果你的 postcss 配置用的是固定根字号比如rootValue: 16那 JS 里直接除就行// 与 postcss.config.js 里的 rootValue 保持一致 const ROOT_VALUE 16 const px (n) (n / ROOT_VALUE).toFixed(4) * 1 // 或者返回带单位的字符串适用于支持字符串尺寸的属性 const pxStr (n) ${n / ROOT_VALUE}rem需要提醒的是ECharts 的fontSize、radius的数字写法只接受纯数字传0.875rem是不认的百分比是特例radius支持70%这种字符串。所以能不能用 rem 字符串取决于具体属性稳妥起见还是全部换成计算后的数字。第二种如果你的根字号是动态算的比如按视口宽度算出一整套缩放比例那就实时读根元素function rpx(basePx) { const rootFontSize parseFloat( getComputedStyle(document.documentElement).fontSize ) || 16 return (basePx / 16) * rootFontSize }把这个函数包在配置生成逻辑外面窗口尺寸变化时重新生成 option 并 setOption就能跟着一起缩放了。注意setOption默认是合并模式字号这种标量值会被覆盖但数组类型的data会按索引合并如果你把整个 series 都重新传进去行为会比较可控。第三种让图表跟着 CSS 变量走用getComputedStyle读自定义属性的值。这个方案的优点是尺寸来源单一改一处全局生效缺点是每次取值都要触发一次样式计算高频 resize 场景下性能一般需要自己加缓存。最后补一个 vue3 里的实践建议图表配置尽量写成一个computed或者返回对象的函数把依赖的尺寸参数集中放在顶部别散落在 option 各处。这样调整断点或者缩放比例的时候只改几个变量不用去几百行配置里大海捞针。我自己的习惯是给每个图表组件留一组sizeConf常量字号、内边距、圈半径、图例间距全在里面切主题、切断点都只动这一处维护成本比在 option 里到处追数值低太多。环形图的厚度、图例的留白、引导线的长度这些东西在文档里看到的都是数字真正合不合适得放到真实容器里看一眼才知道。我的经验是先把radius和center调到顺眼再处理图例最后收拾指示文字顺序反了会来回改很多遍。另外每次改完记得把容器拉到一个极窄的宽度看一眼能扛住窄容器才算真的调好了。