引言
在现代 Web 应用中,大文件上传是一个常见需求。传统的文件上传方式在面对大文件时存在诸多问题:网络不稳定可能导致上传失败、服务器压力大、用户体验差等。本文将介绍如何使用 Vue 封装一个可复用的文件分片上传组件。
分片上传原理
分片上传的核心思想是将大文件分割成多个小块(chunks),然后逐个上传这些小块,最后在服务器端合并。
组件封装实现
1. 创建基础组件结构
1、首先创建一个 uploadminio.vue 组件,此组件用于上传文件:
<template>
<el-dialog
title="上传文件"
:visible.sync="isMinioDialogVisible"
width="60%"
:before-close="handleClose"
:modal-append-to-body="false"
:append-to-body="true"
v-el-drag-dialog
>
<div v-if="isFinite">
<div>
<input
ref="fileInput"
type="file"
id="fileDoc"
multiple
@change="handleFileUpload"
:accept="fileType"
style="display: none"
/>
<el-button
class="otherZbtn"
size="mini"
type="primary"
@click="triggerFileInput"
:disabled="isminio"
>选择文件</el-button
>
</div>
<div>
<el-table
v-if="tableData.length > 0"
size="mini"
style="margin: 10px 0 15px"
border
header-row-class-name="tableHeader"
:data="tableData"
:highlight-current-row="true"
:header-cell-style="{
background: '#f7f8fa',
color: 'rgb(3, 92, 160)',
border: '1px solid #e8eaec',
}"
>
<el-table-column
type="index"
header-align="center"
align="center"
label="序"
width="50"
/>
<el-table-column
prop="name"
header-align="center"
align="center"
label="文件名称"
/>
<el-table-column
prop="testItemName"
header-align="center"
align="center"
label="进度"
width="160"
>
<template slot-scope="scope">
<el-tag v-if="scope.row.percentage == 100" size="mini"
>上传完成</el-tag
>
<el-tag v-if="!scope.row.percentage" size="mini">暂未上传</el-tag>
<el-progress
v-if="scope.row.percentage != 0 && scope.row.percentage != 100"
:percentage="scope.row.percentage"
></el-progress>
</template>
</el-table-column>
<el-table-column
prop="name"
header-align="center"
align="center"
label="操作"
width="80"
>
<template slot-scope="scope">
<!-- 修改部分,添加点击事件 -->
<el-button
size="mini"
style="color: red; cursor: pointer"
@click="deleteFile(scope.$index)"
:disabled="isminio"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
</div>
<span
slot="footer"
class="dialog-footer"
style="display: flex; justify-content: center"
v-loading="isminio"
>
<el-button @click="confirmClearFiles" size="small" type="danger"
>清 空</el-button
>
<el-button type="primary" size="small" @click="uploadFiles"
>上 传</el-button
>
</span>
</div>
</el-dialog>
</template>
<script>
import {
uploadMerge,
uploadInit,
uploadChunk,
} from "../../api/upload/index.js";
// wenjianlist: {
// fileList: [], // 文件列表
// limitFlie: 3, // 上传文件个数
// isNull: true, // 文件是否可以为空 false 是 true 否
// fileNames: "", // 文件名称
// fileUrls: "", //文件地址
// fileUniques: "", //文件id
// isMultiple: false, //是否批量上传 true 多 false 单 针对接口,不针对页面
// isLook: true, // 查看or上传 true 查看
// fileType:".png,.jpg,.xls,.doc",//允许上传的文件类型
// fileMsg:"请上传",//为空后提示的话
// isDown:1,// 是否可以下载 1可以 其他不可以
// arrType: 1, // 1 新增文件 0 修改文件
// }
export default {
name: "uploadMinio",
data() {
return {
files: [], // 存储文件的数组
CHUNK_SIZE: 5 * 1024 * 1024, // 5MB
concurrentLimit: 3, // 并发上传数
progress: {},
isminio: false, // 控制loading
isMinioDialogVisible: false, // 控制弹窗的显示
allUploadSucceeded: false, // 所有文件上传是否成功
tableData: [], // 表格数据
tableFileData: [], // 表格文件数据
MAX_FILE_COUNT: 99, // 新增:最大文件数量限制
isFinite: true, // 新增:控制文件选择框的显示
fileType: "", //可以上传的文件类型
};
},
// 在组件销毁前重置input值
beforeDestroy() {
if (this.$refs.fileInput) {
this.$refs.fileInput.value = "";
}
},
methods: {
// 打开上传文件弹窗
showDialog(wenjianlist) {
this.fileType = wenjianlist.fileType; // 文件类型
this.MAX_FILE_COUNT = wenjianlist.limitFlie; // 传入的 limitFlie 赋值给 MAX_FILE_COUNT
// 调用 clearFiles 方法清空选中的值
this.clearFiles();
this.isMinioDialogVisible = true;
},
// 上传文件
triggerFileInput() {
// 先重置再触发
this.$refs.fileInput.value = "";
this.$refs.fileInput.click();
},
handleFileUpload(e) {
// 将新选中的文件转换为数组
const newFiles = Array.from(e.target.files);
// 检查添加新文件后是否会超过最大文件数量限制
if (this.files.length + newFiles.length > this.MAX_FILE_COUNT) {
this.$message.warning(`最多只能上传 ${this.MAX_FILE_COUNT} 个文件。`);
return;
}
// 将新文件追加到现有的 this.files 数组中
this.files = [...this.files, ...newFiles];
// 更新表格数据,将新文件的信息也添加进去
this.tableData = this.files.map((file) => ({
name: file.name,
percentage: 0,
}));
},
handleClose(done) {
// 清空 tableFileData 数组
this.tableFileData = [];
// 这里可以添加一些在关闭对话框前需要执行的逻辑,比如确认提示等
// 最后调用 done() 来关闭对话框
done();
},
async uploadFiles() {
this.$emit("wenjianlistBack", this.tableFileData);
// 新增:检查文件数组是否为空
if (this.files.length === 0) {
this.$message.warning("没有选择要上传的文件,请先选择文件。");
return;
}
this.isminio = true;
this.allUploadSucceeded = true;
// 可以最后一个文件走完就关闭弹窗,现在是只要文件合并失败就不能关闭弹窗
for (const file of this.files) {
try {
const uploadId = await this.initUpload(file.name);
let uploadFileChunks = await this.uploadFileChunks(
file,
uploadId,
file.name,
file.size
);
if (uploadFileChunks) {
await this.mergeChunks(uploadId, file.name);
}
} catch (error) {
// 接口调用失败,将 isminio 设置为 false
this.allUploadSucceeded = false;
this.isminio = false;
console.error("文件上传失败:", error);
}
}
if (this.allUploadSucceeded) {
// 所有文件上传成功,传到父组件,父组件在传到显示组件
this.$emit("wenjianlistBack", this.tableFileData);
//关闭弹窗
this.isMinioDialogVisible = false;
}
this.isminio = false;
},
async initUpload(fileName) {
try {
const res = await uploadInit();
if (res && res.code == 200) {
return res.uploadId;
}
throw new Error("上传初始化失败");
} catch (err) {
// 接口调用失败,将 isminio 设置为 false
this.isminio = false;
console.log(err);
throw err;
}
},
async uploadFileChunks(file, uploadId, fileName, fileSize) {
let isTrue = true;
const totalChunks = Math.ceil(file.size / this.CHUNK_SIZE);
const chunks = Array.from({ length: totalChunks }, (_, i) => i);
// 控制并发数
const uploadQueue = [];
let vali = 0;
for (let i = 0; i < chunks.length; i++) {
const chunk = file.slice(
i * this.CHUNK_SIZE,
(i + 1) * this.CHUNK_SIZE
);
const formData = new FormData();
formData.append("uploadId", uploadId);
formData.append("chunkIndex", i);
formData.append("chunk", chunk);
formData.append("fileName", fileName);
formData.append("fileSize", fileSize);
// 先找到目标文件
const targetFile = this.tableData.find(
(item) => item.name === fileName
);
// 启动一个定时器让进度条缓慢增长
const intervalId = setInterval(() => {
if (targetFile.percentage < ((vali + 1) / totalChunks) * 99) {
targetFile.percentage++;
}
}, 500); // 每500毫秒增长1%
try {
uploadQueue.push(
// 上传文件
uploadChunk(formData)
.then((res) => {
// 清除定时器
clearInterval(intervalId);
if (res && res.code == 200) {
//上传分片成功后,更新进度条到正确数值
targetFile.percentage = Math.round(
((vali + 1) / totalChunks) * 100
);
vali++;
isTrue = true;
} else {
isTrue = false;
}
})
.catch((err) => {
isTrue = false;
console.log("err", err);
})
);
} catch (err) {
// 清除定时器
clearInterval(intervalId);
// 接口调用失败,将 isminio 设置为 false
this.isminio = false;
console.log(err);
throw err;
}
// 控制并发
if (uploadQueue.length >= this.concurrentLimit) {
await Promise.all(uploadQueue);
uploadQueue.length = 0;
}
}
await Promise.all(uploadQueue);
return isTrue;
},
async mergeChunks(uploadId, fileName) {
let params = new FormData();
params.append("uploadId", uploadId);
params.append("fileName", fileName);
const targetFile = this.tableData.find((item) => item.name === fileName);
try {
const res = await uploadMerge(params);
if (res && res.code == 200) {
this.tableFileData.push(res.map);
console.log("文件上传成功");
// 修改部分:文件合并成功后,将进度条设置为 100%
if (targetFile) {
targetFile.percentage = 100;
}
} else {
// 修改部分:文件合并失败后,将进度条设置为 0%
if (targetFile) {
targetFile.percentage = 0;
}
this.allUploadSucceeded = false;
}
} catch (err) {
this.allUploadSucceeded = false;
// 接口调用失败,将 isminio 设置为 false
this.isminio = false;
console.log(err);
throw err;
}
},
confirmClearFiles() {
// 新增判断,如果文件数组为空,直接清空文件
if (this.files.length === 0) {
this.clearFiles();
this.$message({
type: "success",
message: "文件已清空.!",
});
return;
}
this.$confirm("确定要清空所有上传文件吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(() => {
this.clearFiles();
this.$message({
type: "success",
message: "文件已清空!",
});
})
.catch(() => {
this.$message({
type: "info",
message: "已取消清空操作",
});
});
},
clearFiles() {
// 新增:清空文件列表前,先隐藏文件选择框
this.isminio = false;
this.isFinite = false; // 新增:隐藏文件选择框
// 清空 tableFileData 数组
this.tableFileData = [];
// 清空文件列表
this.files = [];
// 清空表格数据
this.tableData = [];
// 清空进度条
this.progress = {};
setTimeout(() => {
this.isFinite = true; // 新增:显示文件选择框
// 清空文件输入框的选中文件
const fileInput = document.getElementById("fileDoc");
if (fileInput) {
fileInput.value = "";
fileInput.outerHTML = fileInput.outerHTML;
}
}, 0);
},
// 添加删除文件方法
deleteFile(index) {
// 从 files 数组中移除对应的文件
this.files.splice(index, 1);
// 从 tableData 数组中移除对应的文件信息
this.tableData.splice(index, 1);
},
},
};
</script>
<style scoped>
/* 隐藏原来的上传列表 */
::v-deep .uploadFile .el-upload-list {
display: none;
}
</style>
2、其次在创建一个 uploadminioLook.vue 组件,此组件用于显示已经上传成功的文件:
<template>
<!-- 上传文件的显示列表 -->
<div v-loading="isfileload">
<el-table
v-if="fileList.length"
:data="fileList"
:max-height="tableMaxHeight"
style="width: 100%"
size="mini"
row-key="uid"
@selection-change="selectionChangeHandle"
@select-all="selectAll"
>
<el-table-column
:reserve-selection="true"
type="selection"
header-align="center"
align="center"
width="50"
></el-table-column>
<el-table-column prop="name" label="名称" />
<el-table-column width="100" prop="address" fixed="right" label="操作">
<template slot-scope="scope">
<span
title="下载"
style="
display: inline-block;
margin-right: 10px;
cursor: pointer;
font-size: 14px;
"
@click="handlePreview(scope.row)"
><i class="el-icon-download"></i
></span>
<span
v-if="isLook"
title="删除"
style="display: inline-block; margin-right: 10px"
@click="handleRemove(scope.row)"
><i class="el-icon-delete"></i
></span>
<span
title="查看图片"
style="display: inline-block"
@click="handlePictureCardPreview(scope.row)"
v-if="isHzm(scope.row)"
><i class="el-icon-view"></i
></span>
<span
title="查看pdf"
style="display: inline-block"
@click="handlePdfPreview(scope.row)"
v-if="isPdf(scope.row)"
><i class="el-icon-view"></i
></span>
</template>
</el-table-column>
</el-table>
<el-dialog
:visible.sync="picVisible"
title="文件详情"
width="70%"
:modal-append-to-body="false"
:append-to-body="true"
v-el-drag-dialog
>
<div style="width: 100; height: 100%; overflow: auto">
<img
style="max-width: 100%; margin: 15px auto"
:src="dialogImageUrl"
alt=""
v-if="dialogImageUrl != ''"
/>
<iframe
:src="'pdf/web/viewer.html?file=' + pdfSrc"
frameborder="0"
width="100%"
:height="tableMaxHeight"
v-if="pdfSrc != ''"
></iframe>
</div>
</el-dialog>
<div style="height: 1px; overflow: hidden">
<iframe :src="urllist" frameborder="0" width="100" height="100"></iframe>
</div>
</div>
</template>
<script>
import { uploadFile, deleteFile } from "../../api/upload/index.js";
// wenjianlist: {
// fileList: [], // 文件列表
// limitFlie: 3, // 上传文件个数
// isNull: true, // 文件是否可以为空 false 是 true 否
// fileNames: "", // 文件名称
// fileUrls: "", //文件地址
// fileUniques: "", //文件id
// isMultiple: false, //是否批量上传 true 多 false 单 针对接口,不针对页面
// isLook: true, // 查看or上传 true 查看
// fileType:".png,.jpg,.xls,.doc",//允许上传的文件类型
// fileMsg:"请上传",//为空后提示的话
// isDown:1,// 是否可以下载 1可以 其他不可以
// arrType: 1, // 1 新增文件 0 修改文件
// }
export default {
name: "uploadminioLook",
data() {
return {
picVisible: false, // 文件详情弹窗
dialogImageUrl: "", // 图片路径
pdfSrc: "", // pdf路径
isfileload: false, // 上传文件组件显示隐藏
dataForm: {
fileNames: "",
fileUrls: "",
},
limitFlie: 1, //上传文件个数
isNull: true, // 文件是否可以为空
isMultiple: false, //单文件上传 or 多文件上传
isLook: false,
fileDateArr: [], //附件删除列表
fileIdArr: [], //附件删除id列表
urllist: "",
timmer: null, // 用于控制防抖的定时器
fileList: [], // 文件列表--上传结束后回显用
file: [],
disabled: false, // 控制组件是否禁用
uploadFileList: [], // 上传文件列表--待传递
successFileUrls: [], // 记录上传成功的文件列表--用不到,这里为了方便演示
fileType: "", // 上传文件类型
fileMsg: "", //为空后提示的话
isDown: 1, // 文件是否可以下载
dataListSelections: [], //多选
};
},
props: {
wenjianlist: {
type: Object,
default: () => {},
},
},
computed: {
tableMaxHeight() {
return window.innerHeight - 250 + "px";
},
isHzm() {
return function (value) {
if (value) {
let name = value.filePath;
let val = name.substring(name.lastIndexOf(".")).toLowerCase();
if (!/\.(jpg|jpeg|png|GIF)$/.test(val)) {
return false;
} else {
return true;
}
}
};
},
isPdf() {
return function (value) {
if (value) {
let name = value.filePath;
let val = name.substring(name.lastIndexOf(".")).toLowerCase();
if (val == ".pdf") {
return true;
} else {
return false;
}
}
};
},
iswordexcal() {
return function (value) {
if (value) {
let name = value.filePath;
let val = name.substring(name.lastIndexOf(".")).toLowerCase();
if (
val == ".docx" ||
val == ".doc" ||
val == ".xlsx" ||
val == ".xls"
) {
return true;
} else {
return false;
}
}
};
},
},
watch: {
wenjianlist: {
handler(newVal, oldVal) {
this.urllist = "";
this.file = [];
this.limitFlie = newVal.limitFlie;
this.fileType = newVal.fileType;
this.fileMsg = newVal.fileMsg;
// 文件是否可以下载
if (newVal.isDown) {
this.isDown = newVal.isDown;
}
this.isNull = newVal.isNull;
this.isMultiple = newVal.isMultiple;
this.isLook = newVal.isLook;
if (newVal.arrType == 1) {
// 回显的文件列表
let name = newVal.fileNames ? newVal.fileNames.split(",") : [];
let filePath = newVal.fileUrls ? newVal.fileUrls.split(",") : [];
let id = newVal.fileUniques ? newVal.fileUniques.split(",") : [];
let fileList = [];
if (name.length && name[0] != "") {
for (let i in name) {
fileList.push({
name: name[i],
filePath: filePath[i],
id: id[i],
});
}
}
this.fileList = fileList;
} else {
// 重新上传的文件
this.fileList = newVal.fileList;
}
},
deep: true,
immediate: true,
},
},
created() {},
methods: {
// 提交成功之后清空数
serNull() {
this.fileList = []; //附件列表
this.fileDateArr = []; //附件删除列表
this.fileIdArr = []; //附件删除id列表
this.uploadFileList = [];
this.file = [];
},
// 多选
selectionChangeHandle(val) {
this.dataListSelections = val;
},
selectAll(val) {
this.dataListSelections = val;
},
// 批量下载
allDown() {
console.log(this.dataListSelections);
},
//查看大图
async handlePictureCardPreview(file) {
this.pdfSrc = "";
this.dialogImageUrl = "";
this.isfileload = true;
this.dialogImageUrl = await this.fileLook(file.filePath);
this.picVisible = true;
this.isfileload = false;
},
//查看pdf
async handlePdfPreview(file) {
this.pdfSrc = "";
this.dialogImageUrl = "";
this.isfileload = true;
this.pdfSrc = await this.filePdf(file.filePath);
this.picVisible = true;
this.isfileload = false;
},
// 点击文件下载
async handlePreview(file) {
if (this.isDown && this.isDown != 1) {
this.$message.info("暂无下载权限");
return false;
}
this.isfileload = true;
await this.fileDown({
url: file.filePath,
name: file.name,
});
this.isfileload = false;
},
//删除文件
async picRemove(picarr, picid) {
for (let i = 0; i < picarr.length; i++) {
if (picarr[i].id === picid) {
picarr.splice(i, 1);
}
}
return picarr;
},
async picRemove1(data) {
deleteFile(data).then((res) => {
if (res.code != 200) {
} else {
}
});
},
handleRemove(file) {
let data = new FormData();
let dataminio = "";
let filesid = "";
filesid = file.id;
data.append("filePath", file.filePath);
dataminio = file.filePath;
for (let i in this.fileList) {
if (filesid == this.fileList[i].id) {
this.fileList.splice(i, 1);
}
}
this.fileDateArr.push(dataminio);
this.fileIdArr.push(filesid);
this.$message({
message: "文件删除成功",
type: "success",
duration: 1000,
});
},
//提交 true 文件可以为空 false 文件不能为空
async submitupload() {
if (this.fileDateArr) {
for (let i in this.fileDateArr) {
await this.picRemove(this.fileList, this.fileIdArr[i]);
}
}
if (!this.isNull) {
if (this.fileList.length) {
if (!this.fileList.length) {
this.$message({
message: this.fileMsg ? this.fileMsg : "请上传文件",
type: "error",
duration: 1000,
});
return false;
}
} else {
this.$message({
message: this.fileMsg ? this.fileMsg : "请上传文件",
type: "error",
duration: 1000,
});
return false;
}
}
},
},
};
</script>
2. 在项目中使用组件
在需要使用的地方引入组件:
template
<el-col :span="24">
<el-form-item label="附件:" prop="">
<el-button
@click="handlePic"
class="otherZbtn"
size="mini"
type="primary"
>上传文件</el-button
>
<uploadMinio
ref="uploadMinioRef"
@wenjianlistBack="wenjianlistBack"
></uploadMinio>
<uploadminioLook
ref="uploadminioLookRef"
:wenjianlist="wenjianlist"
></uploadminioLook>
</el-form-item>
</el-col>
js
import uploadMinio from "../../../components/uploads/uploadminio.vue";
import uploadminioLook from "../../../components/uploads/uploadminioLook.vue";
components: {
uploadMinio,
uploadminioLook,
},
data() {
return {
wenjianlist: {
fileList: [],
fileNames: "",
fileUrls: "",
fileUniques: "",
limitFlie: 999, // 上传文件个数
isNull: true, // 文件是否可以为空 false 是 true 否
isMultiple: false,
isLook: true,
time: "",
arrType: 1, // 1 新增 0 修改
},
}
}
methods: {
// 上传文件************************************
handlePic() {
// 获取当前文件列表的长度
const currentFileCount = this.$refs.uploadminioLookRef.fileList.length;
// 获取允许上传的最大文件数量
const limit = this.wenjianlist.limitFlie;
// 检查当前文件数量是否超过限制
if (currentFileCount >= limit) {
this.$message.warning("已达到最大文件上传数量,无法继续上传。");
return;
}
// 如果未超过限制,打开上传对话框
this.$refs.uploadMinioRef.showDialog(this.wenjianlist);
},
// 文件回显
wenjianlistBack(data, val) {
console.log("data", data);
if (val == 1) {
this.wenjianlist.fileNames = data.fileNames;
this.wenjianlist.fileUniques = data.fileUniques;
this.wenjianlist.fileUrls = data.fileUrls;
this.wenjianlist.arrType = 1;
this.wenjianlist.time = new Date().getTime();
} else {
console.log("this.wenjianlist.fileList", this.wenjianlist.fileList);
this.wenjianlist.arrType = 0;
// 和已经存在的文件进行合并
this.wenjianlist.fileList = [
...this.$refs.uploadminioLookRef.fileList,
...data,
];
this.wenjianlist.time = new Date().getTime();
}
},
// ************************************
// ************************************
// 附件提交
await this.$refs.uploadminioLookRef.submitupload();
let wjList = this.$refs.uploadminioLookRef.fileList; // 提交的文件
let fileDateArr = this.$refs.uploadminioLookRef.fileDateArr; // 删除的文件
console.log(wjList);
console.log(fileDateArr);
if (wjList.length) {
let name = [];
let url = [];
let id = [];
for (let i in wjList) {
name.push(wjList[i].name);
url.push(wjList[i].filePath);
id.push(wjList[i].id);
}
this.dataForm.fileNames = name.join(",");
this.dataForm.fileUrls = url.join(",");
this.dataForm.fileUniques = id.join(",");
} else {
this.dataForm.fileNames = "";
this.dataForm.fileUrls = "";
this.dataForm.fileUniques = "";
// 文件不能为空的情况
if (!this.wenjianlist.isNull) {
this.isdisabled = false; //打开提交按钮
return false;
}
}
// ************************************
// 删除文件 新增或者修改成功之后在删除需要删除的文件
if (fileDateArr.length) {
for (let i in fileDateArr) {
await this.$refs.uploadminioLookRef.picRemove1(
fileDateArr[i]
);
}
}
}
服务器端实现要点
为了使分片上传正常工作,服务器端需要实现以下接口:
验证接口 (/api/init) - 检查文件是否已存在或部分存在
上传接口 (/api/chunk) - 接收文件分片并保存
合并接口 (/api/merge) - 将所有分片合并为完整文件
3.实现效果图如下
1、打开上传弹窗
2、选择文件
3、上传中
4、上传成功
优化与扩展
**并发控制:**限制同时上传的分片数量,避免浏览器性能问题
**错误重试:**为每个分片添加重试机制,提高上传成功率
**速度限制:**添加上传速度限制选项
**文件预览:**在上传前显示文件预览(如图片)
总结
通过封装 Vue 文件分片上传组件,我们实现了以下功能:
1 大文件分片上传,提高上传成功率;
2上传进度实时显示;
3文件验证避免重复上传;
4良好的用户反馈;
这种组件化实现方式可以方便地在不同项目中复用,只需根据实际需求调整服务器端接口即可。希望本文能帮助你理解并实现文件分片上传功能。