“Mapbox 地球自转”这个词的搜索量一直不低,但点进去看,绝大多数人想要的东西其实很朴素——一个缓慢旋转的地球,放在大屏首页、官网首屏或者展会互动装置上,作为视觉锚点。真正动手写的时候麻烦才浮出水面:转是能转,但抖得像在咳嗽;换成 120Hz 屏幕,转速凭空快一倍;用户手一拖,松手瞬间跳回原点;大屏上图层一多,帧率直接掉到个位数。这类需求我前后接过五六版,从数据大屏到线下展陈,踩下来的坑足够整理成一份清单。下面就把 Mapbox 地球自转这件事从头拆一遍:globe 投影怎么开、自转循环怎么写、角速度怎么换算成每帧的角度增量、什么时机该停、性能不够怎么降级、以及文档里绝不会写的那几个细节。刚接触 Mapbox GL JS 的新手能照着抄出一个能跑的版本,已经写过几版但被抖动和性能问题卡住的老手,也能从后面的排查表里找到对应病症。
1. 先拆需求:大家嘴里的“地球自转”其实有三种
1.1 同一个词背后的三种交付物
动手前先得把“自转”这个词拆干净,因为它在不同人口中指向完全不同的东西。第一种是纯展示型旋转,地球绕地轴匀速转动,不接受任何交互,用户只能看,常见于展会大屏和楼宇广告机。第二种是可交互的自动巡航,地球默认在转,用户一旦按下鼠标拖动就暂停,松手若干秒后恢复,这是官网首屏最常见的形态。第三种是叙事驱动的镜头调度,地球自转只是转场手段,转到某个经度时触发数据飞线、点位弹出、镜头推近,属于数据大屏的典型玩法。
这三种形态在技术实现上的差异并不在“转”这个动作本身,而在状态机的边界:什么时候允许转、什么事件打断它、打断之后从哪个角度续上。我见过不少项目,第一版只做了纯展示,临时加到第二种形态时才发现暂停逻辑是散的,requestAnimationFrame的手柄到处乱飞,最后只能推倒重写。所以在选型之前,先跟需求方确认清楚属于哪一档,比后面改十遍代码都省事。
判断方式很简单,问三个问题:用户能不能拖?拖完要不要自己转回去?转到特定位置要不要触发别的动作?三个问题的答案基本上就把形态定死了。
1.2 为什么“看起来在转”和“转得舒服”是两回事
技术上让地球转起来只需要一行map.setCenter(),但视觉上的舒适度完全取决于另外几个参数。转速太快会让人眩晕,太慢又像卡住了;自转方向如果和地球真实自转方向相反,地理从业者一眼就能看出来别扭;球的俯仰角(pitch)如果给大了,赤道看起来是斜的,配上旋转会像陀螺在翻跟头。
我自己的经验区间是这样的:纯展示型大屏,转速放在 8~12 度/秒,一圈 30~45 秒,远看有动感;官网首屏,4~6 度/秒,一圈 60~90 秒,不抢文字内容的注意力;带数据叙事的场景,转速要更慢甚至分段变速,因为观众需要时间读点位上的信息。俯仰角统一建议压在 0~15 度之间,超过 20 度视线就变成“俯视球体”,旋转时的透视变形会明显加剧。
还有一个容易被忽略的点:方向和真实自转的关系。地球自西向东转,站在球外看,地表是从左往右移动的,等价于观察者看到的中心经度在持续减小。所以自转循环里做的是lng -= speed * dt,而不是加。写成加号也能跑,懂行的人一看就知道方向反了。
1.3 三类场景的参数基线对照
把上面的经验整理成一张表,方便直接取用。这张表的数值是我在多个项目里反复调过的,不是凭空拍的,但具体项目还是要以人眼实际观感为准,毕竟屏幕尺寸和观看距离的差异很大。
| 场景类型 | 转速(度/秒) | 一圈耗时 | 俯仰角 | 缩放级别 | 交互策略 |
|---|---|---|---|---|---|
| 官网首屏展示 | 4~6 | 60~90 秒 | 0~10 | 1.6~2.0 | 悬停暂停,移开恢复 |
| 数据大屏 | 8~12 | 30~45 秒 | 5~15 | 1.8~2.4 | 常驻旋转,无交互 |
| 展会互动装置 | 3~5 | 72~120 秒 | 8~15 | 1.5~2.2 | 拖拽暂停,松手 3 秒后恢复 |
缩放级别这一列特别要说明:globe 投影下如果把 zoom 压到 1.0 以下,地球在视口中会变得很小,四周留白太多,旋转的视觉重心会飘;而 zoom 超过 2.5,地球边缘会顶出容器,转起来像在平移一张卷轴,也失去球体的味道。1.6 到 2.4 是球体形态最完整、边缘刚好不顶框的区间。
2. 方案选型:三条路线,各自的代价
2.1 路线一:globe 投影 + 中心经度偏移
这是当前最省事也最正的路子。Mapbox GL JS 从 2.9 版本开始提供 globe 投影,地图从一个平面被弯成球体,同时保留原有的图层体系、数据源体系和交互体系。自转的实现就是每一帧把地图中心的经度减去一个增量,视觉上等价于相机绕球心转动。
它的优势非常明显:矢量瓦片、GeoJSON 图层、标注、飞线、热力图全都能直接用,不需要重写渲染逻辑;球体边缘的透视、大气层、星空背景都是引擎内置的;性能开销集中在瓦片重绘上,可控。劣势是版本依赖,2.9 之前没有这个能力,而且不同大版本之间投影的 API 参数名有差异,一开始就得把版本锁死。
如果你手上是 3.x 的版本,直接map.setProjection({ type: 'globe' });2.9 到 2.15 之间参数结构不一样,得写兼容分支。这一点后面给代码。
2.2 路线二:贴图伪 3D
早期的做法是把一张世界地图贴到一个 CSSborder-radius: 50%的圆形容器上,用background-position做水平位移,配合渐变阴影假装球体。成本极低,连 WebGL 都不需要,但它有几个无法回避的硬伤:没有透视,球体边缘的经线不会收拢,看起来就是一张会动的圆图;标注无法跟随,任何文字贴上去都会跟着一起平移,转到背面还不会消失;光照不真实,没有一个随视角变化的高光。
唯一适合它的场景是极低预算的静态宣传页,或者作为 WebGL 初始化完成前的占位动画。放到有数据图层的大屏上,这条路基本走不通。
2.3 路线三:Three.js 自建球体
用 Three.js 建一个经纬度球,把地图瓦片拼成纹理贴上去,相机绕球心旋转。这条路的天花板最高——可以做光晕、可以做粒子大气、可以做自定义着色器,视觉上限远超地图引擎自带效果。代价也很直接:你要自己实现一套地图引擎。瓦片金字塔的拼接、缩放级别的切换、经纬度到三维坐标的投影、标注的朝向计算、鼠标拾取对应的经纬度反算,每一项都是工作量。
我的判断标准是:如果项目里只需要“一个会转的球”,不需要任何数据图层和交互查询,Three.js 可以做得很漂亮;如果球面上要挂点位、飞线、区域高亮,哪怕只有一点点,也老老实实走第一条路线。混合方案的维护成本会在第二次需求变更时集中爆发。
| 维度 | globe 投影 | 贴图伪 3D | Three.js 自建 |
|---|---|---|---|
| 开发成本 | 低 | 极低 | 高 |
| 球体真实感 | 高 | 低 | 取决于投入 |
| 数据图层支持 | 原生 | 不支持 | 需自行实现 |
| 性能开销 | 中 | 极低 | 可高可低 |
| 版本依赖 | 强 | 无 | 无 |
| 推荐场景 | 数据类、官网类 | 纯静态宣传 | 纯视觉展示 |
3. 原理层:自转到底在改什么
3.1 globe 投影在渲染管线里做了什么
理解这一点能省掉后面一半的调试时间。平面地图的渲染,本质是把墨卡托平面铺满视口;globe 投影则是先把这张平面卷成一个球,然后让相机站在球外某个距离处看向球心。视线和球面相交的那个点,就是当前地图的 center;相机到球心的距离,对应 zoom 级别;相机的方位角,对应 bearing;相机的俯仰,对应 pitch。
这个模型解释了很多现象:为什么低 zoom 下地球会变小——相机退得远;为什么纬度高的地方会拉伸——墨卡托本身的特性还在;为什么不建议用getBounds()——球面投影下的边界已经不是一个矩形了,返回的值只有参考意义。
也正因为相机是围绕球心在做位置变化,改变 center 的经度,本质上是让相机绕地轴转了一个角度,视觉上就是地球自转。这个等价关系很关键,它意味着你不需要碰任何底层相机 API,一个setCenter就够了。
3.2 为什么不是改 bearing
新手最常犯的错误是用setBearing做自转,因为名字听起来更像“旋转”。实测下来完全是另一回事:bearing 改变的是相机绕视线方向的滚转,视觉上地球会像一个盘子一样在原地打转,南北极会歪到侧面去,配上标注会看到文字全部倾斜,非常难受。
这一点用一个生活化的比喻更好理解:把地球想象成你手里捧着的一个足球,自转是球自己在转;bearing 是你歪着头看它。前者球面上的城市会依次从正面转到背面,后者所有城市还在原来的位置,只是整个画面倾斜了。
所以自转只有一个正确姿势:固定纬度和俯仰,持续减小中心经度。俯仰角和 bearing 保持初始化时的值不动,让它们参与自转只会带来额外的视觉噪音。
3.3 角速度换算:从“度每秒”到“每帧角度”
这是全网文章里最容易被跳过、也是实际项目里最容易翻车的一环。绝大多数自转代码长这样:
function spin() { const c = map.getCenter(); map.setCenter([c.lng - 0.1, c.lat]); requestAnimationFrame(spin); }这段代码在 60Hz 屏幕上跑得挺好,一圈 60 秒。换到 120Hz 的笔记本或者高刷手机上,同样的 0.1 度每帧,每秒的帧数翻倍,角速度就直接变成 12 度每秒,一圈只要 30 秒——肉眼可见地快了一截。反过来,在性能吃紧的设备上帧率掉到 30,转速就慢了一半。
正确的做法是按真实时间增量计算角度,而不是按帧。推导过程很简单:
- 目标角速度记作 ω,单位度每秒,比如 6
- 相邻两帧的时间差记作 Δt,单位秒,从
requestAnimationFrame回调的时间戳里拿 - 这一帧需要转过的角度 = ω × Δt
以 60Hz 为例,Δt ≈ 0.0167 秒,每帧角度 = 6 × 0.0167 ≈ 0.1 度,和上面那版刚好对上。而在 120Hz 下 Δt ≈ 0.0083,每帧角度变成 0.05 度,每秒累计仍然是 6 度。这样无论设备刷新率多少,观感完全一致。
再补一个容易被忽略的上限保护:如果页面被切到后台再切回来,requestAnimationFrame的时间戳会出现一个巨大的跳变,Δt 可能是好几秒,一帧转出去几十度,用户看到的就是“地球猛地甩了一下”。所以必须给 Δt 设一个上限,比如 0.1 秒,超出的部分直接截断。
顺便说个有意思的对比:真实地球的自转角速度是 360 度除以 24 小时,约等于 0.00417 度每秒。也就是说,我们平时看到的“地球自转”动画,比真实速度快了一千多倍。这不是错误,而是必要的艺术夸张——按真实速度转,一分钟才转 0.25 度,屏幕上根本看不出在动。知道这个数字,在跟需求方解释“为什么不能更慢”的时候很好用。
| 目标一圈耗时 | 角速度(度/秒) | 60Hz 每帧角度 | 120Hz 每帧角度 |
|---|---|---|---|
| 30 秒 | 12 | 0.2 度 | 0.1 度 |
| 60 秒 | 6 | 0.1 度 | 0.05 度 |
| 90 秒 | 4 | 0.067 度 | 0.033 度 |
| 120 秒 | 3 | 0.05 度 | 0.025 度 |
4. 落地实现:一份可以直接跑的代码
4.1 环境准备与版本确认
先把依赖装好。项目里用mapbox-gl就行,别去装那些二次封装的组件库,自转这种需要精确控制帧循环的场景,封装层往往会把requestAnimationFrame藏起来,反而更难改。
npm install mapbox-gl装完先确认版本,这条命令的输出决定了后面用哪套投影 API:
npm ls mapbox-gl如果是 3.x,投影写法是对象带type字段;如果是 2.9 到 2.15 的区间,参数名不一样。为了避免上线后因为依赖升级炸掉,我一般会在初始化处包一层兼容函数,用try兜底,而不是靠人肉记版本。这个做法看起来有点丑,但比重训一遍整个投影初始化流程划算得多。
另外,样式资源需要访问令牌。令牌走环境变量注入,别硬编码进仓库,这是老生常谈但每年还是能看到泄露的案例。
import mapboxgl from 'mapbox-gl'; import 'mapbox-gl/dist/mapbox-gl.css'; mapboxgl.accessToken = import.meta.env.VITE_MAP_TOKEN;4.2 初始化:球体、视角与大气层
初始化的参数里,除了常规的容器、样式、中心点,关键在三处:投影、俯仰、以及样式加载完成后的大气层设置。
const map = new mapboxgl.Map({ container: 'globe', style: 'mapbox://styles/mapbox/satellite-streets-v12', center: [105, 25], zoom: 1.9, pitch: 8, bearing: 0, antialias: false, attributionControl: false, renderWorldCopies: false, projection: 'globe' }); function enableGlobe(instance) { try { instance.setProjection({ type: 'globe' }); } catch (err) { try { instance.setProjection('globe'); } catch (err2) { console.warn('当前版本不支持 globe 投影,请升级依赖'); } } } map.on('load', () => { enableGlobe(map); map.setFog({ color: 'rgb(186, 210, 235)', 'high-color': 'rgb(36, 92, 223)', 'horizon-blend': 0.02, 'space-color': 'rgb(11, 11, 25)', 'star-intensity': 0.6 }); });antialias: false是刻意关掉的,globe 模式下边缘本身有雾化过渡,抗锯齿带来的收益不明显,但对填充率的开销是实打实的,尤其是在大尺寸容器上。
setFog这几个参数值得单独说:space-color决定球体外侧的深空底色,star-intensity控制星点密度,horizon-blend控制大气层和地表的过渡宽度。把horizon-blend调到 0.01 到 0.03 之间,球体边缘会有一圈很薄的光晕,接近真实的大气散射;调到 0.1 以上就变成毛茸茸的一团,像糊了。星点强度别超过 0.7,太多会显得廉价。
还有个小提醒:setFog要在样式加载完之后调用,如果在load之前调,某些版本里会被样式的默认配置覆盖回去。
4.3 自转循环:把状态收进一个类里
我不建议把自转逻辑写成散落在外的几个函数,因为一旦涉及到暂停、恢复、限帧、销毁,散装状态很容易漏掉某个分支。封装成一个小类,把raf句柄、上一次时间戳、累积量都收进去,后面加功能就不容易出问题。
class GlobeSpinner { constructor(map, options = {}) { this.map = map; this.speed = options.speed ?? 6; // 度/秒 this.maxFps = options.maxFps ?? 45; // 上限帧率,省电 this.maxDelta = options.maxDelta ?? 0.1; // 单帧最大时间增量 this.raf = null; this.lastTs = 0; this.acc = 0; this.running = false; this.tick = this.tick.bind(this); } start() { if (this.running) return; this.running = true; this.lastTs = 0; this.acc = 0; this.raf = requestAnimationFrame(this.tick); } stop() { this.running = false; if (this.raf) cancelAnimationFrame(this.raf); this.raf = null; } destroy() { this.stop(); this.map = null; } tick(ts) { if (!this.running || !this.map) return; if (!this.lastTs) { this.lastTs = ts; this.raf = requestAnimationFrame(this.tick); return; } let delta = (ts - this.lastTs) / 1000; this.lastTs = ts; // 后台切换导致的巨大跳变,直接截断 if (delta > this.maxDelta) delta = this.maxDelta; // 累积时间,达到目标帧间隔才执行一次更新 this.acc += delta; const interval = 1 / this.maxFps; if (this.acc < interval) { this.raf = requestAnimationFrame(this.tick); return; } const step = this.acc; this.acc = 0; const center = this.map.getCenter(); let lng = center.lng - this.speed * step; // 减号 = 自西向东 lng = ((lng + 180) % 360 + 360) % 360 - 180; this.map.setCenter([lng, center.lat]); this.raf = requestAnimationFrame(this.tick); } }这段代码里有三个设计点需要解释。
第一,限帧不是降速。用累积器acc把时间攒起来,攒够一个间隔才更新一次,每次更新用的是攒下来的总时间。这样即便更新频率从 60 降到 30,每秒转过的角度依然是 6 度,只是位置更新变粗了。如果直接按帧数限流而丢掉时间,转速就会跟着帧率一起掉,那是两码事。
第二,经度取模用的是双重取模。((lng + 180) % 360 + 360) % 360 - 180这套写法是为了处理负数。JS 的%对负数返回负值,直接用lng % 180会在经度越过 -180 之后直接把球甩回去。虽然多数版本的getCenter内部会做归一化,但依赖引擎的内部行为不是好习惯。
第三,纬度取自当前地图而非写死。如果写死一个纬度,用户拖动改变纬度后,下一帧就被强行拉回去,手感像在跟弹簧较劲。取当前值意味着用户可以自由调整观察角度,自转只接管经度。
4.4 交互接管:什么时候停,停了怎么续
暂停逻辑的触发源比想象中多,我列一下实际项目里必须覆盖的几种:
const spinner = new GlobeSpinner(map, { speed: 6 }); map.on('load', () => { spinner.start(); }); // 拖拽与缩放 map.on('dragstart', () => spinner.stop()); map.on('dragend', () => spinner.start()); map.on('zoomstart', () => spinner.stop()); map.on('zoomend', () => spinner.start()); // 悬停暂停(桌面端) const canvas = map.getCanvas(); canvas.addEventListener('mouseenter', () => spinner.stop()); canvas.addEventListener('mouseleave', () => spinner.start()); // 页面不可见 document.addEventListener('visibilitychange', () => { document.hidden ? spinner.stop() : spinner.start(); }); // 组件卸载 function dispose() { spinner.destroy(); map.remove(); }这里有几个坑要提前说。
第一个坑:不要用movestart和movestart来暂停。因为自转本身调用的setCenter也会触发movestart/moveend,你会得到“转了第一帧就永久停止”的效果,非常迷惑。用dragstart、dragend、zoomstart、zoomend这些专门针对用户手势的事件,边界清楚得多。
第二个坑:拖拽结束时地图可能有惯性滑动。如果dragend立刻启动自转,会和惯性动画抢方向盘,表现为拖动松手后地球先滑一下再顿住。稳妥的做法是延迟一小段时间再恢复,比如setTimeout(() => spinner.start(), 800),让惯性先跑完。这个延迟值给 600 到 1200 毫秒都比较自然。
第三个坑:触摸设备上mouseenter不会触发,但touchstart会和拖动重复。移动端建议直接用touchstart暂停、touchend加延迟恢复,或者干脆不做悬停暂停,只保留拖动暂停。
第四个坑:组件卸载时忘了cancelAnimationFrame。在单页应用里切路由,地图容器被销毁了但帧循环还在跑,每帧都去访问一个已经失效的map实例,控制台会持续报错,内存也不会释放。destroy方法必须被调用,可以用生命周期钩子或者IntersectionObserver兜底。
4.5 视觉调优:让“转”看起来自然
代码跑通只是起点,观感调优才是决定成败的部分。我通常会按这个顺序调:先定转速,再定俯仰,最后微调雾效。
转速的判断标准很朴素——盯着看三十秒不觉得烦就是合适的。如果发现眼睛总想跟着球体边缘走,说明转太快了,降一档;如果觉得像卡住了,加一档。
俯仰角的经验值在 5 到 12 度之间。给一点俯仰会让球体看起来是“从斜上方看”,边缘的弧度更有立体感;给了之后一定要同步微调setFog的horizon-blend,因为俯仰变化会改变地平面在屏幕上的位置,原来的过渡宽度可能就不合适了。
还有一个细节是容器高宽比。球体在正方形容器里最完整,放到 16:9 的宽屏里,上下会被裁掉一块。这时候要么把 zoom 压到 1.6 左右保证球体完整入画,要么接受裁切换取更大的视觉冲击。大屏项目我一般倾向于后者,因为观看距离远,裁掉一点边缘反而不显得空旷。
5. 性能:真实设备上的表现和降级
5.1 掉帧的成因拆解
自转本身的逻辑开销几乎为零,一帧只做一次getCenter加一次setCenter,撑死几十微秒。真正吃掉帧率的是setCenter触发的重绘。每一次中心变化都意味着视口里的瓦片需要重新评估可见性、重新计算投影矩阵、重新绘制所有图层。图层越多、瓦片越密、屏幕分辨率越高,单帧成本就越高。
我做过一个粗略的对照:同样是 globe 自转,只开底图的场景在 4K 容器上能稳在 55 帧以上;加上十几个 GeoJSON 图层、几千个点位、几条飞线动画之后,掉到 25 帧左右;如果再叠一层地形,可能直接掉到 15 帧以下。所以性能优化不是改自转代码,而是控制场景复杂度。
几个具体手段:点位密集时改用circle图层而不是symbol图层,符号图层的碰撞检测开销大得多;飞线动画用line-dasharray配合少量渐变宽度,而不是几百条独立图层;热力图只在需要时加载,不要常驻。
5.2 低端设备的自适应降级
靠navigator.hardwareConcurrency猜设备性能不太靠谱,更好的办法是实际测量。在自转循环里采样最近若干帧的平均耗时,超过阈值就降档。
class AdaptiveQuality { constructor(spinner, map, options = {}) { this.spinner = spinner; this.map = map; this.samples = []; this.threshold = options.threshold ?? 40; // 毫秒 this.floor = options.floor ?? 24; // 降档后的目标帧率 this.degraded = false; } record(deltaMs) { this.samples.push(deltaMs); if (this.samples.length < 60) return; const avg = this.samples.reduce((a, b) => a + b, 0) / this.samples.length; this.samples.length = 0; if (!this.degraded && avg > this.threshold) { this.degraded = true; this.spinner.maxFps = this.floor; this.map.setPixelRatio(1); } } }这里加了一个degraded标志位做滞回,避免性能在临界点上下抖动时反复降档升档。降档只降一次,不往回升,因为设备性能在运行期间基本不会变好,来回切换反而更卡。
降档的两个动作值得说明:把maxFps从 45 降到 24,观感上转速不变,只是位置更新变粗,在缓慢旋转的场景里几乎看不出来;把pixelRatio从 2 降到 1,是直接砍掉四分之三的像素填充量,对帧率提升最明显,代价是画面略微变糊。两个动作配合,通常能把低端设备的帧率从十几帧拉到三十帧左右,属于可接受的交互水平。
5.3 加载阶段与资源体积
自转启动的时机也影响体验。如果在地图还没渲染完就开始转,会出现“黑屏上飘着一个不完整的球”的尴尬画面。正确的做法是等load事件,再等一个idle,让瓦片先铺满,再启动旋转,同时配一个淡入。
map.on('load', () => { enableGlobe(map); map.setFog({ /* ... */ }); map.once('idle', () => { document.querySelector('#globe').style.opacity = '1'; spinner.start(); }); });容器初始opacity: 0,配合transition: opacity 0.8s,淡入过程里地球已经在慢慢转,观感比硬切自然得多。
资源体积方面,mapbox-gl的包体不小,如果只是首屏展示,可以考虑用动态import()懒加载,把球体渲染推迟到主内容加载完之后,避免拖慢首屏指标。
6. 排查实录:那些文档里不会写的问题
6.1 问题速查表
下面这些是我在实际项目里真实遇到过并记录下来的,按现象归类。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 高刷屏上转速明显变快 | 按帧递增角度,未做时间补偿 | 改用时间戳计算 Δt |
| 拖拽松手后跳回原位 | 恢复时用了写死的初始经纬度 | 取当前getCenter的纬度 |
| 转一帧就停 | 用movestart做暂停 | 改用dragstart |
| 切标签页回来猛甩一下 | Δt 跳变未截断 | 给 Δt 设 0.1 秒上限 |
| 控制台持续报错、内存不降 | 组件卸载未取消帧循环 | cancelAnimationFrame+map.remove() |
| 球体边缘有锯齿状硬边 | 雾效未开或参数过窄 | 调horizon-blend到 0.02 左右 |
| 标注文字随旋转倾斜 | 用了setBearing而非改经度 | 自转只改经度,bearing 固定 |
| 低 zoom 时球体过小 | 相机距离过远 | zoom 提到 1.6 以上 |
| 移动端拖动后不恢复 | 只监听了mouseenter/mouseleave | 补touchstart/touchend |
| 中心经度越过 180 后画面闪回 | 取模写法错误 | 用双重取模处理负数 |
6.2 三个印象最深的坑
第一个是事件风暴。有个大屏项目里,业务方在moveend事件上挂了数据加载逻辑,用于“用户拖动后加载新区域的数据”。加上自转之后,每一帧的setCenter都会触发一次movestart,虽然moveend不是每帧都触发,但因为帧与帧之间间隔极短,moveend也被高频触发,结果就是每秒钟发起几十次数据请求,后端直接被打挂。这个问题的隐蔽之处在于,开发环境数据量小,看不出异常,上了生产才暴露。解决方式是在自转期间用一个标志位屏蔽业务侧的moveend响应,或者干脆把数据加载改成防抖。
第二个是缩放与自转的耦合。早期版本里,我在zoomstart时停止自转、zoomend时恢复,测试时一切正常。但用户如果用滚轮连续滚动,zoomend会延迟到惯性结束才触发,中间那段时间地球是静止的,观感很突兀。后来改成缩放过程中不停止,只在拖拽时停止,反而更顺滑。这个结论和直觉相反,值得单独试一下再定。
第三个是多实例冲突。一个页面里放了两个地球,一个在首屏,一个在页面底部的内容区。两个实例各自跑帧循环,底部的那个即使不在视口里也在满负荷渲染,整体帧率被拉低三成。加上IntersectionObserver,让不可见的地球暂停自转,问题立刻消失。这个优化做起来只要十几行代码,收益却很大,建议默认就带上。
const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { entry.isIntersecting ? spinner.start() : spinner.stop(); }); }, { threshold: 0.1 }); observer.observe(document.querySelector('#globe'));7. 在框架里接入时要注意的边界
7.1 生命周期与实例归属
在 Vue 或 React 里接入,最容易出问题的是实例的归属。地图实例不能放在响应式对象里,否则框架的代理会包裹整个地图对象,内部大量的属性访问都会走一层拦截,性能影响不说,某些版本还会因为this指向问题直接报错。正确的做法是用普通变量或者shallowRef存放实例。
挂载时要等 DOM 真正就绪,容器尺寸不能是 0。如果容器是display: none或者高度靠内容撑开,初始化时拿到的宽高是 0,地图会渲染成一条缝,后面即使容器显示了也不会自动恢复。这种情况必须手动调map.resize(),或者用ResizeObserver监听容器尺寸变化。
const ro = new ResizeObserver(() => map.resize()); ro.observe(document.querySelector('#globe'));卸载时的清理顺序也很关键:先停帧循环,再解绑监听,最后map.remove()。顺序反了会在地图销毁后还收到事件回调,控制台会刷一堆空指针。
7.2 源码组织建议
自转这部分代码我一般会拆成三个文件:globe-core.js负责地图初始化和投影、globe-spinner.js负责帧循环、globe-interaction.js负责暂停恢复的事件绑定。拆开的好处是,当需求方突然要加“转到特定经度暂停”或者“变速旋转”时,改动范围被限制在单个文件里,不会牵连到初始化逻辑。
接口设计上,对外只暴露start()、stop()、setSpeed(degPerSec)、destroy()四个方法就够了。setSpeed支持运行时改速度,做“慢速巡航 + 特定角度加速”这种叙事节奏时非常方便,实现上只是改一个成员变量,下一帧自动生效。
8. 这套方案后续还能怎么扩展
基础自转跑通之后,往上的空间还挺大的。最直接的一条是联动地理数据:在自转的同时,让某几个城市点位按固定节奏依次点亮,配合自转形成的“城市依次出现在正面”的错觉,做出来的效果比静态展示强很多。实现方式是在帧循环里根据当前中心经度和目标点经度的差值计算“正面程度”,映射到点位的透明度或半径。
第二条是变速与停顿。纯匀速转久了会腻,可以设计成“转一圈之后在某个角度停 3 秒,再继续”,用缓动函数处理加减速。这里要提醒一句,变速时不能直接继承匀速循环的帧循环,得单独写一个带时间线的调度器,否则加减速会和其他状态互相干扰。
第三条是切换视角。同一个球,可以在低角度斜视和正视之间做插值,配合自转形成“环绕镜头”的感觉。pitch和zoom都可以做数值插值,但要小心这两者的插值曲线不是线性的,实际调的时候多试几组参数,找到观感最顺的那条。
这个内容后续还可以这样扩展:把自转和地形结合起来做“环球飞行”视角,或者用多球体做数据对比展示。不过那已经是另一个量级的工程了,等真的有需求再动手不迟。
我个人在实际操作中的体会是,Mapbox 地球自转这件事,技术难度都集中在细节里——帧率补偿、事件边界、生命周期清理,这三块处理干净了,剩下的就是审美问题。真正花时间的从来不是把球转起来,而是让它在各种设备上、各种操作路径下,都能稳定地、不出戏地转下去。每次接手这类需求,我都会先把上面那张排查表过一遍,看看这次会不会踩到新的坑。