关于大文件断点续传的问题,解决方案是Blob.prototype.slice方法,和数组的slice方法类似,使用slice方法可以返回源文件的切片。按照要求将源文件切位n个切片,将多个切片同时上传,源文件由一个大文件转换成n个小切片同时上传,可以大大减少上传时间。
需要注意的是上传到服务端的切片可能位置会发生改变,需要将切片的位置记录下来。

一、服务端(Node.js)

服务端需要做的是:接受n个切片,并将这些切片在上传后合并。

需要注意的是:

1、合并切片的时间: 即n个切片什么时候上传完成

前端上传的切片中带有切片的个数,服务端接受到切片的总数后自动合并
2、怎么合并切片
使用node.js的读写流(readStream/writeStream),将所有切片的流传输到最终文件的流里。

服务端:

server
-index.js
-controller.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
// index.js
const Controller = require("./controller");
const http = require("http");
const server = http.createServer();

const controller = new Controller();

server.on("request", async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
// 解决跨域
res.setHeader("Access-Control-Allow-Headers", "*");
if (req.method === "OPTIONS") {
res.status = 200;
res.end();
return;
}
// 验证上传文件是否已上传
if (req.url === "/verify") {
await controller.handleVerifyUpload(req, res);
return;
}
// 合并切片
if (req.url === "/merge") {
await controller.handleMerge(req, res);
return;
}
if (req.url === "/") {
await controller.handleFormData(req, res);
}
});

server.listen(3001, () => console.log("正在监听 3001 端口"));
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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
// controller.js
// 处理前端传来的FormData
const multiparty = require("multiparty");
const path = require("path");
const fse = require("fs-extra");

const extractExt = filename =>
filename.slice(filename.lastIndexOf("."), filename.length); // 提取后缀名
const UPLOAD_DIR = path.resolve(__dirname, "..", "target"); // 大文件存储目录

const pipeStream = (path, writeStream) =>
new Promise(resolve => {
// 创建可读流
const readStream = fse.createReadStream(path);
readStream.on("end", () => {
fse.unlinkSync(path);
resolve();
});
readStream.pipe(writeStream);
});

// 合并切片
const mergeFileChunk = async (filePath, fileHash, size) => {
const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
const chunkPaths = await fse.readdir(chunkDir);
// 根据切片下标进行排序
// 否则直接读取目录的获得的顺序可能会错乱
chunkPaths.sort((a, b) => a.split("-")[1] - b.split("-")[1]);
await Promise.all(
chunkPaths.map((chunkPath, index) =>
pipeStream(
path.resolve(chunkDir, chunkPath),
// 指定位置创建可写流
fse.createWriteStream(filePath, {
start: index * size,
end: (index + 1) * size
})
)
)
);
fse.rmdirSync(chunkDir); // 合并后删除保存切片的目录
};

const resolvePost = req =>
new Promise(resolve => {
let chunk = "";
req.on("data", data => {
chunk += data;
});
req.on("end", () => {
resolve(JSON.parse(chunk));
});
});

// 返回已经上传切片名
const createUploadedList = async fileHash =>
fse.existsSync(path.resolve(UPLOAD_DIR, fileHash))
? await fse.readdir(path.resolve(UPLOAD_DIR, fileHash))
: [];

module.exports = class {
// 合并切片
async handleMerge(req, res) {
const data = await resolvePost(req);
const { fileHash, filename, size } = data;
const ext = extractExt(filename);
const filePath = path.resolve(UPLOAD_DIR, `${fileHash}${ext}`);
await mergeFileChunk(filePath, fileHash, size);
res.end(
JSON.stringify({
code: 0,
message: "file merged success"
})
);
}
// 处理切片
async handleFormData(req, res) {
const multipart = new multiparty.Form();
//下面multipart.parse的回调中 fields 参数保存了FormData中的文件
multipart.parse(req, async (err, fields, files) => {
if (err) {
console.error(err);
res.status = 500;
res.end("process file chunk failed");
return;
}
const [chunk] = files.chunk;
const [hash] = fields.hash;
const [fileHash] = fields.fileHash;
const [filename] = fields.filename;
const filePath = path.resolve(
UPLOAD_DIR,
`${fileHash}${extractExt(filename)}`
);
const chunkDir = path.resolve(UPLOAD_DIR, fileHash);

// 文件存在直接返回
if (fse.existsSync(filePath)) {
res.end("file exist");
return;
}

// 切片目录不存在,创建切片目录
if (!fse.existsSync(chunkDir)) {
await fse.mkdirs(chunkDir);
}
// fs-extra 专用方法,类似 fs.rename 并且跨平台
// fs-extra 的 rename 方法 windows 平台会有权限问题
// https://github.com/meteor/meteor/issues/7852#issuecomment-255767835
await fse.move(chunk.path, path.resolve(chunkDir, hash));
res.end("received file chunk");
});
}
// 验证是否已上传/已上传切片下标
async handleVerifyUpload(req, res) {
const data = await resolvePost(req);
const { fileHash, filename } = data;
const ext = extractExt(filename);
const filePath = path.resolve(UPLOAD_DIR, `${fileHash}${ext}`);
if (fse.existsSync(filePath)) {
res.end(
JSON.stringify({
shouldUpload: false
})
);
} else {
res.end(
JSON.stringify({
shouldUpload: true,
uploadedList: await createUploadedList(fileHash)
})
);
}
}
};

二、客户端

前端使用Vue+elementUI展示界面,当点击上传按钮时,slice方法将源文件做切片处理,将切片放入数组中返回,使用 hash+index 给每个切片做标识,用于上传完成后合并切片。
调用uploadChunks上传所有的切片,将切片、切片hash、切片名filename放入FormData中,使用promise.all并发上传所有切片。

断点续传原理在于前后端需要记住已经上传的切片,继续上传的时候就可以跳过之前已经上传的部分。

实现的方案:

服务端保存已经上传的切片hash,前端每次上传前都向服务端获取已经上传的切片。

这里也可以在前端使用localStorage记录已经上传的切片的hash,但是存在问题,就是换一个浏览器就失去已经上传的切片的hash了。

客户端、服务端都需要生成文件和切片的hash,根据文件内容生成hash。使用spark-md5根据文件内容计算出文件的hash值。

当文件比较大的时候,读取文件内容计算hash是非常耗时的,会引起UI阻塞,导致页面假死,解决方式是使用web-worker在worker线程计算hash。

实例化web-worker,参数是一个js文件路径不能跨域,需要单独创建一个hash.js文件放在public中,在worker中不允许访问dom,使用importScripts函数导入外部脚本spark-md5

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
// hash.js
self.importScripts("/spark-md5.min.js"); // 导入脚本

// 生成文件 hash
self.onmessage = e => {
const { fileChunkList } = e.data;
const spark = new self.SparkMD5.ArrayBuffer();
let percentage = 0;
let count = 0;
const loadNext = index => {
const reader = new FileReader();
reader.readAsArrayBuffer(fileChunkList[index].file);
reader.onload = e => {
count++;
spark.append(e.target.result);
if (count === fileChunkList.length) {
self.postMessage({
percentage: 100,
hash: spark.end()
});
self.close();
} else {
percentage += 100 / fileChunkList.length;
self.postMessage({
percentage
});
loadNext(count);
}
};
};
loadNext(0);
};

1、template

点我展示代码
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
<div>
<input
type="file"
:disabled="status !== Status.wait"
@change="handleFileChange"
/>
<el-button @click="handleUpload" :disabled="uploadDisabled"
>上传</el-button
>
<el-button @click="handleResume" v-if="status === Status.pause"
>恢复</el-button
>
<el-button
v-else
:disabled="status !== Status.uploading || !container.hash"
@click="handlePause"
>暂停</el-button
>
</div>
<div>
<!-- <div>计算文件 hash</div>
<el-progress :percentage="hashPercentage"></el-progress> -->
<div>总进度</div>
<el-progress :percentage="fakeUploadPercentage"></el-progress>
</div>
<el-table :data="data">
<el-table-column
prop="hash"
label="切片hash"
align="center"
></el-table-column>
<el-table-column label="大小(KB)" align="center" width="120">
<template v-slot="{ row }">
{{ row.size | transformByte }}
</template>
</el-table-column>
<el-table-column label="进度" align="center">
<template v-slot="{ row }">
<el-progress
:percentage="row.percentage"
color="#909399"
></el-progress>
</template>
</el-table-column>
</el-table>

2、js部分:设置切片大小 考虑到通用性,简单封装了XMLHttpRequest, 实际使用可以隐藏掉hash进度条

点我展示代码
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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
<script>
const SIZE = 100 * 1024 * 1024; // 切片大小

const Status = {
wait: "wait",
pause: "pause",
uploading: "uploading"
};


export default {
name: "app",
filters: {
transformByte(val) {
return Number((val / 1024).toFixed(0));
}
},
data: () => ({
Status,
container: {
file: null,
hash: "",
worker: null
},
hashPercentage: 0,
data: [],
requestList: [],
status: Status.wait,
// 当暂停时会取消 xhr 导致进度条后退
// 为了避免这种情况,需要定义一个假的进度条
fakeUploadPercentage: 0
}),
computed: {
uploadDisabled() {
return (
!this.container.file ||
[Status.pause, Status.uploading].includes(this.status)
);
},
uploadPercentage() {
if (!this.container.file || !this.data.length) return 0;
const loaded = this.data
.map(item => item.size * item.percentage)
.reduce((acc, cur) => acc + cur);
return parseInt((loaded / this.container.file.size).toFixed(2));
}
},
watch: {
uploadPercentage(now) {
if (now > this.fakeUploadPercentage) {
this.fakeUploadPercentage = now;
}
}
},
methods: {
// 暂停
handlePause() {
this.status = Status.pause;
this.resetData();
},
resetData() {
this.requestList.forEach(xhr => xhr?.abort());
this.requestList = [];
if (this.container.worker) {
this.container.worker.onmessage = null;
}
},
async handleResume() {
this.status = Status.uploading;
const { uploadedList } = await this.verifyUpload(
this.container.file.name,
this.container.hash
);
await this.uploadChunks(uploadedList);
},
// xhr
request({
url,
method = "post",
data,
headers = {},
onProgress = e => e,
requestList
}) {
return new Promise(resolve => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = onProgress;
xhr.open(method, url);
Object.keys(headers).forEach(key =>
xhr.setRequestHeader(key, headers[key])
);
xhr.send(data);
xhr.onload = e => {
// 将请求成功的 xhr 从列表中删除
if (requestList) {
const xhrIndex = requestList.findIndex(item => item === xhr);
requestList.splice(xhrIndex, 1);
}
resolve({
data: e.target.response
});
};
// 暴露当前 xhr 给外部
requestList?.push(xhr);
});
},
// 生成文件切片
createFileChunk(file, size = SIZE) {
const fileChunkList = [];
let cur = 0;
while (cur < file.size) {
fileChunkList.push({ file: file.slice(cur, cur + size) });
cur += size;
}
return fileChunkList;
},
// 生成文件 hash(web-worker)
calculateHash(fileChunkList) {
return new Promise(resolve => {
this.container.worker = new Worker("/hash.js");
this.container.worker.postMessage({ fileChunkList });
this.container.worker.onmessage = e => {
const { percentage, hash } = e.data;
this.hashPercentage = percentage;
if (hash) {
resolve(hash);
}
};
});
},
handleFileChange(e) {
const [file] = e.target.files;
if (!file) return;
this.resetData();
Object.assign(this.$data, this.$options.data());
this.container.file = file;
},
async handleUpload() {
if (!this.container.file) return;
this.status = Status.uploading;
const fileChunkList = this.createFileChunk(this.container.file);
this.container.hash = await this.calculateHash(fileChunkList);

const { shouldUpload, uploadedList } = await this.verifyUpload(
this.container.file.name,
this.container.hash
);
if (!shouldUpload) {
this.$message.success("秒传:上传成功");
this.status = Status.wait;
return;
}

this.data = fileChunkList.map(({ file }, index) => ({
fileHash: this.container.hash,
index,
hash: this.container.hash + "-" + index,
chunk: file,
size: file.size,
percentage: uploadedList.includes(index) ? 100 : 0
}));

await this.uploadChunks(uploadedList);
},
// 上传切片,同时过滤已上传的切片
async uploadChunks(uploadedList = []) {
const requestList = this.data
.filter(({ hash }) => !uploadedList.includes(hash))
.map(({ chunk, hash, index }) => {
const formData = new FormData();
formData.append("chunk", chunk);
formData.append("hash", hash);
formData.append("filename", this.container.file.name);
formData.append("fileHash", this.container.hash);
return { formData, index };
})
.map(async ({ formData, index }) =>
this.request({
url: "http://localhost:3001",
data: formData,
onProgress: this.createProgressHandler(this.data[index]),
requestList: this.requestList
})
);
await Promise.all(requestList);
// 之前上传的切片数量 + 本次上传的切片数量 = 所有切片数量时
// 合并切片
if (uploadedList.length + requestList.length === this.data.length) {
await this.mergeRequest();
}
},
// 通知服务端合并切片
async mergeRequest() {
await this.request({
url: "http://localhost:3001/merge",
headers: {
"content-type": "application/json"
},
data: JSON.stringify({
size: SIZE,
fileHash: this.container.hash,
filename: this.container.file.name
})
});
this.$message.success("上传成功");
this.status = Status.wait;
},
// 根据 hash 验证文件是否曾经已经被上传过
// 没有才进行上传
async verifyUpload(filename, fileHash) {
const { data } = await this.request({
url: "http://localhost:3001/verify",
headers: {
"content-type": "application/json"
},
data: JSON.stringify({
filename,
fileHash
})
});
return JSON.parse(data);
},
// 用闭包保存每个 chunk 的进度数据
createProgressHandler(item) {
return e => {
item.percentage = parseInt(String((e.loaded / e.total) * 100));
};
}
}
};
</script>