Appearance
文件上传处理
概述
文件上传是Web应用中常见但复杂的功能,涉及文件选择、验证、上传进度、错误处理等多个环节。本文将深入探讨React中文件上传的各种实现方式、优化技术和最佳实践。
基础文件上传
单文件上传
jsx
import { useState } from 'react';
function BasicFileUpload() {
const [file, setFile] = useState(null);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const handleFileChange = (e) => {
const selectedFile = e.target.files[0];
setFile(selectedFile);
};
const handleUpload = async () => {
if (!file) {
alert('请选择文件');
return;
}
setUploading(true);
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('上传失败');
}
const result = await response.json();
alert('上传成功! URL: ' + result.url);
// 重置
setFile(null);
setUploadProgress(0);
} catch (error) {
alert('上传失败: ' + error.message);
} finally {
setUploading(false);
}
};
return (
<div>
<input
type="file"
onChange={handleFileChange}
disabled={uploading}
/>
{file && (
<div className="file-info">
<p>文件名: {file.name}</p>
<p>大小: {(file.size / 1024).toFixed(2)} KB</p>
<p>类型: {file.type}</p>
</div>
)}
<button onClick={handleUpload} disabled={!file || uploading}>
{uploading ? '上传中...' : '上传'}
</button>
{uploading && (
<div className="progress">
<div
className="progress-bar"
style={{ width: `${uploadProgress}%` }}
/>
</div>
)}
</div>
);
}多文件上传
jsx
function MultipleFileUpload() {
const [files, setFiles] = useState([]);
const [uploading, setUploading] = useState(false);
const [uploadResults, setUploadResults] = useState([]);
const handleFilesChange = (e) => {
const selectedFiles = Array.from(e.target.files);
setFiles(selectedFiles);
};
const handleUpload = async () => {
if (files.length === 0) {
alert('请选择文件');
return;
}
setUploading(true);
setUploadResults([]);
const results = [];
for (const file of files) {
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('上传失败');
}
const result = await response.json();
results.push({
filename: file.name,
success: true,
url: result.url,
});
} catch (error) {
results.push({
filename: file.name,
success: false,
error: error.message,
});
}
}
setUploadResults(results);
setUploading(false);
};
const removeFile = (index) => {
setFiles(files.filter((_, i) => i !== index));
};
return (
<div>
<input
type="file"
multiple
onChange={handleFilesChange}
disabled={uploading}
/>
{files.length > 0 && (
<div className="file-list">
<h3>已选择 {files.length} 个文件:</h3>
<ul>
{files.map((file, index) => (
<li key={index}>
{file.name} ({(file.size / 1024).toFixed(2)} KB)
{!uploading && (
<button onClick={() => removeFile(index)}>删除</button>
)}
</li>
))}
</ul>
</div>
)}
<button onClick={handleUpload} disabled={files.length === 0 || uploading}>
{uploading ? '上传中...' : '上传所有'}
</button>
{uploadResults.length > 0 && (
<div className="upload-results">
<h3>上传结果:</h3>
<ul>
{uploadResults.map((result, index) => (
<li key={index} className={result.success ? 'success' : 'error'}>
{result.filename}: {result.success ? '成功' : result.error}
</li>
))}
</ul>
</div>
)}
</div>
);
}文件验证
类型和大小验证
jsx
function ValidatedFileUpload() {
const [file, setFile] = useState(null);
const [error, setError] = useState('');
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const validateFile = (file) => {
// 验证类型
if (!ALLOWED_TYPES.includes(file.type)) {
return '只允许上传 JPG, PNG, GIF, WebP 格式的图片';
}
// 验证大小
if (file.size > MAX_SIZE) {
return `文件大小不能超过 ${MAX_SIZE / 1024 / 1024} MB`;
}
return null;
};
const handleFileChange = (e) => {
const selectedFile = e.target.files[0];
if (!selectedFile) {
setFile(null);
setError('');
return;
}
const validationError = validateFile(selectedFile);
if (validationError) {
setError(validationError);
setFile(null);
e.target.value = ''; // 清空输入
} else {
setError('');
setFile(selectedFile);
}
};
const handleUpload = async () => {
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
const result = await response.json();
alert('上传成功!');
} catch (error) {
alert('上传失败: ' + error.message);
}
};
return (
<div>
<input
type="file"
accept={ALLOWED_TYPES.join(',')}
onChange={handleFileChange}
/>
{error && <div className="error">{error}</div>}
{file && (
<div className="file-preview">
<img
src={URL.createObjectURL(file)}
alt="预览"
style={{ maxWidth: '200px' }}
/>
<p>{file.name}</p>
<p>{(file.size / 1024).toFixed(2)} KB</p>
</div>
)}
<button onClick={handleUpload} disabled={!file}>
上传
</button>
</div>
);
}图片尺寸验证
jsx
function ImageDimensionValidator() {
const [file, setFile] = useState(null);
const [dimensions, setDimensions] = useState(null);
const [error, setError] = useState('');
const MIN_WIDTH = 800;
const MIN_HEIGHT = 600;
const MAX_WIDTH = 4000;
const MAX_HEIGHT = 3000;
const validateImage = (file) => {
return new Promise((resolve) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
const { width, height } = img;
if (width < MIN_WIDTH || height < MIN_HEIGHT) {
resolve({
valid: false,
error: `图片尺寸至少为 ${MIN_WIDTH}x${MIN_HEIGHT} 像素`,
});
return;
}
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
resolve({
valid: false,
error: `图片尺寸不能超过 ${MAX_WIDTH}x${MAX_HEIGHT} 像素`,
});
return;
}
resolve({
valid: true,
dimensions: { width, height },
});
};
img.onerror = () => {
URL.revokeObjectURL(url);
resolve({
valid: false,
error: '无法读取图片',
});
};
img.src = url;
});
};
const handleFileChange = async (e) => {
const selectedFile = e.target.files[0];
if (!selectedFile) {
setFile(null);
setDimensions(null);
setError('');
return;
}
const result = await validateImage(selectedFile);
if (!result.valid) {
setError(result.error);
setFile(null);
setDimensions(null);
e.target.value = '';
} else {
setError('');
setFile(selectedFile);
setDimensions(result.dimensions);
}
};
return (
<div>
<input
type="file"
accept="image/*"
onChange={handleFileChange}
/>
{error && <div className="error">{error}</div>}
{dimensions && (
<div className="info">
图片尺寸: {dimensions.width} x {dimensions.height} 像素
</div>
)}
{file && (
<img
src={URL.createObjectURL(file)}
alt="预览"
style={{ maxWidth: '300px' }}
/>
)}
</div>
);
}上传进度
XMLHttpRequest实现进度
jsx
function UploadWithProgress() {
const [file, setFile] = useState(null);
const [progress, setProgress] = useState(0);
const [uploading, setUploading] = useState(false);
const xhrRef = useRef(null);
const handleUpload = () => {
if (!file) return;
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest();
xhrRef.current = xhr;
// 监听上传进度
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentage = Math.round((e.loaded / e.total) * 100);
setProgress(percentage);
}
});
// 监听完成
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
const result = JSON.parse(xhr.responseText);
alert('上传成功! URL: ' + result.url);
setFile(null);
setProgress(0);
} else {
alert('上传失败');
}
setUploading(false);
});
// 监听错误
xhr.addEventListener('error', () => {
alert('上传出错');
setUploading(false);
});
// 发送请求
xhr.open('POST', '/api/upload');
xhr.send(formData);
setUploading(true);
};
const handleCancel = () => {
if (xhrRef.current) {
xhrRef.current.abort();
setUploading(false);
setProgress(0);
}
};
return (
<div>
<input
type="file"
onChange={(e) => setFile(e.target.files[0])}
disabled={uploading}
/>
<button onClick={handleUpload} disabled={!file || uploading}>
上传
</button>
{uploading && (
<>
<button onClick={handleCancel}>取消</button>
<div className="progress-container">
<div
className="progress-bar"
style={{ width: `${progress}%` }}
/>
<span>{progress}%</span>
</div>
</>
)}
</div>
);
}Axios实现进度
jsx
import axios from 'axios';
function AxiosUploadProgress() {
const [file, setFile] = useState(null);
const [progress, setProgress] = useState(0);
const [uploading, setUploading] = useState(false);
const cancelTokenRef = useRef(null);
const handleUpload = async () => {
if (!file) return;
const formData = new FormData();
formData.append('file', file);
// 创建取消token
const CancelToken = axios.CancelToken;
const source = CancelToken.source();
cancelTokenRef.current = source;
setUploading(true);
try {
const response = await axios.post('/api/upload', formData, {
cancelToken: source.token,
onUploadProgress: (progressEvent) => {
const percentage = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
setProgress(percentage);
},
headers: {
'Content-Type': 'multipart/form-data',
},
});
alert('上传成功! URL: ' + response.data.url);
setFile(null);
setProgress(0);
} catch (error) {
if (axios.isCancel(error)) {
console.log('上传已取消');
} else {
alert('上传失败: ' + error.message);
}
} finally {
setUploading(false);
}
};
const handleCancel = () => {
if (cancelTokenRef.current) {
cancelTokenRef.current.cancel('用户取消上传');
}
};
return (
<div>
<input
type="file"
onChange={(e) => setFile(e.target.files[0])}
disabled={uploading}
/>
<button onClick={handleUpload} disabled={!file || uploading}>
上传
</button>
{uploading && (
<>
<button onClick={handleCancel}>取消</button>
<div className="progress">
<div className="progress-bar" style={{ width: `${progress}%` }}>
{progress}%
</div>
</div>
</>
)}
</div>
);
}拖拽上传
基础拖拽上传
jsx
function DragDropUpload() {
const [files, setFiles] = useState([]);
const [isDragging, setIsDragging] = useState(false);
const handleDragEnter = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files);
setFiles(droppedFiles);
};
const handleFileInput = (e) => {
const selectedFiles = Array.from(e.target.files);
setFiles(selectedFiles);
};
return (
<div>
<div
className={`drop-zone ${isDragging ? 'dragging' : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<p>拖拽文件到这里或</p>
<input
type="file"
multiple
onChange={handleFileInput}
style={{ display: 'none' }}
id="file-input"
/>
<label htmlFor="file-input" className="select-button">
选择文件
</label>
</div>
{files.length > 0 && (
<ul>
{files.map((file, index) => (
<li key={index}>
{file.name} - {(file.size / 1024).toFixed(2)} KB
</li>
))}
</ul>
)}
</div>
);
}高级拖拽上传组件
jsx
function AdvancedDragDrop({ onFilesChange, maxFiles = 10, maxSize = 10 * 1024 * 1024 }) {
const [files, setFiles] = useState([]);
const [isDragging, setIsDragging] = useState(false);
const [errors, setErrors] = useState([]);
const dragCounter = useRef(0);
const validateFiles = (fileList) => {
const validFiles = [];
const errors = [];
if (fileList.length + files.length > maxFiles) {
errors.push(`最多只能上传 ${maxFiles} 个文件`);
return { validFiles, errors };
}
for (const file of fileList) {
if (file.size > maxSize) {
errors.push(`${file.name} 超过大小限制`);
continue;
}
validFiles.push(file);
}
return { validFiles, errors };
};
const addFiles = (newFiles) => {
const { validFiles, errors: validationErrors } = validateFiles(newFiles);
if (validationErrors.length > 0) {
setErrors(validationErrors);
}
if (validFiles.length > 0) {
const updatedFiles = [...files, ...validFiles];
setFiles(updatedFiles);
onFilesChange?.(updatedFiles);
}
};
const removeFile = (index) => {
const updatedFiles = files.filter((_, i) => i !== index);
setFiles(updatedFiles);
onFilesChange?.(updatedFiles);
};
const handleDragEnter = (e) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current++;
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragging(true);
}
};
const handleDragLeave = (e) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current--;
if (dragCounter.current === 0) {
setIsDragging(false);
}
};
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
dragCounter.current = 0;
const droppedFiles = Array.from(e.dataTransfer.files);
addFiles(droppedFiles);
};
const handleFileInput = (e) => {
const selectedFiles = Array.from(e.target.files);
addFiles(selectedFiles);
e.target.value = ''; // 清空input
};
return (
<div className="drag-drop-upload">
<div
className={`drop-zone ${isDragging ? 'dragging' : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<div className="drop-zone-content">
<svg className="upload-icon" /* ... */ />
<p className="drop-text">
{isDragging ? '释放文件以上传' : '拖拽文件到这里'}
</p>
<p className="or-text">或</p>
<input
type="file"
multiple
onChange={handleFileInput}
style={{ display: 'none' }}
id="file-input-advanced"
/>
<label htmlFor="file-input-advanced" className="select-button">
选择文件
</label>
<p className="hint-text">
最多 {maxFiles} 个文件,每个文件最大 {(maxSize / 1024 / 1024).toFixed(0)} MB
</p>
</div>
</div>
{errors.length > 0 && (
<div className="errors">
{errors.map((error, index) => (
<div key={index} className="error">{error}</div>
))}
</div>
)}
{files.length > 0 && (
<div className="file-list">
<h3>已选择 {files.length} 个文件:</h3>
<ul>
{files.map((file, index) => (
<li key={index} className="file-item">
<div className="file-info">
<span className="file-name">{file.name}</span>
<span className="file-size">
{(file.size / 1024).toFixed(2)} KB
</span>
</div>
<button
onClick={() => removeFile(index)}
className="remove-button"
>
×
</button>
</li>
))}
</ul>
</div>
)}
</div>
);
}分片上传
大文件分片上传
jsx
function ChunkedUpload() {
const [file, setFile] = useState(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const CHUNK_SIZE = 1 * 1024 * 1024; // 1MB per chunk
const uploadChunk = async (chunk, chunkIndex, totalChunks, fileId) => {
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('chunkIndex', chunkIndex);
formData.append('totalChunks', totalChunks);
formData.append('fileId', fileId);
const response = await fetch('/api/upload-chunk', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`Chunk ${chunkIndex} upload failed`);
}
return await response.json();
};
const handleUpload = async () => {
if (!file) return;
setUploading(true);
setProgress(0);
const fileId = `${Date.now()}-${file.name}`;
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
try {
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const start = chunkIndex * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
await uploadChunk(chunk, chunkIndex, totalChunks, fileId);
const progress = Math.round(((chunkIndex + 1) / totalChunks) * 100);
setProgress(progress);
}
// 通知服务器合并文件
const mergeResponse = await fetch('/api/merge-chunks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileId,
filename: file.name,
totalChunks,
}),
});
const result = await mergeResponse.json();
alert('上传成功! URL: ' + result.url);
setFile(null);
setProgress(0);
} catch (error) {
alert('上传失败: ' + error.message);
} finally {
setUploading(false);
}
};
return (
<div>
<input
type="file"
onChange={(e) => setFile(e.target.files[0])}
disabled={uploading}
/>
{file && (
<div>
<p>文件: {file.name}</p>
<p>大小: {(file.size / 1024 / 1024).toFixed(2)} MB</p>
<p>将分为 {Math.ceil(file.size / CHUNK_SIZE)} 个分片上传</p>
</div>
)}
<button onClick={handleUpload} disabled={!file || uploading}>
{uploading ? '上传中...' : '上传'}
</button>
{uploading && (
<div className="progress">
<div className="progress-bar" style={{ width: `${progress}%` }}>
{progress}%
</div>
</div>
)}
</div>
);
}断点续传
支持断点续传的上传
jsx
function ResumableUpload() {
const [file, setFile] = useState(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [uploadedChunks, setUploadedChunks] = useState(new Set());
const CHUNK_SIZE = 1 * 1024 * 1024;
const getUploadedChunks = async (fileId) => {
try {
const response = await fetch(`/api/upload-status?fileId=${fileId}`);
const data = await response.json();
return new Set(data.uploadedChunks || []);
} catch (error) {
return new Set();
}
};
const handleUpload = async () => {
if (!file) return;
setUploading(true);
const fileId = `${file.name}-${file.size}-${file.lastModified}`;
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
// 获取已上传的分片
const uploaded = await getUploadedChunks(fileId);
setUploadedChunks(uploaded);
try {
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
// 跳过已上传的分片
if (uploaded.has(chunkIndex)) {
continue;
}
const start = chunkIndex * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('chunkIndex', chunkIndex);
formData.append('totalChunks', totalChunks);
formData.append('fileId', fileId);
const response = await fetch('/api/upload-chunk', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`Chunk ${chunkIndex} upload failed`);
}
setUploadedChunks(prev => new Set([...prev, chunkIndex]));
setProgress(Math.round(((uploadedChunks.size + 1) / totalChunks) * 100));
}
// 合并文件
await fetch('/api/merge-chunks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileId,
filename: file.name,
totalChunks,
}),
});
alert('上传成功!');
setFile(null);
setProgress(0);
setUploadedChunks(new Set());
} catch (error) {
alert('上传中断,下次可以继续');
} finally {
setUploading(false);
}
};
return (
<div>
<input
type="file"
onChange={(e) => setFile(e.target.files[0])}
disabled={uploading}
/>
{file && (
<div>
<p>文件: {file.name}</p>
{uploadedChunks.size > 0 && (
<p>已上传 {uploadedChunks.size} 个分片,点击继续上传</p>
)}
</div>
)}
<button onClick={handleUpload} disabled={!file || uploading}>
{uploading ? '上传中...' : (uploadedChunks.size > 0 ? '继续上传' : '开始上传')}
</button>
{progress > 0 && (
<div className="progress">
<div className="progress-bar" style={{ width: `${progress}%` }}>
{progress}%
</div>
</div>
)}
</div>
);
}总结
文件上传处理要点:
- 基础上传:单文件、多文件上传
- 文件验证:类型、大小、尺寸验证
- 上传进度:XMLHttpRequest、Axios监听进度
- 拖拽上传:拖拽区域、文件预览
- 分片上传:大文件分片处理
- 断点续传:支持中断后继续上传
合理的文件上传方案能够提升用户体验和系统可靠性。