使用场景
在日常开发中会遇到头像上传,文件上传,视频上传等场景,若是文件过大(尤其是视频),中途一旦出现网络卡顿,就需要重新上传这个文件,对用户体验造成不好的影响,所以对于数据量大的文件,需要采用断点续传。
概念
断点续传和秒传都是基于分片上传扩展的功能
分片上传原理:客户端将选择的文件进行切分,每个分片都单独发送请求到服务端 断点续传&秒传:客户端发送请求询问服务端某文件的上传状态,服务端返回已上传的分片索引,客户端再判断是否需要继续上传:
- 如果没有需要上传的分片就是秒传
- 如果有需要上传的分片就是断点续传
每个文件都有自己唯一的标识,这个标识就是将整个文件进行MD5加密,这是个hash算法,将加密后的Hash值作为文件的唯一标识
- 使用spark-md5第三方工具库
文件合并: 当服务端确认分片都发送完成后,此时会发送请求通知服务端对文件进行合并操作
前端流程
- 第一步:将文件进行分片,并计算其Hash值(文件的唯一标识)
- 第二步:发送请求,询问服务端文件的上传状态
- 第三步:根据文件上传状态进行后续上传
- 文件已经上传过了
- 结束--秒传功能
- 文件存在,但分片不完整
- 将未上传的分片进行上传 --- 断点续传功能
- 文件不存在
- 将所有分片上传
- 文件已经上传过了
- 第四步:文件分片全部上传后,发送请求通知服务端合并文件分片
具体实现
const handleUpload = async () => {
if (!file.value) {
return ElMessage.error("请选择文件");
}
// 第一步:将文件进行切片,并计算整个文件的hash值
let chunks = createFileChunk(file.value);
// 优化一:hash值使用抽样
hash.value = await calculateHashSample(file.value);
// hash.value = await calculateHashWorker(chunks);
// 第二步发送请求,询问服务端文件的上传状态
const { uploaded, uploadedList } = await request.post("/check", {
ext: ext(file.value.name),
hash: hash.value,
});
if (uploaded) {
return ElMessage.success("秒传: 上传成功");
}
// 第三步: 根据文件上传状态进行后续上传
/**
* (1)文件已经上传过了:结束---秒传功能
* (2)文件存在,但分片不完整: 断点续传功能(计算未上传的分片序列并上传对应分片)
* (3)文件不存在: 将所有分片上传
*/
// 修改上传的数据类型,将所有的切片重新命名
chunksList.value = chunks.map((chunk, index) => {
// 每个切片的名字
const chunkName = hash.value + "-" + index;
return {
hash: hash.value,
chunk: chunk.file,
name: chunkName,
index,
// 进度条
progress: uploadedList.indexOf(chunkName) > -1 ? 100 : 0,
};
});
// 断点续传的功能
await uploadChunks(uploadedList);
文件分片
(1)确定每个分片的大小 1 * 1024 * 1024; (2)返回所有分片的集合
const CHUNK_SIZE = 1 * 1024 * 1024; // 1M
const createFileChunk = (file, size = CHUNK_SIZE) => {
const chunks = []
let cur = 0
while(cur < file.size) {
chunks.push({ index: cur, file: file.slice(cur, cur+size)})
cur += size
}
return chunks
}
计算整个文件的hash值
使用第三方库: spark-md5
hash策略:
取前两块分片+ 中间分片的前中后2个字节 + 最后分片偏移量
const calculateHashSample = async (file) => {
return new Promise((resolve)=> {
let spark = new sparkMd5.ArrayBuffer()
let fileReader = new FileReader()
// hash的优化值:取前两块分片
let offset = 2 * 1024 * 1024
// 前面取2个分片
let chunks = [file.slice(0, offset)]
let cur = offset
while(cur < file.size) {
// 最后一片加入
if(cur + offset >= file.size) {
chunks.push(file.slice(cur, cur + offset))
} else {
// 中间分片,前中后取2个字节
let mid = cur + offset/2
let end = cur + offset
chunks.push(file.slice(cur, cur + 2))
chunks.push(file.slice(mid, mid + 2))
chunks.push(file.slice(end - 2, end))
}
cur += offset
}
// 拼接在一起
fileReader.readAsArrayBuffer(new Blob(chunks))
fileReader.onload = (e) => {
// 将当前分块的结果追加到spark对象中
spark.append(e.target.result)
// 全部读取,获取文件的hash
resolve(spark.end())
}
})
}
分片上传
服务端返回上传成功的分片文件名uploadedList,由于请求不仅包含文件,还包含文件名以及hash值,请求体应该是formData
const uploadChunks = async (uploadedList = []) => {
const list = chunksList.value
.filter((chunk) => uploadedList.indexOf(chunk.name) == -1)
.map(({ hash, chunk, name, index }) => {
const form = new FormData();
form.append("chunkname", name);
form.append("hash", hash);
form.append("file", chunk);
form.append("ext", ext(file.value.name));
return { form, index, error: 0, progress: 0 };
});
try {
// 分片上传,错误4次结束进程
await sendRequest([...list], 4);
// 通知后端进行合并
if (list.length + uploadedList.length === chunksList.value.length) {
await mergeRequest();
}
} catch (e) {
ElMessage.error("上传出了点小问题");
}
};
前后端流程
优化
文件非常大时,计算hash值十分耗时,对于一个 2GB 大小的文件来说,即使是使用 MD5 算法来计算 Hash 值,也会造成浏览器的卡顿。 解决hash计算耗时问题:
- 使用Web Worker,不占用主线程资源(多线程,异步处理)
- 不一定非要hash整个文件,仅hash文件的第一个分片 + 中间分片的首尾n字节 + 最后一个分片
web Worker
const calculateHashWorker = async (chunks) => {
return new Promise((resolve) => {
// web-worker 防止卡顿主线程,创建文件分片worker
const worker = new Worker("/hash.js");
// 将文件通过postMessage发送给worker线程
worker.postMessage({ chunks });
// 分片处理完之后触发onmessage事件
worker.onmessage = (e) => {
// 获取处理结果
const { progress, hash } = e.data;
hashProgress.value = Number(progress.toFixed(2));
if (hash) {
resolve(hash);
}
};
});
};