思路
大文件上传的逻辑主要在于文件的分片和并发上传,在进行分片的时候我们要为文件添加md5哈希值以此来防止数据上传的数据丢失或者未上传,而计算md5是一个十分耗时并且cpu密集的任务,为了不占用浏览器主线程的资源我们可以利用js的多线程进行md5的计算,在文件分片完之后为了提高文件的上传效率我们可以试试并发上传
分片
在分片结束之后我们就可以对分片好的小文件进行上传,并发上传主要为了提升上传的效率,为了控制上传的时机可以定义一个变量count以便监控线程完成状态,作者这里是等所有线程完成之后再进行数据的上传
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>大文件上传</title>
</head>
<body>
<h2>选择文件</h2>
<form id="uploadForm">
<input type="file" name="myfile" id="fileInput">
<br><br>
<input type="submit" value="上传">
</form>
<p id="result"></p>
<script>
const CHUNK_SIZE = 1024 * 1024 * 5; // 5MB
const THREAD_COUNT = navigator.hardwareConcurrency || 4;
const form = document.getElementById('uploadForm');
const fileInput = document.getElementById('fileInput');
const result = document.getElementById('result');
form.addEventListener('submit', async function (event) {
event.preventDefault();
const file = fileInput.files[0];
if (!file) {
result.textContent = '请先选择文件';
return;
}
await cutFile(file);
result.textContent = '文件处理完成';
});
const cutFile = async (file) => {
const chunkCount = Math.ceil(file.size / CHUNK_SIZE);//一共需要分多少片
const threadChunkCount = Math.ceil(chunkCount / THREAD_COUNT);//每一个线程需要搞定多少个分片
const workerCount = Math.ceil(chunkCount / threadChunkCount);//会有多少个线程参与分片
let res = [];//分片结果
let count = 0;
for (let i = 0, index = 0; index < chunkCount; index += threadChunkCount, i++) {
//创建新线程
const worker = new Worker('./worker.js', {
type: 'module'
});
//需要考虑最后一个线程是否有threadChunkCount个片要分
worker.postMessage({
file,
CHUNK_SIZE,
startChunkIndex: i * threadChunkCount,
endChunkIndex: Math.min((i + 1) * threadChunkCount, chunkCount)
});
worker.onmessage = e => {
for (let j = 0; j < e.data.length; j++) {
res.push(e.data[j]); //将线程结果整合
}
count++;
//这里可以根据需求 到多少个片就上传 或者等到所有分片结束后上传
if (count === workerCount) {
console.log(res.length);
concurrentUpload(res);
}
worker.terminate();
}
}
}
</script>
</body>
</html>
worker.js
import SparkMD5 from 'https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/+esm';
onmessage = async (e) => {
const { file, CHUNK_SIZE, startChunkIndex, endChunkIndex } = e.data;
const promises = [];
for (let i = startChunkIndex; i < endChunkIndex; i++) {
promises.push(createChunk(file, i, CHUNK_SIZE));
}
const chunks = await Promise.all(promises);
postMessage(chunks);
}
const createChunk = async (file, index, chunkSize) => {
return new Promise((resolve) => {
const start = index * chunkSize;
const end = (index + 1) * chunkSize;
const spark = new SparkMD5.ArrayBuffer(); //创建md5 用于校验数据完整性
const fileReader = new FileReader();
const blob = file.slice(start, end);
fileReader.onload = (e) => {
spark.append(e.target.result);
resolve({
start,
end,
index,
hash: spark.end(),
blob
});
}
fileReader.readAsArrayBuffer(blob);
})
}
并发上传
我这里的并发上传思路是为利用局部变量控制同时上传的数量通过不断弹出队列的数据进行数据的上传(但是感觉遇到很大的文件会oom 这里有待优化) 并且根据promise的结果去处理再次上传的逻辑, 在这个代码里面我为每一个对象都添加了controller 方便控制上传的逻辑(没有实现 可以拓展)
const concurrentUpload = async (queue) => {
const maxRetries = 3;//最大重传次数
const CONCURRENT_NUM = 5;//并发上传数量
let activeCount = 0;
//为每个文件片添加controller方便控制上传
for (let i = 0; i < queue.length; i++) {
const controller = new AbortController();
queue[i].signal = controller.signal;
queue[i].retries = 0;
}
//当队列不为空并且未到并发最大数的时候可以上传
const runQueue = async () => {
while (queue.length > 0 && activeCount < CONCURRENT_NUM) {
const file = queue.shift();
activeCount++;
// 启动上传
uploadFile(file)
.then(result => {
console.log("上传文件成功");
})
.catch(error => {
console.error("上传失败", error);
if (file.retries < maxRetries) {
console.log(file, "再次上传"); //再次上传
queue.push(file);
}
})
.finally(() => {
activeCount--;
runQueue();
});
}
}
runQueue();
}
const uploadFile = async (file) => {
console.log("上传文件ing");
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 1000);
});
}
文件断点续传
作者这里断点续传的思路是在实际uploadFile之后根据后端返回的http响应码判断文件的上传状态,失败就尝试再次发送(这里可以定义一个最大的上传次数),当有分片超过最大上传的时候就停止上传,这里可以和后端协商去记录当前文件的md5 这样下次重新传输的时候就可以只传输未上传的部分了(秒传原理)
const uploadFile = async (file) => {
console.log("上传文件开始");
return new Promise((resolve, reject) => {
setTimeout(() => {
// 模拟50%的失败概率
const isSuccess = Math.random() < 0.5;
if (isSuccess) {
resolve({
status: 200
});
} else {
reject({
status: 500,
message: "服务器内部错误",
data: {
fileId: file.index
}
});
}
}, 1000);
});
}