各位靓仔靓女,晚上好!我是老司机,今天跟大家聊聊Vue里面如何打造一个“豪华版”文件上传组件,让你的文件上传体验丝滑到飞起。我们今天要搞定的功能包括:
- 文件分块上传: 把大文件切成小块,一块一块传,妈妈再也不用担心我的浏览器崩溃了!
- 断点续传: 就算网络突然抽风,下次还能接着上次的地方继续传,简直不要太贴心。
- 进度显示: 清晰地看到上传进度,心里有数,告别焦虑。
- 多文件上传: 一次性上传多个文件,省时省力,告别重复劳动。
准备好了吗?坐稳扶好,发车咯!
1. 基础架构搭建:先搭个“毛坯房”
首先,我们需要创建一个Vue组件,作为我们上传组件的“毛坯房”。
<template>
<div class="upload-container">
<input type="file" multiple @change="handleFileChange" ref="fileInput" />
<button @click="startUpload">开始上传</button>
<div v-for="(file, index) in fileList" :key="index">
<p>{{ file.name }} - {{ file.progress }}%</p>
</div>
</div>
</template>
<script>
import SparkMD5 from 'spark-md5'; // 引入计算文件hash的库
export default {
data() {
return {
fileList: [], // 存储文件信息的数组
chunkSize: 1024 * 1024 * 2, // 2MB 分块大小
uploadUrl: '/api/upload', // 上传接口地址,后面会换成你自己的
};
},
methods: {
handleFileChange(event) {
const files = Array.from(event.target.files);
this.fileList = files.map(file => ({
file,
name: file.name,
size: file.size,
progress: 0,
uploaded: false,
chunks: [], // 用于存放分片信息
}));
},
startUpload() {
this.fileList.forEach(fileInfo => {
this.sliceFile(fileInfo);
});
},
async sliceFile(fileInfo) {
const file = fileInfo.file;
const chunkSize = this.chunkSize;
const totalSize = file.size;
const chunkCount = Math.ceil(totalSize / chunkSize);
fileInfo.chunks = []; // 初始化chunks数组
for (let i = 0; i < chunkCount; i++) {
const start = i * chunkSize;
const end = Math.min(totalSize, start + chunkSize);
const chunk = file.slice(start, end); // 切割文件
fileInfo.chunks.push({
chunk: chunk,
index: i,
start: start,
end: end,
uploaded: false,
});
}
// 计算整个文件的hash,用于唯一标识,防止重复上传
const fileHash = await this.calculateFileHash(file);
fileInfo.fileHash = fileHash;
// 接下来进行分片上传
this.uploadChunks(fileInfo);
},
async calculateFileHash(file) {
return new Promise((resolve, reject) => {
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
fileReader.onload = (e) => {
spark.append(e.target.result);
resolve(spark.end());
};
fileReader.onerror = () => {
reject('文件读取出错');
};
fileReader.readAsArrayBuffer(file);
});
},
async uploadChunks(fileInfo) {
const chunks = fileInfo.chunks;
const uploadPromises = []; // 并发上传promise
for (const chunkInfo of chunks) {
if (!chunkInfo.uploaded) {
const formData = new FormData();
formData.append('file', chunkInfo.chunk);
formData.append('filename', fileInfo.name);
formData.append('totalSize', fileInfo.size);
formData.append('chunkIndex', chunkInfo.index);
formData.append('totalChunks', chunks.length);
formData.append('fileHash', fileInfo.fileHash);
const uploadPromise = fetch(this.uploadUrl, {
method: 'POST',
body: formData,
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json(); // 假设服务器返回JSON
})
.then(data => {
if (data.code === 200) {
chunkInfo.uploaded = true;
// 上传成功后更新进度条
this.updateProgress(fileInfo);
} else {
console.error('上传失败:', data.message);
}
})
.catch(error => {
console.error('上传出错:', error);
});
uploadPromises.push(uploadPromise); // 推进并发上传
}
}
// 使用Promise.all等待所有分片上传完成
await Promise.all(uploadPromises);
// 检查是否所有分片都已上传
const allChunksUploaded = chunks.every(chunk => chunk.uploaded);
if (allChunksUploaded) {
console.log(`文件 ${fileInfo.name} 上传完成!`);
}
},
updateProgress(fileInfo) {
const uploadedChunks = fileInfo.chunks.filter(chunk => chunk.uploaded).length;
fileInfo.progress = Math.floor((uploadedChunks / fileInfo.chunks.length) * 100);
this.$forceUpdate(); // 强制更新视图
},
},
};
</script>
<style scoped>
.upload-container {
padding: 20px;
border: 1px dashed #ccc;
}
</style>
代码解释:
input[type="file"]
:文件选择框,multiple
属性允许选择多个文件。handleFileChange
:当文件选择发生变化时触发,将选择的文件信息存储到fileList
数组中。sliceFile
:将文件分割成多个 chunk。calculateFileHash
:使用spark-md5
库计算文件的hash值,作为文件的唯一标识。uploadChunks
:遍历文件,将每个 chunk 通过fetch
API 发送到服务器。updateProgress
:更新文件的上传进度。startUpload
:遍历fileList
,对每个文件进行分片并开始上传。
2. 分块上传:化整为零,各个击破
核心思想:将大文件分割成若干小块,然后并发上传这些小块。
好处:
- 提高上传速度: 并发上传,充分利用带宽。
- 降低服务器压力: 每次只处理小块数据,减轻服务器负担。
- 实现断点续传: 如果某个 chunk 上传失败,只需要重新上传这个 chunk 即可。
在上面的代码里,我们已经实现了文件分块,关键在于sliceFile
方法:
async sliceFile(fileInfo) {
const file = fileInfo.file;
const chunkSize = this.chunkSize;
const totalSize = file.size;
const chunkCount = Math.ceil(totalSize / chunkSize);
fileInfo.chunks = []; // 初始化chunks数组
for (let i = 0; i < chunkCount; i++) {
const start = i * chunkSize;
const end = Math.min(totalSize, start + chunkSize);
const chunk = file.slice(start, end); // 切割文件
fileInfo.chunks.push({
chunk: chunk,
index: i,
start: start,
end: end,
uploaded: false,
});
}
// 计算整个文件的hash,用于唯一标识,防止重复上传
const fileHash = await this.calculateFileHash(file);
fileInfo.fileHash = fileHash;
// 接下来进行分片上传
this.uploadChunks(fileInfo);
},
代码解释:
- 计算分块数量:
chunkCount = Math.ceil(totalSize / chunkSize)
计算需要分割成多少个 chunk。 - 循环切割文件:
file.slice(start, end)
方法用于切割文件,返回一个新的Blob
对象,代表文件的一个 chunk。 - chunk 信息存储: 将每个 chunk 的信息(chunk 数据、index、start、end)存储到
fileInfo.chunks
数组中。
3. 断点续传:网络波动?不存在的!
核心思想:在上传每个 chunk 之前,先检查服务器是否已经存在该 chunk,如果存在,则跳过上传。
实现步骤:
- 服务器端: 需要记录每个 chunk 的上传状态(已上传/未上传)。
- 客户端: 在上传每个 chunk 之前,向服务器发送一个请求,检查该 chunk 是否已经上传。
修改 uploadChunks
方法:
async uploadChunks(fileInfo) {
const chunks = fileInfo.chunks;
const uploadPromises = []; // 并发上传promise
for (const chunkInfo of chunks) {
if (!chunkInfo.uploaded) {
const formData = new FormData();
formData.append('file', chunkInfo.chunk);
formData.append('filename', fileInfo.name);
formData.append('totalSize', fileInfo.size);
formData.append('chunkIndex', chunkInfo.index);
formData.append('totalChunks', chunks.length);
formData.append('fileHash', fileInfo.fileHash);
const uploadPromise = fetch(this.uploadUrl, {
method: 'POST',
body: formData,
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json(); // 假设服务器返回JSON
})
.then(data => {
if (data.code === 200) {
chunkInfo.uploaded = true;
// 上传成功后更新进度条
this.updateProgress(fileInfo);
} else {
console.error('上传失败:', data.message);
}
})
.catch(error => {
console.error('上传出错:', error);
});
uploadPromises.push(uploadPromise); // 推进并发上传
}
}
// 使用Promise.all等待所有分片上传完成
await Promise.all(uploadPromises);
// 检查是否所有分片都已上传
const allChunksUploaded = chunks.every(chunk => chunk.uploaded);
if (allChunksUploaded) {
console.log(`文件 ${fileInfo.name} 上传完成!`);
}
},
服务器端(示例):
# Python (Flask) 示例
from flask import Flask, request, jsonify
import os
app = Flask(__name__)
UPLOAD_FOLDER = 'uploads' # 上传文件存储目录
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
@app.route('/api/upload', methods=['POST'])
def upload():
file = request.files['file']
filename = request.form['filename']
total_size = int(request.form['totalSize'])
chunk_index = int(request.form['chunkIndex'])
total_chunks = int(request.form['totalChunks'])
file_hash = request.form['fileHash']
chunk_filename = f"{file_hash}_{chunk_index}" # 分片文件名
chunk_filepath = os.path.join(app.config['UPLOAD_FOLDER'], chunk_filename)
# 检查分片是否已存在
if os.path.exists(chunk_filepath):
return jsonify({'code': 200, 'message': '分片已存在'})
# 保存分片
file.save(chunk_filepath)
# 检查是否所有分片都已上传
uploaded_chunks = [f for f in os.listdir(app.config['UPLOAD_FOLDER']) if f.startswith(file_hash)]
if len(uploaded_chunks) == total_chunks:
# 合并分片
merged_filename = filename
merged_filepath = os.path.join(app.config['UPLOAD_FOLDER'], merged_filename)
with open(merged_filepath, 'wb') as merged_file:
for i in range(total_chunks):
chunk_filename = f"{file_hash}_{i}"
chunk_filepath = os.path.join(app.config['UPLOAD_FOLDER'], chunk_filename)
with open(chunk_filepath, 'rb') as chunk_file:
merged_file.write(chunk_file.read())
os.remove(chunk_filepath) # 删除分片
return jsonify({'code': 200, 'message': '上传完成'})
return jsonify({'code': 200, 'message': '分片上传成功'})
if __name__ == '__main__':
if not os.path.exists(UPLOAD_FOLDER):
os.makedirs(UPLOAD_FOLDER)
app.run(debug=True)
代码解释:
- 服务器端存储: 使用文件系统的hash值加分片索引来保存分片。
- 检查分片是否存在: 在保存分片之前,检查分片是否已经存在,如果存在直接返回。
- 分片合并: 上传完成后,将所有的分片合并成一个完整的文件。
4. 进度显示:让用户心里有数
核心思想:在每次 chunk 上传成功后,更新文件的上传进度,并在 UI 上展示。
修改 updateProgress
方法:
updateProgress(fileInfo) {
const uploadedChunks = fileInfo.chunks.filter(chunk => chunk.uploaded).length;
fileInfo.progress = Math.floor((uploadedChunks / fileInfo.chunks.length) * 100);
this.$forceUpdate(); // 强制更新视图
},
代码解释:
- 计算上传进度:
(uploadedChunks / fileInfo.chunks.length) * 100
计算已上传 chunk 的比例。 - 更新 UI: 将计算出的进度值更新到
file.progress
属性,并在 template 中展示。this.$forceUpdate()
强制更新视图,确保进度条及时更新。
5. 多文件上传:雨露均沾,一个都不能少
多文件上传的功能已经在上面的代码中实现了。input
标签的 multiple
属性允许用户选择多个文件,handleFileChange
方法会将所有选择的文件信息存储到 fileList
数组中。然后,我们只需要遍历 fileList
数组,对每个文件进行分块上传即可。
6. 优化和改进:精益求精,更上一层楼
- 错误处理: 添加更完善的错误处理机制,例如:上传失败重试、网络错误提示等。
- 并发控制: 限制并发上传的 chunk 数量,防止浏览器卡顿。
- 取消上传: 允许用户取消正在上传的文件。
- 暂停/恢复上传: 允许用户暂停和恢复上传。
- UI 美化: 使用更美观的 UI 库(例如 Element UI、Ant Design Vue)来美化上传组件。
- 拖拽上传: 支持拖拽文件到上传区域进行上传。
- 服务端优化: 使用 CDN 加速文件上传,优化服务器端存储方案。
7. 总结:豪华版上传组件,安排!
今天我们一起打造了一个功能强大的 Vue 文件上传组件,支持文件分块上传、断点续传、进度显示和多文件上传。虽然代码比较多,但是每个部分都进行了详细的解释。希望大家能够理解其中的原理,并将其应用到自己的项目中。
记住,罗马不是一天建成的,一个优秀的组件也不是一蹴而就的。我们需要不断地学习、实践、总结,才能打造出真正好用的组件。
最后,希望大家多多练习,早日成为 Vue 大神! 下课!