加载管理与进度
本教程共 40 篇 · 第 31 篇 · 更新于 2026-08-14 · 约 9 分钟阅读
本节目标:学会用 LoadingManager 统一管理模型、纹理、字体等资源的加载,监听开始、逐项进度、全部完成与出错事件,实现一个真正的加载进度条。
为什么要 LoadingManager
一个场景往往要同时加载模型、纹理、字体。每个 loader 单独回调,进度分散在各处,没法统一统计。LoadingManager 把所有加载任务登记在一起,统一汇报进度和错误。
three.js 自带一个全局实例 DefaultLoadingManager:不传 manager 的 loader 默认都用它。想要独立的进度统计(比如模型和纹理分开两条进度条),就自己 new LoadingManager(),传给相关 loader。
manager 还有一个隐性作用:解决加载顺序依赖。字体没加载完不能建 TextGeometry,模型没加载完不能播动画——把”准备好了”的判断统一放在 onLoad 里,比在每个 loader 回调里各自判断清晰得多。
四个回调
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const manager = new THREE.LoadingManager();
manager.onStart = (url, itemsLoaded, itemsTotal) => {
console.log('开始加载:', url);
};
manager.onProgress = (url, itemsLoaded, itemsTotal) => {
console.log('完成一个:', url, itemsLoaded, '/', itemsTotal);
};
manager.onLoad = () => {
console.log('全部加载完成!');
};
manager.onError = (url) => {
console.error('加载失败:', url);
};
// 把这个 manager 传给需要的 loader
const loader = new GLTFLoader(manager);
loader.load('model.glb', (gltf) => scene.add(gltf.scene));
四个回调的时机:
onStart(url, itemsLoaded, itemsTotal):每个资源开始加载时触发。onProgress(url, itemsLoaded, itemsTotal):每个资源完成时触发。onLoad():所有资源全部完成,最后触发一次。onError(url):某个资源出错时触发。
把 manager 传给 loader 的方式都一样:new GLTFLoader(manager)、new TextureLoader(manager)、new FontLoader(manager)。GLTFLoader 内部加载贴图时也会用同一个 manager,所以”1 个 glb + 2 张贴图”会被统计成 3 个加载项。
进度怎么算
manager 的进度按文件个数算,不是字节数:
manager.onProgress = (url, itemsLoaded, itemsTotal) => {
const percent = Math.round((itemsLoaded / itemsTotal) * 100);
progressBar.style.width = percent + '%';
};
单个文件很大时(比如 50MB 的模型),manager 只在它完成的瞬间跳一下,中间没有连续进度。想要字节级的连续进度,用 loader 自带的 onProgress 回调——底层 FileLoader 会汇报已下载字节:
loader.load(url, onLoad, (event) => {
if (event.lengthComputable) {
const percent = (event.loaded / event.total) * 100;
}
});
Tip简单场景用 manager 的按文件进度就够了;单个超大文件才需要字节级进度。别一开始就上复杂方案。
进度条实现
进度条就是两个嵌套的 div,内部条宽度按百分比变化:
<div id="progress-wrap"><div id="progress-bar"></div></div>
#progress-wrap {
position: fixed; inset: 0;
background: #1a1a2e; z-index: 10;
display: flex; align-items: center; justify-content: center;
}
#progress-bar {
width: 0%; height: 6px;
background: #4dabf7; border-radius: 3px;
transition: width 0.2s;
}
JS 侧就三件事:onProgress 更新宽度、onLoad 隐藏进度层、onError 显示错误信息。
进度数字别只靠一条横条:旁边显示”3/8 · 37%“这样的文本,用户感知更具体。加载完成后给进度层加个透明度过渡再移除,比瞬间消失舒服得多——给 #progress-wrap 加 transition: opacity 0.3s 就行。
FileLoader 与 Loader 基类
FileLoader 是所有加载器的底层,也可以直接用来加载任意文件:
import { FileLoader } from 'three';
const loader = new FileLoader();
loader.setResponseType('json'); // '' | 'arraybuffer' | 'blob' | 'document' | 'json'
const data = await loader.loadAsync('data.json');
所有 loader 都继承自 Loader 基类,常用配置:
setPath(path):设置基础路径,之后的 URL 都拼在它后面。setResourcePath(path):设置附属资源(纹理等)的基础路径。setRequestHeader(headers):给请求加自定义头。setCrossOrigin(value):跨域设置,默认 ‘anonymous’。
加载缓存:THREE.Cache.enabled = true 开启后,同一 URL 只请求一次,后续直接读缓存。
加载 JSON 配置文件是 FileLoader 最常见的用法:setResponseType('json') 后 loadAsync 直接返回解析好的对象,不用手动 JSON.parse。
responseType 的 ‘arraybuffer’ 适合加载二进制资源,比如自定义格式的地形数据或音频文件。
其他能力(知道即可)
LoadingManager 还有一些进阶能力:itemStart / itemEnd / itemError 是 loader 调用 manager 的内部记账方法,一般不用碰;addHandler(正则, loader) 可以注册”某类文件用哪个 loader 加载”;setURLModifier(fn) 可以在请求前改写 URL(比如从 zip 包或 Blob 里取资源);abort() 中止所有进行中的请求。
addHandler 的实际例子:项目里有 TGA 格式的纹理,注册一次后,所有 TextureLoader 遇到 .tga 结尾的 URL 都会自动改用 TGALoader:
import { TGALoader } from 'three/addons/loaders/TGALoader.js';
manager.addHandler(/\.tga$/i, new TGALoader());
加载失败的处理
onError 只是”知道了”,要不要补救由你决定。常见做法是重试:记录失败的 URL,提示用户点击重试按钮,再调一次 load。注意重试前先检查原因——路径写错,重试一万次也没用。
let failedUrl = '';
manager.onError = (url) => {
failedUrl = url;
retryButton.style.display = 'block';
};
retryButton.onclick = () => {
retryButton.style.display = 'none';
loader.load(failedUrl, onLoad, onProgress, onError);
};
abort() 可以中止所有进行中的请求:用户取消加载、或页面切走时调用它,避免浪费带宽。中止后相关请求会以 AbortError 结束。
全局的 DefaultLoadingManager 也能挂回调——适合统一记录所有加载错误,不用每个 loader 单独写:
THREE.DefaultLoadingManager.onError = (url) => {
console.error('全局加载错误:', url);
};
可运行示例:带进度条的加载页
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>31. 加载管理与进度</title>
<style>
* { margin: 0; padding: 0; }
body { overflow: hidden; background: #1a1a2e; }
canvas { display: block; }
#progress-wrap {
position: fixed; inset: 0; z-index: 10;
background: #1a1a2e;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
color: #fff; font: 14px/1.6 sans-serif;
gap: 12px;
}
#progress-track {
width: 320px; height: 6px;
background: rgba(255, 255, 255, 0.15); border-radius: 3px;
overflow: hidden;
}
#progress-bar {
width: 0%; height: 100%;
background: #4dabf7; border-radius: 3px;
transition: width 0.2s;
}
</style>
</head>
<body>
<div id="progress-wrap">
<div id="progress-text">正在加载资源…</div>
<div id="progress-track"><div id="progress-bar"></div></div>
</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.185.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.185.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { FontLoader } from 'three/addons/loaders/FontLoader.js';
import { TextGeometry } from 'three/addons/geometries/TextGeometry.js';
// ---- LoadingManager:统一进度 ----
const manager = new THREE.LoadingManager();
const bar = document.getElementById('progress-bar');
const text = document.getElementById('progress-text');
manager.onProgress = (url, loaded, total) => {
const percent = Math.round((loaded / total) * 100);
bar.style.width = percent + '%';
text.textContent = `正在加载 ${loaded}/${total}:${percent}%`;
};
manager.onLoad = () => {
document.getElementById('progress-wrap').style.display = 'none';
renderer.setAnimationLoop(animate); // 全部加载完再开启动画
};
manager.onError = (url) => {
text.textContent = '加载失败:' + url;
};
// ---- 场景 ----
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
scene.add(new THREE.AmbientLight(0xffffff, 1));
const dirLight = new THREE.DirectionalLight(0xffffff, 2);
dirLight.position.set(2, 4, 3);
scene.add(dirLight);
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 0, 5);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.append(renderer.domElement);
// ---- 用同一个 manager 加载两个资源 ----
const fontLoader = new FontLoader(manager);
fontLoader.load(
'https://cdn.jsdelivr.net/gh/mrdoob/three.js@r185/examples/fonts/helvetiker_regular.typeface.json',
(font) => {
const geometry = new TextGeometry('Loading!', {
font, size: 1, depth: 0.2, curveSegments: 8,
bevelEnabled: true, bevelThickness: 0.05, bevelSize: 0.05,
});
const mesh = new THREE.Mesh(geometry, new THREE.MeshPhongMaterial({ color: 0x4dabf7 }));
geometry.computeBoundingBox();
geometry.boundingBox.getCenter(mesh.position).multiplyScalar(-1);
scene.add(mesh);
}
);
const texLoader = new THREE.TextureLoader(manager);
texLoader.load(
'https://cdn.jsdelivr.net/gh/mrdoob/three.js@r185/examples/textures/crate.gif'
);
const clock = new THREE.Clock();
function animate() {
const delta = clock.getDelta();
const mesh = scene.children.find((o) => o.isMesh);
if (mesh) mesh.rotation.y += delta * 0.5;
renderer.render(scene, camera);
}
// 注意:动画循环在 manager.onLoad 里才启动
</script>
</body>
</html>
进度条从 0 走到 100%,两个资源都完成后加载层消失,文字开始旋转。把某个 URL 改成不存在的地址,onError 会触发并显示错误——调试加载问题时就靠它。
Note上面的 crate.gif 纹理加载了但没使用,只为演示多个资源统一计数。实际项目中每个加载的资源都要派上用场,别白白浪费带宽。
进度条的用户体验有两条铁律:第一帧就要显示 0%,别让页面白屏等着;进度只增不减,个别资源加载失败也不要把百分比往回拉,直接走 onError 分支提示重试。
最后提醒:manager 的百分比适合”资源总量固定”的场景。如果游戏是分区块动态加载的,itemsTotal 会不断增长,百分比会回退,这时展示”已完成 N 个”比百分比更稳。
经验
进度条是给用户看的”希望”,也是给开发者的”哨兵”:onError 一响,配合网络面板,八成是路径写错、CORS 拦截或服务器 404。把 onError 写全,能省下大量排查时间。