☰
Three.js地球可视化SDK封装:从重复代码到一行创建3D地球
2026/10/9 19:50:04 网站建设 项目流程

做这一系列博客的时候,我一直被同一个问题折磨:我写过的那些 Three.js 初始化代码,为什么每次新开项目都要原封不动地抄一遍?从第一篇搭开发环境、第二篇渲染白模几何体,到第三篇把相机控制在场景里拖来拖去,零零散散攒下来,大概也有五六百行的样板代码了。所以这篇系列第四篇,我决定做一件不一样的事:把这些反复出现的东西收拢成一个真正的 SDK 模块,让我的下一个项目真的能用一行代码创建出地球。

这篇内容我会拆成四块讲:为什么偏偏拿"地球"来练手、一个 3D 地球在渲染原理层面由哪些东西组成、怎么设计 SDK 的边界与生命周期,以及封装过程中真实踩到的坑。"SDK模块"这个词听起来可能有点重,但它本质上不复杂——就是把"加载纹理、建球体、开旋转、管释放"这些行为收敛到一个类里,对外只暴露一个简单的入口。这篇文章适合所有接触过可视化、写过几段 Three.js 但没有系统做过库设计的开发者。我自己始终觉得,会写功能代码的人很多,能把代码设计成"别人能放心使用"的人少一些,这篇就是来补上这段差距的。

1. 为什么是"地球":从场景漫游到产品化的一步之遥

1.1 先回顾系列前几篇我们做了什么

前几篇其实一直在打地基。第一篇是环境搭建:装依赖、起工程、渲染一个带坐标轴的空白场景;第二篇开始创建几何体:盒子、球体、平面,顺带讲了 Phong 材质和基础光照;第三篇加入相机轨道控制,让场景能拖拽、能缩放。这些内容单拎出来都不算难,但它们的组合形态非常分散——在一个页面里可能就要写几十行初始化代码。

如果你跟着写到第三篇,大概率已经发现一个问题:换个 HTML 文件,那几十行几乎原封不动。这就是"封装"最自然的驱动力,不是谁规定 SDK 应该长什么样,是重复代码重复到让人难受了。尤其是当你开始做多个页面,或者参与到一个多人协作项目里,"全局散落式写代码"的坏处会被放大:每个人初始化参数略有不同,出问题要排查半天;想统一改光照方向,得到处找。我之前参与过一个内网工具项目,三个页面复制了三份 Three.js 初始化代码,后来要统一改场景背景色,全局搜索了二十分钟才改完。这就是典型的工具代码没有产品化。

1.2 工具代码与产品化代码的分水岭

我理解的"产品化"不一定是非得上 npm 发布,而是设计一个稳定的外部契约。普通代码侧重表达过程,SDK 代码侧重表达边界。比如 Three.js 初始化,普通写法是这样:

const container = document.getElementById('app'); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 0.1, 1000); camera.position.set(0, 0, 500); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(container.clientWidth, container.clientHeight); container.appendChild(renderer.domElement);

而 SDK 的使用方式是:

const earth = new Earth('#app', {}); earth.render();

两种写法的差别不在代码量,而在前者把内部状态全部暴露给了调用者,后者给调用者一个确定答案:传入容器,得到地球。封装得好的 SDK 会让使用者只需要回答"把它放在哪里、要什么配置"这两个问题,剩下脏活累活都藏在内部。这不是什么高深技巧,但它直接决定了项目的可维护性。不止 3D 领域,地图组件、图表组件、树形控件,全都是这个思路——先定清楚"外面能碰什么、里面怎么实现",再开始写代码。

1.3 一张职责表,划定 SDK 的第一个边界

这次封装我之前先列了一张职责表,把所有能力分成"内置"和"不内置"两拨:

职责是否内置原因
球体模型创建内置地球 SDK 的核心职责
纹理加载与渲染内置没有纹理就没有地球效果
自转动画与渲染循环内置用户关心的是"它在转"
自动适配容器尺寸内置基础体验,不做会被反复投诉
生命周期销毁内置防止内存泄漏,后面会重点讲
地理坐标系转换不内置属于业务层,不该绑架 SDK
图层 / 弹窗 / 标注不内置留给后续扩展
轨道控制暂不内置减少第一版 API 面积
点击拾取事件内置最小版点击地球能拿经纬度,已有通用场景

边界定清楚之后,所有实现都围绕这张表展开。SDK 最怕的就是"什么都想做",一旦把业务相关的逻辑塞进来,后续维护成本会指数上升。当前版的边界就是"做出一个会转的地球,且能被安全销毁",其他能力通过接入事件和后续版本慢慢补齐。

2. 核心原理拆解:一个 3D 地球最少需要哪几样东西

2.1 球体几何与纹理贴图的配合逻辑

先拆原理。用 Three.js 创建 3D 地球,核心就三行:

const geometry = new THREE.SphereGeometry(200, 64, 64); const material = new THREE.MeshPhongMaterial({ map: texture }); const mesh = new THREE.Mesh(geometry, material); scene.add(mesh);

SphereGeometry(200, 64, 64)里三个参数分别是半径、经线分段数、纬线分段数。分段数决定球面的细密程度:取值太小,比如 8,球体会有明显的棱角;取值太大,比如 256,顶点数暴涨,低端设备直接掉帧。64 是我常用的平衡点,视觉上已经很平滑,面数也控制在合理范围。

纹理贴图的原理可以类比"用世界地图包一个球"。地图是二维的,X 方向对应经度,Y 方向对应纬度,SphereGeometry会自动按这个映射关系把纹理铺到球面上。这里有个前置条件,贴图必须是等距柱状投影(Equirectangular Projection),也就是长宽比为 2:1 的平面世界地图。我第一次封装时忽略了这个,随手拿了一张带装饰边框的示意图,结果南北极上方出现了一圈奇怪的条纹,排查半天才发现是图源投影格式不对。

2.2 坐标系统、相机与光照的三角关系

有了球体,还得有相机和场景才能看见它。Three.js 默认坐标系里 x 轴向右、y 轴向上、z 轴指向屏幕外,所以一个"立着"的地球应该绕 y 轴自转,这跟真实地球仪的表现一致。相机要放在 z 轴正方向,再看向原点:

const camera = new THREE.PerspectiveCamera(45, aspect, 0.1, 1000); camera.position.set(0, 0, 500); camera.lookAt(0, 0, 0);

镜头离地球太近,画面会显得"顶天立地";太远,球又小得可怜。我习惯把视距设为半径的 2.5 倍,这样地球在画面里的占比大概在 55% 左右,留白和主体比例都比较舒服。如果是给后台大屏用,想预留标题和弹窗的位置,可以放到 3 倍以上。

光照必须单独说。如果用MeshBasicMaterial,本质上是"自发光材质",不依赖任何光源,但画面会非常平,没有一点立体感。想看到地球的明暗交界线和质感,建议用MeshPhongMaterial,然后场景里加一个环境光保证暗面不至于死黑,再加一个平行光模拟太阳直射:

const ambientLight = new THREE.AmbientLight(0xffffff, 0.4); const directionalLight = new THREE.DirectionalLight(0xffffff, 0.9); directionalLight.position.set(500, 100, 300);

平行光的位置会影响高光落点。如果球体上总有一块区域过曝或过暗,先检查光源位置和强度,再看材质的shininess,顺序不要反。

2.3 自转动画与帧循环的最小实现

静态地球没有灵魂,必须转起来。Three.js 动画的核心就是requestAnimationFrame循环:

function animate() { requestAnimationFrame(animate); mesh.rotation.y += 0.002; renderer.render(scene, camera); }

要注意每次回调都必须调用renderer.render,否则画面只会停留在第一帧。mesh.rotation.y每帧加一个很小的弧度增量 0.002,换算下来每秒大约转 7 度,绕一圈要 50 秒左右,观感上是比较舒缓的"地球自转"。转太快像滚筒,太慢又像没动。这个值建议直接做成可配置项,让业务方自己按场景调整。

到这里,一个"会转的球"已经能跑起来了,但它离 SDK 还有距离。因为所有变量都暴露在全局,换个页面只能复制粘贴。下一章我就把这些逻辑全部收进一个类里,定义好入口和出口——这才是封装的核心工作。

3. 从"会跑的代码"到 SDK:封装时的结构化决策

3.1 目录结构与模块边界划分

SDK 设计的第一步是定文件边界。这一版我把地球模块拆成了下面这个结构:

earth-master/ ├── src/ │ ├── index.js // 出口,只导出 Earth │ ├── earth.js // 主类,承载生命周期 │ ├── scene.js // 场景、相机、渲染器构建 │ ├── texture.js // 纹理异步加载封装 │ └── config.js // 默认配置 ├── package.json └── README.md

不一定所有类库都按这个结构来,但单人维护的 SDK 我强烈建议按"职责一致"拆分。好处很明显:改纹理逻辑时不用碰场景逻辑,测试也好写。最容易踩的坑是把所有东西都塞进一个earth.js,最后文件膨胀到上千行,找一处光照配置要翻半天。

模块边界同样要定清楚:外部世界只能通过index.js拿到Earth类,src里其他文件不对使用者开放。这样即使内部结构以后调整了,只要Earth的公共接口不变,使用方完全不需要感知变化。

3.2 构造参数、默认值与配置合并策略

接下来是 API 设计。Earth构造函数只接收两个参数:容器和配置项。

const defaultOptions = { radius: 200, textureUrl: null, autoRotate: true, autoRotateSpeed: 0.002, cameraPosition: [0, 0, 500], backgroundColor: 0x000000, enableLight: true };

defaultOptions存在的意义就是让"一行代码"成为可能。用户不传任何配置,SDK 也能用一套合理参数跑起来。默认值的取舍都很刻意:半径 200 既不会让相机裁剪面难调,也不会让球面顶点密度失衡;textureUrl默认 null 时,内部会生成一张简单的单色纹理作为兜底,用户不配置也能看清地球轮廓,换成高清卫星图也只是改一个参数的事。

配置合并策略我直接用了浅合并:

function mergeOptions(userOptions) { return Object.assign({}, defaultOptions, userOptions); }

可能有同事会建议用深合并来处理嵌套对象,但在 SDK 设计里我反而推荐扁平结构。扁平配置项容易枚举、容易覆盖、容易写文档,而深层嵌套配置会显著增加使用者的心智负担。一个配置对象传进来,使用者根本不知道哪一层才是他需要改的。我的原则是:SDK 默认配置保持浅层,如果某个配置天然就是完整对象,再单独开一个子对象,而不是把所有东西都塞进一个大杂烩里。

3.3 生命周期管理:init / render / destroy

生命周期是 SDK 质量的分水岭。普通 Demo 代码不需要生命周期,因为页面一关全没了。但现代前端项目里路由切换、组件销毁是常态,SDK 必须对自己创建出来的资源负责。

第一版我可是设计了三个明确方法:

class Earth { constructor(container, options = {}) { this.container = typeof container === 'string' ? document.querySelector(container) : container; this.options = mergeOptions(options); this.isDestroyed = false; } async init() { // 构建场景、相机、渲染器 // 加载纹理和地球模型 // 派发 ready 事件 } render() { // 启动 requestAnimationFrame 循环 } destroy() { // 取消动画帧 // 移除事件监听 // 清理几何体、材质、纹理 // 销毁 WebGL 上下文 // 从 container 中移除 canvas } }

可能有朋友会问,为什么init不放进构造函数里?因为TextureLoader.load是异步的,构造函数里直接初始化的话,用户new完之后纹理还没加载好,地球是个灰模,而且你没法用同步方式拿到"加载完成"的信号。采用显式init之后,用户可以配合await控制时机:

const earth = new Earth('#app', { textureUrl: '/earth.jpg' }); await earth.init(); earth.render();

destroy我建议做得尽量彻底。有一个真实场景让我印象很深:某个后台管理页面,频繁切换菜单进入和退出地球组件,如果不销毁旧的WebGLRenderer,浏览器最多同时存在十几个 WebGL 上下文,到临界点后新渲染器直接创建失败,页面白屏,刷新都不一定马上恢复。WebGL 上下文是有限的浏览器资源,绝对不能只创建不释放。

destroy() { this.isDestroyed = true; if (this.animationId) { cancelAnimationFrame(this.animationId); } this.geometry?.dispose(); this.material?.dispose(); this.texture?.dispose(); this.renderer?.dispose(); this.renderer?.forceContextLoss(); const canvas = this.renderer?.domElement; if (canvas && canvas.parentNode) { canvas.parentNode.removeChild(canvas); } }

renderer.dispose()释放显存里的资源,forceContextLoss()强制释放 WebGL 上下文。在单页应用里,有了这套销毁逻辑,反复创建几百次都不会有问题。很多开发者一开始忽略销毁,等页面越用越卡才回头排查,这就是典型的内存泄漏现场。

3.4 对外事件与内部回调解耦

事件机制上,第一版 SDK 只暴露了三个事件:ready、error、click。ready表示地球创建完成;error表示资源加载失败;click是点击地球后往外抛经纬度。内部实现用了一个非常小的订阅机制:

class Earth { constructor(...) { this.eventMap = {}; } on(eventName, callback) { if (!this.eventMap[eventName]) this.eventMap[eventName] = []; this.eventMap[eventName].push(callback); return this; // 支持链式调用 } emit(eventName, payload) { if (!this.eventMap[eventName]) return; this.eventMap[eventName].forEach((fn) => fn(payload)); } }

事件机制的引入让 SDK 与业务解耦:使用者不需要把一堆回调函数塞进构造参数里,而是通过on('ready', fn)挂载,语义更清楚。而且事件天然支持多订阅者,同一个页面既想在ready时关掉 loading,又想在ready时播放入场动画,直接挂两个回调就行。

顺便说一下click事件内部怎么拿经纬度:这里用了 Three.js 的Raycaster做射线拾取,但对外并不暴露任何 Three.js 对象,而是把点击结果转换成{ longitude, latitude, altitude }这样的通用结构。这段转换逻辑本身不简单,但对使用者来说它就是"点击地球后能拿到经纬度"这一句话的事。把复杂度留在内部、把简单抛给外部,这正是 SDK 存在的一个核心理由。

4. 一行代码的真实体验:Demo 跑起来与隐含约定

4.1 本地联调:npm link 快速验证

SDK 写完,第一件事就是本地联调。我们这个系列的项目主体和 SDK 不在同一个工程里,如果走"发完包再安装"的流程,改一个参数就要整套发布流程,开发效率太低。本地联调推荐用npm link:

cd earth-master npm link cd your-demo npm link earth-master

这样 Demo 项目里的import { Earth } from 'earth-master'实际指向的是本地源码目录,改动即时生效。npm link配合 Vite 或 Webpack 的热更新,效率非常可观。

有一个小坑要提醒:如果你用的 npm 版本在 7 以上,npm link生成的符号链接在某些构建工具里可能触发目录监听问题。一个稳妥的办法是在 Vite 配置里加resolve.preserveSymlinks: true,或者在 Demo 项目里直接通过相对路径 import 本地 SDK 源码。对于还没准备发布 npm 包的阶段,第二种方式其实更省事。

4.2 完整最小示例:三行 HTML 加两行 JS

下面是这个 SDK 的完整最小用法,我认为它是"一行代码创建地球"最直观的证明:

<div id="container" style="width: 100%; height: 500px;"></div> <script type="module"> import { Earth } from 'earth-master'; const earth = new Earth('#container', { textureUrl: '/earth-texture.jpg' }); await earth.init(); earth.render(); </script>

这里说的"一行代码"不是文字游戏,它同时包含两层意思:第一层,SDK 提供了充足的默认值,纯参数可以不传;第二层,init和render各自承担明确的异步步骤和渲染启动,调用者按顺序执行即可。真实项目里,调用前其实要先确认容器在 DOM 里可见,SDK 内部会做一个尺寸读取,如果容器是display: none或者还没布局完成,宽高读到的是 0,画布就会不可见。所以业务侧要注意:容器可见再调init,是很自然的约定。

4.3 API 边界:什么内置、什么需要扩展

聊完用法,再把"边界"这个事说透一点。SDK 第一版的内置能力就是前面那张职责表里的内容:球体模型、纹理渲染、自转动画、尺寸自适应、基础点击事件、生命周期销毁。这些属于地球可视化最底层的表达。

不内置的内容包括:拖拽旋转(OrbitControls)、GeoJSON 图层、热力图、飞线动画、城市标注。这些能力都能做,但如果全部塞进第一版,API 会迅速膨胀。每一个扩展都有自己独立的配置项和性能考量,强行合在一起,SDK 会变成一个无法稳定交付的"大水桶"。

拿 OrbitControls 举例。它看似只是加一行代码,实际上会绑定鼠标、触屏、滚轮事件,直接影响相机的轨道位置和旋转方式。如果第一版默认内置,用户想关掉时就要找个配置项来"退掉";想接入自研的飞行相机控制器时,又会跟内置控制器产生冲突。所以我不内置,只把相机引用在适当时机提供给外部,允许后续通过扩展方法注册外部控制器。做 SDK 一个很重要的习惯就是:你能主动选择不做什么,比能做什么更考验经验。

5. 封装过程中的坑与心得:纹理、内存与按需引入

5.1 纹理加载是异步的,构造函数里不能直接贴图

这是封装过程中踩得最深刻的一个坑。第一版我为了"省事",在构造函数里直接发起了纹理加载。表面上看代码确实短:

const earth = new Earth('#app', { textureUrl: '/earth.jpg' });

但实际运行时,页面只弹出一个灰色球体,纹理要等网络请求返回之后才生效。如果我不处理加载完成的信号,灰球就一直停留在第一帧,用户会以为 SDK 出了 bug。根源就在于TextureLoader.load是典型的异步任务,它不会阻塞线程等图片下载完。

正确做法是把纹理加载成功的回调真正接起来,并在加载完成后重新设置材质、标记更新:

const loader = new THREE.TextureLoader(); loader.load( url, (texture) => { texture.colorSpace = THREE.SRGBColorSpace; this.material.map = texture; this.material.needsUpdate = true; this.emit('ready', this); }, undefined, (err) => { this.emit('error', err); } );

needsUpdate = true这行尤其关键。Three.js 中纹理加载完成后要重新执行一次渲染,这个标记会告诉材质系统"纹理数据变了"。如果你发现图片明明拿到了、球却还是白色,大概率就是这行忘写了。另外,新版 Three.js 默认启用颜色管理,贴图不显式设置texture.colorSpace = THREE.SRGBColorSpace的话,颜色会偏灰偏暗,这是一个非常典型的细节问题。

5.2 销毁逻辑不写,页面越跑越卡

这个前面提过,这里再把症状和数据补完整。有一段时间我给业务方演示地球 SDK,从列表页反复进入详情页十几次之后,整个标签页开始掉帧,风扇声音都变得明显。打开任务管理器一看,GPU 占用持续上涨。这就是没有销毁的后果:

  • requestAnimationFrame继续以每秒 60 次的频率在后台画场景;
  • 旧 canvas 节点滞留在 DOM 里,每份都占一块显存;
  • WebGL 上下文没有被释放,达到浏览器上限后新场景直接创建失败。

我自己做过一次简单压测:一个页面反复进入退出 20 次,只创建不销毁时 GPU 占用能爬到 70% 以上;加上完整destroy之后,基本稳定在极小值。这个对比给我留下的印象太深了。所以我在 SDK 里宁可让destroy方法写得啰嗦一点,也绝不让使用者为内存泄漏买单。

5.3 按需引入与包体积控制

第三个让我印象深刻的坑是包体积。第一版做构建时图省事,直接在入口文件写:

import * as THREE from 'three';

后果就是 SDK 产物瞬间多了 1MB 还多,功能倒是没问题,但作为一个"一行代码接入"的模块,这种体积实在不体面。后来改成按需引入:

import { WebGLRenderer, Scene, PerspectiveCamera, SphereGeometry, MeshPhongMaterial, TextureLoader, AmbientLight, DirectionalLight, Raycaster } from 'three';

只要打包工具开启了 tree-shaking,这种写法会让构建产物只保留被真正引用的模块。我用 Vite 的库模式做了一组对比:

引入方式打包后体积备注
import * as THREE约 1.2MB全量引入,方便但体积重
按需 import + tree-shaking约 480KB只保留核心模块
按需 import + gzip约 130KB传输体积大幅优化
再优化纹理与叠加层可进一步缩小属于素材与功能层面

做 SDK 的都应该有"尊重用户网速"的自觉。用户引入一个封装好的地球模块,本来就是为了省事,结果你还给他背上一个巨大的运行时依赖,那他还得先忍受漫长的加载时间才能看到地球转起来。按需引入 + tree-shaking 是基础操作,任何用于分发的类库都值得检查一遍。

6. 最后聊聊我对这次封装的体会

代码写完那天,我给同事演示这个地球 SDK,大概就是三行 HTML 加两行 JS。同事第一反应是"这就完了?"然后伸手去拖画面,发现地球已经在那慢慢转了。那一瞬间我才真切感觉到,SDK 封装的真正难点不是写类、不是打包、也不是发布 npm,而是学会克制:不把顺手就能加的功能全塞进去,不为某个临时需求去破坏默认值设计,不为了省几行代码就省略内存释放。

如果这篇文章能给你留下一个具体动作,我希望你下次封装任何模块之前,先写一张"这段代码里面管什么、外面管什么"的职责表,再动手写构造函数。这张表不需要很正式,但它能帮你提前发现边界模糊的部分。地球 SDK 只是开始,后面几篇我会在这个基础上继续加轨道控制、图层叠加和 GeoJSON 数据映射,让模块一点点养大。到那时你会发现,所有新增能力都只是围着这张表在填格子,而不是推倒重来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询