查看器取消时一并中止在途的模型下载

- packages/model3d-viewer/src/loader.ts:readModel3dViewerSource 与 loadModel3dViewerObject 新增可选 AbortSignal,透传给 fetch;signal 已中止时抛 Model3dViewerAbortedError,不再当成 load-failed
- packages/model3d-viewer/src/scene.ts:场景输入新增 signal,创建时透传给模型加载
- packages/model3d-viewer/src/Model3DViewer.tsx:加载 effect 内建 AbortController,卸载 / 换源时先 abort 再释放场景,避免下载要读完 64 MiB 才被丢弃
- packages/model3d-viewer/src/loader.test.ts:新增回归用例,断言 signal 确实交给 fetch 且中止后抛取消错误

验证:npx vitest run packages/model3d-viewer(20 用例全绿)、npx tsc 严格模式检查查看器包全部源文件
This commit is contained in:
2026-09-22 13:18:40 +08:00
parent b71d18b166
commit fb9c1d0e29
4 changed files with 57 additions and 8 deletions
@@ -95,6 +95,8 @@ export function Model3DViewer({
let aborted = false;
let createdScene: Model3dViewerScene | null = null;
// 取消时连在途的模型下载一起停掉,不等字节读完才发现宿主已经走了。
const loadAbortController = new AbortController();
const activeSource: Model3DViewerSource = sourceData
? { kind: 'bytes', data: sourceData, format: sourceFormat }
: { kind: 'url', url: sourceUrl ?? '', format: sourceFormat };
@@ -107,6 +109,7 @@ export function Model3DViewer({
source: activeSource,
autoRotate: autoRotateRef.current,
isAborted: () => aborted,
signal: loadAbortController.signal,
})
.then((scene) => {
if (aborted) {
@@ -140,6 +143,7 @@ export function Model3DViewer({
return () => {
aborted = true;
loadAbortController.abort();
createdScene?.dispose();
if (sceneRef.current === createdScene) {
sceneRef.current = null;
@@ -4,6 +4,8 @@ import {
isModel3dViewerFormatSupported,
isModel3dViewerOverSize,
MODEL3D_VIEWER_MAX_MODEL_BYTES,
Model3dViewerAbortedError,
readModel3dViewerSource,
resolveModel3dViewerFormat,
resolveModel3dViewerFormatFromBytes,
resolveModel3dViewerFormatFromMimeType,
@@ -120,3 +122,31 @@ describe('模型格式判定', () => {
).toBeNull();
});
});
describe('模型下载可中止', () => {
it('把宿主的 signal 交给 fetch,中止后抛取消错误而不是加载失败', async () => {
const controller = new AbortController();
const originalFetch = globalThis.fetch;
const calls: Array<{ url: string; signal?: AbortSignal | null }> = [];
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ url: String(input), signal: init?.signal });
controller.abort();
return Promise.reject(new DOMException('aborted', 'AbortError'));
}) as typeof fetch;
try {
await expect(
readModel3dViewerSource(
{ kind: 'url', url: 'https://x/model.glb' },
controller.signal,
),
).rejects.toBeInstanceOf(Model3dViewerAbortedError);
} finally {
globalThis.fetch = originalFetch;
}
expect(calls).toEqual([
{ url: 'https://x/model.glb', signal: controller.signal },
]);
});
});
+13 -2
View File
@@ -197,9 +197,13 @@ function assertModel3dViewerByteLength(byteLength: number) {
/**
* 取模型字节与声明类型:url 走一次 fetch(顺带取响应的 `Content-Type`,它就是对象
* 自己的内容类型),bytes 直接复用调用方已读入的字节。
*
* 宿主给了 `signal` 时把它交给 fetch:宿主取消(卸载 / 换源)后立刻停掉在途下载,
* 不再把整份字节(上限 64 MiB)读完才判断是否已取消。
*/
export async function readModel3dViewerSource(
source: Model3DViewerSource,
signal?: AbortSignal,
): Promise<{ data: ArrayBuffer; declaredType: string | null }> {
if (source.kind === 'bytes') {
assertModel3dViewerByteLength(source.data.byteLength);
@@ -208,8 +212,11 @@ export async function readModel3dViewerSource(
let response: Response;
try {
response = await fetch(source.url);
response = await fetch(source.url, signal ? { signal } : undefined);
} catch (error) {
if (signal?.aborted) {
throw new Model3dViewerAbortedError();
}
throw new Model3dViewerLoadError(
'load-failed',
`模型地址不可读取:${describeModel3dViewerError(error)}`,
@@ -231,6 +238,9 @@ export async function readModel3dViewerSource(
try {
data = await response.arrayBuffer();
} catch (error) {
if (signal?.aborted) {
throw new Model3dViewerAbortedError();
}
throw new Model3dViewerLoadError(
'load-failed',
`模型数据读取失败:${describeModel3dViewerError(error)}`,
@@ -255,8 +265,9 @@ function resolveModel3dViewerBasePath(source: Model3DViewerSource): string {
export async function loadModel3dViewerObject(
source: Model3DViewerSource,
isAborted: () => boolean = () => false,
signal?: AbortSignal,
): Promise<ThreeTypes.Object3D> {
const { data, declaredType } = await readModel3dViewerSource(source);
const { data, declaredType } = await readModel3dViewerSource(source, signal);
if (isAborted()) {
throw new Model3dViewerAbortedError();
}
+10 -6
View File
@@ -40,6 +40,8 @@ export type Model3dViewerSceneInput = {
source: Model3DViewerSource;
autoRotate: boolean;
isAborted: () => boolean;
/** 宿主的中止信号:取消后连在途的模型下载一起停掉。 */
signal?: AbortSignal;
};
/** 释放模型与场景里所有几何、材质、贴图,避免反复开关时显存只涨不降。 */
@@ -258,12 +260,14 @@ export async function createModel3dViewerScene(
abortIfNeeded();
const model3d = await loadModel3dViewerObject(source, isAborted).catch(
(error: unknown) => {
teardown();
throw error;
},
);
const model3d = await loadModel3dViewerObject(
source,
isAborted,
input.signal,
).catch((error: unknown) => {
teardown();
throw error;
});
model = model3d;
scene.add(model3d);