查看器取消时一并中止在途的模型下载
- 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:
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user