背景
当一个大文件只用一次请求发送给后端,传输时间就会非常的长,一旦请求传输过程中出现问题,比如网络断开了,就要把整个文件重新传输。
大文件分片上传就通过切片技术将大文件分割成小块,每个小块可以作为独立的文件上传。其次,使用哈希算法来唯一标识文件,确保文件上传的完整性和可恢复性。
files: 通过input标签读过来的文件对象
formData: 用于和后端传输的对象
FileReader: 异步读取文件内容
上传流程:
前后端完整的流程:
一、文件切片
对file对象进行切片,主要是使用file.slice(start, end)方法,start :分片的起始字节位置,end :分片的结束字节位置。切完片之后将每一片存储在Blod数组中返回。
const createChunks = (file: File): Blob[] => {
let cur = 0;
const chunks: Blob[] = [];
while (cur < file.size) {
const blob = file.slice(cur, cur + CHUNK_SIZE);
chunks.push(blob);
cur += CHUNK_SIZE;
}
return chunks;
};
二、文件Hash值计算
如果所有切片都完整的参与hash计算,那计算量会非常的大,可能造成页面卡死(可考虑放进web worker里面)。这里通过采样的方式对计算做了优化:第一个和最后一个切片,完整参与计算。 中间的切片,只取前、中、后各2字节,这样计算量就会少很多。注意:如果需要真正唯一的文件指纹,就要全量计算hash。
fileChunks.forEach((chunk, index) => {
if (index === 0 || index === fileChunks.length - 1) {
// 第一个和最后一个切片,完整参与计算
chunks.push(chunk);
} else {
// 中间切片,只取前、中、后各2字节
chunks.push(chunk.slice(0, 2));
chunks.push(chunk.slice(CHUNK_SIZE / 2, CHUNK_SIZE / 2 + 2));
chunks.push(chunk.slice(CHUNK_SIZE - 2, CHUNK_SIZE));
}
});
FileReader对象的readAsArrayBuffer方法可以读取二进制内容。在reader.onload回调中可以拿到读取的内容,再内容通过CRM已经有的CryptoJS 库实现文件的hash计算。由于读取内容的过程是异步的,所以要把hash计算过程放进Promise里面。
const reader = new FileReader();
reader.onload = (e) => {
md5.update(CryptoJS.lib.WordArray.create(e.target?.result as ArrayBuffer));
resolve(md5.finalize().toString(CryptoJS.enc.Hex));
};
reader.readAsArrayBuffer(new Blob(chunks));
完整的代码:
const calculateHash = async (fileChunks: Blob[]): Promise<string> => {
return new Promise((resolve) => {
const chunks: Blob[] = [];
const md5 = CryptoJS.algo.MD5.create();
fileChunks.forEach((chunk, index) => {
if (index === 0 || index === fileChunks.length - 1) {
// 第一个和最后一个切片,完整参与计算
chunks.push(chunk);
} else {
// 中间切片,只取前、中、后各2字节
chunks.push(chunk.slice(0, 2));
chunks.push(chunk.slice(CHUNK_SIZE / 2, CHUNK_SIZE / 2 + 2));
chunks.push(chunk.slice(CHUNK_SIZE - 2, CHUNK_SIZE));
}
});
const reader = new FileReader();
reader.onload = (e) => {
md5.update(CryptoJS.lib.WordArray.create(e.target?.result as ArrayBuffer));
resolve(md5.finalize().toString(CryptoJS.enc.Hex));
};
reader.readAsArrayBuffer(new Blob(chunks));
});
};
使用Web worker处理Hash计算
主线程和worker之间的通信是通过postmessage(data)方法,主线程和worker线程都是通过监听onmessage方法来接收对方传过来的数据。
一、新建处理hash计算的脚本文件
importScripts('https://cdn.bootcdn.net/crypto-js/4.2.0/crypto-js.min.js');
self.onmessage = (e) => {
// 接收主线程传过来的文件切片
const { fileChunks } = e.data;
try {
const md5 = CryptoJS.algo.MD5.create();
const reader = new FileReader();
reader.onload = (res) => {
md5.update(CryptoJS.lib.WordArray.create(res.target?.result as ArrayBuffer));
const hash = md5.finalize().toString(CryptoJS.enc.Hex);
// 把hash值计算的结果传回给主线程
self.postMessage({ success: true, hash });
};
reader.readAsArrayBuffer(new Blob(fileChunks));
} catch (error) {
self.postMessage({ success: false, error: error });
}
};
二、主线程创建worker线程,加载脚本,传递文件切片
const calculateHashByWorker = async (fileChunks: Blob[]): Promise<string> => {
return new Promise((resolve, reject) => {
// 创建worker线程,加载脚本
const worker = new Worker(new URL('./hashWorker.ts', import.meta.url));
// 发送数据到 Worker线程
worker.postMessage({ fileChunks });
// 接收worker线程计算好的hash值
worker.onmessage = (e) => {
// 关闭worker线程
worker.terminate();
if (e.data.success) {
resolve(e.data.hash);
} else {
reject(e.data.error);
}
};
});
};
三、发送上传切片请求(并发请求限制)
定义上传配置和带自动重试的上传函数。每个分片失败后最多重试 3 次,每次重试间隔递增(1s、2s、3s),避免瞬时重试压垮服务。成功则返回响应,彻底失败则抛出错误。
let index = 0;
const max = 5; // 最大并发数
const maxRetries = 3; // 最大重试次数
async function uploadWithRetry(chunk, partNumber, totalCount, hash, filename, retries = 0) {
const formData = new FormData();
formData.append('file', chunk);
formData.append('partNumber', String(partNumber));
formData.append('totalCount', String(totalCount));
formData.append('unicode', hash);
formData.append('filename', filename);
try {
return await axios.post('http://localhost:8080/BigFile/', formData, { timeout: 60000 });
} catch (error) {
if (retries < maxRetries) {
await new Promise(resolve => setTimeout(resolve, 1000 * (retries + 1)));
return uploadWithRetry(chunk, partNumber, totalCount, hash, filename, retries + 1);
}
throw error; // 超过重试次数,抛出错误
}
}
遍历所有分片,为每个分片创建一个上传任务(Promise),并立即加入任务池 taskPool。通过 .finally() 确保无论成功或最终失败,任务完成后都会从池中移除,防止内存泄漏或死锁。
const taskPool = [];
while (index < chunks.length) {
const partNumber = index + 1;
const task = uploadWithRetry(chunks[index], partNumber, chunks.length, hash, file.name);
task.finally(() => {
const idx = taskPool.indexOf(task);
if (idx !== -1) taskPool.splice(idx, 1);
});
taskPool.push(task);
当并发任务数达到上限(5 个)时,暂停启动新任务,等待任意一个任务完成(Promise.race 返回最快结束的任务)。这样始终保持最多 5 个请求在进行,实现平滑的并发控制。
if (taskPool.length >= max) {
await Promise.race(taskPool);
}
index++;
主循环结束后,等待剩余任务全部完成。如果所有分片都成功(包括重试后成功),则继续后续流程;只要有一个分片最终失败(超过重试次数),Promise.all 会 reject,整个上传失败。
await Promise.all(taskPool);
完整的代码:
let index = 0;
const max = 5; // 最大并发数
const maxRetries = 3; // 最大重试次数
// 带重试的上传函数(返回一个 Promise)
async function uploadWithRetry(
chunk: Blob,
partNumber: number,
totalCount: number,
hash: string,
filename: string,
retries = 0
): Promise<any> {
const formData = new FormData();
formData.append('file', chunk);
formData.append('partNumber', String(partNumber));
formData.append('totalCount', String(totalCount));
formData.append('unicode', hash);
formData.append('filename', filename);
try {
const res = await axios.post('http://localhost:8080/BigFile/', formData, {
timeout: 60000, // 可选:设置超时
});
return res;
} catch (error) {
if (retries < maxRetries) {
console.warn(`分片 ${partNumber} 上传失败,第 ${retries + 1} 次重试...`, error.message);
// 可选:指数退避延迟
await new Promise(resolve => setTimeout(resolve, 1000 * (retries + 1)));
return uploadWithRetry(chunk, partNumber, totalCount, hash, filename, retries + 1);
} else {
console.error(`分片 ${partNumber} 上传最终失败,已重试 ${maxRetries} 次`);
throw error; // 最终失败,抛出错误
}
}
}
// 任务池
const taskPool: Promise<any>[] = [];
while (index < chunks.length) {
const partNumber = index + 1;
// 创建带重试逻辑的任务(对外仍是一个 Promise)
const task = uploadWithRetry(
chunks[index],
partNumber,
chunks.length,
hash,
file.name
);
// ✅ 重要:无论成功/失败,完成后都从任务池移除
task.finally(() => {
const idx = taskPool.indexOf(task);
if (idx !== -1) {
taskPool.splice(idx, 1);
}
});
taskPool.push(task);
// 控制并发:当达到最大并发数,等待任意一个完成(包括失败)
if (taskPool.length >= max) {
await Promise.race(taskPool); // race 会等第一个 settled(fulfilled/rejected)的 Promise
}
index++;
}
// 等待所有任务完成(如果任何任务最终失败,这里会 throw)
await Promise.all(taskPool);