第64篇:初始化加载控件——转两秒就撤,它压根没问过地球加载到哪儿
上篇结尾我留了这么一句话“下一篇我们往回收一步——不再折腾模型本身而是回到页面打开那一刻控件该怎么到位”。初始化加载控件我们下一篇见。从 #59 的海量面、#60 的线、#61 的体、#62 的文字到 #63 那座搬进地球的大雁塔我们一路都在跟数据怎么画出来较劲。顶点怎么排、贴图怎么合、模型往哪挪每一篇都是一笔算得清清楚楚的账。可真正决定用户第一印象的往往不是数据画得多漂亮而是地球还没出来的那一两秒——屏幕是黑的鼠标转圈谁也不知道它是卡了还是在加载。今天往回收一步。大家好我是 Cesium 酱也可以叫我本猿一名在 WebGIS 领域摸爬滚打多年的前端开发者。写这篇前我又干了一件傻事兴冲冲给遮罩接上了tileLoadProgressEvent结果地形早就加载完了遮罩还挂在屏幕上死活不消失。那一块赖着不走的黑布就是这篇的开场白。本文目标把这个纯 CSS 的加载遮罩拆开——五个光点是怎么错峰的、2.2 秒里到底跑了几轮、它为什么盖不住控制面板以及它其实从来没问过地球加载到哪了。 本文涉及的全部代码均已开源完整的仓库地址见文末可自行取用、随意魔改。一、痛点与场景为什么加载中值得单独做成一个控件 背景现状WebGIS 的首屏天生比普通网页慢——底图要联网、地形要下载、模型要切片调度。这一两秒里如果没有任何反馈用户的第一反应不是在加载而是是不是坏了。有个粗略的经验值1 秒内没有反馈用户还能忍3 秒还没有任何动静多数人就开始怀疑页面挂了。而三维首屏冷启动恰恰经常落在 2~5 秒这个尴尬区间——所以这一层遮罩不是锦上添花是别让用户走掉的最低成本手段。典型场景里它要对付的是这些首屏底图 / 地形影像瓦片与地形瓦片要逐级下载冷启动最慢️大图层加载一份全省的矢量、一份几 MB 的 GeoJSON解析就要几百毫秒️模型与倾斜摄影3D Tiles 按视野边看边下首次进场尤其明显空间分析 / 查询服务端算一次缓冲区、跑一次插值动辄几秒弱网与内网同样的代码换个网络环境体验天差地别 传统痛点这事看着简单翻车却很稳定。方案问题什么都不做黑屏/白屏一两秒用户以为页面挂了直接关掉整页 spinner 盖住用户拿不到任何进度信息也不知道还要等多久用alert/ 同步阻塞卡死主线程地球更出不来第三方 loading 库层级与 Cesium 画布打架遮罩要么盖不住、要么盖死了交互定时器固定时长网络快了白等、网络慢了提前消失——和真实进度完全脱钩 本文方案一个纯 DOM CSS的加载遮罩三件事分头做1️⃣遮罩层position: absolute; inset: 0铺满容器半透明黑压住画布2️⃣五个错峰光点纯 CSS 动画不占主线程、不用 JS 逐帧3️⃣显示与隐藏一个布尔值 一个定时器真实项目里应换成真实信号✨ 一句话总结它跟 Cesium一点关系都没有——不碰 WebGL、不碰渲染循环就是一层浮在画布上的 div。既便宜也意味着它天生不知道地球加载到哪了。二、核心技术原理一层 div 与五个小圆点2.1 它是纯 DOM三层 z-index 摆出来的秩序整个控件没有一行 Cesium API除了建场景那几行。它靠的是三层 DOM 的叠放层类名z-index作用状态提示条.status-mask9顶部居中的文字提示“正在加载 Bing 地图…”加载遮罩.cesium-loading-mask20全屏半透明黑布 五个光点控制面板.control-panel30左上角的状态与按钮.cesium-loading-mask{position:absolute;inset:0;z-index:20;display:flex;justify-content:center;align-items:center;background:rgba(0,0,0,0.6);}inset: 0让它铺满整个.widget-shell地球画布也在里面rgba(0,0,0,0.6)是那层黑布的浓度。光点靠 flex 居中。2.2 五个光点的错峰1/8 周期的巧劲光点本身是五个 15 px 的小圆.loading span{display:inline-block;width:15px;height:100%;margin-right:5px;border-radius:50%;background:#b8e9ff;animation:load 1.04s ease infinite;}.loading span:nth-child(1){animation-delay:0.13s;}.loading span:nth-child(2){animation-delay:0.26s;}.loading span:nth-child(3){animation-delay:0.39s;}.loading span:nth-child(4){animation-delay:0.52s;}.loading span:nth-child(5){animation-delay:0.65s;}把animation-delay除以周期规律一下就出来了光点delay占周期10.13 s1/820.26 s2/830.39 s3/840.52 s4/850.65 s5/81.04 / 8 0.13——每个点的延迟正好是八分之一周期五个点等距错开。但这里有个容易被忽略的细节五个点只覆盖到5/8剩下37.5% 的周期里没有任何光点。想让五个点均匀铺满一整圈间隔应该是1/5 0.208 s而不是0.13 s。所以这串数字更像是取了个好算的整数而不是按点数均分。2.3 光点是硬跳回不是呼吸keyframesload{0%{opacity:1;transform:scale(1.3);}100%{opacity:0.2;transform:scale(0.3);}}只有0%和100%两帧animation也没加alternate。所以每个点的真实轨迹是从又大又亮一路缩到又小又暗然后瞬间跳回起点。视觉直径15 × 1.3 19.5 px→15 × 0.3 4.5 px面积比最小只有最大的5.33%不透明度1→0.2那五个跳回各自发生在delay 1.04也就是1.17 / 1.30 / 1.43 / 1.56 / 1.69 秒——五个跳变挤在 0.52 秒里依次发生。之后进入稳态整体图案每1.04 秒重复一次。 想要真正的呼吸感大→小→大没有跳变把关键帧补成0% / 50% / 100%或者给animation加alternate。2.4 尺寸账150 px 的盒子装 95 px 的内容.loading宽 150 px可内容是五个 15 px 的圆加四个 5 px 的间隙内容宽 5 × 15 4 × 5 95 px容器宽 150 px →富余 55 pxmargin: 0 auto一居中左右各空 27.5 px再看缩放带来的溢出scale(1.3)时圆点视觉直径 19.5 px比自己的盒子每侧多出2.25 px。而相邻两点之间只有 5 px 的间隙——最大缩放时相邻光点之间的净间隙只剩5 − 2 × 2.25 0.50 px。2.5 遮罩盖不住控制面板这是层级表里最值得瞪一眼的一行控制面板 z-index 30遮罩只有 20。✅ 遮罩铺满inset: 0挡住了地球画布上的鼠标交互缩放、拾取都失效⚠️ 但它挡不住左上角那个面板——面板在它上面加载过程中模拟加载 2.2 秒按钮照点不误也就是说这个遮罩没有真正锁住交互。想要彻底锁死得把面板的 z-index 压到遮罩之下或者给遮罩加pointer-events之外的拦截比如盖一层更高的透明层。2.6 它与真实加载状态是脱钩的demo 里控制显隐的是这个// 源码级剧透WidgetLoadingMaskDemo.vuefunctionsimulateLoading(duration2200):void{loading.valuetrueif(loadTimer)window.clearTimeout(loadTimer)loadTimerwindow.setTimeout((){loading.valuefalse},duration)}// 触发点onBasemapReady:(){statusMessage.value;simulateLoading()}两处值得注意 它就是一个2200 ms 的setTimeout。网络快了要白等网络慢了会提前消失⚠️触发点是onBasemapReady——也就是底图已经加载完成之后才把遮罩亮起来 2.2 秒。这是为了让你看清效果而故意倒过来的真实用法应该是异步任务开始前显示、完成后隐藏面板上的提示也老老实实写了这一点。2.7 真正的加载信号在哪Cesium 其实给了两个能直接接的事件我把源码位置核对过信号位置语义globe.tileLoadProgressEventScene/Globe.jsL564地形/影像待加载瓦片数变化时触发tileset.loadProgressScene/Cesium3DTileset.jsL600参数为(numberOfPendingRequests, numberOfTilesProcessing)所以遮罩什么时候消失这件事正确解法不是猜一个 2200 ms而是监听待加载数为 0。2.8 为什么得自己做一个Cesium 只给信号不给遮罩我把cesium/widgets里跟加载沾边的组件翻了一遍结论有点意外组件 / 接口它其实是什么能不能当加载遮罩globe.tileLoadProgressEvent加载信号待加载瓦片数❌ 只给数据不管界面tileset.loadProgress加载信号待请求数、处理中瓦片数❌ 同上Cesium3DTilesInspector3D Tiles 调试器❌ 面向开发者不是给用户看的PerformanceWatchdog帧率过低时的警告可自定义lowFrameRateMessage默认是一段英文❌ 说的是性能差不是在加载Cesium 没有内置的全屏加载遮罩它只负责告诉你还有多少没加载完至于怎么盖、盖多久、什么时候收——全交给应用层⚠️ 这也解释了为什么各家三维项目的加载动画长得都不一样这一块从来没被引擎包办过每个项目都得自己重写一遍三、代码实战关键实现拆解⚠️ 温馨提示这个控件只有 97 行但因为它是纯 DOM/CSS很多看起来是动画问题的坑其实在 CSS 里。下面四节按结构 → 动画 → 时机 → 接真实信号拆。3.1 遮罩与光点的结构divclasswidget-shelldivrefcontainerclasscesium-container/divdivv-ifloadingclasscesium-loading-maskdivclassloadingspan/spanspan/spanspan/spanspan/spanspan/span/div/divdivclasscontrol-panel…/divdivv-ifstatusMessageclassstatus-mask{{ statusMessage }}/div/divv-ifloading而不是v-show不显示时整个遮罩从 DOM 里移除不占层、不参与命中测试⚠️五个span必须写在同一行display: inline-block会渲染标签之间的空白字符。一旦换行排版每个点之间会多出约 4 px 的间隙95 px 的内容宽就对不上了3.2 错峰靠的是 animation-delay不是 JS.loading{width:150px;height:15px;margin:0 auto;}.loading span{display:inline-block;width:15px;height:100%;margin-right:5px;border-radius:50%;background:#b8e9ff;animation:load 1.04s ease infinite;}.loading span:last-child{margin-right:0;}错峰只用animation-delay五个元素共用同一份keyframes靠延迟拉开相位不写一行 JS 逐帧:last-child { margin-right: 0 }最后一个不留间隙否则内容宽会多 5 px⚠️ease是默认的慢-快-慢曲线配合硬跳回会让跳变更明显——视觉上看着像一顿一顿3.3 显示与隐藏定时器 清理functionsimulateLoading(duration2200):void{loading.valuetrueif(loadTimer)window.clearTimeout(loadTimer)// 先清旧的loadTimerwindow.setTimeout((){loading.valuefalse},duration)}onBeforeUnmount((){if(loadTimer)window.clearTimeout(loadTimer)// 卸载也要清destroyScene(viewer)})每次都要先clearTimeout连点几次按钮旧定时器不清就会把新遮罩提前关掉卸载时也要清组件销毁后定时器回调还在跑会去写一个已经不存在的响应式变量3.4 把它接到真实信号上把 2.6 那个定时器换成监听待加载数逻辑就对了// 思路示例非 demo 原文用 tileLoadProgressEvent 替代固定时长letpending0viewer.scene.globe.tileLoadProgressEvent.addEventListener((n:number){pendingn loading.valuen0// 有待加载瓦片就亮归零就收})viewer.scene.globe.tileLoadedEvent// 也可配合用于兜底判据是待加载数 0不是过了多少毫秒⚠️ 一定要配超时兜底网络异常时待加载数可能永远回不到 0遮罩会一直挂着——这正是本猿开头踩的那个坑 3D Tiles 用tileset.loadProgress参数是(numberOfPendingRequests, numberOfTilesProcessing)两个都要看3.5 想复用时把它包成一个组件demo 里loading是组件自己的内部状态。真要复用应该把它翻出来交给父级!-- 思路示例非 demo 原文 -- LoadingMask :visibleloading text正在加载底图… /loading从内部状态改成props谁负责真实加载逻辑谁决定显隐——遮罩本身不该有自己的 2.2 秒text可配置区分加载底图 / 查询中 / 计算中用户才知道自己在等什么⚠️别忘了留一个取消入口长任务只给在转、不给能停用户唯一的出路是刷新页面——那等于把前面所有体验都搭进去3.6 一个完整的说真话版本把 3.4 的思路补全给出可直接替换simulateLoading的写法// 思路示例非 demo 原文lethideTimer:number|undefinedfunctionbindRealLoading(viewer:Viewer,timeoutMs15000):void{constshow(){loading.valuetrueif(hideTimer)window.clearTimeout(hideTimer)// 兜底不管进度如何到点必收hideTimerwindow.setTimeout((){loading.valuefalse},timeoutMs)}consthide(){loading.valuefalseif(hideTimer){window.clearTimeout(hideTimer);hideTimerundefined}}show()viewer.scene.globe.tileLoadProgressEvent.addEventListener((n:number){if(n0){if(!loading.value)show()}else{hide()}})}三道保险待加载数归零就收、超时兜底也收、组件卸载时清定时器⚠️tileLoadProgressEvent在早已加载完之后不会再触发——所以遮罩的首次显示必须自己控制不能等这个事件来叫醒它 3D Tiles 场景再叠一个tileset.loadProgress两个信号一起看才准四、功能亮点与性能优势4.1 面板长什么样遮罩状态加载中…青绿/已就绪橙黄模拟加载 2.2 秒按钮手动再跑一次提示说明真实项目里应在异步任务前后显隐4.2 实测数字所有数字都来自源码里的 CSS 字面值不是估算项实测值光点直径 / 间隙15 px / 5 px内容总宽5 × 15 4 × 595 px容器宽.loading150 px富余 55 px缩放范围scale(1.3)→scale(0.3)视觉 19.5 → 4.5 px面积比 最小/最大5.33%不透明度1 → 0.2动画周期1.04 s五个延迟0.13 / 0.26 / 0.39 / 0.52 / 0.65 s 1/8 ~ 5/8 周期相位覆盖62.5%留 37.5% 空档五个跳回时刻1.17 / 1.30 / 1.43 / 1.56 / 1.69 s模拟时长 / 周期2200 ms / 1040 ms 2.115 个周期最大缩放时相邻净间隙0.50 px遮罩黑度rgba(0, 0, 0, 0.6)4.3 核心优势零渲染开销纯 CSS 动画不进 Cesium 的渲染循环也不占主线程与引擎解耦不依赖任何 Cesium API换个三维引擎照样能用改起来便宜颜色、点数、周期都只是几个 CSS 值不显示即不存在v-if让它从 DOM 移除不留隐式层级五、小贴士避坑指南⏱️关于定时器不等于加载完成⚠️ 重要提示2200 ms 是写死的与真实进度完全脱钩。网络快了白等慢了提前消失正确做法监听globe.tileLoadProgressEvent/tileset.loadProgress以待加载数归零为判据一定要配超时兜底异常情况下待加载数可能永远不为 0——遮罩会一直挂着这是我亲踩的坑关于v-if还是v-show这两个在小控件上差别很实在v-ifdemo 用的不显示时整个节点从 DOM 移除——不占层、不参与命中测试动画也随之销毁v-show只是display: none——节点还在。CSS 动画会被浏览器暂停但再次显示时往往从暂停处接着跑而不是重新开始⚠️ 所以想让每次都从动画开头闪起必须用v-if用v-show会看到从半截接着闪的别扭效果关于遮罩没锁住交互⚠️ 重要提示控制面板 z-index 30 高于遮罩的 20加载中面板仍可点击遮罩只挡住了地球画布它铺满inset: 0想彻底锁死把面板压到遮罩之下或者额外盖一层更高的拦截层关于硬跳回关键帧只有0%/100%且无alternate→ 每个点走到最小后瞬间跳回五个跳变集中在 1.17~1.69 秒之间视觉上是一串顿挫想要呼吸感补50%帧或加animation-direction: alternate关于尺寸与间隙内容 95 px 装进 150 px 的盒子富余 55 px——改点数或间距时记得同步改.loading的宽度最大缩放时相邻光点净间隙只剩0.50 px把scale(1.3)再调大一点点圆点就会互相碰上⚠️inline-block会渲染标签间的空白五个span一旦换行写每个间隙会多出约 4 px关于什么时候该用它它适合短、可预期、能给出反馈的等待首屏底图、一次查询、一次模型进场。⚠️ 重要提示超过 3~5 秒的等待光点闪烁就不够了——用户需要数字或进度条而不是还在转长时间任务请配文字进度或分段反馈反过来几十毫秒的操作不要加遮罩亮一下就灭反而像闪屏♿关于无障碍这篇最该补的一课⚠️ 重要提示这份 CSS没有media (prefers-reduced-motion: reduce)兜底。对前庭功能敏感的用户无限循环的缩放动画可能引起不适——哪怕系统里开了减少动态效果这里也不会被尊重补一行就够media(prefers-reduced-motion:reduce){.loading span{animation:none;opacity:0.7;}} 遮罩还应带上语义rolestatusaria-busytrue配合aria-livepolite读屏用户才知道这一块正在忙 用v-if把遮罩移出 DOM 之后记得把焦点还到合理位置——键盘用户不该掉进虚空里坦诚局限这个 demo 只做了固定 2.2 秒 手动触发没有接任何真实加载事件也没有进度数字、没有超时兜底、没有无障碍aria-busy/rolestatus。这些都不改变一层 div 五个圆点的主线但每一条都是上生产前要补的课。六、写在最后Cesium 是头优雅的大象。我们曾经以为加载中是这头大象自己的事今天才发现它从不为自己的慢吞吞道歉——那层遮罩、那五个光点、那 2.2 秒全是我们替它编的善意谎言。前五篇我们一直在算引擎替我们做了什么这一篇反过来引擎什么都没做全是我们自己补的。五个 15 px 的圆点、一串 0.13 秒的延迟、一个 2.2 秒的定时器——加起来不到一百行却决定了用户对这个页面的第一印象。而最该记住的是那句实话这个遮罩从来没问过地球加载到哪了。它只是闪够了 2.2 秒就自己走人。想让它说真话得去听tileLoadProgressEvent。这个案例还不完美时长写死、没接真实信号、没锁住交互、跳变没抹平。但它把一个加载遮罩的每一帧是怎么来的、又在哪儿说了谎这件事讲清楚了这就够了。下一篇我们接着往回收——这次不是遮罩而是把两块影像拼在同一屏上左右对比。地图卷帘控件我们下一篇见。如果你也做过三维首屏的加载体验欢迎留言说说是怎么收场的——是老实监听瓦片数还是干脆也给了个定时器 项目已开源欢迎 Star你的 Star ⭐ 就是对我最大的支持仓库地址https://github.com/jianlei-wang/CESIUM-TREASURE-BOX代码在src/cases/widget-loading-mask/目录下核心就一个 97 行的WidgetLoadingMaskDemo.vue复制进你的项目、把定时器换成自己的加载事件就能跑。Cesium酱の百宝箱 · 第 64 篇五个小圆点闪够了 2.2 秒便替大象道完了歉2026 年的秋夜与你共探等待这件小事如何被做得诚实