如果不做处理面临的问题
- 文件过大,用intpu type='file'上传的时候,是一次性加载进入内存,会内存溢出;
- 如果文件体积比较大,或者网络条件不好时,上传的时间会比较长(要传输更多的报文,丢包重传的概率也更大),用户不能刷新页面,只能耐心等待请求完成,遇到断网等情况,用户只能从头再来,体验特别不好;
需要解决的几个问题
- 文件的唯一性判断,如果用户上传的文件在服务器存在,则不用上传,实现秒传功能;
- 将文件分片,按片来发请求;
- 断点续传;
- 显示上传进度;
- 暂停、恢复上传;
文件切片
编码方式上传中,在前端我们只要先获取文件的二进制内容,然后对其内容进行拆分,最后将每个切片上传到服务端即可。 在JavaScript中,文件FIle对象是Blob对象的子类,Blob对象包含一个重要的方法slice,通过这个方法,我们就可以对二进制文件进行拆分。
- 前端进行切片处理,然后将每个切片传到服务器
class Util {
// 生成文件 hash(web-worker)
static calculateHash(fileChunkList) {
return new Promise(resolve => {
// 添加 worker 属性
let worker = new Worker("./util/hash.js");
worker.postMessage({ fileChunkList });
worker.onmessage = e => {
const { percentage, hash } = e.data;
this.hashPercentage = percentage;
if (hash) {
resolve(hash);
}
};
});
}
/**
* 生成文件切片
*/
static createFileChunks(file) {
const fileChunkList = [];
let start = 0,
end = size,
index = 0;
while (end < file.size) {
let chunk;
let _file = file.slice(start, end);
start = end;
end = end + size;
if (end > file.size - 1 && start < file.size - 1) {
end = file.size - 1;
}
chunk = {
file: _file,
index: index
}
fileChunkList.push(chunk);
index++;
}
return fileChunkList;
}
//验证是否已经上传
static verifyUploaded(fileName, fileHash) {
$.ajax({
url: 'http://localhost:3300/upload/verify',
method: 'GET',
async: false,
data: {
hash: fileHash,
fileName: file.name,
},
success: function(res) {
this.uploadedList = res.uploadedList;
this.isUploaded = res.isUploaded;
}
})
}
//上传切片
static async uploadChunks(fileChunkList, file) {
var fileHash = await Util.calculateHash(fileChunkList);
Util.verifyUploaded(file.name, fileHash);
if (isUploaded) {
alert('[已经上传过]--上传成功');
return;
}
const requestList = fileChunkList.filter((data) => {
return !uploadedList.includes(fileHash + '_' + data.index);
});
Promise.all(
requestList.map(function(fileChunk) {
new Promise(resovle => {
const formData = new FormData();
formData.append("file", fileChunk.file);
formData.append("index", fileChunk.index);
formData.append('hash', fileHash);
//上传切片
$.ajax({
url: 'http://localhost:3300/upload/bigFile',
method: 'POST',
processData: false,
contentType: false,
data: formData,
xhr: function() {
var xhr = $.ajaxSettings.xhr();
fileChunk.xhr = xhr;
xhr.onprogress = function e() {
// For downloads
if (e.lengthComputable) {
console.log(e.loaded / e.total);
}
};
xhr.upload.onprogress = function(e) {
// For uploads
if (e.lengthComputable) {
loaded = loaded + e.loaded;
progressbar.progressbar("value", (loaded / file.size) * 100);
progressLabel.text(progressbar.progressbar("value") + "%");
}
};
xhr.onload = e => {
if (requestList) {
const xhrIndex = requestList.findIndex(item => item.index === chunk.inddex);
requestList.splice(xhrIndex, 1);
}
}
return xhr;
},
success: function() {
resovle();
},
error: function() {}
});
});
})
).then(() => {
//所有切片生成完以后进行切片合并
$.ajax({
url: 'http://localhost:3300/upload/done',
method: 'GET',
data: {
hash: fileHash,
fileName: file.name,
size: size
}
})
}).catch(function(reason) {
throw new Error(reason);
});
}
}
- 服务器接收切片请求,并将文件写入磁盘
const multiparty = require("multiparty");
const fse = require("fs-extra");
const path = require("path");
const UPLOAD_DIR = path.resolve(__dirname, "..", "target"); // 大文件存储目录
function index(req, res) {
const multipart = new multiparty.Form();
multipart.parse(req, async(err, fields, files) => {
if (err) {
return;
}
const [file] = files.file;
const [context] = fields.context;
const [index] = fields.index;
const filename = context + '_' + index;
const fileDir = path.resolve(UPLOAD_DIR, filename);
// 切片目录不存在,创建切片目录
if (!fse.existsSync(UPLOAD_DIR)) {
await fse.mkdirs(UPLOAD_DIR);
}
await fse.move(file.path, fileDir);
res.end("received file chunk");
});
}
module.exports = index;
结果分析:
- 上传一个72286KB的文件;
- 将切片大小定义为10M;
- 所以发送了8个切片请求;
- 服务器将这个8个切片写到磁盘;
合并切片
在接收到前端发送的合并请求后,服务端将文件夹下的所有切片进行合并
- 前端发送合并请求
//所有切片生成完以后进行切片合并
$.ajax({
url: 'http://localhost:3300/upload/done',
method: 'GET',
data:{
hash:fileHash,
fileName:file.name,
size:size
}
})
- 服务器端合并切片
const pipeStream = (path,writeStream)=>{
new Promise(resolve=>{
const readStream = fse.createReadStream(path);
readStream.on('end',()=>{
fse.unlinkSync(path);
resolve();
});
readStream.pipe(writeStream);
})
}
async function merge(req,res){
const hash = req.query.hash;
const size = req.query.size;
const fileName = req.query.fileName;
const fileDir = path.resolve(UPLOAD_DIR,hash);
const filePaths = await fse.readdir(fileDir);
//根据切片的index进行排序
filePaths.sort((value,value2)=>{
return value.split('-')[1]-value2.split('-')[1]
});
Promise.all(
filePaths.map((filePath,index)=>{
pipeStream(path.resolve(fileDir,filePath),fse.createWriteStream(`${fileDir}/${fileName}`,{start:index*size,end:(index+1)*size}));
})
).then(()=>{
//合并完以后删除切片目录
fse.rmdir(fileDir);
res.end(
JSON.stringify({
code:200,
message:'file merged success'
})
);
})
}
如上图可以看到切片合并后生成了一个上传的大文件
显示上传进度
xhr.upload.onprogress = function(e) {
// For uploads
if (e.lengthComputable) {
loaded = loaded + e.loaded;
progressbar.progressbar("value", (loaded / file.size) * 100);
progressLabel.text(progressbar.progressbar("value") + "%");
}
};
累加每个切片的上传进度,然后除以整个文件的大小,即为整个文件的上传进度。
暂停、恢复上传、断点续传
断点续传就是将暂停的上传切片的请求恢复上传。
- 每次发送文件上传请求前发送一个verify请求,判断该文件是否已经上传过,并且返回已经上传过的切片请求uploadedList;
- 创建一个数组requestList用来存放所有的切片请求;
- 从requestList中上传掉所有的uploadedList;
- 根据xhr.onload中删除每一个上传成功的请求;
- 暂停上传使用 XMLHttpRequest 的 abort 方法实现;
- 恢复上传的时候就只发送requestList中剩下的切片请求;
项目代码路径
- 运行步骤
- npm install
- node server.js
- node app.js
- 浏览器:http://localhost:3300/