做三维地球可视化这几年Cesium.js 一直是我绕不开的一个库。它能把卫星影像、地形、倾斜摄影、矢量数据全部塞进一个可以自由旋转缩放的地球里而且在浏览器里跑得动。但真把它接到 Vue 项目里前几次基本都会踩坑样式引不进来、静态资源 404、打包后白屏、组件卸载了内存还在涨。这篇内容就是把我自己从零搭一套 Vue Cesium 的过程完整梳理一遍讲清楚每一步为什么要这么做、参数怎么选、哪些地方最容易翻车。不管你刚接触 Vue 想找一个上手项目还是已经在做地图相关开发、需要把 Cesium 嵌进现有工程都能照着往下走。1. 为什么要在 Vue 项目里引入 Cesium.js1.1 Cesium 到底解决了什么问题先说清楚 Cesium.js 是个什么东西。它是一个基于 WebGL 的开源三维地理信息可视化库底层封装了 WebGL 的渲染管线上层提供了一整套地理坐标系统、相机模型、图层管理和实体EntityAPI。换句话说你不需要懂着色器、不需要手写矩阵变换只要调用viewer.entities.add()就能把一个点、一条线、一个模型放到地球上正确的位置。它和普通地图库最大的差异在于三维和地理精度两个维度。二维地图工具擅长展示平面路网和标注但一旦涉及地形起伏、建筑高度、空域航线、地下管线这类带垂直维度的场景二维就顶不住了。Cesium 内置了 WGS84 椭球体模型、支持地形高程数据、支持 3D Tiles 格式的倾斜摄影和点云这些能力让它在电力巡检、城市规划、应急调度、航空航天的可视化项目里几乎是标配。我在做配电工艺图这类项目时客户的诉求很直接把设备位置、线路走向、周边地形放到一个球上能转、能放大、能点开看属性。用二维地图也能做但那种站在天上往下看的临场感完全出不来。Cesium 恰好补上了这块。它的短板也很明显学习曲线陡、包体积大、API 偏底层所以怎么把它和 Vue 这种声明式框架揉到一起就成了一个需要认真设计的问题。1.2 Vue 与 Cesium 结合的三种姿势与选型逻辑把 Cesium 放进 Vue主流有三种做法各有取舍。第一种是最省事的直接用vite-plugin-cesium这类插件它帮你把 Cesium 的静态资源、worker 文件、样式全部托管好你只管import * as Cesium from cesium。第二种是手动拷贝Build/Cesium目录到 public 下然后在 HTML 里挂window.Cesium再用CESIUM_BASE_URL指定资源路径。第三种是把 Cesium 挂到 Vue 的全局属性或者封成一个自定义 hooks让每个组件都能拿到同一个 Viewer。我为什么最后选了插件方案核心原因是 Vite 的 ESM 打包机制和 Cesium 的 worker 加载方式天然冲突。Cesium 内部会去动态加载Workers/*.js和Assets/*如果不用插件或者不手动配置CESIUM_BASE_URL浏览器会按当前路由去请求这些文件结果就是满屏 404。插件做的事情本质上是帮你把CESIUM_BASE_URL指对同时把 worker 文件复制到产物目录省掉一堆手工活。而每个组件都 new 一个 Viewer是最典型的错误。Viewer 内部持有 WebGL 上下文、事件监听、定时器多个实例会迅速吃满显存而且坐标系状态互相干扰。正确的思路是全局单例一个页面一个 Viewer组件之间通过 props 或状态管理共享这个实例。这个原则后面会反复用到。至于 Vue 和 React 的区别在这一点上其实不影响结论声明式框架处理命令式的三维库统一要面对生命周期错位这个问题只是 Vue 的onMounted/onUnmounted用起来更顺手一些。2. 环境搭建与依赖安装的完整流程2.1 创建 Vue 项目与版本选择的现实考量起步先把架子搭好。我一般用 Vite 创建 Vue3 项目命令很直接npm create vitelatest cesium-demo -- --template vue cd cesium-demo npm install选 Vue3 而不是 Vue2原因不是跟风而是 Cesium 新版本已经全面转向 ESM配合 Vite 的按需构建更顺。Vue2 用 webpack 那套也能跑但配置CESIUM_BASE_URL和 worker 处理要写一堆copy-webpack-plugin维护成本明显更高。如果你手上是 Vue2 的老项目也不是不能上后面第 5 节我会单独说这种情况的处理思路。Node 版本建议 16 以上最好 18。Cesium 的安装包体积不小node_modules里会多出几百 MBnpm 在低版本 Node 上解压偶尔会报错。如果你所在环境网络不稳可以配个国内镜像源再安装npm config set registry那条命令大家都熟这里就不展开了。安装依赖这一步还有个细节cesium和vite-plugin-cesium要一起装缺一个都启动不了。npm install cesium vite-plugin-cesium --save注意cesium是运行时依赖必须放在dependencies里不要用--save-dev。很多人在打包后白屏就是因为把它装成了开发依赖构建时被 tree-shaking 掉了一部分运行时文件。2.2 vite-plugin-cesium 的配置要点装完之后改vite.config.js。这个配置看着简单但每一项都有它的用途我逐行说明import { defineConfig } from vite import vue from vitejs/plugin-vue import cesium from vite-plugin-cesium export default defineConfig({ plugins: [vue(), cesium()], server: { port: 5173, host: 0.0.0.0 } })cesium()这个插件内部做了三件事把 Cesium 的Widgets/widgets.css自动注入、把Workers、Assets、ThirdParty这几个目录复制到构建产物、设置好运行时的基础路径。所以你不需要再手动import cesium/Build/Cesium/Widgets/widgets.css重复引入反而可能导致样式冲突。host: 0.0.0.0这行是我加的习惯方便局域网内其他设备访问调试尤其是做移动端适配或者给同事演示的时候很省事。如果你的项目本身已经有 vite 配置直接把cesium()追加到 plugins 数组末尾即可注意顺序一般放在 vue 插件之后。配置完启动npm run dev如果控制台没有报模块找不到的错误说明环境通了。这一步看着平淡但它是后面所有功能的基础装错版本后面会连环出问题。我见过有人把 cesium 装成 1.60 的老版本结果 API 名字对不上调半天以为是自己代码写错。2.3 地形与影像服务的准备与取舍Cesium 默认会连它自己的在线影像和地形服务需要配置访问凭证。如果你只是本地练手、看效果用默认的就行但正式项目里我强烈建议换成自己可控的影像源比如国内的地图服务或者自建的瓦片服务原因有两个一是稳定性和加载速度二是数据合规和长期可维护。配置地形和影像的基础代码是这样const viewer new Cesium.Viewer(cesiumContainer, { terrainProvider: new Cesium.CesiumTerrainProvider({ url: 你的地形服务地址 }), imageryProvider: new Cesium.UrlTemplateImageryProvider({ url: 你的瓦片服务地址/{z}/{x}/{y}.png }), baseLayerPicker: false, geocoder: false, homeButton: false, sceneModePicker: false, navigationHelpButton: false, animation: false, timeline: false, fullscreenButton: false })这里把一堆默认控件关掉了不是为了好看而是因为这些控件会额外发起请求、占用 UI 空间实际项目里基本都要换成自己的交互。UrlTemplateImageryProvider是接入自定义瓦片最通用的方式{z}/{x}/{y}是标准的瓦片编号占位符。如果你的服务是 TMS 规范记得加tilingScheme或者-y反转。关于凭证如果你暂时用默认服务需要去 Cesium 官网申请一个 access token然后设置Cesium.Ion.defaultAccessToken 你的token。这件事一定要在创建 Viewer 之前做否则第一帧加载会报错。我踩过的坑是把这行放在组件里写结果组件还没挂载就创建了 Viewertoken 没生效地球一片黑。3. Cesium 在 Vue 组件中的核心实现3.1 Viewer 初始化与容器挂载的正确姿势真正写进 Vue 组件的时候第一件事是给 Viewer 找一个稳定的 DOM 容器并且保证它在onMounted里已经渲染完成。我习惯把 Viewer 实例挂在一个模块级的变量上而不是ref因为 Cesium 对象不是响应式的硬塞进响应式系统会让性能急剧下降Vue 会尝试代理它的每一个属性那场面很壮观帧率直接掉到个位数。template div classmap-wrapper div idcesiumContainer refcontainerRef/div /div /template script setup import { onMounted, onUnmounted, ref } from vue import * as Cesium from cesium let viewer null const containerRef ref(null) onMounted(() { viewer new Cesium.Viewer(cesiumContainer, { animation: false, timeline: false, baseLayerPicker: false }) }) onUnmounted(() { if (viewer !viewer.isDestroyed()) { viewer.destroy() } viewer null }) /script style scoped .map-wrapper { width: 100%; height: 100vh; position: relative; } #cesiumContainer { width: 100%; height: 100%; } /style这段代码有三处值得说。第一容器必须有明确的高度height: 100%依赖父级有高度所以我给外层加了100vh。Cesium 初始化时会读容器尺寸如果高度是 0画布就是 0看起来像没加载出来这是新手最常问的问题。第二onUnmounted里必须destroy()否则 WebGL 上下文不会释放来回切路由几次页面就卡死了。第三用let viewer而非ref(viewer)是刻意的原因上面说了。提示如果容器是动态显示隐藏的比如放在 tab 或弹窗里一定要在显示之后再初始化 Viewer或者初始化后调用viewer.resize()。隐藏状态下容器尺寸为 0Cesium 会算错视口。3.2 相机控制与视角定位的实操技巧视角定位是三维场景里最高频的操作。Cesium 提供了两套写法camera.setView()是直接跳转没有动画camera.flyTo()是带飞行动画。做演示或者切换关注点时我喜欢用flyTo因为视觉上有过渡用户能感知到场景在变化。// 直接定位 viewer.camera.setView({ destination: Cesium.Cartesian3.fromDegrees(116.397, 39.908, 1500), orientation: { heading: Cesium.Math.toRadians(0), pitch: Cesium.Math.toRadians(-45), roll: 0 } }) // 带飞行动画 viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(121.473, 31.230, 2000), duration: 2, orientation: { heading: Cesium.Math.toRadians(30), pitch: Cesium.Math.toRadians(-50), roll: 0 } })这里的坐标全是经纬度加高度heading是方位角旋转方向pitch是俯仰角负数表示向下看roll是翻滚角一般保持 0。新手容易在pitch上翻车写成正数结果镜头朝天上去了。记住负数向下就行。duration控制动画时长单位秒。我一般设 1.5 到 2 秒太快看不清楚太慢用户会不耐烦。如果你想精确定位到某个矩形区域可以用矩形定位viewer.camera.flyTo({ destination: Cesium.Rectangle.fromDegrees(116.0, 39.5, 116.8, 40.2) })flyTo会自动算出合适的相机高度来完整显示这个范围这在缩放到区域的功能里特别省事。不过它算的高度经常偏保守如果觉得太远可以配合offset参数或者干脆用setView手动指定高度。3.3 实体添加与样式配置的细节Cesium 里往地球上放东西主要靠entities。点、线、面、模型、标签都通过这套 API 添加。我贴一段带样式配置的例子const point viewer.entities.add({ name: 监测点A, position: Cesium.Cartesian3.fromDegrees(116.397, 39.908, 50), point: { pixelSize: 12, color: Cesium.Color.CYAN, outlineColor: Cesium.Color.WHITE, outlineWidth: 2, heightReference: Cesium.HeightReference.CLAMP_TO_GROUND }, label: { text: 监测点A, font: 14px sans-serif, fillColor: Cesium.Color.WHITE, style: Cesium.LabelStyle.FILL_AND_OUTLINE, outlineWidth: 2, outlineColor: Cesium.Color.BLACK, pixelOffset: new Cesium.Cartesian2(0, -24), heightReference: Cesium.HeightReference.CLAMP_TO_GROUND } })几个参数值得解释。pixelSize是屏幕像素单位跟缩放级别无关所以不管镜头拉多远点看起来都是这么大。heightReference设成CLAMP_TO_GROUND会让点自动贴合地形高度不用你手算海拔这个在有地形的场景里非常实用。pixelOffset控制标签相对点的偏移负 y 表示往上挪不然标签会盖在点上。线状数据我一般用polyline如果是动态数据流比如实时轨迹会配合CallbackProperty让坐标每帧刷新viewer.entities.add({ polyline: { positions: new Cesium.CallbackProperty(() { return Cesium.Cartesian3.fromDegreesArray(currentPath.flat()) }, false), width: 3, material: Cesium.Color.ORANGE, clampToGround: true } })CallbackProperty的第二个参数false表示不每帧都判断是否变化设为true会更精细但更耗性能。这个细节很多人不注意数据量大时差别很明显。需要说明的是用clampToGround: true的贴地线性能开销较高如果只是展示不要求贴地关掉它帧率会好不少。3.4 数据加载与坐标转换的关键点实际项目里的数据基本都是经纬度或者投影坐标进到 Cesium 都要转成Cartesian3。经纬度转好办fromDegrees直接支持。如果是墨卡托投影或者 CGCS2000 这类坐标就得先经过专业库转换比如用proj4转到经纬度再喂给 Cesium。有一类坑和坐标顺序有关。GeoJSON 是按[经度, 纬度]存的而有些接口返回的是[纬度, 经度]如果不确认就传进去点会跑到地球另一边出现在中国却渲染到南美洲的诡异现象。我的习惯是写一个通用的转换函数把顺序统一然后所有数据都走这个函数function toCartesians(coords) { return coords.map(([lng, lat, height 0]) Cesium.Cartesian3.fromDegrees(lng, lat, height) ) }另外大批量点数据不要一个个entities.add那样会有大量重绘。数据量超过几千个的时候应该改用Primitive或者PointPrimitiveCollection它们的渲染效率比 Entity 高一个量级。Entity 的好处是 API 友好、支持拾取和属性绑定适合几百个以内的交互对象超过这个量级就该换方案了。这个取舍我在第 5 节性能优化里还会展开。4. 实操案例做一个可交互的立体看板4.1 组件结构设计与状态划分光看 API 没意思我们把它拼成一个完整的小功能。目标是这样页面上有一个三维地球左侧是一个数据面板点击列表里的设备地球飞到对应位置并高亮反过来点击地球上的点左侧面板同步高亮。这就是一个典型的列表 - 地图双向联动场景在做配电工艺图、设备管理这类项目时几乎必然遇到。组件结构我拆成三层最外层是页面容器负责持有 Viewer 实例中间是数据列表组件接收数据和高亮状态最内层是地图交互逻辑挂在页面容器里。状态上只需要维护三样东西设备列表、当前选中 id、Viewer 实例。前两个用ref管理Viewer 用模块级变量或者一个简单的非响应式包装。这个划分的好处是地图逻辑和 UI 逻辑分开列表组件完全不关心 Cesium只通过 emit 和 props 通信。以后要换地图库或者调整交互改动面很小。很多人图省事把所有逻辑堆在一个组件里几百行之后就没法维护了。4.2 关键代码实现与联动逻辑先把数据和地图初始化串起来。下面是一段精简后的核心逻辑import { ref, onMounted } from vue import * as Cesium from cesium let viewer null const devices ref([]) const activeId ref(null) const entityMap new Map() function initViewer() { viewer new Cesium.Viewer(cesiumContainer, { animation: false, timeline: false, baseLayerPicker: false, infoBox: false, selectionIndicator: false }) viewer.screenSpaceEventHandler.setInputAction((movement) { const picked viewer.scene.pick(movement.position) if (Cesium.defined(picked) picked.id) { activeId.value picked.id.id syncHighlight() } }, Cesium.ScreenSpaceEventType.LEFT_CLICK) } function loadDevices(list) { devices.value list list.forEach(item { const entity viewer.entities.add({ id: item.id, name: item.name, position: Cesium.Cartesian3.fromDegrees(item.lng, item.lat, 30), point: { pixelSize: 10, color: Cesium.Color.GOLD, heightReference: Cesium.HeightReference.CLAMP_TO_GROUND }, label: { text: item.name, font: 13px sans-serif, pixelOffset: new Cesium.Cartesian2(0, -20), heightReference: Cesium.HeightReference.CLAMP_TO_GROUND } }) entityMap.set(item.id, entity) }) } function syncHighlight() { entityMap.forEach((entity, id) { const isActive id activeId.value entity.point.pixelSize isActive ? 18 : 10 entity.point.color isActive ? Cesium.Color.RED : Cesium.Color.GOLD }) if (activeId.value) { const target devices.value.find(d d.id activeId.value) if (target) { viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(target.lng, target.lat, 1200), duration: 1.5 }) } } }这段代码里setInputAction是地图上的点击拾取viewer.scene.pick返回被点中的对象picked.id就是实体本身。用entityMap缓存实体引用是为了在高亮时能快速找到对应实体避免每次遍历整个entities集合。这个 Map 缓存是我在数据量上来之后加的直接从几百毫秒降到几毫秒。syncHighlight同时做了两件事更新实体样式和飞向目标。这里要注意flyTo每次点击都会触发如果用户连续快速点击相机会反复起飞画面会很乱。实际项目里我会加一个节流或者判断如果目标距离很近就不飞了。4.3 双向联动与卸载清理列表点击的回调很简单就是设置activeId然后调用syncHighlightfunction handleSelect(id) { activeId.value id syncHighlight() }然后在onUnmounted里做彻底清理onUnmounted(() { if (viewer !viewer.isDestroyed()) { viewer.entities.removeAll() viewer.destroy() } entityMap.clear() viewer null })removeAll()和destroy()都要调用。只调destroy其实也会释放但显式清空实体能让内存回收更干脆。我在一个长期运行的看板项目里就是因为漏了清理切了十几次页面之后浏览器直接提示 WebGL context lost。加上清理之后连续切换五十次都很稳。提示如果你的数据是通过定时器刷新的记得把定时器也一起清掉并且刷新数据时先移除旧实体再添加新的不要一直add那会导致实体数量无限增长。5. 常见问题与排查技巧实录5.1 打包后白屏、样式丢失、资源 404 的排查顺序这是被问得最多的一类问题几乎每个第一次把 Cesium 打包上线的人都会遇到。排查我有一套固定顺序先看控制台报什么错再对症下药。现象可能原因解决方式白屏控制台报 404 请求Workers/*.js静态资源路径没配对确认插件已启用或手动设置CESIUM_BASE_URL地球出来了但没有样式控件错位widgets.css没引入检查插件是否自动注入或手动 import打包后布局异常画布大小不对容器高度用了百分比但父级无高度给容器固定高度或100vh本地正常部署到子路径后失效publicPath 与资源路径不匹配配置base并同步 Cesium 基础路径部分设备黑屏WebGL 不支持或被禁用检测 WebGL 能力给出降级提示打包后布局异常这个词你肯定在各种搜索里见过它的根源十有八九是容器尺寸问题。开发时因为热更新和浏览器缩放看起来是对的打包后各种样式合并父级高度塌陷Cesium 的 canvas 就变成 0 高度了。我的做法是给地图容器直接写死100vh或者用 flex 布局保证它有确定高度别去依赖不确定的百分比链。至于资源 404核心就是让 Cesium 知道它的Assets、Workers到底在哪个 URL 下。用插件的话它会自动处理但如果你项目有自定义的构建脚本或者部署到非根路径就可能需要手动加一句window.CESIUM_BASE_URL /你的子路径/cesium/这行要放在加载 Cesium 之前放在组件里往往已经晚了。5.2 内存泄漏与性能优化的实战经验三维场景的性能问题八成出在实体数量和无谓的重绘上。我总结了几条实测有效的做法。第一能用Primitive批量渲染的就不要用 Entity 逐个添加尤其是静态的、不需要单独交互的点线面。PointPrimitiveCollection在一万个点的情况下依然很流畅而同样数量的 Entity 会直接把帧率拖到个位数。第二关闭不需要的能力。比如不需要日照阴影就关掉viewer.scene.globe.enableLighting不需要抗锯齿就调viewer.scene.postProcessStages不需要地形就换回默认椭球。每一个开关背后都是 GPU 计算能省则省。第三控制requestRenderMode。如果你的场景不是每帧都在变可以开启按需渲染模式只有相机移动或数据变化时才重绘viewer.scene.requestRenderMode true viewer.scene.maximumRenderTimeChange Infinity这个设置能让静态场景的 GPU 占用下降一大截笔记本风扇都不叫了。但要注意开启之后如果用CallbackProperty动态数据需要手动调用viewer.scene.requestRender()触发重绘否则画面不会更新。我第一次用的时候数据不动还以为程序挂了排查半天才发现是这个。第四纹理和模型尺寸要控制。倾斜摄影和 3D 模型动辄几百 MB加载慢、显存占用高。上线前应该对模型做减面、压缩纹理用 LOD 分级。这块通常需要美术配合但效果立竿见影。5.3 Vue2 老项目接入的兼容处理手上是 Vue2 webpack 老项目的也不少这里单独说一下。核心思路是把 Cesium 的资源手动托管然后通过全局变量暴露。// vue.config.js const CopyWebpackPlugin require(copy-webpack-plugin) module.exports { configureWebpack: { plugins: [ new CopyWebpackPlugin([{ from: node_modules/cesium/Build/Cesium, to: cesium }]) ] } }然后在public/index.html里引入link relstylesheet href% BASE_URL %cesium/Widgets/widgets.css script window.CESIUM_BASE_URL % BASE_URL %cesium/ /script这样 Cesium 会从 public 目录下的cesium文件夹找资源。代价是构建产物里多了一份完整拷贝体积会增加。如果项目对体积敏感可以考虑用 CDN 托管这份资源但要注意版本一致别出现版本错配。Vue2 组件里用法和 Vue3 类似只是生命周期换成mounted和beforeDestroy。清理逻辑一样不能省。我见过有人在beforeDestroy里只清实体不销毁 Viewer结果内存慢慢涨跑一天就崩这类问题排查起来很折磨最好一开始就做对。5.4 常见问题速查表与避坑清单最后整理一份我自己常年备查的清单遇到问题先过一遍问题描述排查方向快速解法地球全黑凭证未设置或影像服务不可用检查 token 设置时机、换影像源点渲染位置偏移经纬度顺序写反统一封装转换函数点击拾取不生效事件未绑定或实体未设 id检查setInputAction和实体 id切换页面后卡顿Viewer 未销毁onUnmounted里 destroy动态数据不刷新按需渲染未触发手动调用requestRender()标签被地形遮挡未设heightReference加CLAMP_TO_GROUND移动端手势冲突默认交互未适配调整scene.screenSpaceCameraController关于移动端我补充一句默认的相机控制器在触摸屏上表现一般双指缩放和旋转容易误触。可以调整screenSpaceCameraController的一些参数比如关掉enableTilt防止误倾斜把minimumZoomDistance设大一点防止钻到地下。requestRenderMode这个模式我强烈建议在只读看板里开启但对需要实时轨迹的场景要慎用权衡点是省电和实时性之间的取舍。我自己的经验是如果数据刷新频率低于每秒一次用按需渲染高于这个频率就老老实实让它在渲染循环里跑。另外还有个小技巧调试 Cesium 的时候可以打开viewer.scene.debugShowFramesPerSecond true右上角会显示实时帧率优化前后一眼就能看出来效果。这个开关在开发环境开着上线记得关掉不然多多少少有点影响。三维可视化这个方向Cesium 的能力边界其实还在不断扩展从最初的地球展示到现在支持点云、体渲染、时间动态数据。但不管上层功能怎么加底层的这几个基本功——实例管理、资源路径、坐标转换、性能取舍——是不会变的。把这几点吃透后面不管是接倾斜摄影、做轨迹回放还是搞空域分析都是在这个地基上搭东西心里会踏实很多。