|
|

### 1. 文件MD5加密
使用SparkMD5库计算文件的MD5值,作为文件的唯一标识,用于后端校验文件完整性和实现秒传功能。
### 2. 文件分片
使用 Blob.slice() 方法将大文件分割成固定大小的小块(5M或10M),分别上传。
### 3. 断点续传
通过localStorage或IndexedDB记录已上传的分片信息,上传前检查哪些分片已上传,只上传未完成的部分。
### 4. 分片上传管理
使用Promise.all或async/await控制分片上传,支持并发上传和错误重试。
/**
* 大文件分片上传工具类
*/
class FileUploader {
constructor(options = {}) {
// 配置项
this.chunkSize = options.chunkSize || 5 * 1024 * 1024; // 默认5MB一片
this.concurrency = options.concurrency || 3; // 并发上传数量
this.uploadUrl = options.uploadUrl || '/api/upload/chunk'; // 分片上传接口
this.mergeUrl = options.mergeUrl || '/api/upload/merge'; // 合并文件接口
this.checkUrl = options.checkUrl || '/api/upload/check'; // 检查文件接口
}
/**
* 计算文件MD5值
* @param {File} file - 要上传的文件
* @returns {Promise<string>} - 返回文件的MD5值
*/
async calculateFileMd5(file) {
return new Promise((resolve, reject) => {
// 引入SparkMD5库
const SparkMD5 = require('spark-md5');
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
const chunkSize = 2 * 1024 * 1024; // 计算MD5的块大小
let currentChunk = 0;
// 读取文件块
const loadNextChunk = () => {
const start = currentChunk * chunkSize;
const end = Math.min(start + chunkSize, file.size);
fileReader.readAsArrayBuffer(file.slice(start, end));
};
// 处理读取完成的块
fileReader.onload = (e) => {
spark.append(e.target.result);
currentChunk++;
if (currentChunk < Math.ceil(file.size / chunkSize)) {
// 继续读取下一块
loadNextChunk();
// 可以在这里触发进度回调
} else {
// 计算完成,返回MD5值
const md5 = spark.end();
resolve(md5);
}
};
// 处理错误
fileReader.onerror = (error) => {
reject(new Error('文件MD5计算失败'));
};
// 开始读取第一块
loadNextChunk();
});
}
/**
* 获取文件分片信息
* @param {File} file - 要分片的文件
* @param {string} fileMd5 - 文件的MD5值
* @returns {Array} - 返回分片信息数组
*/
getFileChunks(file, fileMd5) {
const chunks = [];
const totalChunks = Math.ceil(file.size / this.chunkSize);
for (let i = 0; i < totalChunks; i++) {
const start = i * this.chunkSize;
const end = Math.min(start + this.chunkSize, file.size);
chunks.push({
chunk: file.slice(start, end), // 分片数据
index: i, // 分片索引
size: end - start, // 分片大小
fileMd5, // 文件MD5
fileName: file.name, // 文件名
totalChunks // 总分片数
});
}
return chunks;
}
/**
* 保存上传状态到localStorage
* @param {string} fileMd5 - 文件MD5
* @param {Array} uploadedChunks - 已上传的分片索引数组
*/
saveUploadState(fileMd5, uploadedChunks) {
try {
localStorage.setItem(`upload_${fileMd5}`, JSON.stringify({
uploadedChunks,
lastUpdate: Date.now()
}));
} catch (error) {
console.warn('保存上传状态失败:', error);
}
}
/**
* 从localStorage获取上传状态
* @param {string} fileMd5 - 文件MD5
* @returns {Array|null} - 返回已上传的分片索引数组或null
*/
getUploadState(fileMd5) {
try {
const data = localStorage.getItem(`upload_${fileMd5}`);
if (data) {
const { uploadedChunks } = JSON.parse(data);
return uploadedChunks;
}
} catch (error) {
console.warn('获取上传状态失败:', error);
}
return null;
}
/**
* 上传单个分片
* @param {Object} chunkInfo - 分片信息
* @returns {Promise} - 上传结果
*/
async uploadChunk(chunkInfo) {
const { chunk, index, fileMd5, fileName, totalChunks } = chunkInfo;
// 创建FormData
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('index', index);
formData.append('fileMd5', fileMd5);
formData.append('fileName', fileName);
formData.append('totalChunks', totalChunks);
try {
const response = await axios.post(this.uploadUrl, formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress: (progressEvent) => {
// 可以在这里处理单个分片的上传进度
if (progressEvent.total) {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
console.log(`分片 ${index} 上传进度: ${percentCompleted}%`);
}
}
});
return response.data;
} catch (error) {
console.error(`分片 ${index} 上传失败:`, error);
throw error;
}
}
/**
* 并发上传分片
* @param {Array} chunks - 分片信息数组
* @param {Array} uploadedChunks - 已上传的分片索引
* @param {Function} onProgress - 进度回调函数
*/
async uploadChunks(chunks, uploadedChunks = [], onProgress) {
// 过滤出未上传的分片
const unUploadedChunks = chunks.filter(chunk => !uploadedChunks.includes(chunk.index));
// 用于保存已上传的分片索引
const currentUploadedChunks = [...uploadedChunks];
// 分片总数
const totalChunks = chunks.length;
// 并发上传控制
const queue = [...unUploadedChunks];
const results = [];
// 处理队列中的任务
const processQueue = async () => {
if (queue.length === 0) return;
const chunk = queue.shift();
try {
const result = await this.uploadChunk(chunk);
results.push(result);
// 记录已上传的分片
currentUploadedChunks.push(chunk.index);
this.saveUploadState(chunk.fileMd5, currentUploadedChunks);
// 更新进度
if (onProgress) {
onProgress(currentUploadedChunks.length / totalChunks);
}
console.log(`分片 ${chunk.index} 上传成功`);
} catch (error) {
console.error(`分片 ${chunk.index} 上传失败,将重试`, error);
// 将失败的分片重新加入队列
queue.unshift(chunk);
}
// 继续处理队列
return processQueue();
};
// 创建并发任务
const concurrentTasks = [];
for (let i = 0; i < Math.min(this.concurrency, unUploadedChunks.length); i++) {
concurrentTasks.push(processQueue());
}
// 等待所有任务完成
await Promise.all(concurrentTasks);
// 检查是否所有分片都已上传
const allUploaded = currentUploadedChunks.length === totalChunks;
return {
allUploaded,
uploadedChunks: currentUploadedChunks
};
}
/**
* 检查文件是否已存在或部分上传
* @param {string} fileMd5 - 文件MD5
* @param {string} fileName - 文件名
* @returns {Promise<Array>} - 返回已上传的分片索引数组
*/
async checkFile(fileMd5, fileName) {
try {
const response = await axios.post(this.checkUrl, {
fileMd5,
fileName
});
// 如果文件已存在且完整,直接返回秒传成功
if (response.data.fileExists) {
return { fileExists: true, uploadedChunks: [] };
}
// 返回已上传的分片索引
return {
fileExists: false,
uploadedChunks: response.data.uploadedChunks || []
};
} catch (error) {
console.warn('检查文件失败,假设文件不存在或未上传', error);
return { fileExists: false, uploadedChunks: [] };
}
}
/**
* 合并分片文件
* @param {string} fileMd5 - 文件MD5
* @param {string} fileName - 文件名
* @param {number} totalChunks - 总分片数
* @returns {Promise} - 合并结果
*/
async mergeChunks(fileMd5, fileName, totalChunks) {
try {
const response = await axios.post(this.mergeUrl, {
fileMd5,
fileName,
totalChunks
});
// 合并成功后,清除本地缓存的上传状态
this.clearUploadState(fileMd5);
return response.data;
} catch (error) {
console.error('合并文件失败:', error);
throw error;
}
}
/**
* 清除上传状态
* @param {string} fileMd5 - 文件MD5
*/
clearUploadState(fileMd5) {
try {
localStorage.removeItem(`upload_${fileMd5}`);
} catch (error) {
console.warn('清除上传状态失败:', error);
}
}
/**
* 上传文件(主方法)
* @param {File} file - 要上传的文件
* @param {Function} onProgress - 进度回调函数
* @returns {Promise} - 上传结果
*/
async uploadFile(file, onProgress = () => {}) {
try {
// 1. 计算文件MD5
console.log('开始计算文件MD5...');
const fileMd5 = await this.calculateFileMd5(file);
console.log('文件MD5计算完成:', fileMd5);
// 2. 检查文件是否已存在或部分上传
console.log('检查文件状态...');
const checkResult = await this.checkFile(fileMd5, file.name);
if (checkResult.fileExists) {
console.log('文件已存在,秒传成功');
onProgress(1); // 设置进度为100%
return { success: true, message: '秒传成功', fileMd5 };
}
// 3. 获取本地缓存的上传状态
let uploadedChunks = checkResult.uploadedChunks;
const localState = this.getUploadState(fileMd5);
// 合并服务端和本地的已上传分片信息
if (localState && localState.length > uploadedChunks.length) {
uploadedChunks = localState;
}
// 4. 生成分片信息
console.log('准备分片...');
const chunks = this.getFileChunks(file, fileMd5);
// 5. 上传分片
console.log(`开始上传分片,已上传 ${uploadedChunks.length}/${chunks.length} 个分片`);
const uploadResult = await this.uploadChunks(chunks, uploadedChunks, onProgress);
if (!uploadResult.allUploaded) {
console.log('部分分片上传失败,请重试');
return {
success: false,
message: '部分分片上传失败,请重试',
uploadedChunks: uploadResult.uploadedChunks
};
}
// 6. 合并分片
console.log('所有分片上传完成,开始合并文件...');
await this.mergeChunks(fileMd5, file.name, chunks.length);
console.log('文件合并完成');
// 更新进度为100%
onProgress(1);
return { success: true, message: '文件上传成功', fileMd5 };
} catch (error) {
console.error('文件上传失败:', error);
return { success: false, message: '文件上传失败', error: error.message };
}
}
}
module.exports = FileUploader;
|
|