修改并行上传

This commit is contained in:
itcsx committed 2026-09-22 11:06:15 +08:00
1 parent 9cf69dee5d
commit eb107db8c5
5 files changed
+194 -76

No files matched your search

+141 -50
View File
@@ -71,7 +71,10 @@ const props = withDefaults(
showButton?: boolean;
/** 默认上传按钮文案 */
buttonText?: string;
/** 分片大小(字节) */
/**
* 分片大小(字节)。**不传时按网络自动**:
* 内网(localhost/IP,走并发)3MB、公网(域名,走串行)1MB。
*/
chunkSize?: number;
/** 请求超时时间(毫秒) */
timeout?: number;
@@ -79,6 +82,13 @@ const props = withDefaults(
retryCount?: number;
/** 重试的初始等待毫秒数,之后按 1x、2x、4x 递增 */
retryDelay?: number;
/**
* 是否并发上传分片(最后一片仍然串行,见下方说明)。
* 不传时自动判断:访问地址是 localhost / IP(内网)就并发,域名(公网)就串行。
*/
parallel?: boolean;
/** 并发上传时的并行数,默认 4 */
concurrency?: number;
}>(),
{
action: "",
@@ -89,10 +99,13 @@ const props = withDefaults(
disabled: false,
showButton: false,
buttonText: "上传文件",
chunkSize: 1024 * 1024,
// 不传时由 resolveChunkSize() 按内网/公网自动决定(内网 3MB / 公网 1MB)
chunkSize: undefined,
timeout: 60000,
retryCount: 3,
retryDelay: 1000,
parallel: undefined,
concurrency: 4,
}
);
@@ -134,7 +147,7 @@ const resolvePath = async (): Promise<string> => {
/** 文件分片 */
const splitFile = (file: File): Blob[] => {
const chunks: Blob[] = [];
const size = props.chunkSize > 0 ? props.chunkSize : 1024 * 1024;
const size = resolveChunkSize();
let start = 0;
while (start < file.size) {
const end = Math.min(start + size, file.size);
@@ -157,7 +170,7 @@ const buildChunkName = (
return `${uid}-${index}-${total}${fileExtension ? "." + fileExtension : ""}`;
};
/** 分片上传(顺序上传) */
/** 分片上传 */
/** 计算进度需要的上下文:多文件时要能算到「整体」而不是单个文件内 */
interface ChunkProgress {
/** 当前第几个文件,从 1 开始 */
@@ -170,8 +183,8 @@ interface ChunkProgress {
totalBytes: number;
/** 当前文件的大小 */
fileSize: number;
/** 每成功一片回调,用来记续传位置 */
onChunkDone?: (index: number) => void;
/** 已完成的分片序号(从 1 开始)。并发上传时顺序是乱的,用它记账、也用它续传 */
done: Set<number>;
}
/** 等待指定毫秒 */
@@ -229,7 +242,33 @@ const requestChunk = async (
throw lastError;
};
const uploadChunk = async (
/** 按已完成分片数估算整体百分比(按字节算,多文件时不会每个文件退回 0) */
const calcPercent = (progress: ChunkProgress, total: number) => {
const chunkSize = resolveChunkSize();
if (progress.totalBytes > 0) {
const sent =
progress.baseBytes + Math.min(progress.done.size * chunkSize, progress.fileSize);
return Math.min(100, Math.round((sent / progress.totalBytes) * 100));
}
return total > 0 ? Math.round((progress.done.size / total) * 100) : 0;
};
/** 上报进度:单文件、多文件统一用百分比 */
const emitProgress = (progress: ChunkProgress, total: number) => {
const percent = calcPercent(progress, total);
emit("progress", {
percent,
current: progress.done.size,
total,
text:
progress.fileCount > 1
? `上传中(第 ${progress.fileNo}/${progress.fileCount} 个) ${percent}%`
: `上传中 ${percent}%`,
});
};
/** 传一片并记账 */
const sendChunk = async (
chunks: Blob[],
fileName: string,
uid: string,
@@ -237,49 +276,103 @@ const uploadChunk = async (
path: string,
progress: ChunkProgress
): Promise<any> => {
const chunkSize = props.chunkSize > 0 ? props.chunkSize : 1024 * 1024;
const response = await requestChunk(
chunks,
fileName,
uid,
index,
path,
calcPercent(progress, chunks.length)
);
progress.done.add(index);
emitProgress(progress, chunks.length);
return response;
};
// 这片还没传时的进度:重试期间保持不动,不让进度条倒退
const sentBefore = progress.baseBytes + Math.min((index - 1) * chunkSize, progress.fileSize);
const percentBefore =
progress.totalBytes > 0
? Math.min(100, Math.round((sentBefore / progress.totalBytes) * 100))
: Math.round(((index - 1) / chunks.length) * 100);
/**
* 内网判定:localhost / 回环地址 / 纯 IP(IPv4、IPv6)。
* 内网带宽足、延迟低,分片可以并发传;公网仍保持串行,免得占满上行带宽、也给服务器留点余量。
*/
const isIntranetHost = (): boolean => {
const host = window.location.hostname;
if (!host) return false;
if (host === "localhost" || host.endsWith(".localhost")) return true;
// IPv4
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true;
// IPv6(形如 [::1] 或 ::1,必须带冒号,避免把普通域名当成 IP)
if (host.includes(":") && /^[0-9a-f:.]+$/i.test(host.replace(/[[\]]/g, ""))) return true;
return false;
};
const response = await requestChunk(chunks, fileName, uid, index, path, percentBefore);
/** 是否并发上传:显式传了 parallel 就用它,否则按访问地址自动判断 */
const isParallelUpload = () => props.parallel ?? isIntranetHost();
// 整体进度按字节算:多文件时进度条一路向前,不会每个文件都退回 0
const sentBytes = progress.baseBytes + Math.min(index * chunkSize, progress.fileSize);
const percent =
progress.totalBytes > 0
? Math.min(100, Math.round((sentBytes / progress.totalBytes) * 100))
: Math.round((index / chunks.length) * 100);
/**
* 实际使用的分片大小:调用方显式传了 chunkSize 就用它的,否则按网络自动选。
* 内网走并发:3MB,请求数降到 1/3,省掉每请求的固定开销(MVC 管道 + 事务 + 写盘);
* 公网走串行:1MB,重试与续传的粒度小,网络抖动时代价低。
*/
const resolveChunkSize = () => {
const size = props.chunkSize;
if (typeof size === "number" && size > 0) return size;
return isParallelUpload() ? 3 * 1024 * 1024 : 1024 * 1024;
};
// 这一片已经落到服务器上了,记下位置,中断后从这里之后接着传
progress.onChunkDone?.(index);
/**
* 上传一个文件的全部分片。
*
* 关键点:**最后一片必须最后传**——服务端是收到最后一片才合并分片、建文件记录的,
* 提前发过去会拿到不完整的文件。所以无论并发还是串行,前面的分片传完才发最后一片。
* 续传时按 done 里已完成的序号跳过,乱序完成也能对得上。
*/
const uploadChunks = async (
chunks: Blob[],
fileName: string,
uid: string,
path: string,
progress: ChunkProgress
): Promise<any> => {
const total = chunks.length;
if (total === 0) return null;
emit("progress", {
percent,
current: index,
total: chunks.length,
// 单文件、多文件都统一用百分比(按字节算的整体进度),不再显示「第几片/共几片」
text:
progress.fileCount > 1
? `上传中(第 ${progress.fileNo}/${progress.fileCount} 个) ${percent}%`
: `上传中 ${percent}%`,
});
if (index < chunks.length) {
return await uploadChunk(chunks, fileName, uid, index + 1, path, progress);
const pending: number[] = [];
for (let i = 1; i <= total - 1; i++) {
if (!progress.done.has(i)) pending.push(i);
}
return response.data;
let lastResponse: any = null;
if (isParallelUpload()) {
// 并发:固定并发数,避免一次把上百个请求全打出去
let cursor = 0;
const worker = async () => {
while (cursor < pending.length) {
const index = pending[cursor++];
await sendChunk(chunks, fileName, uid, index, path, progress);
}
};
const workers = Math.min(Math.max(1, props.concurrency), pending.length);
await Promise.all(Array.from({ length: workers }, () => worker()));
} else {
// 串行:上一片成功后才发下一片
for (const index of pending) {
await sendChunk(chunks, fileName, uid, index, path, progress);
}
}
// 最后一片:等前面全部完成后再发
if (!progress.done.has(total)) {
lastResponse = await sendChunk(chunks, fileName, uid, total, path, progress);
}
return lastResponse?.data;
};
/**
* 一次上传的断点状态。
*
* 只要不刷新页面,文件、切好的分片和 uid 都还在内存里,
* 中断时记住「下一个要传第几片」,续传就能接着发,不必从头再来。
* 中断时记住「哪些分片已经传完」,续传只补没传完的,不必从头再来。
* 刷新页面就丢了——这是浏览器的限制,文件对象无法持久化。
*/
interface UploadSession {
@@ -294,8 +387,8 @@ interface UploadSession {
chunks?: Blob[];
/** 当前文件的 uid,续传时必须沿用,否则对不上服务器上的分片 */
uid?: string;
/** 当前文件下一个要传的分片序号 */
nextChunk: number;
/** 当前文件已完成的分片序号(从 1 开始),并发时顺序是乱的,只能按集合记 */
done: Set<number>;
}
/** 未完成的上传会话,null 表示没有待续传的任务 */
@@ -319,21 +412,19 @@ const runSession = async (s: UploadSession): Promise<void> => {
if (chunks.length === 0) throw new Error("无法读取文件内容");
s.chunks = chunks;
s.uid = uuidv4();
s.nextChunk = 1;
s.done = new Set<number>();
}
const chunks = s.chunks;
const uid = s.uid;
const response = await uploadChunk(chunks, file.name, uid, s.nextChunk, s.path, {
const response = await uploadChunks(chunks, file.name, uid, s.path, {
fileNo: s.fileIndex + 1,
fileCount: s.files.length,
baseBytes: s.doneBytes,
totalBytes: s.totalBytes,
fileSize: file.size || 0,
onChunkDone: (index) => {
s.nextChunk = index + 1;
},
done: s.done,
});
// 返回分割后的最后一个(序号最大的)分片文件名
@@ -345,7 +436,7 @@ const runSession = async (s: UploadSession): Promise<void> => {
s.fileIndex += 1;
s.chunks = undefined;
s.uid = undefined;
s.nextChunk = 1;
s.done = new Set<number>();
}
session.value = null;
@@ -355,7 +446,7 @@ const runSession = async (s: UploadSession): Promise<void> => {
const file = s.files[s.fileIndex];
emit("interrupted", {
fileName: file?.name || "",
uploaded: Math.max(0, s.nextChunk - 1),
uploaded: s.done?.size ?? 0,
total: s.chunks?.length ?? 0,
});
emit("error", error);
@@ -366,8 +457,8 @@ const runSession = async (s: UploadSession): Promise<void> => {
};
/**
* 上传一批文件:按顺序一个一个传,不做并发,
* 免得后端同时收到多个分片流。多选时所有文件进同一个目录。
* 上传一批文件:多选时逐个文件上传(每个文件内部再按并发/串行切分片),
* 所有文件进同一个目录。
*/
const uploadFiles = async (files: File[]): Promise<void> => {
const list = (files || []).filter((f) => f && typeof f.size === "number");
@@ -382,7 +473,7 @@ const uploadFiles = async (files: File[]): Promise<void> => {
totalBytes: list.reduce((sum, f) => sum + (f.size || 0), 0),
doneBytes: 0,
fileIndex: 0,
nextChunk: 1,
done: new Set<number>(),
});
} catch (error) {
emit("error", error);