简单实现一下断点续传

Posted by maybelence on 2021-12-31

前言

我们在上传文件的时候,如果文件内容较小,我们可以直接采用将文件转化为字节流传输到服务端。但是如果遇到大文件,这样的方式是非常折磨用户的,而且万一中途传输中断,又要重新从 0 开始。

0_jVvFr4qLr4YQ48xo.gif

所以我们需要采用分片上传/断点续传来优化用户的使用体验。


断点续传

断点续传 就是将跳过上一次上传的文件内容,采用 Blob.slice 方法分割文件内容,直接上传剩余的字节数。

监听进度

如果要实现断点续传,我们就需要知道我们之前上传了多少进度。我们采用 XMLHttpRequest 来进行上传,因为 fetch 是无法监听 progress 事件的(也可能是怪我菜)。

我们采用 xhr.upload.onprogress 来实现进度监听。要实现断点续传,我们就需要知道服务端接收的字节数。所以除了上传请求,我们还需要增加一个请求来询问服务器上传了多少字节。

实现思路

首先我们先创建一个唯一的标识 id 来标识我们要上传的文件。

1
let fileId = file.name + '-' + file.size + '-' + file.lastModified;

我们这里简单判定一下,当文件名,大小,最近修改日期发生更改的话,断点续传的时候就会判定这个文件就是一个新的文件,重新生成一个新的 fileId 。也就是说不会进行续传。

向服务端发送一个请求,请求服务端已经接收了多少个字节。

1
2
3
4
5
6
7
8
let response = await fetch('status', {
headers: {
'X-File-Id': fileId
}
});

// 服务端已有的字节数
let startByte = +await response.text();

服务器通过获取 X-File-Id 头信息获取我们刚刚设定的 fileId 来处理当前文件,当服务器中没有文件的时候,响应应为 0

然后我们采用 Blob.slice 方法发送 startByte 之后的文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<!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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
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 请求,让服务端拼接这些文件块。在这里也整理了几个比较好用的上传组件分享一下:


Copyright by @maybelence.

...

...

00:00
00:00