大文件断点续传原理总结和Demo示例详解

把大文件切成多个小块,用 Hash 标识唯一文件,服务端持久化已上传的切片,中断后只传缺失部分,最后合并成完整文件。

目录

1. 为什么需要断点续传?

大文件一次性上传容易遇到:

网络中断 → 整文件重传,浪费流量和时间

超时失败 → 大请求更容易失败

内存压力 → 浏览器/服务端难以一次处理超大文件

解决方案:分片 + 记录进度 + 按需续传。

2. 核心概念

概念 说明 Demo 中的实现
文件 Hash 文件唯一标识,用于识别「是不是同一个文件」 SparkMD5 分块计算
切片 (Chunk) 把文件按固定大小(如 2MB)切成多块 createChunks()
切片索引 每块的序号 0, 1, 2... 上传时带 index
已上传记录 服务端保存哪些切片已到位 tmp/{hash}/{index}
断点续传 只上传缺失的切片 对比 uploadedIndexes
合并 所有切片到齐后按序拼接 /merge 接口

3. 完整流程(6 步)

选文件 → 算 Hash → 切片 → 查已上传 → 传剩余切片 → 合并

Step 1:选择文件并计算 Hash

  • 操作:用户选择文件,前端使用 SparkMD5 库对文件进行分块读取并计算 MD5 值。
  • 目的:生成文件的唯一 Hash,作为该文件的"身份证"。同一文件无论何时上传,其 Hash 值相同。

Step 2:创建切片

  • 操作 :按预设的 CHUNK_SIZE(例如 2MB)将文件切分成多个 Blob 数据块。
  • 切片信息 :每个切片包含 chunk(Blob 数据)、hash(文件标识)、index(切片序号)、totalChunks(切片总数)。

Step 3:查询已上传切片(断点续传关键)

  • 操作 :前端发起 GET /uploaded?hash=xxx 请求。
  • 服务端 :扫描 tmp/{hash}/ 目录,返回已成功上传的切片索引列表。
  • 前端逻辑 :对比所有切片,计算出 remainingChunks(未上传的切片列表)。

Step 4:并发上传剩余切片

  • 并发控制 :启动 3 个并发 Worker 同时上传 remainingChunks
  • 可靠性:每个切片上传附带重试机制(最多3次)和指数退避策略,以应对网络波动。
  • 交互性
    • 支持暂停:取消所有进行中的 axios 请求。
    • 支持恢复 :重新获取 remainingChunks 并继续上传。

Step 5:合并所有切片

  • 操作 :所有切片上传完成后,前端请求 POST /merge
  • 服务端 :按 index 顺序读取所有切片,流式合并写入最终文件。
  • 清理 :合并成功后,删除临时目录 tmp/{hash}/

Step 6:文件管理

  • 功能:提供已上传文件的列表查看,以及临时文件夹(未完成上传的文件)的查看与清理功能。

#mermaid-svg-UpzSCRt0UM1Ioslf{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-UpzSCRt0UM1Ioslf .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-UpzSCRt0UM1Ioslf .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-UpzSCRt0UM1Ioslf .error-icon{fill:#552222;}#mermaid-svg-UpzSCRt0UM1Ioslf .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-UpzSCRt0UM1Ioslf .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-UpzSCRt0UM1Ioslf .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-UpzSCRt0UM1Ioslf .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-UpzSCRt0UM1Ioslf .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-UpzSCRt0UM1Ioslf .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-UpzSCRt0UM1Ioslf .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-UpzSCRt0UM1Ioslf .marker{fill:#333333;stroke:#333333;}#mermaid-svg-UpzSCRt0UM1Ioslf .marker.cross{stroke:#333333;}#mermaid-svg-UpzSCRt0UM1Ioslf svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-UpzSCRt0UM1Ioslf p{margin:0;}#mermaid-svg-UpzSCRt0UM1Ioslf .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-UpzSCRt0UM1Ioslf .cluster-label text{fill:#333;}#mermaid-svg-UpzSCRt0UM1Ioslf .cluster-label span{color:#333;}#mermaid-svg-UpzSCRt0UM1Ioslf .cluster-label span p{background-color:transparent;}#mermaid-svg-UpzSCRt0UM1Ioslf .label text,#mermaid-svg-UpzSCRt0UM1Ioslf span{fill:#333;color:#333;}#mermaid-svg-UpzSCRt0UM1Ioslf .node rect,#mermaid-svg-UpzSCRt0UM1Ioslf .node circle,#mermaid-svg-UpzSCRt0UM1Ioslf .node ellipse,#mermaid-svg-UpzSCRt0UM1Ioslf .node polygon,#mermaid-svg-UpzSCRt0UM1Ioslf .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-UpzSCRt0UM1Ioslf .rough-node .label text,#mermaid-svg-UpzSCRt0UM1Ioslf .node .label text,#mermaid-svg-UpzSCRt0UM1Ioslf .image-shape .label,#mermaid-svg-UpzSCRt0UM1Ioslf .icon-shape .label{text-anchor:middle;}#mermaid-svg-UpzSCRt0UM1Ioslf .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-UpzSCRt0UM1Ioslf .rough-node .label,#mermaid-svg-UpzSCRt0UM1Ioslf .node .label,#mermaid-svg-UpzSCRt0UM1Ioslf .image-shape .label,#mermaid-svg-UpzSCRt0UM1Ioslf .icon-shape .label{text-align:center;}#mermaid-svg-UpzSCRt0UM1Ioslf .node.clickable{cursor:pointer;}#mermaid-svg-UpzSCRt0UM1Ioslf .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-UpzSCRt0UM1Ioslf .arrowheadPath{fill:#333333;}#mermaid-svg-UpzSCRt0UM1Ioslf .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-UpzSCRt0UM1Ioslf .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-UpzSCRt0UM1Ioslf .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UpzSCRt0UM1Ioslf .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-UpzSCRt0UM1Ioslf .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UpzSCRt0UM1Ioslf .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-UpzSCRt0UM1Ioslf .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-UpzSCRt0UM1Ioslf .cluster text{fill:#333;}#mermaid-svg-UpzSCRt0UM1Ioslf .cluster span{color:#333;}#mermaid-svg-UpzSCRt0UM1Ioslf div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-UpzSCRt0UM1Ioslf .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-UpzSCRt0UM1Ioslf rect.text{fill:none;stroke-width:0;}#mermaid-svg-UpzSCRt0UM1Ioslf .icon-shape,#mermaid-svg-UpzSCRt0UM1Ioslf .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UpzSCRt0UM1Ioslf .icon-shape p,#mermaid-svg-UpzSCRt0UM1Ioslf .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-UpzSCRt0UM1Ioslf .icon-shape .label rect,#mermaid-svg-UpzSCRt0UM1Ioslf .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UpzSCRt0UM1Ioslf .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-UpzSCRt0UM1Ioslf .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-UpzSCRt0UM1Ioslf :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是



📁 用户选择文件
🔢 Step 1: 计算文件 Hash

(SparkMD5)
✂️ Step 2: 创建切片

(CHUNK_SIZE = 2MB)
❓ Step 3: 查询已上传切片

(GET /uploaded)
是否有未上传切片?
⚡ Step 4: 并发上传剩余切片

(3 Worker + 重试)
🔄 上传完成?
🧩 Step 5: 合并切片

(POST /merge)
🗂️ Step 6: 文件管理

(列表与清理)
🎉 上传成功!

4、断点续传的关键机制要

(1)Hash 唯一性:同一文件 Hash 不变,可识别「续传的是同一个文件」

(2)切片持久化:每片独立保存,不依赖内存

(3)幂等上传:同一 index 可重复上传(覆盖),不影响结果

(4)合并校验:合并前检查 chunks.length === totalChunks

一句话总结

大文件断点续传 = 分片上传 + Hash 标识 + 服务端记录进度 + 只传缺失片 + 最后合并

中断后再次上传同一文件时,通过 Hash 查到已上传切片,跳过它们,只传剩余部分,从而节省时间和带宽。

demo流程图

5、一个前后端的简单demo

前端index.html

bash 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>大文件断点续传</title>
  <style>
    * { box-sizing: border-box; }
    body {
      margin: 0;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
      background: #f5f7fb;
      color: #1f2937;
    }
    .page {
      max-width: 960px;
      margin: 0 auto;
      padding: 32px 20px 48px;
    }
    h1 {
      margin: 0 0 8px;
      font-size: 28px;
    }
    .desc {
      margin: 0 0 24px;
      color: #6b7280;
      line-height: 1.6;
    }
    .panel {
      background: #fff;
      border-radius: 12px;
      padding: 20px;
      box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
      margin-bottom: 20px;
    }
    .toolbar {
      display: flex;
      flex-wrap: wrap;
      gap: 12px;
      align-items: center;
    }
    .toolbar input[type="file"] { display: none; }
    .btn {
      border: none;
      border-radius: 8px;
      padding: 10px 16px;
      font-size: 14px;
      cursor: pointer;
      transition: opacity 0.2s;
    }
    .btn:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }
    .btn-primary { background: #2563eb; color: #fff; }
    .btn-secondary { background: #e5e7eb; color: #111827; }
    .btn-warning { background: #f59e0b; color: #fff; }
    .btn-success { background: #059669; color: #fff; }
    .hint {
      margin-top: 12px;
      font-size: 13px;
      color: #6b7280;
    }
    .file-list { margin-top: 16px; }
    .file-item {
      border: 1px solid #e5e7eb;
      border-radius: 10px;
      padding: 14px;
      margin-bottom: 12px;
      background: #fafafa;
    }
    .file-head {
      display: flex;
      justify-content: space-between;
      gap: 12px;
      align-items: center;
      margin-bottom: 10px;
    }
    .file-name {
      font-weight: 600;
      word-break: break-all;
    }
    .file-meta {
      font-size: 12px;
      color: #6b7280;
      margin-top: 4px;
    }
    .status {
      font-size: 13px;
      padding: 4px 10px;
      border-radius: 999px;
      white-space: nowrap;
    }
    .status.pending { background: #e5e7eb; color: #374151; }
    .status.hashing { background: #dbeafe; color: #1d4ed8; }
    .status.uploading { background: #dcfce7; color: #166534; }
    .status.paused { background: #fef3c7; color: #92400e; }
    .status.merging { background: #ede9fe; color: #6d28d9; }
    .status.done { background: #d1fae5; color: #065f46; }
    .status.error { background: #fee2e2; color: #991b1b; }
    progress {
      width: 100%;
      height: 10px;
    }
    .progress-text {
      margin-top: 6px;
      font-size: 12px;
      color: #6b7280;
    }
    .actions {
      display: flex;
      gap: 8px;
      margin-top: 10px;
      flex-wrap: wrap;
    }
    table {
      width: 100%;
      border-collapse: collapse;
    }
    th, td {
      border-bottom: 1px solid #e5e7eb;
      padding: 10px 8px;
      text-align: left;
      font-size: 14px;
    }
    th { color: #6b7280; font-weight: 600; }
    a { color: #2563eb; text-decoration: none; }
    .empty {
      text-align: center;
      color: #9ca3af;
      padding: 24px 0;
    }
  </style>
</head>
<body>
  <div class="page">
    <h1>大文件断点续传</h1>
    <p class="desc">
      支持 100M~200M 大文件,最多同时选择 6 个文件上传。每个文件可单独暂停或继续,互不影响。
      网络中断或刷新页面后,重新选择同一文件即可从已上传分片处继续。
    </p>

    <div class="panel">
      <div class="toolbar">
        <label class="btn btn-secondary" for="fileInput">选择文件(最多 6 个)</label>
        <input id="fileInput" type="file" multiple />
        <button id="startBtn" class="btn btn-primary" disabled>确认上传</button>
        <button id="clearBtn" class="btn btn-secondary" disabled>清空列表</button>
      </div>
      <div class="hint">单次最多选择 6 个文件,分片大小 2MB,每个文件独立控制暂停/继续。</div>
      <div id="uploadList" class="file-list"></div>
    </div>

    <div class="panel">
      <div class="toolbar">
        <h2 style="margin: 0; font-size: 18px;">已上传文件</h2>
        <button id="refreshBtn" class="btn btn-secondary">刷新</button>
      </div>
      <table>
        <thead>
          <tr>
            <th>文件名</th>
            <th>大小</th>
            <th>上传时间</th>
            <th>操作</th>
          </tr>
        </thead>
        <tbody id="serverFileList">
          <tr><td colspan="4" class="empty">暂无文件</td></tr>
        </tbody>
      </table>
    </div>
  </div>

  <script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
  <script>
    const API_BASE = 'http://localhost:3011/api';
    const CHUNK_SIZE = 2 * 1024 * 1024;
    const MAX_FILES = 6;
    const CHUNK_CONCURRENCY = 3;
    const MAX_RETRIES = 3;

    const STATUS_TEXT = {
      pending: '待上传',
      hashing: '计算 Hash',
      uploading: '上传中',
      paused: '已暂停',
      merging: '合并中',
      done: '已完成',
      error: '失败',
    };

    const fileInput = document.getElementById('fileInput');
    const startBtn = document.getElementById('startBtn');
    const clearBtn = document.getElementById('clearBtn');
    const uploadList = document.getElementById('uploadList');
    const serverFileList = document.getElementById('serverFileList');
    const refreshBtn = document.getElementById('refreshBtn');

    /** @type {Map<string, UploadTask>} */
    const tasks = new Map();

    class UploadTask {
      constructor(file) {
        this.id = `${file.name}_${file.size}_${file.lastModified}`;
        this.file = file;
        this.hash = '';
        this.chunks = [];
        this.uploaded = new Set();
        this.status = 'pending';
        this.progress = 0;
        this.message = '';
        this.isPaused = false;
        this.activeControllers = new Set();
        this.element = null;
      }

      render() {
        if (!this.element) {
          this.element = document.createElement('div');
          this.element.className = 'file-item';
          this.element.dataset.id = this.id;
          uploadList.appendChild(this.element);
        }

        const canPause = this.status === 'uploading' || this.status === 'hashing';
        const canResume = this.status === 'paused' || this.status === 'error';
        const canRemove = ['pending', 'paused', 'done', 'error'].includes(this.status);

        this.element.innerHTML = `
          <div class="file-head">
            <div>
              <div class="file-name">${escapeHtml(this.file.name)}</div>
              <div class="file-meta">${formatSize(this.file.size)}${this.hash ? ` · Hash: ${this.hash.slice(0, 8)}...` : ''}</div>
            </div>
            <span class="status ${this.status}">${STATUS_TEXT[this.status] || this.status}</span>
          </div>
          <progress max="100" value="${this.progress}"></progress>
          <div class="progress-text">${this.progress.toFixed(1)}%${this.message ? ` · ${escapeHtml(this.message)}` : ''}</div>
          <div class="actions">
            <button class="btn btn-warning pause-btn" ${canPause ? '' : 'disabled'}>暂停</button>
            <button class="btn btn-success resume-btn" ${canResume ? '' : 'disabled'}>继续</button>
            <button class="btn btn-secondary remove-btn" ${canRemove ? '' : 'disabled'}>移除</button>
          </div>
        `;

        this.element.querySelector('.pause-btn').onclick = () => this.pause();
        this.element.querySelector('.resume-btn').onclick = () => this.resume();
        this.element.querySelector('.remove-btn').onclick = () => removeTask(this.id);
      }

      update(status, extra = {}) {
        if (status) this.status = status;
        if (extra.progress !== undefined) this.progress = extra.progress;
        if (extra.message !== undefined) this.message = extra.message;
        this.render();
        updateToolbar();
      }

      pause() {
        if (!['uploading', 'hashing'].includes(this.status)) return;
        this.isPaused = true;
        this.activeControllers.forEach((controller) => controller.abort());
        this.activeControllers.clear();
        this.update('paused', { message: '已暂停,可随时继续' });
      }

      async resume() {
        if (!['paused', 'error', 'pending'].includes(this.status)) return;
        this.isPaused = false;
        await this.start();
      }

      async start() {
        try {
          this.isPaused = false;
          this.update('hashing', { message: '正在计算文件指纹' });

          if (!this.hash) {
            this.hash = await calculateHash(this.file, () => this.isPaused);
            if (this.isPaused) return;
          }

          this.chunks = createChunks(this.file, this.hash);
          const uploadedRes = await fetch(`${API_BASE}/upload/uploaded?hash=${this.hash}`);
          const uploadedData = await uploadedRes.json();
          uploadedData.uploaded.forEach((index) => this.uploaded.add(index));
          this.updateProgress();

          const remaining = this.chunks.filter((item) => !this.uploaded.has(item.index));
          if (remaining.length === 0) {
            await this.merge();
            return;
          }

          this.update('uploading', { message: `剩余 ${remaining.length} 个分片` });
          await this.uploadChunks(remaining);
          if (!this.isPaused && this.status !== 'error') {
            await this.merge();
          }
        } catch (err) {
          if (err.name === 'AbortError') return;
          this.update('error', { message: err.message || '上传失败' });
        }
      }

      updateProgress() {
        const percent = this.chunks.length
          ? (this.uploaded.size / this.chunks.length) * 100
          : 0;
        this.progress = percent;
        this.render();
      }

      async uploadChunks(queue) {
        const items = queue.map((chunk) => ({ ...chunk, retries: 0 }));
        let pointer = 0;

        const worker = async () => {
          while (pointer < items.length && !this.isPaused) {
            const current = pointer++;
            const chunkItem = items[current];
            const ok = await this.uploadOne(chunkItem);
            if (ok) {
              this.uploaded.add(chunkItem.index);
              this.updateProgress();
              this.message = `已上传 ${this.uploaded.size}/${this.chunks.length} 个分片`;
              this.render();
            }
          }
        };

        await Promise.all(Array.from({ length: CHUNK_CONCURRENCY }, worker));
      }

      async uploadOne(chunkItem) {
        const { chunk, hash, index, totalChunks } = chunkItem;
        const formData = new FormData();
        formData.append('chunk', chunk);
        formData.append('hash', hash);
        formData.append('index', index);
        formData.append('totalChunks', totalChunks);

        for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
          if (this.isPaused) return false;

          const controller = new AbortController();
          this.activeControllers.add(controller);

          try {
            const res = await fetch(`${API_BASE}/upload/chunk`, {
              method: 'POST',
              body: formData,
              signal: controller.signal,
            });
            this.activeControllers.delete(controller);

            if (!res.ok) {
              const data = await res.json().catch(() => ({}));
              throw new Error(data.error || `分片 ${index} 上传失败`);
            }
            return true;
          } catch (err) {
            this.activeControllers.delete(controller);
            if (err.name === 'AbortError' || this.isPaused) return false;
            if (attempt === MAX_RETRIES) {
              this.update('error', { message: `分片 ${index} 上传失败` });
              return false;
            }
            await sleep(Math.pow(2, attempt + 1) * 1000);
          }
        }
        return false;
      }

      async merge() {
        this.update('merging', { message: '正在合并文件' });
        const res = await fetch(`${API_BASE}/upload/merge`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            hash: this.hash,
            totalChunks: this.chunks.length,
            filename: this.file.name,
          }),
        });
        const data = await res.json();
        if (!res.ok) throw new Error(data.error || '合并失败');
        this.update('done', { progress: 100, message: '上传完成' });
        refreshServerFiles();
      }
    }

    function escapeHtml(text) {
      return String(text)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;');
    }

    function formatSize(size) {
      if (size < 1024) return `${size} B`;
      if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`;
      if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(2)} MB`;
      return `${(size / (1024 * 1024 * 1024)).toFixed(2)} GB`;
    }

    function sleep(ms) {
      return new Promise((resolve) => setTimeout(resolve, ms));
    }

    function calculateHash(file, shouldStop) {
      return new Promise((resolve, reject) => {
        const chunkSize = 2 * 1024 * 1024;
        const total = Math.ceil(file.size / chunkSize);
        let current = 0;
        const spark = new SparkMD5.ArrayBuffer();
        const reader = new FileReader();

        reader.onload = (e) => {
          if (shouldStop && shouldStop()) return;
          spark.append(e.target.result);
          current += 1;
          if (current < total) {
            loadNext();
          } else {
            resolve(spark.end());
          }
        };
        reader.onerror = () => reject(new Error('Hash 计算失败'));

        function loadNext() {
          if (shouldStop && shouldStop()) return;
          const start = current * chunkSize;
          const end = Math.min(start + chunkSize, file.size);
          reader.readAsArrayBuffer(file.slice(start, end));
        }

        loadNext();
      });
    }

    function createChunks(file, hash) {
      const total = Math.ceil(file.size / CHUNK_SIZE);
      const list = [];
      for (let i = 0; i < total; i++) {
        const start = i * CHUNK_SIZE;
        const end = Math.min(start + CHUNK_SIZE, file.size);
        list.push({
          chunk: file.slice(start, end),
          hash,
          index: i,
          totalChunks: total,
        });
      }
      return list;
    }

    function updateToolbar() {
      const hasTasks = tasks.size > 0;
      const hasPending = [...tasks.values()].some((task) => ['pending', 'paused', 'error'].includes(task.status));
      const allFinished = hasTasks && [...tasks.values()].every((task) => ['done', 'error'].includes(task.status));

      startBtn.disabled = !hasPending;
      clearBtn.disabled = !hasTasks || [...tasks.values()].some((task) => ['uploading', 'hashing', 'merging'].includes(task.status));
      fileInput.disabled = tasks.size >= MAX_FILES && !allFinished;
    }

    function removeTask(id) {
      const task = tasks.get(id);
      if (!task) return;
      if (['uploading', 'hashing', 'merging'].includes(task.status)) return;
      task.element?.remove();
      tasks.delete(id);
      if (tasks.size === 0) uploadList.innerHTML = '';
      updateToolbar();
    }

    fileInput.addEventListener('change', (event) => {
      const selected = Array.from(event.target.files || []);
      if (!selected.length) return;

      const available = MAX_FILES - tasks.size;
      if (available <= 0) {
        alert('最多只能同时处理 6 个文件');
        fileInput.value = '';
        return;
      }

      const filesToAdd = selected.slice(0, available);
      if (selected.length > available) {
        alert(`最多还能添加 ${available} 个文件,已自动截取前 ${available} 个`);
      }

      filesToAdd.forEach((file) => {
        const task = new UploadTask(file);
        tasks.set(task.id, task);
        task.render();
      });

      fileInput.value = '';
      updateToolbar();
    });

    startBtn.addEventListener('click', async () => {
      const pendingTasks = [...tasks.values()].filter((task) => ['pending', 'paused', 'error'].includes(task.status));
      await Promise.all(pendingTasks.map((task) => task.start()));
    });

    clearBtn.addEventListener('click', () => {
      const busy = [...tasks.values()].some((task) => ['uploading', 'hashing', 'merging'].includes(task.status));
      if (busy) {
        alert('仍有文件正在上传,请先暂停后再清空');
        return;
      }
      tasks.clear();
      uploadList.innerHTML = '';
      updateToolbar();
    });

    async function refreshServerFiles() {
      try {
        const res = await fetch(`${API_BASE}/files`);
        const files = await res.json();
        if (!files.length) {
          serverFileList.innerHTML = '<tr><td colspan="4" class="empty">暂无文件</td></tr>';
          return;
        }

        serverFileList.innerHTML = files
          .map((file) => {
            const time = new Date(file.mtime).toLocaleString();
            const url = `${API_BASE}/download/${encodeURIComponent(file.name)}`;
            return `
              <tr>
                <td>${escapeHtml(file.name)}</td>
                <td>${formatSize(file.size)}</td>
                <td>${time}</td>
                <td><a href="${url}" target="_blank">下载</a></td>
              </tr>
            `;
          })
          .join('');
      } catch (err) {
        serverFileList.innerHTML = `<tr><td colspan="4" class="empty">加载失败:${escapeHtml(err.message)}</td></tr>`;
      }
    }

    refreshBtn.addEventListener('click', refreshServerFiles);
    refreshServerFiles();
  </script>
</body>
</html>

后端node-express,require的库自行引入

bash 复制代码
const express = require('express');
const multer = require('multer');
const fs = require('fs-extra');
const path = require('path');
const cors = require('cors');

const app = express();
const PORT = 3011;
const UPLOAD_DIR = path.resolve(__dirname, 'uploads');
const CHUNK_DIR = path.resolve(__dirname, 'chunks');

fs.ensureDirSync(UPLOAD_DIR);
fs.ensureDirSync(CHUNK_DIR);

app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static(__dirname));

const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });

// 上传单个分片
app.post('/api/upload/chunk', upload.single('chunk'), async (req, res) => {
  try {
    const { hash, index } = req.body;
    const chunk = req.file;

    if (!hash || index === undefined || !chunk) {
      return res.status(400).json({ error: '缺少 hash、index 或 chunk' });
    }

    const dir = path.join(CHUNK_DIR, hash);
    await fs.ensureDir(dir);
    await fs.writeFile(path.join(dir, String(index)), chunk.buffer);

    res.json({ message: '分片上传成功', index: Number(index) });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// 查询已上传的分片
app.get('/api/upload/uploaded', async (req, res) => {
  try {
    const { hash } = req.query;
    if (!hash) {
      return res.status(400).json({ error: '缺少 hash' });
    }

    const dir = path.join(CHUNK_DIR, hash);
    if (!(await fs.pathExists(dir))) {
      return res.json({ uploaded: [] });
    }

    const files = await fs.readdir(dir);
    const uploaded = files.map((name) => Number(name)).filter((n) => !Number.isNaN(n)).sort((a, b) => a - b);
    res.json({ uploaded });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// 合并分片
app.post('/api/upload/merge', async (req, res) => {
  const { hash, totalChunks, filename } = req.body;

  if (!hash || !totalChunks || !filename) {
    return res.status(400).json({ error: '缺少 hash、totalChunks 或 filename' });
  }

  const safeName = path.basename(filename);
  const chunkDir = path.join(CHUNK_DIR, hash);
  const outputPath = path.join(UPLOAD_DIR, safeName);

  try {
    if (!(await fs.pathExists(chunkDir))) {
      return res.status(400).json({ error: '分片目录不存在' });
    }

    const chunkNames = await fs.readdir(chunkDir);
    if (chunkNames.length !== Number(totalChunks)) {
      return res.status(400).json({
        error: `分片数量不匹配,期望 ${totalChunks},实际 ${chunkNames.length}`,
      });
    }

    chunkNames.sort((a, b) => Number(a) - Number(b));

    const writeStream = fs.createWriteStream(outputPath);
    for (const name of chunkNames) {
      const data = await fs.readFile(path.join(chunkDir, name));
      if (!writeStream.write(data)) {
        await new Promise((resolve) => writeStream.once('drain', resolve));
      }
      await fs.remove(path.join(chunkDir, name));
    }

    writeStream.end();
    await new Promise((resolve, reject) => {
      writeStream.on('finish', resolve);
      writeStream.on('error', reject);
    });

    await fs.remove(chunkDir);
    res.json({ message: '文件合并成功', filename: safeName });
  } catch (err) {
    await fs.remove(outputPath).catch(() => {});
    res.status(500).json({ error: err.message });
  }
});

// 已上传文件列表
app.get('/api/files', async (req, res) => {
  try {
    const items = await fs.readdir(UPLOAD_DIR);
    const files = [];

    for (const name of items) {
      const fullPath = path.join(UPLOAD_DIR, name);
      const stat = await fs.stat(fullPath);
      if (stat.isFile()) {
        files.push({ name, size: stat.size, mtime: stat.mtime });
      }
    }

    res.json(files);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// 下载文件
app.get('/api/download/:filename', async (req, res) => {
  const safeName = path.basename(req.params.filename);
  const filepath = path.join(UPLOAD_DIR, safeName);

  try {
    if (await fs.pathExists(filepath)) {
      res.download(filepath);
    } else {
      res.status(404).send('文件不存在');
    }
  } catch (err) {
    res.status(500).send(err.message);
  }
});

// 删除未完成的分片任务
app.delete('/api/upload/chunks/:hash', async (req, res) => {
  const dir = path.join(CHUNK_DIR, req.params.hash);
  try {
    if (await fs.pathExists(dir)) {
      await fs.remove(dir);
      res.json({ message: '已删除' });
    } else {
      res.status(404).json({ error: '分片目录不存在' });
    }
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.listen(PORT, () => {
  console.log(`断点续传服务已启动: http://localhost:${PORT}`);
  console.log(`打开页面: http://localhost:${PORT}/index.html`);
});

对于100MB、200MB大小的文件其实上传还是很快的,网络一般不会很差,如果要测试就修改浏览器的网速,那什么情况可能会用到大文件上传这种,而且也有些可以直接使用的库

比如:

✅ 视频相关系统(99% 要用):短视频后台、在线教育(老师传视频)、直播素材上传

媒体 / 影视公司

✅ 企业网盘、云盘:钉钉、企业微信、私有云盘,没有断点续传根本没法用。

✅ 工程 / 设计 / 制造业:CAD 图纸、3D 模型、超大设计文件、BIM 模型

✅ 医疗系统:CT、X 光 影像(DICOM 文件非常大)

✅ 大数据 / AI 平台:数据集上传、模型文件

✅ 备份 / 日志系统:服务器备份包、系统日志

✅ 政府 / 事业单位系统:网络差、文件大、必须稳定。

6、那下载呢,下载如果网络不好可不可以断点续传呢,完全可以,而且更简单!

断点下载 = 下载中途断了、关了、断网了,下次继续从断掉的位置下,不用从头来。

比如:

下 10GB 文件,下到 90% 断网,普通下载 → 从头下,断点下载 → 从 90% 继续

断点下载不是前端库实现的,而是 HTTP 协议原生支持。

只要后端返回一个响应头:

Accept-Ranges: bytes

Nginx / Apache / Java / Python / Node.js

只要开启文件流式传输 + Accept-Ranges 头,默认就支持断点下载。

几乎所有云存储(阿里云 OSS、腾讯云 COS、七牛)全都默认支持断点下载。

没有什么特殊情况前端不需要管,除非要做一个网页内的下载管理器(带暂停、继续、进度条)

相关推荐
程序员祥云9 小时前
VUE2_TO_VITE_VUE3
javascript·vue.js·ecmascript
苏瞳儿9 小时前
vue3+pinia+mqtt实时响应连接
前端·javascript·vue.js
i220818 Faiz Ul10 小时前
理财系统|基于java+vue的家庭理财系统小程序(源码+数据库+文档)
java·vue.js·spring boot·小程序·论文·毕设·理财系统
暗冰ཏོ10 小时前
《2026 Vue2 + Vue3 完整学习指南:基础语法、路由缓存、登录拦截、项目实战与面试题》
前端·vue.js·vue·vue3·vue2
蜡台10 小时前
VUE 侧边按钮组,可自定义位置
前端·javascript·css
AI科技星10 小时前
维度原本——基于超复数谱系的全域维度统一理论
c语言·前端·javascript·网络·electron
遇事不決洛必達10 小时前
【爬虫随笔】常见加密算法特征总结
javascript·爬虫·逆向·加密算法
kyriewen10 小时前
14MB VS 15KB:前React核心成员用AI写了个排版库,让Safari快了一千倍
前端·javascript·react.js
幸运小圣11 小时前
动态表格在 Vue 3 中的实现指南【前端】
前端·javascript·vue.js