做这一系列博客的时候,我一直被同一个问题折磨:我写过的那些 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 数据映射,让模块一点点养大。到那时你会发现,所有新增能力都只是围着这张表在填格子,而不是推倒重来。