大文件上传

摘要 :大文件上传的核心思路是「切片 + 断点续传 + 秒传」。前端将大文件按固定大小(如 5MB)切分成多个 chunk,逐个上传;用文件的 hash 值作为唯一标识,后端据此判断哪些分片已存在(秒传)或需要续传;切片进度存入 IndexedDB,支持刷新页面后恢复。一句话概括------把大象装冰箱分三步:切、传、合,中间断了能接着来。

一、为什么需要大文件上传方案

传统上传的三大痛点

直接用 <input type="file"> + FormData 上传大文件(1GB+),会遇到:

痛点 场景 后果
网络中断 WiFi 切换 / 信号波动 / 走隧道 之前传的全白费,得从头再来
浏览器崩溃 / 关机 上传几 GB 占满内存 进度丢失,用户想砸电脑
服务端限制 Nginx 默认 client_max_body_size=1MB 直接返回 413 Request Entity Too Large
无进度反馈 用户盯着转圈圈 体验极差,不知道是卡了还是还在传
并发瓶颈 多人同时传大文件 带宽被打满,所有人都在慢

解决思路:化整为零

css 复制代码
传统方式:  [======== 1GB 文件 ========]  →  一次性 POST  →  失败 = 全部重来 ❌

切片方式:  [chunk1][chunk2][chunk3]...[chunkN]  →  逐个 POST  →  失败只重传那一片 ✅
           ↑ 每个 5MB,独立上传,独立记录进度

二、核心原理:切片上传

2.1 文件切片

使用 File 对象继承自 Blob.slice() 方法,将大文件切成固定大小的分片:

javascript 复制代码
const CHUNK_SIZE = 5 * 1024 * 1024; // 每块 5MB

/**
 * 将文件切分成 chunk 数组
 * @param {File} file - 原始文件
 * @param {number} size - 每块大小(字节)
 * @returns {Array<{index: number, chunk: Blob, hash: string}>}
 */
function createChunks(file, size = CHUNK_SIZE) {
  const chunks = [];
  const totalChunks = Math.ceil(file.size / size);

  for (let i = 0; i < totalChunks; i++) {
    const start = i * size;
    const end = Math.min(start + size, file.size);
    const chunk = file.slice(start, end);

    chunks.push({
      index: i,          // 分片序号,从 0 开始
      chunk: chunk,      // Blob 数据
      start: start,
      end: end,
    });
  }

  return chunks;
}

// 使用示例
// const fileInput = document.getElementById('fileInput').files[0];
// const chunks = createChunks(fileInput); // 1GB 文件 → 200 个 chunk

2.2 文件 Hash 计算(唯一标识)

Hash 是整个方案的「身份证」------同一个文件无论切多少次,hash 都一样;不同文件 hash 不同。常用 SparkMD5 库计算:

javascript 复制代码
import SparkMD5 from 'spark-md5';

/**
 * 计算文件 hash(增量读取,避免内存爆炸)
 * @param {File} file
 * @returns {Promise<string>} hex 格式的 md5
 */
async function calculateFileHash(file) {
  return new Promise((resolve, reject) => {
    const chunkSize = 2 * 1024 * 1024; // 每次 2MB 读入内存
    const chunks = Math.ceil(file.size / chunkSize);
    let currentChunk = 0;
    const spark = new SparkMD5.ArrayBuffer();
    const reader = new FileReader();

    reader.onload = (e) => {
      spark.append(e.target.result);
      currentChunk++;

      if (currentChunk < chunks) {
        loadNext();
      } else {
        resolve(spark.end()); // 最终 hash
      }
    };

    reader.onerror = reject;

    function loadNext() {
      const start = currentChunk * chunkSize;
      const end = Math.min(start + chunkSize, file.size);
      reader.readAsArrayBuffer(file.slice(start, end));
    }

    loadNext();
  });
}

为什么不用整个文件一次性读? 一个 1GB 的文件如果 readAsArrayBuffer(file) 会占用 ~1GB 内存,浏览器直接卡死。增量读取每次只拿 2MB 到内存中,算完就释放。

2.3 逐片上传

javascript 复制代码
/**
 * 上传单个分片
 */
async function uploadChunk(fileHash, chunkData, onProgress) {
  const formData = new FormData();
  formData.append('fileHash', fileHash);       // 文件唯一标识
  formData.append('chunkHash', `${fileHash}-${chunkData.index}`); // 分片标识
  formData.append('chunkIndex', chunkData.index);
  formData.append('totalChunks', chunkData.total);
  formData.append('file', chunkData.chunk);     // 分片 Blob 数据
  formData.append('fileName', chunkData.fileName);

  const response = await fetch('/api/upload/chunk', {
    method: 'POST',
    body: formData,
  });

  if (!response.ok) throw new Error(`Upload failed: ${response.status}`);

  const result = await response.json();

  // 回报进度
  onProgress?.(chunkData.index + 1, chunkData.total);

  return result;
}

/**
 * 并发控制上传所有分片
 * @param {string} fileHash
 * @param {Array} chunks - createChunks 返回的数组
 * @param {number} concurrency - 并发数,默认 3
 * @param {Function} onProgress - 进度回调 (uploaded, total) => void
 */
async function uploadWithPool(fileHash, chunks, concurrency = 3, onProgress) {
  let uploadedCount = 0;
  const total = chunks.length;

  // 用一个简单的并发池
  const pool = new Set();
  let index = 0;

  return new Promise((resolve, reject) => {
    function next() {
      while (pool.size < concurrency && index < total) {
        const currentIndex = index++;
        const chunkData = {
          ...chunks[currentIndex],
          total: total,
          fileName: chunks[currentIndex].fileName,
        };

        const task = uploadChunk(fileHash, chunkData, () => {
          uploadedCount++;
          onProgress?.(uploadedCount, total);
        })
          .then(() => pool.delete(task))
          .catch(reject);

        pool.add(task);
      }

      if (pool.size === 0 && index >= total) {
        resolve(); // 全部完成
      }

      // 任一任务完成后,尝试填充下一个
      if (pool.size > 0) {
        Promise.race(pool).then(next, reject);
      }
    }

    next();
  });
}

三、断点续传

3.1 原理

断点续传的核心问题:重新打开页面后,怎么知道之前传到哪了?

答案:问服务器

css 复制代码
客户端                              服务端
  │                                   │
  ├── 1. 计算文件 hash ──────────────→  │
  │                                   │
  ├── 2. POST /api/upload/check ───→   │
  │     { fileHash: "abc123" }         │
  │                                   │
  ├── 3. ← 返回已上传的分片列表 ─────│
  │     { uploaded: [0,1,2,4,5] }     │
  │     (说明第 3 号分片丢了/没传完)  │
  │                                   │
  ├── 4. 只传缺失的分片 [3,6,7...] ─→ │
  │                                   │
  ├── 5. 全部到齐 → 通知合并 ───────→  │
  │                                   │
  ├── 6. ← 返回文件 URL ─────────────│

3.2 完整实现

javascript 复制代码
class ResumableUploader {
  constructor(options = {}) {
    this.chunkSize = options.chunkSize || 5 * 1024 * 1024;
    this.concurrency = options.concurrency || 3;
    this.uploadUrl = options.uploadUrl || '/api/upload/chunk';
    this.checkUrl = options.checkUrl || '/api/upload/check';
    this.mergeUrl = options.mergeUrl || '/api/upload/merge';
    this.onProgress = options.onProgress || (() => {});
  }

  /**
   * 主入口:开始上传
   */
  async upload(file) {
    this.file = file;
    this.fileName = file.name;
    this.fileSize = file.size;

    // Step 1: 计算文件 hash
    this.fileHash = await calculateFileHash(file);

    // Step 2: 检查已上传的分片(秒传判断)
    const { uploaded, exists } = await this.checkUploadedChunks();

    // 秒传:文件已完整存在
    if (exists) {
      this.onProgress(100, 100, '秒传成功');
      return { url: exists, message: '文件已存在(秒传)' };
    }

    // Step 3: 创建分片列表,排除已上传的
    const allChunks = createChunks(file, this.chunkSize).map(c => ({
      ...c,
      fileName: this.fileName,
    }));
    const pendingChunks = allChunks.filter(c => !uploaded.includes(c.index));

    console.log(`共 ${allChunks.length} 片,已传 ${uploaded.length} 片,待传 ${pendingChunks.length} 片`);

    if (pendingChunks.length === 0) {
      // 所有分片都已上传,只需通知合并
      return this.notifyMerge(allChunks.length);
    }

    // Step 4: 上传缺失分片
    await uploadWithPool(this.fileHash, pendingChunks, this.concurrency,
      (done, total) => {
        const actualDone = done + uploaded.length;
        this.onProgress(actualDone, allChunks.length, '上传中...');
      }
    );

    // Step 5: 通知后端合并
    return this.notifyMerge(allChunks.length);
  }

  /**
   * 检查已上传的分片
   */
  async checkUploadedChunks() {
    const res = await fetch(this.checkUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        fileHash: this.fileHash,
        fileName: this.fileName,
        totalChunks: Math.ceil(this.fileSize / this.chunkSize),
      }),
    });

    if (!res.ok) throw new Error('检查上传状态失败');
    return res.json();
    // 返回格式: { uploaded: [0,1,2], exists: null } 或 { uploaded: [], exists: "https://..." }
  }

  /**
   * 通知后端合并分片
   */
  async notifyMerge(totalChunks) {
    this.onProgress(totalChunks, totalChunks, '正在合并...');

    const res = await fetch(this.mergeUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        fileHash: this.fileHash,
        fileName: this.fileName,
        totalChunks: totalChunks,
        fileSize: this.fileSize,
      }),
    });

    if (!res.ok) throw new Error('合并失败');
    return res.json();
    // 返回格式: { url: "https://cdn.example.com/files/xxx" }
  }
}

// 使用示例
// const uploader = new ResumableUploader({
//   onProgress: (done, total, status) => {
//     console.log(`进度: ${done}/${total} (${Math.round(done/total*100)}%) ${status}`);
//   }
// });
//
// uploader.upload(fileInput.files[0])
//   .then(result => console.log('上传完成:', result.url))
//   .catch(err => console.error('上传失败:', err));

四、进阶优化

4.1 Web Worker 加速切片 & Hash 计算

切片和 hash 计算是 CPU 密集型操作,放主线程会阻塞 UI。用 Web Worker 把活儿甩到后台:

javascript 复制代码
// worker/hash-worker.js ------ 在 Web Worker 中计算 hash
self.importScripts('https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js');

self.onmessage = async function(e) {
  const { file, chunkSize } = e.data;
  const spark = new SparkMD5.ArrayBuffer();
  const chunks = Math.ceil(file.size / chunkSize);

  for (let i = 0; i < chunks; i++) {
    const start = i * chunkSize;
    const end = Math.min(start + chunkSize, file.size);
    const blob = file.slice(start, end);
    const buffer = await blob.arrayBuffer();
    spark.append(buffer);

    // 向主线程报告 hash 计算进度
    self.postMessage({ type: 'progress', current: i + 1, total: chunks });
  }

  self.postMessage({ type: 'done', hash: spark.end() });
};

// 主线程调用
function calculateHashInWorker(file) {
  return new Promise((resolve) => {
    const worker = new Worker('./worker/hash-worker.js');

    worker.onmessage = (e) => {
      if (e.data.type === 'done') {
        resolve(e.data.hash);
        worker.terminate();
      }
    };

    worker.postMessage({ file, chunkSize: 2 * 1024 * 1024 });
  });
}

4.2 IndexedDB 持久化上传记录

页面刷新或关闭后再打开,从 IndexedDB 恢复上传状态:

javascript 复制代码
// db.js ------ IndexedDB 操作封装
const DB_NAME = 'FileUploadDB';
const DB_VERSION = 1;
const STORE_NAME = 'uploadRecords';

function openDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);

    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      if (!db.objectStoreNames.contains(STORE_NAME)) {
        db.createObjectStore(STORE_NAME, { keyPath: 'fileHash' });
      }
    };

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

/** 保存上传记录 */
async function saveUploadRecord(record) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE_NAME, 'readwrite');
    tx.objectStore(STORE_NAME).put(record);
    tx.oncomplete = resolve;
    tx.onerror = () => reject(tx.error);
  });
}

/** 获取上传记录 */
async function getUploadRecord(fileHash) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE_NAME, 'readonly');
    const req = tx.objectStore(STORE_NAME).get(fileHash);
    req.onsuccess = () => resolve(req.result || null);
    req.onerror = () => reject(req.error);
  });
}

/** 删除已完成的上传记录 */
async function removeUploadRecord(fileHash) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORENAME, 'readwrite');
    tx.objectStore(STORE_NAME).delete(fileHash);
    tx.oncomplete = resolve;
    tx.onerror = () => reject(tx.error);
  });
}

// 集成到 Uploader 中:
// 上传前 → saveUploadRecord({ fileHash, fileName, fileSize, uploadedChunks: [], status: 'uploading' })
// 每传完一片 → 更新 uploadedChunks
// 页面加载时 → 扫描未完成的记录,提示用户"是否继续上传?"

4.3 WebSocket 实时进度推送

HTTP 轮询太浪费,用 WebSocket 让服务端主动推进度给前端:

javascript 复制代码
class UploadWithWebSocket {
  constructor(uploadUrl, wsUrl) {
    this.uploadUrl = uploadUrl;
    this.wsUrl = wsUrl;
    this.ws = null;
  }

  connect(fileHash) {
    this.ws = new WebSocket(`${this.wsUrl}?fileHash=${fileHash}`);

    this.ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      switch (data.type) {
        case 'progress':
          console.log(`服务端确认收到分片 ${data.chunkIndex}`);
          break;
        case 'merge_complete':
          console.log('合并完成,文件 URL:', data.url);
          break;
        case 'error':
          console.error('服务端错误:', data.message);
          break;
      }
    };
  }

  disconnect() {
    this.ws?.close();
  }
}

4.4 错误处理与重试策略

javascript 复制代码
/**
 * 带指数退避的重试上传
 */
async function uploadChunkWithRetry(uploadFn, maxRetries = 3) {
  let lastError;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await uploadFn();
    } catch (err) {
      lastError = err;

      if (attempt < maxRetries) {
        // 指数退避:1s → 2s → 4s
        const delay = Math.pow(2, attempt) * 1000;
        console.warn(`分片上传失败,${delay}ms 后重试 (${attempt + 1}/${maxRetries})`);
        await new Promise(r => setTimeout(r, delay));
      }
    }
  }

  throw lastError;
}

五、完整组件设计

5.1 React 组件示例

jsx 复制代码
import React, { useState, useRef, useCallback } from 'react';
import { ResumableUploader } from './uploader';

function LargeFileUploader({
  accept = '*',              // 接受的文件类型
  maxSize = Infinity,        // 最大文件大小(字节)
  chunkSize = 5 * 1024 * 1024,
  concurrency = 3,
  onUploadSuccess,           // (url) => void
  onUploadError,             // (error) => void
}) {
  const [file, setFile] = useState(null);
  const [progress, setProgress] = useState(0);
  const [status, setStatus] = useState('idle'); // idle | hashing | uploading | merging | success | error
  const [dragOver, setDragOver] = useState(false);
  const inputRef = useRef(null);
  const abortRef = useRef(null);

  const handleFile = useCallback((selectedFile) => {
    if (selectedFile.size > maxSize) {
      alert(`文件过大,最大支持 ${formatSize(maxSize)}`);
      return;
    }
    setFile(selectedFile);
    setProgress(0);
    setStatus('idle');
  }, [maxSize]);

  const handleUpload = useCallback(async () => {
    if (!file) return;

    const uploader = new ResumableUploader({
      chunkSize,
      concurrency,
      onProgress: (done, total, msg) => {
        setProgress(Math.round((done / total) * 100));
        setStatus(msg.includes('hash') ? 'hashing'
          : msg.includes('合并') ? 'merging'
            : 'uploading');
      },
    });

    // 支持取消
    abortRef.current = () => {
      // 实际项目中可用 AbortController 取消 fetch
      setStatus('idle');
      setProgress(0);
    };

    try {
      setStatus('hashing');
      const result = await uploader.upload(file);
      setStatus('success');
      onUploadSuccess?.(result.url);
    } catch (err) {
      setStatus('error');
      onUploadError?.(err);
    }
  }, [file, chunkSize, concurrency, onUploadSuccess, onUploadError]);

  // 拖拽处理
  const handleDragOver = (e) => { e.preventDefault(); setDragOver(true); };
  const handleDragLeave = (e) => { e.preventDefault(); setDragOver(false); };
  const handleDrop = (e) => {
    e.preventDefault();
    setDragOver(false);
    const droppedFile = e.dataTransfer.files[0];
    if (droppedFile) handleFile(droppedFile);
  };

  return (
    <div
      className={`uploader ${dragOver ? 'drag-over' : ''}`}
      onDragOver={handleDragOver}
      onDragLeave={handleDragLeave}
      onDrop={handleDrop}
    >
      <input
        ref={inputRef}
        type="file"
        accept={accept}
        onChange={(e) => handleFile(e.target.files[0])}
        style={{ display: 'none' }}
        multiple={false}
      />

      {!file ? (
        <div className="uploader-prompt" onClick={() => inputRef.current?.click()}>
          <p>点击或拖拽文件到此区域</p>
          <p className="hint">支持大文件上传,断网可续传</p>
        </div>
      ) : (
        <div className="uploader-active">
          <div className="file-info">
            <span className="file-name">{file.name}</span>
            <span className="file-size">{formatSize(file.size)}</span>
          </div>

          <div className="progress-bar">
            <div className="progress-fill" style={{ width: `${progress}%` }} />
            <span className="progress-text">{progress}%</span>
          </div>

          <div className="status-text">{statusTextMap[status]}</div>

          <div className="actions">
            {status !== 'uploading' && status !== 'success' && (
              <button onClick={handleUpload}>开始上传</button>
            )}
            {(status === 'uploading' || status === 'hashing') && (
              <button onClick={abortRef.current}>取消</button>
            )}
            <button onClick={() => { setFile(null); setStatus('idle'); }}>
              重新选择
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

const statusTextMap = {
  idle: '准备就绪',
  hashing: '正在计算文件指纹...',
  uploading: '正在上传...',
  merging: '正在合并文件...',
  success: '上传完成!',
  error: '上传失败,请重试',
};

function formatSize(bytes) {
  if (bytes < 1024) return bytes + ' B';
  if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
  if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
  return (bytes / 1073741824).toFixed(2) + ' GB';
}

5.2 组件 Props / 事件 / 状态一览

类别 名称 类型 说明
Props accept string 文件类型过滤,如 '.pdf,.docx'
maxSize number 最大文件字节数
chunkSize number 分片大小,默认 5MB
concurrency number 并发上传数,默认 3
Events onUploadSuccess (url: string) => void 上传成功回调,返回文件 URL
onUploadError (error: Error) => void 上传失败回调
State status enum idle → hashing → uploading → merging → success/error
progress number 0~100 百分比

六、前后端协议设计

6.1 统一接口规范

接口 方法 参数 返回值 说明
/api/upload/check POST { fileHash, fileName, totalChunks } { uploaded: number[], exists?: string } 查询已上传分片;若文件完整则返回 url(秒传)
/api/upload/chunk POST FormData: fileHash, chunkIndex, chunkHash, file, totalChunks, fileName { success: true, index: number } 上传单个分片
/api/upload/merge POST { fileHash, fileName, totalChunks, fileSize } { url: string } 通知后端合并所有分片

6.2 分片命名规则

makefile 复制代码
文件 hash:  a1b2c3d4e5f6...
分片命名:  {fileHash}-{index}

示例:
  a1b2c3d4-0    ← 第 1 片
  a1b2c3d4-1    ← 第 2 片
  a1b2c3d4-2    ← 第 3 片
  ...
  a1b2c3d4-199  ← 第 200 片(假设 1GB 文件,每片 5MB)

后端存储时可以用这个名作为临时文件名,合并时按 index 顺序拼接。


七、方案对比

维度 普通 FormData 上传 切片上传(基础版) 切片+断点续传(完整版)
文件大小限制 受浏览器/服务端限制(通常 < 100MB) 理论无上限 理论无上限
断网恢复 ❌ 从头再来 ❌ 从头再来 ✅ 只传剩余分片
进度显示 ❌ 只有整体完成/失败 ✅ 分片级进度 ✅ 分片级 + 可恢复
秒传 ✅ hash 匹配直接返回 URL
实现复杂度 低(3 行代码) 中(~200 行) 高(~600 行+)
适用场景 头像、小附件 内网稳定环境 公网、大文件、弱网

八、面试高频问题

Q1:为什么切片大小一般选 5MB?

  • 太小(如 10KB):请求数爆炸,HTTP 头部开销占比高,反而更慢
  • 太大(如 100MB):单次传输时间长,一旦失败重传代价大
  • 5MB 是经验平衡点:单次传输约 2~5 秒(普通宽带),失败重传代价可控,总请求数也合理(1GB ≈ 200 个请求)

Q2:hash 计算太慢怎么办?

  • Web Worker:不阻塞 UI(如上 4.1 所示)
  • 增量计算:不要一次读整个文件,分块读入(如上 2.2 所示)
  • 抽样 hash:只取文件头部 + 尾部 + 中间各 2MB 来算(牺牲精度换速度,适合非严格场景)
  • Web Crypto API :浏览器原生 crypto.subtle.digest('SHA-256', buffer) 比 SparkMD5 更快且不需要引入库

Q3:如何保证分片的顺序性?

  • 每个分片带 chunkIndex 序号
  • 后端收到后存为 {fileHash}-{index} 临时文件
  • 合并时按 index 从小到大拼接:cat 0 1 2 3 ... > final_file
  • 或者用 preallocated file + seek write,每个分片写到对应 offset 位置

Q4:如何处理用户重复选择同一个文件?

  • file.name + file.size + file.lastModified 做 quick check
  • 如果一致且 hash 已算过,跳过 hash 步骤直接走 check 接口
  • 这就是「同一文件秒传」的另一种形式

Q5:如果后端合并时部分分片丢失怎么办?

  • merge 接口在合并前先检查所有分片是否存在
  • 缺失则返回 409 + 缺失的 index 列表
  • 前端补传缺失分片后再次请求 merge

九、记忆口诀

bash 复制代码
大文件上传三步走:切、传、合,断了能续不用愁。

切片五兆刚刚好,hash 当作身份证。
先问后端传了啥,缺谁补谁不白忙。
Worker 算 hash 不卡页,IndexedDB 存进度。
WebSocket 推消息,指数退避防重试。

一句话总结:
切片 + hash 标识 + 断点查询 + 补传缺失 + 后端合并 = 完整的大文件上传方案
相关推荐
做前端的娜娜子3 小时前
React 性能优化
掘金·金石计划
做前端的娜娜子1 天前
Vue的响应式原理
掘金·金石计划
做前端的娜娜子2 天前
Web缓存策略有哪些?
掘金·金石计划
光影少年10 天前
RN 原生动画 Animated 用法、动画性能优化
react native·react.js·掘金·金石计划
光影少年13 天前
RN适配方案:屏幕适配、分辨率适配、刘海屏/全面屏适配
react native·react.js·掘金·金石计划
光影少年13 天前
RN中的StyleSheet.create 好处、内联样式弊端
react native·react.js·掘金·金石计划
光影少年15 天前
RN 样式特点:Flex 布局默认、没有像素单位、样式不能级联
react native·react.js·掘金·金石计划
光影少年22 天前
react navite JSBridge 通信机制、异步通信特点
react native·react.js·掘金·金石计划