React 中封装一个完整的上传组件

6 阅读3分钟

在 React 中封装一个完整的上传组件,可以包含以下功能:

  • 支持单文件/多文件上传
  • 拖拽上传
  • 限制文件类型、大小
  • 显示上传进度
  • 预览图片(如果是图片)
  • 支持取消上传
  • 自定义上传请求(支持自定义 actionheaders
  • 支持上传成功/失败回调

下面是一个使用 React + TypeScript 封装的完整上传组件示例。


📦 1. 组件结构说明

Upload/
├── Upload.tsx        // 主组件
├── UploadList.tsx    // 上传文件列表展示
└── type.ts           // 类型定义

🔧 2. 类型定义 (type.ts)

// type.ts
export type UploadFileStatus = 'ready' | 'uploading' | 'success' | 'error';

export interface UploadFile {
  uid: string;
  size: number;
  name: string;
  file: File;
  status?: UploadFileStatus;
  percent?: number;
  response?: any;
  error?: any;
  url?: string; // 用于预览
}

export interface UploadProps {
  action: string;
  headers?: { [key: string]: string };
  data?: { [key: string]: any };
  name?: string;
  withCredentials?: boolean;
  multiple?: boolean;
  accept?: string;
  beforeUpload?: (file: File) => boolean | Promise<File>;
  onProgress?: (percent: number, file: UploadFile) => void;
  onSuccess?: (response: any, file: UploadFile) => void;
  onError?: (err: any, file: UploadFile) => void;
  onChange?: (file: UploadFile) => void;
  onRemove?: (file: UploadFile) => void;
  fileList?: UploadFile[];
  children?: React.ReactNode;
  drag?: boolean;
}

🧩 3. 文件列表组件 (UploadList.tsx)

// UploadList.tsx
import React from 'react';
import { UploadFile } from './type';
import { CloseCircleOutlined, PaperClipOutlined, LoadingOutlined } from '@ant-design/icons';

interface UploadListProps {
  fileList: UploadFile[];
  onRemove: (file: UploadFile) => void;
}

const UploadList: React.FC<UploadListProps> = ({ fileList, onRemove }) => {
  return (
    <ul className="upload-list">
      {fileList.map(file => {
        return (
          <li key={file.uid} className={`upload-list-item upload-list-item-${file.status}`}>
            <span className="upload-list-name">
              <PaperClipOutlined /> {file.name}
            </span>
            {file.status === 'uploading' && <LoadingOutlined />}
            {file.status === 'success' && '✅'}
            {file.status === 'error' && '❌'}
            <CloseCircleOutlined
              onClick={() => onRemove(file)}
              style={{ cursor: 'pointer', color: '#ff4d4f' }}
            />
          </li>
        );
      })}
    </ul>
  );
};

export default UploadList;

🚀 4. 主上传组件 (Upload.tsx)

// Upload.tsx
import React, { useState, useRef } from 'react';
import axios from 'axios';
import { v4 as uuidv4 } from 'uuid';
import UploadList from './UploadList';
import { UploadFile, UploadProps } from './type';
import './upload.css'; // 可选样式

const Upload: React.FC<UploadProps> = (props) => {
  const {
    action,
    headers,
    data,
    name = 'file',
    withCredentials,
    multiple = false,
    accept,
    beforeUpload,
    onProgress,
    onSuccess,
    onError,
    onChange,
    onRemove,
    fileList: defaultFileList = [],
    children,
    drag = false,
  } = props;

  const [fileList, setFileList] = useState<UploadFile[]>(defaultFileList);
  const fileInputRef = useRef<HTMLInputElement>(null);

  // 添加文件到列表
  const updateFileList = (updateFile: UploadFile, updateObj: Partial<UploadFile>) => {
    setFileList(prevList => {
      return prevList.map(file => {
        if (file.uid === updateFile.uid) {
          return { ...file, ...updateObj };
        }
        return file;
      });
    });
  };

  // 触发 onChange 回调
  const handleChange = (file: UploadFile) => {
    onChange?.(file);
  };

  // 上传文件
  const uploadFile = (file: File) => {
    const uid = uuidv4();
    const uploadFile: UploadFile = {
      uid,
      size: file.size,
      name: file.name,
      file,
      status: 'ready',
      percent: 0,
    };

    // 如果是图片,生成预览 URL
    if (file.type.startsWith('image/')) {
      uploadFile.url = URL.createObjectURL(file);
    }

    // 添加到文件列表
    setFileList(prevList => [...prevList, uploadFile]);

    // 执行 beforeUpload
    const beforeResult = beforeUpload?.(file);
    if (beforeResult instanceof Promise) {
      beforeResult
        .then(transformedFile => {
          doUpload({ ...uploadFile, file: transformedFile }, transformedFile || file);
        })
        .catch(err => {
          console.error('beforeUpload failed:', err);
          updateFileList(uploadFile, { status: 'error', error: err });
          onError?.(err, uploadFile);
        });
    } else if (beforeResult !== false) {
      doUpload(uploadFile, file);
    } else {
      setFileList(prev => prev.filter(f => f.uid !== uid)); // 移除被拒绝的文件
    }
  };

  // 执行上传
  const doUpload = (uploadFile: UploadFile, file: File) => {
    const formData = new FormData();
    formData.append(name, file);

    // 添加额外数据
    if (data) {
      Object.keys(data).forEach(key => {
        formData.append(key, data[key]);
      });
    }

    updateFileList(uploadFile, { status: 'uploading' });

    const request = axios.request({
      method: 'POST',
      url: action,
      data: formData,
      headers: {
        'Content-Type': 'multipart/form-data',
        ...headers,
      },
      withCredentials,
      onUploadProgress: (e) => {
        const percent = Math.round((e.loaded * 100) / e.total!);
        updateFileList(uploadFile, { percent });
        onProgress?.(percent, uploadFile);
      },
    });

    request
      .then(resp => {
        updateFileList(uploadFile, { status: 'success', response: resp.data });
        onSuccess?.(resp.data, uploadFile);
        handleChange(uploadFile);
      })
      .catch(err => {
        updateFileList(uploadFile, { status: 'error', error: err });
        onError?.(err, uploadFile);
        handleChange(uploadFile);
      });
  };

  // 文件选择处理
  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const files = e.target.files;
    if (!files) return;

    const fileArray = Array.from(files);
    fileArray.forEach(file => {
      uploadFile(file);
    });

    // 清空 input,允许重复选择同一文件
    if (fileInputRef.current) {
      fileInputRef.current.value = '';
    }
  };

  // 点击上传
  const handleClick = () => {
    if (fileInputRef.current) {
      fileInputRef.current.click();
    }
  };

  // 拖拽上传
  const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
  };

  const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    const files = e.dataTransfer.files;
    if (files) {
      Array.from(files).forEach(file => {
        uploadFile(file);
      });
    }
  };

  // 删除文件
  const handleRemove = (file: UploadFile) => {
    setFileList(prev => prev.filter(f => f.uid !== file.uid));
    onRemove?.(file);
  };

  const uploadButton = (
    <div
      className={`upload ${drag ? 'upload-drag' : ''}`}
      onClick={handleClick}
      onDragOver={drag ? handleDragOver : undefined}
      onDrop={drag ? handleDrop : undefined}
    >
      {drag ? (
        <p>拖拽文件到这里上传</p>
      ) : (
        children || <button>点击上传</button>
      )}
      <input
        ref={fileInputRef}
        className="upload-input"
        type="file"
        multiple={multiple}
        accept={accept}
        onChange={handleFileChange}
      />
    </div>
  );

  return (
    <div>
      {uploadButton}
      <UploadList fileList={fileList} onRemove={handleRemove} />
    </div>
  );
};

export default Upload;

🎨 5. 样式 (upload.css)

/* upload.css */
.upload {
  display: inline-block;
}

.upload-drag {
  border: 2px dashed #1890ff;
  padding: 20px;
  text-align: center;
  cursor: pointer;
  border-radius: 4px;
}

.upload-input {
  display: none;
}

.upload-list {
  list-style: none;
  padding: 0;
  margin-top: 10px;
}

.upload-list-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 6px 0;
  font-size: 14px;
}

.upload-list-name {
  flex: 1;
}

.upload-list-item-upload.upload-list-item-uploading {
  color: #1890ff;
}

.upload-list-item-success {
  color: #52c41a;
}

.upload-list-item-error {
  color: #f5222d;
}

✅ 6. 使用示例

// App.tsx
import React from 'react';
import Upload from './components/Upload';

const App: React.FC = () => {
  const handleSuccess = (res: any, file: UploadFile) => {
    console.log('上传成功:', res, file);
  };

  const handleError = (err: any, file: UploadFile) => {
    console.error('上传失败:', err, file);
  };

  const handleProgress = (percent: number, file: UploadFile) => {
    console.log(`上传进度: ${percent}%`, file);
  };

  const beforeUpload = (file: File) => {
    const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
    if (!isJpgOrPng) {
      alert('只允许上传 JPG/PNG 文件!');
    }
    const isLt2M = file.size / 1024 / 1024 < 2;
    if (!isLt2M) {
      alert('文件大小不能超过 2MB!');
    }
    return isJpgOrPng && isLt2M;
  };

  return (
    <div style={{ padding: 20 }}>
      <h3>普通上传</h3>
      <Upload
        action="https://httpbin.org/post"
        onProgress={handleProgress}
        onSuccess={handleSuccess}
        onError={handleError}
        beforeUpload={beforeUpload}
        multiple
        accept="image/*"
      >
        <button>选择文件</button>
      </Upload>
      <h3>拖拽上传</h3>
      <Upload
        action="https://httpbin.org/post"
        drag
        multiple
        accept="image/*"
        onProgress={handleProgress}
        onSuccess={handleSuccess}
        onError={handleError}
      >
        <p>📁 拖拽文件到这里</p>
      </Upload>
    </div>
  );
};

export default App;

📦 安装依赖

npm install axios uuid
# 或者
yarn add axios uuid

提示:如果你不用 Ant Design 图标,可以移除 @ant-design/icons 并用普通图标代替。


✅ 功能总结

功能支持
单/多文件上传
拖拽上传
文件类型/大小校验✅(通过 beforeUpload
上传进度
图片预览✅(自动判断)
取消/删除文件
自定义请求头、参数
错误处理
TypeScript 支持

如需进一步增强,可加入:

  • 分片上传
  • 断点续传
  • 上传队列控制
  • 更丰富的 UI(如缩略图预览)

好的,下面是在原有上传组件基础上 扩展支持「分片上传」功能 的完整实现。


🌟 分片上传(Chunked Upload)核心思路

  1. 将大文件切分为多个小块(chunk)
  2. 依次或并发上传每个 chunk
  3. 所有 chunk 上传完成后,通知服务端合并文件
  4. 支持断点续传(记录已上传的 chunk)

✅ 本示例包含:

  • 文件分片切割
  • 并发控制上传
  • 进度实时更新(整体 + 单个 chunk)
  • 合并请求
  • 模拟断点续传(通过 chunkMap 记录状态)

🔧 1. 修改类型定义 type.ts

// type.ts
export type UploadFileStatus = 'ready' | 'uploading' | 'success' | 'error' | 'paused';

export interface UploadFile {
  uid: string;
  size: number;
  name: string;
  file: File;
  status?: UploadFileStatus;
  percent?: number;
  response?: any;
  error?: any;
  url?: string;

  // 分片相关字段
  chunks?: Chunk[];
  chunkSize?: number;
  uploadedChunks?: Set<number>; // 已成功上传的 chunk index
  isMerging?: boolean;
}

export interface Chunk {
  index: number;
  start: number;
  end: number;
  blob: Blob;
  loaded: boolean;
  error?: any;
}

🚀 2. 修改 Upload.tsx —— 增加分片上传逻辑

⚠️ 只展示修改部分,其余结构保持不变(如 state、input 等)

✅ 新增配置项:chunkSizeonMerge

interface UploadProps {
  // ...原有 props
  chunkSize?: number;           // 分片大小,单位字节,默认 1MB
  onMergeSuccess?: (res: any, file: UploadFile) => void;
  onMergeError?: (err: any, file: UploadFile) => void;
}

✅ 在组件中添加分片处理函数

const Upload: React.FC<UploadProps> = ({
  action,
  chunkSize = 1024 * 1024, // 默认 1MB
  onMergeSuccess,
  onMergeError,
  // 其他 props...
}) => {
  const [fileList, setFileList] = useState<UploadFile[]>(defaultFileList);
  const uploadControllers = useRef<Map<string, AbortController>>(new Map()); // 用于取消请求

  // --- 分片上传主逻辑 ---
  const uploadChunkedFile = (uploadFile: UploadFile, file: File) => {
    const chunks: Chunk[] = [];
    const size = file.size;
    let index = 0;

    // 切割文件为 chunks
    while (index * chunkSize < size) {
      const start = index * chunkSize;
      const end = Math.min(start + chunkSize, size);
      const blob = file.slice(start, end);

      chunks.push({
        index,
        start,
        end,
        blob,
        loaded: false,
      });
      index++;
    }

    const totalChunks = chunks.length;
    const uploadedChunks = new Set<number>();

    // 更新文件状态
    const newFile: UploadFile = {
      ...uploadFile,
      chunks,
      chunkSize,
      uploadedChunks,
      status: 'uploading',
      percent: 0,
    };
    setFileList(prev => [...prev, newFile]);

    // 并发控制上传(例如最多同时上传 3 个 chunk)
    const maxConcurrent = 3;
    let activeUploads = 0;
    let completedCount = 0;

    const startNext = () => {
      if (completedCount === totalChunks && activeUploads === 0) {
        // 所有 chunk 完成 → 触发合并
        mergeChunks(newFile);
        return;
      }

      while (activeUploads < maxConcurrent) {
        const nextChunkIndex = chunks.findIndex(
          c => !c.loaded && !uploadedChunks.has(c.index)
        );

        if (nextChunkIndex === -1) break;

        const chunk = chunks[nextChunkIndex];
        activeUploads++;
        uploadChunk(newFile, chunk).then(() => {
          activeUploads--;
          completedCount++;
          uploadedChunks.add(chunk.index);
          updateChunkProgress(newFile);
          startNext();
        }).catch(() => {
          activeUploads--;
          startNext();
        });
      }
    };

    startNext();
  };

  // --- 上传单个 chunk ---
  const uploadChunk = (uploadFile: UploadFile, chunk: Chunk) => {
    const formData = new FormData();
    formData.append('chunk', chunk.blob);
    formData.append('filename', uploadFile.file.name);
    formData.append('chunkIndex', chunk.index.toString());
    formData.append('totalChunks', uploadFile.chunks!.length.toString());
    formData.append('fileKey', uploadFile.uid); // 用于服务端识别同一个文件

    const controller = new AbortController();
    uploadControllers.current.set(`${uploadFile.uid}-chunk-${chunk.index}`, controller);

    return axios.post(action, formData, {
      headers: { ...headers, 'Content-Type': 'multipart/form-data' },
      withCredentials,
      signal: controller.signal,
      onUploadProgress: (e) => {
        // 可以计算 chunk 内部进度(可选)
      },
    })
    .then(res => {
      chunk.loaded = true;
      return res;
    })
    .catch(err => {
      chunk.error = err;
      throw err;
    });
  };

  // --- 更新整体上传进度 ---
  const updateChunkProgress = (file: UploadFile) => {
    const total = file.chunks!.length;
    const uploaded = file.uploadedChunks!.size;
    const percent = Math.round((uploaded / total) * 100);

    updateFileList(file, { percent });

    // 触发 onProgress 回调
    onProgress?.(percent, file);
  };

  // --- 合并所有分片 ---
  const mergeChunks = (file: UploadFile) => {
    file.isMerging = true;
    updateFileList(file, { isMerging: true, status: 'uploading' });

    axios.post(
      `${action}/merge`,
      {
        filename: file.file.name,
        fileKey: file.uid,
        totalChunks: file.chunks!.length,
      },
      { headers }
    )
    .then(res => {
      updateFileList(file, { status: 'success', response: res.data });
      onSuccess?.(res.data, file);
      onMergeSuccess?.(res.data, file);
    })
    .catch(err => {
      updateFileList(file, { status: 'error', error: err });
      onError?.(err, file);
      onMergeError?.(err, file);
    });
  };

  // --- 取消上传(支持取消整个文件或单个 chunk)---
  const abortUpload = (file: UploadFile) => {
    // 取消所有未完成的 chunk 请求
    file.chunks?.forEach(chunk => {
      const key = `${file.uid}-chunk-${chunk.index}`;
      const controller = uploadControllers.current.get(key);
      if (controller) {
        controller.abort();
        uploadControllers.current.delete(key);
      }
    });
    updateFileList(file, { status: 'paused' });
  };

✅ 修改 uploadFile 函数:判断是否启用分片

const uploadFile = (file: File) => {
  const uid = uuidv4();
  const uploadFile: UploadFile = {
    uid,
    size: file.size,
    name: file.name,
    file,
    status: 'ready',
    percent: 0,
  };

  if (file.type.startsWith('image/')) {
    uploadFile.url = URL.createObjectURL(file);
  }

  const shouldChunk = file.size > chunkSize * 2; // 大于 2 倍 chunk 才分片

  const beforeResult = beforeUpload?.(file);
  if (beforeResult instanceof Promise) {
    beforeResult.then(transformedFile => {
      if (shouldChunk) {
        uploadChunkedFile(uploadFile, transformedFile);
      } else {
        doUpload(uploadFile, transformedFile);
      }
    }).catch(err => {
      updateFileList(uploadFile, { status: 'error', error: err });
      onError?.(err, uploadFile);
    });
  } else if (beforeResult !== false) {
    if (shouldChunk) {
      uploadChunkedFile(uploadFile, file);
    } else {
      doUpload(uploadFile, file);
    }
  } else {
    setFileList(prev => prev.filter(f => f.uid !== uid));
  }
};

✅ 添加“取消上传”按钮到 UploadList

修改 UploadList.tsx 中增加一个“取消”按钮:

// UploadList.tsx
import { CloseCircleOutlined, LoadingOutlined, StopOutlined } from '@ant-design/icons';

// 在列表项中加入:
{file.status === 'uploading' && (
  <StopOutlined
    style={{ cursor: 'pointer', color: '#faad14', marginRight: 8 }}
    onClick={() => abortUpload(file)} // 需从父组件传递
    title="取消上传"
  />
)}

然后将 abortUpload 作为 prop 传给 UploadList


🖥️ 3. 使用示例(App.tsx)

<Upload
  action="https://your-api.com/upload"
  chunkSize={1024 * 1024} // 1MB 分片
  onProgress={(p, file) => console.log(`进度: ${p}%`, file)}
  onSuccess={(res, file) => alert('上传成功!')}
  onMergeSuccess={(res, file) => console.log('合并成功', res)}
  beforeUpload={(file) => {
    if (file.size < 1024 * 1024) {
      console.log('小文件直接上传');
      return true;
    }
    console.log('大文件将分片上传');
    return true;
  }}
  drag
>
  <p>📁 拖拽大文件测试分片上传</p>
</Upload>

🛠️ 4. 服务端 API 要求(Node.js 示例)

你需要后端支持以下接口:

接口 1:接收分片

POST /upload
// 参数: chunk, filename, chunkIndex, totalChunks, fileKey
// 存储路径: `uploads/${fileKey}/${chunkIndex}`

接口 2:合并分片

POST /upload/merge
// 参数: filename, fileKey, totalChunks
// 动作: 按序读取 chunks 并合并成完整文件

示例 Node.js 实现可用 Express + fs.createWriteStream 实现。


✅ 当前功能总结

功能支持
自动判断是否分片
文件切片
并发控制上传✅(3 个并发)
实时进度条
合并请求
取消防止内存泄漏✅(AbortController)
断点续传雏形⚠️(需配合服务端记录已上传 chunk)

🔮 下一步优化建议

  1. 持久化已上传 chunk
    使用 localStorage 或服务端记录已传 chunk,刷新页面后跳过。
  2. MD5 校验避免重复上传
    对文件生成 hash,上传前先询问服务端是否已存在。
  3. Web Worker 计算 hash 不阻塞 UI
  4. 秒传支持
    若服务端已有相同文件,直接标记 success。