在网盘、视频平台、医疗影像系统等场景中,动辄几百MB甚至几个GB的文件上传几乎是绕不开的需求。如果采用传统的整文件一次性上传,一旦网络波动导致请求失败,整个文件就得重新来过,用户体验极差。断点续传的思路是把大文件切成若干个小分片,逐个上传,每个分片上传成功后由服务端记录下来,这样即使中途断网或用户主动暂停,下次也能从上次中断的位置继续,而不必重头开始。本文将以React为前端技术栈,完整实现一套包含分片上传、进度展示、暂停与继续功能的方案。

分片上传的核心原理与文件切片
分片上传的第一步是把文件对象切成小块。浏览器提供的Blob.prototype.slice方法可以很方便地从一个File或Blob对象中截取指定范围的数据,返回的仍然是Blob对象,可以直接作为请求体发送。切片时需要确定一个合适的分片大小,通常建议设置为2MB到10MB之间:分片太小会导致请求次数过多、HTTP头部开销占比增大;分片太大则失败重传的代价变高,也失去了断点续传的精细粒度。
下面是基础的切片逻辑,将文件按照固定大小切割并生成带编号的分片数组:
const CHUNK_SIZE = 5 * 1024 * 1024; // 每个分片 5MB
function createFileChunks(file) {
const chunks = [];
let cur = 0;
let index = 0;
while (cur < file.size) {
chunks.push({
chunk: file.slice(cur, cur + CHUNK_SIZE),
index: index++,
});
cur += CHUNK_SIZE;
}
return chunks;
}
切片完成后,每个分片需要携带文件标识、分片序号、分片总数等信息,服务端才能正确地把它们重新拼装起来。文件标识不能简单使用文件名,因为不同用户可能上传同名但内容不同的文件,稳妥的做法是对文件内容计算hash。可以使用spark-md5这个库,它基于增量计算,可以边读取分片边更新hash值,避免一次性把整个大文件读入内存导致页面卡死。如果文件特别大,还应该把hash计算放到Web Worker中执行,保证主线程的流畅。
秒传与续传判断:上传前的接口设计
在真正开始上传之前,前端需要先调用一个校验接口,把文件的hash值发给服务端。服务端根据这个hash检查两件事:一是是否已经存在完整文件,如果存在,说明该文件之前被上传过,直接返回成功,前端根本不需要传输任何数据,这就是所谓的秒传;二是如果完整文件不存在,返回已经上传成功的分片编号列表,前端拿到这个列表后,把对应的分片过滤掉,只上传缺失的部分,这就实现了续传。
前端请求校验接口的示例代码如下:
async function verifyUpload(fileName, fileHash) {
const res = await fetch('/api/upload/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName, fileHash }),
});
const data = await res.json();
return data; // { shouldUpload: true, uploadedChunks: [0, 2, 3] }
}
这个接口的返回结构很简单,shouldUpload表示是否需要上传,uploadedChunks是服务端已存在的分片索引数组。如果shouldUpload为false,前端直接把进度置为百分之百即可。这种设计的价值在于,无论用户是刷新页面、关闭浏览器还是切换网络,只要文件hash不变,上传进度就相当于被永久保存在服务端了,这比把进度存在localStorage里可靠得多。
并发控制与暂停继续功能的实现
如果分片数量很多,一次性把所有请求发出去会瞬间打满浏览器对同一域名的并发连接数限制(HTTP/1.1下通常是6个),还会给服务端带来瞬时压力。所以需要一个并发控制模块,维护一个请求池,始终保持固定数量的请求在跑,某个请求完成后自动从队列中取出下一个补充进来。
更关键的是暂停与继续。实现思路是维护一个可变的控制状态,比如用一个paused标志位配合AbortController。每个分片请求都绑定一个独立的AbortController,当用户点击暂停时,把当前正在进行的请求全部abort掉,并停止从队列中取新任务;点击继续时,重新校验一遍服务端已上传的分片,再把剩余分片重新加入队列执行。下面的React Hook完整展示了这套逻辑:
import { useRef, useState, useCallback } from 'react';
export function useChunkUpload() {
const [progress, setProgress] = useState(0);
const controllersRef = useRef([]);
const pausedRef = useRef(false);
const uploadChunks = useCallback(async (chunks, fileHash, uploadedSet) => {
const list = chunks.filter(c => !uploadedSet.has(c.index));
let done = uploadedSet.size;
const runOne = async (chunk) => {
if (pausedRef.current) return;
const controller = new AbortController();
controllersRef.current.push(controller);
const form = new FormData();
form.append('chunk', chunk.chunk);
form.append('index', chunk.index);
form.append('fileHash', fileHash);
await fetch('/api/upload/chunk', {
method: 'POST',
body: form,
signal: controller.signal,
});
done += 1;
setProgress(Math.round((done / chunks.length) * 100));
};
// 简单的并发池实现,最多同时4个请求
const pool = [];
const taskQueue = [...list];
while (taskQueue.length || pool.length) {
while (pool.length < 4 && taskQueue.length) {
const task = runOne(taskQueue.shift())
.finally(() => pool.splice(pool.indexOf(task), 1));
pool.push(task);
}
await Promise.race(pool);
}
}, []);
const pause = useCallback(() => {
pausedRef.current = true;
controllersRef.current.forEach(c => c.abort());
controllersRef.current = [];
}, []);
const resume = useCallback(() => {
pausedRef.current = false;
}, []);
return { progress, uploadChunks, pause, resume };
}
注意几个细节:进度条的计算要基于服务端已确认的分片数,而不是本地发起的请求数,否则暂停后再继续,进度会出现回退或跳变。abort被触发的请求会抛出AbortError,在runOne里可以捕获这个异常并静默处理,避免它污染并发池的race逻辑。另外,暂停后恢复时强烈建议重新调用verify接口,因为暂停期间可能有其他端在同一账号下传过同一个文件,重新校验可以避免重复上传已经存在的分片。
最后别忘了通知合并。所有分片上传完成后,前端再调用一个merge接口,把文件hash、文件名和分片大小告诉服务端,由服务端按序号把分片合并成完整文件。合并动作放在服务端做而不是前端,是因为前端无法直接操作服务器磁盘上的分片文件。至此整个断点续传闭环就完成了:切片、hash计算、秒传校验、并发上传、暂停继续、合并通知,每一步职责清晰,稍加改造就能直接应用到生产项目中。