各位靓仔靓女,老司机们大家好!今天咱们来聊聊Vue应用中如何打造一个牛逼哄哄的通用文件上传组件。这玩意儿听起来高大上,其实只要掌握了几个核心技巧,就能轻松搞定,让你的应用在文件上传这块儿一骑绝尘。
咱们的目标是:支持文件分片上传、断点续传,还能预览,用户体验直接拉满!
一、 磨刀不误砍柴工:技术选型和准备工作
首先,工欲善其事必先利其器。咱们先来确定一下要用到的技术:
- Vue.js: 这是咱们的大本营,不用多说。
- Axios/Fetch: 用于发起HTTP请求,跟后端老哥交流的桥梁。
- File API: 浏览器提供的强大API,用于操作文件,比如读取文件内容、切割文件等。
- SparkMD5 (可选): 用于计算文件/分片的MD5值,用于校验文件完整性和实现断点续传。
- 一个能处理文件上传的后端服务: 这个咱们就不细说了,后端同学会搞定的,比如Node.js + Koa/Express, Java + Spring Boot, Python + Django/Flask等等。要求后端提供分片上传的接口和合并分片的接口。
二、 组件结构搭建:搭好咱们的舞台
先创建一个Vue组件,名字就叫 FileUpload.vue
吧,简单粗暴!
<template>
<div class="file-upload">
<input type="file" @change="handleFileChange" ref="fileInput" multiple/>
<div class="preview" v-if="filePreviews.length">
<div v-for="(preview, index) in filePreviews" :key="index">
<img :src="preview" alt="预览" v-if="isVideo(files[index])"/>
<span v-else>{{ files[index].name }}</span>
<button @click="removeFile(index)">移除</button>
</div>
</div>
<button @click="uploadFiles" :disabled="isUploading">
{{ isUploading ? '上传中...' : '开始上传' }}
</button>
<div class="progress-bar" v-if="uploadProgress > 0">
<div class="progress" :style="{ width: uploadProgress + '%' }"></div>
</div>
<div class="message" v-if="message">{{ message }}</div>
</div>
</template>
<script>
import SparkMD5 from 'spark-md5'; // 如果需要的话
export default {
name: 'FileUpload',
data() {
return {
files: [], // 存储选择的文件
filePreviews: [], // 存储文件预览
chunkSize: 1024 * 1024 * 2, // 2MB 分片大小
uploadProgress: 0, // 上传进度
isUploading: false, // 是否正在上传
message: '', // 消息提示
uploadedList:[], //存储已经上传的文件
};
},
methods: {
handleFileChange(event) {
this.files = Array.from(event.target.files);
this.createPreviews();
},
createPreviews() {
this.filePreviews = [];
this.files.forEach(file => {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
this.filePreviews.push(e.target.result);
};
reader.readAsDataURL(file);
} else if (file.type.startsWith('video/')) {
// 创建视频预览
const reader = new FileReader();
reader.onload = (e) => {
this.filePreviews.push(e.target.result);
};
reader.readAsDataURL(file);
}
else {
this.filePreviews.push(file.name); // 其他文件类型显示文件名
}
});
},
removeFile(index) {
this.files.splice(index, 1);
this.filePreviews.splice(index, 1);
},
isVideo(file) {
return file.type.startsWith('video/');
},
async uploadFiles() {
this.isUploading = true;
this.uploadProgress = 0;
this.message = '';
for (const file of this.files) {
try {
await this.uploadFile(file);
} catch (error) {
this.message = `文件 ${file.name} 上传失败:${error.message}`;
this.isUploading = false;
return;
}
}
this.message = '所有文件上传成功!';
this.isUploading = false;
this.uploadProgress = 100;
// 清空选择的文件
this.$refs.fileInput.value = null; // Reset the file input
this.files = []; // Clear the files array
this.filePreviews = []; // Clear the previews array
},
async uploadFile(file) {
const fileHash = await this.calculateFileHash(file); // 计算文件hash
const fileName = file.name;
const fileSize = file.size;
const chunkCount = Math.ceil(fileSize / this.chunkSize);
let uploadedList = await this.getUploadedList(fileHash, fileName);
console.log('uploadedList', uploadedList);
for (let i = 0; i < chunkCount; i++) {
//如果已上传,跳过
if (uploadedList.includes(i)) {
console.log(`slice ${i} has been uploaded`);
continue;
}
const chunk = file.slice(i * this.chunkSize, (i + 1) * this.chunkSize);
const formData = new FormData();
formData.append('file', chunk);
formData.append('fileHash', fileHash);
formData.append('fileName', fileName);
formData.append('chunkIndex', i);
formData.append('chunkCount', chunkCount);
try {
await this.uploadChunk(formData, file, i, chunkCount);
} catch (error) {
throw new Error(`分片 ${i + 1} 上传失败:${error.message}`);
}
this.uploadProgress = Math.round(((i + 1) / chunkCount) * 100);
}
// 合并文件
try {
await this.mergeChunks(fileHash, fileName);
} catch (error) {
throw new Error(`文件合并失败:${error.message}`);
}
},
//获取已经上传的列表
async getUploadedList(fileHash, fileName) {
try {
const response = await fetch('/api/uploadedList', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileHash,
fileName,
}),
});
const data = await response.json();
return data.data || [];
} catch (error) {
console.error('获取已上传分片列表失败:', error);
return [];
}
},
async uploadChunk(formData, file, chunkIndex, chunkCount) {
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.code !== 200) {
throw new Error(data.message || '上传失败');
}
} catch (error) {
console.error(`分片 ${chunkIndex + 1} 上传失败:`, error);
throw error;
}
},
async mergeChunks(fileHash, fileName) {
try {
const response = await fetch('/api/merge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileHash,
fileName,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.code !== 200) {
throw new Error(data.message || '合并失败');
}
} catch (error) {
console.error('合并失败:', error);
throw error;
}
},
calculateFileHash(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
const spark = new SparkMD5.ArrayBuffer();
reader.onload = (event) => {
spark.append(event.target.result);
resolve(spark.end());
};
reader.onerror = (error) => {
reject(error);
};
reader.readAsArrayBuffer(file);
});
},
},
};
</script>
<style scoped>
.file-upload {
width: 500px;
margin: 20px auto;
padding: 20px;
border: 1px solid #ccc;
border-radius: 5px;
}
.preview {
margin-top: 10px;
}
.preview div {
display: flex;
align-items: center;
margin-bottom: 5px;
}
.preview img {
width: 50px;
height: 50px;
margin-right: 10px;
}
.progress-bar {
height: 10px;
background-color: #f0f0f0;
border-radius: 5px;
margin-top: 10px;
}
.progress {
height: 100%;
background-color: #4caf50;
border-radius: 5px;
width: 0; /* 初始值为0 */
}
.message {
margin-top: 10px;
font-weight: bold;
}
</style>
代码解释:
template
: 定义了组件的HTML结构,包括文件选择框、预览区域、上传按钮和进度条。data
: 存储组件的状态数据,比如选择的文件、预览图片、分片大小、上传进度等。handleFileChange
: 当用户选择文件时触发,将选择的文件存储到files
数组中,并创建预览。createPreviews
: 根据文件类型创建预览,图片和视频使用FileReader
读取数据并显示,其他文件类型显示文件名。removeFile
: 移除选择的文件。uploadFiles
: 开始上传文件,遍历files
数组,逐个上传文件。uploadFile
: 上传单个文件,计算文件Hash,然后将文件分成多个分片,逐个上传分片,最后合并分片。uploadChunk
: 上传单个分片,使用fetch
发送请求到后端。mergeChunks
: 通知后端合并分片,完成文件上传。calculateFileHash
: 计算文件的 MD5 值,用于校验文件完整性和实现断点续传。这里使用了spark-md5
库,需要先安装:npm install spark-md5
。
三、 文件分片:化整为零,各个击破
文件分片的核心思想就是把一个大文件分割成多个小文件(分片),然后逐个上传。这样可以避免一次性上传大文件导致的网络拥堵、上传失败等问题。
在 uploadFile
方法中,我们使用 file.slice()
方法来切割文件:
const chunk = file.slice(i * this.chunkSize, (i + 1) * this.chunkSize);
i * this.chunkSize
: 分片的起始位置。(i + 1) * this.chunkSize
: 分片的结束位置。
然后,我们将分片数据放到 FormData
中,并通过 fetch
发送到后端:
const formData = new FormData();
formData.append('file', chunk);
formData.append('fileHash', fileHash); //文件hash
formData.append('fileName', fileName); // 文件名
formData.append('chunkIndex', i); // 分片索引
formData.append('chunkCount', chunkCount); // 分片总数
四、 断点续传:上次没传完,这次接着传
断点续传的关键在于记录哪些分片已经上传成功,哪些没有上传。这样,下次上传的时候,只需要上传没有上传的分片即可。
- 计算文件Hash: 计算文件的 MD5 值,作为文件的唯一标识。
- 后端记录: 后端需要记录每个文件已经上传的分片,可以使用数据库或者 Redis 等存储。
- 前端查询: 在上传文件之前,前端先向后端查询哪些分片已经上传成功。
- 跳过已上传的分片: 在上传分片的时候,跳过已经上传成功的分片。
//获取已经上传的列表
async getUploadedList(fileHash, fileName) {
try {
const response = await fetch('/api/uploadedList', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileHash,
fileName,
}),
});
const data = await response.json();
return data.data || [];
} catch (error) {
console.error('获取已上传分片列表失败:', error);
return [];
}
},
这段代码中,前端通过 fetch
向后端发送请求,获取已经上传的分片列表。后端需要提供 /api/uploadedList
接口,接收 fileHash
和 fileName
参数,并返回已经上传的分片列表。
然后在上传分片的时候,跳过已经上传成功的分片:
for (let i = 0; i < chunkCount; i++) {
//如果已上传,跳过
if (uploadedList.includes(i)) {
console.log(`slice ${i} has been uploaded`);
continue;
}
// ... 上传分片的代码
}
五、 文件预览:眼见为实,心中有数
文件预览可以提升用户体验,让用户在上传之前可以确认选择的文件是否正确。
在 createPreviews
方法中,我们根据文件类型创建预览:
createPreviews() {
this.filePreviews = [];
this.files.forEach(file => {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
this.filePreviews.push(e.target.result);
};
reader.readAsDataURL(file);
} else if (file.type.startsWith('video/')) {
// 创建视频预览
const reader = new FileReader();
reader.onload = (e) => {
this.filePreviews.push(e.target.result);
};
reader.readAsDataURL(file);
}
else {
this.filePreviews.push(file.name); // 其他文件类型显示文件名
}
});
},
- 图片: 使用
FileReader
读取图片数据,然后将数据URL赋值给img
标签的src
属性。 - 视频: 同样使用
FileReader
读取视频数据,然后将数据URL赋值给video
标签的src
属性。 - 其他文件类型: 显示文件名。
六、 异常处理:防患于未然,有备无患
在文件上传过程中,可能会出现各种异常,比如网络错误、后端错误、文件损坏等等。因此,我们需要做好异常处理,保证上传过程的稳定性。
- try…catch: 使用
try...catch
语句捕获异常,并进行处理。 - 错误提示: 向用户显示友好的错误提示信息。
- 重试机制: 对于可重试的错误,可以尝试重新上传。
在 uploadChunk
方法中,我们使用 try...catch
语句捕获异常:
try {
await this.uploadChunk(formData, file, i, chunkCount);
} catch (error) {
throw new Error(`分片 ${i + 1} 上传失败:${error.message}`);
}
七、 优化建议:锦上添花,更上一层楼
- 进度条: 显示上传进度,让用户了解上传进度。
- 并发上传: 可以同时上传多个分片,提高上传速度。但是需要注意控制并发数量,避免对服务器造成过大的压力。
- 取消上传: 提供取消上传的功能,让用户可以随时停止上传。
- 拖拽上传: 支持拖拽上传,提升用户体验。
- 文件类型限制: 限制上传文件的类型,避免上传恶意文件。
- 文件大小限制: 限制上传文件的大小,避免上传过大的文件。
八、 后端配合:前后端齐心,其利断金
前端只是负责将文件分片,然后发送到后端,真正干活的还是后端老哥。所以,后端需要提供以下接口:
- /api/upload: 接收分片数据,并将分片数据保存到临时目录。
- /api/merge: 接收文件Hash和文件名,将临时目录中的分片合并成一个完整的文件。
- /api/uploadedList: 接收文件Hash和文件名,返回已经上传的分片列表。
后端代码示例 (Node.js + Koa):
const Koa = require('koa');
const Router = require('koa-router');
const koaBody = require('koa-body');
const fs = require('fs');
const path = require('path');
const app = new Koa();
const router = new Router();
const UPLOAD_DIR = path.resolve(__dirname, 'upload');
// 确保上传目录存在
if (!fs.existsSync(UPLOAD_DIR)) {
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
}
// 获取已上传分片列表
router.post('/api/uploadedList', koaBody(), async (ctx) => {
const { fileHash, fileName } = ctx.request.body;
const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
if (!fs.existsSync(chunkDir)) {
ctx.body = { code: 200, data: [] };
return;
}
const uploadedChunks = fs.readdirSync(chunkDir);
const uploadedList = uploadedChunks.map(chunk => parseInt(chunk.split('-')[1]));
ctx.body = { code: 200, data: uploadedList };
});
// 上传分片
router.post('/api/upload', koaBody({ multipart: true }), async (ctx) => {
const file = ctx.request.files.file;
const { fileHash, fileName, chunkIndex, chunkCount } = ctx.request.body;
const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
if (!fs.existsSync(chunkDir)) {
fs.mkdirSync(chunkDir, { recursive: true });
}
const chunkPath = path.resolve(chunkDir, `${fileName}-${chunkIndex}`);
const reader = fs.createReadStream(file.path);
const writer = fs.createWriteStream(chunkPath);
reader.pipe(writer);
console.log(`Chunk ${chunkIndex} uploaded for ${fileName}`);
ctx.body = { code: 200, message: '分片上传成功' };
});
// 合并分片
router.post('/api/merge', koaBody(), async (ctx) => {
const { fileHash, fileName } = ctx.request.body;
const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
const targetPath = path.resolve(UPLOAD_DIR, fileName);
const chunkFiles = fs.readdirSync(chunkDir);
chunkFiles.sort((a, b) => {
const indexA = parseInt(a.split('-')[1]);
const indexB = parseInt(b.split('-')[1]);
return indexA - indexB;
});
const writeStream = fs.createWriteStream(targetPath);
for (const chunkFile of chunkFiles) {
const chunkPath = path.resolve(chunkDir, chunkFile);
const readStream = fs.createReadStream(chunkPath);
readStream.pipe(writeStream, { end: false });
await new Promise((resolve) => readStream.on('end', resolve));
fs.unlinkSync(chunkPath); // 删除分片
}
writeStream.on('finish', () => {
fs.rmdirSync(chunkDir, { recursive: true }); // 删除分片目录
console.log(`File ${fileName} merged successfully`);
});
ctx.body = { code: 200, message: '文件合并成功' };
});
app.use(router.routes()).use(router.allowedMethods());
app.listen(3000, () => {
console.log('Server is running on port 3000');
});
九、 总结:功夫不负有心人
好了,各位。一个支持文件分片上传、断点续传和预览功能的通用 Vue 文件上传组件就完成了。当然,这只是一个基础版本,还有很多可以优化的地方。希望大家能够在此基础上,打造出更加强大、更加易用的文件上传组件。
记住,撸代码就像谈恋爱,需要耐心、细心和激情!
最后,祝大家早日成为代码界的弄潮儿!溜了溜了~