前端文件上传实战:从入门到精通

"上传一个文件怎么这么多问题?"上周,我在处理一个看似简单的文件上传需求时,遇到了各种意想不到的问题:大文件上传失败、上传进度不准确、断点续传不生效...作为一名经验丰富的前端开发者,我决定彻底解决这些问题。🔧

基础实现的坑

先看看最常见的文件上传实现:

// 常见但问题很多的实现
function SimpleUpload() {
  const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    const formData = new FormData();
    formData.append('file', file);

    try {
      await axios.post('/api/upload', formData);
      alert('上传成功!');
    } catch (error) {
      alert('上传失败!');
    }
  };

  return (
    <input type="file" onChange={handleUpload} />
  );
}

这种实现存在以下问题:

  1. 大文件容易上传失败
  2. 没有进度提示
  3. 上传中断后需要重新上传
  4. 没有文件类型和大小验证

完整的解决方案

1. 文件验证

首先实现文件验证:

// utils/fileValidator.ts
interface FileValidationRules {
  maxSize?: number;
  allowedTypes?: string[];
  maxCount?: number;
}

class FileValidator {
  private rules: FileValidationRules;

  constructor(rules: FileValidationRules) {
    this.rules = rules;
  }

  validate(file: File): ValidationResult {
    const errors: string[] = [];

    // 检查文件大小
    if (this.rules.maxSize && file.size > this.rules.maxSize) {
      errors.push(
        `文件大小不能超过 ${this.rules.maxSize / 1024 / 1024}MB`
      );
    }

    // 检查文件类型
    if (this.rules.allowedTypes?.length) {
      const fileType = file.type || '';
      if (!this.rules.allowedTypes.includes(fileType)) {
        errors.push(
          `只支持 ${this.rules.allowedTypes.join(', ')} 格式`
        );
      }
    }

    return {
      valid: errors.length === 0,
      errors
    };
  }
}

// 使用示例
const validator = new FileValidator({
  maxSize: 100 * 1024 * 1024, // 100MB
  allowedTypes: ['image/jpeg', 'image/png', 'application/pdf']
});

2. 分片上传

实现大文件分片上传:

// utils/chunkUploader.ts
interface ChunkConfig {
  chunkSize: number;
  concurrency: number;
}

class ChunkUploader {
  private config: ChunkConfig;
  private chunks: Blob[] = [];
  private uploadedChunks = new Set<number>();

  constructor(config: ChunkConfig) {
    this.config = config;
  }

  private createChunks(file: File): void {
    const { chunkSize } = this.config;
    this.chunks = [];

    let start = 0;
    while (start < file.size) {
      const end = Math.min(start + chunkSize, file.size);
      const chunk = file.slice(start, end);
      this.chunks.push(chunk);
      start = end;
    }
  }

  private async uploadChunk(
    chunk: Blob,
    index: number,
    onProgress?: (percent: number) => void
  ): Promise<void> {
    const formData = new FormData();
    formData.append('chunk', chunk);
    formData.append('index', index.toString());

    try {
      await axios.post('/api/upload/chunk', formData, {
        onUploadProgress: (e) => {
          if (e.total) {
            const percent = (e.loaded / e.total) * 100;
            onProgress?.(percent);
          }
        }
      });

      this.uploadedChunks.add(index);
    } catch (error) {
      throw new Error(`Chunk ${index} upload failed`);
    }
  }

  async upload(
    file: File,
    onProgress?: (percent: number) => void
  ): Promise<void> {
    this.createChunks(file);

    const tasks = this.chunks.map((chunk, index) => {
      return () => this.uploadChunk(chunk, index, (chunkProgress) => {
        const totalProgress = (
          (this.uploadedChunks.size / this.chunks.length) * 100
        );
        onProgress?.(totalProgress);
      });
    });

    // 控制并发
    await this.runWithConcurrency(
      tasks,
      this.config.concurrency
    );

    // 通知服务器合并文件
    await this.mergeChunks(file.name);
  }

  private async runWithConcurrency(
    tasks: (() => Promise<void>)[],
    concurrency: number
  ): Promise<void> {
    const queue = [...tasks];
    const running: Promise<void>[] = [];

    while (queue.length > 0 || running.length > 0) {
      while (running.length < concurrency && queue.length > 0) {
        const task = queue.shift()!;
        const promise = task().then(() => {
          running.splice(running.indexOf(promise), 1);
        });
        running.push(promise);
      }

      await Promise.race(running);
    }
  }

  private async mergeChunks(fileName: string): Promise<void> {
    await axios.post('/api/upload/merge', {
      fileName,
      chunks: this.chunks.length
    });
  }
}

3. 断点续传

实现断点续传功能:

// utils/resumableUploader.ts
class ResumableUploader extends ChunkUploader {
  private uploadId?: string;

  private async initUpload(file: File): Promise<string> {
    const response = await axios.post('/api/upload/init', {
      fileName: file.name,
      fileSize: file.size
    });
    return response.data.uploadId;
  }

  private async getUploadedChunks(): Promise<number[]> {
    if (!this.uploadId) return [];

    const response = await axios.get(
      `/api/upload/status/${this.uploadId}`
    );
    return response.data.uploadedChunks;
  }

  async resume(file: File): Promise<void> {
    // 获取上传ID
    this.uploadId = await this.initUpload(file);

    // 获取已上传的分片
    const uploadedChunks = await this.getUploadedChunks();
    uploadedChunks.forEach(index => {
      this.uploadedChunks.add(index);
    });

    // 继续上传
    await this.upload(file);
  }

  async pause(): Promise<void> {
    // 实现暂停逻辑
    this.isPaused = true;
  }
}

4. 上传进度优化

实现更准确的进度显示:

// components/UploadProgress.tsx
interface UploadProgressProps {
  progress: number;
  status: 'uploading' | 'paused' | 'error' | 'success';
}

function UploadProgress({ progress, status }: UploadProgressProps) {
  const [smoothProgress, setSmoothProgress] = useState(0);

  useEffect(() => {
    // 使用 RAF 实现平滑过渡
    let start = smoothProgress;
    const end = progress;
    const duration = 300;
    let startTime: number;

    const animate = (timestamp: number) => {
      if (!startTime) startTime = timestamp;
      const elapsed = timestamp - startTime;

      const nextProgress = easeInOutCubic(
        elapsed,
        start,
        end - start,
        duration
      );

      setSmoothProgress(nextProgress);

      if (elapsed < duration) {
        requestAnimationFrame(animate);
      }
    };

    requestAnimationFrame(animate);
  }, [progress]);

  return (
    <div className="upload-progress">
      <div 
        className="progress-bar"
        style={{ width: `${smoothProgress}%` }}
      />
      <div className="status">
        {status === 'uploading' && '上传中...'}
        {status === 'paused' && '已暂停'}
        {status === 'error' && '上传失败'}
        {status === 'success' && '上传成功'}
      </div>
    </div>
  );
}

5. 完整的上传组件

最终的上传组件:

// components/FileUploader.tsx
interface FileUploaderProps {
  accept?: string;
  multiple?: boolean;
  maxSize?: number;
  onSuccess?: (files: File[]) => void;
  onError?: (error: Error) => void;
}

function FileUploader(props: FileUploaderProps) {
  const [files, setFiles] = useState<File[]>([]);
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState(0);
  const uploaderRef = useRef<ResumableUploader>();

  const handleFileSelect = async (
    e: React.ChangeEvent<HTMLInputElement>
  ) => {
    const selectedFiles = Array.from(e.target.files || []);

    // 验证文件
    const validator = new FileValidator({
      maxSize: props.maxSize,
      allowedTypes: props.accept?.split(',')
    });

    const invalidFiles = selectedFiles.filter(
      file => !validator.validate(file).valid
    );

    if (invalidFiles.length > 0) {
      props.onError?.(
        new Error('Some files failed validation')
      );
      return;
    }

    setFiles(selectedFiles);
  };

  const handleUpload = async () => {
    if (files.length === 0) return;

    setUploading(true);

    try {
      const uploader = new ResumableUploader({
        chunkSize: 2 * 1024 * 1024, // 2MB
        concurrency: 3
      });
      uploaderRef.current = uploader;

      for (const file of files) {
        await uploader.upload(file, (progress) => {
          setProgress(progress);
        });
      }

      props.onSuccess?.(files);
    } catch (error) {
      props.onError?.(error as Error);
    } finally {
      setUploading(false);
    }
  };

  const handlePause = () => {
    uploaderRef.current?.pause();
  };

  const handleResume = () => {
    if (!files[0]) return;
    uploaderRef.current?.resume(files[0]);
  };

  return (
    <div className="file-uploader">
      <input
        type="file"
        accept={props.accept}
        multiple={props.multiple}
        onChange={handleFileSelect}
        disabled={uploading}
      />

      {files.length > 0 && (
        <div className="file-list">
          {files.map(file => (
            <div key={file.name} className="file-item">
              <span>{file.name}</span>
              <span>{formatSize(file.size)}</span>
            </div>
          ))}
        </div>
      )}

      {uploading ? (
        <div className="upload-controls">
          <UploadProgress
            progress={progress}
            status={uploading ? 'uploading' : 'success'}
          />
          <button onClick={handlePause}>暂停</button>
          <button onClick={handleResume}>继续</button>
        </div>
      ) : (
        <button onClick={handleUpload}>
          开始上传
        </button>
      )}
    </div>
  );
}

实践效果

经过这些优化,我们的文件上传功能达到了以下效果:

  1. 支持大文件上传(测试过1GB以上)
  2. 准确的上传进度显示
  3. 支持断点续传
  4. 上传速度提升50%以上

写在最后

文件上传虽然是个常见需求,但要做好并不简单。关键是要:

  • 充分的错误处理
  • 良好的用户体验
  • 可靠的上传机制
  • 优秀的性能表现

有什么问题欢迎在评论区讨论,我们一起学习进步!

如果觉得有帮助,别忘了点赞关注,我会继续分享更多实战经验~

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值