大文件分片上传
内容
一般情况下,前端上传文件就是new FormData,然后把文件 append 进去,然后post发送给后端就完事了,但是文件越大,上传的文件也就越长,如果在上传过程中,突然网络故障,又或者请求超时,等待过久等等情况,就会导致错误而后又得重新传大文件。所以这时候就要使用分片上传了,就算断网了也能继续接着上传(断点上传),如果是之前上传过这个文件了(服务器还存着),就不需要做二次上传了(秒传)。
7.17实现方式
首先获取文件信息后设定分片大小对文件进行分片(slice函数),而后为文件生成一个hash对文件进行标注。在请求时先验证所上传的文件是否已经存在于服务器(若是则直接提示上传成功,即秒传功能),若不存在或部分存在则需要后端返回上传成功的分片标识数组,前端将使用成功分片数组与原文件分片数组进行处理得到未上传成功的分片,而后将未成功的分片以并发方式上传至后端。上传后即完成了整个文件的上传,向后端发送合并分片请求即完成大文件分片上传,断点续传功能。
7.19实现方式(即彻底完成功能)
步骤:
- 由于前端计算md5耗时过长,可能会导致页面卡死,因此考虑使用Web Worker来计算md5,即使用worker.js(使用spark-md5)文件来计算:分片数组,分片哈希数组,文件整体哈希。
- 在拿到web worker所得到的分片数组,分片哈希数组,文件整体哈希后,即可开始进行大文件上传工作,前端使用文件整体哈希、文件名以及分片数组作为请求参数调用后端/init接口初始化上传操作。【initSend函数】。
- 初始化操作完成后,前端使用文件整体哈希作为参数调用后端/status接口获取此文件分片的状态信息,前端根据后端所返回的状态信息使用filter以及every方法得到:文件是否已经上传的状态existFile、后端存在的文件分片existChunks。若existFile为true,则直接提示上传成功,即秒传功能。【verifyInfo函数】
- 若existFile为false,则使用后端返回的existChunks数组与分片数组进行对比,得到未上传成功的分片数组,并将其分片信息(分片哈希值,分片内容,分片序号以及文件整体哈希值)作为formData参数发送至后端/chunk接口(在发送分片时使用并发操作,并限制最大并发数为6)【uploadChunks函数】
- 当所有分片发送完成后,前端给后端以文件整体哈希做为参数调用后端/merge接口提示后端可以进行合并操作,后端返回成功消息即完成大文件分片上传,断点续传功能。【mergeFile函数】
演示图
-
选择文件:
-
秒传:
-
分片上传:
代码内容
在后续操作中,完成了对请求的封装以及分片上传的hook的编写,主处理逻辑部分仅仅为下方所示:
const submitUpload = () => {
const file = FileInfo.value;
if(!file) {
return;
}
fileName.value = file.name;
const { mainDeal } = useUpload(file, fileName.value)
mainDeal()
}
api的封装为下方所示:
import request from "@/utils/request";
export async function initSend(uploadId, fileName, totalChunk) {
return request({
url: "/init",
method: "POST",
data: {
uploadId,
fileName,
totalChunk,
},
});
}
export async function verifyInfo(uploadId) {
return request({
url: "/status",
method: "POST",
data: {
uploadId,
},
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
}
export async function uploadSingle(formData) {
return request({
url: "/chunk",
method: "POST",
data: {
formData,
},
headers: {
"Content-Type": "multipart/form-data",
},
});
}
export async function mergeFile(uploadId) {
return request({
url: "/merge",
method: "POST",
data: {
uploadId,
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
}
useUpload钩子函数为:
import { ref } from "vue";
import axios from "axios";
import type { AxiosResponse } from "axios";
import {
initSend,
verifyInfo,
mergeFile,
uploadSingle,
} from "@/apis/uploadApi";
export function useUpload(fileInfo, filename) {
const FileInfo = ref<File>(fileInfo);
const fileName = ref(filename); // 文件名称
const fileHash = ref(""); // 文件hash
const fileHashArr = ref([]);
const chunks = ref([]);
interface Verify {
id?: Number;
uploadId?: String;
chunkIndex?: Number;
status?: String;
}
const mainDeal = async () => {
const worker = new Worker(new URL("@/utils/worker.js", import.meta.url), {
type: "module",
});
const file = FileInfo.value;
console.log(worker);
console.log("file_info", file);
worker.postMessage({ file: file });
worker.onmessage = async (e) => {
const { data } = e;
chunks.value = data.fileChunkList;
fileHashArr.value = data.fileChunkHashList;
fileHash.value = data.fileMd5;
console.log("uploadid", fileHash.value);
const res_init = await initSend(
fileHash.value,
fileName.value,
chunks.value.length
);
console.log("res_init", res_init);
const { existFile, existChunks } = await verify(fileHash.value);
if (existFile) return;
uploadChunks(chunks.value, existChunks, fileHashArr.value);
worker.terminate();
};
};
// 控制请求并发
const concurRequest = (
taskPool: Array<() => Promise<Response>>,
max: number
): Promise<Array<Response | unknown>> => {
return new Promise((resolve) => {
if (taskPool.length === 0) {
resolve([]);
return;
}
console.log("taskPool", taskPool);
const results: Array<Response | unknown> = [];
let index = 0;
let count = 0;
console.log("results_before", results);
const request = async () => {
if (index === taskPool.length) return;
const i = index;
const task = taskPool[index];
index++;
try {
results[i] = await task();
console.log("results_try", results);
} catch (err) {
results[i] = err;
} finally {
count++;
if (count === taskPool.length) {
resolve(results);
}
request();
}
};
const times = Math.min(max, taskPool.length);
for (let i = 0; i < times; i++) {
request();
}
});
};
// 合并分片请求
const mergeRequest = async () => {
return mergeFile(fileHash.value);
};
// 上传文件分片
const uploadChunks = async (
chunks: Array<Blob>,
existChunks: Array<string>,
md5Arr: Array<string>
) => {
const formDatas = chunks
.map((chunk, index) => ({
fileHash: fileHash.value,
chunkHash: fileHash.value + "-" + index,
chunkIndex: index,
checksum: md5Arr[index],
chunk,
}))
.filter((item) => !existChunks.includes(item.chunkHash));
console.log("formDatas", formDatas);
const form_Datas = formDatas.map((item) => {
console.log("!", item.chunkIndex);
const formData = new FormData();
formData.append("uploadId", item.fileHash);
formData.append("chunkIndex", String(item.chunkIndex));
formData.append("checksum", item.checksum);
formData.append("file", item.chunk);
return formData;
});
console.log("formDatas", form_Datas);
const taskPool = form_Datas.map(
(formData) => () =>
fetch("http://10.184.131.57:8101/ferret/upload/chunk", {
method: "POST",
body: formData,
})
);
//控制请求并发
const response = await concurRequest(taskPool, 6);
console.log("response", response);
// 合并分片请求
const res_merge = await mergeRequest();
console.log("res_merge", res_merge);
};
// 校验文件、文件分片是否存在
const verify = async (uploadId: string) => {
const res = await verifyInfo(uploadId);
const { data } = res.data;
console.log("verify", res);
// 看服务器是不是已经有文件所有信息
const existFile = data.every((item: Verify) => item.status === "Uploaded");
const existChunks: string[] = [];
data.filter((item: Verify) => {
if (item.status === "Uploaded") {
existChunks.push(`${item.uploadId}-${item.chunkIndex}`);
}
});
console.log("existFile", existFile, "existChunks", existChunks);
return {
existFile,
existChunks,
};
};
return {
mainDeal,
};
}
web worker实现方式:
import SparkMD5 from 'spark-md5';
let DefaultChunkSize = 1024 * 1024 * 5; // 5MB
self.onmessage = (e) => {
console.log("!!>", e.data)
if (e.data.file.size >= 1024 * 1024 * 100 && e.data.file.size < 1024 * 1024 * 512) {
DefaultChunkSize = 1024 * 1024 * 10
}else if (e.data.file.size >= 1024 * 1024 * 512) {
DefaultChunkSize = 1024 * 1024 * 50
}
const { file, chunkSize = DefaultChunkSize } = e.data;
let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
chunks = Math.ceil(file.size / chunkSize),
currentChunk = 0,
spark = new SparkMD5.ArrayBuffer(),
fileChunkHashList = [],
fileChunkList = [],
fileReader = new FileReader();
loadNext();
function loadNext() {
let start = currentChunk * chunkSize,
end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
let chunk = blobSlice.call(file, start, end);
fileChunkList.push(chunk);
fileReader.readAsArrayBuffer(chunk);
}
function getChunkHash(e) {
const chunkSpark = new SparkMD5.ArrayBuffer();
chunkSpark.append(e.target.result);
fileChunkHashList.push(chunkSpark.end());
}
// 处理每一块的分片
fileReader.onload = function (e) {
spark.append(e.target.result);
currentChunk++;
getChunkHash(e)
if (currentChunk < chunks) {
loadNext();
} else {
// 计算完成后,返回结果
self.postMessage({
fileMd5: spark.end(),
fileChunkList,
fileChunkHashList,
});
fileReader.abort();
fileReader = null;
}
}
// 读取失败
fileReader.onerror = function () {
self.postMessage({
error: 'wrong'
});
}
};