Skip to content

文件上传处理

概述

文件上传是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>
  );
}

总结

文件上传处理要点:

  1. 基础上传:单文件、多文件上传
  2. 文件验证:类型、大小、尺寸验证
  3. 上传进度:XMLHttpRequest、Axios监听进度
  4. 拖拽上传:拖拽区域、文件预览
  5. 分片上传:大文件分片处理
  6. 断点续传:支持中断后继续上传

合理的文件上传方案能够提升用户体验和系统可靠性。