Skip to content
This repository was archived by the owner on Jul 3, 2026. It is now read-only.

Commit d1ccae4

Browse files
committed
优化大文件下载时的内存占用
1 parent 838937d commit d1ccae4

9 files changed

Lines changed: 874 additions & 90 deletions

File tree

src/hooks/useDownload.ts

Lines changed: 39 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,18 @@
88
*/
99

1010
import { useReducer, useCallback, useRef } from "react";
11-
import JSZip from "jszip";
1211
import { saveAs } from "file-saver";
1312
import type { DownloadState, DownloadAction, GitHubContent } from "@/types";
1413
import { GitHub } from "@/services/github";
1514
import { logger } from "@/utils";
15+
import { isAbortError } from "@/utils/network/abort";
1616
import { requestManager } from "@/utils/request/requestManager";
17+
import {
18+
downloadFolderAsZip,
19+
prepareZipOutputSink,
20+
type FolderDownloadEntry,
21+
type ZipOutputSink,
22+
} from "@/utils/download/folderZipPipeline";
1723
import { getForceServerProxy } from "@/services/github/config/ProxyForceManager";
1824
import { useI18n } from "@/contexts/I18nContext";
1925

@@ -216,7 +222,7 @@ export const useDownload = (
216222
const collectFiles = useCallback(
217223
async function collectFilesInner(
218224
folderPath: string,
219-
fileList: { path: string; url: string }[],
225+
fileList: FolderDownloadEntry[],
220226
basePath: string,
221227
signal: AbortSignal,
222228
): Promise<void> {
@@ -226,6 +232,7 @@ export const useDownload = (
226232
const contents = await requestManager.request(
227233
`download-folder-${folderPath}`,
228234
(requestSignal) => GitHub.Content.getContents(folderPath, requestSignal),
235+
{ signal },
229236
);
230237

231238
// 检查是否已取消 (ref可在异步期间被cancelDownload修改)
@@ -258,6 +265,7 @@ export const useDownload = (
258265
fileList.push({
259266
path: relativePath,
260267
url: downloadUrl,
268+
size: item.size,
261269
});
262270
} else {
263271
// 递归处理子文件夹 (type === 'dir')
@@ -297,95 +305,62 @@ export const useDownload = (
297305
// 创建新的AbortController
298306
abortControllerRef.current = new AbortController();
299307
const signal = abortControllerRef.current.signal;
308+
let outputSink: ZipOutputSink | null = null;
309+
let zipPipelineStarted = false;
300310

301311
try {
302-
const zip = new JSZip();
312+
outputSink = await prepareZipOutputSink({
313+
archiveName: `${folderName}.zip`,
314+
saveAsImpl: saveAs,
315+
});
303316

304317
// 递归获取文件夹内容
305-
const allFiles: { path: string; url: string }[] = [];
318+
const allFiles: FolderDownloadEntry[] = [];
306319
await collectFiles(path, allFiles, path, signal);
307320

308321
// 检查是否已取消 (ref可在异步期间被cancelDownload修改)
309322
if (hasBeenCancelled()) {
323+
await outputSink.abort();
310324
logger.info("文件夹下载已取消");
311325
return;
312326
}
313327

314328
dispatch({ type: "SET_TOTAL_FILES", count: allFiles.length });
315329
logger.info(`需要下载的文件总数: ${String(allFiles.length)}`);
316330

317-
// 下载并添加到zip
318331
let processedCount = 0;
319-
for (const file of allFiles) {
320-
try {
321-
// 检查是否已取消 (ref可在异步期间被cancelDownload修改)
322-
if (hasBeenCancelled()) {
323-
logger.info("文件夹下载已取消");
324-
return;
325-
}
326-
327-
const response = await fetch(file.url, { signal });
328-
329-
if (!response.ok) {
330-
logger.error(
331-
`文件 ${file.path} 下载失败:`,
332-
new Error(`下载失败: ${String(response.status)}`),
333-
);
334-
continue;
335-
}
336-
337-
const blob = await response.blob();
338-
zip.file(file.path, blob);
339-
340-
processedCount++;
341-
dispatch({ type: "SET_PROCESSING_FILES", count: processedCount });
332+
zipPipelineStarted = true;
333+
await downloadFolderAsZip({
334+
files: allFiles,
335+
signal,
336+
archiveName: `${folderName}.zip`,
337+
outputSink,
338+
onFileComplete: (count, total) => {
339+
processedCount = count;
340+
dispatch({ type: "SET_PROCESSING_FILES", count });
342341
dispatch({
343342
type: "SET_FOLDER_PROGRESS",
344-
progress: Math.round((processedCount / allFiles.length) * 100),
343+
progress: total > 0 ? Math.round((count / total) * 100) : 100,
345344
});
346-
} catch (e) {
347-
// 检查是否是取消导致的错误
348-
if (e instanceof Error && (e.name === "AbortError" || hasBeenCancelled())) {
349-
logger.info("文件夹下载已取消");
350-
return;
351-
}
352-
logger.error(`文件 ${file.path} 下载失败:`, e);
353-
}
354-
355-
// 检查是否已取消 (ref可在异步期间被cancelDownload修改)
356-
if (hasBeenCancelled()) {
357-
logger.info("文件夹下载已取消");
358-
return;
359-
}
360-
}
361-
362-
// 生成zip文件
363-
const zipBlob = await zip.generateAsync(
364-
{
365-
type: "blob",
366-
compression: "DEFLATE",
367-
compressionOptions: { level: 6 },
368345
},
369-
(metadata: { percent: number }) => {
370-
// 检查是否已取消 (ref可在异步期间被cancelDownload修改)
371-
if (hasBeenCancelled()) {
372-
return;
373-
}
374-
dispatch({ type: "SET_FOLDER_PROGRESS", progress: Math.round(metadata.percent) });
346+
onFileError: (file, error) => {
347+
logger.error(`文件 ${file.path} 下载失败:`, error);
375348
},
376-
);
349+
});
377350

378-
// 最后一次检查是否已取消 (ref可在异步期间被cancelDownload修改)
379-
if (hasBeenCancelled()) {
380-
logger.info("文件夹下载已取消");
381-
return;
351+
if (!hasBeenCancelled()) {
352+
dispatch({ type: "SET_PROCESSING_FILES", count: processedCount });
353+
dispatch({ type: "SET_FOLDER_PROGRESS", progress: 100 });
382354
}
383355

384-
saveAs(zipBlob, `${folderName}.zip`);
385356
logger.info(`文件夹下载完成: ${path}`);
386357
} catch (e: unknown) {
387358
const error = e as Error;
388-
if (error.name === "AbortError" || hasBeenCancelled()) {
359+
if (outputSink !== null && !zipPipelineStarted) {
360+
await outputSink.abort(error);
361+
}
362+
363+
if (isAbortError(error) || hasBeenCancelled()) {
389364
logger.info("文件夹下载已取消");
390365
} else {
391366
logger.error("下载文件夹失败:", error);

src/services/github/RequestBatcher.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2+
import { createAbortError } from "@/utils/network/abort";
23

34
vi.mock("@/utils", () => ({
45
logger: {
@@ -97,4 +98,20 @@ describe("RequestBatcher", () => {
9798
expect(firstResult).toEqual({ value: 1 });
9899
expect(secondResult).toEqual({ value: 1 });
99100
});
101+
102+
it("does not retry aborted requests", async () => {
103+
const batcher = new RequestBatcher();
104+
const executeRequest = vi.fn(async () => {
105+
throw createAbortError("Request aborted");
106+
});
107+
108+
await expect(
109+
batcher.enqueue("https://example.com/repos", executeRequest, {
110+
method: "GET",
111+
headers: { Accept: "application/json" },
112+
}),
113+
).rejects.toMatchObject({ name: "AbortError" });
114+
115+
expect(executeRequest).toHaveBeenCalledTimes(1);
116+
});
100117
});

src/services/github/RequestBatcher.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { logger } from "@/utils";
2+
import { isAbortError } from "@/utils/network/abort";
23
import { createTimeWheel } from "@/utils/data-structures/TimeWheel";
34
import type { TimeWheel } from "@/utils/data-structures/TimeWheel";
45

@@ -205,7 +206,15 @@ export class RequestBatcher {
205206

206207
// 如果是队列中的第一个请求,执行它
207208
if (isFirstRequest) {
208-
void this.executeWithRetry(requestKey, key, executeRequest, fingerprint, fingerprintCache);
209+
void this.executeWithRetry(
210+
requestKey,
211+
key,
212+
executeRequest,
213+
fingerprint,
214+
fingerprintCache,
215+
).catch(() => {
216+
// 请求结果会通过队列中的 promise 向调用方传递,这里只避免未处理 rejection。
217+
});
209218
}
210219
});
211220
}
@@ -257,6 +266,7 @@ export class RequestBatcher {
257266
const retryOptions: RetryOptions = {
258267
maxRetries: this.maxRetries,
259268
backoff: (attempt) => Math.min(1000 * Math.pow(2, attempt), 5000), // 指数退避,最大5秒
269+
shouldRetry: (error) => !isAbortError(error),
260270
onRetry: (attempt, error) => {
261271
const errorMessage = error instanceof Error ? error.message : String(error);
262272
logger.warn(
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2+
3+
import { createAbortError } from "@/utils/network/abort";
4+
5+
const { axiosGetMock } = vi.hoisted(() => ({
6+
axiosGetMock: vi.fn(),
7+
}));
8+
9+
vi.mock("axios", () => ({
10+
default: {
11+
get: axiosGetMock,
12+
},
13+
}));
14+
15+
vi.mock("@/utils", () => ({
16+
logger: {
17+
debug: vi.fn(),
18+
info: vi.fn(),
19+
warn: vi.fn(),
20+
error: vi.fn(),
21+
},
22+
}));
23+
24+
vi.mock("../../config", () => ({
25+
getForceServerProxy: vi.fn(() => false),
26+
shouldUseServerAPI: vi.fn(() => false),
27+
}));
28+
29+
vi.mock("../Auth", () => ({
30+
getAuthHeaders: vi.fn(() => ({ Authorization: "Bearer test-token" })),
31+
}));
32+
33+
vi.mock("../Config", () => ({
34+
USE_TOKEN_MODE: false,
35+
getApiUrl: vi.fn((path: string, branch: string) => `https://api.example.com/${branch}/${path}`),
36+
getCurrentBranch: vi.fn(() => "main"),
37+
}));
38+
39+
vi.mock("../../schemas", () => ({
40+
safeValidateGitHubContentsResponse: vi.fn((data: unknown) => ({ success: true, data })),
41+
filterAndNormalizeGitHubContents: vi.fn((data: unknown) => data),
42+
transformGitHubContentsResponse: vi.fn((data: unknown) => data),
43+
validateGitHubContentsArray: vi.fn(() => ({ isValid: true, invalidItems: [] })),
44+
}));
45+
46+
vi.mock("./cacheState", () => ({
47+
ensureCacheInitialized: vi.fn(async () => {}),
48+
getCachedDirectoryContents: vi.fn(async () => null),
49+
getCachedFileContent: vi.fn(async () => null),
50+
isCacheAvailable: vi.fn(() => false),
51+
storeDirectoryContents: vi.fn(async () => {}),
52+
storeFileContent: vi.fn(async () => {}),
53+
}));
54+
55+
vi.mock("./cacheKeys", () => ({
56+
buildContentsCacheKey: vi.fn((path: string, branch: string) => `${branch}:${path}`),
57+
}));
58+
59+
vi.mock("./hydrationStore", () => ({
60+
consumeHydratedDirectory: vi.fn(async () => null),
61+
consumeHydratedFile: vi.fn(async () => null),
62+
hydrateInitialContent: vi.fn(),
63+
INITIAL_CONTENT_EXCLUDE_FILES: [],
64+
}));
65+
66+
if (typeof window === "undefined") {
67+
vi.stubGlobal("window", globalThis);
68+
}
69+
70+
import { shouldUseServerAPI } from "../../config";
71+
const { clearBatcherCache, getContents } = await import("./service");
72+
73+
describe("content service abort handling", () => {
74+
beforeEach(() => {
75+
vi.clearAllMocks();
76+
vi.unstubAllGlobals();
77+
vi.stubGlobal("window", globalThis);
78+
clearBatcherCache();
79+
vi.mocked(shouldUseServerAPI).mockReturnValue(false);
80+
});
81+
82+
it("propagates abort to direct fetch requests", async () => {
83+
let fetchSignal: AbortSignal | undefined;
84+
let resolveFetchStarted: (() => void) | null = null;
85+
const fetchStarted = new Promise<void>((resolve) => {
86+
resolveFetchStarted = resolve;
87+
});
88+
const fetchMock = vi.fn((_: RequestInfo | URL, init?: RequestInit) => {
89+
fetchSignal = init?.signal;
90+
resolveFetchStarted?.();
91+
92+
return new Promise<Response>((_, reject) => {
93+
fetchSignal?.addEventListener("abort", () => reject(createAbortError("Request aborted")), {
94+
once: true,
95+
});
96+
});
97+
});
98+
const controller = new AbortController();
99+
vi.stubGlobal("fetch", fetchMock);
100+
101+
const promise = getContents("docs", controller.signal);
102+
await fetchStarted;
103+
controller.abort();
104+
105+
await expect(promise).rejects.toMatchObject({ name: "AbortError" });
106+
expect(fetchMock).toHaveBeenCalledTimes(1);
107+
expect(fetchSignal?.aborted).toBe(true);
108+
});
109+
110+
it("propagates abort to server proxy axios requests", async () => {
111+
vi.mocked(shouldUseServerAPI).mockReturnValue(true);
112+
const controller = new AbortController();
113+
let resolveAxiosStarted: (() => void) | null = null;
114+
const axiosStarted = new Promise<void>((resolve) => {
115+
resolveAxiosStarted = resolve;
116+
});
117+
118+
axiosGetMock.mockImplementationOnce(async (_url, config) => {
119+
const signal = config?.signal as AbortSignal | undefined;
120+
resolveAxiosStarted?.();
121+
122+
return new Promise((_, reject) => {
123+
signal?.addEventListener(
124+
"abort",
125+
() =>
126+
reject(
127+
Object.assign(new Error("canceled"), {
128+
name: "CanceledError",
129+
code: "ERR_CANCELED",
130+
}),
131+
),
132+
{ once: true },
133+
);
134+
});
135+
});
136+
137+
const promise = getContents("docs", controller.signal);
138+
await axiosStarted;
139+
controller.abort();
140+
141+
await expect(promise).rejects.toMatchObject({ name: "AbortError" });
142+
expect(axiosGetMock).toHaveBeenCalledWith(
143+
"/api/github?action=getContents&path=docs&branch=main",
144+
{
145+
signal: controller.signal,
146+
},
147+
);
148+
});
149+
});

0 commit comments

Comments
 (0)