Skip to content

多文件上传案例

该案例用于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);