大文件上传实战:前端分片、断点续传、秒传与并发控制怎么落地

做过图片站、网盘、素材库的前端多半都撞过同一堵墙:用户传一张几百 MB 的 RAW 大图,或者一段几个 G 的视频,一个 FormData 直接 POST 上去,要么请求超时,要么中途断网前功尽弃,要么内存直接被撑爆。

单请求整包上传的问题不是"慢",而是"脆"------它把一次网络往返变成了一个不能失败的原子操作。文件越大,失败概率越高,而失败的代价是从头再来。分片上传的核心思路很朴素:把大文件切成很多小块,每块独立上传、独立重试、可以并发、可以断点续跑,最后在服务端拼回去。

下面按"切片 → 秒传 → 断点续传 → 并发 → 进度 → 重试 → 合并"的顺序,把整条链路拆开讲,每一步都给能跑的代码。

一、用 File.slice 把文件切成分片

File 继承自 BlobBlob.prototype.slice 能在不读取内容的前提下拿到一段切片,几乎零成本------它只是记录了偏移量,真正的字节要等你 read 或上传时才会被读。

js 复制代码
// 把一个 File/Blob 按固定大小切成分片数组
// chunkSize 常见取值 2MB~5MB,太小请求数爆炸,太大失去分片意义
function createChunks(file, chunkSize = 5 * 1024 * 1024) {
  const chunks = [];
  let start = 0;
  let index = 0;
  while (start < file.size) {
    const end = Math.min(start + chunkSize, file.size);
    chunks.push({
      index,               // 分片序号,后端按它排序合并
      start,
      end,
      blob: file.slice(start, end), // 惰性切片,不占内存
    });
    start = end;
    index += 1;
  }
  return chunks;
}

分片大小没有标准答案,要结合平均文件体积和后端网关限制来定。5MB 是个稳妥的起点。

二、算文件 hash 做"秒传"

秒传的原理是:如果服务器上已经存在一份内容完全相同的文件,就没必要再传一遍。判断"内容相同"要靠内容指纹,也就是对整个文件算一个 hash(常用 SHA-256 或 MD5),先拿这个 hash 问后端"你有没有",有就直接秒过。

浏览器算 hash 的库一般用 spark-md5 或 hash-wasm。下面用增量的方式喂分片,避免一次性把整个大文件读进内存:

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

// 增量读取每个分片,边读边算,内存占用只跟单个分片相关
function calcFileHash(chunks) {
  return new Promise((resolve, reject) => {
    const spark = new SparkMD5.ArrayBuffer();
    let current = 0;

    const loadNext = () => {
      const reader = new FileReader();
      reader.onload = (e) => {
        spark.append(e.target.result); // 累加当前分片
        current += 1;
        if (current < chunks.length) {
          loadNext();
        } else {
          resolve(spark.end()); // 最终指纹
        }
      };
      reader.onerror = reject;
      reader.readAsArrayBuffer(chunks[current].blob);
    };
    loadNext();
  });
}

拿到 hash 后,先问后端:

js 复制代码
async function checkInstantUpload(hash, filename) {
  const res = await fetch('/api/upload/check', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ hash, filename }),
  });
  // uploaded: 服务端已有完整文件 -> 秒传成功
  // uploadedChunks: 已经传过的分片序号列表 -> 用于断点续传
  return res.json(); // { uploaded, uploadedChunks }
}

诚实说一句:对几个 G 的大文件,主线程上全量算 hash 会明显卡顿甚至卡死界面。后面第七节会讲怎么处理。

三、断点续传:只传没传过的分片

秒传的接口顺便把"已传过哪些分片"也返回了。断点续传就是拿这份清单做差集,只上传缺失的分片。这样即使上次传到一半断网,这次也能接着传。

js 复制代码
async function uploadFile(file) {
  const chunks = createChunks(file);
  const hash = await calcFileHash(chunks);

  const { uploaded, uploadedChunks = [] } = await checkInstantUpload(hash, file.name);
  if (uploaded) return { hash, instant: true }; // 秒传直接返回

  // 过滤掉服务端已确认收到的分片
  const done = new Set(uploadedChunks);
  const pending = chunks.filter((c) => !done.has(c.index));

  await uploadChunksWithConcurrency(pending, hash, file.name, chunks.length);
  await mergeChunks(hash, file.name, chunks.length); // 通知后端合并
  return { hash, instant: false };
}

这里"已传过哪些分片"的清单来自服务端,而不是前端 localStorage------服务端才是唯一可信来源,前端记录只能做辅助提示,不能当合并依据。

四、并发控制:别让几百个请求同时冲出去

一个大文件切完可能有几百个分片。如果 Promise.all 一次性全发出去,浏览器会卡在连接数上限,请求排队反而更慢,还可能触发后端限流。合理做法是维持一个固定大小的并发窗口,比如同时最多 4~6 个在传。

js 复制代码
// 简易并发池:始终保持最多 limit 个任务在跑
async function runWithLimit(tasks, limit = 4) {
  const results = [];
  const executing = new Set();

  for (const task of tasks) {
    const p = task().then((r) => {
      executing.delete(p);
      return r;
    });
    executing.add(p);
    results.push(p);

    // 池子满了就等任意一个先完成,再放新的进来
    if (executing.size >= limit) {
      await Promise.race(executing);
    }
  }
  return Promise.all(results);
}

上传单个分片本身也是个任务,用 FormData 带上 hash 和 index 让后端能对号入座:

js 复制代码
function uploadOneChunk(chunk, hash, filename, onProgress) {
  const form = new FormData();
  form.append('hash', hash);
  form.append('index', String(chunk.index));
  form.append('filename', filename);
  form.append('chunk', chunk.blob);

  return fetch('/api/upload/chunk', { method: 'POST', body: form })
    .then((res) => {
      if (!res.ok) throw new Error(`chunk ${chunk.index} failed`);
      onProgress?.(chunk.index);
      return chunk.index;
    });
}

五、进度合并:把每片进度汇成总进度

整体进度不是"传完几片"这么粗,用户想看到平滑的百分比。思路是记录每个分片各自的已传字节,再累加除以总大小。fetch 本身拿不到上传进度,要用 XMLHttpRequestupload.onprogress

js 复制代码
function uploadWithProgress(chunk, hash, filename, onChunkProgress) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    const form = new FormData();
    form.append('hash', hash);
    form.append('index', String(chunk.index));
    form.append('chunk', chunk.blob);

    // 单片上传进度回调,把已传字节报给上层
    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) onChunkProgress(chunk.index, e.loaded);
    };
    xhr.onload = () => (xhr.status < 300 ? resolve(chunk.index) : reject(new Error(String(xhr.status))));
    xhr.onerror = () => reject(new Error('network error'));
    xhr.open('POST', '/api/upload/chunk');
    xhr.send(form);
  });
}

上层维护一张 loadedMap,把各片字节加起来算总进度:

js 复制代码
const loadedMap = new Map(); // index -> 已传字节
function makeProgressHandler(totalSize, onTotal) {
  return (index, loaded) => {
    loadedMap.set(index, loaded);
    let sum = 0;
    for (const v of loadedMap.values()) sum += v;
    onTotal(Math.min(100, Math.round((sum / totalSize) * 100)));
  };
}

六、失败重试:分片级别的自动重试

分片上传最大的好处就是失败面被缩小了------一片失败只重传一片。给单片包一层带上限的重试,配合指数退避,能扛住大部分瞬时抖动。

js 复制代码
// 失败自动重试,最多 retries 次,每次等待翻倍
async function withRetry(fn, retries = 3, delay = 1000) {
  for (let i = 0; i <= retries; i++) {
    try {
      return await fn();
    } catch (err) {
      if (i === retries) throw err;       // 重试用尽,抛给上层
      await new Promise((r) => setTimeout(r, delay * 2 ** i));
    }
  }
}

把它套在上传任务外层,并发池里每个任务就都自带了重试能力。

七、诚实的工程边界

分片续传这套东西看着漂亮,落地时有几个坑必须提前知道:

  • 算 hash 会卡主线程。 对上 GB 的文件,全量 MD5/SHA 计算是 CPU 密集操作,放主线程会冻结 UI。正解是搬到 Web Worker 里算,或者用抽样 hash(只取头、尾和几个固定位置的字节算指纹)换速度,代价是极小概率误判需要后端二次校验。
  • 别把整个文件读进内存。 一定要用 slice + 增量读,逐片处理。一次性 readAsArrayBuffer 整个大文件,手机浏览器直接崩。
  • 服务端存储成本别忽略。 分片先落临时目录,合并后要清理;断点续传的半成品分片得有过期回收机制,否则磁盘会被没传完的垃圾撑满。
  • 合并的原子性。 后端按 index 顺序拼接时,要校验分片数量和总大小,合并成功再删临时片,避免拼出损坏文件。

后端合并的逻辑(伪代码示意)就是按序号排好、逐片追加、校验完整性:

js 复制代码
// Node 端合并示意:按 index 升序把分片流式追加到目标文件
const fs = require('fs');
const path = require('path');

async function mergeOnServer(hash, filename, total, chunkDir) {
  const target = fs.createWriteStream(path.resolve('/uploads', filename));
  for (let i = 0; i < total; i++) {
    const chunkPath = path.join(chunkDir, `${hash}-${i}`);
    if (!fs.existsSync(chunkPath)) throw new Error(`missing chunk ${i}`); // 完整性校验
    await new Promise((resolve, reject) => {
      const rs = fs.createReadStream(chunkPath);
      rs.on('end', resolve);
      rs.on('error', reject);
      rs.pipe(target, { end: false }); // 流式追加,内存友好
    });
    fs.unlinkSync(chunkPath); // 追加完就清理临时片
  }
  target.end();
}

八、上传前先给图片瘦身

还有个常被忽略的优化:很多"大图片"根本不需要原尺寸上传。一张手机拍的照片压到合理质量往往能砍掉一大半体积,分片数、上传时长、服务端存储全都跟着降。上传前顺手压一道,性价比极高。

在线压图/处理工具可以先收藏几个备用,需求不同各有取舍:squoosh.app 适合逐张手动调质量和格式,tinypng.com 对 PNG/JPG 批量压缩很省心,tudingai.cn 偏在线修图和格式处理,cloudconvert.com 走批量格式转换。前端接入自动压缩也可以用 canvas.toBlob 或 browser-image-compression,把压缩后的 Blob 再喂进上面的分片流程即可。

小结

分片上传不是一个单点技术,而是一套组合拳:slice 负责切,hash 负责秒传和去重,服务端清单负责断点续传,并发池负责吞吐,XHR 进度负责体验,重试负责稳定,后端合并负责收口。每一环单独看都不复杂,难的是把边界情况(卡顿、内存、脏分片、合并原子性)都照顾到。把这套骨架搭起来,再大的文件也只是"多切几刀"而已。

相关推荐
轻动琴弦2 小时前
VoltTool —— 一站式在线开发者工具平台,69+实用工具免费使用
前端·chrome
就叫飞六吧2 小时前
不同站点iframe嵌套种cookie、localstorege.set() 关系
前端
星栈2 小时前
LiveView 怎么测:从组件测试到端到端,我终于不再只敢手点页面了
前端·前端框架·elixir
蜡台2 小时前
uniapp vue2 转 vue3
前端·javascript·uni-app·vue3·vue2
Web4Browser2 小时前
Headless 浏览器自动化适合哪些任务?巡检、截图和提交动作要分级
运维·前端·自动化
用户059540174462 小时前
把前端内存泄漏排查从2小时压到5分钟,我们把它集成进了CI
前端·css
申君健24864182772023 小时前
Vapor 核心数据结构:Block原理
前端
Csvn3 小时前
工程化专题 | 改完组件保存,浏览器毫无反应,重启 dev server 又好了——气不气?
前端
我有满天星辰3 小时前
Vue 指令完全指南:从 Vue 2 到 Vue 3 的演进与实战
前端·javascript·vue.js