前端音视频大文件分片上传与断点续传方案设计
背景
文件上传是前端开发中一个常见的功能需求,最近给在对接券商的一个后台管理系统需求的时候,需要新增音视频素材库,用于上传相应的素材在业务中使用。由于音视频文件体积一般都较大,直接上传会遇到很多问题,比如:文件体积大,上传时间长、网络不稳定上传失败、服务器压力大和用户体验差等。
为了解决这些问题,我们需要设计一个可靠的大文件上传方案:
整体设计
我们采用"分片上传 + 断点续传"的方案,主要包含以下核心功能:
- 文件分片:将大文件分割成小块进行上传
- 断点续传:支持断点续传,提高上传可靠性
- 秒传功能:通过文件指纹避免重复上传
- 并发控制:控制上传并发数,避免服务器压力过大
- 进度显示:实时显示上传进度,提升用户体验
1. 整体上传流程
选择文件
↓
文件校验(类型、大小)
↓
计算文件 MD5
↓
检查文件是否已存在(秒传)
↓
生成文件切片
↓
检查已上传切片
↓
上传未完成的切片(断点续传)
↓
合并切片(后端)
↓
上传完成
2. 核心功能实现
2.1 文件预处理
在文件上传前,我们需要进行必要的预处理校验工作:
// 允许上传的文件类型
const FILE_TYPE_LIMIT = ["video/mp4"];
// 文件大小限制,单位为字节,这里是 1GB
const FILE_SIZE_LIMIT = 1024 * 1024 * 1024;
const handleFileChange = async (file) => {
try {
// 1. 文件校验
if (!FILE_TYPE_LIMIT.includes(file.raw.type)) {
console.error("不支持的文件类型");
return;
}
if (file.raw.size > FILE_SIZE_LIMIT) {
console.error("文件大小超出限制");
return;
}
// 2. 添加到文件列表
fileList.push({
originFile: file.raw,
fileName: file.raw.name,
fileSize: file.raw.size,
fileStatus: 'wating',
progress: 0,
});
// 3. 开始上传流程
await handleUploadLargeFile(file.raw, fileList[0]);
} catch (error) {
console.error("文件处理错误:", error);
}
};
2.2 MD5 计算
为了支持秒传和断点续传功能,我们需要计算文件的 MD5 值作为唯一标识。由于大文件计算 MD5 可能会比较耗时,我们采用分片计算的方式:
import SparkMD5 from "spark-md5";
// 计算文件 MD5
const calculateFileMD5 = (file) => {
return new Promise((resolve, reject) => {
const chunkSize = 2 * 1024 * 1024; // 2MB
const chunks = Math.ceil(file.size / chunkSize);
let currentChunk = 0;
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
// 读取文件切片
const loadNext = () => {
const start = currentChunk * chunkSize;
const end = start + chunkSize >= file.size ? file.size : start + chunkSize;
fileReader.readAsArrayBuffer(file.slice(start, end));
};
loadNext(); // 开始读取第一个切片
// 文件读取完成后的处理
fileReader.onload = (e) => {
spark.append(e.target.result);
currentChunk++;
if (currentChunk < chunks) {
// 继续读取下一个切片
loadNext();
} else {
// 所有切片读取完成,计算最终的 MD5 值
const md5 = spark.end();
resolve(md5);
}
};
// 错误处理
fileReader.onerror = (error) => {
reject(error);
};
});
};
2.3 秒传功能
秒传功能可以避免重复上传相同的文件,提高上传效率。实现原理是通过文件的 MD5 值判断文件是否已存在于服务器:
// 在文件上传流程中使用秒传功能
const handleUploadLargeFile = async (file, index) => {
try {
// 1. 计算文件 MD5
const fileMd5 = await calculateFileMD5(file);
// 2. 检查文件是否已存在(秒传)
const isFileExists = await checkFileExists(fileMd5, file.name);
if (isFileExists) {
// 秒传成功
return;
}
// 3. 继续后续的分片上传流程...
} catch (error) { }
};
// 检查文件在服务端是否已存在(秒传)
const checkFileExists = async (fileMd5, fileName) => {
try {
const { data } = await axios({
method: "post",
url: "/api/check-file",
data: {
md5: fileMd5,
fileName: fileName,
}
});
return data.state === 1;
} catch (error) {
console.error("检查文件是否存在失败:", error);
return false;
}
};
2.4 文件分片
为了提高上传效率和可靠性,我们将文件分割成小块:
const generateFileChunks = (file) => {
const chunkSize = 2 * 1024 * 1024; // 2MB
const chunks = [];
let cur = 0;
while (cur < file.size) {
chunks.push(file.slice(cur, cur + chunkSize));
cur += chunkSize;
}
return chunks;
};
2.5 切片上传队列与状态管理
切片上传队列和状态管理是整个上传功能的核心,它负责管理所有切片的上传状态、重试机制和进度更新:
// 切片上传状态管理
const chunkStatus = new Map();
// 上传队列管理
const uploadQueue = [];
const activeUploads = 0;
// 初始化切片状态
/*
* chunks: 需要上传的切片
* uploadedChunks: 已经上传过的切片
*/
const initChunkStatus = (chunks, uploadedChunks) => {
chunkStatus = new Map();
chunks.forEach((_, index) => {
const isUploaded = uploadedChunks.includes(index);
chunkStatus.set(index, {
status: isUploaded ? 'success' : 'pending',
retryCount: 0,
progress: isUploaded ? 100 : 0
});
});
};
// 创建上传任务
const createUploadTasks = (chunks, fileMd5, fileName) => {
return chunks
.map((chunk, index) => ({
chunk,
index,
fileMd5,
fileName
}))
.filter((_, index) => chunkStatus.get(index).status !== 'success'); // 断点续传未成功的
};
// 处理上传队列,控制并发数量不超过3
const processUploadQueue = async () => {
while (uploadQueue.length > 0 && activeUploads < 3) {
const task = uploadQueue.shift();
activeUploads++;
await uploadChunk(task);
}
};
// 上传单个切片
const uploadChunk = async (task) => {
const { chunk, index, fileMd5, fileName } = task;
const chunkInfo = chunkStatus.get(index);
try {
// 更新切片状态为上传中
chunkStatus.set(index, {
...chunkInfo,
status: 'uploding',
progress: 0
});
const formData = new FormData();
formData.append('file', chunk);
formData.append('chunkIndex', index);
formData.append('md5', fileMd5);
formData.append('fileName', fileName);
const { data } = await axios({
method: 'post',
url: '/api/upload-chunk',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (progressEvent) => {
// 更新切片上传进度
const progress = Math.floor((progressEvent.loaded / progressEvent.total) * 100);
chunkStatus.set(index, {
...chunkInfo,
progress
});
// 更新文件总进度
updateFileProgress(fileIndex);
}
});
// 上传成功,更新状态
chunkStatus.set(index, {
...chunkInfo,
status: 'success',
progress: 100
});
// 检查是否所有切片都上传完成
checkUploadComplete(fileIndex);
} catch (error) {
console.error(`切片 ${index} 上传失败:`, error);
handleUploadError(task, chunkInfo);
} finally {
activeUploads--;
// 继续执行切片上传任务
processUploadQueue();
}
};
// 处理上传错误
const handleUploadError = (task, chunkInfo) => {
const { index, fileIndex } = task;
if (chunkInfo.retryCount < 3) {
// 重试上传
chunkStatus.set(index, {
...chunkInfo,
status: 'pending',
retryCount: chunkInfo.retryCount + 1
});
setTimeout(() => {
uploadQueue.push(task);
processUploadQueue();
}, 1000);
} else {
// 重试次数用完,标记为失败
chunkStatus.set(index, {
...chunkInfo,
status: 'failed'
});
fileList[0].fileStatus = FileStatus.Failed;
console.error(`切片 ${index + 1} 上传失败`);
}
};
// 检查上传是否完成
const checkUploadComplete = (fileIndex) => {
const allChunks = Array.from(chunkStatuss());
const isComplete = allChunks.every(chunk => chunk.status === 'success');
if (isComplete) {
// 所有切片上传完成,请求合并
mergeChunks(fileIndex);
}
};
// 更新文件总进度
const updateFileProgress = (fileIndex) => {
const chunks = Array.from(chunkStatuss());
const totalProgress = chunks.reduce((acc, chunk) => acc + chunk.progress, 0) / chunks.length;
fileList[0].progress = Math.floor(totalProgress);
};
// 请求合并切片
const mergeChunks = async () => {
try {
const file = fileList[0];
const { data } = await axios({
method: 'post',
url: '/api/merge-chunks',
data: {
md5: file.fileMd5,
fileName: file.fileName
}
});
if (data.state === 1) {
// 文件上传成功...
} else {
throw new Error('文件合并失败');
}
} catch (error)
// 文件合并失败...
}
};
2.6 断点续传
断点续传是提高上传可靠性的关键功能:若当前文件存在上次未完成切片,则继续上传
const checkUploadedChunks = async (fileMd5, fileName) => {
try {
const { data } = await axios({
method: "post",
url: "/api/check-chunks",
data: {
md5: fileMd5,
fileName: fileName,
}
});
return data.uploadedChunks || [];
} catch (error) {
console.error("检查已上传切片失败:", error);
return [];
}
};
2.7 并发控制
为了避免服务器压力过大,我们实现了并发控制:
const uploadQueue = [];
const activeUploads = 0;
const processUploadQueue = async () => {
while (uploadQueue.length > 0 && activeUploads < 3) {
const task = uploadQueue.shift();
activeUploads++;
await uploadChunk(task);
}
};
2.8 进度管理
为了提供良好的用户体验,我们实现了实时进度显示:
const updateChunkProgress = (fileIndex, chunkIndex, progressEvent) => {
const totalChunks = chunkStatus.size;
const chunkProgress = progressEvent.loaded / progressEvent.total;
const completedChunks = Array.from(chunkStatuss())
.filter(status => status.status === "success").length;
const totalProgress = Math.floor(
((completedChunks + chunkProgress) / totalChunks) * 100
);
fileList[0].progress = totalProgress;
};
总结
通过上述方案,我们成功解决了大文件上传的问题。该方案分片上传优化了服务器压力,提升了用户体验;断点续传提高了上传的可靠性,还实现了秒传功能。在实际应用中,可以根据具体需求进行相应的调整和优化,以达到最佳效果。

浙公网安备 33010602011771号