前言
我们在上传文件的时候,如果文件内容较小,我们可以直接采用将文件转化为字节流传输到服务端。但是如果遇到大文件,这样的方式是非常折磨用户的,而且万一中途传输中断,又要重新从 0 开始。
所以我们需要采用分片上传/断点续传来优化用户的使用体验。
断点续传
断点续传 就是将跳过上一次上传的文件内容,采用 Blob.slice 方法分割文件内容,直接上传剩余的字节数。
监听进度
如果要实现断点续传,我们就需要知道我们之前上传了多少进度。我们采用 XMLHttpRequest 来进行上传,因为 fetch 是无法监听 progress 事件的(也可能是怪我菜)。
我们采用 xhr.upload.onprogress 来实现进度监听。要实现断点续传,我们就需要知道服务端接收的字节数。所以除了上传请求,我们还需要增加一个请求来询问服务器上传了多少字节。
实现思路
首先我们先创建一个唯一的标识 id 来标识我们要上传的文件。
let fileId = file.name + '-' + file.size + '-' + file.lastModified;
我们这里简单判定一下,当文件名,大小,最近修改日期发生更改的话,断点续传的时候就会判定这个文件就是一个新的文件,重新生成一个新的 fileId 。也就是说不会进行续传。
向服务端发送一个请求,请求服务端已经接收了多少个字节。
let response = await fetch('status', {
headers: {
'X-File-Id': fileId
}
});
// 服务端已有的字节数
let startByte = +await response.text();
服务器通过获取 X-File-Id 头信息获取我们刚刚设定的 fileId 来处理当前文件,当服务器中没有文件的时候,响应应为 0 。
然后我们采用 Blob.slice 方法发送 startByte 之后的文件。
xhr.open("POST", "upload", true);
//文件唯一标识
xhr.setRequestHeader('X-File-Id', fileId);
// 上传开始的字节数
xhr.setRequestHeader('X-Start-Byte', startByte);
xhr.upload.onprogress = (e) => {
console.log(`Uploaded ${startByte + e.loaded} of ${startByte + e.total}`);
};
xhr.send(file.slice(startByte));
在这里,我们将文件唯一标识 fileId 作为 X-File-Id 发送给服务端,这样它就知道我们要上传的文件,并且将 startByte 作为 X-Start-Byte 告知服务器,我们是最初上传还是在续传,主要取决于 startByte 是否为 0 。
服务端如果发现 startByte 不为 0 ,应该向 field 文件追加字节流。
这里的核心代码引用了一下 GiHub 中的代码。
index.html
<!DOCTYPE HTML>
<script src="uploader.js"></script>
<form name="upload" method="POST" enctype="multipart/form-data" action="/upload">
<input type="file" name="myfile">
<input type="submit" name="submit" value="Upload (Resumes automatically)">
</form>
<button onclick="uploader.stop()">Stop upload</button>
<div id="log">Progress indication</div>
<script>
function log(html) {
document.getElementById('log').innerHTML = html;
console.log(html);
}
function onProgress(loaded, total) {
log("progress " + loaded + ' / ' + total);
}
let uploader;
document.forms.upload.onsubmit = async function(e) {
e.preventDefault();
let file = this.elements.myfile.files[0];
if (!file) return;
uploader = new Uploader({file, onProgress});
try {
let uploaded = await uploader.upload();
if (uploaded) {
log('success');
} else {
log('stopped');
}
} catch(err) {
console.error(err);
log('error');
}
};
</script>
uploader.js
class Uploader {
constructor({file, onProgress}) {
this.file = file;
this.onProgress = onProgress;
// create fileId that uniquely identifies the file
// we could also add user session identifier (if had one), to make it even more unique
this.fileId = file.name + '-' + file.size + '-' + file.lastModified;
}
async getUploadedBytes() {
let response = await fetch('status', {
headers: {
'X-File-Id': this.fileId
}
});
if (response.status != 200) {
throw new Error("Can't get uploaded bytes: " + response.statusText);
}
let text = await response.text();
return +text;
}
async upload() {
this.startByte = await this.getUploadedBytes();
let xhr = this.xhr = new XMLHttpRequest();
xhr.open("POST", "upload", true);
// send file id, so that the server knows which file to resume
xhr.setRequestHeader('X-File-Id', this.fileId);
// send the byte we're resuming from, so the server knows we're resuming
xhr.setRequestHeader('X-Start-Byte', this.startByte);
xhr.upload.onprogress = (e) => {
this.onProgress(this.startByte + e.loaded, this.startByte + e.total);
};
console.log("send the file, starting from", this.startByte);
xhr.send(this.file.slice(this.startByte));
// return
// true if upload was successful,
// false if aborted
// throw in case of an error
return await new Promise((resolve, reject) => {
xhr.onload = xhr.onerror = () => {
console.log("upload end status:" + xhr.status + " text:" + xhr.statusText);
if (xhr.status == 200) {
resolve(true);
} else {
reject(new Error("Upload failed: " + xhr.statusText));
}
};
// onabort triggers only when xhr.abort() is called
xhr.onabort = () => resolve(false);
});
}
stop() {
if (this.xhr) {
this.xhr.abort();
}
}
}
总结
这种断点续传的方式在传输大文件的时候,其实依然有一些缺点,比如当服务器如果有上传大小限制的时候,还是会无法上传。
既然采用了 Blob.slice 方法,干脆直接就对文件进行分块。并为每个文件块添加规则的文件头,然后分别上传。
在全部分块发送完成之后发送一个 merge 请求,让服务端拼接这些文件块。在这里也整理了几个比较好用的上传组件分享一下:
觉得整理的不错的点个赞吧😂。