前言
在实际项目开发中,我们经常会遇到需要上传大文件的场景。传统的文件上传方式在处理大文件时存在诸多问题:
- 上传时间长:大文件上传耗时,用户体验差
- 易失败:网络不稳定时容易导致上传失败
- 无法续传:上传失败后需要重新上传整个文件
- 服务器压力大:大文件一次性上传占用大量内存
本文将介绍如何使用 Vue3 + Element Plus 实现一个功能完善的大文件分片上传组件,支持以下特性:
✅ 分片上传 :将大文件切分成小块上传
✅ 秒传功能 :通过 MD5 校验实现文件秒传
✅ 断点续传 :上传失败后可从断点处继续
✅ 上传进度 :实时显示上传进度
✅ 文件校验 :支持文件类型、大小校验
✅ 拖拽排序:支持文件列表拖拽排序
技术栈
- Vue 3
- Element Plus
- SparkMD5(计算文件 MD5)
- Sortable.js(拖拽排序)
核心原理
1. 分片上传流程
1. 选择文件
2. 计算文件 MD5 值
3. 检查文件是否已上传(秒传)
4. 将文件切分成多个分片
5. 逐个上传分片
6. 服务端合并分片
7. 返回文件地址
2. MD5 计算
使用 SparkMD5 库计算文件的 MD5 值,作为文件的唯一标识:
javascript
function computeMD5(file) {
return new Promise((resolve, reject) => {
const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
const chunkSize = 2097152 // 2MB
const chunks = Math.ceil(file.size / chunkSize)
let currentChunk = 0
const spark = new SparkMD5.ArrayBuffer()
const fileReader = new FileReader()
fileReader.onload = function (e) {
spark.append(e.target.result)
currentChunk++
if (currentChunk < chunks) {
loadNext()
} else {
resolve(spark.end())
}
}
fileReader.onerror = () => reject('MD5 computation failed')
function loadNext() {
const start = currentChunk * chunkSize
const end = Math.min(start + chunkSize, file.size)
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end))
}
loadNext()
})
}
3. 文件分片
将大文件按指定大小切分成多个小块:
javascript
function createChunks(file, chunkSize) {
const chunks = []
let cur = 0
while (cur < file.size) {
chunks.push(file.slice(cur, cur + chunkSize))
cur += chunkSize
}
return chunks
}
完整组件代码
ChunkUpload/index.vue
vue
<template>
<div class="upload-file">
<el-upload
multiple
:action="uploadFileUrl"
:before-upload="handleBeforeUpload"
:file-list="fileList"
:limit="limit"
:on-error="handleUploadError"
:on-exceed="handleExceed"
:show-file-list="false"
:headers="headers"
:http-request="customUpload"
class="upload-file-uploader"
ref="fileUpload"
v-if="!disabled"
>
<!-- 上传按钮 -->
<el-button v-if="!isUploadBtn" type="primary">选取文件</el-button>
<slot v-else name="btn"></slot>
</el-upload>
<!-- 上传提示 -->
<div class="el-upload__tip" v-if="showTip && !disabled">
请上传
<template v-if="fileSize"> 大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b> </template>
<template v-if="fileType"> 格式为 <b style="color: #f56c6c">{{ fileType.join("/") }}</b> </template>
的文件
</div>
<!-- 文件列表 -->
<transition-group
v-if="showTip"
ref="uploadFileList"
class="upload-file-list el-upload-list el-upload-list--text"
name="el-fade-in-linear"
tag="ul"
>
<li
:key="file.uid"
class="el-upload-list__item ele-upload-list__item-content"
v-for="(file, index) in fileList"
>
<el-link :href="file.url" :underline="false" target="_blank">
<span class="el-icon-document"> {{ getFileName(file.name) }} </span>
</el-link>
<div class="ele-upload-list__item-content-action">
<el-link :underline="false" @click="handleDelete(index)" type="danger" v-if="!disabled">
删除
</el-link>
</div>
<div v-if="file.status === 'uploading'" class="upload-progress">
<el-progress :percentage="file.progress || 0" />
</div>
</li>
</transition-group>
</div>
</template>
<script setup>
import { getToken } from "@/utils/auth"
import { uploadFileChunk, checkFileChunk } from '@/api/file/uploadFile'
import Sortable from 'sortablejs'
import SparkMD5 from 'spark-md5'
const props = defineProps({
modelValue: [String, Object, Array],
// 分片上传地址
action: {
type: String,
default: "/file/uploadBig"
},
// 检查文件地址
checkAction: {
type: String,
default: "/file/checkFile"
},
// 分片大小 (默认 5MB)
chunkSize: {
type: Number,
default: 5 * 1024 * 1024
},
// 上传携带的参数
data: {
type: Object
},
// 数量限制
limit: {
type: Number,
default: 5
},
// 大小限制(MB)
fileSize: {
type: Number,
default: 50000
},
// 文件类型
fileType: {
type: Array,
default: () => ["doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "pdf"]
},
// 是否显示提示
isShowTip: {
type: Boolean,
default: true
},
// 禁用组件
disabled: {
type: Boolean,
default: false
},
// 拖动排序
drag: {
type: Boolean,
default: true
},
// 上传按钮使用插槽
isUploadBtn: {
type: Boolean,
default: false
},
// 自定义返回参数
customParams: {
type: Boolean,
default: false
},
// 是否支持重复上传
isRepeat: {
type: Boolean,
default: false
}
})
const { proxy } = getCurrentInstance()
const emit = defineEmits()
const number = ref(0)
const uploadList = ref([])
const uploadFileUrl = ref(import.meta.env.VITE_APP_BASE_API + props.action)
const baseCdnUrl = import.meta.env.VITE_APP_BASE_CDN
const headers = ref({ Authorization: "Bearer " + getToken() })
const fileList = ref([])
const showTip = computed(
() => props.isShowTip && (props.fileType || props.fileSize)
)
watch(() => props.modelValue, val => {
if (val && !props.isRepeat) {
let temp = 1
const list = Array.isArray(val) ? val : props.modelValue.split(',')
fileList.value = list.map(item => {
if (typeof item === "string") {
item = { name: baseCdnUrl + item, url: baseCdnUrl + item }
}
item.uid = item.uid || new Date().getTime() + temp++
return item
})
} else {
fileList.value = []
number.value = 0
return []
}
}, { deep: true, immediate: true })
// 上传前校检
function handleBeforeUpload(file) {
// 校检文件类型
if (props.fileType.length) {
const fileName = file.name.split('.')
const fileExt = fileName[fileName.length - 1]
const isTypeOk = props.fileType.indexOf(fileExt) >= 0
if (!isTypeOk) {
proxy.$modal.msgError(`文件格式不正确,请上传${props.fileType.join("/")}格式文件!`)
return false
}
}
// 校检文件名
if (file.name.includes(',')) {
proxy.$modal.msgError('文件名不正确,不能包含英文逗号!')
return false
}
// 校检文件大小
if (props.fileSize) {
const isLt = file.size / 1024 / 1024 < props.fileSize
if (!isLt) {
proxy.$modal.msgError(`上传文件大小不能超过 ${props.fileSize} MB!`)
return false
}
}
proxy.$modal.loading("正在处理上传文件,请稍候...")
number.value++
return true
}
// 计算MD5
function computeMD5(file) {
return new Promise((resolve, reject) => {
const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
const chunkSize = 2097152 // 2MB
const chunks = Math.ceil(file.size / chunkSize)
let currentChunk = 0
const spark = new SparkMD5.ArrayBuffer()
const fileReader = new FileReader()
fileReader.onload = function (e) {
spark.append(e.target.result)
currentChunk++
if (currentChunk < chunks) {
loadNext()
} else {
const md5 = spark.end()
resolve(md5)
}
}
fileReader.onerror = function () {
reject('MD5 computation failed')
}
function loadNext() {
const start = currentChunk * chunkSize
const end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end))
}
loadNext()
})
}
// 自定义上传
async function customUpload(options) {
const { file } = options
// 初始化进度
const fileIndex = fileList.value.findIndex(f => f.uid === file.uid)
if (fileIndex === -1) {
fileList.value.push({
name: file.name,
uid: file.uid,
status: 'uploading',
progress: 0
})
}
try {
// 1. 计算文件MD5
const md5 = await computeMD5(file)
// 2. 检查文件状态(秒传/断点续传)
const checkRes = await checkFileChunk({ md5 })
if (checkRes.code === 200 && checkRes.data && checkRes.data.relativePath) {
// 秒传成功
const successRes = {
code: 200,
msg: "操作成功",
data: {
relativePath: checkRes.data.relativePath,
url: checkRes.data.relativePath
}
}
updateFileProgress(file.uid, 100)
handleUploadSuccess(successRes, file)
return
}
// 获取已上传的分片状态
const uploadedStatus = checkRes.data?.chucks || ""
const chunks = createChunks(file, props.chunkSize)
const totalChunks = chunks.length
// 3. 逐个上传分片
for (let i = 0; i < totalChunks; i++) {
// 检查当前分片是否已上传
if (uploadedStatus.length > i && uploadedStatus[i] === '1') {
const progress = Math.ceil(((i + 1) / totalChunks) * 100)
updateFileProgress(file.uid, progress)
proxy.$modal.loadingText("正在上传文件,已完成 " + progress + "%")
continue
}
let fileName = file.name
const formData = new FormData()
formData.append('file', new File([chunks[i]], fileName))
formData.append('chunkNumber', i)
formData.append('chunkSize', props.chunkSize)
formData.append('totalNumber', totalChunks)
formData.append('md5', md5)
// 添加额外参数
if (props.data) {
Object.keys(props.data).forEach(key => {
formData.append(key, props.data[key])
})
}
const res = await uploadFileChunk(formData)
if (res.code === 200) {
if (i === totalChunks - 1) {
updateFileProgress(file.uid, 100)
handleUploadSuccess(res, file)
proxy.$modal.msgSuccess('文件上传成功')
return
}
} else {
throw new Error(res.msg || '上传失败')
}
// 更新进度
const progress = Math.ceil(((i + 1) / totalChunks) * 100)
updateFileProgress(file.uid, progress)
proxy.$modal.loadingText("正在上传文件,已完成 " + progress + "%")
}
} catch (err) {
handleUploadError(err)
}
}
// 创建分片
function createChunks(file, chunkSize) {
const chunks = []
let cur = 0
while (cur < file.size) {
chunks.push(file.slice(cur, cur + chunkSize))
cur += chunkSize
}
return chunks
}
// 更新文件进度
function updateFileProgress(uid, progress) {
const item = fileList.value.find(f => f.uid === uid)
if (item) {
item.progress = progress
}
}
// 文件个数超出
function handleExceed() {
proxy.$modal.msgError(`上传文件数量不能超过 ${props.limit} 个!`)
}
// 上传失败
function handleUploadError(err) {
console.error(err)
proxy.$modal.msgError("上传文件失败")
proxy.$modal.closeLoading()
fileList.value = fileList.value.filter(f => f.status !== 'uploading')
number.value--
}
// 上传成功回调
function handleUploadSuccess(res, file) {
if (!res) return
if (res.code === 200) {
if (props.customParams) {
emit('update:files', { url: res.data?.relativePath })
proxy.$modal.closeLoading()
return
}
const url = res.data.relativePath || res.data.url || res.data.path
uploadList.value.push({ name: url, url: url })
uploadedSuccessfully()
} else {
number.value--
proxy.$modal.closeLoading()
proxy.$modal.msgError(res.msg)
uploadedSuccessfully()
}
}
// 删除文件
function handleDelete(index) {
fileList.value.splice(index, 1)
emit("update:modelValue", listToString(fileList.value))
}
// 上传结束处理
function uploadedSuccessfully() {
if (number.value > 0 && uploadList.value.length === number.value) {
fileList.value = fileList.value.filter(f => f.status !== 'uploading').concat(uploadList.value)
uploadList.value = []
number.value = 0
emit("update:modelValue", listToString(fileList.value))
proxy.$modal.closeLoading()
}
}
// 获取文件名称
function getFileName(name) {
if (name.lastIndexOf("/") > -1) {
return name.slice(name.lastIndexOf("/") + 1)
} else {
return name
}
}
// 对象转成指定字符串分隔
function listToString(list, separator) {
let strs = ""
separator = separator || ","
for (let i in list) {
if (list[i].url) {
strs += list[i].url + separator
}
}
return strs != '' ? strs.substr(0, strs.length - 1) : ''
}
// 初始化拖拽排序
onMounted(() => {
if (props.drag && !props.disabled) {
nextTick(() => {
const element = proxy.$refs.uploadFileList?.$el || proxy.$refs.uploadFileList
if (element) {
Sortable.create(element, {
ghostClass: 'file-upload-darg',
onEnd: (evt) => {
const movedItem = fileList.value.splice(evt.oldIndex, 1)[0]
fileList.value.splice(evt.newIndex, 0, movedItem)
emit('update:modelValue', listToString(fileList.value))
}
})
}
})
}
})
</script>
<style scoped lang="scss">
.file-upload-darg {
opacity: 0.5;
background: #c8ebfb;
}
.upload-file-uploader {
margin-bottom: 5px;
}
.upload-file-list .el-upload-list__item {
border: 1px solid #e4e7ed;
line-height: 2;
margin-bottom: 10px;
position: relative;
transition: none !important;
}
.upload-file-list .ele-upload-list__item-content {
display: flex;
justify-content: space-between;
align-items: center;
color: inherit;
flex-wrap: wrap;
}
.ele-upload-list__item-content-action .el-link {
margin-right: 10px;
}
.upload-progress {
width: 100%;
padding: 0 10px;
margin-top: 5px;
}
</style>
API 接口定义
uploadFile.js
javascript
import request from '@/utils/request'
// 分片上传
export function uploadFileChunk(data) {
return request({
url: '/file/uploadBig',
method: 'post',
data: data,
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
// 检查文件状态
export function checkFileChunk(params) {
return request({
url: '/file/checkFile',
method: 'get',
params: params
})
}
使用示例
基础用法
vue
<template>
<div>
<ChunkUpload
v-model="fileList"
:chunk-size="5 * 1024 * 1024"
:file-size="500"
:file-type="['pdf', 'doc', 'docx']"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import ChunkUpload from '@/components/ChunkUpload'
const fileList = ref('')
</script>
自定义上传按钮
vue
<template>
<ChunkUpload
v-model="fileList"
:is-upload-btn="true"
>
<template #btn>
<el-button type="success" icon="Upload">
点击上传大文件
</el-button>
</template>
</ChunkUpload>
</template>
表单中使用
vue
<template>
<el-form :model="form" ref="formRef">
<el-form-item label="附件上传" prop="attachments">
<ChunkUpload
v-model="form.attachments"
:limit="3"
:file-size="1000"
:chunk-size="10 * 1024 * 1024"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm">提交</el-button>
</el-form-item>
</el-form>
</template>
<script setup>
import { ref } from 'vue'
import ChunkUpload from '@/components/ChunkUpload'
const formRef = ref()
const form = ref({
attachments: ''
})
const submitForm = () => {
formRef.value.validate((valid) => {
if (valid) {
console.log('提交的附件:', form.value.attachments)
// 提交表单逻辑
}
})
}
</script>
Props 参数说明
| 参数 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| modelValue | 文件列表(v-model) | String/Array/Object | - |
| action | 分片上传接口地址 | String | '/file/uploadBig' |
| checkAction | 检查文件接口地址 | String | '/file/checkFile' |
| chunkSize | 分片大小(字节) | Number | 5242880 (5MB) |
| data | 上传时附带的额外参数 | Object | - |
| limit | 最大上传文件数 | Number | 5 |
| fileSize | 文件大小限制(MB) | Number | 50000 |
| fileType | 允许的文件类型 | Array | ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'pdf'] |
| isShowTip | 是否显示提示信息 | Boolean | true |
| disabled | 是否禁用(只读模式) | Boolean | false |
| drag | 是否支持拖拽排序 | Boolean | true |
| isUploadBtn | 是否使用自定义上传按钮 | Boolean | false |
| customParams | 是否自定义返回参数 | Boolean | false |
| isRepeat | 是否支持重复上传 | Boolean | false |
Events 事件
| 事件名 | 说明 | 回调参数 |
|---|---|---|
| update:modelValue | 文件列表变化时触发 | (fileList: string) |
| update:files | 自定义参数模式下触发 | ({ url: string }) |
服务端接口要求
1. 检查文件接口 (GET /file/checkFile)
请求参数:
javascript
{
md5: string // 文件的 MD5 值
}
返回格式:
javascript
{
code: 200,
msg: "操作成功",
data: {
relativePath: string, // 如果文件已存在,返回文件路径(秒传)
chucks: string // 已上传的分片状态,如 "111000" 表示前3个分片已上传
}
}
2. 分片上传接口 (POST /file/uploadBig)
请求参数(FormData):
javascript
{
file: File, // 分片文件
chunkNumber: number, // 当前分片索引(从0开始)
chunkSize: number, // 分片大小
totalNumber: number, // 总分片数
md5: string // 文件 MD5 值
}
返回格式:
javascript
{
code: 200,
msg: "操作成功",
data: {
relativePath: string, // 文件完整路径(最后一个分片上传成功时返回)
url: string // 文件访问地址
}
}
服务端实现示例(Java Spring Boot)
java
@RestController
@RequestMapping("/file")
public class FileUploadController {
@Autowired
private FileService fileService;
/**
* 检查文件状态
*/
@GetMapping("/checkFile")
public AjaxResult checkFile(@RequestParam String md5) {
// 1. 检查文件是否已存在(秒传)
String filePath = fileService.getFileByMd5(md5);
if (StringUtils.isNotEmpty(filePath)) {
return AjaxResult.success(Map.of("relativePath", filePath));
}
// 2. 检查已上传的分片
String chucks = fileService.getUploadedChunks(md5);
return AjaxResult.success(Map.of("chucks", chucks));
}
/**
* 分片上传
*/
@PostMapping("/uploadBig")
public AjaxResult uploadBig(
@RequestParam("file") MultipartFile file,
@RequestParam("chunkNumber") Integer chunkNumber,
@RequestParam("chunkSize") Long chunkSize,
@RequestParam("totalNumber") Integer totalNumber,
@RequestParam("md5") String md5
) throws IOException {
// 1. 保存分片
String chunkPath = fileService.saveChunk(file, md5, chunkNumber);
// 2. 检查是否所有分片都已上传
if (fileService.isAllChunksUploaded(md5, totalNumber)) {
// 3. 合并分片
String filePath = fileService.mergeChunks(md5, totalNumber);
// 4. 保存文件记录
fileService.saveFileRecord(md5, filePath);
return AjaxResult.success(Map.of(
"relativePath", filePath,
"url", filePath
));
}
return AjaxResult.success();
}
}
核心功能详解
1. 秒传功能
通过计算文件的 MD5 值,在上传前先检查服务器是否已存在相同的文件。如果存在,直接返回文件地址,无需重新上传。
优势:
- 节省带宽和时间
- 减轻服务器压力
- 提升用户体验
2. 断点续传
上传过程中如果网络中断或页面刷新,再次上传时会检查已上传的分片,只上传未完成的部分。
实现原理:
- 服务端记录每个文件(通过 MD5 标识)已上传的分片
- 客户端上传前先查询已上传的分片状态
- 跳过已上传的分片,只上传剩余部分
3. 进度显示
实时显示上传进度,让用户了解上传状态。
javascript
// 计算进度百分比
const progress = Math.ceil(((currentChunk + 1) / totalChunks) * 100)
updateFileProgress(file.uid, progress)
4. 文件校验
上传前进行文件类型、大小、文件名等校验,避免无效上传。
javascript
// 文件类型校验
const fileExt = file.name.split('.').pop()
const isTypeOk = props.fileType.indexOf(fileExt) >= 0
// 文件大小校验
const isLt = file.size / 1024 / 1024 < props.fileSize
性能优化建议
1. 合理设置分片大小
- 小文件(< 10MB):不建议使用分片上传
- 中等文件(10MB - 100MB):建议 5MB 分片
- 大文件(> 100MB):建议 10MB 分片
2. 并发上传
可以修改代码支持多个分片并发上传,提升上传速度:
javascript
// 并发上传示例
const concurrency = 3 // 并发数
const uploadPromises = []
for (let i = 0; i < totalChunks; i += concurrency) {
const batch = []
for (let j = 0; j < concurrency && i + j < totalChunks; j++) {
batch.push(uploadChunk(i + j))
}
await Promise.all(batch)
}
3. 服务端优化
- 使用临时目录存储分片
- 合并完成后删除分片文件
- 使用消息队列异步处理合并操作
- 定期清理过期的分片文件
常见问题
1. 上传大文件时浏览器卡顿
原因: MD5 计算占用大量 CPU
解决: 使用 Web Worker 在后台线程计算 MD5
2. 分片上传失败
原因: 网络不稳定或服务器超时
解决: 添加重试机制,失败的分片自动重试
3. 文件合并失败
原因: 分片顺序错误或分片丢失
解决: 服务端严格校验分片完整性
总结
本文介绍了一个功能完善的 Vue3 大文件分片上传组件,支持秒传、断点续传、进度显示等特性。通过合理的分片策略和 MD5 校验,可以大大提升大文件上传的成功率和用户体验。
核心要点:
- 使用 SparkMD5 计算文件唯一标识
- 通过 File.slice() 实现文件分片
- 服务端记录分片状态,支持断点续传
- 实时显示上传进度,提升用户体验
希望这篇文章能帮助你在项目中实现高效的大文件上传功能!
依赖安装
bash
# 安装 SparkMD5
npm install spark-md5
# 安装 Sortable.js(如需拖拽排序)
npm install sortablejs
如果觉得这个组件对你有帮助,欢迎 Star ⭐