做可视化项目这些年被问得最多的一类需求就是“能不能把图做得立体一点”尤其是饼图。Vue ECharts 这套组合大家都很熟2D 饼图、环形图配个 legend 加 label写起来五分钟不到。但一旦需求变成“3D 饼图”很多人的第一反应是去 ECharts 配置文档里找series类型叫pie3D或者pie-3d之类的东西结果搜了一圈发现根本没有这个类型于是卡住了。实际上 ECharts 官方并没有原生支持真正的 3D 饼图所谓 3D 效果通常要靠扩展库 echarts-gl 来实现。echarts-gl 里提供的是scatter3D、bar3D、surface、globe这些基础三维系列并没有一个现成的“3D 饼图”系列。换句话说想要 3D 饼图得先理解三维世界的底层逻辑然后用这些基础能力去模拟和构建。这篇文章我把自己在 Vue 项目里实现 3D 饼图和环形图的完整思路、核心代码、以及踩过的坑全部整理出来给正在被这个需求折磨的同学一个可以直接抄作业的方案。这篇文章适合两类人一类是在公司做数据可视化大屏、被产品要求“把饼图做成 3D”的前端开发另一类是刚开始接触 echarts-gl、想搞清楚它到底能干什么的同学。我会从原理讲到实战再讲排查尽量把每个“为什么”都交代清楚。1. 先搞清楚一件事3D 饼图到底是怎么画出来的1.1 为什么 ECharts 没有原生 3D 饼图要理解这一点得先看 ECharts 的底层设计。ECharts 2D 图表走的是 Canvas 或者 SVG 渲染饼图本质上就是把一个圆按比例切分成若干扇形然后在二维平面上做填充和描边。2D 饼图的核心是角度一个数据项占的百分比决定了它对应的圆心角大小这个逻辑在 Canvas 2D 的ctx.arc里几行代码就搞定了。但 3D 场景完全不同。一旦涉及到三维就必须处理透视、光照、材质、深度排序这些东西底层渲染方式也要切换到 WebGL。ECharts 本身的 2D 渲染管线做不了这件事所以官方单独搞了一个 echarts-gl 库专门用来扩展三维可视化能力。echarts-gl 提供的是通用三维图元比如散点、柱条、曲面、地球但饼图这种“把高度抽象出来的立体图形”不属于通用图元官方没有做也不太可能专门做因为饼图一旦变成 3D其实已经不算严格意义上的“饼图”了。所以实际情况就是3D 饼图需要我们自己用scatter3D或者bar3D这类基础系列去模拟需要用离散点阵去构造一个圆柱体再按角度给不同扇区上色。从本质上讲你做出来的不是“饼图”而是一个被染了色的立体圆柱只不过从轴侧视角看过去它长得像饼图。1.2 用离散点构建立体饼图的数学原理每个接触过图形学的同学都清楚三维世界里没有曲面这种东西所有曲面最终都是靠三角形网格或者点云去逼近的。一个看起来光滑的球体拉近了看也是一堆顶点组成的多面体。3D 饼图也是同样的思路我们要在一个圆形区域内生成大量点点的位置由极坐标转换决定。一个简单得接近直觉的公式对于圆柱体水平面上某个点的坐标是x r * cos(angle) y r * sin(angle)其中r是半径angle是极角。再叠加一个垂直方向上的高度z就得到了三维空间里的点。如果对同一个角度让半径r从内半径变化到外半径然后让角度从起始角扫到结束角再按数据值映射出高度z那这些点就能组成一个扇区形状的“实体”。把所有扇区拼起来就是一个完整的被分割成多块的圆柱这就是 3D 饼图。实际操作中我们只需要两层循环外层循环角度内层循环半径。角度循环的步长决定饼图边缘的光滑度半径循环的步长决定表面的致密程度。步长越小点越多图形越细腻性能开销也越大。这个平衡点我在后面会专门讲。1.3 为什么不用 Three.js 直接用有些人可能会问既然要自己造立体图形为什么不干脆用 Three.js这里我聊聊自己的经验。Three.js 确实更底层、更灵活能做的东西也远超 echarts-gl但代价是要自己处理数据映射、颜色映射、坐标轴、提示框、图例、响应式布局这些乱七八糟的前端逻辑。而 echarts-gl 搭了 ECharts 的便车legend、tooltip、visualMap这些组件在三维系列里很多也能复用配置风格也和 2D 保持一致团队如果已经有 ECharts 的技术积累上手成本最低。另外还有一个很现实的原因你们项目里大概率已经用了 ECharts 画折线图、柱状图再引入一个 Three.js 等于让项目多了一套渲染体系。而 echarts-gl 的包本身是基于 ECharts 扩展的引入姿势和用其他 ECharts 插件一样心智负担小得多。2. 环境准备Vue 项目里正确引入 echarts-gl2.1 安装版本与依赖匹配先说明版本问题。ECharts 4 对应的是 echarts-gl 1.xECharts 5 对应的是 echarts-gl 2.x。如果你用的是 ECharts 5就不要去装 echarts-gl 1.x否则大概率会出现一堆莫名其妙的报错。在 Vue 项目根目录执行npm install echarts5 echarts-gl2注意echarts-gl会依赖echarts作为 peer dependency正常情况下 npm 会自动处理依赖关系但如果你的项目里原来就有 ECharts 4建议先卸载再重装避免版本冲突npm uninstall echarts npm install echarts5 echarts-gl22.2 全局引入还是按需引入echarts-gl 最省心的引入方式是在入口文件main.js里直接全局注册import * as echarts from echarts import echarts-gl这里import echarts-gl会往 echarts 上注册所有的三维系列包括scatter3D、bar3D、surface、lines3D等。之后在任意组件里用echarts.init创建实例配置项里的series就可以直接写type: scatter3D了。有人可能会想用 ECharts 5 的按需引入也就是从echarts/core里按模块引但实际上 echarts-gl 目前对按需引入的支持并不友好它的官方文档和示例基本都走的是全量引入路线。为了省事我个人建议直接全量引入。仪表盘项目一般也不差 echarts 全量引入那几百 KB 的体积实在介意可以用按需引入 ECharts 本体然后单独把 echarts-gl 挂上去但那样配置起来容易出问题不推荐新手折腾。2.3 Vue 组件里的基础封装在 Vue 2 项目里组件结构一般是这样的template div refchartRef classchart-container/div /template script import * as echarts from echarts import echarts-gl export default { name: Pie3D, data() { return { chart: null } }, mounted() { this.chart echarts.init(this.$refs.chartRef) this.setOption() window.addEventListener(resize, this.handleResize) }, beforeDestroy() { window.removeEventListener(resize, this.handleResize) if (this.chart) { this.chart.dispose() this.chart null } }, methods: { handleResize() { this.chart this.chart.resize() }, setOption() { // 核心配置 } } } /script style scoped .chart-container { width: 100%; height: 500px; } /style这三点需要特别留意容器必须有一个确定的宽度和高度否则 ECharts 初始化后会得到一个 0 尺寸的 canvas图根本不会显示echarts.init必须在组件挂载之后调用也就是mounted生命周期里因为此时 DOM 元素才真正存在组件销毁前要调用dispose()并移除 window 上的 resize 监听否则会有内存泄漏风险在路由切换频繁的项目里尤其明显。3. 3D 饼图核心实现用 scatter3D 构造立体扇区3.1 生成扇区点位数据我先拿出自己封装的生成函数这段代码是整个 3D 饼图的核心建议直接复制保存。/** * 生成3D饼图的数据点 * param {Array} data 原始数据 [{ name: 品类A, value: 100 }, ...] * param {Number} innerRadius 内半径 * param {Number} outerRadius 外半径 * param {Number} height 最大高度 * param {Number} angleStart 起始角度弧度制 * param {Number} angleOffset 扇形间隔角度弧度制 */ function generatePie3DData(data, innerRadius, outerRadius, height, angleStart 0, angleOffset 0.02) { const points [] const total data.reduce((sum, item) sum item.value, 0) let currentAngle angleStart data.forEach((item, index) { const angleSpan (item.value / total) * Math.PI * 2 - angleOffset const colorIndex index % data.length for (let angle currentAngle; angle currentAngle angleSpan; angle 0.02) { for (let r innerRadius; r outerRadius; r 0.5) { const x r * Math.cos(angle) const y r * Math.sin(angle) const z (item.value / total) * height points.push({ value: [x, y, z], name: item.name, itemIndex: index, rawValue: item.value }) } } currentAngle angleSpan angleOffset }) return { points, currentAngle } }这里有几个关键点要解释一下。首先是角度循环步长0.02这是弧度值约等于 1.15 度。360 度整圆大概需要 314 次角度循环配合半径循环总点数是可控的。如果发现边缘锯齿感很强可以把步长调小到0.01点数量会翻倍如果渲染卡顿可以调到0.03以上。内层半径步长0.5决定的是柱体表面的致密度我没有把这个步长做得太小因为笛卡尔坐标系下角度相同、半径相邻的两列点最终渲染时视觉上已经能覆盖成面太过密集只会白白消耗 GPU。其次是扇区间隔angleOffset。很多现成教程会忽略这个参数直接让相邻扇区首尾相连。但实际渲染出来扇区之间如果没有间隙整个圆柱看起来就只是一个被画了不同颜色的整体立体感会被削弱。我习惯给每个扇区留一点点间隔角度这样每个扇区像一块独立的“积木”立体感和层次感会明显得多。然后是 z 轴高度。value越大的扇区越高这是 3D 饼图和 2D 饼图本质上的区别。2D 饼图的数值只体现在面积角度上而 3D 饼图多了一个高度维度视觉上会更直观但也更容易造成误导——人眼对高度的感知比对面积的感知更敏感。3.2 完整 Option 配置有了点数据之后ECharts 的 option 配置如下const { points } generatePie3DData(chartData, 3, 10, 8) const option { tooltip: { trigger: item, formatter: function(params) { return params.data ? params.data.name br/数值 params.data.rawValue : } }, xAxis3D: { type: value, min: -12, max: 12 }, yAxis3D: { type: value, min: -12, max: 12 }, zAxis3D: { type: value, min: 0, max: 10 }, grid3D: { show: false, boxWidth: 100, boxHeight: 40, boxDepth: 100, axisPointer: { show: false }, viewControl: { alpha: 25, beta: 0, distance: 90, autoRotate: false }, light: { main: { intensity: 1.2, shadow: true }, ambient: { intensity: 0.3 } } }, series: [{ type: scatter3D, coordinateSystem: cartesian3D, symbolSize: 0.8, blendMode: lighter, itemStyle: { color: function(params) { const colorList [#5470c6, #fac858, #ee6666, #73c0de, #3ba272, #fc8452, #9a60b4, #ea7ccc] return colorList[params.data.itemIndex % colorList.length] } }, data: points }] } this.chart.setOption(option)我来逐个解释这些配置的作用因为很多人抄了代码不调参最后发现效果和预期差很多。symbolSize是散点的大小。我设置成0.8这个值如果太大会让点与点之间出现明显间隔看起来像颗粒太小则渲染时点几乎不可见但多个点叠加后还是能形成连续的面。实测下来0.5到1之间比较合适具体要看图表缩放比例。blendMode: lighter是一个容易出效果的点。它会让重叠的像素颜色叠加变亮这样在扇区边缘和两侧点的叠加密度不同会产生自然的明暗变化立体感会更强。如果不设置这个值默认的 blending 模式下整个饼图看起来是平的像一张贴纸。xAxis3D / yAxis3D / zAxis3D的范围需要和半径、高度匹配。上面我把外半径设置为 10饼图中心在原点所以 x 和 y 的范围设置-12 ~ 12比较合适。z 轴范围从 0 到 10这里的上限只要大于height参数就能保证点不被裁切。grid3D里的show: false是隐藏三维坐标系网格和坐标轴否则图上会保留一个 3D 坐标盒子看起来像工程建模软件而不是数据图表。很多人第一次写 3D 图都会在这里踩坑默认的 grid3D 是显示的需要手动关掉。viewControl控制三维视角这是 3D 图表的灵魂配置。alpha是俯仰角25 度左右看 3D 饼图最自然太高接近于俯视太低就变成平视了distance是摄像机距离根据饼图的大小来调让图表占住画布的 60% 到 70% 比较合适autoRotate如果改成 true图表会自动旋转在大屏展示时看起来更炫但交互时不容易点准我一般默认关掉。3.3 加一个底盘更真实上面实现的其实是一个“立体圆柱被竖着切了几刀”的效果底面是空的从某些角度能看到空心。为了让视觉效果更完整我会再用surface系列生成一个圆形底盘。// 生成底盘点数据 function generateBottomData(radius, segment 60) { const points [] for (let i 0; i segment; i) { for (let j 0; j segment; j) { const u (i / segment) * Math.PI * 2 const v (j / segment) * radius points.push([v * Math.cos(u), v * Math.sin(u), 0.01]) } } return points }// 在 series 数组里追加 { type: surface, coordinateSystem: cartesian3D, data: generateBottomData(12), itemStyle: { color: #333 }, silent: true }这个底盘的 z 坐标设置成0.01而不是 0是避免和扇区最低点完全重叠产生 z-fighting 闪烁问题。底盘颜色我用深灰色这样能让上面的彩色扇区更突出。如果你想让整个图更轻盈也可以把底盘去掉完全看需求。3.4 事件交互与外部联动ECharts 的 2D 图表绑定点击事件大家都很熟3D 系列也支持this.chart.on(click, function(params) { if (params.data) { console.log(点击了, params.data.name) // 联动其他图表 } })有一点要注意由于scatter3D的数据是离散的点点击事件返回的params.data是我们自己 push 进去的对象所以可以在对象里塞任意自定义字段比如name、rawValue、itemIndex方便外部逻辑处理。另外底盘surface系列设了silent: true这样点击底盘时不会触发事件避免误操作。4. 3D 环形图本质是给圆柱开一个同心圆孔4.1 先回顾 2D 环形图的做法在开始 3D 环形图之前先说说 2D 环形图。ECharts 2D 环形图的实现其实特别简单就是在饼图的radius配置里同时指定内半径和外半径series: [{ type: pie, radius: [40%, 70%], data: [...] }]内半径 40% 外半径 70% 就形成了一个环。2D 环形图在很多场景下比 2D 饼图更好看因为中心区域可以放标题或者放总计数值。这也是为什么很多大屏项目首选环形图而不是饼图。4.2 3D 环形图的数据生成差异3D 环形图和 3D 饼图的差别也非常直接饼图是从圆心到外半径环形图是从内半径到外半径。也就是说在生成点位数据的循环里半径r的起点从0改成innerRadius就行。function generateRing3DData(data, innerRadius, outerRadius, height, angleStart 0, angleOffset 0.02) { const points [] const total data.reduce((sum, item) sum item.value, 0) let currentAngle angleStart data.forEach((item, index) { const angleSpan (item.value / total) * Math.PI * 2 - angleOffset for (let angle currentAngle; angle currentAngle angleSpan; angle 0.02) { for (let r innerRadius; r outerRadius; r 0.5) { points.push({ value: [r * Math.cos(angle), r * Math.sin(angle), (item.value / total) * height], name: item.name, itemIndex: index, rawValue: item.value }) } } currentAngle angleSpan angleOffset }) return points }调用方式const ringData generateRing3DData(chartData, 5, 10, 5)这里内半径5外半径10环的宽度是 5和饼图相比少了一块圆心区域。从渲染效果上看3D 环形图就是一个空心圆柱被竖着切了几刀每个扇区中间还有空隙视觉效果要比实心饼图轻盈很多也更适合放在大屏中央配合中心区域的数字指标。4.3 视角调优环形图对视角更敏感3D 环形图有个明显特点对视角高度特别敏感。如果你把alpha调得太大比如 60 度以上整个环形会显得很扁平内半径和外半径的间距会被压缩甚至看不出来是环如果alpha调得太小高度方向拉伸夸张环内壁几乎看不到像一个厚实的轮胎。我用下来比较舒服的值是alpha: 30此时既能清楚地看到环形顶面的扇区分割又能看到环的外壁和内壁三维感最强。另外环形图因为中心区域是空的所以distance摄像机距离反而可以比饼图设得更近一点让环充满整个视口。我自己做 3D 环形图时会把viewControl里的distance设置成饼图的 80%这样环的细节更清晰。4.4 另一种思路用 bar3D 做 12 个环块除了用scatter3D点阵模拟环体还有一种实现 3D 环形图的方式是用bar3D围成一个圆环。做法是把圆环分成 N 等份每份是一个bar3D的柱体const ringBarData [] const sectors 12 for (let i 0; i sectors; i) { const angle (i / sectors) * Math.PI * 2 const x 8 * Math.cos(angle) const y 8 * Math.sin(angle) ringBarData.push([x, y, value / total * height]) } series: [{ type: bar3D, coordinateSystem: cartesian3D, data: ringBarData, barSize: [2, 2, height], shading: lambert }]这种方式的优点是每个数据项是一个独立的柱体交互事件非常明确鼠标悬浮高亮效果也更标准。缺点也很明显柱体多了以后更像一个“圆形的柱状图”而不是环形图。它适用的场景是数据项少、每个项需要强调整体的数据块感比如“一台服务器的 12 个核心 CPU 使用率”那 12 个围成环的齿轮状柱体反而非常合适。5. 遇到问题怎么办高频故障与实测排坑5.1 图表区域一片空白这个问题出现的频率最高。大部分时候不是代码问题而是容器尺寸导致的。echarts.init时如果容器宽度高度为 0canvas 不会渲染控制台也不报错看起来就像“白屏”。解决办法是先给容器设置明确的宽高比如上面的示例里.chart-container { width: 100%; height: 500px; }。如果容器尺寸没问题再看看是不是echarts-gl没被正确引入。检查方式是在控制台输入echarts变量看series里有没有scatter3D对应的注册信息。如果echarts-gl没有生效即使写了type: scatter3DECharts 也不会报错只会静默地跳过这个 series留下空白画布。5.2 饼图看起来是平的没有立体感这个问题几乎每个人都碰到过。原因多半是blendMode没设置或者点密度不够。如果点太少扇区边缘会看到明显的锯齿如果blendMode是默认值图的明暗变化不明显看起来就是一块扁平的彩色拼图。我的建议是先把角度步长调到0.02以下再把blendMode设置成lighter然后在grid3D里开启主光源适当增加light.main.intensity到 1.2 左右。这三个动作配合起来立体感立刻就能出来。5.3 扇区颜色不按预期显示itemStyle.color写成函数后每个点都调用回调如果点非常多几万甚至十几万个点每次渲染都会有性能损失。更常见的坑是params.data.itemIndex这个取值路径。如果你在构造点位数据时把itemIndex放在了 value 数组里而不是对象里即points.push([x, y, z])那么params.data.itemIndex是取不到值的。解决方法是构造对象类型的数据如上面示例那样把itemIndex、name、rawValue作为对象的属性。5.4 tooltip 触发太灵敏或显示 undefinedscatter3D的 tooltip 默认触发方式是item但因为是点云渲染鼠标悬停后命中的可能是任意一个点而相邻的点又属于同一个扇区。如果不加处理tooltip 内容会变成[x, y, z]这样的坐标数组完全没法看。解决方案是给每个点位对象塞入name和rawValue然后在formatter里读出来。如果觉得 hover 灵敏度太高可以在tooltip里加confine: true让提示框不超出画布边界或者用enterable: true让鼠标可以移到 tooltip 内部操作这在数据项多时比较好用。5.5 数据更新后图表不刷新Vue 里最常见的问题是数据请求回来后直接修改了data数组然后发现图表没变。原因是 ECharts 的setOption有“增量更新”的概念。如果你第二次调用setOption时只传了新数据而没有清空原来的 series新旧数据会做 merge导致点位残留甚至双重渲染。我的习惯是在数据动态更新的场景下每次调用前先执行this.chart.clear() this.chart.setOption(option)或者直接this.chart.setOption(option, true)第二个参数true表示 notMerge也就是完全覆盖旧的配置。要注意的是notMerge会重置所有状态包括视角角度。如果希望视角保持不变可以把viewControl里的状态通过getOption读出来再传回去。5.6 性能卡顿与移动端优化先看一个典型数据量外半径 10内半径 3角度步长 0.02半径步长 0.5。角度循环约 314 次半径循环约 14 次一共约 4400 个点。如果数据项有 6 个每个扇区略有增减总点位在一万上下。这是可以接受的。但如果把角度步长调到 0.01半径步长调到 0.2点位会暴涨到接近 10 万此时低端手机或者集成显卡的电脑就会开始卡。优化手段首先是降低点位密度优先调大半径步长因为半径方向的点更多是用来“填充面”的视觉上不如角度方向的点重要。其次考虑把blendMode从lighter改回默认模式弃用光照阴影性能也能提升一截。最后是建议在做移动端时谨慎使用 3D 饼图屏幕小、性能弱、触控交互和三维视角并不好协调。实际项目中如果只是要“看起来立体一点”用一个 2D 饼图加径向渐变和阴影就足够达到 7 分效果了没必要硬上 3D。6. 一个更聪明的做法先画 2D 再用 CSS/Canvas 伪 3D这里我想多说一句个人经验。3D 饼图虽然视觉冲击力强但它本质上是对数据的一种“失真表达”——饼图本身用面积角度表示占比再叠加高度维度很容易让观看者把高度误认为是数据的大小从而产生认知偏差。所以在真实项目里我一般会先问清楚需求方想要 3D 的原因是“数据表达需要”还是“视觉效果需要”。如果只是视觉效果需要有一个成本更低的方案先用 ECharts 正常画一个 2D 饼图然后在容器下方补一个简单的立体底座一个用 CSS transform 绘制的椭圆投影或者用 Canvas 对饼图边缘做厚度拉伸比如把饼图底部复制一份并向下偏移几像素填充同样的颜色再做一个渐变遮罩。这种“伪 3D”在静态截图上几乎看不出区别但渲染性能和兼容性都好得多在老旧浏览器和部分国产浏览器里也不会出问题。当然如果你确定要真 3D 的旋转交互、视角调整那 echarts-gl 的scatter3D方案仍然是 Vue 生态里最小成本的解法。只是要记住3D 图表的沉浸感是优点也是陷阱它会让你的大屏变得更加生动也会让数据解读变得更困难。使用前最好确认它真的适合你的业务场景。在代码里我习惯在组件里预留几个配置参数比如视角、半径、颜色通过 props 传入。这样后期如果产品要求调整角度或者配色不需要改逻辑代码只需改配置就能快速出效果。这是我在多个可视化项目里沉淀下来的经验——需求方几乎一定会让你反复调视角和配色把这个做成配置项你会感谢自己当初的懒惰。