diff --git a/packages/model3d-viewer/src/Model3DViewer.tsx b/packages/model3d-viewer/src/Model3DViewer.tsx index b627d010b..c8755cf6f 100644 --- a/packages/model3d-viewer/src/Model3DViewer.tsx +++ b/packages/model3d-viewer/src/Model3DViewer.tsx @@ -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; diff --git a/packages/model3d-viewer/src/loader.test.ts b/packages/model3d-viewer/src/loader.test.ts index 810d7c889..3419e616a 100644 --- a/packages/model3d-viewer/src/loader.test.ts +++ b/packages/model3d-viewer/src/loader.test.ts @@ -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 }, + ]); + }); +}); diff --git a/packages/model3d-viewer/src/loader.ts b/packages/model3d-viewer/src/loader.ts index 92756f569..be4b376ed 100644 --- a/packages/model3d-viewer/src/loader.ts +++ b/packages/model3d-viewer/src/loader.ts @@ -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 { - const { data, declaredType } = await readModel3dViewerSource(source); + const { data, declaredType } = await readModel3dViewerSource(source, signal); if (isAborted()) { throw new Model3dViewerAbortedError(); } diff --git a/packages/model3d-viewer/src/scene.ts b/packages/model3d-viewer/src/scene.ts index f9fd1eab2..978c77467 100644 --- a/packages/model3d-viewer/src/scene.ts +++ b/packages/model3d-viewer/src/scene.ts @@ -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);