返回技术资讯

十堰网站开发实战:前端大文件分片上传与断点续传完整实现(秒传、并发控制与失败重试)

十堰网站开发前端开发大文件上传分片上传断点续传JavaScript十堰网站建设

一、一个真实的上传失败场景

去年十堰一家装备制造企业找到我们,说他们的官网后台“传图纸老是失败”。具体情况是:工艺部门要把 300MB 到 600MB 的 CAD 图纸和质检视频传给外地客户,用的是我们自己开发的文件管理模块。测试时在办公室里传 20MB 的 PDF 一切正常,一到真实场景就出问题——客户在车间用手机热点打开页面,传到 60% 左右网络抖一下,进度条直接归零,只能从头再来;有时候浏览器直接报 413,连错误提示都没有。

这类问题在十堰的工业品、汽车零部件、工程施工类企业里非常普遍。企业上传的不是几张产品图,而是图纸、投标文件、验收视频、监控录像,单个文件动辄几百兆。传统的一次性 POST 上传在这种文件体积和本地网络条件下几乎必然失败:请求体太大容易被网关拦截,上传时间太长容易超时,中途断网就前功尽弃。解决办法不是换服务器,而是把上传改成分片 + 断点续传。本文把我们在十堰网站开发项目中实际使用的完整前端实现拆开讲,代码可以直接拿去改。

二、整体方案:五步走完一次上传

分片上传的核心思想很朴素:把一个 500MB 的文件切成 100 个 5MB 的小块,逐块上传,服务端最后按顺序拼回去。这样一来单次请求体积可控、失败只影响一个小块、并发上传还能跑满带宽。完整流程分为五步:

  1. 计算文件指纹:根据文件内容算出一个 hash,作为这次上传的唯一标识。
  2. 秒传检查:把 hash 发给服务端,如果服务端已经有这个文件,直接返回访问地址,一个字节都不用传。
  3. 查询已上传分片:服务端返回哪些分片序号已经存在,前端只补传缺失的部分,这就是断点续传。
  4. 并发上传缺失分片:用并发池控制同时进行的请求数,单块失败按指数退避重试。
  5. 通知服务端合并:全部分片到位后调用合并接口,服务端按序号拼接、校验、落盘,返回最终文件地址。

这套流程不只解决“传得上去”的问题,还顺带解决了重复上传的带宽浪费。十堰一家企业同一份投标文件经常要发给五个部门,秒传让后四次上传的耗时从十几分钟变成不到一秒。

三、第一步:文件指纹与秒传

3.1 为什么不能直接对整个文件做 MD5

常见教程会让你用 SparkMD5 把整个文件读进内存算 MD5。文件小的时候没问题,500MB 的文件在低配办公电脑上会把主线程卡死十几秒,内存也可能直接爆掉。正确做法是分片增量计算:用 FileReader 逐片读取、逐片 append,内存占用只有一个分片的大小。

import SparkMD5 from "spark-md5";

async function calcFileHash(file, chunkSize = 5 * 1024 * 1024) {
  const spark = new SparkMD5.ArrayBuffer();
  const total = Math.ceil(file.size / chunkSize);
  for (let i = 0; i < total; i++) {
    const blob = file.slice(i * chunkSize, (i + 1) * chunkSize);
    spark.append(await blob.arrayBuffer());
  }
  return spark.end();
}

即便如此,500MB 文件全量哈希仍然要 3 到 5 秒。如果业务允许,可以用抽样哈希进一步提速:取文件头 2MB、尾 2MB 加上文件大小拼起来算 hash。碰撞概率在业务上可以接受,因为秒传只是加速手段,最终完整性还有服务端校验兜底。要注意的是,抽样哈希只能用于“判断是否已存在”,不能当作文件的唯一凭证写入数据库。

3.2 秒传的服务端约定

秒传依赖服务端按 hash 建立索引,并且做好引用计数:同一个文件被十个业务引用时,物理上只存一份;删除时要等引用计数归零再清理,否则会误删别人还在用的文件。这一点在文件管理类项目里非常关键,我们见过因为直接按 hash 删除导致历史附件全部 404 的事故。

四、第二步:分片、并发与进度

4.1 分片大小的取舍

分片不是越小越好。1MB 分片意味着 500MB 文件要发 500 个请求,HTTP 头和鉴权开销会把收益吃光;分片太大又会让单次失败的重传成本变高。我们的经验值是 2MB 到 5MB,移动端弱网环境取 2MB,内网或专线取 5MB。分片大小一旦确定就不要随意改,否则历史断点记录会对不上。

const CHUNK_SIZE = 5 * 1024 * 1024;

function createChunks(file) {
  const chunks = [];
  let index = 0;
  for (let start = 0; start < file.size; start += CHUNK_SIZE) {
    chunks.push({
      index,
      blob: file.slice(start, Math.min(start + CHUNK_SIZE, file.size)),
    });
    index++;
  }
  return chunks;
}

注意 file.slice 返回的是 Blob 视图,不会真的复制数据,所以切 500MB 文件是毫秒级的,不用担心内存。

4.2 并发池:同时只发 3 到 4 个请求

一次性把 100 个分片全部 Promise.all 发出去,浏览器会排队、服务端会被打满,反而更慢。用一个简单的并发池,保持 3 到 4 个请求在飞即可:

async function runPool(tasks, limit = 4) {
  let cursor = 0;
  const workers = Array.from(
    { length: Math.min(limit, tasks.length) },
    async () => {
      while (cursor < tasks.length) {
        const i = cursor++;
        await tasks[i]();
      }
    }
  );
  await Promise.all(workers);
}

这里的 cursor++ 依赖 JavaScript 单线程特性,不会出现两个 worker 抢到同一个下标的竞态,可以放心使用。

4.3 进度条为什么用 XMLHttpRequest

fetch 至今没有稳定的上传进度事件,上传大文件必须用 XMLHttpRequest 的 upload.onprogress。真实项目里进度条要按已上传字节数 / 文件总字节数计算,而不是按“完成的分片数”,否则进度会一跳一跳,用户以为卡死了。

let uploadedBytes = 0;

function uploadChunk({ hash, total, chunk, onProgress }) {
  return new Promise((resolve, reject) => {
    const form = new FormData();
    form.append("hash", hash);
    form.append("index", chunk.index);
    form.append("total", total);
    form.append("chunk", chunk.blob, `${hash}-${chunk.index}`);

    const xhr = new XMLHttpRequest();
    xhr.open("POST", "/api/upload/chunk");
    xhr.setRequestHeader("Authorization", `Bearer ${getToken()}`);

    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) onProgress(chunk.index, e.loaded);
    };
    xhr.onload = () => {
      if (xhr.status === 200) resolve(JSON.parse(xhr.responseText));
      else reject(new Error(`HTTP ${xhr.status}`));
    };
    xhr.onerror = () => reject(new Error("network error"));
    xhr.ontimeout = () => reject(new Error("timeout"));
    xhr.timeout = 60_000;
    xhr.send(form);
  });
}

4.4 失败重试:指数退避,别死循环

弱网环境下偶发的失败非常正常,关键是重试策略要克制。固定间隔狂重试会把本来就拥塞的网络打得更死,我们用 500ms 起步、每次翻倍的指数退避,最多重试 3 次:

async function withRetry(fn, retries = 3) {
  let delay = 500;
  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));
      delay *= 2; // 500 → 1000 → 2000
    }
  }
}

重试仍失败的分片要记录下来,在界面上明确告诉用户“已上传 87 个分片,3 个失败,点击继续”,而不是弹一个“上传失败”然后清空进度。用户关掉页面再回来,凭 hash 查询已上传分片就能接着传,这才是真正的断点续传。

五、后端接口设计与合并

前端做得再好,接口约定不清楚一样会翻车。我们通常定四个接口:

  • GET /api/upload/status?hash=xxx:返回 { exist: true, url } 或 { exist: false, uploaded: [0,1,2,5] }。
  • POST /api/upload/chunk:接收 hash、index、total 和分片文件,分片落在临时目录 /data/tmp/{hash}/{index}。
  • POST /api/upload/merge:接收 hash、filename、total,按 index 顺序流式拼接。
  • DELETE /api/upload/{hash}:用户取消上传时清理临时分片,避免磁盘被垃圾占满。

合并时不要用 fs.readFile 把分片全部读进内存再拼接,500MB 文件会直接把 Node 进程打挂。正确做法是创建写入流,逐个分片 pipe 进去,最后用 crypto.createHash("sha256") 边写边校验。校验不通过就删除合并结果并返回错误,让前端重传——这一步是防止“文件传上去了但打不开”的最后防线,我们确实遇到过因为分片乱序导致 CAD 图纸损坏的案例。

六、部署环节最容易踩的三个坑

6.1 Nginx 默认 1MB 限制

这是 413 错误的头号原因。Nginx 的 client_max_body_size 默认只有 1MB,5MB 的分片必然被拒。在 server 或 location 块里放开:

location /api/upload/ {
    client_max_body_size 8m;
    proxy_request_buffering off;
    proxy_read_timeout 300s;
    proxy_send_timeout 300s;
    proxy_pass http://127.0.0.1:3000;
}

proxy_request_buffering off 让 Nginx 边收边转发,避免先把整个分片缓冲到磁盘再转发,能明显降低大文件上传的内存和磁盘压力。

6.2 临时目录和磁盘水位

分片目录要单独挂盘,并且设一个定时清理任务,把超过 24 小时没有合并的临时分片删掉。十堰一家客户就因为用户反复取消上传,临时目录堆积了 200 多 GB,最后把系统盘写满,网站直接 502。监控上至少要对临时目录做 80% 水位告警。

6.3 鉴权与文件类型校验

分片接口同样要校验登录态和权限,不能因为“只是传个分片”就放行。文件名要做白名单过滤,禁止 ../ 路径穿越;合并后的文件类型要以服务端检测为准,不能相信前端传来的 MIME。涉及图纸、合同这类敏感文件,还应该加病毒扫描和访问签名,链接有效期控制在 15 分钟以内。

七、真实数据:改造前后的对比

回到开头那家十堰装备制造企业。改造前:单文件上限 200MB,500MB 图纸只能压缩或拆分,弱网下上传成功率约 62%,一次成功上传平均耗时 18 分钟,失败后需要从头再来。改造后:

  • 上传成功率从 62% 提升到 98.7%(失败集中在客户主动取消);
  • 500MB 文件在办公网下平均耗时 6 分 40 秒,4 台并发、每片 5MB;
  • 断网恢复后只需补传缺失分片,实测一次中断只重传了 3 个分片、约 15MB;
  • 同一份文件第二次上传触发秒传,耗时 0.8 秒;
  • 客服关于“传不上去”的工单从每月 11 条降到 0 条。

这些数字不是实验室跑出来的,是生产环境两周的统计。对十堰本地的制造、工程类企业来说,上传体验直接影响到客户对你信息化水平的判断,值得认真做一次。

八、上线前检查清单

  1. 分片大小与后端限制一致,Nginx client_max_body_size 已放开。
  2. hash 计算在 Web Worker 或分片增量模式下进行,主线程不卡顿。
  3. 断点记录能跨页面刷新恢复(存 localStorage 或服务端查询)。
  4. 并发数可配置,弱网自动降到 2。
  5. 失败分片有明确提示和“继续上传”入口,不丢进度。
  6. 合并接口有顺序校验和 SHA-256 完整性校验。
  7. 临时分片有定时清理,磁盘水位有告警。
  8. 上传接口有鉴权、限流和文件类型白名单。

九、结语

大文件上传看起来是个“前端小功能”,实际牵扯到浏览器 API、并发控制、网络重试、后端流式处理、Nginx 配置和磁盘运维,是一条完整的工程链路。十堰网站开发团队在给本地企业做文件管理、招投标、图纸流转这类系统时,把这条链路做扎实,比在页面上多加几个动画更能体现专业度。如果你正在为上传超时、413、断点丢失这些问题头疼,可以按本文的顺序逐项排查,大部分问题在半天内就能定位。

十堰易度网络传媒有限公司在企业网站、管理后台和小程序开发中持续沉淀这类工程实践,后续我们也会把文件存储、权限体系和访问审计的落地经验整理成系列文章,供十堰本地的开发同行参考。

准备好开始您的项目了吗?

无论是软件开发、网站建设还是APP定制,我们都能为您提供专业解决方案