如何搞定多文件异步上传(并发控制 + 物理中止)

· Anonymous ·

在做CMS素材管理系统时,批量上传素材是个高频场景。但如果直接一把梭把几十个请求全发出去,浏览器轻则排队卡顿,重则直接 OOM(内存溢出)崩溃。

这几天我把这套逻辑彻底重构了一遍,实现了 2 个核心目标

  1. 并发限流:不管选多少文件,永远只有 3 个在传。
  2. 物理中止:点取消不是简单的“前端隐藏”,而是直接掐断 TCP 连接,秒停。

这里是批量上传的Demo效果图:

批量上传逻辑时序图

一、 核心逻辑:Promise 并发队列

与其让浏览器自己去排队,不如我们手动实现一个“红绿灯”。我把每个文件封装成一个任务,通过一个 activeCount 计数器来控制流量。

实现思路:

  • 队列监听:通过 useEffect 监控任务状态,只要有“等待中”的文件且“运行中”的任务少于 3 个,就立刻补位。
  • 状态解耦:UI 只负责渲染 tasks 数组,逻辑层通过 useUploadQueue 钩子处理任务的流转。

二、 物理中止:AbortController 到底怎么用?

很多人用 AbortController 只是做个样子,其实要做到“秒停”,必须把信号(Signal)真正传给底层的上传实例。

在最新的 S3 SDK (@aws-sdk/lib-storage) 中,我们需要监听 signalabort 事件,并手动调用上传实例的 .abort() 方法。

时序图演示(前端、SDK、R2 之间的博弈):

批量上传逻辑时序图

三、 核心代码实现

这是我整理后的 React Hook,包含了真实的 R2 上传逻辑和 Mock 模拟逻辑的无缝切换。

1. 逻辑层:useUploadQueue.ts

import { useState, useCallback, useEffect, useRef } from 'react';
import { S3Client } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";

// 存储配置:实际使用请通过环境变量引入
export const STORAGE_CONFIG = {
    accessKeyId: '',
    secretAccessKey: '',
    endpoint: '',
    bucket: '',
    publicDomain: ''
};

// 初始化 S3 客户端(兼容 R2)
const s3Client = new S3Client({
    region: "auto",
    endpoint: STORAGE_CONFIG.endpoint,
    credentials: {
        accessKeyId: STORAGE_CONFIG.accessKeyId,
        secretAccessKey: STORAGE_CONFIG.secretAccessKey,
    },
});

export function useUploadQueue(concurrency: number = 3) {
    const [tasks, setTasks] = useState<UploadTask[]>([]);

    // 核心上传逻辑
    const uploadFile = async (task: UploadTask, signal: AbortSignal) => {
        try {
            if (STORAGE_CONFIG.accessKeyId && STORAGE_CONFIG.secretAccessKey) {
                // --- 真实 R2/S3 上传逻辑 ---
                const parallelUploads3 = new Upload({
                    client: s3Client,
                    params: {
                        Bucket: STORAGE_CONFIG.bucket,
                        Key: task.file.name,
                        Body: task.file,
                        ContentType: task.file.type,
                    },
                });

                // 【关键】物理中止监听:只要外部触发 abort,立刻销毁 S3 上传实例
                const abortHandler = () => {
                    console.log("正在掐断 TCP 连接,停止上传...");
                    parallelUploads3.abort(); 
                };
                signal.addEventListener('abort', abortHandler);

                parallelUploads3.on("httpUploadProgress", (progress) => {
                    const percentage = Math.round((progress.loaded! / progress.total!) * 100);
                    setTasks(prev => prev.map(t => t.id === task.id ? { ...t, progress: percentage } : t));
                });

                await parallelUploads3.done();
                signal.removeEventListener('abort', abortHandler);
            } else {
                // --- Mock 模拟逻辑 ---
                await mockUpload(task, signal);
            }
            
            // 更新成功状态
            setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: 'success', progress: 100 } : t));
        } catch (error: any) {
            // 如果是因为中止导致的报错,更新状态为 cancelled
            if (error.name === 'AbortError' || signal.aborted) {
                setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: 'cancelled' } : t));
            }
        } finally {
            processQueue(); // 无论成功失败,拉取下一个任务
        }
    };

    // ... 队列调度逻辑
}

2. 交互层:UploadManager.tsx (UI 部分)

import React, { useCallback, useRef } from 'react';
import { useUploadQueue } from './useUploadQueue';
import { UploadCloud, CheckCircle2, XCircle, AlertCircle, Loader2, X, Trash2, StopCircle } from 'lucide-react';

function formatSize(bytes: number) {
    if (bytes === 0) return '0 B';
    const k = 1024;
    const sizes = ['B', 'KB', 'MB', 'GB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}

export default function UploadManager() {

    const { tasks, addFiles, cancelFile, cancelAll, clearRecords, isMockMode } = useUploadQueue(3);
    const fileInputRef = useRef<HTMLInputElement>(null);

    const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        if (e.target.files && e.target.files.length > 0) {
            addFiles(Array.from(e.target.files));
            if (fileInputRef.current) fileInputRef.current.value = '';
        }
    };

    const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
        e.preventDefault();
        if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
            addFiles(Array.from(e.dataTransfer.files));
        }
    }, [addFiles]);

    const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
        e.preventDefault();
    };

    return (
        <div className="w-full max-w-3xl mx-auto p-6 bg-white dark:bg-zinc-950 rounded-2xl border border-zinc-200 dark:border-zinc-800 shadow-sm font-sans">
            {/* 顶部状态栏 */}
            <div className="flex justify-between items-center mb-6">
                <div>
                    <h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">上传管理</h2>
                    <p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">最大支持同时并发上传 3 个文件</p>
                </div>
                <div className="flex flex-col items-end gap-2">
                    {isMockMode ? (
                        <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 dark:bg-amber-500/10 text-amber-700 dark:text-amber-500 border border-amber-200 dark:border-amber-500/20">
                            模拟模式
                        </span>
                    ) : (
                        <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-100 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-500 border border-emerald-200 dark:border-emerald-500/20">
                            生产模式
                        </span>
                    )}
                </div>
            </div>

            {/* 拖拽上传区域 */}
            <div
                onDrop={handleDrop}
                onDragOver={handleDragOver}
                className="relative group border border-dashed border-zinc-300 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500 rounded-xl p-8 text-center transition-all bg-zinc-50 dark:bg-zinc-900/50 cursor-pointer overflow-hidden mb-4"
                onClick={() => fileInputRef.current?.click()}
            >
                <input type="file" multiple ref={fileInputRef} className="hidden" onChange={handleFileChange} />
                <UploadCloud className="w-8 h-8 mx-auto text-zinc-400 dark:text-zinc-500 group-hover:text-zinc-600 dark:group-hover:text-zinc-300 transition-colors mb-3" />
                <h3 className="text-sm font-medium text-zinc-900 dark:text-zinc-100">点击此处或拖拽文件到此区域上传</h3>
                <p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1.5">单文件大小不超过 150MB</p>
            </div>

            {/* 操作按钮与任务列表 */}
            {tasks.length > 0 && (
                <div className="mt-8">
                    <div className="flex justify-between items-center mb-4">
                        <h3 className="text-sm border-b border-transparent font-medium text-zinc-900 dark:text-zinc-100">上传队列 ({tasks.length})</h3>
                        <div className="flex justify-end gap-2">
                            <button
                                onClick={cancelAll}
                                className="text-xs px-2.5 py-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-900 text-zinc-600 dark:text-zinc-400 transition-colors flex items-center gap-1.5 border border-transparent hover:border-zinc-200 dark:hover:border-zinc-800"
                            >
                                <StopCircle className="w-3.5 h-3.5" /> 取消全部
                            </button>
                            <button
                                onClick={clearRecords}
                                className="text-xs px-2.5 py-1.5 rounded-lg hover:bg-red-50 dark:hover:bg-red-500/10 text-red-600 dark:text-red-400 transition-colors flex items-center gap-1.5 border border-transparent hover:border-red-100 dark:hover:border-red-500/20"
                            >
                                <Trash2 className="w-3.5 h-3.5" /> 清除记录
                            </button>
                        </div>
                    </div>

                    <div className="flex flex-col gap-2">
                        {tasks.map(task => (
                            <div key={task.id} className="group relative flex items-center justify-between p-3.5 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 overflow-hidden shadow-sm hover:border-zinc-300 dark:hover:border-zinc-700 transition-colors">

                                {/* 背景进度填充条 */}
                                {task.status === 'uploading' && (
                                    <div
                                        className="absolute left-0 top-0 bottom-0 bg-blue-50 dark:bg-blue-500/5 transition-all duration-300 ease-out z-0"
                                        style={{ width: `${task.progress}%` }}
                                    />
                                )}

                                <div className="relative z-10 flex items-center gap-3.5 flex-1 min-w-0">
                                    <div className="shrink-0">
                                        {task.status === 'waiting' && <Loader2 className="w-4 h-4 text-zinc-400 dark:text-zinc-600" />}
                                        {task.status === 'uploading' && <Loader2 className="w-4 h-4 text-zinc-900 dark:text-zinc-100 animate-spin" />}
                                        {task.status === 'success' && <CheckCircle2 className="w-4 h-4 text-zinc-900 dark:text-zinc-100" />}
                                        {task.status === 'error' && <AlertCircle className="w-4 h-4 text-red-500" />}
                                        {task.status === 'cancelled' && <XCircle className="w-4 h-4 text-zinc-400 dark:text-zinc-600" />}
                                    </div>

                                    <div className="flex-1 min-w-0 flex items-center justify-between gap-4">
                                        <div className="flex-1 min-w-0">
                                            <div className="flex items-center gap-2 mt-0.5">
                                                <p className="text-sm font-medium text-zinc-900 dark:text-zinc-100 truncate">{task.file.name}</p>
                                                {task.url && (task.status === 'success') && (
                                                    <a
                                                        href={task.url}
                                                        target="_blank"
                                                        rel="noopener noreferrer"
                                                        className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-800 text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors shrink-0"
                                                        onClick={(e) => e.stopPropagation()}
                                                    >
                                                        Link
                                                    </a>
                                                )}
                                            </div>
                                            <div className="flex items-center gap-2 mt-0.5">
                                                <span className="text-xs text-zinc-500 dark:text-zinc-400 shrink-0">{formatSize(task.file.size)}</span>

                                                {/* 状态文字 */}
                                                <span className={`text-xs capitalize ${task.status === 'success' ? 'text-zinc-900 dark:text-zinc-100 font-medium' :
                                                    task.status === 'error' ? 'text-red-600 dark:text-red-400 font-medium' :
                                                        task.status === 'cancelled' ? 'text-zinc-500 dark:text-zinc-500' :
                                                            task.status === 'waiting' ? 'text-zinc-500 dark:text-zinc-400' :
                                                                'text-zinc-900 dark:text-zinc-100'
                                                    }`}>
                                                    {task.status === 'uploading' ? '上传中' :
                                                        task.status === 'success' ? '已完成' :
                                                            task.status === 'cancelled' ? '已取消' :
                                                                task.status === 'error' ? '失败' :
                                                                    '排队中'}
                                                </span>
                                            </div>
                                        </div>

                                        {/* 进度条(仅在上传中或等待时显示) */}
                                        {(task.status === 'uploading' || task.status === 'waiting') && (
                                            <div className="w-24 shrink-0 flex items-center gap-3">
                                                <div className="h-1 flex-1 bg-zinc-100 dark:bg-zinc-800 rounded-full overflow-hidden">
                                                    <div
                                                        className="h-full bg-zinc-900 dark:bg-zinc-100 rounded-full transition-all duration-300 ease-out"
                                                        style={{ width: `${task.status === 'waiting' ? 0 : task.progress}%` }}
                                                    />
                                                </div>
                                                <span className="text-xs font-medium text-zinc-900 dark:text-zinc-100 w-7 text-right">
                                                    {task.status === 'waiting' ? '0%' : `${task.progress}%`}
                                                </span>
                                            </div>
                                        )}
                                    </div>
                                </div>

                                {/* 取消操作 */}
                                <div className="relative z-10 ml-4 shrink-0 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
                                    {(task.status === 'waiting' || task.status === 'uploading') ? (
                                        <button
                                            onClick={() => cancelFile(task.id)}
                                            className="p-1.5 rounded-lg text-zinc-500 hover:text-zinc-900 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:text-zinc-100 dark:hover:bg-zinc-800 transition-colors focus:outline-none focus:ring-2 focus:ring-zinc-500/50"
                                            title="取消上传"
                                        >
                                            <X className="w-4 h-4" />
                                        </button>
                                    ) : (
                                        <div className="w-7"></div>
                                    )}
                                </div>
                            </div>
                        ))}
                    </div>
                </div>
            )}
        </div>
    );
}

如果你想直接体验该组件的完整功能,可以访问:在线演示地址