移动端上做 3D 图表,一直是个让人又爱又恨的活。业务方拿着大屏上那种炫酷的 3D 柱状图说“手机上也来一套”,等你真把 WebGL 跑起来,才发现掉帧、发热、误触、模糊一堆问题排队等着。raychart 这套基于 Vue 的 3D 图表可视化实践,就是为了解决移动端优先场景下的这些问题。它不是简单调一个 three.js 的 API 完事,而是从交互设计、渲染架构、性能调优到 Vue 组件封装,做了一次系统性的升级。这篇文章会把我在这个项目里的完整思路、踩坑记录和可复用的代码片段都摊开讲,适合正在做 Vue 数据可视化、或者想把图表从 2D 搬到 3D 的前端同学参考。
1. 项目定位与设计思路
1.1 为什么“移动端优先”不是一个口号
先泼一盆冷水:大多数开源图表库的 3D 能力,是为桌面大屏设计的。大屏上鼠标精度高、GPU 性能强、网络稳定,开发者可以无脑加载几 MB 的模型、开 4 倍抗锯齿。但到了移动端,一切都变了。
手机屏幕尺寸小,意味着图表的信息密度必须重新设计。桌面端可以放 12 根柱子加 3 条折线,手机上放 6 根柱子就差不多满了。屏幕小还直接影响交互方式,鼠标悬停这个动作在触摸屏上不存在,你得设计成点击、长按、拖动。更实际的问题是硬件门槛,中低端安卓机的 GPU 和 iOS 老机型放在一起,性能差距能到好几倍,同一个 WebGL 场景,在 iPhone 上能跑 60 帧,在千元机上可能只有 20 帧。
所以 raychart 从立项开始,就把“移动端优先”定成铁律,而不是“桌面端做完再适配移动”。这意味着所有设计决策,从图表类型、交互手势到渲染分辨率,都要先问一句:在 4 英寸屏幕上、在低端安卓机上,这么做会不会崩?会不会卡?会不会看不清?这个前置思维帮我省掉了后期大量返工,也是这个项目区别于其他“能做 3D 图表”的方案最核心的一点。
1.2 技术选型:为什么是 Vue + WebGL,而不是直接上 ECharts 或 Three.js
选型这件事,很多团队会直接说“用 ECharts 吧,它有 3D 图表”。ECharts 的 GL 插件确实能画 3D 柱状图、散点图,而且配置简单。但我在实际项目里遇到了几个绕不开的问题。
第一个是包体积和首屏加载。ECharts 本体加 GL 插件,压缩后大概要 500KB 以上,移动端首屏本来就紧张,为了一个 3D 柱状图砸这么多资源,性价比太低。raychart 用自研的轻量 WebGL 渲染层,按需打包,核心渲染部分能控制在 100KB 以内。
第二个是定制自由度。ECharts 的 3D 图表本质上是把数据映射到 three.js 场景,再套一层配置项。一旦你想做特殊效果,比如自定义着色器、动态飞线、粒子背景,就得去翻它内部实现,定制成本很高。raychart 直接基于 WebGL 封装场景管理,相当于自己在渲染层之上做图表语法,这个度拿捏下来,反而比抱着一个巨型库更灵活。
第三个是 Vue 的集成深度。并不是说在 Vue 里包一层 ECharts 就算集成了,而是要让图表的生命周期、响应式更新、内存释放跟 Vue 的组件机制严丝合缝。raychart 从组件设计上就按 Vue 3 的组合式 API 来组织逻辑,图表实例不依赖全局单例,每个组件实例自己管自己的渲染循环和资源。这样在列表页复用、动态切换数据、组件销毁时,都能做到干净利落。
技术栈最终定为:Vue 3 + TypeScript + 自研 WebGL 渲染引擎。用 TypeScript 不是为了炫,而是 3D 代码里涉及向量、矩阵、颜色、事件这些复杂类型,类型系统能挡住一大批低级错误。
1.3 整体架构:三层分离,别把逻辑都塞进组件里
raychart 的架构分成三层:数据层、渲染层、交互层。这三层严格分离,是后面所有优化能顺利推进的基础。
数据层负责把业务数据转换成渲染引擎能吃的结构。比如原始数据可能是“城市A、城市B、数值1、数值2”,渲染层不认识这些,数据层要把它映射成三维坐标、柱子的宽高、颜色渐变、标签文本。同时数据层也要做降采样和聚合,特别是时间序列类数据,一条折线有十万个点,在手机屏幕上根本没必要全画,数据层提前抽稀能省掉大量 GPU 开销。
渲染层是一套基于 WebGL 的场景图系统。它负责创建几何体、管理材质、处理光照、维护相机矩阵、执行绘制调用。这一层的核心原则是“尽量少地改变状态”,渲染顺序按照材质、深度、透明度排序,减少状态切换带来的性能损耗。
交互层单独抽出来,是因为移动端的输入事件非常复杂。触摸事件和鼠标事件不同,有单指、双指、长按、滑动的组合,还有触摸坐标系到 WebGL 坐标系的转换。交互层把所有输入统一成抽象的手势事件,向上层抛出“旋转”“缩放”“点击柱子”这类业务语义,不直接跟 DOM 事件搅在一起。这三层各管各的,组件只是一个薄薄的胶水层,把 Vue 的 props/emits 翻译成三层之间的调用。
2. 从交互到视觉:核心体验的打磨
2.1 移动端手势体系:单指旋转、双指缩放、惯性滑动
移动端 3D 图表的第一体验门槛,是手势。桌面端用户习惯按住鼠标左键拖拽旋转,移动端用一根手指拖拽,逻辑类似,但有两个细节完全不同。
第一个是旋转阻尼。桌面端鼠标拖动旋转,可以做到 1:1 跟随,但手机触摸屏上的手指滑动会有天然的粘滞感,如果不加阻尼,画面会显得很“贼”。raychart 的做法是给旋转角度加了一个质量-弹簧模型:手指移动时角度有一个目标值,相机每次渲染时通过插值逼近目标值,松手后还有一小段惯性滑动。这个效果看起来简单,实际调参花了不少时间,参数太大会觉得飘,太小又显得迟钝,最后在低端机上以 60fps 为基准做了几轮测试才定下来。
第二个是双指缩放。3D 图表不能像地图那样无限缩放,缩放范围必须和场景尺寸绑定。如果用户把相机拉到柱子内部,整个画面会穿模;拉得太远,柱子又小到失去意义。所以我给相机设置了一个 min/max 距离范围,双指缩放的视场角变化用指数映射,而不是线性映射。指数映射的好处是手指开合距离小时缩放细腻,距离大时缩放迅速,更符合人手势操作的自然感受。
交互层实现的时候,我没有直接使用 touchstart/touchmove,而是通过 Pointer Events 统一处理鼠标和触摸。Pointer Events 可以同时拿到 pointerId、压力值、坐标,而且在 iOS 和安卓上兼容性都不错。手势识别器内部维护一个指针状态机,检测到两个 active pointer 就进入双指模式,一个就进入旋转模式,还有一个单独的长按定时器用于触发 tooltip。
2.2 图表类型与视觉编码:不能把 PC 端图表直接缩小
视觉设计是移动端 3D 图表特别容易翻车的地方。很多团队觉得“3D 柱状图就是给 2D 柱状图加个厚度”,然后做出来一坨又矮又胖的柱子,在手机上糊成一片。这里有一个核心原则:3D 必须服务于信息表达,而不是单纯炫技。
raychart 首批支持了三种图表类型:柱状图、折线图、散点图。柱状图适合对比数据,3D 的深度方向可以多塞一个维度,比如 X 轴是月份,Z 轴是城市,Y 轴是数值,一眼就能看出多个城市的数据差异。折线图则不太适合做太高的“厚度”,因为线条本身是细长的,加 3D 视角后容易遮挡,所以 raychart 的 3D 折线图保留了深度方向的错位排列,而不是给线加一个扁的截面。散点图最吃 3D 性能,因为点数量可能非常多,视觉上要用点的大小、颜色、深度三层编码信息。
配色也跟桌面端不一样。手机屏幕在户外强光下往往亮度不够,所以 raychart 默认色板饱和度比桌面端高,明暗对比更强烈。柱子的顶面、侧面、底面用不同亮度的同色系,而不是完全相同的颜色,这样即使没有光照也能靠面与面的色差看出立体感。坐标轴和标签的字体渲染,我没有用 TextGeometry 去生成真正的 3D 文字,那东西既吃内存又是锯齿重灾区。raychart 的做法是把坐标轴标签用 Canvas 2D 画好,再作为纹理贴到平面上,文字始终朝向相机。这样既保证了清晰度,又能用 Canvas 2D 轻松处理字体加粗、换行、单位后缀。
2.3 交互反馈:拾取、高亮、联动,以及触控命中精度
移动端图表的交互反馈比桌面端更依赖“命中精度”。鼠标悬停可以精确到像素,手指点击的面积至少有 20 到 40 像素。如果你按照鼠标的精度去检测柱子,用户点了三次可能只有一次命中,体验会非常糟糕。
raychart 的拾取机制做了两层处理。第一层是常规的射线检测,也就是从相机出发发一条射线,穿过点击点,检测和场景中哪个几何体相交。这个方案精准但计算量随物体数量上升,而且手指点偏了就检测不到。所以第二层是“扩大命中范围”,在检测到射线没有命中任何柱子时,会把射线附近一定像素半径内的候选对象都扫一遍,取最近的一个。这个半径在移动端我设成了 24 像素,实测下来误触率和漏触率都比较平衡。
选中后的高亮反馈同样要适配触控。桌面端可以是 hover 就高亮,移动端必须设计成点击后高亮,并且要有明显的状态变化,比如亮度提升、柱子顶部浮现一个数字标签,或者伴随一个 200ms 的缩放动画。动画不要太长,移动端交互最忌讳像幻灯片一样拖沓。联动场景也要考虑,比如点击柱状图的一根柱子,下方的折线图同步更新。raychart 通过统一的事件总线来管理跨组件联动,图表组件只负责抛出“select”事件,业务层去决定其他组件如何响应,避免组件之间耦合。
3. 性能优化实战:从掉帧到 60fps
3.1 性能瓶颈分析:先搞清楚卡顿发生在 CPU 还是 GPU
项目里最常见的错误,是一卡就怪 WebGL、怪手机,其实很多瓶颈根本不在 GPU。我在 raychart 的性能剖析脚本里加了一个简单的分段计时:数据更新耗时、场景更新耗时、渲染调用耗时、浏览器 layout 耗时、事件处理耗时。分清楚这些,才能对症下药。
CPU 侧的瓶颈通常出在数据转换、事件冒泡、组件渲染。Vue 的响应式系统在处理高频更新的图表数据时要特别小心,如果每一帧都去改一个响应式数组,Vue 的依赖收集和派发更新会吃掉不少时间。raychart 的数据层把“外部数据”和“渲染内部数据”做了隔离,只有通过 throttle 后的数据变更才进入渲染循环。
GPU 侧的瓶颈则主要是 DrawCall 过多、Shader 过于复杂、纹理过大。移动端 GPU 的并行能力和填充率要比桌面端弱很多,一个场景里面如果有 200 个柱子,每个柱子都是一个独立 mesh,就有 200 次 DrawCall。哪怕柱子只有几十个三角形,DrawCall 也是一笔巨大的开销。raychart 会把静态柱子合并成一个大的 BufferGeometry,一次性提交绘制,动态变动的部分再单独抽出少量 mesh 处理。
3.2 关键优化手段:合并几何体、对象池、按需渲染
性能优化是一个系统工程,我按实际收益从高到低排序分享几个手段。
第一个是合并几何体。场景中静态的柱子、坐标轴平面、背景平面,全部在初始化阶段合并到同一个几何体里。合并之后,整体提交一次 DrawCall 就能画完,渲染开销下降一个数量级。代价是单个物体的高亮就不能用“transform 变化”来做,得依靠顶点着色器里的 per-vertex 标识。我会在合并时给每个柱子的顶点附加一个 groupId 属性,高亮时修改对应 groupId 的 uniform 数组,在着色器里单独加亮。这样既保住了性能,又没牺牲交互效果。
第二个是对象池。相机对象、事件对象、射线对象、临时向量,这些在每帧都会创建的对象,如果用手写的new THREE.Vector3()方式,JavaScript 的 GC 会被频繁触发,造成帧率抖动。raychart 内部实现了一个简单的对象池,请求一个临时向量、矩阵或事件结构时,优先从池里复用,用完再归还。这个优化在移动端效果非常明显,GC 造成的卡顿减少了很多。
第三个是按需渲染。常规渲染循环是每帧都调用 draw,但图表静止时这么做纯粹是浪费。raychart 默认关闭了持续渲染,只有在数据更新、相机姿态变化、动画播放时才请求下一帧,其他时间 CPU 和 GPU 都处于空闲状态。这个策略在移动端带来的收益比桌面端大得多,因为手机的电量和散热都有限,静态图表还要以 60fps 空转,属于典型的自杀式优化。
还有一个移动端特有的优化是 DPR 限制。很多前端在做 Canvas 时习惯用window.devicePixelRatio作为缩放比,但 3D 场景在手机上开到 3 倍 DPR,意味着要渲染 9 倍像素,中低端机根本扛不住。raychart 把 DPR 上限设成 2,并且可以配置成把上限降到 1.5,视觉损失很小,但帧率提升非常明显。在深度性能调优模式下,还可以动态检测帧率,如果连续 30 帧低于 45fps,就自动降低 DPR 或关闭后处理效果。
3.3 移动端适配:发热、降频与后台恢复
移动端性能优化不能只看帧率数字,还要看发热和稳定性。中低端手机在高负载 WebGL 场景下很容易发热,严重时系统会主动降频,帧率直接掉到 20fps 以下。所以 raychart 的渲染器内置了一个“功耗感知”机制:根据每秒的平均绘制耗时和帧间隔,把渲染质量分成三档,高帧率时保持最好效果,帧率掉下去时自动减少粒子数量、降低阴影分辨率、关闭抗锯齿。
另外,移动端还要处理 App 切换到后台再回来的场景。WebGL 上下文在后台不会销毁,但很多资源可能被系统回收。raychart 在visibilitychange事件里做了两个动作:切到后台时暂停渲染并丢弃未完成的动画,回到前台时重新初始化相机矩阵、恢复渲染循环并检查 WebGL 上下文是否失效。如果不做这一步,很多用户从后台切回来会发现图表白屏或者纹理全黑。
内存泄漏问题在移动端尤其致命。图表组件如果放在列表页面里,用户滑来滑去,组件频繁销毁创建,一旦有泄漏,浏览器最终会把整个页面拖崩。raychart 的组件注册了onUnmounted钩子,会显式释放 WebGL 缓冲区、删除纹理对象、断开事件监听、取消动画帧请求。这里一个容易漏的坑是:Vue 组件的自定义事件如果在外部通过on方式监听,不会自动随组件销毁,必须在组件内部把这些监听句柄收集起来统一解绑。这个坑我踩过两次,现在专门在组件里维护了一个事件清理队列。
4. 实操与踩坑:基于 Vue 的接入与定制
4.1 组件设计:props、events、slots 怎么规划才不背锅
从使用者角度,一个图表组件好不好用,看三样东西:props 配置是否清晰、events 回调是否完整、slots 扩展性是否够用。
raychart 的主组件叫RayChart,它的 props 设计遵循一个原则:data和options分开。data是图表业务数据,比如[{ name: '城市A', value: 100 }, { name: '城市B', value: 85 }];options是图表配置项,包括图表类型、颜色、坐标系范围、是否开启自动旋转、手势开关、质量档位等。这样分的好处是,业务方只需要维护数据,视觉和交互配置单独交出去,不容易互相污染。
events 方面,最重要的几个是ready(实例初始化完成)、select(点击选中图表元素)、rotate(相机旋转状态变化)、error(渲染错误)。为了避免 Vue 组件每一次交互都往外抛事件造成性能浪费,select 事件做了去抖处理,200ms 内只抛最后一次。
slots 我保留了三个扩展口:tooltip自定义提示框内容、loading自定义加载态、empty自定义空数据状态。这三个需求在真实项目中几乎一定会出现,提前设计好,业务方就不需要 hack 组件内部结构。
4.2 从零接入:安装、注册、一个最小 Demo
raychart 目前通过 npm 包分发,依赖vue@3,没有强制要求 TypeScript环境,但类型定义文件是随包发布的。安装命令很简单:
npm install raychart然后在一个 Vue 组件里这样使用:
<template> <RayChart style="width: 100%; height: 300px;" type="bar" :data="chartData" :options="chartOptions" @select="handleSelect" /> </template> <script setup> import { ref } from 'vue' import { RayChart } from 'raychart' const chartData = ref([ { name: '一月', value: 120 }, { name: '二月', value: 200 }, { name: '三月', value: 150 }, { name: '四月', value: 280 }, ]) const chartOptions = ref({ color: ['#4e79a7', '#f28e2c'], rotateSpeed: 0.3, enableScale: true, quality: 'auto', }) function handleSelect(e) { console.log(e.name, e.value) } </script>如果你需要自动响应式更新数据,直接改chartData数组即可。组件内部会通过watch监听数据变化,但注意不要用deep: true去监听一个超大数据集,那会在每次数据更新时造成额外开销。我建议业务方使用不可变数据或手动标记版本号来触发刷新。
4.3 常见问题速查:白屏、模糊、卡顿、内存泄漏
我在 raychart 的 issue 里整理了一个速查表,把最常见的四类问题列出来,基本囊括了我接触的移动端 3D 图表场景。
| 问题现象 | 常见原因 | 解决方案 |
|---|---|---|
| iOS 或低端安卓白屏 | WebGL 不支持或上下文受限 | 初始化前检测canvas.getContext('webgl'),不支持的降级到 Canvas 2D 渲染 |
| 画面模糊,文字看不清 | DPR 设置过高或过低 | 禁止 DPR 超过 2,文字用 Canvas 2D 纹理并做像素对齐 |
| 滚动页面时图表卡顿 | 渲染循环持续运行,GPU 满载 | 开启按需渲染,离开视口时暂停渲染 |
| 页面长时间运行后掉帧 | 内存泄漏 / 纹理未释放 | 用onUnmounted释放所有 GPU 资源,并清理事件监听 |
补充几个排查技巧。白屏问题建议优先在浏览器地址栏开启 WebGL 标志,然后看 console 是否有创建上下文报错。模糊问题可以在纹理加载回调里打印纹理实际尺寸,排查是否被浏览器限制了最大纹理大小。卡顿问题用 Chrome DevTools 的 Performance 面板录一段,看绿色帧区间内 JavaScript 和 Rendering 的占比。内存泄漏相对难查,我一般用 Performance Monitor 的 JS Heap Size 曲线,如果直线上升不回落,基本就是泄漏了,再逐个注释业务逻辑定位。
5. 后续扩展与个人经验
5.1 从 WebGL 到 WebGPU:降本增效的下一步
raychart 当前的渲染层是基于 WebGL 2.0 实现的,但 WebGPU 已经在主流浏览器上开始普及,它更贴近现代 GPU 架构,批量绘制和数据密集型任务的表现会更好。我后续计划增加一个 WebGPU 后端,对外暴露的组件 API 保持不变,内部根据运行环境自动选择渲染后端。这样业务方不用改任何代码,就能在支持 WebGPU 的浏览器上获得更好性能。
还有一个方向是图表类型的扩展。目前柱状图、折线图、散点图已经足够覆盖大部分 BI 场景,但用户经常询问是否有 3D 饼图、3D 热力图、3D 飞线图。饼图和热力图实现起来并不难,但飞线图的移动端性能压力很大,需要更精细的顶点动画设计。我倾向于把更复杂的图表做成独立子包,按需加载,避免核心包体积膨胀。
5.2 踩过几次坑后,我的一点实在建议
做 raychart 这半年,最大的体会是:3D 图表可视化在移动端不是“能不能炫”的问题,而是“能不能稳定地炫”的问题。很多项目一开始被大屏效果诱惑,结果到了手机上要么卡顿、要么发热,最后只能退回 2D,浪费了大量时间。我的建议是,在启动 3D 图表之前,先拿真实的低端安卓机做一次原型验证,跑一个最简单的 3D 柱状图,看看帧率和发热是否可接受,再投入人力全面开发。
另外,Vue 3 的组合式 API 和 TypeScript 给我省了很多事,尤其是把渲染逻辑拆成可组合函数之后,单元测试变得容易多了。建议每个图表组件都把数据解析、手势计算、渲染器初始化和资源释放拆成独立的 composable,而不是塞在一个巨大的 setup 函数里。这样不仅可读性好,后续换渲染引擎或者加图表类型也不会伤筋动骨。
最后分享一个小技巧:移动端 3D 图表上线后,一定要做线上监控。raychart 内置了一个轻量性能上报,会采集帧率、DPR、设备模型、渲染耗时,以 1% 采样率上报到监控平台。没有数据支撑的话,你永远不知道真实用户的中位数帧率其实只有 30fps,而你的测试机永远跑在 60fps。
raychart 这条路还在走,但它已经证明了一件事:只要把移动端的约束当成第一优先级,而不是补丁,基于 Vue 的 3D 图表可视化完全可以做到又好看又能用。希望这篇实践总结能帮想走同一条路的同学少踩几个坑。