大文件分片上传并保存上传进度
一、基本原理
1.1 为什么需要分片上传?
在前端开发中,当需要上传大文件时,常常会遇到以下问题:
- 请求超时:大文件上传耗时长,容易触发服务器或浏览器的超时限制
- 上传失败成本高:如果上传到 99% 时网络中断,则需要重新上传整个文件
- 内存占用大:一次性加载大文件会占用大量内存资源
- 用户体验差:无法精确显示上传进度,用户等待时间长
分片上传技术可以有效解决上述问题,它的核心思想是:将大文件切分成多个小块,分别上传,最后在服务器端合并。
1.2 分片上传的基本流程
- 文件分片:使用 Blob.slice() 方法将文件切分成多个小块
- 并发上传:将这些分片并发上传到服务器
- 上传进度记录:记录每个分片的上传状态和进度
- 断点续传:当上传中断时,可以从已上传的部分继续
- 服务器合并:所有分片上传完成后,服务器将分片合并成完整文件
二、前端实现方法
2.1 文件分片
使用 JavaScript 的 File 和 Blob API 可以轻松实现文件分片:
/**
* 将文件切片
* @param {File} file - 要上传的文件
* @param {Number} chunkSize - 分片大小(字节)
* @returns {Array} 分片数组
*/
function createFileChunks(file, chunkSize = 2 * 1024 * 1024) {
const chunks = [];
let cur = 0;
while (cur < file.size) {
chunks.push(file.slice(cur, cur + chunkSize));
cur += chunkSize;
}
return chunks;
}
2.2 计算文件唯一标识
为了实现断点续传和秒传功能,需要给每个文件生成唯一标识:
/**
* 计算文件的hash值(使用spark-md5)
* @param {File} file - 文件对象
* @returns {Promise<string>} 文件hash值
*/
async function calculateFileHash(file) {
return new Promise((resolve) => {
const chunks = createFileChunks(file);
const spark = new SparkMD5.ArrayBuffer();
let count = 0;
const loadNext = (index) => {
const reader = new FileReader();
reader.onload = (e) => {
spark.append(e.target.result);
count++;
if (count === chunks.length) {
resolve(spark.end());
} else {
loadNext(count);
}
};
reader.readAsArrayBuffer(chunks[index]);
};
loadNext(0);
});
}
2.3 上传分片
/**
* 上传单个分片
* @param {Blob} chunk - 分片数据
* @param {Number} index - 分片索引
* @param {String} fileHash - 文件hash
* @param {String} fileName - 文件名
* @param {Function} onProgress - 进度回调
* @returns {Promise}
*/
async function uploadChunk(chunk, index, fileHash, fileName, onProgress) {
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('hash', fileHash);
formData.append('fileName', fileName);
formData.append('chunkIndex', index);
return axios.post('/upload/chunk', formData, {
onUploadProgress: (progressEvent) => {
if (onProgress) {
onProgress({
chunkIndex: index,
loaded: progressEvent.loaded,
total: progressEvent.total
});
}
}
});
}
2.4 并发控制
为了避免同时发送过多请求导致浏览器或服务器崩溃,需要控制并发数:
/**
* 控制并发上传
* @param {Array} chunks - 分片数组
* @param {String} fileHash - 文件hash
* @param {String} fileName - 文件名
* @param {Number} limit - 并发数限制
* @param {Function} onProgress - 进度回调
* @returns {Promise}
*/
async function uploadChunksWithConcurrencyLimit(chunks, fileHash, fileName, limit = 3, onProgress) {
const pool = []; // 并发池
const results = []; // 所有请求的结果
let uploadedChunks = 0; // 已上传的分片数
// 创建上传任务
const createTask = (chunk, index) => {
const task = uploadChunk(chunk, index, fileHash, fileName, (progress) => {
// 更新进度
if (onProgress) {
onProgress({
chunkIndex: index,
loaded: progress.loaded,
total: progress.total,
uploadedChunks,
totalChunks: chunks.length
});
}
}).then(result => {
uploadedChunks++;
results[index] = result;
// 从并发池中移除已完成的任务
const taskIndex = pool.findIndex(t => t === task);
if (taskIndex !== -1) pool.splice(taskIndex, 1);
});
pool.push(task);
return task;
};
// 开始上传
for (let i = 0; i < chunks.length; i++) {
const task = createTask(chunks[i], i);
// 控制并发数
if (pool.length >= limit) {
await Promise.race(pool);
}
}
// 等待所有分片上传完成
await Promise.all(pool);
return results;
}
三、保存和恢复上传进度
3.1 进度信息存储
可以使用 localStorage 或 IndexedDB 存储上传进度信息:
/**
* 保存上传进度
* @param {String} fileHash - 文件hash
* @param {Object} progressInfo - 进度信息
*/
function saveUploadProgress(fileHash, progressInfo) {
try {
localStorage.setItem(
`upload_progress_${fileHash}`,
JSON.stringify(progressInfo)
);
} catch (e) {
console.error('保存上传进度失败', e);
}
}
/**
* 获取上传进度
* @param {String} fileHash - 文件hash
* @returns {Object|null} 进度信息
*/
function getUploadProgress(fileHash) {
try {
const progress = localStorage.getItem(`upload_progress_${fileHash}`);
return progress ? JSON.parse(progress) : null;
} catch (e) {
console.error('获取上传进度失败', e);
return null;
}
}
3.2 断点续传实现
在上传前,先检查是否有未完成的上传任务:
/**
* 检查文件上传状态
* @param {String} fileHash - 文件hash
* @returns {Promise<Object>} 上传状态
*/
async function checkFileUploadStatus(fileHash) {
try {
const response = await axios.post('/upload/check', { fileHash });
return response.data;
} catch (e) {
console.error('检查文件上传状态失败', e);
return { uploaded: false, uploadedChunks: [] };
}
}
/**
* 上传文件(支持断点续传)
* @param {File} file - 文件对象
*/
async function uploadFile(file) {
// 1. 计算文件hash
const fileHash = await calculateFileHash(file);
const fileName = file.name;
// 2. 检查文件上传状态
const { uploaded, uploadedChunks = [] } = await checkFileUploadStatus(fileHash);
// 3. 如果文件已上传完成,直接返回
if (uploaded) {
console.log('文件已上传,秒传成功');
return;
}
// 4. 创建文件分片
const chunks = createFileChunks(file);
// 5. 过滤掉已上传的分片
const chunksToUpload = chunks.filter((_, index) => !uploadedChunks.includes(index));
// 6. 上传分片
const uploadProgress = {
fileHash,
fileName,
chunkProgress: {},
uploadedChunks: [...uploadedChunks],
totalChunks: chunks.length
};
// 保存初始进度
saveUploadProgress(fileHash, uploadProgress);
// 上传分片并更新进度
await uploadChunksWithConcurrencyLimit(
chunksToUpload,
fileHash,
fileName,
3,
(progress) => {
// 更新进度信息
uploadProgress.chunkProgress[progress.chunkIndex] = {
loaded: progress.loaded,
total: progress.total
};
if (!uploadProgress.uploadedChunks.includes(progress.chunkIndex) &&
progress.loaded === progress.total) {
uploadProgress.uploadedChunks.push(progress.chunkIndex);
}
// 保存进度
saveUploadProgress(fileHash, uploadProgress);
// 计算总进度
const totalLoaded = Object.values(uploadProgress.chunkProgress)
.reduce((acc, cur) => acc + cur.loaded, 0);
const totalSize = chunks.reduce((acc, chunk) => acc + chunk.size, 0);
const percent = Math.floor((totalLoaded / totalSize) * 100);
console.log(`上传进度: ${percent}%`);
}
);
// 7. 通知服务器合并分片
await axios.post('/upload/merge', {
fileHash,
fileName,
size: file.size
});
// 8. 清除进度记录
localStorage.removeItem(`upload_progress_${fileHash}`);
console.log('文件上传成功');
}
四、完整实现示例
下面是一个完整的前端实现示例,包含UI交互:
// 引入依赖
import SparkMD5 from 'spark-md5';
import axios from 'axios';
class FileUploader {
constructor(options = {}) {
this.chunkSize = options.chunkSize || 2 * 1024 * 1024; // 默认2MB
this.concurrency = options.concurrency || 3; // 默认并发数
this.baseUrl = options.baseUrl || '';
this.onProgress = options.onProgress || (() => {});
this.onSuccess = options.onSuccess || (() => {});
this.onError = options.onError || (() => {});
}
// 创建文件分片
createFileChunks(file) {
const chunks = [];
let cur = 0;
while (cur < file.size) {
chunks.push(file.slice(cur, cur + this.chunkSize));
cur += this.chunkSize;
}
return chunks;
}
// 计算文件hash
calculateFileHash(file) {
return new Promise((resolve) => {
const chunks = this.createFileChunks(file);
const spark = new SparkMD5.ArrayBuffer();
let count = 0;
const loadNext = (index) => {
const reader = new FileReader();
reader.onload = (e) => {
spark.append(e.target.result);
count++;
// 更新hash计算进度
this.onProgress({
stage: 'hash',
percentage: Math.floor((count / chunks.length) * 100)
});
if (count === chunks.length) {
resolve(spark.end());
} else {
loadNext(count);
}
};
reader.readAsArrayBuffer(chunks[index]);
};
loadNext(0);
});
}
// 检查文件上传状态
async checkFileUploadStatus(fileHash) {
try {
const response = await axios.post(`${this.baseUrl}/upload/check`, { fileHash });
return response.data;
} catch (e) {
console.error('检查文件上传状态失败', e);
return { uploaded: false, uploadedChunks: [] };
}
}
// 上传单个分片
uploadChunk(chunk, index, fileHash, fileName) {
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('hash', fileHash);
formData.append('fileName', fileName);
formData.append('chunkIndex', index);
return axios.post(`${this.baseUrl}/upload/chunk`, formData, {
onUploadProgress: (progressEvent) => {
this.chunkProgress[index] = {
loaded: progressEvent.loaded,
total: progressEvent.total
};
this.updateTotalProgress();
}
});
}
// 更新总进度
updateTotalProgress() {
const loaded = Object.values(this.chunkProgress)
.reduce((acc, cur) => acc + cur.loaded, 0);
const total = this.chunks.reduce((acc, chunk) => acc + chunk.size, 0);
const percentage = Math.floor((loaded / total) * 100);
// 更新上传进度
this.onProgress({
stage: 'upload',
percentage,
loaded,
total
});
// 保存进度
this.saveUploadProgress();
}
// 保存上传进度
saveUploadProgress() {
const uploadedChunks = Object.entries(this.chunkProgress)
.filter(([_, progress]) => progress.loaded === progress.total)
.map(([index]) => parseInt(index));
const progressInfo = {
fileHash: this.fileHash,
fileName: this.fileName,
chunkProgress: this.chunkProgress,
uploadedChunks,
totalChunks: this.chunks.length
};
try {
localStorage.setItem(
`upload_progress_${this.fileHash}`,
JSON.stringify(progressInfo)
);
} catch (e) {
console.error('保存上传进度失败', e);
}
}
// 获取上传进度
getUploadProgress(fileHash) {
try {
const progress = localStorage.getItem(`upload_progress_${fileHash}`);
return progress ? JSON.parse(progress) : null;
} catch (e) {
console.error('获取上传进度失败', e);
return null;
}
}
// 控制并发上传
async uploadChunksWithConcurrencyLimit() {
const pool = []; // 并发池
const chunksToUpload = this.chunks.filter((_, index) =>
!this.uploadedChunks.includes(index)
);
// 创建上传任务
const createTask = (chunk, index) => {
const task = this.uploadChunk(chunk, index, this.fileHash, this.fileName)
.then(result => {
// 从并发池中移除已完成的任务
const taskIndex = pool.findIndex(t => t === task);
if (taskIndex !== -1) pool.splice(taskIndex, 1);
return result;
});
pool.push(task);
return task;
};
// 开始上传
for (let i = 0; i < chunksToUpload.length; i++) {
const chunk = chunksToUpload[i];
const index = this.chunks.findIndex(c => c === chunk);
const task = createTask(chunk, index);
// 控制并发数
if (pool.length >= this.concurrency) {
await Promise.race(pool);
}
}
// 等待所有分片上传完成
await Promise.all(pool);
}
// 合并分片
async mergeChunks() {
await axios.post(`${this.baseUrl}/upload/merge`, {
fileHash: this.fileHash,
fileName: this.fileName,
size: this.file.size
});
}
// 上传文件
async upload(file) {
try {
this.file = file;
this.fileName = file.name;
this.chunkProgress = {};
// 1. 计算文件hash
this.onProgress({ stage: 'hash', percentage: 0 });
this.fileHash = await this.calculateFileHash(file);
// 2. 检查文件上传状态
const { uploaded, uploadedChunks = [] } = await this.checkFileUploadStatus(this.fileHash);
// 3. 如果文件已上传完成,直接返回
if (uploaded) {
this.onProgress({ stage: 'upload', percentage: 100 });
this.onSuccess({ fileHash: this.fileHash, fileName: this.fileName });
return;
}
// 4. 创建文件分片
this.chunks = this.createFileChunks(file);
this.uploadedChunks = uploadedChunks;
// 5. 恢复上传进度
const savedProgress = this.getUploadProgress(this.fileHash);
if (savedProgress) {
this.chunkProgress = savedProgress.chunkProgress || {};
this.uploadedChunks = savedProgress.uploadedChunks || [];
this.updateTotalProgress();
}
// 6. 上传分片
this.onProgress({ stage: 'upload', percentage: 0 });
await this.uploadChunksWithConcurrencyLimit();
// 7. 合并分片
this.onProgress({ stage: 'merge', percentage: 0 });
await this.mergeChunks();
// 8. 清除进度记录
localStorage.removeItem(`upload_progress_${this.fileHash}`);
this.onProgress({ stage: 'merge', percentage: 100 });
this.onSuccess({ fileHash: this.fileHash, fileName: this.fileName });
} catch (error) {
this.onError(error);
}
}
}
// 使用示例
const uploader = new FileUploader({
baseUrl: 'http://localhost:3000',
onProgress: (progress) => {
console.log(`阶段: ${progress.stage}, 进度: ${progress.percentage}%`);
// 更新UI进度条
document.getElementById('progress').style.width = `${progress.percentage}%`;
document.getElementById('progress-text').innerText = `${progress.percentage}%`;
},
onSuccess: (result) => {
console.log('上传成功', result);
// 更新UI
document.getElementById('status').innerText = '上传成功';
},
onError: (error) => {
console.error('上传失败', error);
// 更新UI
document.getElementById('status').innerText = '上传失败';
}
});
// 绑定上传事件
document.getElementById('file-input').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
uploader.upload(file);
}
});
五、常见问题及解决方案
5.1 如何处理上传失败的分片?
当某个分片上传失败时,可以采用以下策略:
- 重试机制:对失败的分片进行多次重试
- 降级策略:如果重试多次仍失败,可以降低并发数或分片大小
- 记录失败信息:将失败的分片信息记录下来,下次上传时优先处理
// 分片上传失败重试示例
async function uploadChunkWithRetry(chunk, index, fileHash, fileName, retries = 3) {
try {
return await uploadChunk(chunk, index, fileHash, fileName);
} catch (err) {
if (retries > 0) {
console.log(`分片${index}上传失败,剩余重试次数:${retries}`);
return uploadChunkWithRetry(chunk, index, fileHash, fileName, retries - 1);
} else {
throw new Error(`分片${index}上传失败,已达到最大重试次数`);
}
}
}
5.2 如何优化文件hash计算?
对于大文件,计算完整hash可能会很耗时,可以采用以下优化方法:
- 抽样hash:只取文件的部分内容计算hash
- Web Worker:将hash计算放在Web Worker中进行,避免阻塞主线程
- 分片计算:分批计算hash,避免一次性读取大文件
// 抽样hash计算示例
async function calculateFileHashSample(file) {
return new Promise((resolve) => {
const spark = new SparkMD5.ArrayBuffer();
const reader = new FileReader();
// 取文件的头部、中间和尾部各2MB计算hash
const chunks = [
file.slice(0, 2 * 1024 * 1024),
file.slice(file.size / 2 - 1024 * 1024, file.size / 2 + 1024 * 1024),
file.slice(file.size - 2 * 1024 * 1024, file.size)
];
let count = 0;
reader.onload = (e) => {
spark.append(e.target.result);
count++;
if (count === chunks.length) {
// 加入文件大小信息,提高唯一性
spark.append(file.size.toString());
resolve(spark.end());
} else {
reader.readAsArrayBuffer(chunks[count]);
}
};
reader.readAsArrayBuffer(chunks[0]);
});
}
5.3 如何处理浏览器刷新或关闭?
当用户刷新或关闭浏览器时,可以通过以下方式保存上传状态:
- localStorage:保存上传进度和状态
- beforeunload事件:在页面关闭前提示用户
- Service Worker:在后台继续上传(高级用法)
// 监听页面关闭事件
window.addEventListener('beforeunload', (e) => {
if (uploader.isUploading) {
// 显示提示信息
const message = '文件正在上传中,关闭页面将中断上传,是否确认关闭?';
e.returnValue = message;
return message;
}
});
5.4 秒传功能实现
秒传功能的核心是通过文件hash判断服务器是否已存在相同文件:
async function uploadFile(file) {
// 1. 计算文件hash
const fileHash = await calculateFileHash(file);
// 2. 检查文件是否已存在
const response = await axios.post('/upload/check', { fileHash });
// 3. 如果文件已存在,直接返回成功
if (response.data.uploaded) {
console.log('文件已存在,秒传成功');
return response.data.url;
}
// 4. 文件不存在,继续上传流程
// ...
}
六、服务器端实现思路
虽然本文主要关注前端实现,但服务器端的处理也很重要:
- 接收分片:处理前端上传的文件分片
- 保存分片:将分片保存到临时目录
- 合并分片:所有分片上传完成后,合并成完整文件
- 清理分片:合并完成后,清理临时分片文件
服务器端可以使用Node.js、Java、Python等语言实现,核心逻辑相似。
七、总结
大文件分片上传是解决大文件上传问题的有效方案,通过合理的前端实现,可以显著提升用户体验:
- 可靠性提升:断点续传确保上传过程可靠
- 性能优化:并发上传提高上传速度
- 用户体验改善:精确的进度显示和状态保存
- 服务器压力减轻:分片处理减轻服务器压力
在实际项目中,可以根据具体需求调整分片大小、并发数等参数,以达到最佳效果。
八、参考资源
- MDN Web Docs: File API
- MDN Web Docs: Blob
- MDN Web Docs: FileReader
- spark-md5 - 用于计算文件hash
- axios - 用于发送HTTP请求