ARTICLE DETAIL

资讯详情

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

Vue 集成 Cesium 全流程避坑:环境搭建、组件封装与性能调优

Vue 集成 Cesium 全流程避坑:环境搭建、组件封装与性能调优 第一次在 Vue 项目里跑 Cesium十个人里有八个会卡在同一件事上代码没报错控制台也不红但页面上就是一片空白或者地球是出来了浏览器风扇立刻开始起飞。剩下那两个运气好的开发环境跑得挺顺一打包上服务器Assets 目录 404地球变成一个纯黑球。Cesium.js 基础使用vue这个题目听着像照着官方文档抄一遍但真做过的人都知道坑基本都集中在 Vue 这一侧——组件生命周期、响应式代理、打包资源路径这三样跟 Cesium 的配合都需要额外的处理官方文档默认你是原生 JS 环境。下面这些内容我按自己的实操顺序写先在 Vue 项目里把 Cesium 装起来、跑出第一颗地球再把它封装成一个可复用可销毁的组件然后落到打点、模型、飞行、点击交互这些真实业务场景最后是打包部署和性能调优里那些文档不会提的坑。内容面向已经会 Vue 基本用法、第一次碰三维地球的开发者也面向之前在原生 HTML 里用过 Cesium、想搬进 Vue 却发现水土不服的人。代码以 Vue 3 组合式 API Vite 为主Options API 和 webpack 的差异我会在对应位置单独标出来。1. Cesium 与 Vue 搭配前先想清楚这三件事动手装依赖之前有几个判断值得先做完。每年都有项目在要不要上三维这件事上反复横跳最后选了 Cesium结果发现业务根本用不到球体白白背上几十兆的包体和一个陡峭的学习曲线。1.1 Cesium 到底给你什么代价是什么Cesium 是一个基于 WebGL 的三维地理引擎核心能力可以拆成四块全球范围的三维球体渲染、多源影像与地形叠加、三维模型glTF加载与动画、以及时间轴驱动的动态数据回放。它和普通的 WebGL 库最大的区别在于地理坐标系原生化——你给的是经纬度和高度它负责把 WGS84 椭球、投影、地形贴合、相机视锥这一整套数学问题处理掉。代价也很明确。Cesium 的运行时体积在同类库里属于偏大的一档Build/Cesium目录通常在十几兆到二十几兆之间不同版本有差异加上 Assets、Workers、ThirdParty 这些静态资源首屏传输量不小。它的 API 风格是命令式的、实例化的一个Viewer实例背后挂着Scene、Camera、Globe、DataSourceCollection等一长串对象树跟 Vue 那种声明式的数据驱动完全是两个路子。我一般用一句话判断要不要上业务里出现高度这个维度的需求才考虑 Cesium。比如管线埋深、飞行高度、楼栋层数、雷达探测锥。如果只是在地图上标点连线、看区域分布二维地图库的开发成本和运行成本都低一个数量级。1.2 二维地图能解决的场景别硬上三维我在一个园区管理项目里见过这种情况需求是展示所有摄像头位置、点击查看实时画面。第一版用了 Cesium做出来一个球体用户拖半天才转到园区还得放大到很近才看得清点位。后来回退到二维平面地图点位一目了然加载速度快了三倍。下面这张表是我自己攒的选型判断依据可以直接对着业务需求对号入座业务特征建议方案原因只做点位、区域、路径展示二维地图库开发快、体积小、用户上手无门槛需要展示地形起伏、埋深、高度Cesium高度维度是刚需需要加载倾斜摄影、BIM、点云Cesium3D Tiles 生态成熟需要在球体上做全球范围演示Cesium二维地图不适合跨半球展示需要时间轴回放轨迹Cesium内置 Clock 与 SampledPositionProperty移动端为主、性能敏感二维地图库三维渲染在中低端机型上压不住提示如果项目里三维只是大屏演示时好看而日常业务都在二维完全可以做成两套视图切换没必要让所有用户都承担三维的加载成本。1.3 Vue 的响应式和 Cesium 的命令式渲染天生不对付这是我认为最容易出事、也最少被文档提及的一点。Vue 3 的reactive和ref会对对象做深层代理。如果你把一个 Cesium 的Viewer实例写进ref()或者reactive()里Vue 会递归遍历这个对象的所有属性给它们套上 Proxy。Viewer内部的场景对象树极其庞大包含大量循环引用、WebGL 上下文、Worker 引用、TypedArray这个遍历过程轻则造成明显的卡顿重则直接抛错或者让 Cesium 内部的状态判断失效。更隐蔽的问题在于Cesium 内部有大量基于引用相等性的判断比如这个图层对象是不是已经添加过了。经过 Proxy 包装之后proxy ! targetCesium 可能认为是个新对象于是重复添加、重复渲染表现为莫名其妙的性能下降或者视觉异常。结论很直接Cesium 的任何实例都不要放进 Vue 的深度响应式系统。要保存就用shallowRef或者用markRaw()显式标记为永远不要代理再或者干脆存在模块作用域的普通变量里。这个原则我后面还会反复提到因为它在组件封装的每个环节都会冒出来。2. 环境搭建从空目录到地球转起来环境这块看着简单但 Cesium 和普通 npm 包不太一样它除了 JS 模块还带了一堆运行时要按固定路径去 fetch 的静态资源这也是打包后 404 的根源。2.1 项目初始化与依赖选型构建工具我推荐 Vite。Cesium 的依赖树比较深Vite 的依赖预构建能省掉很多首次启动的等待时间webpack 也不是不能用只是配置量明显更大。创建一个 Vue 3 项目然后把 Cesium 装上npm create vitelatest cesium-demo -- --template vue cd cesium-demo npm install npm install cesium npm run dev安装完之后先确认一下package.json里 Cesium 的版本号。Cesium 的 API 在近几个大版本里有不少破坏性变更最典型的是createWorldTerrain()和createOsmBuildings()这两个同步方法已经被createWorldTerrainAsync()和createOsmBuildingsAsync()取代旧写法在新版本里会直接报不是函数。你如果从网上抄了一段几年前的示例代码跑不通八成是这个原因。{ dependencies: { cesium: ^1.1xx, vue: ^3.4.0 } }提示Cesium 的版本号是1.x递增的没有所谓的 2.x。看到别人写 Cesium 2 基本都是笔误。2.2 Cesium 静态资源该怎么放Cesium 在运行时会去加载四类静态文件Assets图标、字体、纹理、天空盒、Workers几何计算、瓦片解码的 Worker 脚本、ThirdParty第三方运行时脚本、WidgetsUI 样式与图片。这些文件必须以固定相对路径可访问否则会出现地球渲染出来了但控件图标全裂、或者 Worker 创建失败导致加载卡死。处理方式有两种各有取舍。方案一手动拷贝 全局变量。在vite.config.js里定义CESIUM_BASE_URL再用插件把目录拷到public下// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue import { viteStaticCopy } from vite-plugin-static-copy import path from node:path export default defineConfig({ plugins: [ vue(), viteStaticCopy({ targets: [ { src: node_modules/cesium/Build/Cesium/Workers, dest: cesium }, { src: node_modules/cesium/Build/Cesium/ThirdParty, dest: cesium }, { src: node_modules/cesium/Build/Cesium/Assets, dest: cesium }, { src: node_modules/cesium/Build/Cesium/Widgets, dest: cesium }, ], }), ], define: { CESIUM_BASE_URL: JSON.stringify(/cesium), }, })方案二用现成的 Vite 插件。社区有一个vite-plugin-cesium它把上面这些拷贝和路径注入都包好了加一行插件即可。优点是省事缺点是多一层黑盒遇到路径问题排查时要先去看插件源码。对比项手动配置现成插件配置量约 20 行1 行可控性完全可控可自定义子路径依赖插件实现排查难度路径问题一眼看穿需读插件源码内网离线部署天然支持改路径即可需确认插件是否支持自定义 base我自己的选择是正式项目用手动配置写 demo 或者做技术验证用插件。因为部署到子路径比如https://example.com/portal/时CESIUM_BASE_URL得跟着改手动配置改起来最直接。2.3 第一段能跑的代码Viewer 初始化Cesium 的入口是Viewer。它默认会带上一堆 UI 控件动画条、时间轴、图层选择器、地名搜索等等。这些在演示里挺好看在业务系统里基本都是碍事的所以我通常会一次性关掉。先写一个最小可跑的组件template div refcontainerRef classcesium-container/div /template script setup import { onMounted, onBeforeUnmount, ref, shallowRef } from vue import * as Cesium from cesium import cesium/Build/Cesium/Widgets/widgets.css const containerRef ref(null) // 关键用 shallowRef不要用 ref const viewer shallowRef(null) onMounted(async () { const instance new Cesium.Viewer(containerRef.value, { animation: false, // 左下角动画控制条 timeline: false, // 底部时间轴 baseLayerPicker: false, // 图层选择器 geocoder: false, // 右上角地名搜索 homeButton: false, // 复位按钮 sceneModePicker: false, // 2D/3D 切换 navigationHelpButton: false, // 帮助面板 fullscreenButton: false, // 全屏按钮 infoBox: false, // 点击实体弹出的默认信息框 selectionIndicator: false, // 选中框 shouldAnimate: false, }) viewer.value instance }) onBeforeUnmount(() { if (viewer.value !viewer.value.isDestroyed()) { viewer.value.destroy() } viewer.value null }) /script style scoped .cesium-container { width: 100%; height: 100%; min-height: 400px; } /style这里有三处细节值得单独说。第一容器必须有明确的高度。Cesium 的canvas尺寸取自父容器父容器高度是auto的话画布高度就是 0表现就是代码没错但什么都没显示。我踩过这个坑排查了半小时才想起来是 CSS 问题。.cesium-container用width: 100%; height: 100%的前提是它的父级链路都有确定高度否则直接给一个height: 600px更保险。第二shallowRef不能换成ref。原因就是前面说的响应式代理问题。这个差别在 demo 里看不出来点位上了几千个、渲染开始掉帧的时候才会暴露。第三destroy()之前的isDestroyed()判断不是多余的。如果组件在onMounted里因为异常提前退出viewer.value可能已经是销毁状态再调一次destroy()会抛错而这个错发生在卸载阶段会污染整条错误日志。2.4 底图与地形在线瓦片和完全离线两条路Viewer默认会去加载 Cesium ion 的全球影像和地形需要 access token。开发阶段用默认的演示 token 能跑但有配额限制正式项目一定要换成自己的。// 在创建 Viewer 之前设置 Cesium.Ion.defaultAccessToken 你的 access token国内项目更常见的是用天地图或自建的瓦片服务。以天地图影像底图为例const imageryProvider new Cesium.WebMapTileServiceImageryProvider({ url: https://t{s}.tianditu.gov.cn/img_w/wmts?servicewmtsrequestGetTileversion1.0.0LAYERimgtileMatrixSetwTileMatrix{TileMatrix}TileRow{TileRow}TileCol{TileCol}styledefaultformattilestk你的密钥, layer: img, style: default, format: tiles, tileMatrixSetID: w, subdomains: [0, 1, 2, 3, 4, 5, 6, 7], maximumLevel: 18, }) const instance new Cesium.Viewer(containerRef.value, { baseLayer: Cesium.ImageryLayer.fromProviderAsync(Promise.resolve(imageryProvider)), // ...其余开关 })注意baseLayer这个参数在较新版本里替代了老的imageryProvider写法上是传一个ImageryLayer。如果你用的是老代码里的imageryProvider新版本可能直接报不识别。这个变更在升级时会集中爆发值得留意。如果是内网离线部署思路是把瓦片目录挂到静态服务上然后用 URL 模板方式指向本地路径const offlineProvider new Cesium.UrlTemplateImageryProvider({ url: /tiles/{z}/{x}/{y}.png, minimumLevel: 0, maximumLevel: 16, tilingScheme: new Cesium.WebMercatorTilingScheme(), })地形方面在线环境直接用Cesium.createWorldTerrainAsync()离线环境需要用CesiumTerrainProvider.fromUrl(/terrain)指向本地切片目录。地形数据体积通常比影像大得多内网部署前一定要跟运维确认磁盘配额。提示如果底图加载很慢或者一直转圈先打开浏览器网络面板看瓦片请求。请求发出但全部 pending通常是并发数被浏览器限制请求直接 403多半是密钥或者 Referer 白名单的问题。3. 把 Viewer 装进 Vue 组件生命周期、响应式与内存跑通 demo 只是第一步。真正困难的是把它变成一个能在真实项目里反复挂载卸载的组件——路由切换十几次之后页面卡死、内存不释放这类问题我在三个项目里都遇到过。3.1 Viewer 实例该存哪里前面提到过不要用ref这里把可选方案列清楚存放方式是否会被代理适用场景备注ref()是不推荐大对象会导致深层代理性能风险高shallowRef()否.value赋值不代理推荐组件内使用最方便markRaw()包裹后放ref否需要在模板里引用时显式表达不要代理模块作用域普通变量否单例场景多实例会互相覆盖慎用provide/injectshallowRef否父子组件共享同一个地球推荐用于复杂组件树shallowRef的语义是只有.value整体被替换时才触发响应内部属性变化不追踪。正好符合我们的需求——我们只关心viewer 有没有创建好这一个状态变化不关心它内部场景树的变化。如果需要把 viewer 通过provide传给子组件记得给注入的键加个 Symbol 或者用InjectionKey避免和业务里其他同名字符串键冲突。3.2 挂载与销毁的完整闭环一个完整、无泄漏的销毁流程不只是viewer.destroy()一行。Cesium 里有很多需要手动释放的东西onBeforeUnmount(() { const v viewer.value if (!v || v.isDestroyed()) return // 1. 自定义的鼠标事件处理器 if (handler.value !handler.value.isDestroyed()) { handler.value.destroy() handler.value null } // 2. 自己创建的 Primitive 集合如果有独立资源先清空 v.scene.primitives.removeAll() // 3. 数据源集合用 GeoJSON 加载的图层 v.dataSources.removeAll(true) // 4. 最后销毁 viewer v.destroy() viewer.value null })这里每一步都有它的理由。ScreenSpaceEventHandler是我们自己 new 出来的对象viewer.destroy()不会自动帮你释放它它注册在 canvas 上的原生监听如果不摘掉就会持有对 DOM 的引用造成组件节点无法被垃圾回收。dataSources.removeAll(true)里的true表示同时销毁对应的数据源对象如果不传加载过的 GeoJSON 数据会留在内存里。至于primitives.removeAll()在数据量大的场景下这一步能明显降低销毁耗时。有些 Primitive 内部持有 WebGL 缓冲区直接viewer.destroy()虽然最终也会释放但释放过程会更集中、更容易触发一次长任务卡顿。注意不要在beforeDestroyVue 2里做这些操作然后指望 DOM 还在。Vue 2 的beforeDestroy阶段 DOM 还没卸载此时销毁 Cesium 会触发 canvas 尺寸变更相关的重计算Vue 3 的onBeforeUnmount相对安全但更保险的做法是在onUnmounted里做纯资源释放。3.3 用 props 和事件把地球接入 Vue 的数据流封成组件之后最自然的接口设计是props 驱动视图emit 回传交互。script setup const props defineProps({ points: { type: Array, default: () [] }, center: { type: Object, default: () ({ lon: 116.39, lat: 39.91, height: 2000000 }) }, follow: { type: Boolean, default: false }, }) const emit defineEmits([pick]) /script对points的监听要小心。如果用watch(() props.points, ..., { deep: true })数组里每个点位对象的每个字段变化都会触发回调。点位数量上千的时候一次数据更新可能触发几十次回调每次都去重建 Entity性能直接崩掉。我的做法是分两步先用一个watch只监听数组长度和引用变化做增量增删再针对确实会变的字段比如颜色、大小单独监听。或者更干脆加一个版本号 props父组件数据变了就自增版本号子组件只监听版本号在回调里做全量 diff。事件方向就简单多了鼠标点击、相机移动、实体选中都可以通过emit抛给父组件handler.value.setInputAction((movement) { const picked viewer.value.scene.pick(movement.position) if (Cesium.defined(picked) picked.id) { emit(pick, { id: picked.id.id, position: picked.id.position?.getValue(Cesium.JulianDate.now()), }) } }, Cesium.ScreenSpaceEventType.LEFT_CLICK)父组件拿到这些数据之后就可以用普通的 Vue 逻辑去弹窗、调接口、更新列表两边职责清晰。3.4 keep-alive 和路由切换时的双重初始化这是我最想单独拎出来讲的一个坑。Vue 的keep-alive会缓存组件实例被缓存的组件在第二次进入路由时不会重新执行onMounted而是执行onActivated。同样离开时执行的是onDeactivated不是onBeforeUnmount。如果你的 Cesium 组件被keep-alive包着会出现这些现象从 A 页面进入地球页面正常返回 A再进地球页面地球还是刚才那个状态因为实例被缓存了但如果父组件传的数据已经变了视图不会更新。更糟的情况组件被缓存但容器 DOM 被替换Cesium 的 canvas 尺寸失效地球显示成一条细线或者完全变形。如果路由里配置了keep-alive但又没处理好加上router-view的key变化可能出现同一个组件实例被创建两次两个 Viewer 抢同一个容器最终只有一个能正常工作。正确的处理方式是在onDeactivated里处理尺寸和渲染暂停在onActivated里恢复onActivated(() { if (viewer.value !viewer.value.isDestroyed()) { viewer.value.resize() viewer.value.resize() viewer.value.scene.requestRender() } }) onDeactivated(() { // 暂停时钟避免后台继续消耗 CPU if (viewer.value !viewer.value.isDestroyed()) { viewer.value.clock.shouldAnimate false } })如果确实不需要缓存状态最简单的方案是给router-view加一个:keyroute.fullPath强制每次进入都重新创建实例配合组件的onBeforeUnmount销毁逻辑反而更干净。多实例场景下还有个额外的细节如果一个页面里同时存在两个 Cesium 容器比如左右分屏对比一定要给每个 Viewer 传独立的容器 DOM并且用不同的requestRenderMode策略否则两个场景会共用同一个渲染循环帧率互相拖累。4. 四类高频业务场景的具体写法环境通了、组件封好了接下来就是往地球上堆业务。下面这四类需求占了实际项目里的绝大多数。4.1 打点与标注Entity 还是 Primitive这是新手第一个要做的选择。Cesium 提供了两套渲染体系Entity实体是高层 API写法简单把位置、图形、标注打包成一个对象Cesium 内部自动帮你管理渲染批次和生命周期。适合点位数量在几百到一两千的场景。viewer.value.entities.add({ id: camera-${item.id}, position: Cesium.Cartesian3.fromDegrees(item.lon, item.lat, item.height ?? 30), billboard: { image: /icons/camera.png, width: 32, height: 32, verticalOrigin: Cesium.VerticalOrigin.BOTTOM, disableDepthTestDistance: Number.POSITIVE_INFINITY, // 不被地形遮挡 }, label: { text: item.name, font: 14px sans-serif, fillColor: Cesium.Color.WHITE, pixelOffset: new Cesium.Cartesian2(0, -36), showBackground: true, backgroundColor: Cesium.Color.fromCssColorString(rgba(0,0,0,0.6)), }, })Primitive图元是底层 API需要自己构造几何描述、自己管理集合写起来啰嗦但相同数量下性能明显更好尤其是纯点、纯线这种简单图形。点云、批量线条、十万级点位基本都得走 Primitive。const pointCollection new Cesium.PointPrimitiveCollection({ blendOption: Cesium.BlendOption.TRANSLUCENT, }) items.forEach((item) { pointCollection.add({ position: Cesium.Cartesian3.fromDegrees(item.lon, item.lat, item.height ?? 0), color: Cesium.Color.fromCssColorString(#22d3ee), pixelSize: 8, outlineWidth: 0, }) }) viewer.value.scene.primitives.add(pointCollection)我的经验分界线是这样2000 个点以内用 Entity超过就考虑 Primitive。这个数字不是绝对的跟浏览器和机器性能有关但作为初始判断够用。另外Entity 的billboard加载 PNG 图标时要留意图片数量和尺寸每个不同的图片 URL 都会占一次纹理上传用一套 sprite 图集会好很多。提示disableDepthTestDistance: Number.POSITIVE_INFINITY这个设置能让标注永远显示在最前面不被山脉遮挡。演示场景很实用但地形起伏大的业务场景要慎用——会让用户完全失去高度感知。4.2 加载 glTF 模型与定位朝向三维模型在 Cesium 里的通用格式是 glTF.gltf或压缩版.glb。用 Entity 加载最省事const position Cesium.Cartesian3.fromDegrees(116.39, 39.91, 120) viewer.value.entities.add({ position, model: { uri: /models/building.glb, minimumPixelSize: 64, // 缩到最小时也不小于这个像素数 maximumScale: 200, // 相机很近时的最大放大倍数 scale: 1.0, }, orientation: Cesium.Transforms.headingPitchRollQuaternion( position, new Cesium.HeadingPitchRoll( Cesium.Math.toRadians(90), // 绕 Z 轴旋转决定模型朝向 0, 0 ) ), })heading是绕垂直于地表方向的旋转角可以理解为模型看向哪个方位。很多模型导入之后是躺着的或者是反的就是heading/pitch/roll没调对。我的习惯是先在模型里把坐标系对齐好Y 轴向上、朝向 Z 或者按 glTF 规范朝 -Z这样代码里只需要调heading。模型资源体积是另一个坑。一个没做减面的建筑模型动辄几十兆用户打开页面要等很久。上线前至少要做三件事用工具做减面保留外观特征的前提下砍掉冗余顶点、把贴图转成压缩格式、用.glb而不是分离的.gltf .bin 贴图减少请求数。4.3 相机飞行与视角复位视角控制在业务里非常高频点击列表飞到某个点位、导航回默认视角、巡检路线自动巡航。// 飞行到指定点位带俯仰角 viewer.value.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(lon, lat, 800), orientation: { heading: Cesium.Math.toRadians(0), pitch: Cesium.Math.toRadians(-45), // 负值表示俯视 roll: 0, }, duration: 2.5, // 秒 complete: () console.log(飞行结束), }) // 直接跳转不带动画适合初始化 viewer.value.camera.setView({ destination: Cesium.Cartesian3.fromDegrees(lon, lat, 2000), }) // 自动缩放到某个实体刚好占满屏幕 viewer.value.zoomTo(entity, new Cesium.HeadingPitchRange(0, Cesium.Math.toRadians(-60), 500))flyTo的duration是飞行时间不是速度所以从北京飞到纽约和飞到楼下小区花的时间一样视觉上会显得后者特别慢。我的处理是按距离动态算短距离给 1 秒跨城市的给 3 秒。还有一个经验flyTo在飞行过程中如果再次被调用前一次飞行会被取消表现为相机抖一下。如果业务上有列表快速点击切换的场景加一个 200 毫秒的防抖或者判断camera.position是否还在变化中正在变就等它结束再飞。4.4 点击拾取与鼠标交互事件Cesium 的鼠标事件不走 DOM 事件而是通过ScreenSpaceEventHandler统一管理。这样做的好处是能拿到 Cesium 坐标系下的信息比如世界坐标坏处是容易被忘记释放。const handler new Cesium.ScreenSpaceEventHandler(viewer.value.scene.canvas) // 左键单击拾取 handler.setInputAction((movement) { const picked viewer.value.scene.pick(movement.position) if (Cesium.defined(picked) picked.id instanceof Cesium.Entity) { // 拾取到了 Entity emit(pick, { id: picked.id.id }) return } // 没拾取到实体取地表经纬度 const ray viewer.value.camera.getPickRay(movement.position) const cartesian viewer.value.scene.globe.pick(ray, viewer.value.scene) if (cartesian) { const cartographic Cesium.Cartographic.fromCartesian(cartesian) emit(pick, { lon: Cesium.Math.toDegrees(cartographic.longitude), lat: Cesium.Math.toDegrees(cartographic.latitude), height: cartographic.height, }) } }, Cesium.ScreenSpaceEventType.LEFT_CLICK)这里有个容易忽略的点scene.pick()拾取到的不一定是 Entity也可能是 Cesium 内部的其他对象比如瓦片。所以判断要写严格一点picked.id instanceof Cesium.Entity比直接读picked.id.id稳妥。另外scene.globe.pick()在相机是俯视、射线和地表没有交点的时候会返回undefined比如相机朝向天空。这个分支必须处理否则后面Cesium.Cartographic.fromCartesian(undefined)会抛错。还有一个实用技巧如果业务需要鼠标悬停高亮用MOUSE_MOVE事件配合scene.pick会非常耗性能因为每一帧鼠标移动都触发一次拾取。建议加节流或者用 Cesium 的pickPosition做粗略判断只在可能命中的区域才做精确拾取。4.5 数据量上万的显示策略当点位从几百涨到几万前面那套写法就不够用了。我实际验证过的几条策略第一用 Primitive 替代 Entity。这是收益最大的一步通常能带来几倍的帧率提升。极端情况下可以用Cesium.Primitive自己拼GeometryInstance一次提交一批几何效果最好但代码最复杂。第二视图范围裁剪。只渲染当前相机可见范围内的数据。监听camera.changed或者用scene.postRender拿相机视锥计算出可视矩形范围只把范围内的数据交给 Cesium。这个逻辑写起来有点繁琐但在全球范围数据上效果立竿见影。第三分级聚合。缩得远的时候不显示单个点显示聚合后的数量气泡放大到一定级别再展开成实际点位。这样任何时刻屏幕上的图元数量都是可控的。第四开requestRenderMode。这个后面性能章节细说简单讲就是场景不变就不渲染能把闲时的 GPU 占用降到接近零。数据量级推荐方案预期帧率100 以内Entity Billboard流畅100 ~ 2000Entity注意图标复用流畅2000 ~ 20000PointPrimitiveCollection基本流畅2 万以上Primitive 批处理 视图裁剪需要实测调优10 万以上分级聚合 服务端预切需专门设计5. 打包部署与性能调优踩坑清单开发环境一切正常、打包上线就出问题是 Cesium 项目里最常见的落差。这一节把我遇到过的现象和排查路径整理出来。5.1 打包后 404 与 CESIUM_BASE_URL现象本地npm run dev一切正常npm run build之后部署到服务器地球是纯黑的控制台一堆 404路径类似/Workers/xxx.js或者/Assets/Textures/xxx.jpg。根因CESIUM_BASE_URL没有在构建时正确注入或者注入的路径和实际部署路径不一致。开发环境下 Vite 的静态服务起了作用生产环境靠的是构建产物里的资源目录。排查顺序打开浏览器网络面板看 404 的完整 URL 是什么反推出 Cesium 认为的 base 路径。检查vite.config.js里define.CESIUM_BASE_URL的值和实际部署的子路径对比。检查构建产物里dist/cesium/Assets、dist/cesium/Workers这些目录是否存在。如果是部署在子路径下比如/portal/CESIUM_BASE_URL必须写成/portal/cesium/而不是/cesium/。一个更麻烦的变体用了 Nginx 反向代理静态资源的路径被重写了。这种情况下最省事的做法是把 Cesium 的静态资源目录单独挂一个 location或者干脆把整个dist按绝对路径部署。我个人的习惯是在vite.config.js里用环境变量控制 baseexport default defineConfig(({ mode }) { const base mode production ? /portal/ : / return { base, define: { CESIUM_BASE_URL: JSON.stringify(${base}cesium/), }, // ... } })这样开发和生产共用一套逻辑改部署路径只需要改环境变量。5.2 首屏白屏和什么都还没出来的那几秒现象页面打开了容器是白的两三秒后地球突然出现。原因通常有三个一是 Cesium 主包体积大加载和解析本身需要时间二是静态资源尤其是地形和影像瓦片在首屏发起几十个并发请求被浏览器限流排队三是Viewer的初始化是同步的里面包含渲染器创建、shader 编译短则几百毫秒长则一两秒。应对手段加加载态。在viewer创建完成之前容器上盖一个 Vue 的 loading 组件用viewer.value是否为空控制显示。这是成本最低、体验提升最明显的一步。延迟初始化非必要图层。地形是首屏负担最重的一项如果用户进来看到的是全球视角地形可以先不加载等相机降到一定高度再挂上。viewer.value.scene.camera.moveEnd.addEventListener(async () { const height viewer.value.camera.positionCartographic.height if (height 50000 !terrainLoaded) { terrainLoaded true viewer.value.terrainProvider await Cesium.createWorldTerrainAsync() } })按需分包。用动态import()把 Cesium 的引入推迟到组件真正需要渲染的时候让首屏的其他内容先出来。提示Viewer初始化时先传baseLayer: false不加载任何影像等初始化完成后再添加图层可以让地球的骨架先显示出来默认是深蓝色球体用户至少知道在加载中心理等待时间会短很多。5.3 渲染性能requestRenderMode 与 resolutionScaleCesium 默认是持续渲染的也就是不管场景有没有变化每帧都在跑。对一个大屏展示系统来说这意味着 GPU 24 小时满载。打开requestRenderMode能改变这个行为const viewer new Cesium.Viewer(container, { requestRenderMode: true, maximumRenderTimeChange: Infinity, // 没有时间相关变化时不自动重绘 })开启之后只有在以下情况才会重绘相机移动、实体被添加或删除、显式调用scene.requestRender()。这就要求你在改动了场景内容之后主动请求一次渲染viewer.value.entities.add(entity) viewer.value.scene.requestRender()忘了调这一行的表现是数据加进去了但屏幕上没反应动一下鼠标才出现。这个坑非常典型我建议在封装里加一个统一的方法function refresh() { if (viewer.value?.scene.requestRenderMode) { viewer.value.scene.requestRender() } }resolutionScale是另一个调节旋钮取值0到1表示渲染分辨率相对于 canvas 尺寸的比例。默认是 1也就是按设备像素比全分辨率渲染。在 4K 屏或者移动设备上把它降到0.8甚至0.7肉眼几乎看不出差别帧率能提升不少。viewer.value.resolutionScale window.devicePixelRatio 1 ? 0.85 : 1.0还有几个常用的性能开关配置项作用建议scene.debugShowFramesPerSecond显示帧率调优时打开上线前关掉scene.fog.enabled大气雾效演示保留性能敏感可关scene.globe.showGroundAtmosphere地表大气光晕可关节省片元计算scene.skyAtmosphere.show天空大气可关scene.globe.depthTestAgainstTerrain地形遮挡测试需要真实遮挡时开启开销增加viewer.shadows阴影默认关闭开启后性能下降明显5.4 常见报错与现象对照表下面这张表是我这几年攒下来的基本上覆盖了新手会遇到的八成问题现象可能原因处理方式页面空白无报错容器高度为 0给容器设置明确高度地球纯黑控制台 404CESIUM_BASE_URL路径错误核对构建配置与部署路径createWorldTerrain is not a functionAPI 已改为异步版本换成createWorldTerrainAsync控件图标全部裂图Widgets 目录未正确加载检查静态资源拷贝配置加了数据但界面不更新开了requestRenderMode手动调用scene.requestRender()页面卡顿风扇狂转持续渲染 高分辨率开requestRenderMode调低resolutionScale路由切换几次后卡死Viewer 未销毁onBeforeUnmount里完整释放组件数据更新后地球不动响应式代理导致引用判断失效用shallowRef/markRaw模型加载不出来路径错误或格式不受支持用.glb检查网络面板请求点击拾取不到实体pick返回的不是 Entity加instanceof Cesium.Entity判断地形加载后标注被遮挡深度测试开启设置disableDepthTestDistance打包体积远超预期引入了未压缩版本确认引用的是Build/Cesium而不是Build/CesiumUnminified最后这条值得单独强调。node_modules/cesium/Build/下面同时存在Cesium和CesiumUnminified两个目录前者是压缩过的后者包含完整源码和注释体积可能是前者的三倍以上。如果你的引入路径或者拷贝配置不小心指向了后者打包体积会莫名其妙地大出一大截。排查方法很简单在构建产物里搜一下有没有源码注释格式的 Cesium 代码。关于import * as Cesium from cesium这个写法有人担心它会引入整个包导致无法 tree-shaking。实际上 Cesium 是以模块方式组织的构建工具能够按需摇掉未使用的部分但效果有限——因为Viewer内部依赖了绝大多数子模块。真正想减体积可行的方向是把影像、地形、模型加载这些能力拆成运行时按需加载而不是指望打包工具的摇树。我在实际项目里踩得最狠的一次坑是内存泄漏。那是个换班值守系统大屏一开就是一整个工作日。第一版没做销毁只做了viewer置空结果每切换一次视图就多一份 WebGL 上下文到第六七次的时候浏览器直接提示上下文丢失整个页面黑掉。后来把销毁流程补全加上dataSources.removeAll(true)和事件处理器释放连续切换几十次内存曲线都是平的。所以如果你做的也是长时间运行的场景销毁这段代码千万别省。另外一个小技巧调试阶段可以常驻一个性能面板viewer.scene.debugShowFramesPerSecond true再配合 Chrome 的 Performance 面板录制一段操作基本能定位到是渲染瓶颈还是 JS 计算瓶颈。如果帧率低但 CPU 占用不高问题通常在 GPU 侧优先看resolutionScale和阴影、雾效这些开销大的特性如果 CPU 也满那多半是在循环里频繁创建Cartesian3或者大量entities.add把创建逻辑提到循环外、改用 Primitive 集合通常能立竿见影。
返回列表