大文件上传实践

272 阅读3分钟

以下是针对大文件上传的完整解决方案及实践代码,涵盖切片上传、错误处理、断点续传、并行控制、秒传等核心问题:


一、技术方案设计

  1. 文件切片:按固定大小(如 5MB)或动态数量切分文件。
  2. 唯一标识:计算文件 Hash(Web Worker 加速)作为唯一标识。
  3. 断点续传:记录已上传切片,刷新后恢复进度。
  4. 并行上传:控制并发请求数量(如 3-5 个并行)。
  5. 错误重试:单个切片失败后自动重试(最多 3 次)。
  6. 秒传逻辑:上传前检查服务器是否已存在完整文件。

二、代码实现

1. 前端核心代码(React + TypeScript)

import React, { useRef, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { limit } from 'p-limit';

// 切片大小(如 5MB)
const CHUNK_SIZE = 5 * 1024 * 1024; 

interface Chunk {
  index: number;
  hash: string;
  chunk: Blob;
}

const BigFileUploader = () => {
  const fileInputRef = useRef<HTMLInputElement>(null);
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState(0);
  const [fileHash, setFileHash] = useState('');

  // 生成文件 Hash(Web Worker)
  const calculateFileHash = async (file: File): Promise<string> => {
    return new Promise((resolve) => {
      const worker = new Worker('/hash-worker.js');
      worker.postMessage({ file });
      worker.onmessage = (e) => {
        resolve(e.data.hash);
      };
    });
  };

  // 文件切片
  const createFileChunks = (file: File): Blob[] => {
    const chunks = [];
    let cur = 0;
    while (cur < file.size) {
      chunks.push(file.slice(cur, cur + CHUNK_SIZE));
      cur += CHUNK_SIZE;
    }
    return chunks;
  };

  // 上传切片(带重试)
  const uploadChunkWithRetry = async (chunk: Chunk, retries = 3) => {
    try {
      const formData = new FormData();
      formData.append('chunk', chunk.chunk);
      formData.append('hash', chunk.hash);
      formData.append('filename', fileInputRef.current!.files![0].name);
      formData.append('fileHash', fileHash);
      
      await fetch('/api/upload', { method: 'POST', body: formData });
      
      // 记录成功上传的切片
      const uploaded = JSON.parse(localStorage.getItem(fileHash) || '[]');
      localStorage.setItem(fileHash, JSON.stringify([...uploaded, chunk.hash]));
    } catch (error) {
      if (retries > 0) {
        await uploadChunkWithRetry(chunk, retries - 1);
      } else {
        throw error;
      }
    }
  };

  // 开始上传
  const handleUpload = async () => {
    const file = fileInputRef.current?.files?.[0];
    if (!file) return;

    setUploading(true);
    
    // 1. 计算文件 Hash(秒传检查)
    const hash = await calculateFileHash(file);
    setFileHash(hash);
    
    // 秒传验证
    const existRes = await fetch(`/api/check?hash=${hash}`);
    const { exist } = await existRes.json();
    if (exist) {
      setProgress(100);
      setUploading(false);
      return;
    }

    // 2. 切片并过滤已上传的
    const chunks = createFileChunks(file);
    const uploaded = JSON.parse(localStorage.getItem(hash) || '[]');
    const filteredChunks = chunks
      .map((chunk, index) => ({
        index,
        hash: `${hash}-${index}`,
        chunk,
      }))
      .filter((chunk) => !uploaded.includes(chunk.hash));

    // 3. 并行上传(控制并发)
    const concurrencyLimit = limit(3); 
    const uploadPromises = filteredChunks.map((chunk) =>
      concurrencyLimit(() => uploadChunkWithRetry(chunk))
    );

    try {
      await Promise.all(uploadPromises);
      
      // 4. 通知合并文件
      await fetch('/api/merge', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ filename: file.name, hash }),
      });
      
      setProgress(100);
    } catch (error) {
      alert('上传失败');
    } finally {
      setUploading(false);
    }
  };

  return (
    <div>
      <input type="file" ref={fileInputRef} />
      <button onClick={handleUpload} disabled={uploading}>
        {uploading ? `上传中... ${progress}%` : '开始上传'}
      </button>
    </div>
  );
};

export default BigFileUploader;

2. Web Worker 代码(hash-worker.js)

self.importScripts('https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.2/spark-md5.min.js');

self.onmessage = async (e) => {
  const { file } = e.data;
  const spark = new self.SparkMD5.ArrayBuffer();
  const reader = new FileReader();

  // 计算文件 Hash
  reader.readAsArrayBuffer(file);
  reader.onload = (e) => {
    spark.append(e.target.result);
    const hash = spark.end();
    self.postMessage({ hash });
  };
};

3. 服务端伪代码(Node.js + Express)

import express from 'express';
import fs from 'fs';
import path from 'path';

const app = express();
app.use(express.json());

// 检查文件是否存在(秒传)
app.get('/api/check', (req, res) => {
  const { hash } = req.query;
  const filePath = path.join('uploads', `${hash}.complete`);
  res.json({ exist: fs.existsSync(filePath) });
});

// 上传切片
app.post('/api/upload', async (req, res) => {
  const { hash, filename, fileHash } = req.body;
  const chunk = req.files.chunk;
  
  // 保存切片
  const chunkDir = path.join('uploads', fileHash);
  if (!fs.existsSync(chunkDir)) fs.mkdirSync(chunkDir);
  
  await chunk.mv(path.join(chunkDir, hash));
  res.status(200).end();
});

// 合并文件
app.post('/api/merge', async (req, res) => {
  const { filename, hash } = req.body;
  const chunkDir = path.join('uploads', hash);
  const chunks = fs.readdirSync(chunkDir);
  
  // 按索引排序后合并
  chunks.sort((a, b) => parseInt(a.split('-')[1]) - parseInt(b.split('-')[1]));
  await Promise.all(
    chunks.map((chunkPath) =>
      fs.promises.appendFile(
        path.join('uploads', `${hash}.complete`),
        fs.readFileSync(path.join(chunkDir, chunkPath))
    )
  );
  
  // 清理临时文件
  fs.rmdirSync(chunkDir, { recursive: true });
  res.status(200).end();
});

三、关键问题解决方案

1. 切片策略选择

  • 按大小切(推荐):固定每个切片为 5MB,适合大多数场景。
  • 按数量切:当文件大小波动极大时使用(如 100 个切片)。

2. 错误重试机制

  • 每个切片独立重试计数器(最多 3 次)。

  • 失败后延迟 1 秒重试:

    const uploadChunkWithRetry = async (chunk: Chunk, retries = 3) => {
      try {
        // ...上传逻辑
      } catch (error) {
        if (retries > 0) {
          await new Promise(resolve => setTimeout(resolve, 1000));
          await uploadChunkWithRetry(chunk, retries - 1);
        }
      }
    };
    

3. 断点续传实现

  • 使用 localStorage 记录已上传的切片 Hash。
  • 刷新后通过 Hash 过滤已上传的切片。

4. Web Worker 优化

  • 将耗时的 Hash 计算移至 Worker 线程。
  • 使用 SparkMD5 加速大文件 Hash 计算。

5. 秒传逻辑

  • 上传前计算文件 Hash 并查询服务端。
  • 若文件已存在,直接跳过上传。

四、性能优化建议

  1. 动态切片大小:根据网络状况调整切片大小(如弱网时减小切片)。
  2. 进度计算:基于已上传切片数量计算总进度。
  3. 暂停/恢复:记录当前上传状态,允许手动暂停。
  4. 内存管理:使用 FileReader.readAsArrayBuffer 分块读取文件。

通过此方案,可实现高效可靠的大文件上传,覆盖生产环境中的常见问题。实际部署时需根据业务需求调整参数(如切片大小、并发数)。