多文件上传案例
该案例用于2.4.3之前的版本,之后的版本enlarge-file-upload库已经支持多文件上传。现案例基于对单文件上传封装实现多文件上传,你也可以根据自己业务场景对封装逻辑进行适当调整。
简介
这是一个通用的多文件上传管理器封装,基于 enlarge-file-upload 库。它将复杂的并发控制、状态管理、钩子函数等逻辑封装在内部,仅对外暴露简洁的 API。开发者只需关注核心的 "单个文件上传逻辑" 即可轻松实现高性能的多文件上传功能。
封装案例
该类 MultiFileUploader 是一个纯逻辑封装,不包含任何具体的业务请求代码。你可以直接复制下面的代码到你的项目中使用。
js
/**
* ====================================================
* 多文件上传管理器 - MultiFileUploader
* 基于 enlarge-file-upload 库的纯封装,不包含任何业务逻辑
* 可应用到任何项目,用户需自己提供 createUploadFunction
* ====================================================
*/
class MultiFileUploader {
/**
* 构造函数
* @param {Object} options - 配置对象
* @param {Object} options.uploaderOptions - 传递给底层上传库的配置 (chunkSize, concurrency, etc.)
* @param {number} [options.maxConcurrentFiles=3] - 同时上传的最大文件数量
* @param {boolean} [options.autoStart=true] - 添加文件后是否自动开始上传
* @param {Function} options.createUploadFunction - 【必须】为每个文件创建上传函数的工厂方法
* @param {Function} [options.beforeFileUpload] - 【可选】文件上传前的钩子,返回 false 可跳过文件
* @param {Function} [options.afterFileSuccess] - 【可选】文件上传成功后的钩子
* @param {Function} [options.onFileAdded] - 文件添加到队列时的回调
* @param {Function} [options.onFileStart] - 文件开始上传时的回调
* @param {Function} [options.onFileProgress] - 文件进度回调
* @param {Function} [options.onFileSuccess] - 文件成功回调
* @param {Function} [options.onFileError] - 文件失败回调
* @param {Function} [options.onFilePaused] - 文件暂停回调
* @param {Function} [options.onFileResumed] - 文件恢复回调
* @param {Function} [options.onFileCancelled] - 文件取消回调
* @param {Function} [options.onFileSpeed] - 文件速度回调
* @param {Function} [options.onFileHashStart] - 文件开始计算Hash回调
* @param {Function} [options.onFileHashEnd] - 文件Hash计算结束回调
* @param {Function} [options.onGlobalProgress] - 整体进度回调
* @param {Function} [options.onAllSuccess] - 所有文件处理完成回调
*/
constructor(options = {}) {
// 库配置
this.uploaderOptions = options.uploaderOptions || {};
// 多文件配置
this.maxConcurrentFiles = options.maxConcurrentFiles || 3;
this.autoStart = options.autoStart !== false;
// 必须的工厂函数
if (!options.createUploadFunction) {
throw new Error('MultiFileUploader: createUploadFunction is required');
}
this.createUploadFunction = options.createUploadFunction;
// 可选的钩子函数
this.beforeFileUpload = options.beforeFileUpload || null;
this.afterFileSuccess = options.afterFileSuccess || null;
// 回调函数 - 初始化为空函数以避免调用错误
this.onFileAdded = options.onFileAdded || (() => { });
this.onFileStart = options.onFileStart || (() => { });
this.onFileProgress = options.onFileProgress || (() => { });
this.onFileSuccess = options.onFileSuccess || (() => { });
this.onFileError = options.onFileError || (() => { });
this.onFilePaused = options.onFilePaused || (() => { });
this.onFileResumed = options.onFileResumed || (() => { });
this.onFileCancelled = options.onFileCancelled || (() => { });
this.onFileSpeed = options.onFileSpeed || (() => { });
this.onFileHashStart = options.onFileHashStart || (() => { });
this.onFileHashEnd = options.onFileHashEnd || (() => { });
this.onGlobalProgress = options.onGlobalProgress || (() => { });
this.onAllSuccess = options.onAllSuccess || (() => { });
// 内部状态
this.fileQueue = new Map(); // 存储所有文件的信息,Key为FileID
this.activeCount = 0; // 当前正在上传的文件数
this.fileIdCounter = 0; // 用于生成唯一ID的计数器
this.isPaused = false; // 全局暂停标志
this._completedInBatch = false; // 标记当前批次是否已报告过完成
}
/**
* 生成唯一的文件ID
* @private
* @returns {string} 文件ID
*/
_generateFileId() {
return `file_${Date.now()}_${++this.fileIdCounter}`;
}
/**
* 创建文件信息对象
* @private
* @param {File} file - 原始文件对象
* @returns {Object} 文件信息对象
*/
_createFileInfo(file) {
return {
file,
uploader: null,
status: 'pending', // pending(等待) | uploading(上传中) | paused(暂停) | completed(完成) | error(错误) | skipped(跳过)
progress: 0,
speed: null,
hash: null,
error: null,
customData: {}, // 用户可存储自定义数据,如 uploaderConfigOverrides
};
}
/**
* 更新并通知整体进度
* @private
*/
_updateOverallProgress() {
let totalProgress = 0, totalFiles = 0;
let completed = 0, failed = 0, uploading = 0, pending = 0, paused = 0, skipped = 0;
this.fileQueue.forEach((info) => {
totalFiles++;
totalProgress += info.progress || 0;
switch (info.status) {
case 'completed': completed++; break;
case 'error': failed++; break;
case 'uploading': uploading++; break;
case 'pending': pending++; break;
case 'paused': paused++; break;
case 'skipped': skipped++; break;
}
});
const stats = {
totalFiles, completed, failed, uploading, pending, paused, skipped,
overallProgress: totalFiles > 0 ? totalProgress / totalFiles : 0,
};
this.onGlobalProgress(stats.overallProgress, stats);
// 全部完成检查 (排除 skipped 的文件是否需要计入完成视业务而定,这里只要处理完都算)
if (completed + failed + skipped === totalFiles && totalFiles > 0 && !this._completedInBatch) {
this._completedInBatch = true;
this.onAllSuccess(this.getFileList());
}
}
/**
* 处理单个文件的上传流程
* @private
* @param {string} fileId - 文件ID
*/
async _processFile(fileId) {
const fileInfo = this.fileQueue.get(fileId);
if (!fileInfo || fileInfo.status !== 'pending') return;
// 1. 执行前置钩子 (beforeFileUpload)
if (this.beforeFileUpload) {
try {
const result = await this.beforeFileUpload(fileId, fileInfo.file, fileInfo);
// 如果返回 false,则跳过该文件
if (result === false) {
fileInfo.status = 'skipped';
this._updateOverallProgress();
this._processNextFile();
return;
}
} catch (err) {
// 前置钩子出错,标记为错误
fileInfo.status = 'error';
fileInfo.error = err;
this.onFileError(fileId, err, fileInfo.file);
this._updateOverallProgress();
this._processNextFile();
return;
}
}
this.activeCount++;
fileInfo.status = 'uploading';
try {
// 2. 构建上传配置
const uploaderConfig = {
...this.uploaderOptions,
// 使用工厂函数创建具体的上传逻辑
uploadFunction: this.createUploadFunction(fileId, fileInfo.file, fileInfo),
onProgress: (progress) => {
fileInfo.progress = progress;
this.onFileProgress(fileId, progress, fileInfo.file);
this._updateOverallProgress();
},
onSuccess: async () => {
fileInfo.status = 'completed';
fileInfo.progress = 100;
// 3. 执行后置钩子 (afterFileSuccess)
if (this.afterFileSuccess) {
try {
await this.afterFileSuccess(fileId, fileInfo.file, fileInfo.uploader);
} catch (err) {
console.warn('afterFileSuccess hook error:', err);
}
}
this.onFileSuccess(fileId, fileInfo.file, fileInfo.uploader);
this._updateOverallProgress();
this.activeCount--;
this._processNextFile(); // 尝试处理下一个
},
onError: (error) => {
fileInfo.status = 'error';
fileInfo.error = error;
this.onFileError(fileId, error, fileInfo.file);
this._updateOverallProgress();
this.activeCount--;
this._processNextFile(); // 尝试处理下一个
},
onSpeed: (speed) => {
fileInfo.speed = speed;
this.onFileSpeed(fileId, speed, fileInfo.file);
},
beginHash: () => this.onFileHashStart(fileId, fileInfo.file),
endHash: (hash) => {
fileInfo.hash = hash;
this.onFileHashEnd(fileId, hash, fileInfo.file);
},
};
// 合并用户在 beforeFileUpload 中设置的配置覆盖 (如断点续传 includeChunks)
if (fileInfo.customData.uploaderConfigOverrides) {
Object.assign(uploaderConfig, fileInfo.customData.uploaderConfigOverrides);
}
// 创建并启动上传器
fileInfo.uploader = fileUploader.createUploader(uploaderConfig);
this.onFileStart(fileId, fileInfo.file, fileInfo.uploader);
await fileInfo.uploader.upload(fileInfo.file);
} catch (error) {
// 捕获创建过程中的同步错误
fileInfo.status = 'error';
fileInfo.error = error;
this.onFileError(fileId, error, fileInfo.file);
this._updateOverallProgress();
this.activeCount--;
this._processNextFile();
}
}
/**
* 尝试处理队列中的下一个文件
* @private
*/
_processNextFile() {
if (this.isPaused || this.activeCount >= this.maxConcurrentFiles) return;
// 寻找下一个 pending 状态的文件
for (const [fileId, info] of this.fileQueue) {
if (info.status === 'pending') {
this._processFile(fileId);
// 如果并发满了就停止寻找
if (this.activeCount >= this.maxConcurrentFiles) break;
}
}
}
// ==================== 公开 API ====================
/**
* 添加文件到上传队列
* @param {File|File[]|FileList} files - 文件对象或文件列表
* @returns {string[]} 生成的文件ID列表
*/
addFiles(files) {
const fileList = files instanceof FileList ? Array.from(files) : (Array.isArray(files) ? files : [files]);
const fileIds = [];
this._completedInBatch = false;
fileList.forEach((file) => {
const fileId = this._generateFileId();
this.fileQueue.set(fileId, this._createFileInfo(file));
fileIds.push(fileId);
this.onFileAdded(fileId, file);
});
if (this.autoStart && !this.isPaused) this.start();
return fileIds;
}
/**
* 开始/继续处理队列
*/
start() {
this.isPaused = false;
this._completedInBatch = false;
// 填充并发槽位
while (this.activeCount < this.maxConcurrentFiles) {
let found = false;
for (const [fileId, info] of this.fileQueue) {
if (info.status === 'pending') {
found = true;
this._processFile(fileId);
break;
}
}
if (!found) break; // 没有更多待处理文件
}
}
/**
* 暂停所有正在上传的文件
*/
pauseAll() {
this.isPaused = true;
this.fileQueue.forEach((info, fileId) => {
if (info.status === 'uploading' && info.uploader) {
info.uploader.pause();
info.status = 'paused';
this.activeCount--;
this.onFilePaused(fileId, info.file);
}
});
}
/**
* 恢复所有暂停的文件
*/
resumeAll() {
this.isPaused = false;
this.fileQueue.forEach((info, fileId) => {
if (info.status === 'paused' && info.uploader) {
info.uploader.resume();
info.status = 'uploading';
this.activeCount++;
this.onFileResumed(fileId, info.file);
}
});
this._processNextFile(); // 恢复后可能腾出了并发位置,检查是否有 pending 的
}
/**
* 暂停指定文件
* @param {string} fileId
*/
pauseUpload(fileId) {
const info = this.fileQueue.get(fileId);
if (info && info.status === 'uploading' && info.uploader) {
info.uploader.pause();
info.status = 'paused';
this.activeCount--;
this.onFilePaused(fileId, info.file);
this._processNextFile(); // 腾出一个并发位,尝试上传下一个
}
}
/**
* 恢复指定文件
* @param {string} fileId
*/
resumeUpload(fileId) {
const info = this.fileQueue.get(fileId);
if (info && info.status === 'paused' && info.uploader && this.activeCount < this.maxConcurrentFiles) {
info.uploader.resume();
info.status = 'uploading';
this.activeCount++;
this.onFileResumed(fileId, info.file);
}
}
/**
* 取消指定文件上传
* @param {string} fileId
*/
removeFile(fileId) {
const info = this.fileQueue.get(fileId);
if (info) {
if (info.uploader) info.uploader.reset();
// 如果正在上传,减少活跃计数
if (info.status === 'uploading') this.activeCount--;
this.fileQueue.delete(fileId);
this.onFileCancelled(fileId, info.file);
this._updateOverallProgress();
this._processNextFile(); // 腾出位置或队列变更,尝试下一个
}
}
/**
* 取消所有文件
*/
cancelAll() {
this.fileQueue.forEach((info, fileId) => {
if (info.uploader) info.uploader.reset();
this.onFileCancelled(fileId, info.file);
});
this.fileQueue.clear();
this.activeCount = 0;
this.isPaused = false;
}
/**
* 重试指定失败的文件
* @param {string} fileId
*/
retryFile(fileId) {
const info = this.fileQueue.get(fileId);
if (info && info.status === 'error') {
// 重置状态为 pending
info.status = 'pending';
info.progress = 0;
info.error = null;
info.uploader = null;
this._completedInBatch = false;
this._processNextFile();
}
}
/**
* 重试所有失败的文件
*/
retryAllFailed() {
this.fileQueue.forEach((info) => {
if (info.status === 'error') {
info.status = 'pending';
info.progress = 0;
info.error = null;
info.uploader = null;
}
});
this._completedInBatch = false;
this.start();
}
/**
* 获取指定文件的状态信息
* @param {string} fileId
* @returns {Object|null}
*/
getFileStatus(fileId) {
const info = this.fileQueue.get(fileId);
if (!info) return null;
return {
fileId, file: info.file, status: info.status, progress: info.progress,
speed: info.speed, hash: info.hash, error: info.error, customData: info.customData,
uploader: info.uploader,
};
}
/**
* 获取所有文件的状态列表
* @returns {Array}
*/
getFileList() {
return Array.from(this.fileQueue.entries()).map(([id]) => this.getFileStatus(id));
}
/**
* 获取整体统计信息
* @returns {Object}
*/
getStats() {
let totalFiles = 0, completed = 0, failed = 0, uploading = 0, pending = 0, paused = 0, skipped = 0, totalProgress = 0;
this.fileQueue.forEach((info) => {
totalFiles++;
totalProgress += info.progress || 0;
switch (info.status) {
case 'completed': completed++; break;
case 'error': failed++; break;
case 'uploading': uploading++; break;
case 'pending': pending++; break;
case 'paused': paused++; break;
case 'skipped': skipped++; break;
}
});
return {
totalFiles, completed, failed, uploading, pending, paused, skipped,
activeCount: this.activeCount, isPaused: this.isPaused,
overallProgress: totalFiles > 0 ? totalProgress / totalFiles : 0,
};
}
/**
* 获取指定文件的内部 uploader 实例
* @param {string} fileId
* @returns {Object|null}
*/
getUploader(fileId) {
const info = this.fileQueue.get(fileId);
return info ? info.uploader : null;
}
/**
* 设置文件的自定义数据
* @param {string} fileId
* @param {string} key
* @param {any} value
*/
setCustomData(fileId, key, value) {
const info = this.fileQueue.get(fileId);
if (info) info.customData[key] = value;
}
/**
* 获取文件的自定义数据
* @param {string} fileId
* @param {string} key
* @returns {any}
*/
getCustomData(fileId, key) {
const info = this.fileQueue.get(fileId);
return info ? info.customData[key] : undefined;
}
}
// 暴露到全局,以便在非模块环境下使用 (如直接 script 引入)
window.MultiFileUploader = MultiFileUploader;使用案例
下面是如何使用 MultiFileUploader 的示例。你需要提供 createUploadFunction 来对接你的后端接口。
js
// 使用示例
const multiUploader = new MultiFileUploader({
// 传递给 enlarge-file-upload 库的基础配置
uploaderOptions: {
chunkSize: 5 * 1024 * 1024, // 分片大小 5MB
concurrency: 5, // 单个文件的分片上传并发数
maxRetries: 3, // 失败重试次数
hash: true, // 是否计算 Hash
},
// 多文件管理器配置
maxConcurrentFiles: 3, // 同时上传的文件数量
autoStart: true, // 不用手动调用 start(),添加文件即自动开始
// 【核心】为每个文件动态创建上传函数
// 这里可以根据 fileInfo (如文件名、类型) 决定上传接口,或者生成不同的参数
createUploadFunction: (fileId, file, fileInfo) => {
return async ({ chunk, index, hash, cancelToken }) => {
// 模拟上传请求
// 实际场景:使用 axios 或 fetch 发送分片到服务器
/*
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('index', index);
formData.append('hash', hash);
await axios.post('/api/upload', formData, { cancelToken });
*/
console.log(`Uploading ${file.name} - Chunk ${index}`);
};
},
// 【可选】文件上传前的钩子 (例如:秒传检查)
beforeFileUpload: async (fileId, file, fileInfo) => {
console.log('Preparing:', file.name);
// 模拟检查:如果返回 false 则跳过此文件
// const exist = await checkFileExist(fileInfo.hash);
// if (exist) return false;
// 你也可以在这里覆盖该文件的上传配置
// fileInfo.customData.uploaderConfigOverrides = { includeChunks: [1, 2] }; // 断点续传
return true;
},
// 【可选】文件上传成功后的钩子 (例如:通知合并)
afterFileSuccess: async (fileId, file, uploader) => {
console.log('File Done:', file.name);
// await mergeRequest(file.name);
},
// ========== 各种状态回调 ==========
onFileAdded: (fileId, file) => {
console.log('Added:', file.name, fileId);
},
onFileProgress: (fileId, progress, file) => {
console.log(`Progress ${file.name}: ${progress}%`);
},
onFileSuccess: (fileId, file, uploader) => {
console.log('Success:', file.name);
},
onFileError: (fileId, error, file) => {
console.error('Error:', file.name, error);
},
onGlobalProgress: (progress, stats) => {
console.log(`Total Progress: ${(progress * 100).toFixed(2)}%`, stats);
},
onAllSuccess: (results) => {
console.log('All files finished!', results);
}
});
// 添加文件到队列
// const input = document.querySelector('input[type="file"]');
// multiUploader.addFiles(input.files);