用ECharts在Vue2中实现3D饼图:从数学建模到项目落地的完整实操
前阵子接到一个可视化大屏的需求,产品经理指着参考图上的“立体饼图”跟我说:就用ECharts,画一个3D饼图。我当时第一反应是——ECharts原生饼图只有2D,哪来的3D?结果一搜,铺天盖地全是“两个半圆拼一块儿”的伪3D教程。折腾几天之后,我把echarts-gl的surface曲面方案和社区pie3D扩展方案都跑通了,在Vue2项目里也踩完了所有能踩的坑。这篇文章就把这两种能真正落地的3D饼图实现方案,连同版本兼容、性能优化、tooltip换行这些实战细节一次性讲清楚,给同样被“3D饼图”坑过的朋友一个能直接抄作业的参考。
1. 先搞清楚:ECharts本身能不能画3D饼图
1.1 为什么搜出来的教程都在“骗你”
先说结论:ECharts原生的series类型里,压根没有pie3D这一说。官方支持的饼图是pie,组件是平面的圆形扇区;ECharts的3D能力由扩展库echarts-gl提供,但echarts-gl里提供的是bar3D、scatter3D、surface、map3D这些,没有现成的“3D饼图”系列。
那网上那些教程是什么?我扒过不少,大部分是拿两个2D饼图叠在一起:一个正常显示,一个用纯色填充并偏移一段距离,视觉上造成“厚度”的错觉。这种方案渲染出来确实像个圆盘,但转不了视角、做不了真实的光影,鼠标交互也基本等于零,数据一变还得手工调偏移量。说白了,它适合“出图交差”,不适合“做产品”。
另一种教程会用bar3D加极坐标变换,把柱体弯成圆柱,但那个本质上是圆柱图,不是饼图,扇区之间的分割和标签处理都很别扭。
所以,如果你真想实现一个带厚度、能旋转、扇区边界清晰的3D饼图,基本只有两条路:一条是用echarts-gl的surface参数曲面,自己按数学方式建模扇面;另一条是引入社区插件pie3D,让three.js在ECharts底下直接渲染3D几何体。
1.2 三条实现路线,怎么选
为了避免大家走了弯路才回头,我先用一张表格把三条路线摆一起对比,后面再逐个展开。
| 方案 | 实现方式 | 3D真实感 | 依赖复杂度 | 适合场景 |
|---|---|---|---|---|
| 伪3D | 两个2D饼图叠加 | 低,只是“看起来有点厚” | 无额外依赖 | 时间极紧、只出一张静态图 |
| echarts-gl surface | 参数方程逐扇区建模 | 中高,可旋转、可光照、可交互 | 只需echarts-gl | 数据可视化大屏、生产环境可用 |
| pie3D扩展插件 | three.js渲染3D几何体 | 高,带倒角、真实光影 | 需要three.js和社区插件 | 效果要求高、能接受冷门依赖 |
我的建议是:如果是在公司项目里做数据大屏,优先走echarts-gl surface方案,它没有引入非官方插件,出问题的概率更可控;如果是个人作品或演示Demo,想快速看到惊艳的3D效果,可以试试pie3D插件。至于伪3D,说实话我后来再也没用过,因为调两个series的偏移和透明度太反直觉,而且3D感非常有限。
2. Vue2项目环境准备与依赖安装
2.1 版本对应关系是第一道坑
Vue2项目里集成ECharts,大多数人早就装好了echarts,但加echarts-gl的时候,版本对应关系处理不好,第一秒就报错。我见过无数人问“为什么注册不了surface系列”,答案几乎都是版本不匹配。
这里直接给出版本矩阵,照着装不会错:
| echarts版本 | 兼容的echarts-gl版本 | 说明 |
|---|---|---|
| echarts 4.x | echarts-gl 1.x(如1.1.2) | 老项目常见,稳定 |
| echarts 5.x | echarts-gl 2.x(如2.0.9) | 新项目推荐 |
安装命令分两种情况:
# 如果你用echarts 4 npm install echarts@4.9.0 echarts-gl@1.1.2 # 如果你用echarts 5 npm install echarts@5.4.3 echarts-gl@2.0.9为什么版本要锁死?因为echarts-gl是通过ECharts的扩展机制注册新series类型的,它内部会访问ECharts的某些API。ECharts 4到5的架构做了调整,如果用echarts-gl 1.x配echarts 5,控制台大概率会报series.surface not exists或者Cannot read property 'gl' of undefined,实际上就是注册的时候找不到对应接口。这一点在npm装包时不会强制报错,但运行时就会翻车。
提示:如果你是给老项目加功能,先看一眼package.json里echarts的版本号再决定装哪个gl版本。别图省事直接
npm i echarts-gl装最新版,很可能把项目炸了。
2.2 在Vue2组件里引入并初始化
假设你用的是vue-cli搭建的Vue2项目,组件里引入ECharts和echarts-gl的姿势如下。
<template> <div ref="chart" style="width: 100%; height: 400px;"></div> </template><script> // echarts 5的引入方式 import * as echarts from 'echarts' import 'echarts-gl' export default { name: 'Pie3DChart', data() { return { chartData: [ { name: '直接访问', value: 335 }, { name: '邮件营销', value: 310 }, { name: '联盟广告', value: 234 }, { name: '视频广告', value: 135 }, { name: '搜索引擎', value: 1548 } ] } }, mounted() { // 注意:chart实例不要放进data里,否则会被Vue做响应式代理,触发一堆奇怪问题 this.chart = echarts.init(this.$refs.chart) this.renderChart() window.addEventListener('resize', this.handleResize) }, beforeDestroy() { window.removeEventListener('resize', this.handleResize) // 组件销毁前一定要释放实例 if (this.chart) { this.chart.dispose() } }, methods: { handleResize() { this.chart && this.chart.resize() }, renderChart() { const option = this.buildOption() this.chart.setOption(option) } } } </script>如果你用的是ECharts 4,引入方式略有不同:
import echarts from 'echarts' import 'echarts-gl'核心就一点:import 'echarts-gl'必须执行一次,让扩展库完成对ECharts的注册。这一步漏了,后面写什么type: 'surface'都白搭。
另外,在Vue2里有个非常容易踩的坑:不要把chart实例放到data()里return出去。Vue2会对data对象的属性做递归响应式绑定(Object.defineProperty),ECharts实例内部有一大堆复杂对象,被代理之后要么初始化报错,要么性能骤降,要么运行过程中出现莫名其妙的TypeError。我习惯把实例挂在this上,或者用Object.freeze包一层,总之别让Vue碰它。
3. 方案一:用echarts-gl的surface曲面手写3D饼图
3.1 数学建模:一个3D饼图其实是一组扇面
echarts-gl的surface类型支持参数曲面,也就是说你可以用u、v两个参数定义曲面上每个点的x、y、z坐标。我们要做的,就是把一个3D饼图拆成若干“扇面”。
想象一下切蛋糕:一块蛋糕有三个可见面——外侧的弧形面、顶上的圆形扇面、底下的圆形扇面。3D饼图也是一样,对每一个数据扇区,我们生成三个surface:
- 外侧面:半径固定为
R,角度从startAngle扫到endAngle,高度从0到height。 - 顶面:角度区间相同,半径从0到
R,高度固定在height。 - 底面:角度区间相同,半径从0到
R,高度固定在0。
在参数方程里,u代表角度,v代表另一维度的变量(高度或半径)。如果用放射状的角度来做u,那么:
外侧面参数方程:
{ u: { min: startAngle, max: endAngle }, v: { min: 0, max: height }, x: (u, v) => R * Math.cos(u), y: (u, v) => R * Math.sin(u), z: (u, v) => v }顶面参数方程:
{ u: { min: startAngle, max: endAngle }, v: { min: 0, max: R }, x: (u, v) => v * Math.cos(u), y: (u, v) => v * Math.sin(u), z: (u, v) => height }底面参数方程就是把z改成0。
再强调一下数学思路:u控制的是一圈360度的角度,v控制的是面上另一个方向的“拉伸”。外侧面是“角度×高度”的矩形卷成的弧面,顶面是“角度×半径”的扇形平面。这套思路搞明白了,你不仅能画饼图,后面想画环形3D图、半圆3D图都能举一反三。
3.2 代码实现:从数据到series的完整转换
下面这段是我在Vue2项目里实际跑通的代码,我把核心逻辑封装成两个函数:一个负责把数据转换成角度区间,一个负责生成三个surface的series对象。
const COLORS = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272'] function buildPie3DBySurface(chartData, options = {}) { const { radius = 80, height = 20, startAngle = 0 } = options const total = chartData.reduce((sum, item) => sum + item.value, 0) // 这里的angle指的是弧度,不是角度,注意Math.cos接收的是弧度 let currentAngle = startAngle * Math.PI / 180 const series = [] chartData.forEach((item, index) => { const angleSpan = (item.value / total) * Math.PI * 2 const start = currentAngle const end = currentAngle + angleSpan currentAngle = end const color = item.color || COLORS[index % COLORS.length] series.push(createOuterSurface(start, end, radius, height, item.name, color)) series.push(createTopSurface(start, end, radius, height, item.name, color)) series.push(createBottomSurface(start, end, radius, height, item.name, color)) }) return series } function createOuterSurface(start, end, radius, height, name, color) { return { name, type: 'surface', parametric: true, silent: false, wireframe: { show: false }, shading: 'lambert', itemStyle: { color }, parametricEquation: { u: { min: start, max: end }, v: { min: 0, max: height }, x: (u, v) => radius * Math.cos(u), y: (u, v) => radius * Math.sin(u), z: (u, v) => v } } } function createTopSurface(start, end, radius, height, name, color) { return { name, type: 'surface', parametric: true, silent: false, wireframe: { show: false }, shading: 'lambert', itemStyle: { color }, parametricEquation: { u: { min: start, max: end }, v: { min: 0, max: radius }, x: (u, v) => v * Math.cos(u), y: (u, v) => v * Math.sin(u), z: (u, v) => height } } } function createBottomSurface(start, end, radius, height, name, color) { return { name, type: 'surface', parametric: true, silent: false, wireframe: { show: false }, shading: 'lambert', itemStyle: { color }, parametricEquation: { u: { min: start, max: end }, v: { min: 0, max: radius }, x: (u, v) => v * Math.cos(u), y: (u, v) => v * Math.sin(u), z: (u, v) => 0 } } }然后buildOption把series塞进ECharts配置里:
function buildOption() { return { backgroundColor: '#0f1d36', tooltip: { trigger: 'item', formatter: function (params) { // 因为一个扇区生成3个surface,这里通过seriesName去找到原始数据 const raw = this.chartData.find(item => item.name === params.seriesName) return raw ? `${raw.name}<br/>数值:${raw.value}<br/>占比:${(raw.value / total * 100).toFixed(2)}%` : params.seriesName } }, xAxis3D: { type: 'value' }, yAxis3D: { type: 'value' }, zAxis3D: { type: 'value' }, grid3D: { show: false, boxWidth: 200, boxDepth: 200, boxHeight: height, viewControl: { alpha: 25, beta: 0, distance: 220, autoRotate: false } }, series: buildPie3DBySurface(this.chartData, { radius: 80, height: 20 }) } }有几个配置点说明一下:
parametric: true:告诉surface系列,坐标来自parametricEquation,而不是data数组。wireframe.show: false:关闭曲面网格线。不关的话,所有扇面会被白色网格线覆盖,效果很丑,而且非常吃性能。shading: 'lambert':让曲面有光照明暗变化,3D感就靠这个。可选的还有'color'和'realistic',color没有光照效果,realistic更真实但更耗性能。boxWidth/boxDepth/boxHeight:设置3D场景的包围盒尺寸。包围盒相当于3D坐标系里能容纳图形的“箱子”,如果设太小,饼图会被裁切;设太大,饼图会显得很小。
3.3 视角、尺寸与交互配置
3D饼图的视觉冲击力,很大程度来自视角。grid3D.viewControl是最重要的配置项:
alpha:俯仰角,也就是相机从多高的地方往下看。我实测alpha: 25最自然,能看到顶部扇面和侧面的厚度,角度再大就接近俯视图,厚度感变弱;角度再小又像平视,顶部信息看不清。beta:水平旋转角,设为0就是正对着某个方向,也可以设成45让扇区边界斜着对向观众。distance:相机距离,数值越大图形越小。可以根据盒子的尺寸和容器大小微调。autoRotate:是否自动旋转。大屏展示时如果想让它自己慢慢转,可以设autoRotate: true, autoRotateSpeed: 5,但生产环境我建议关掉,不然用户鼠标一上去视角被拖走,体验反而乱。
交互方面,点击事件用chart.on('click', handler)就能拿到当前点击的seriesName,再通过name映射回原始数据。比如在Vue2里可以这样绑定:
this.chart.on('click', (params) => { const raw = this.chartData.find(item => item.name === params.seriesName) if (raw) { // 跳转、弹窗、联动其他图表,都从这里写 console.log('clicked:', raw) } })这里有个隐藏坑:一个扇区有三个surface,虽然name相同,但它们实际上是三个独立的series。鼠标点击顶面、侧面、底面,params.seriesType都是surface,需要自己用seriesName做聚合。你在写事件逻辑时,要记得做一个去重或者按name聚合的处理。
3.4 标签、tooltip与颜色定制的细节
ECharts的2D饼图自带label,能直接在扇区中央显示文字和百分比,但surface系列没有对应的label能力。这是个硬伤,所以我在实际项目里只用tooltip来展示数值,不在3D饼图上硬堆文字。
如果产品要求在图上直接显示占比,我的做法是在grid3D上加自定义图形,或者用graphic组件在绝对坐标上放文字。不过这需要根据3D坐标到屏幕坐标做换算,ECharts提供了convertToPixel方法,但3D场景下的换算经常不准,实现成本高。所以我个人建议:能接受就只上tooltip,别跟3D标签死磕。
tooltip换行也是一个经常被问到的点,尤其是数据项很多、名字很长的时候。ECharts的tooltip formatter里,如果在formatter函数中返回字符串,直接写\n并不会生效,要用<br/>,而且要把tooltip的confine、extraCssText之类的样式配合好。我常用的写法是:
tooltip: { trigger: 'item', formatter: function (params) { const raw = findRawData(params.seriesName) return [ `<span style="display:inline-block;margin-right:5px;border-radius:10px;width:10px;height:10px;background-color:${raw.color};"></span>`, `<b>${raw.name}</b>`, `<br/>数值:${raw.value}`, `<br/>占比:${(raw.value / total * 100).toFixed(2)}%` ].join('') } }这种方式比在字符串里拼\n更可控,而且可以塞颜色小圆点,观感接近2D饼图的默认tooltip。
颜色定制方面,用渐变色能显著提升3D质感,比如顶面用亮色、外侧面用同色系深色。surface系列的itemStyle颜色支持普通的LinearGradient对象,但注意在parametricEquation模式下,ECharts对渐变的坐标计算会有偏差,我通常就是给每个扇区一个固定色值,靠shading: 'lambert'的光影变化来产生立体感。如果你实在想用渐变,建议先在小范围demo里试清楚,别一上来就铺满整个大屏。
4. 方案二:引入pie3D插件实现高仿真3D饼图
4.1 pie3D是什么:一个基于three.js的社区扩展
如果你觉得surface方案要自己写参数方程、处理一堆细节太麻烦,或者产品对“3D感”的要求特别高,可以试试pie3D这个社区开源扩展。
简单说,pie3D是在ECharts基础上注册了一个新series类型pie3D,底层通过three.js渲染真正的3D几何体。它保留了ECharts的setOption、on事件、resize这些API,但画出来的饼是带厚度、带倒角、有真实光源的立体模型。效果比surface方案更接近产品经理心里的“3D饼图”。
代价就是多引入three和pie3D两个依赖,而且这个插件更新不活跃,社区资料少,遇到问题基本得自己看源码。我建议把它用在“Demo演示”或“一次性活动页”上,大规模生产项目还是优先surface方案。
4.2 在Vue2组件里接入pie3D
安装依赖:
npm install three echarts-pie3d然后这样引入:
import * as echarts from 'echarts' import 'echarts-pie3d'在Vue2组件里,初始化还是老一套,mounted里init,beforeDestroy里dispose。重点是option的写法:
renderChart() { const option = { backgroundColor: '#0f1d36', tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' }, series: [{ type: 'pie3D', data: this.chartData, // 饼图厚度,数值越大“饼”越高 pieHeight: 18, // 饼图半径,默认100 pieRadius: 60, // 倒角大小,控制边缘圆滑程度 bevelSize: 2, // 倒角分段数,越大越平滑 bevelSegments: 4, // 环境光,整体亮度 ambient: '#ffffff', // 主光源颜色 diffuse: '#ffffff', // 扇区颜色,可以用函数形式 sectorColor: function (params) { return params.data.itemStyle?.color || COLORS[params.dataIndex] } }] } this.chart.setOption(option) }这里要提醒一句:pie3D的具体参数名在不同版本里可能不一样。比如有的版本用height,有的用pieHeight;有的版本还支持opacity、glossiness。接入前一定要去仓库里看README,或者直接打开node_modules里的源码搜一下参数定义,不要照抄网上的旧配置。
4.3 参数与样式调优:把3D饼图调出质感
pie3D的参数我实测下来,最影响观感的是三个:
pieHeight:饼的厚度。设太薄,比如10以下,看起来就像个UFO;设太厚,比如50以上,数据占比小的扇区会被厚度遮挡,看不全。一般pieRadius在60到100之间时,pieHeight设在15到25比较协调。bevelSize:倒角大小。倒角就是饼的顶面和侧面交界处的圆滑过渡,设0是直角,2到3是轻微圆角,视觉效果更精致。倒角过大会让饼的边缘发虚,个人觉得2到4足矣。ambient和diffuse:环境光和漫反射光。大屏背景通常是深色,环境光太暗整张图会黑乎乎,建议ambient: '#ffffff';如果想让颜色更浓郁,可以稍微调低环境光强度,让diffuse主导。
另外,pie3D底层既然是three.js,那么图表的背景也是three.js的场景背景。如果你设置了ECharts的backgroundColor,实际背景色可能由three.js的场景渲染,这里不同版本处理方式不同。我的经验是:直接用CSS给容器div设置背景色最稳,ECharts的backgroundColor留空,避免两套背景叠加。
顺带说一句,如果你的大屏项目里同时用了echarts中国地图、折线图、柱状图,配色上要保持统一。3D饼图因为自带光影,颜色会比普通2D图表更深更沉,我会在选色时特意挑亮一个色阶,这样和大屏上其他平面图表共存时才不会显得灰蒙蒙。
5. 性能优化、常见问题与避坑实录
5.1 一页多个3D饼图卡到无法操作
surface方案最大的性能隐患是:一个扇区要生成3个series,5个扇区就是15个series。如果一页大屏上放4个3D饼图,就是60个surface系列同时渲染。我实测过,老一点的办公电脑会明显掉帧,鼠标拖拽旋转时能感觉到迟滞。
我的优化建议按优先级排序:
- 第一,关闭
wireframe,也就是网格线。surface系列默认会画线框,生成的三角面片数量一变多,线框开销非常可观。 - 第二,减少扇区数量。数据项超过6个时,把占比小于5%的合并成“其他”,一方面视觉更清爽,另一方面series数量直接减少。
- 第三,控制曲面细分。
parametricEquation里可以给u和v指定step(步长),步长越大,曲面越粗糙,但渲染越快。对饼图这种曲面弧度不大的场景,u.step设0.1左右就够了,肉眼几乎看不出差别。 - 第四,非必要时关闭
autoRotate。自动旋转要求每帧都重新渲染,CPU占用率会一直居高不下。
parametricEquation: { u: { min: start, max: end, step: 0.1 }, v: { min: 0, max: height, step: 0.4 }, x: (u, v) => radius * Math.cos(u), y: (u, v) => radius * Math.sin(u), z: (u, v) => v }step这个参数很多人不知道,实际上它能直接控制曲面网格的细分度。step越小网格越密,效果越精细但越卡;step越大网格越疏,性能越好但边缘会出现明显的多边形感。这个值要根据半径和数据量慢慢试,我一般从0.1开始调,卡了就调大。
5.2 WebGL不支持的浏览器怎么办
echarts-gl和pie3D都依赖WebGL。如果用户浏览器环境禁用了WebGL,或者用的是老版本IE,页面会直接白屏,控制台报各种getContext is null之类的错误。
建议在初始化图表前做一次能力检测:
function isWebGLAvailable() { try { const canvas = document.createElement('canvas') return !!(window.WebGLRenderingContext && (canvas.getContext('webgl') || canvas.getContext('experimental-webgl'))) } catch (e) { return false } }检测不通过时,降级渲染普通2D饼图,或者给用户展示一段友好的提示。这个降级逻辑在数据大屏项目里尤其重要,因为你不知道客户现场那台控制电脑是什么时候的配置。
5.3 常见报错与解决方案速查表
我把这段时间踩过的坑整理成一张速查表,方便你排错时对照:
| 报错或现象 | 大概率原因 | 解决方案 |
|---|---|---|
series.surface not exists | echarts-gl没引入,或版本不匹配 | 执行import 'echarts-gl',并核对echarts与gl版本矩阵 |
Cannot read property 'gl' of undefined | echarts-gl 1.x配了echarts 5 | 降级到echarts 4.9,或升级echarts-gl 2.x |
| 饼图全黑,看不到颜色 | 光照配置不对,或法线方向反了 | 把shading改为'color',或调整ambient |
| 扇区边缘有明显棱角 | 曲面细分不够 | 调小parametricEquation里u.step、v.step |
| tooltip不显示 | surface系列默认没有tooltip数据 | 自定义formatter,从seriesName反查数据 |
| 组件切换后图表不渲染 | 容器宽高为0,或Vue响应式代理了chart实例 | 确保容器有明确高度,chart实例挂在this上 |
pie3D属性不生效 | 插件版本不同,参数名不一致 | 打开node_modules源码确认参数名 |
5.4 大屏场景下的统一与细节:tooltip换行和主题联动
最后聊一个做数据大屏绕不开的细节。大屏项目里,一个页面上通常会有多个图表,背景也是统一的深色主题。3D饼图在这个环境里有两个细节很容易被忽略:
第一个是tooltip的换行。前面我提过,formatter返回的HTML里用<br/>换行。但真正做的时候,你会发现数据项一多、文字一长,tooltip默认样式会溢出屏幕边缘或者被截断。我的处理是加上confine: true(让tooltip不出容器边框),再用extraCssText控制宽度和最大高度:
tooltip: { trigger: 'item', confine: true, extraCssText: 'max-width: 260px; max-height: 200px; overflow-y: auto; white-space: normal;', formatter: function (params) { return [ params.seriesName, '', '数值:xxx', '占比:xx%' ].join('<br/>') } }这样即使扇区名字很长,tooltip也会在容器内换行,不会把大屏布局顶乱。
第二个是主题联动。3D饼图用了echarts-gl渲染,它的背景、光源颜色和ECharts的backgroundColor不是一套体系。如果你在大屏里用深蓝色背景,拉一组浅色系数据,3D饼图的光照颜色和大屏上其他2D图表的配色很容易打架。我现在的习惯是:3D饼图的颜色从全局主题色板里取,但亮度统一提一个维度;同时把3D场景的backgroundColor设为全透明,只让CSS的容器背景色透出来。这样无论切浅色主题还是深色主题,3D饼图都不会突兀。
最后再分享一点个人实践体会
在这两套方案之间反复横跳之后,我的工作流已经固定成:先用echarts-gl surface方案快速出一版能看的3D饼图,因为它的依赖最少、配置项可控性最高;如果产品经理还嫌不够“炫”,我才会引入pie3D插件做最终版。3D饼图真正的观感核心,其实不是代码,而是角度和比例的调试——我把alpha固定在25度左右、厚度控制在半径的四分之一,出来的视觉比例最接近日常看到的“立体蛋糕”。
如果你正在做一个vue2项目,建议先跑通surface方案,把版本矩阵和series生成逻辑吃透,再去玩pie3D。毕竟前者是ECharts官方资源,后面出问题的概率小得多;后者虽然效果好,但一遇到bug就只能自己啃源码了。希望这篇实操记录能帮你少走几步弯路。