拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue2中实现ECharts 3D饼图:基于echarts-gl与pie3D的完整实战

Vue2中实现ECharts 3D饼图:基于echarts-gl与pie3D的完整实战 用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.xecharts-gl 1.x如1.1.2老项目常见稳定echarts 5.xecharts-gl 2.x如2.0.9新项目推荐安装命令分两种情况# 如果你用echarts 4 npm install echarts4.9.0 echarts-gl1.1.2 # 如果你用echarts 5 npm install echarts5.4.3 echarts-gl2.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 refchart stylewidth: 100%; height: 400px;/div /templatescript // 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.definePropertyECharts实例内部有一大堆复杂对象被代理之后要么初始化报错要么性能骤降要么运行过程中出现莫名其妙的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和realisticcolor没有光照效果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 styledisplay: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里initbeforeDestroy里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个series5个扇区就是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 existsecharts-gl没引入或版本不匹配执行import echarts-gl并核对echarts与gl版本矩阵Cannot read property gl of undefinedecharts-gl 1.x配了echarts 5降级到echarts 4.9或升级echarts-gl 2.x饼图全黑看不到颜色光照配置不对或法线方向反了把shading改为color或调整ambient扇区边缘有明显棱角曲面细分不够调小parametricEquation里u.step、v.steptooltip不显示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就只能自己啃源码了。希望这篇实操记录能帮你少走几步弯路。
返回列表