基于simple-uploader.js的文件切片上传等

安装依赖
  • npm install vue-simple-uploader --save

  • npm install spark-md5 --save

特性
  • 支持文件、多文件、文件夹上传
  • 支持拖拽文件、文件夹上传
  • 统一对待文件和文件夹,方便操作管理
  • 可暂停、继续上传
  • 错误处理
  • 支持“快传”,通过文件判断服务端是否已存在从而实现“快传”
  • 上传队列管理,支持最大并发上传
  • 分块上传
  • 支持进度、预估剩余时间、出错自动重试、重传等操作
Code
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
// template
<template>
<div>
<uploader ref="uploaderRef" class="uploader-app" :options="initOptions"
:file-status-text="fileStatusText" :auto-start="false" @file-added="onFileAdded" @file-success="onFileSuccess"
@file-progress="onFileProgress" @file-error="onFileError">
<uploader-btn class="uploader-app-btn" ref="uploadBtnRef">选择文件</uploader-btn>
<div class="uploadingWrapper">
<div class="uploadingWrapper-header">
<div class="uploadingWrapper-header-title">上传完成
({{ uploaderRef?.fileList.length }}/{{ completedFileList.length }})
</div>
<div class="hidden uploadingWrapper-header-operate">
<span>全部取消</span>
<span>清空失效</span>
</div>
</div>
<div class="uploadingWrapper-list">
<el-scrollbar>
<div v-if="uploaderRef?.fileList.length > 0">
<div class="uploadingWrapper-list-item" v-for="file in uploaderRef?.fileList" :key="file.id">
<div class="uploadingWrapper-list-item-filename">
<div class="uploadingWrap-list-item-filename-icon">
<img src="./images/audio-icon.png" alt="">
</div>
<div class="uploadingWrapper-list-item-filename-desc">
<div class="uploadingWrapper-list-item-filename-desc-name">
{{ file.name }}
</div>
<div class="uploadingWrapper-list-item-filename-desc-progress">
<el-progress :text-inside="true" :percentage="file.percentage" />
</div>
<div class="uploadingWrapper-list-item-filename-desc-status">
<div class="uploadingWrapper-list-item-filename-desc-status-filesize">
{{ (file.size / 1024 / 1024).toFixed(2) }}MB
</div>
</div>
</div>
</div>
<div class="uploadingWrapper-list-item-operate">
<div v-if="file.md5Status && file.completed" class="uploadingWrapper-list-item-operate-item"
@click="download(file)">
<img
src="https://edu-wenku.bdimg.com/v1/pc/aigc/xiuding-1685086333409.png"
alt="">
</div>
<div v-if="file.md5Status && !file.completed && file.paused"
class="uploadingWrapper-list-item-operate-item" @click="resume(file)">
<img
src="https://edu-wenku.bdimg.com/v1/pc/aigc/xiuding-1685086333409.png"
alt="">
</div>
<div v-if="file.md5Status && !file.completed && !file.paused"
class="uploadingWrapper-list-item-operate-item" @click="pause(file)">
<img
src="https://edu-wenku.bdimg.com/v1/pc/aigc/xiuding-1685086333409.png"
alt="">
</div>
<div v-if="file.md5Status && !file.completed && file.error"
class="uploadingWrapper-list-item-operate-item" @click="retry(file)">
<img
src="https://edu-wenku.bdimg.com/v1/pc/aigc/xiuding-1685086333409.png"
alt="">
</div>
<div v-if="!file.completed" class="uploadingWrapper-list-item-operate-item" @click="close(file)">
<img
src="https://edu-wenku.bdimg.com/v1/pc/aigc/xiuding-1685086333409.png"
alt="">
</div>
</div>
</div>
</div>
<el-empty v-else description="暂无待上传文件" :image-size="200" />
</el-scrollbar>
</div>
</div>
</uploader>
</div>
</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
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
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
<script setup lang="ts">
import { ref, watch, computed, nextTick, onMounted } from 'vue'
import { generateMD5 } from './md5'
import Bus from './bus'
import { ElMessage } from 'element-plus'

defineOptions({
name: 'Upload',
})
const props = defineProps({
global: {
type: Boolean,
default: true,
},
// 发送给服务器的额外参数
params: {
type: Object,
},
options: {
type: Object,
default: () => ({
accept: ['.pdf', '.txt', '.docx']
})
},
})

const emits = defineEmits(['fileAdded', 'fileSuccess'])

const initOptions = {
// 后端接口地址
target: 'http://localhost:3000/upload',
// 分块大小
chunkSize: '2048000',
// 上传参数名
fileParameterName: 'file',
// 最大自动失败重试上传次数
maxChunkRetries: 3,
// 是否开启服务器分片校验
testChunks: true,
// 服务器分片校验函数,秒传及断点续传基础
checkChunkUploadedByResponse: function (chunk, message) {
console.log('切片: ', chunk, message);
let skip = false

try {
let objMessage = JSON.parse(message)
if (objMessage.skipUpload) {
skip = true
} else {
skip = (objMessage.uploaded || []).indexOf(chunk.offset + 1) >= 0
}
} catch (e) { }

return skip
},
// 额外参数
query: (file, chunk) => {
return {
...file.params
}
}
}
// 文件上传状态
const fileStatusText = {
success: '上传成功',
error: '上传失败',
uploading: '上传中',
paused: '已暂停',
waiting: '等待上传'
}
const panelShow = ref(false)
const uploaderRef = ref()
const uploadBtnRef = ref()
let mergeFn = mergeSimpleUpload
function mergeSimpleUpload() {
return Promise.resolve()
}

// const uploader = computed(() => uploaderRef.value?.uploader)

let customParams = {}
// 自定义配置项
function customizeOptions(opts: any) {
// 自定义上传url
if (opts.target) {
uploaderRef.value.uploader.opts.target = opts.target
}
// 是否可以秒传、断点续传
if (opts.testChunks !== undefined) {
uploaderRef.value.uploader.opts.testChunks = opts.testChunks
}
// merge 的方法,类型为Function,返回Promise
if (opts.mergeFn) {
mergeFn = opts.mergeFn
}
// 自定义文件上传类型
let uploadBtnLabel = document.getElementsByClassName('uploader-app-btn')[0]
let uploadBtn = uploadBtnLabel.getElementsByTagName('input')[0]
uploadBtn.setAttribute('accept', props.options.accept.join(','))
}

watch(
() => props.params,
(data) => {
if (data) {
customParams = data
}
}
)

watch(
() => props.options,
(data) => {
if (data) {
setTimeout(() => {
customizeOptions(data)
}, 0)
}
}
)

const acceptType = ['pdf', 'vnd.openxmlformats-officedocument.wordprocessingml.document']
async function onFileAdded(file: any) {
if (!acceptType.includes(file.getType())) {
error('该格式文件不支持')
file.ignored = true // 文件校验,不符规则的文件过滤掉
}
panelShow.value = true
// 展示弹窗
// trigger('fileAdded') 文件选择后的回调
// 将额外的参数赋值到每个文件上,以不同文件使用不同params的需求
// file.params = customParams.value
uploaderRef?.value.fileList.forEach((item: any) => {
if (item.id === file.id)
item.md5Status = false
})
// 计算MD5
const md5 = await computeMD5(file)
startUpload(file, md5)
}

function computeMD5(file: any) {
// 暂停文件
file.pause()
// 开始计算MD5
return new Promise((resolve, reject) => {
generateMD5(file, {
onProgress(currentChunk, chunks) {
// 实时展示MD5的计算进度
nextTick(() => {
const md5ProgressText = '校验MD5 ' + ((currentChunk / chunks) * 100).toFixed(0) + '%'
console.log('实时展示MD5的计算进度: ', md5ProgressText);
// document.querySelector(`.custom-status-${file.id}`).innerText = md5ProgressText
})
},
onSuccess(md5) {
uploaderRef?.value.fileList.forEach((item: any) => {
if (item.id === file.id)
item.md5Status = true
})
resolve(md5)
},
onError() {
error(`文件${file.name}读取出错,请检查该文件`)
file.cancel()
reject()
}
})
})
}
// md5计算完毕,开始上传
function startUpload(file: any, md5: any) {
file.uniqueIdentifier = md5
file.resume()
}
// 上传成功list
const completedFileList = ref([])
function onFileSuccess(rootFile, file, response, chunk) {
console.log('rootFile, file, response, chunk: ', rootFile, file, response, chunk);
let res = JSON.parse(response)

// 服务端自定义的错误(即http状态码为200,但是是错误的情况),这种错误是Uploader无法拦截的
if (!res.result) {
error(res.message)
return
}

// 如果服务端返回了需要合并的参数
if (res.needMerge) {
console.log('合并Fn');
// mergeFn({
// tempName: res.tempName,
// fileName: file.name,
// ...file.params
// })
// .then((res) => {
// // 文件合并成功
// trigger('fileSuccess')
// })
// .catch((e) => { })
// 不需要合并
} else {
// trigger('fileSuccess')
console.log('上传成功')
}
// 记录上传成功列表
completedFileList.value = uploaderRef?.value?.fileList.map((item: any) => {
if (item.completed) {
return item
}
})
}

function onFileProgress(rootFile, file, chunk) {
const percentage = file.progress() * 100
uploaderRef?.value.fileList.forEach(item => {
if (item.uniqueIdentifier === file.uniqueIdentifier) {
item.percentage = percentage
}
})
console.log('percentage * 100: ', percentage);
}

function onFileError(rootFile, file, response, chunk) {
error(response)
}

// 下载
function download(file: any) {
console.log('下载')
}
// 暂停
function pause(file: any) {
file.pause()
}
// 继续上传
function resume(file: any) {
file.resume()
}
// 删除
function close(file: any) {
file.cancel()
}
// 重试
function retry(file: any) {
file.retry()
}

function error(msg: string) {
console.log('msg: ', msg);
ElMessage({
message: msg,
type: 'error',
})
}

onMounted(() => {
// Bus.on('openUploader', ({ params = {}, options = {} }) => {
// customParams = params

// customizeOptions(options)

// if (uploadBtnRef.value) {
// uploadBtnRef.value.$el.click()
// }
// })
nextTick(() => {
const opt = {
target: 'http://localhost:3000/upload',
testChunks: true,
mergeFn: null,
}
customizeOptions(opt)
})
})
</script>
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
// css
<style lang="scss" scoped>
.uploader-app-btn {
display: none;
}

.uploadingWrapper {
width: 560px;
height: 408px;
background-color: #fff;
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, .16);
border-radius: 8px;

&-header {
&-title {
font-size: 14px;
font-weight: 600;
border-bottom-width: 1px;
border-color: #f4f4f4;
border-bottom-style: solid;
height: 40px;
line-height: 24px;
padding: 8px 16px;
color: #454d5a;
}
}

&-list {
display: flex;
flex-direction: column;
height: 349px;

&-item {
display: flex;
height: 72px;
padding: 0 10px;

&-filename {
flex: none;
display: flex;
justify-content: center;
align-items: center;
width: 420px;

&-icon {
width: 40px;
height: 40px;

&>img {
width: 40px;
height: 40px;
}
}

&-desc {
width: 360px;
margin-left: 10px;
display: flex;
flex-direction: column;
justify-content: space-around;
font-size: 14px;
color: #03081a;
padding: 10px 0;

&-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

&-progress {
:deep(.el-progress-bar__innerText) {
display: none;
}
}

&-status {
&-filesize {
font-size: 12px;
color: #878c9c;
display: inline-block;
}
}
}
}

&-operate {
display: flex;
align-items: center;
margin-left: 15px;

&-item {
width: 28px;
height: 28px;
margin-right: 10px;
cursor: pointer;

&>img {
width: 28px;
height: 28px;
}
}

&-item:last-child {
margin-right: 0;
}
}
}

&-tip {
display: flex;
justify-content: center;
width: 100%;
color: #afb3bf;
margin-bottom: -12px;
font-size: 12px;
}
}
}
</style>
Description
  • uploadBtnRef.value.btn.click() 唤醒选择文件窗口
  • MD5数字签名

核心

  • md5数字签名
  • 文件分片
    • 把一个大文件按照一定大小来分成很多个小文件(分片)来上传,上传后再由服务端对所有上传的文件进行汇总整合成原始的文件
    • 流程概述
      • Blob对象中的slice方法可以对文件进行切割,File对象是继承Blob对象的,因此File对象也有slice方法
      • 定义每一个分片文件的大小变量为chunkSize,通过文件大小fileSize和分片大小chunkSize得到分片数量chunks,通过for循环以及**file.slice()**方法对文件进行分片,序号为0 - n,和已上传的切片列表做比对,得到所有未上传的分片,添加到请求列表中
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
// 自己实现的切片上传最小示例
// vue-simple-uploader自动将文件进行分片,在options的chunkSize中可以设置每个分片的大小。

// 切片大小
const chunkSize = 100 * 1024 // 100kb

function changeFile(e) {
const files = e.target.files
for (let i = 0; i < files.length; i++) {
const curFile = files[i]
console.log('curFile: ', curFile);
const totalSize = curFile.size // 文件大小
const fileName = curFile.name // 文件名
let md5Identifier; // md5数字签名
md5File(curFile).then(res => {
md5Identifier = res
// md5加密异步,需等待加密成功再进行切片上传
startToSlice()
})
function startToSlice() {
// 计算文件切片总数
const totalChunks = Math.ceil(totalSize / chunkSize)
//
for (let j = 1; j <= totalChunks; j++) {
let chunk;
if (j == totalChunks) {
// 最后一片
chunk = curFile.slice((j - 1) * chunkSize, totalSize)
} else {
chunk = curFile.slice((j - 1) * chunkSize, j * chunkSize)
}
const formData = new FormData();
formData.append('chunkNumber', j.toString()) // 当前切片idx
formData.append('chunkSize', chunkSize.toString()) // 切片大小
formData.append('totalSize', totalSize) // 总文件大小
formData.append('md5Identifier', md5Identifier) // md5数字签名
formData.append('totalChunks', totalChunks.toString()) // 文件切片总数
formData.append('file', chunk) // 当前切片blob对象
// 上传
axios({
method: 'post',
url: '/upload',
data: formData
});
}
}
}
}

// md5加密
function md5File(file) {
return new Promise((resolve, reject) => {
let blobSlice =
File.prototype.slice ||
File.prototype.mozSlice ||
File.prototype.webkitSlice
let chunkSize = file.size / 100
let chunks = 100
let currentChunk = 0
let spark = new SparkMD5.ArrayBuffer()
let fileReader = new FileReader()
fileReader.onload = function (e) {
// console.log('read chunk nr', currentChunk + 1, 'of', chunks)
spark.append(e.target.result) // Append array buffer
currentChunk++
if (currentChunk < chunks) {
loadNext()
} else {
let cur = +new Date()
console.log('finished loading')
// alert(spark.end() + '---' + (cur - pre)); // Compute hash
let result = spark.end()
resolve(result)
}
}
fileReader.onerror = function (err) {
console.warn('oops, something went wrong.')
reject(err)
}
function loadNext() {
let start = currentChunk * chunkSize
let end =
start + chunkSize >= file.size ? file.size : start + chunkSize
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end))
}
loadNext()
})
}
  • 秒传及断点续传
    • 在上传过程最开始,发送一个get请求,询问服务器当前文件的存储信息
    • 存储信息中已有该文件
      • 秒传
    • 存储信息中包含当前文件的部分分片信息
      • 断点续传
        • 跳过这部分分片的上传
    • 存储信息为空
      • 完整上传