最近项目上遇到了一个大文件上传的需求,故有这篇文章
我所用的框架是 element-ui vue2
使用的分片上传的组件插件是 spark-md5
1. 下载插件
npm i spark-md5
2. html的文件内容
<div class="upload-container">
<el-upload
class="upload-demo"
action="#"
:http-request="customUploadRequest"
:before-upload="beforeUpload"
:on-progress="handleProgress"
:show-file-list="true"
:file-list="fileList"
:on-remove="handleRemove"
>
<el-button size="small" type="primary" icon="el-icon-upload2" :disabled="uploading || isParsingLargeFile">点击上传</el-button>
</el-upload>
<div class="upload-progress" v-if="uploading">
<el-progress
:percentage="uploadProgress"
:stroke-width="18"
></el-progress>
<div class="chunk-info">{{ currentChunk }}/{{ totalChunks }} 上传中...</div>
<!-- <el-button type="danger" @click="pauseUpload" icon="el-icon-video-pause">暂停上传</el-button> -->
</div>
<div class="resume-upload" v-if="canResume && !uploading">
<el-button type="primary" @click="resumeUpload" icon="el-icon-video-play">继续上传</el-button>
</div>
</div>
3. script 文件所在的内容
// 分片上传代码
beforeUpload (file) {
// 设定最大文件大小(单位:字节),比如100MB
const MAX_SIZE = 100 * 1024 * 1024
if (file.size > MAX_SIZE) {
// 文件过大,给用户提示
// this.$message.warning('文件过大,正在解析,请稍等片刻')
this.isParsingLargeFile = true
this.parsingMessageInstance = this.$message({
message: '正在解析中,请稍等',
type: 'warning',
duration: 0, // 不自动关闭
showClose: false
})
} else {
this.isParsingLargeFile = false
}
// 生成文件唯一标识,用于断点续传
this.file = file
this.calculateFileHash(file).then(hash => {
this.fileHash = hash
console.log(hash, 'hashhashhash')
// 检查是否可以断点续传
this.checkFileExists(hash).then(({ data: exists }) => {
console.log(exists, 111111)
if (!exists.data) {
console.log(exists, 'exists')
// 获取已上传的分片列表
this.getUploadedChunks(hash).then(uploaded => {
this.uploadedChunks = uploaded
this.canResume = true
this.isParsingLargeFile = false
if (this.parsingMessageInstance) {
this.parsingMessageInstance.close()
this.parsingMessageInstance = null
}
// 自动调用上传逻辑
this.uploadFile(file).catch(err => {
this.$message.error('自动上传失败: ' + err.message)
this.isParsingLargeFile = false
if (this.parsingMessageInstance) {
this.parsingMessageInstance.close()
this.parsingMessageInstance = null
}
this.canResume = false
this.uploading = false
})
})
}
})
}).finally(() => {
this.isParsingLargeFile = false
if (this.parsingMessageInstance) {
this.parsingMessageInstance.close()
this.parsingMessageInstance = null
}
})
// 返回false阻止默认上传
return false
},
// 计算文件哈希,用于断点续传标识
calculateFileHash (file) {
console.log('第一个请求', file)
return new Promise(resolve => {
const spark = new SparkMD5.ArrayBuffer()
const reader = new FileReader()
const chunks = Math.ceil(file.size / (2 * 1024 * 1024))
let currentChunk = 0
// 只计算文件部分内容的哈希,提高性能
const loadNext = () => {
const start = currentChunk * (2 * 1024 * 1024)
const end = Math.min(start + 2 * 1024 * 1024, file.size)
reader.readAsArrayBuffer(file.slice(start, end))
}
reader.onload = e => {
spark.append(e.target.result)
currentChunk++
if (currentChunk < chunks) {
loadNext()
} else {
resolve(spark.end())
}
}
loadNext()
})
},
// 检查文件是否已存在
checkFileExists (hash) {
return uploadCheckFile(
{
fileHash: hash
}
)
},
// 获取已上传的分片
getUploadedChunks (hash) {
return uploadedChunks({
fileHash: hash
})
},
// 文件切片
createFileChunks (file) {
// 1. 分片开始时弹出不自动关闭的提示消息,并保存实例
if (this.parsingMessageInstance) {
this.parsingMessageInstance.close()
}
this.parsingMessageInstance = this.$message({
message: '正在解析中,请稍等',
type: 'info',
duration: 0, // 不自动关闭
showClose: false
})
const chunks = []
let cur = 0
while (cur < file.size) {
chunks.push({
file: file.slice(cur, cur + this.chunkSize),
index: chunks.length
})
cur += this.chunkSize
}
this.totalChunks = chunks.length
// 2. 分片完成后关闭提示消息
if (this.parsingMessageInstance) {
this.parsingMessageInstance.close()
this.parsingMessageInstance = null
}
return chunks
},
// 自定义上传请求
customUploadRequest ({ file, onProgress, onSuccess, onError }) {
// 这里使用我们自己的上传逻辑,不使用默认的
this.uploadFile(file).then(onSuccess).catch(onError)
},
// 上传文件
uploadFile (file) {
console.log(file, '文件')
this.uploading = true
this.uploadProgress = 0
this.file = file
this.chunks = this.createFileChunks(file)
this.uploadedChunks = []
this.currentChunk = 0
// 检查是否可以断点续传
return this.checkFileExists(this.fileHash).then(({ data: exists }) => {
console.log(exists.data, 'exists66666')
if (exists.data) {
return this.getUploadedChunks(this.fileHash).then(({ data: uploaded }) => {
console.log(uploaded, 'uploaded')
this.uploadedChunks = uploaded.data
this.canResume = true
// 上传剩余分片
return this.uploadChunks()
})
} else {
// 生成唯一上传ID
return initUpload({
fileHash: this.fileHash,
fileName: file.name,
fileSize: file.size
}).then(({ data: res }) => {
this.uploadId = res.data.uploadId
// 上传所有分片
return this.uploadChunks()
})
}
})
},
// 上传所有分片
uploadChunks () {
// 过滤已上传的分片
const chunksToUpload = this.chunks.filter((_, index) => !this.uploadedChunks.includes(index))
// 没有需要上传的分片,直接提示分片已上传完成
if (chunksToUpload.length === 0) {
this.uploading = false
this.uploadProgress = 100
this.canResume = false
this.$message.success('所有分片上传完成,请点击确定提交后合并文件')
// 添加到 fileList,标记为“待合并”
// 避免重复添加
if (!this.fileList.some(f => f.name === this.file.name && f.status === 'pending')) {
this.fileList.push({
name: this.file.name,
status: 'pending' // 自定义字段,表示待合并
// 你可以加更多字段,比如 fileHash、uploadId 等
})
}
// 1秒后进度条归零,提升体验
setTimeout(() => {
this.uploadProgress = 0
}, 1000)
return Promise.resolve()
}
// 批量上传分片,控制并发
return new Promise((resolve, reject) => {
let completed = 0
let failed = 0
let index = 0
const uploadNext = () => {
if (index >= chunksToUpload.length) {
if (failed > 0) {
reject(new Error(`${failed}个分片上传失败`))
} else {
// 上传完成后,不再自动调用mergeChunks // 交给后端做合并
this.fileList.push({
fileHash: this.fileHash,
fileName: this.file.name,
fileSize: this.file.size,
totalChunks: this.totalChunks,
uploadId: this.uploadId,
name: this.file.name
})
this.uploading = false
this.uploadProgress = 100
this.canResume = false // 上传完成后禁用继续上传按钮
this.$message.success('文件上传成功!')
resolve()
}
return
}
const chunk = chunksToUpload[index]
const formData = new FormData()
formData.append('file', chunk.file)
formData.append('fileHash', this.fileHash)
formData.append('fileName', this.file.name)
formData.append('chunkIndex', chunk.index)
formData.append('totalChunks', this.totalChunks)
formData.append('uploadId', this.uploadId)
uploadChunk(formData, {
onUploadProgress: progressEvent => {
// 计算当前分片的进度百分比
const percent = Math.floor((progressEvent.loaded * 100) / progressEvent.total)
// 计算总进度 = (已上传分片数 + 当前分片进度) / 总分片数
// this.uploadedChunks.length 表示已上传分片数
// completed 表示本轮已完成的分片数
// percent/100 表示当前分片的上传进度
const alreadyUploaded = this.uploadedChunks.length
const currentUploading = completed + percent / 100
const totalPercent = ((alreadyUploaded + currentUploading) / this.totalChunks) * 100
this.uploadProgress = Math.floor(totalPercent)
}
})
.then(() => {
completed++
this.currentChunk = completed // 实时更新当前已上传分片数
this.uploadedChunks.push(chunk.index)
// 上传完成后再更新一次进度条,确保进度条到达正确位置
this.uploadProgress = Math.floor((this.uploadedChunks.length / this.totalChunks) * 100)
index++
uploadNext()
})
.catch(err => {
console.error(`分片 ${chunk.index} 上传失败:`, err)
failed++
index++
uploadNext()
// 如果失败次数超过3次,可以认为上传失败
if (failed >= 3) {
reject(err)
this.$message.error('上传失败,请刷新页面重新上传')
}
})
}
// 开始上传
uploadNext()
})
},
// 合并分片
mergeChunks () {
this.$message.info('所有分片上传完成,正在合并文件,请稍候...')
return mergeChunks({
fileHash: this.fileHash,
fileName: this.file.name,
fileSize: this.file.size,
totalChunks: this.totalChunks,
uploadId: this.uploadId
})
.then(({ data: res }) => {
this.uploading = false
this.uploadProgress = 100
this.canResume = false // 上传完成后禁用继续上传按钮
this.$message.success('文件上传成功!')
// 添加到文件列表
this.fileList.push({
name: this.file.name,
url: res.data.url
})
return res.data
})
.catch(err => {
this.uploading = false
this.$message.error('合并文件失败: ' + err.message)
throw err
})
},
4.所需要的后端接口
Vue2 Element UI 分片上传前端实现方案
在Vue2和Element UI环境下实现分片上传功能,主要思路是将大文件分割成多个小文件片段并行或串行上传,最终在服务端合并。以下是具体实现步骤:
核心实现代码
<template>
<div>
<el-upload
class="upload-demo"
drag
action="#"
:http-request="customUploadRequest"
:before-upload="beforeUpload"
:on-progress="handleProgress"
:show-file-list="true"
:file-list="fileList"
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
<div class="el-upload__tip" slot="tip">只能上传jpg/png文件,且不超过500MB</div>
</el-upload>
<el-progress v-if="uploading" :percentage="uploadProgress" :stroke-width="18"></el-progress>
<div v-if="uploading">{{ currentChunk }}/{{ totalChunks }} 分片上传中...</div>
<el-button v-if="canResume && !uploading" type="primary" @click="resumeUpload">继续上传</el-button>
<el-button v-if="uploading" type="danger" @click="pauseUpload">暂停上传</el-button>
</div>
</template>
<script>
import SparkMD5 from 'spark-md5';
import axios from 'axios';
export default {
name: 'ChunkUploader',
data() {
return {
fileList: [],
uploading: false,
uploadProgress: 0,
file: null,
chunkSize: 2 * 1024 * 1024, // 2MB 每片
chunks: [],
currentChunk: 0,
totalChunks: 0,
uploadedChunks: [], // 已上传的分片索引
canResume: false,
fileHash: '',
uploadId: '' // 服务端生成的上传ID
};
},
methods: {
beforeUpload(file) {
// 生成文件唯一标识,用于断点续传
this.file = file;
this.calculateFileHash(file).then(hash => {
this.fileHash = hash;
// 检查是否可以断点续传
this.checkFileExists(hash).then(exists => {
if (exists) {
// 获取已上传的分片列表
this.getUploadedChunks(hash).then(uploaded => {
this.uploadedChunks = uploaded;
this.canResume = true;
this.$message.success('文件已存在,可继续上传');
});
}
});
});
// 返回false阻止默认上传
return false;
},
// 计算文件哈希,用于断点续传标识
calculateFileHash(file) {
return new Promise(resolve => {
const spark = new SparkMD5.ArrayBuffer();
const reader = new FileReader();
const chunks = Math.ceil(file.size / (2 * 1024 * 1024));
let currentChunk = 0;
// 只计算文件部分内容的哈希,提高性能
const loadNext = () => {
const start = currentChunk * (2 * 1024 * 1024);
const end = Math.min(start + (2 * 1024 * 1024), file.size);
reader.readAsArrayBuffer(file.slice(start, end));
};
reader.onload = e => {
spark.append(e.target.result);
currentChunk++;
if (currentChunk < chunks) {
loadNext();
} else {
resolve(spark.end());
}
};
loadNext();
});
},
// 检查文件是否已存在
checkFileExists(hash) {
return axios.get(`/api/checkFile?fileHash=${hash}`);
},
// 获取已上传的分片
getUploadedChunks(hash) {
return axios.get(`/api/uploadedChunks?fileHash=${hash}`);
},
// 文件切片
createFileChunks(file) {
const chunks = [];
let cur = 0;
while (cur < file.size) {
chunks.push({
file: file.slice(cur, cur + this.chunkSize),
index: chunks.length
});
cur += this.chunkSize;
}
this.totalChunks = chunks.length;
return chunks;
},
// 自定义上传请求
customUploadRequest({ file, onProgress, onSuccess, onError }) {
// 这里使用我们自己的上传逻辑,不使用默认的
this.uploadFile(file).then(onSuccess).catch(onError);
},
// 上传文件
uploadFile(file) {
this.uploading = true;
this.uploadProgress = 0;
this.file = file;
this.chunks = this.createFileChunks(file);
this.uploadedChunks = [];
this.currentChunk = 0;
// 检查是否可以断点续传
return this.checkFileExists(this.fileHash).then(exists => {
if (exists) {
return this.getUploadedChunks(this.fileHash).then(uploaded => {
this.uploadedChunks = uploaded;
this.canResume = true;
// 上传剩余分片
return this.uploadChunks();
});
} else {
// 生成唯一上传ID
return axios.post('/api/initUpload', {
fileHash: this.fileHash,
fileName: file.name,
fileSize: file.size
}).then(res => {
this.uploadId = res.data.uploadId;
// 上传所有分片
return this.uploadChunks();
});
}
});
},
// 上传所有分片
uploadChunks() {
// 过滤已上传的分片
const chunksToUpload = this.chunks.filter((_, index) =>
!this.uploadedChunks.includes(index)
);
// 没有需要上传的分片,直接合并
if (chunksToUpload.length === 0) {
return this.mergeChunks();
}
// 批量上传分片,控制并发
const concurrency = 4; // 同时上传的分片数
const total = chunksToUpload.length;
return new Promise((resolve, reject) => {
let completed = 0;
let failed = 0;
let index = 0;
const uploadNext = () => {
if (index >= chunksToUpload.length) {
if (failed > 0) {
reject(new Error(`${failed}个分片上传失败`));
} else {
this.mergeChunks().then(resolve).catch(reject);
}
return;
}
const chunk = chunksToUpload[index];
const formData = new FormData();
formData.append('file', chunk.file);
formData.append('fileHash', this.fileHash);
formData.append('fileName', this.file.name);
formData.append('chunkIndex', chunk.index);
formData.append('totalChunks', this.totalChunks);
formData.append('uploadId', this.uploadId);
axios.post('/api/uploadChunk', formData, {
onUploadProgress: progressEvent => {
// 更新单个分片的进度
const percent = Math.floor((progressEvent.loaded * 100) / progressEvent.total);
// 整体进度 = 已上传分片大小 / 总文件大小
const totalPercent = ((completed + percent / 100) / total) * 100;
this.uploadProgress = Math.floor(totalPercent);
}
}).then(() => {
completed++;
this.uploadedChunks.push(chunk.index);
index++;
uploadNext();
}).catch(err => {
console.error(`分片 ${chunk.index} 上传失败:`, err);
failed++;
index++;
uploadNext();
// 如果失败次数超过3次,可以认为上传失败
if (failed >= 3) {
reject(err);
}
});
};
// 开始上传
uploadNext();
});
},
// 合并分片
mergeChunks() {
this.$message.info('所有分片上传完成,正在合并文件...');
return axios.post('/api/mergeChunks', {
fileHash: this.fileHash,
fileName: this.file.name,
fileSize: this.file.size,
totalChunks: this.totalChunks,
uploadId: this.uploadId
}).then(res => {
this.uploading = false;
this.uploadProgress = 100;
this.$message.success('文件上传成功!');
// 添加到文件列表
this.fileList.push({
name: this.file.name,
url: res.data.url
});
return res.data;
}).catch(err => {
this.uploading = false;
this.$message.error('合并文件失败: ' + err.message);
throw err;
});
},
// 暂停上传
pauseUpload() {
// 这里可以保存已上传的分片信息到本地存储
// 实际实现中可能需要调用后端API记录上传状态
this.uploading = false;
this.canResume = true;
this.$message.warning('上传已暂停');
},
// 继续上传
resumeUpload() {
this.uploading = true;
this.uploadFile(this.file).catch(err => {
this.$message.error('继续上传失败: ' + err.message);
});
},
// 处理上传进度
handleProgress(event, file) {
// 在自定义上传中,这个事件可能不会被触发
// 我们已经在uploadChunks中更新了进度
}
}
};
</script>
后端接口说明
后端需要提供以下接口支持:
/api/initUpload- 初始化上传,生成上传ID/api/uploadChunk- 上传单个分片/api/mergeChunks- 合并所有分片/api/checkFile- 检查文件是否已存在/api/uploadedChunks- 获取已上传的分片列表
5. 最后说一下我这边踩的坑
- 分片的时候不能被中断,分片只要丢失一片,文件整体都会上传失败
- 分片的时候最好禁用任何能影响到分片上传的按钮功能,
- 最后提交的时候,分片可以后端异步合并,如果前端合并的话,会很慢影响用户的体验
好了,今天的分享就到这了,有什么疑问欢迎各位在评论区提出来,我看到会第一时间回复的!