From fb9c1d0e2961253b682ef8d8116beb61b03ca286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 13:18:40 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9F=A5=E7=9C=8B=E5=99=A8=E5=8F=96=E6=B6=88?= =?UTF-8?q?=E6=97=B6=E4=B8=80=E5=B9=B6=E4=B8=AD=E6=AD=A2=E5=9C=A8=E9=80=94?= =?UTF-8?q?=E7=9A=84=E6=A8=A1=E5=9E=8B=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 严格模式检查查看器包全部源文件 --- packages/model3d-viewer/src/Model3DViewer.tsx | 4 +++ packages/model3d-viewer/src/loader.test.ts | 30 +++++++++++++++++++ packages/model3d-viewer/src/loader.ts | 15 ++++++++-- packages/model3d-viewer/src/scene.ts | 16 ++++++---- 4 files changed, 57 insertions(+), 8 deletions(-) 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);