文章目录
- 1 前言
- 2 功能实现
- 2.1 添加 Element Plus 上传代码及进度条展示代码
- 2.2 限制文件上传类型和大小
- 2.3 判断文件大小,小文件直接上传
- 2.4 大文件获取文件唯一标识
- 2.5 计算切片数量
- 2.6 上传切片
- 2.7 取消上传
- 3 完整代码
- 4 拓展
1 前言
最近在做一个项目的重构,其中有大文件上传的功能,由于项目是几年前,代码没有前后分离,用的是 jQuery + webuploader 库做的,但实际上只是实现了大文件切片上传,并没有切片并发、秒传及断点续传功能,后端也不支持,且 webuploader 库已经不再维护了,故决定自己实现一个最简单的大文件切片上传功能。
2 功能实现
2.1 添加 Element Plus 上传代码及进度条展示代码
<template>
<el-upload
accept=".mp3, .m4a, .aac, .mp4, .m4v"
:before-upload="beforeUpload"
:http-request="upload"
:show-file-list="false"
:disabled="disabled"
style="display: inline-block"
>
<el-tooltip placement="bottom">
<template #content>
可上传本地录音录像,支持上传的
<br />音频格式为:mp3、m4a、aac
<br />
视频格式为:mp4、m4v
</template>
<el-button type="primary" :disabled="disabled">
上传录音录像
</el-button>
</el-tooltip>
</el-upload>
<el-dialog
v-model="dialogVisible"
:fullscreen="true"
:show-close="false"
custom-class="dispute-upload-dialog"
>
<div class="center">
<div class="fz-18 ellipsis">正在上传:{{ fileData.name }}</div>
<el-progress :text-inside="true" :stroke-width="16" :percentage="percentage" />
<el-button @click="cancel">取消上传</el-button>
</div>
</el-dialog>
</template>
2.2 限制文件上传类型和大小
const beforeUpload = (file: File) => {
const mimeTypes = ['audio/mpeg', 'audio/x-m4a', 'audio/aac', 'video/mp4', 'video/x-m4v']
if (!mimeTypes.includes(file.type)) {
ElMessage({
type: 'error',
message: '只能上传 MP3、M4A、AAC、MP4、M4V 格式的文件',
duration: 6000
})
return false
}
if (file.size / 1024 / 1024 / 1024 > 1.5) {
ElMessage.error('文件大小不能超过 1.5G')
ElMessage({
type: 'error',
message: '文件大小不能超过 1.5G',
duration: 6000
})
return false
}
return true
}
2.3 判断文件大小,小文件直接上传
const chunkSize = 1 * 1024 * 1024 // 切片大小
const upload = async (file: { file: File }) => {
const fileObj = file.file
const nameList = fileObj.name.split('.')
fileData.value.name = fileObj.name
fileData.value.size = fileObj.size
fileData.value.type = fileObj.type
fileData.value.suffix = nameList[nameList.length - 1]
if (chunkSize > fileData.value.size) { // 文件大小小于切片大小,直接上传
disabled.value = true
axios
.post('upload', fileObj) // 调用后端上传文件接口
.then((res) => {
ElMessageBox({ message: `${fileData.value.name}上传成功`, title: '提示' })
updateUrl(res.data) // 调用后端保存上传文件路径接口
})
.catch(() => ElMessageBox({ message: `${fileData.value.name}上传失败`, title: '提示' })) // 上传失败弹框
.finally(() => (disabled.value = false))
return
}
batchUpload(fileObj) // 大文件切片上传
}
2.4 大文件获取文件唯一标识
// 重构项目没有断点续传等功能,故不需要做hash计算,只需要保证唯一即可,后端会拿这个值新建文件夹保存切片
let counter = 0
const getFileMd5 = () => {
let guid = (+new Date()).toString(32)
for (let i = 0; i < 5; i++) {
guid += Math.floor(Math.random() * 65535).toString(32)
}
return 'wu_' + guid + (counter++).toString(32)
}
2.5 计算切片数量
const percentage = ref(0)
const dialogVisible = ref(false)
const cancelUpload = ref(false)
const batchUpload = async (fileObj: File) => {
percentage.value = 0 // 每次上传文件前清空进度条
dialogVisible.value = true // 显示上传进度
cancelUpload.value = false // 每次上传文件前将取消上传标识置为 false
const chunkCount = Math.ceil(fileData.value.size / chunkSize) // 切片数量
fileData.value.md5 = getFileMd5() // 文件唯一标识
for (let i = 0; i < chunkCount; i++) {
if (cancelUpload.value) return // 若已经取消上传,则不再上传切片
const res = await uploadChunkFile(i, fileObj) // 上传切片
if (res.code !== 0) { // 切片上传失败
dialogVisible.value = false
ElMessageBox({ message: `${fileData.value.name}上传失败`, title: '提示' })
return
}
if (i === chunkCount - 1) { // 最后一片切片上传成功
setTimeout(() => { // 延迟关闭上传进度框用户体验会更好
dialogVisible.value = false
ElMessageBox({ message: `${fileData.value.name}上传成功`, title: '提示' })
axios.post('mergeUpload', { folder: fileData.value.md5 }) // 调用后端合并切片接口,参数需要与后端对齐
.then((res) => updateUrl(res.data)) // 调用后端保存上传文件路径接口
}, 500)
}
}
}
2.6 上传切片
let controller: AbortController | null = null // 当前切片上传 AbortController
const uploadChunkFile = async (i: number, fileObj: File) => {
const start = i * chunkSize // 切片开始位置
const end = Math.min(fileData.value.size, start + chunkSize) // 切片结束位置
const chunkFile = fileObj.slice(start, end) // 切片文件
const formData = new FormData() // formData 参数需要与后端对齐
formData.append('fileName', fileData.value.name)
formData.append('folder', fileData.value.md5)
formData.append('file', chunkFile, String(i + 1)) // 必传字段;若第三个参数不传,切片 filename 默认是 blob ,如果后端是以切片名称来做合并的,则第三个参数一定要传
controller = new AbortController() // 每一次上传切片都要新生成一个 AbortController ,否则重新上传会失败
return await axios
.post('mergeUpload', formData, { // 调用后端上传切片接口
onUploadProgress: (data) => { // 进度条展示
percentage.value = Number(
(
(Math.min(fileData.value.size, start + data.loaded) / fileData.value.size) *
100
).toFixed(2)
)
},
signal: controller.signal // 取消上传
})
.then((res) => updateUrl(res.data))
}
2.7 取消上传
const cancel = () => {
dialogVisible.value = false
cancelUpload.value = true
controller?.abort()
axios.post('cancelUpload', { folder: fileData.value.md5 }) // 调用后端接口,删除已上传的切片
}
3 完整代码
<template>
<el-upload
accept=".mp3, .m4a, .aac, .mp4, .m4v"
:before-upload="beforeUpload"
:http-request="upload"
:show-file-list="false"
:disabled="disabled"
style="display: inline-block"
class="m-x-12"
>
<el-tooltip placement="bottom">
<template #content>
可上传本地录音录像,支持上传的
<br />音频格式为:mp3、m4a、aac
<br />
视频格式为:mp4、m4v
</template>
<el-button type="primary" style="font-size: 12px" :disabled="disabled">
上传录音录像
</el-button>
</el-tooltip>
</el-upload>
<el-dialog
v-model="dialogVisible"
:fullscreen="true"
:show-close="false"
custom-class="dispute-upload-dialog"
>
<div class="center">
<div class="fz-18 ellipsis">正在上传:{{ fileData.name }}</div>
<el-progress :text-inside="true" :stroke-width="16" :percentage="percentage" />
<el-button @click="cancel">取消上传</el-button>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import axios from 'axios'
const beforeUpload = (file: File) => {
const mimeTypes = ['audio/mpeg', 'audio/x-m4a', 'audio/aac', 'video/mp4', 'video/x-m4v']
if (!mimeTypes.includes(file.type)) {
ElMessage({
type: 'error',
message: '只能上传 MP3、M4A、AAC、MP4、M4V 格式的文件',
duration: 6000
})
return false
}
if (file.size / 1024 / 1024 / 1024 > 1.5) {
ElMessage.error('文件大小不能超过 1.5G')
ElMessage({
type: 'error',
message: '文件大小不能超过 1.5G',
duration: 6000
})
return false
}
return true
}
const dialogVisible = ref(false)
const cancelUpload = ref(false)
let controller: AbortController | null = null
const chunkSize = 1 * 1024 * 1024 // 切片大小
const percentage = ref(0)
const fileData = ref({
name: '',
size: 0,
type: '',
suffix: '',
md5: ''
})
const cancel = () => {
dialogVisible.value = false
cancelUpload.value = true
controller?.abort()
axios.post('cancelUpload', { folder: fileData.value.md5 })
}
let counter = 0
const getFileMd5 = () => {
let guid = (+new Date()).toString(32)
for (let i = 0; i < 5; i++) {
guid += Math.floor(Math.random() * 65535).toString(32)
}
return 'wu_' + guid + (counter++).toString(32)
}
const updateUrl = (fileUrl: string) => {
axios.post('saveUrl', {
fileName: fileData.value.name,
fileUrl
})
}
const uploadChunkFile = async (i: number, fileObj: File) => {
const start = i * chunkSize
const end = Math.min(fileData.value.size, start + chunkSize)
const chunkFile = fileObj.slice(start, end)
const formData = new FormData()
formData.append('encrypt', 'true')
formData.append('fileName', fileData.value.name)
formData.append('folder', fileData.value.md5)
formData.append('file', chunkFile, String(i + 1))
controller = new AbortController()
return await axios
.post('mergeUpload', formData, {
onUploadProgress: (data) => {
percentage.value = Number(
(
(Math.min(fileData.value.size, start + data.loaded) / fileData.value.size) *
100
).toFixed(2)
)
},
signal: controller.signal
})
.then((res) => updateUrl(res.data))
}
const batchUpload = async (fileObj: File) => {
percentage.value = 0
dialogVisible.value = true
cancelUpload.value = false
const chunkCount = Math.ceil(fileData.value.size / chunkSize) // 切片数量
fileData.value.md5 = getFileMd5() // 文件唯一标识
for (let i = 0; i < chunkCount; i++) {
if (cancelUpload.value) return
const res = await uploadChunkFile(i, fileObj)
if (res.code !== 0) {
dialogVisible.value = false
ElMessageBox({ message: `${fileData.value.name}上传失败`, title: '提示' })
return
}
if (i === chunkCount - 1) {
setTimeout(() => {
dialogVisible.value = false
ElMessageBox({ message: `${fileData.value.name}上传成功`, title: '提示' })
axios.post('mergeUpload', { folder: fileData.value.md5 }).then((res) => updateUrl(res.data))
}, 500)
}
}
}
const disabled = ref(false)
const upload = async (file: { file: File }) => {
const fileObj = file.file
const nameList = fileObj.name.split('.')
fileData.value.name = fileObj.name
fileData.value.size = fileObj.size
fileData.value.type = fileObj.type
fileData.value.suffix = nameList[nameList.length - 1]
if (chunkSize > fileData.value.size) {
disabled.value = true
axios
.post('upload', fileObj)
.then((res) => {
ElMessageBox({ message: `${fileData.value.name}上传成功`, title: '提示' })
updateUrl(res.data)
})
.catch(() => ElMessageBox({ message: `${fileData.value.name}上传失败`, title: '提示' }))
.finally(() => (disabled.value = false))
return
}
batchUpload(fileObj)
}
</script>
<style lang="scss">
.dispute-upload-dialog {
background: none;
}
</style>
<style lang="scss" scoped>
.center {
color: #fff;
width: 50%;
text-align: center;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
</style>
4 拓展
- hash 值作为文件唯一标识
引入 js-spark-md5 库做文件 hash 计算 - 切片并发上传
需要控制好并发数量 - 秒传
上传文件前请求后端接口,通过文件 hash 值判断否存已经上传过该文件,存在则无需再上传该文件,直接返回上传成功,实现秒传 - 断点续传
上传切片前请求后端接口,通过切片 hash 值判断是否已经上传过该切片,存在则无需再上传该切片,从下一个切片开始上传,实现断点续传