主要记录一下多文件上传(一次请求)、分片和断点续传。
多文件上传一般可以通过两种方式实现,一是多请求,即发送多次上传请求,每次请求只携带一个文件;二是单请求,即一次请求携带所有需上传的文件。
下面主要介绍单请求方式的实现。相比于多次请求,单请求可以较简单的统计上传进度,简洁的传递额外参数(无需重复传递),但单请求需要更复杂的异常处理。
handleFileChange(event) {
console.log(event.target.files);
let postFiles = Array.prototype.slice.call(event.target.files);
this.uploadAction(postFiles);
},
uploadAction(files) {
const formData = new FormData();
files.forEach((file, index) => {
// 这里的参数名一定要和后端统一
// files 会是一个数组
formData.append("files", file);
// 如果文件需要区分不同文件,可以为文件设置不同的参数名
// 但一定要跟后端统一接收文件的参数名
// formData.append("files" + index, file);
});
const xhr = new XMLHttpRequest();
xhr.open("POST", "http://127.0.0.1:8888/multipleFile");
xhr.upload.onprogress = (event) => {
if (event.total > 0) {
this.percentage = parseInt(
(event.loaded / event.total) * 100,
10
);
}
};
xhr.send(formData);
// 可以用来取消上传
this.xhr = xhr;
},
const formidable = require('formidable');
const fs = require('fs');
// 保存文件
const saveFile = function (file) {
return new Promise((resolve, reject) => {
if (file && file.size > 0) {
// 组装文件的存储地址
const file_path = './public/' + new Date().getTime() + file.name;
// 为文件改名字,同时也改了存储位置
// 注意 rename 方法不支持跨盘移动,比如不能从 C 盘转存到 D 盘
fs.rename(file.path, file_path, (err) => {
if (err) {
reject({
name: file.name,
message: '文件保存失败',
status: 'failed',
error: err
});
} else {
resolve({
url: `http://127.0.0.1:8888${file_path.slice(1)}`,
name: file.name,
size: file.size,
status: 'success',
});
}
});
}
})
}
// 多文件上传,也支持单文件
const handleUpload = function (request, response) {
let form = new formidable.IncomingForm({
maxFileSize: 2 * 1024 * 1024 * 1024, // 文件最大字节
multiples: true, // 允许多文件
keepExtensions: true, // 保留文件后缀
allowEmptyFiles: false, // 不允许上传空文件
});
form.parse(request, (err, fields, files) => {
if (err) {
response.status(400).send({
message: '参数解析失败:' + err,
status: 400
});
return;
}
if (Array.isArray(files.files)) {
let saveFileProimseList = [];
files.files.forEach((file) => {
saveFileProimseList.push(saveFile(file));
})
// saveFileProimseList 中所有的 Promise 结束
Promise.allSettled(saveFileProimseList).then(results => {
let successful = [], failed = [];
results.forEach(result => {
if (result.status === 'rejected') {
failed.push(result.reason);
} else {
successful.push(result.value);
}
})
if (successful.length > 0) {
response.status(200).send({
successful,
failed,
message: `${successful.length} 个文件上传成功,${failed.length} 个文件上传失败`,
status: 200
});
} else {
response.status(400).send({
successful,
failed,
message: '文件上传失败',
status: 400
});
}
})
} else if (files.files) {
saveFile(files.files)
.then(res => {
response.status(200).send({
successful: [res],
failed: [],
message: '文件上传成功',
status: 200
});
})
.catch(err => {
response.status(400).send({
successful: [],
failed: [err],
message: '文件上传失败',
status: 400
});
})
} else {
response.status(400).send({
message: '未接收到文件',
status: 400
});
}
})
}
上传大文件的时候往往需要用到分片和断点续传。
所谓分片,就是将一个大的文件分割成一片片小的文件块,然后分别上传,服务端在接收到所有的文件块之后,再将其按照正确的次序合并成一个完成的文件。分片上传可以利用浏览器的并发请求,提升文件的上传速率。
断点续传是分片上传功能上的一个延伸,其原理是前端或后端记住已经上传的文件块,然后下次上传的时候过滤掉已经上传的,只上传未上传的块,已达到续传的目的。断点续传主要是为了避免大文件上传过程,意外或主动中断后,复传时文件块重复上传的问题。
File
对象继承自 Blob
对象,所以可以直接用 slice()
方法截取文件块,该方法返回一个新的Blob
对象。
createFileChunk(file, chunkSize = SIZE) {
let start = 0,
total = file.size,
chunkList = [];
while (start < total) {
chunkList.push(file.slice(start, start + chunkSize));
start += chunkSize;
}
return chunkList;
},
计算 hash 主要是为了区分文件,下面的代码将演示如何利用 spark-md5
计算文件的 hash。另外,计算大文件的 hash 是一个比较耗时的操作,为了避免页面的卡顿,可以使用工作者线程(web-worker)执行计算 hash 的操作。
hash.js
// web-worker
self.importScripts('spark-md5.min.js')
self.onmessage = e => {
// 接受主线程的通知
const { chunkList } = e.data;
const spark = new self.SparkMD5.ArrayBuffer();
let progress = 0, count = 0;
const loadNext = index => {
const reader = new FileReader();
reader.readAsArrayBuffer(chunkList[index].file)
reader.onload = e => {
// 累加器 不能依赖index,
count++;
// 增量计算 md5
spark.append(e.target.result);
if (count === chunkList.length) {
// 通知主线程,计算结束
self.postMessage({
progress: 100,
hash: spark.end()
});
} else {
// 每个区块计算结束,通知进度即可
progress += 100 / chunkList.length;
self.postMessage({ progress });
// 计算下一个
loadNext(count);
}
}
}
// 启动
loadNext(0);
}
主线程调用
async calculateHash(chunkList) {
return new Promise((resolve) => {
this.container.workder = new Worker("/hash.js");
this.container.workder.postMessage({ chunkList });
this.container.workder.onmessage = (e) => {
// 监听工作者线程发来的消息
const { progress, hash } = e.data;
this.hashProgress = Number(progress.toFixed(2));
if (hash) {
resolve(hash);
}
};
});
},
同一域名下的最大请求并发数是 6 个,超出的请求不会被发送,而是加入到请求队列中等待。大文件的分块是往往是较多的,如果不做并发控制,很容易会占满并发数,导致页面上的其他请求无法发送,数据无法刷新。
async sendRequest(urls, max = 4 /** 最大并发数 */) {
// 续传时,所有的块都已经上传了
if (urls.length === 0) return Promise.resolve();
return new Promise((resolve, reject) => {
const len = urls.length;
let counter = 0;
const start = async () => {
// 有请求,有通道
while (counter < len && max > 0) {
max--; // 占用通道
const req = urls.find((u) => u.status === Status.wait);
if (req === undefined) break;
req.status = Status.uploading;
const { form, index } = req;
request({
url: "/chunkUpload",
data: form,
onProgress: this.createProgresshandler(
this.chunkList[index]
),
requestList: this.requestList,
})
.then(() => {
req.status = Status.done;
max++; // 释放通道
counter++;
if (counter === len) {
console.log("resolve", "请求结束");
resolve();
} else {
start();
}
})
.catch(() => {
req.status = Status.error;
urls.forEach((item) => {
// 已经出错,未上传的就不要上传了
if (item.status === Status.wait) {
item.status = Status.error;
}
});
this.chunkList[index].progress = -1; // 报错的进度条
max++; // 释放当前占用的通道,但是counter不累加
// 还需终止其他的
return reject();
});
}
};
start();
});
},
整个上传流程如下:创建文件切片、计算文件 hash、获取已上传文件块列表、筛选出未上传的文件块并将其上传、请求合并文件。
// 判断文件是否存在,如果不存在,获取已经上传的切片
async verify(filename, hash) {
const data = await post("/chunkVerify", { filename, hash });
return data;
},
// 合并文件
async mergeRequest() {
await post("/chunkMerge", {
filename: this.container.file.name,
size: SIZE,
fileHash: this.container.hash,
});
},
async uploadChunks(uploadedList = []) {
const list = this.chunkList
// 筛选出还没有上传的文件块
.filter((chunk) => uploadedList.indexOf(chunk.hash) == -1)
.map(({ chunk, hash, index }) => {
const form = new FormData();
form.append("chunk", chunk);
form.append("hash", hash);
form.append("filename", this.container.file.name);
form.append("fileHash", this.container.hash);
return { form, index, status: Status.wait };
});
this.sendRequest(list, 4)
.then(async () => {
if (
uploadedList.length + list.length ===
this.chunkList.length
) {
// 上传和已经存在之和 等于全部的再合并
await this.mergeRequest();
}
})
.catch(() => {
// 上传有被reject的
console.log("亲 上传失败了,考虑重试下呦");
});
},
async handleUpload() {
if (!this.container.file) return;
// 创建切片
const chunkList = this.createFileChunk(this.container.file);
// 计算哈希
this.container.hash = await this.calculateHash(chunks);
const { uploaded, uploadedList } = await this.verify(
this.container.file.name,
this.container.hash
);
if (uploaded) {
return console.log("秒传:上传成功");
}
this.chunkList = chunkList.map((chunk, index) => {
const chunkName = this.container.hash + "-" + index;
return {
fileHash: this.container.hash,
chunk: chunk,
index,
hash: chunkName,
progress: uploadedList.indexOf(chunkName) > -1 ? 100 : 0,
size: chunk.size,
};
});
// 传入已经存在的切片清单
await this.uploadChunks(uploadedList);
},
通过取消请求就可以实现终止上传的功能,而恢复上传其实和续传一个道理,获取已上传的列表,然后将没有上传的文件块上传即可。
// 终止上传
handlePause() {
this.requestList.forEach((item) => {
item && item.abort();
});
this.requestList = [];
}
// 恢复上传
async handleResume() {
const { uploadedList } = await this.verify(
this.container.file.name,
this.container.hash
);
await this.uploadChunks(uploadedList);
},
参考:https://juejin.cn/post/6844904055819468808
demo: https://gitee.com/liuyuechuliu/file-upload