在 React 中封装一个完整的上传组件,可以包含以下功能:
- 支持单文件/多文件上传
- 拖拽上传
- 限制文件类型、大小
- 显示上传进度
- 预览图片(如果是图片)
- 支持取消上传
- 自定义上传请求(支持自定义
action和headers) - 支持上传成功/失败回调
下面是一个使用 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)核心思路
- 将大文件切分为多个小块(chunk)
- 依次或并发上传每个 chunk
- 所有 chunk 上传完成后,通知服务端合并文件
- 支持断点续传(记录已上传的 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 等)
✅ 新增配置项:chunkSize 和 onMerge
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) |
🔮 下一步优化建议
- 持久化已上传 chunk
使用localStorage或服务端记录已传 chunk,刷新页面后跳过。 - MD5 校验避免重复上传
对文件生成 hash,上传前先询问服务端是否已存在。 - Web Worker 计算 hash 不阻塞 UI
- 秒传支持
若服务端已有相同文件,直接标记 success。