1. ECharts 里到底有没有现成的 3D 饼图
先把话说在前头:ECharts 官方从 3.x 到现在的 5.x,都没有pie3D这个系列类型。你在配置里写type: 'pie3D',控制台会直接告诉你Series pie3D is not exists。而echarts-gl扩展包里提供的是bar3D、scatter3D、surface、lines3D、map3D这几个三维系列,饼图并不在其中。
我见过太多同学在这个点上卡住:搜「echarts 3D 饼图」,跳出来的第一篇教程写着「引入 echarts-gl 即可」,照做之后发现要么没反应,要么报错,然后就开始怀疑是不是版本不对。其实方向从一开始就偏了——3D 饼图在 ECharts 体系里,从来不是"开个开关",而是要"造出来"的。
那造的方式有几种?我可以负责任地说,业界主流的做法归纳下来就是四条路,难度和效果差异非常大:
- 容器 CSS 3D 倾斜:把一张普通平面饼图的 DOM 容器旋转一下,用 CSS 的
rotateX制造透视。零配置改动,五分钟出效果,但文字会被一起压扁。 - 多层 pie 堆叠:用十几个半径相同、颜色逐层加深、垂直方向逐层偏移的饼图叠在一起,靠视觉残影骗出"厚度"。配置量中等,兼容性最好。
- custom series 手绘:把每个扇区拆成顶面椭圆扇形、侧壁带状多边形、底边棱线,用自定义渲染逐块画出来。这是唯一能做出真实几何结构的方案,也是本文的重点。
- echarts-gl 的 surface 参数化:用参数方程生成曲面。理论上可行,实际上因为
surface只能接收z = f(x, y)这种单值函数,没法按扇区独立着色,做出来基本是个带渐变的圆盘,实用价值很低。
1.1 四种方案的横向对照
选型之前,我一般先看三个指标:能不能带标签、能不能 hover 联动、数据量撑不撑得住。按这三个维度把四条路摆在一起:
| 方案 | 视觉效果 | 标签支持 | 交互支持 | 实现成本 | 适配场景 |
|---|---|---|---|---|---|
| 容器 CSS 倾斜 | 中(有真实透视) | 差(文字变形) | 好 | 极低 | 快速 Demo、无标签大屏 |
| 多层 pie 堆叠 | 中(伪厚度) | 好 | 好 | 低 | 常规业务看板 |
| custom series 手绘 | 高(真几何) | 好(手动定位) | 好(需手写) | 高 | 有视觉要求的展示页 |
| surface 参数化 | 低 | 不支持 | 弱 | 中高 | 基本不推荐 |
这张表是我自己在多个数据大屏项目里反复试出来的结论。注意"交互支持"这一列,custom series 虽然要手写,但 ECharts 5 提供了api.state,hover 时能在renderItem里读到当前状态,做"扇区抬升"这种效果反而是四条路里最自然的。
1.2 为什么我最后推荐 custom series
多层堆叠那个方案,第一次看到会觉得挺惊艳,做一个小尺寸的饼图确实能糊弄过去。但一旦图表放大会发现,叠层之间的错位是"像素级"的,弧线边缘会出现锯齿状的台阶,特别是在渐变背景上特别明显。而且层与层之间没有真正的侧面,从侧面看过去是"死"的,扇区 hover 的时候整块一起动,做不了单个扇区抬升。
custom series 的代价是要自己算坐标,但换来的是完全的控制权:侧壁的明暗可以直接用光照方向算出来,越是朝向观察者的侧面越亮;扇区可以单独抬升;甚至可以在顶面做高光椭圆。这些在堆叠方案里都做不到。
代价是什么?大概 150 行左右的渲染代码,加上一点极坐标到屏幕坐标的换算。我下面会把这段代码完整拆开讲,你复制过去改几个常量就能用。
2. 最省事的一条路:容器 CSS 3D 倾斜
这个方法严格来说不算是"ECharts 实现",但它确实是目前网上流传最广、见效最快的做法,值得先讲清楚,因为它能帮你快速判断自己到底需不需要真正的 3D。
2.1 核心原理与最小可运行代码
思路非常朴素:ECharts 画出来的饼图就是一个正圆,你把这个圆所在的平面绕 X 轴转个角度,它在屏幕上就变成了椭圆,人眼会自动把它脑补成一个躺着的圆盘。这就是经典的等距投影错觉。
HTML 结构两层,外层负责裁剪和定位,内层负责倾斜:
<div class="pie-stage"> <div class="pie-tilted" id="chart"></div> </div>CSS 关键就三行:
.pie-stage { position: relative; width: 640px; height: 380px; margin: 40px auto; } .pie-tilted { position: absolute; left: 50%; top: 50%; width: 640px; height: 640px; transform-origin: center center; transform: translate(-50%, -50%) perspective(1100px) rotateX(58deg) scale(0.86); }这里的三个参数值得说一下。rotateX(58deg)决定了视觉倾斜度,垂直方向会被压缩为cos(58°) ≈ 0.53,这个比例和大多数 3D 饼图素材里的椭圆比例相当接近,是我试过最顺眼的角度。perspective(1100px)控制透视强度,值越小近大远小的效果越夸张,低于 600px 时圆的远端会被拉得很难看,我一般固定在 900 到 1200 之间。scale(0.86)是为了补偿旋转后视觉尺寸缩水,让整体看起来不显得太小。
容器高度必须比宽度小,因为圆被压扁了;如果你直接把 ECharts 容器设成 640×640 再旋转,会有一大片空白区域,图例和标题也会被推到看不见的地方。
ECharts 的配置基本不用改,只要注意一点:关掉所有标签和 labelLine。原因看下一节。
2.2 文字变形的问题,以及三种绕开的思路
倾斜容器之后,所有画在 canvas 里的内容都会被一起旋转,包括标签文字和数据。一段 12px 的文字被rotateX(58deg)之后,垂直方向被压缩一半,看起来就像一个被踩扁的蚂蚁,完全没法读。
我在项目里试过三种解法,各有取舍:
第一种是关掉系列标签,只靠 legend 和 tooltip。这是最省事的,label: { show: false }一行搞定,用户想知道具体数值就 hover 一下。缺点是信息密度低,如果图表要截图发到群里,别人看不到数值。
第二种是双实例叠加:下面一层是倾斜的图形实例,上面盖一层完全不倾斜的透明实例,只负责渲染标签、tooltip 和响应鼠标事件,两个实例的数据源共享,通过dispatchAction联动高亮。
// 两个实例共用同一份数据 const data = [/* ... */]; // 图形层:禁用交互和标签 const chart3d = echarts.init(document.getElementById('chart3d')); chart3d.setOption({ series: [{ type: 'pie', radius: '62%', label: { show: false }, labelLine: { show: false }, emphasis: { scale: false } }] }); // 标签层:透明饼图,只画引线和文字 const chartLabel = echarts.init(document.getElementById('chartLabel')); chartLabel.setOption({ series: [{ type: 'pie', radius: '62%', itemStyle: { opacity: 0 }, label: { show: true, formatter: '{b}\n{d}%' }, labelLine: { show: true } }] }); // 联动 chart3d.on('mouseover', (p) => { chartLabel.dispatchAction({ type: 'highlight', dataIndex: p.dataIndex }); });这套组合拳的坑在于:标签层因为没倾斜,它算出来的标签位置是按正圆算的,而视觉上的图形是椭圆,所以引线的起点会和扇区边缘对不上。视觉上表现为"线从空中飘过来"。要修正的话,得用labelLayout手动把labelLine的起点往下压,公式大致是y = center.y + (y - center.y) * Math.cos(θ)。
第三种是放弃标签,改用 canvas 外挂的 HTML 标注,用绝对定位的 div 贴在图上。这种方式最灵活,但一旦容器尺寸变化就要重算,维护成本很高。
2.3 这套方案的适用边界
坦白讲,CSS 倾斜方案最适合的场景是"一次性展示":活动页、宣传图、需要快速出效果的 Demo。它的优势是没有额外的 JS 逻辑,出问题概率极低,甚至不需要动 ECharts 的任何配置。
但如果你要做的是一个会长期迭代的业务看板,我不建议用它。原因有两个:一是它的 3D 只是视角错觉,圆柱体的侧面完全不存在,用户从图上得不到任何"高度"信息;二是倾斜之后整个图表的可点击区域也跟着变了,ECharts 内部的事件命中检测是按未变换的坐标算的,在部分浏览器上会出现"鼠标位置和 hover 扇区对不上"的问题,尤其是倾斜角度大的时候偏移会很明显。
3. 多层 pie 堆叠:用 12 个饼图叠出厚度
如果你只是想要"看起来有厚度",又不想写自定义渲染,堆叠法是最务实的选择。它的原理很简单:画 N 个数据完全相同、半径完全相同、但垂直位置逐层错开的饼图,最上面那层用正常颜色,往下每层颜色加深一点。人眼看到一叠由亮到暗的同心圆错位排布,就会理解成一个立体的圆盘。
3.1 层数和偏移量的取值方法
这两个参数直接决定成败。偏移量太小看不出厚度,太大就变成"千层饼"了。
我的经验值是:层数 10 到 16 层,每层向下偏移 1.2 到 1.8 像素。这样总厚度在 15 到 25 像素之间,对于一块高度 400px 左右的图表来说,厚度占比约 5%,视觉上既明显又不抢戏。
如果你想让厚度随容器自适应,可以按容器高度算:
const H = chart.getHeight(); const LAYERS = 14; const STEP = Math.max(1, Math.round(H * 0.0035)); // 400px 高时约 1.4px const THICK = LAYERS * STEP; const CENTER_Y = H / 2 - THICK / 2 + STEP; // 让整体在视觉上居中注意center的 y 坐标我用了像素值而不是百分比,因为百分比是相对容器高度算的,容器一变厚度就跟着变,不好控制。ECharts 的pie.center第二个元素支持直接写数字,单位是像素,从容器顶部算起。
3.2 完整配置与逐项说明
下面是能直接跑的配置。核心思路是用一个循环生成 N 个 series,用ratio控制每层的明暗。
const palette = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de']; const data = [ { value: 1048, name: '搜索引擎' }, { value: 735, name: '直接访问' }, { value: 580, name: '邮件营销' }, { value: 484, name: '联盟广告' }, { value: 300, name: '视频广告' } ]; // 颜色加深工具:k < 1 变暗,k > 1 变亮 function shade(hex, k) { const n = parseInt(hex.slice(1), 16); const r = Math.min(255, Math.round(((n >> 16) & 255) * k)); const g = Math.min(255, Math.round(((n >> 8) & 255) * k)); const b = Math.min(255, Math.round((n & 255) * k)); return `rgb(${r},${g},${b})`; } const chart = echarts.init(document.getElementById('chart')); const H = chart.getHeight(); const LAYERS = 14; const STEP = Math.max(1, Math.round(H * 0.0035)); const BASE_Y = H / 2 - (LAYERS * STEP) / 2 + STEP; const series = []; for (let i = 0; i < LAYERS; i++) { const ratio = i / (LAYERS - 1); // 0 是最底层,1 是最顶层 const isTop = i === LAYERS - 1; series.push({ type: 'pie', radius: ['0%', '60%'], center: ['50%', BASE_Y + i * STEP], avoidLabelOverlap: false, silent: !isTop, // 只有顶层响应鼠标 z: i, // 保证绘制顺序自下而上 label: { show: isTop, formatter: '{b}\n{d}%', color: '#e6ecf5', fontSize: 12, lineHeight: 18 }, labelLine: { show: isTop, length: 12, length2: 14, lineStyle: { color: 'rgba(230,236,245,0.45)' } }, emphasis: { scale: false, // 关键:堆叠时禁用放大,否则会露馅 itemStyle: { shadowBlur: 16, shadowColor: 'rgba(0,0,0,0.55)' } }, itemStyle: { borderWidth: 0, color: (params) => { // 底层压到 42% 亮度,顶层回到 100% const base = palette[params.dataIndex % palette.length]; return shade(base, 0.42 + 0.58 * ratio); } }, data }); } chart.setOption({ backgroundColor: '#0f1423', tooltip: { trigger: 'item', backgroundColor: 'rgba(20,26,42,0.92)', borderColor: 'rgba(120,150,200,0.3)', textStyle: { color: '#e6ecf5' }, formatter: '{b}<br/>{c} ({d}%)' }, legend: { bottom: 8, icon: 'circle', itemWidth: 8, itemHeight: 8, textStyle: { color: '#9fb0c8', fontSize: 12 } }, series });这段代码里有几个细节特别容易踩坑。
emphasis.scale必须设为false。ECharts 的 pie 在 hover 时默认会把扇区往外推几像素,堆叠状态下如果只有顶层被推动,底层不动,视觉上会像"盖子被掀开",穿帮得非常明显。
silent除了顶层全部设为true。否则你 hover 到底层某个扇区时,tooltip 会跳出来,而且因为底层被顶层遮住,鼠标其实"点不到"它,但 ECharts 的命中检测是按几何范围算的,会导致 tooltip 闪烁。
z要显式指定并且递增。ECharts 默认按 series 声明顺序绘制,理论上后面的盖住前面的,但在某些版本里 pie 的 z 优先级会被zlevel干扰,显式写出z: i最稳。
3.3 让它更像"实物"的三个打磨点
配置跑通只是第一步,要做到能上大屏,还得补三样东西。
第一,顶面加一层斜向高光。真实的圆盘顶面不会是纯色,会有光从某个方向打过来。可以在顶层 series 的itemStyle里再叠一个LinearGradient:
itemStyle: { color: (params) => { const base = palette[params.dataIndex % palette.length]; return new echarts.graphic.LinearGradient(0, 0, 0.6, 1, [ { offset: 0, color: shade(base, 1.25) }, { offset: 0.55, color: base }, { offset: 1, color: shade(base, 0.82) } ]); } }第二,最底层加投影。在堆叠的最下面再垫一个纯黑色的、稍微大一圈的饼图,透明度 0.25,shadowBlur开到 30 以上,模拟落地阴影。这一步会让整个图形"贴"在背景上,而不是漂浮着。
第三,侧壁边缘加一条暗线。在顶层 series 上给itemStyle加borderWidth: 1、borderColor: 'rgba(0,0,0,0.15)',能让扇区之间的边界更清晰,特别是在深色背景的浅色扇区上。
3.4 这个方案的边界在哪里
堆叠法最大的问题是放大后的锯齿。因为层与层之间是整数像素偏移,在 2 倍屏上勉强看不出来,但在 1 倍屏、图表尺寸又大的时候,侧面会出现明显的阶梯。我在一个 1200px 宽的大屏项目里就遇到过这个问题,最后的解法是把层数从 12 提到 26,偏移降到 1px,代价是 series 数量翻倍,首屏渲染时间从 40ms 涨到了 90ms 左右。
另一个边界是环形图做不了。因为每层都是一个完整的饼,如果你把radius改成['40%', '60%'],叠出来的空心部分会互相穿透,中间那个洞会变成一堆错位的弧线。环形 3D 饼图只能走 custom series 那条路。
4. custom series 手绘:把每个扇区真正"立"起来
终于到正题了。这条路要自己算坐标,但一旦跑通,你就是完全自由的。
4.1 先把投影模型算清楚
我们要做的是正交投影下的等距圆柱。假设我们从一个斜上方 60° 的角度俯视一个圆柱体,那么:
- 圆柱的顶面圆在屏幕上投影成一个椭圆,水平半径不变,垂直半径乘以压缩系数
K。 - 圆柱的侧面在屏幕上是一块带状区域,高度等于圆柱厚度
THICK(正交投影下厚度不会变化)。
这在数学上非常简单。设椭圆中心为(CX, CY),水平半径R,压缩比K(我常用 0.5 到 0.55),那么角度a对应的顶面点就是:
x = CX + R * cos(a) y = CY + R * K * sin(a)注意屏幕坐标 y 轴向下,所以sin(a)为正时点在下方。起始角我取-π/2,对应 12 点方向,角度递增方向就是顺时针,和 ECharts 原生饼图的默认行为一致。
底面点就是把顶面点的 y 加上厚度:
y_bottom = y_top + THICK每个扇区的角度区间由数据占比算出:
const total = data.reduce((s, d) => s + d.value, 0); let acc = 0; const sectors = data.map((d, i) => { const a0 = -Math.PI / 2 + (acc / total) * Math.PI * 2; acc += d.value; const a1 = -Math.PI / 2 + (acc / total) * Math.PI * 2; return { ...d, a0, a1, index: i }; });4.2 四类图元与它们的绘制顺序
一个完整的 3D 扇区由这些部分组成:
- 顶面:从椭圆中心出发,沿弧线走一圈回到中心,形成一个扇形多边形。
- 侧壁:把弧线按细分点拆成上下两排点,拼成一个带状多边形。
- 底边棱线:可选,但加上会让轮廓更实。
- 标签:在扇形中线上手动定位。
绘制顺序上,因为在正交投影且所有扇区高度相同的情况下,扇区之间不会互相遮挡,所以顺序其实无所谓。但如果做了 hover 抬升,就要把被抬起的那个扇区放到最后画,否则它的侧壁会被相邻的扇区盖住。
标签位置的计算,就是在扇形中线的 60% 半径处:
const midA = (s.a0 + s.a1) / 2; const lr = R * 0.6; const lx = CX + lr * Math.cos(midA); const ly = CY + lr * K * Math.sin(midA);因为椭圆是压扁的,标签的 y 也要跟着乘K,否则标签会飘到图形外面去。
4.3 可以直接复制运行的完整实现
坐标系我用的是cartesian2d,把坐标轴的min/max直接设成画布的像素宽高,这样数据坐标和像素坐标就是 1:1,算起来不用来回换算。y 轴加inverse: true让数据值向下增长,和屏幕坐标一致。
const chart = echarts.init(document.getElementById('chart3d')); const W = 800; // 逻辑画布宽 const H = 560; // 逻辑画布高 const R = 168; // 顶面椭圆水平半径 const K = 0.52; // 椭圆压缩比 const THICK = 46; // 厚度(像素) const CX = W / 2; const CY = H / 2 - THICK / 2; const raw = [ { value: 1048, name: '搜索引擎', color: '#5470c6' }, { value: 735, name: '直接访问', color: '#91cc75' }, { value: 580, name: '邮件营销', color: '#fac858' }, { value: 484, name: '联盟广告', color: '#ee6666' }, { value: 300, name: '视频广告', color: '#73c0de' } ]; function shade(hex, k) { const n = parseInt(hex.slice(1), 16); const r = Math.min(255, Math.round(((n >> 16) & 255) * k)); const g = Math.min(255, Math.round(((n >> 8) & 255) * k)); const b = Math.min(255, Math.round((n & 255) * k)); return `rgb(${r},${g},${b})`; } const total = raw.reduce((s, d) => s + d.value, 0); let acc = 0; const sectors = raw.map((d, i) => { const a0 = -Math.PI / 2 + (acc / total) * Math.PI * 2; acc += d.value; const a1 = -Math.PI / 2 + (acc / total) * Math.PI * 2; return { ...d, a0, a1, index: i }; }); const pt = (a, dy = 0) => [CX + R * Math.cos(a), CY + R * K * Math.sin(a) + dy]; function arc(a0, a1, dy, step = 0.07) { const n = Math.max(3, Math.ceil(Math.abs(a1 - a0) / step)); const out = []; for (let i = 0; i <= n; i++) { out.push(pt(a0 + (a1 - a0) * (i / n), dy)); } return out; } function renderItem(params, api) { const idx = api.value(0); const s = sectors[idx]; if (!s) return; const isActive = api.state === 'emphasis'; const lift = isActive ? -10 : 0; // hover 时整体上抬 const topArc = arc(s.a0, s.a1, lift); const botArc = arc(s.a0, s.a1, lift + THICK); const children = []; // 1) 侧壁:整条弧正反拼接成带状多边形,纵向渐变模拟光照 children.push({ type: 'polygon', shape: { points: [...topArc, ...[...botArc].reverse()] }, style: { fill: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: shade(s.color, isActive ? 0.95 : 0.78) }, { offset: 1, color: shade(s.color, 0.36) } ]) }, silent: true }); // 2) 顶面:中心 + 弧线 children.push({ type: 'polygon', shape: { points: [[CX, CY + lift], ...topArc] }, style: { fill: new echarts.graphic.LinearGradient(0, 0, 0.4, 1, [ { offset: 0, color: shade(s.color, 1.28) }, { offset: 0.5, color: s.color }, { offset: 1, color: shade(s.color, 0.86) } ]), stroke: 'rgba(0,0,0,0.20)', lineWidth: 1 }, emphasis: { style: { lineWidth: 1.5, stroke: '#ffffff' } } }); // 3) 标签 const midA = (s.a0 + s.a1) / 2; const lr = R * 0.60; children.push({ type: 'text', x: CX + lr * Math.cos(midA), y: CY + lr * K * Math.sin(midA) + lift, style: { text: `${s.name}\n${((s.value / total) * 100).toFixed(1)}%`, fill: '#f2f6ff', fontSize: 12, lineHeight: 17, align: 'center', verticalAlign: 'middle', textShadowColor: 'rgba(0,0,0,0.6)', textShadowBlur: 4 }, silent: true }); return { type: 'group', children }; } chart.setOption({ backgroundColor: '#0d1220', grid: { left: 0, right: 0, top: 0, bottom: 0, containLabel: false }, xAxis: { type: 'value', min: 0, max: W, show: false, axisPointer: { show: false } }, yAxis: { type: 'value', min: 0, max: H, inverse: true, show: false, axisPointer: { show: false } }, tooltip: { trigger: 'item', backgroundColor: 'rgba(18,24,40,0.94)', borderColor: 'rgba(120,150,200,0.35)', textStyle: { color: '#e6ecf5' }, formatter: (p) => { const d = sectors[p.dataIndex]; if (!d) return ''; return `${d.name}<br/>${d.value} 次 · ${((d.value / total) * 100).toFixed(2)}%`; } }, series: [{ type: 'custom', renderItem, clip: false, silent: false, data: sectors.map((s, i) => ({ name: s.name, value: [i, s.value] })), emphasis: { disabled: false } }] }); window.addEventListener('resize', () => chart.resize());4.4 交互补全:hover 抬升与优先级处理
上面代码里我用了api.state来判断当前是否处于 emphasis 状态。这个 API 在 ECharts 5.x 里是可用的,返回'normal'、'emphasis'、'blur'或'select'。它需要在 series 里显式声明emphasis配置(我写了emphasis: { disabled: false })才会被激活,否则永远是normal。
实际跑起来你会发现一个问题:抬起的扇区侧壁可能被相邻扇区盖住。因为所有扇区在同一个 custom series 里,绘制顺序是按 data 顺序来的。要解决的话,把被抬起的扇区的数据临时移到数组末尾,然后setOption更新:
chart.on('mouseover', (p) => { if (p.seriesType !== 'custom') return; // 把当前项挪到末尾,保证最后绘制 const list = sectors.slice(); const cur = list.splice(p.dataIndex, 1)[0]; list.push(cur); chart.setOption({ series: [{ data: list.map((s, i) => ({ name: s.name, value: [sectors.indexOf(s), s.value] })) }] }); });嗯,这个写法有点绕,因为api.value(0)取的是sectors里的原始下标。更干净的做法是直接在渲染时把"当前激活项"存到一个外部变量里,renderItem里读它决定lift值,这样只需要setOption一个空对象触发重绘:
let activeIndex = -1; chart.on('mouseover', (p) => { if (p.seriesType === 'custom') { activeIndex = p.dataIndex; chart.setOption({}); } }); chart.on('mouseout', () => { activeIndex = -1; chart.setOption({}); });然后在renderItem里把isActive改成idx === activeIndex。这种方式性能更好,也不会有数据重排的问题。注意chart.setOption({})传空对象只会触发重绘,不会重置配置,这是 ECharts 里一个很实用的小技巧。
5. 几个绕不开的实操细节
5.1 尺寸自适应与高清屏
custom series 用的是固定像素坐标,这就意味着容器尺寸一变,图就错位了。标准做法是监听resize,重新计算W、H、R、CX、CY这些常量,然后setOption全量重绘:
function relayout() { const dom = chart.getDom(); W = dom.clientWidth; H = dom.clientHeight; R = Math.min(W, H) * 0.30; CX = W / 2; CY = H / 2 - THICK / 2; chart.resize(); chart.setOption({ xAxis: { max: W }, yAxis: { max: H } }); }注意顺序:先chart.resize()让 ECharts 更新内部尺寸,再setOption改轴的max值。反过来做的话,坐标轴范围还是旧的,图会画歪。
高清屏还有一个坑。ECharts 默认会按devicePixelRatio缩放 canvas,所以你用像素坐标算出来的图形在高清屏上依然是清晰的,不需要额外处理。但如果你的页面上做了rem或者vw缩放(比如大屏常用的transform: scale()适配方案),那么 canvas 本身会被拉伸,图形会出现边缘发虚。这种情况下的解法是:用chart.getWidth()而不是dom.clientWidth来取尺寸,因为前者返回的是缩放前的逻辑尺寸。
还有一个常见提问是"用了postcss-pxtorem之后 ECharts 没跟着缩放"。这个问题的根源是 ECharts 内部大量使用内联的 px 值,这些值不会经过 PostCSS 处理。正确做法是保持 ECharts 容器尺寸用 px 计算,只在外层包裹的容器上做 rem 转换,或者在resize时用chart.resize({ width, height })主动传入 rem 换算后的像素值。
5.2 tooltip 换行与 labelLine 偏移
这两个问题在 3D 饼图里出现的频率特别高,因为它们和图形定位强相关。
tooltip 内容太长不换行,是因为 ECharts 的 tooltip 默认white-space: nowrap。最简单的解法是给 tooltip 加extraCssText: 'white-space:normal; max-width:260px; word-break:break-all;'。如果需要在 formatter 里主动控制换行,用\n加extraCssText: 'white-space:pre-line;'。
labelLine 末尾的小圆点位置偏移,这个在原生饼图叠加方案里很常见。原因是labelLine的起点由扇区边缘决定,而如果你给itemStyle加了borderWidth或者做了缩放,边缘位置会变,圆点就会和弧线对不齐。调整方式是关掉默认的小圆点(labelLine: { showAbove: true, smooth: false }),自己在label.formatter里用rich富文本画一个圆点符号,位置就完全由你控制了。
5.3 数据量大时怎么办
如果扇区数量超过 12 个,custom series 的渲染压力会明显上升。每个扇区的弧线细分点数和扇区角度成正比,一个 3° 的小扇区也会有至少 3 个细分点,加上侧壁的翻倍,元素数量膨胀得很快。
我的处理办法是按角度动态调整细分精度:角度大于 30° 的扇区用step = 0.05(约每 3° 一个点),小于 10° 的用step = 0.15。这样小扇区省下来的开销足够支撑大扇区的平滑度。实测在一个 16 扇区的图表上,元素数量能从 400 多降到 200 左右,帧率从 45fps 回到稳定 60fps。
另外,把自定义系列的progressive打开(比如progressive: 200)能进一步优化大数据量下的首屏渲染,它会分批绘制而不是一次性画完。
6. 常见问题速查与我的踩坑记录
6.1 问题速查表
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 图形整体偏移,不在容器中央 | xAxis.max和实际容器宽不一致 | 在resize后同步更新轴max,用chart.getWidth()取真实逻辑宽 |
| 扇区之间出现细缝 | 弧线细分点数量太少,相邻多边形没接上 | 降低step值到 0.04 以下,或给顶面加 0.5px 描边 |
| hover 不触发 | custom series 的silent为 true,或没声明emphasis | 设silent: false,并写emphasis: { disabled: false } |
tooltip 显示的是[0, 1048]这种数组 | 没有自定义 formatter,走的是默认取值逻辑 | 在tooltip.formatter里从sectors取原始数据 |
| 侧壁颜色一片死黑 | 渐变方向写反,或者shade系数给太低 | 检查LinearGradient的起止点,侧壁顶部系数不低于 0.7 |
| 高清屏上图形发虚 | 外层做了 CSS 缩放,canvas 被拉伸 | 用chart.getWidth()代替clientWidth,必要时关闭transform缩放 |
| 移动端手指点不中扇区 | 命中区域只有顶面多边形 | 把侧壁的silent也设为 false,扩大命中范围 |
6.2 我踩过的几个坑
第一个坑:以为echarts-gl装了就有pie3D。这个前面说过了,但还是要重复一遍,因为我见过至少五个项目在这个上面浪费了半天。echarts-gl里没有任何形式的三维饼图,别再去它的文档里找了。
第二个坑:侧壁用逐段小多边形画,性能炸了。我最早实现的时候,为了做逐段光照,把侧壁拆成每 2° 一个小四边形。一个 90° 的扇区就是 45 个多边形,5 个扇区 225 个,再加上顶面和标签,元素总数接近 900。结果就是鼠标移动时明显掉帧。后来改成整条弧拼一个多边形,侧壁的明暗改由整块的纵向渐变来表现,元素数量直接降到十分之一。视觉上的差别肉眼几乎看不出来,但性能提升是数量级的。
第三个坑:center用百分比导致厚度不稳定。堆叠方案里我一开始写的是center: ['50%', 50 - i * 0.5 + '%'],本地小窗口测试没问题,扔到大屏 1080p 上发现厚度变了两倍多。原因是百分比偏移是相对容器高度算的。改成像素偏移之后就锁死了。
第四个坑:忘了处理mouseout。做 hover 抬升的时候,我在mouseover里改了activeIndex,但忘了在mouseout里复位。结果就是鼠标滑过一圈之后,所有扇区都停在了抬起状态,看起来像整张图飘起来了。这个 bug 特别隐蔽,因为你在开发的时候鼠标一直在图上移动,很难注意到。
第五个坑:标签和图形打架。当某个扇区特别小(占比不到 2%)时,按中位线定位的标签会和相邻扇区的标签重叠。我的做法是给标签位置加一个判断:如果扇区角度小于 0.18 弧度,就把标签沿着中线的方向往外推,推到半径的 1.1 倍处,并用一条短线连回扇形。这就相当于手动实现了一遍labelLine的避让逻辑。
6.3 一点延伸的想法
custom series 这套骨架其实不只用来画 3D 饼图。把pt函数里的椭圆参数改一改,就能做3D 环形图(顶面用内外两条弧拼成环带,侧壁要画内外两面);把角度计算改成 0 到 2π 的均匀分布,就能做3D 仪表盘;再极端一点,如果给每个扇区不同的THICK,用厚度去编码第二个数据维度,就得到了一个"高度可变的 3D 饼图"——这个在展示数据时其实比单纯的饼图信息量大得多,我最近就在一个项目里用它来同时表达"占比"和"总量"。
代价是高度的映射需要做归一化,否则数据差异大的时候最高的那个扇区会顶穿容器。我的经验是把最大厚度控制在半径的 35% 以内,同时给底部留出至少 60px 的空间给图例。
整套代码我放在项目里跑了大概半年,从小屏手机到 4K 大屏都测过,主要的稳定性风险还是集中在 resize 那一段。如果你的容器尺寸是固定不变的,那基本上写完就不用管了。但如果是响应式布局,建议把 relayout 的逻辑抽成一个防抖函数,resize事件触发频率太高的时候,频繁重绘反而会导致页面卡顿,我用 120ms 的防抖基本就够了。