three.js 资源加载核心:LoadingManager 加载管理器完全指南
导读
LoadingManager 是 three.js 中负责统计与跟踪所有"已加载/加载中"资源的统一管理器。无论你使用 OBJLoader、GLTFLoader 还是 TextureLoader,它们都默认挂在同一个全局加载管理器上;而当你需要为不同类别资源(如模型与纹理)分别展示独立的加载进度条、或者统一接管 URL 变换、文件类型分发与请求取消时,就需要创建并传入自定义的 LoadingManager。读完本文,你将掌握 LoadingManager 的全部回调、属性与 9 个核心方法的用法,并能结合源码理解其计数状态机与 loader 之间的协作机制。
一、LoadingManager 是什么
LoadingManager 的核心职责(实现源码 src/loaders/LoadingManager.js 的开头注释)是:
Handles and keeps track of loaded and pending data. — 处理并跟踪已加载与待加载的数据。
它本身不负责实际请求资源,真正执行 fetch/Image 等网络工作的是各个 Loader(如 FileLoader、ImageLoader),LoadingManager 更像一个调度与统计中枢:Loader 在开始、结束、失败时通知 manager,manager 据此驱动 onStart/onProgress/onLoad/onError 回调,并负责把 URL 交给"文件处理器"或"URL 变换函数"处理。
默认全局实例与何时需要自建实例
在 src/loaders/Loader.js 的构造函数中可以看到这一关键默认逻辑:
constructor( manager ) {
this.manager = ( manager !== undefined ) ? manager : DefaultLoadingManager;
...
}
也就是说,任何 loader 在创建时若不传 manager,都会自动使用模块级单例 DefaultLoadingManager(定义于 src/loaders/LoadingManager.js)。例如:
new THREE.TextureLoader(); // 内部使用 DefaultLoadingManager
new THREE.FileLoader(); // 同上
对于大多数场景,全局默认实例已足够。文档明确指出一个典型例外场景:当你想为"几何模型"与"纹理"分别显示独立的加载进度条时,应为两组 loader 各创建一个独立的 manager,否则它们的进度会被混在同一个统计计数中。
二、基础用法:代码示例
把一个自定义 manager 分发给多个 loader,即可让它们共享同一套加载生命周期回调(原文档核心示例):
const manager = new THREE.LoadingManager();
manager.onLoad = () => console.log( 'Loading complete!' );
const loader1 = new OBJLoader( manager );
const loader2 = new ColladaLoader( manager );
运行效果:无论 loader1、loader2 各自发起多少次资源请求,只有当两者全部加载完成时,manager.onLoad 才会被触发一次。这正是"合并统计、统一收口"的设计目的。
注意:manager 分发必须以"loader 创建时传入"的方式完成。像
OBJLoader、ColladaLoader这类 loader 位于examples/jsm/loaders/目录(例如 examples/jsm/loaders/3MFLoader.js 所示,FileLoader构造时接收并透传this.manager),而它们内部的子资源加载器也会继承同一 manager,从而实现整棵加载树的统一跟踪。
三、构造函数与生命周期回调
3.1 构造签名
new THREE.LoadingManager( onLoad, onProgress, onError )
三个回调全部可选。构造函数实现见 src/loaders/LoadingManager.js:
| 参数 | 类型 | 触发时机 |
|---|---|---|
onLoad |
Function |
当所有待加载条目全部加载完成 |
onProgress |
Function |
当单个条目加载完成 |
onError |
Function |
当某个条目加载出错 |
构造函数内部还创建了 itemsLoaded = 0、itemsTotal = 0 两个计数器和 isLoading = false 状态标志,它们构成了整个进度统计的"状态机"。
3.2 回调参数签名(源码级细节)
要写出真正可用的进度条,必须知道回调收到的参数。阅读 itemStart 实现 与 itemEnd 实现,可以得到精确签名:
onStart( url, itemsLoaded, itemsTotal )—— 由itemStart触发。注意:只有"第一项"开始加载时才触发,之后新增条目不再重复调用onStart。源码中if ( isLoading === false ) { ... onStart(...) }正是这个语义。注释还特别说明:不把onStart放进构造函数赋值,是为了避免 issue #5689 讨论的时序问题。onProgress( url, itemsLoaded, itemsTotal )—— 由itemEnd触发。每完成一项调用一次,参数中的itemsLoaded与itemsTotal可直接用于计算进度百分比itemsLoaded / itemsTotal。onLoad()—— 无参数。在itemsLoaded === itemsTotal的瞬间触发(发生在最后一次itemEnd中)。onError( url )—— 由itemError触发,仅接收出错的 URL。
一个包含全部回调的进度条示例:
const manager = new THREE.LoadingManager();
manager.onStart = ( url, itemsLoaded, itemsTotal ) => {
console.log( `开始加载:${url}(第 ${itemsLoaded}/${itemsTotal} 项)` );
};
manager.onProgress = ( url, itemsLoaded, itemsTotal ) => {
const percent = itemsTotal === 0 ? 0 : Math.round( itemsLoaded / itemsTotal * 100 );
progressBar.style.width = percent + '%';
};
manager.onLoad = () => {
console.log( '所有资源加载完成!' );
};
manager.onError = ( url ) => {
console.error( `资源加载失败:${url}` );
};
3.3 属性一览
| 属性 | 类型 | 说明 |
|---|---|---|
.onLoad |
Function | undefined |
全部条目加载完成时执行,默认 undefined |
.onProgress |
Function | undefined |
单个条目加载完成时执行,默认 undefined |
.onError |
Function | undefined |
出错时执行,默认 undefined |
.onStart |
Function | undefined |
首个条目开始加载时执行,默认 undefined(仅当第一项启动时触发一次) |
.abortController |
AbortController |
用于取消使用该 manager 的 loader 正在进行的请求(懒创建 getter,见下) |
其中 abortController 是定义在类上的 getter(源码),首次访问时才会创建真实的 AbortController 实例并缓存在私有字段 _abortController 中。源码注释提到这一写法是为规避 Cloudflare workerd(issue #3657)的限制。
四、Loader 与 Manager 的协作机制:itemStart / itemEnd / itemError
4.1 三个方法的作用
.itemStart( url ):loader 开始加载一个条目时调用。会使itemsTotal++,并在"首个条目"时触发onStart。.itemEnd( url ):loader 加载完一个条目时调用。会使itemsLoaded++,触发onProgress;若此时所有条目都已结束,则触发onLoad并复位isLoading。.itemError( url ):loader 加载出错时调用,触发onError。
文档将其定位为"任何使用该 manager 的 loader 在对应时刻都应调用"的通知接口。以底层文件加载器 src/loaders/FileLoader.js 为例,它在真实网络流程中的调用顺序为:
- 请求发出前调用
this.manager.itemStart( url ); - fetch 成功 → 触发各自的
onLoad回调后,在.finally()中调用this.manager.itemEnd( url ); - fetch 失败 → 触发
onError后调用this.manager.itemError( url ),随后依然由.finally()调用itemEnd。
由于 itemStart/itemEnd 一一配对,即便中途失败,计数器也能正确闭合,onLoad 不会因为个别失败而永远不触发。
4.2 多个 loader 共享计数的意义
因为 OBJLoader、ColladaLoader 乃至更底层的 FileLoader、ImageLoader、ImageBitmapLoader 都会透传同一个 manager,所以一次模型加载往往会在内部展开成"主文件 + 若干纹理 + 若干缓冲区"等多个条目,全部汇聚到 manager 的计数中——这正是它能驱动整页加载进度条的原因。
有几点需要留意:
- 进度事件按"条目"而非"字节"统计,
itemsLoaded是完成条目数而非真实下载量;如需要精确到字节的进度,可参考 FileLoader 内部基于ReadableStream读取与Content-Length解析出的逐字节ProgressEvent(src/loaders/FileLoader.js),它只传给 loader 自己的onProgress回调,不进入 manager。 TextureLoader自 r84 起已不支持 per-texture 的onProgress(见 src/loaders/TextureLoader.js 注释),但纹理仍会计入 manager 的条目统计中,manager.onProgress不受影响。- 个别 loader 会做防御性处理:例如 AudioLoader 会在解码阶段再次调用
itemStart,以避免"加载管理器过早完成"(源码注释提及 issue #33378)。
五、文件类型分发:addHandler / removeHandler / getHandler
5.1 把特定扩展名路由给自定义 loader
.addHandler( regex, loader ) 注册一个"正则表达式 → loader"的映射,用于决定某类文件应该由哪个 loader 加载。典型场景是覆盖纹理的默认加载器。原文档示例:
// 为 .tga 纹理注册专用加载器
manager.addHandler( /\.tga$/i, new TGALoader() );
.removeHandler( regex ) 则按正则反注册:
manager.removeHandler( /\.tga$/i );
.getHandler( file ) 按文件路径查询当前命中的 loader;未命中任何规则时返回 null。
三者均返回/引用 manager(removeHandler 与 getHandler 返回 null/loader 除外),因此支持链式调用。
5.2 源码实现要点
- 内部用扁平数组交替存放
[regex0, loader0, regex1, loader1, ...]; removeHandler通过handlers.indexOf( regex )定位索引,找到后splice( index, 2 )一次删除"正则 + loader"两项;getHandler按注册顺序线性遍历并执行regex.test( file ),返回第一个匹配的 loader;- 有一个易踩的坑:若传入的正则带有
g(global)标志,test()会受lastIndex状态影响,因此源码在每次测试前强制regex.lastIndex = 0(见 getHandler 实现,注释关联 issue #17920)。
5.3 实际消费方:GLTFLoader
addHandler/getHandler 是"生产者与消费者"配套设计。以 glTF 加载为例,examples/jsm/loaders/GLTFLoader.js 在处理图片资源时:
let loader = parser.textureLoader;
if ( source.uri ) {
const handler = parser.options.manager.getHandler( source.uri );
if ( handler !== null ) loader = handler;
}
即:先用 getHandler 查询管理器里有没有针对该 URI 注册过专用 loader,命中则改用该 loader 加载纹理。因此如果你给 manager 注册了 KTX2Loader 之类的压缩纹理处理器,加载含 .ktx2 图片的 glTF 时就能自动走压缩纹理通道,而无需改 GLTFLoader 自身的调用代码。
六、URL 改写:setURLModifier 与 resolveURL
6.1 何时需要 URL 改写
.setURLModifier( transform ) 允许在任何请求发出前,把每个资源 URL 交给回调 transform 处理一次。回调可以返回原 URL,也可以返回一个新 URL 来整体改写加载行为。文档列举的典型场景包括:从 .ZIP 压缩包内加载资源、对接拖放(drag-and-drop)API、以及使用 Data URI / Blob URL 等不能直接作为常规路径的资源来源。
6.2 配合 Blob URL 的官方示例
把 Blob 集合映射为 URL.createObjectURL() 生成的临时对象 URL,加载完成后统一释放,是文档给出的一段可以直接运行的完整代码:
const blobs = { 'fish.gltf': blob1, 'diffuse.png': blob2, 'normal.png': blob3 };
const manager = new THREE.LoadingManager();
// 用 URL 回调初始化 manager
const objectURLs = [];
manager.setURLModifier( ( url ) => {
url = URL.createObjectURL( blobs[ url ] );
objectURLs.push( url );
return url;
} );
// 照常加载,随后释放这些 blob URL
const loader = new GLTFLoader( manager );
loader.load( 'fish.gltf', ( gltf ) => {
scene.add( gltf.scene );
objectURLs.forEach( ( url ) => URL.revokeObjectURL( url ) );
} );
要点拆解:
- 回调入参是 loader 想要请求的"逻辑文件名"(如
'fish.gltf'),返回值才是真正会发出的请求地址; URL.createObjectURL()生成的临时地址必须在资源用完后通过URL.revokeObjectURL()手动释放,否则会持续占用内存,这是示例最后集中 revoke 的原因;- 同样的机制也适用于"拖入浏览器窗口的文件列表"场景:把
File对象按文件名放入映射,即可让 glTF 及其引用纹理全部从拖放文件解析。
6.3 底层行为:resolveURL 与 NFC 归一化
.resolveURL( url ) 是 URL 改写落地的接口——每个 loader 在真正发起请求前都会先调用它。例如 FileLoader.load 与 ObjectLoader 中均有 url = this.manager.resolveURL( url ) 的调用。
值得注意的源码细节:在 resolveURL 实现 中,URL 首先会被执行一次 url.normalize( 'NFC' )。注释说明这是为了将 Unicode URI(例如来自 glTF 的非 ASCII 文件名)按 RFC 3987 规范正确地做百分号编码。若设置了 urlModifier 则调用它,否则原样返回 URL。
七、请求取消:abort 与 abortController
7.1 方法签名与前提
manager.abort() // 返回 manager 本身,便于链式调用
从 abort 实现 可以看到它做的事很简单:this.abortController.abort() 再清空内部控制器引用。真正的取消能力来自每个请求持有的 AbortSignal。
文档给出两个生效前提,缺一不可:
- 具体 loader 实现了
Loader#abort()(基类方法默认空实现,见 Loader 的 abort 定义,返回this但什么都不做;可查阅文档页 Loader.html 了解各 loader 的覆盖情况); - 浏览器支持
AbortSignal.any()。
7.2 两层信号如何合并
为什么调用 manager.abort() 能取消"所有使用该 manager 的 loader"的请求?关键在于 FileLoader 构造请求时:
const req = new Request( url, {
...
signal: ( typeof AbortSignal.any === 'function' )
? AbortSignal.any( [ this._abortController.signal, this.manager.abortController.signal ] )
: this._abortController.signal
} );
它把loader 私有的信号与 manager 共享的信号通过 AbortSignal.any() 合并成一个信号传给 fetch。于是两种取消入口都生效:
- 调用单个 loader 的
loader.abort()→ 只取消该 loader 的请求; - 调用
manager.abort()→ 同时取消所有以该 manager 构造的 loader 的进行中请求。
同样模式也存在于 ImageBitmapLoader。注意 AbortSignal.any() 是较新的 Web 标准能力,在不支持的环境下代码自动退化为只使用 loader 自身信号(manager 级别的广播取消将不可用),这正是文档强调该前提的原因。
八、完整实战:一个带进度的多资源加载流程
综合以上知识,一个可运行的整体示例(把模型、纹理、字体统一纳入进度统计,并演示全部四个生命周期回调):
import { LoadingManager, GLTFLoader } from 'three';
import { TGALoader } from './examples/jsm/loaders/TGALoader.js';
const manager = new LoadingManager();
manager.onStart = ( url, loaded, total ) => {
console.log( `开始加载第 ${loaded + 1}/${total} 项:${url}` );
};
manager.onProgress = ( url, loaded, total ) => {
const percent = total ? ( loaded / total * 100 ).toFixed( 1 ) : 0;
document.querySelector( '#bar' ).style.width = `${percent}%`;
console.log( `进度:${percent}%(${loaded}/${total})` );
};
manager.onLoad = () => {
console.log( '全部加载完成,可以开始渲染场景!' );
// 例如:startAnimation();
};
manager.onError = ( url ) => {
console.error( `加载失败:${url}` );
};
// 覆盖纹理默认加载器:.tga 交给 TGALoader
manager.addHandler( /\.tga$/i, new TGALoader() );
// 共享同一 manager
const modelLoader = new GLTFLoader( manager );
const texLoader = new THREE.TextureLoader( manager );
modelLoader.load( 'models/robot.glb', ( gltf ) => scene.add( gltf.scene ) );
texLoader.load( 'textures/robot_diffuse.tga' );
// 需要中断时:manager.abort();
当模型引用了多张贴图、多个 buffer 时,这些子资源同样会经由 resolveURL、getHandler 与计数通知流入该 manager,进度条因此能如实反映"从网络发出第一字节到最后一块 buffer 落地"的完整过程。
九、小结与易错点提示
围绕 LoadingManager 文档页 的主题,快速回顾关键结论:
- 计数机制:进度由"条目数"驱动,
onStart仅在第一项开始时触发一次;onProgress(url, loaded, total)提供可计算的进度;onLoad在所有itemStart/itemEnd配对完成后触发。 - 共享 vs 独立:不传 manager 时默认共享
DefaultLoadingManager(src/loaders/LoadingManager.js);需要分类进度条或分类控制时就各自 new 一个。 - 文件分发:
addHandler+getHandler配对使用,注意带g标志正则的lastIndex复位问题(源码已内部处理,自定义时尽量避免使用 global 标志);GLTFLoader 等加载器会按 URI 查询 handler 决定纹理加载器。 - URL 改写:
setURLModifier作用于每次请求前,resolveURL会先做 NFC 归一化再交由回调处理;从 Blob/拖放/.ZIP 取资源时必须记得revokeObjectURL释放。 - 取消加载:
manager.abort()需要 loader 实现abort()且环境支持AbortSignal.any(),否则仅能单 loader 取消。
理解这套"Loader 上报、Manager 统计、回调驱动"的协作契约后,无论是做全屏加载遮罩、分资源类型进度条、压缩纹理自动路由,还是拖放/内存资源加载与中途取消,都可以在 API 层轻松实现。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00