技术手记算法实践

大文件分片上传与断点续传:从 MD5 秒传到 TypeScript 完整实现

Michael Meng· 2026年7月23日· ◷ 5 分钟阅读
大文件分片上传与断点续传:从 MD5 秒传到 TypeScript 完整实现

概述

前端上传大视频文件时,单次上传容易失败、超时,重试只能从头再来。本文实现一套完整的分片上传系统:前端用 SparkMD5 计算文件指纹实现秒传,将文件切成 5MB 分片并发上传,刷新页面后可断点续传,最终合并成完整文件。附带统一 API 接口规范和生产级 TypeScript 代码。

1. 系统架构

┌─────────────────────────────────────────────────────────┐
│  前端                                                    │
│  1. 选择文件 → SparkMD5 计算指纹                         │
│  2. POST /upload/prepare 申请上传                        │
│     ├── needUpload=false → 秒传(同 MD5 已存在)         │
│     └── needUpload=true  → 返回 uploadId + 已上传分片    │
│  3. 并发上传分片 POST /upload/chunk (每个5MB)            │
│  4. 全部分片完成 → POST /upload/merge                    │
│  5. 返回 fileId + url                                    │
└─────────────────────────────────────────────────────────┘

2. API 接口规范

通用返回格式

{
  "code": 200,
  "message": "success",
  "data": {},
  "requestId": "uuid"
}

接口列表

接口 说明
POST /upload/prepare 申请上传,返回 uploadId + 秒传判断
POST /upload/chunk 上传单个分片
POST /upload/merge 合并所有分片
POST /upload/cancel 取消上传

请求参数

// /upload/prepare
{ fileName: string; fileSize: number; fileType: string; md5: string }
// 响应: { uploadId, chunks: number[], needUpload: boolean }

// /upload/chunk (FormData)
uploadId: string; chunkIndex: number; chunkSize: number; file: Blob

// /upload/merge
{ uploadId: string; fileName: string }
// 响应: { fileId, url, duration? }

3. 前端完整实现

// ===== FileChunkedUpload.tsx =====
import React, { useState } from 'react';
import SparkMD5 from 'spark-md5';

const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
const MAX_CONCURRENT_CHUNKS = 3;     // 分片并发数

interface UploadState {
  status: 'idle' | 'hashing' | 'preparing' | 'uploading' | 'merging' | 'done' | 'error';
  progress: number;     // 总体 0-100
  message: string;
  uploadId?: string;
  uploadedChunks: number[];
}

async function getFileMD5(file: File): Promise<string> {
  return new Promise((resolve, reject) => {
    const spark = new SparkMD5.ArrayBuffer();
    const reader = new FileReader();
    let offset = 0;

    reader.onload = (e) => {
      spark.append(e.target!.result as ArrayBuffer);
      offset += CHUNK_SIZE;
      if (offset < file.size) {
        // 继续读下一块
        readNext();
      } else {
        resolve(spark.end());
      }
    };
    reader.onerror = reject;

    function readNext() {
      const blob = file.slice(offset, offset + CHUNK_SIZE);
      reader.readAsArrayBuffer(blob);
    }

    readNext();
  });
}

export default function FileChunkedUpload() {
  const [state, setState] = useState<UploadState>({
    status: 'idle',
    progress: 0,
    message: '',
    uploadedChunks: [],
  });
  const [resultUrl, setResultUrl] = useState('');

  const uploadFile = async (file: File) => {
    try {
      // === 步骤 1: 计算 MD5 ===
      setState((s) => ({ ...s, status: 'hashing', message: '计算文件指纹...', progress: 0 }));
      const md5 = await getFileMD5(file);

      // === 步骤 2: 申请上传 ===
      setState((s) => ({ ...s, status: 'preparing', message: '准备上传...' }));
      const prepareRes = await fetch('/api/upload/prepare', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          fileName: file.name,
          fileSize: file.size,
          fileType: file.type || 'application/octet-stream',
          md5,
        }),
      });
      const prepare = await prepareRes.json();

      // 秒传判断
      if (!prepare.data.needUpload) {
        setState((s) => ({ ...s, status: 'done', progress: 100, message: '秒传成功!' }));
        setResultUrl(prepare.data.url);
        return;
      }

      const { uploadId, chunks: skippedChunks = [] } = prepare.data;
      const totalChunks = Math.ceil(file.size / CHUNK_SIZE);

      // === 步骤 3: 找出需要上传的分片(跳过已上传的) ===
      const pendingChunks: number[] = [];
      for (let i = 0; i < totalChunks; i++) {
        if (!skippedChunks.includes(i)) {
          pendingChunks.push(i);
        }
      }

      setState((s) => ({
        ...s,
        status: 'uploading',
        message: `上传中... (${skippedChunks.length}/${totalChunks} 已跳过)`,
        uploadId,
        uploadedChunks: [...skippedChunks],
      }));

      // === 步骤 4: 并发上传分片 ===
      await uploadChunksWithConcurrency(file, uploadId, pendingChunks, totalChunks, (done, total, chunkIndex) => {
        setState((s) => ({
          ...s,
          progress: Math.round((done / total) * 90),
          message: `上传中 ${done}/${total} 分片`,
          uploadedChunks: [...s.uploadedChunks, chunkIndex],
        }));
      });

      // === 步骤 5: 合并 ===
      setState((s) => ({ ...s, status: 'merging', message: '合并文件...' }));
      const mergeRes = await fetch('/api/upload/merge', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ uploadId, fileName: file.name }),
      });
      const merge = await mergeRes.json();

      setState((s) => ({ ...s, status: 'done', progress: 100, message: '上传完成!' }));
      setResultUrl(merge.data.url);
    } catch (err: any) {
      setState((s) => ({ ...s, status: 'error', message: `失败:${err.message}` }));
    }
  };

  return (
    <div className="file-uploader">
      <input
        type="file"
        onChange={(e) => {
          const file = e.target.files?.[0];
          if (file) uploadFile(file);
        }}
        disabled={state.status === 'uploading' || state.status === 'merging'}
      />

      {state.status !== 'idle' && (
        <div className="upload-progress">
          <p>{state.message}</p>
          <div className="progress-bar-bg">
            <div
              className="progress-bar-fill"
              style={{ width: `${state.progress}%`, transition: 'width 0.3s' }}
            />
          </div>
          <p>{state.progress}%</p>
        </div>
      )}

      {resultUrl && <p>结果: <a href={resultUrl} target="_blank">{resultUrl}</a></p>}
    </div>
  );
}

// 分片并发上传器
async function uploadChunksWithConcurrency(
  file: File,
  uploadId: string,
  chunkIndices: number[],
  totalChunks: number,
  onProgress: (done: number, total: number, chunkIndex: number) => void,
  maxConcurrent = MAX_CONCURRENT_CHUNKS
): Promise<void> {
  let completed = 0;
  const total = chunkIndices.length + (totalChunks - chunkIndices.length);
  let currentIndex = 0;

  async function uploadChunk(index: number): Promise<void> {
    const start = index * CHUNK_SIZE;
    const end = Math.min(start + CHUNK_SIZE, file.size);
    const blob = file.slice(start, end);

    const formData = new FormData();
    formData.append('uploadId', uploadId);
    formData.append('chunkIndex', String(index));
    formData.append('chunkSize', String(end - start));
    formData.append('file', blob);

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

    completed++;
    onProgress(completed, total, index);
  }

  async function worker() {
    while (currentIndex < chunkIndices.length) {
      const idx = currentIndex++;
      await uploadChunk(chunkIndices[idx]);
    }
  }

  // 启动并发 workers
  const workers: Promise<void>[] = [];
  for (let i = 0; i < Math.min(maxConcurrent, chunkIndices.length); i++) {
    workers.push(worker());
  }
  await Promise.all(workers);
}

4. 断点续传原理

断点续传无需前端额外代码。核心在 /upload/prepare 的响应:

{
  "uploadId": "abc123",
  "chunks": [0, 1, 3, 5],   // 已上传的分片索引
  "needUpload": true
}

前端根据 chunks 跳过已上传的分片,只上传缺失的分片。即使关闭浏览器再打开,只要 uploadId 还在(存在 localStorage 或 URL 参数),就能续传。

5. localStorage 持久化上传状态

interface UploadRecord {
  uploadId: string;
  fileName: string;
  fileSize: number;
  md5: string;
  completedChunks: number[];
  totalChunks: number;
  createdAt: number;
}

const STORAGE_KEY = 'file-upload-records';

function saveUploadRecord(record: UploadRecord) {
  const records = loadUploadRecords();
  const idx = records.findIndex((r) => r.uploadId === record.uploadId);
  if (idx >= 0) {
    records[idx] = record;
  } else {
    records.push(record);
  }
  localStorage.setItem(STORAGE_KEY, JSON.stringify(records));
}

function loadUploadRecords(): UploadRecord[] {
  try {
    return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
  } catch { return []; }
}

function findUploadRecordByMD5(md5: string): UploadRecord | undefined {
  return loadUploadRecords().find((r) => r.md5 === md5);
}

6. 面试核心要点

为什么能秒传?

  1. 前端用 SparkMD5 对文件内容计算唯一哈希
  2. /upload/prepare 传递 MD5,后端查库看是否已存在
  3. 若存在,直接返回已有的 fileId 和 URL,跳过上传

断点续传怎么实现的?

前端在 /upload/prepare 拿到已上传的分片列表,只上传缺失分片。刷新页面后用 MD5 或 uploadId 恢复上传状态。

多个分片同时上传,怎么保证顺序?

每个分片带 index 编号,/chunk 时指定 chunkIndex。合并阶段后端按 index 顺序拼接,前端无需关心上传顺序。

总结

  • SparkMD5 分块计算指纹,大文件不卡顿
  • 秒传 的核心是 MD5 去重 + /prepare 接口的 needUpload 判断
  • 断点续传 的关键是 /prepare 返回已上传分片列表
  • 并发控制 用 worker 模式,MAX_CONCURRENT_CHUNKS=3 平衡速度与稳定性
  • 面试规范:上传用 POST + FormData,下载用 GET + Stream,实时用 WebSocket/SSE

Comments 留言讨论

还没有评论,来抢个沙发,聊聊你的看法~

Michael.Meng

michaelnews@126.com
用 AI 记录,用文字沉淀

© 2026 Michael Meng · 保留所有权利 · Powered by FastAPI + Nuxt