
一句话:
fetch没有原生上传进度(要用XMLHttpRequest或手动消费ReadableStream),取消请求用AbortController(XHR 用xhr.abort());大文件要分片上传(并发 + 断点续传 + 秒传)。
一、为什么上传进度需要 XHR
| 能力 | XMLHttpRequest | fetch |
| 上传进度 | ✅ `upload.onprogress` | ❌ 无原生(需手动读 ReadableStream) |
| 下载进度 | ✅ `onprogress` | ⚠️ 可通过 response.body 计算(但要自己算) |
| 取消 | `xhr.abort()` | `AbortController` |
| 超时 | `xhr.timeout` | 需 AbortController |
| 基于 Promise | ❌(要自己包装) | ✅ 原生 |
| 流式请求体 | ❌ | ⚠️(部分浏览器支持) |
⭐ 结论:
· 需要「上传进度」→ 用 XHR(axios 的 onUploadProgress 也是基于 XHR)
· 不需要进度 → fetch 更好(标准、简洁)
· 下载进度 → fetch + ReadableStream 也可以(但要自己算)
// ✅ fetch 计算下载进度(需要响应体是流)
async function downloadWithProgress(url, onProgress) {
const res = await fetch(url);
const total = Number(res.headers.get('content-length') ?? 0);
const reader = res.body!.getReader();
const chunks = [];
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
onProgress(total ? received / total : 0, received, total);
}
return new Blob(chunks);
}
// ⚠️ 需要服务端返回 Content-Length(没有的话无法算百分比)
// ⚠️ 需要支持 ReadableStream(现代浏览器)
二、XHR 上传进度
// utils/upload.ts
export interface UploadOptions {
onProgress?: (percent: number, loaded: number, total: number) => void;
signal?: AbortSignal;
headers?: Record<string, string>;
}
export function uploadFile(
url: string,
file: File | Blob | FormData,
{ onProgress, signal, headers }: UploadOptions = {}
): Promise<unknown> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
// ⚠️ 不要手动设置 Content-Type(FormData 需要浏览器加 boundary)
Object.entries(headers ?? {}).forEach(([k, v]) => xhr.setRequestHeader(k, v));
// ① 上传进度(关键)
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
onProgress?.(Math.round((e.loaded / e.total) * 100), e.loaded, e.total);
}
};
// ② 下载进度(响应体,可选)
xhr.onprogress = (e) => {
// 主要用于「下载」场景
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText));
} catch {
resolve(xhr.responseText);
}
} else {
reject(new Error(`HTTP ${xhr.status}: ${xhr.responseText.slice(0, 200)}`));
}
};
xhr.onerror = () => reject(new Error('网络错误'));
xhr.ontimeout = () => reject(new Error('上传超时'));
xhr.onabort = () => reject(new DOMException('Upload aborted', 'AbortError'));
// ③ 支持外部取消
signal?.addEventListener('abort', () => xhr.abort(), { once: true });
const body = file instanceof FormData ? file : toFormData(file);
xhr.send(body);
});
}
function toFormData(file: File | Blob): FormData {
const fd = new FormData();
fd.append('file', file, (file as File).name ?? 'blob');
return fd;
}
// ✅ 组件里的用法
function FileUploader() {
const [percent, setPercent] = useState(0);
const [status, setStatus] = useState<'idle' | 'uploading' | 'done' | 'error'>('idle');
const controllerRef = useRef<AbortController | null>(null);
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const controller = new AbortController();
controllerRef.current = controller;
setStatus('uploading');
setPercent(0);
try {
await uploadFile('/api/upload', file, {
onProgress: setPercent,
signal: controller.signal,
});
setStatus('done');
} catch (err) {
if ((err as Error).name === 'AbortError') {
setStatus('idle'); // ⭐ 主动取消 → 安静地回到初始态
return;
}
setStatus('error');
} finally {
controllerRef.current = null;
}
};
return (
<div>
<input type="file" onChange={handleChange} disabled={status === 'uploading'} />
{status === 'uploading' && (
<>
<progress value={percent} max={100} /> {/* ⭐ 进度条 */}
<span>{percent}%</span>
<button onClick={() => controllerRef.current?.abort()}>取消上传</button>
</>
)}
{status === 'done' && <p>上传完成</p>}
{status === 'error' && <p role="alert">上传失败,请重试</p>}
</div>
);
}
三、axios 的进度回调
// ✅ axios 简化了进度处理(内部基于 XHR)
const controller = new AbortController();
axios.post('/api/upload', formData, {
signal: controller.signal,
onUploadProgress: (e) => {
if (e.total) {
const percent = Math.round((e.loaded / e.total) * 100);
setPercent(percent);
}
},
onDownloadProgress: (e) => {
// 用于下载场景
},
});
// 取消
controller.abort();
⚠️ axios 的两个细节:
① `e.total` 可能为 undefined(服务端没返回 Content-Length)→ 要判空
② 进度会「先快后慢」——因为 `loaded` 反映的是「已发送到内核缓冲区」的字节数,
不代表「服务端已接收」(大文件时更明显)
需要「更真实的服务端进度」→ 用分片上传(每片返回后才能确认)
四、取消请求
// ✅ 统一用 AbortController(fetch / axios 新版 / XHR 包装都支持)
const controller = new AbortController();
// 传给请求
fetch(url, { signal: controller.signal });
axios.get(url, { signal: controller.signal });
// 取消
controller.abort();
// 判断「是不是取消导致的错误」
err.name === 'AbortError' // fetch / XHR 包装
axios.isCancel(err) || err.code === 'ERR_CANCELED' // axios
// ✅ 组件里的标准模式:在 useEffect 清理里取消
useEffect(() => {
const controller = new AbortController();
upload(file, { signal: controller.signal }).catch((err) => {
if (err.name === 'AbortError') return; // ⭐ 取消 → 不提示
setError(err.message);
});
return () => controller.abort(); // 依赖变化/卸载时取消
}, [file]);
⚠️ 取消的四个注意点:
① 取消会产生一个错误(AbortError)→ 必须【区分】它与真实错误(不要弹提示)
② 取消后「服务端可能仍在处理」(请求已发出)→ 重要操作要有「服务端幂等/补偿」
③ 上传取消后再重试,要「从头开始」(HTTP 层没有续传能力)→ 分片可以续传
④ 取消「不保证资源立刻释放」(浏览器会异步处理)
五、分片上传
为什么需要分片:
① 大文件一次上传容易超时/失败(一次失败就要全部重来)
② 无法做「断点续传」
③ 无法「并发上传」加速
④ 服务端也要一次性处理整个文件(内存压力)
分片上传的完整流程:
① 【前端切片】把文件按固定大小(如 5MB)切成 N 片
② 【计算哈希】对文件算一个唯一标识(如 md5 / 秒传用)
③ 【上传前询问】调「检查接口」问服务端「这个文件/这些片是否已存在」→ 秒传/续传
④ 【并发上传】把未上传的片并发请求(限流,如 3~5 并发)
⑤ 【合并】全部成功后调「合并接口」,服务端按顺序拼接
⑥ 【校验】服务端返回最终文件地址
// utils/chunkUpload.ts
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
interface ChunkUploadOptions {
file: File;
onProgress?: (percent: number) => void;
concurrency?: number;
signal?: AbortSignal;
onChunkDone?: (index: number) => void;
}
export async function chunkUpload({
file,
onProgress,
concurrency = 3,
signal,
onChunkDone,
}: ChunkUploadOptions) {
// ① 切片
const chunks: Blob[] = [];
for (let start = 0; start < file.size; start += CHUNK_SIZE) {
chunks.push(file.slice(start, Math.min(start + CHUNK_SIZE, file.size)));
}
// ② 文件指纹(用于秒传/续传;大文件可以用「抽样哈希」加速)
const fileHash = await computeFileHash(file);
// ③ 询问服务端:文件是否已存在(秒传)/ 已上传了哪些片(续传)
const { uploaded, fileId } = await api.checkFile({
hash: fileHash,
name: file.name,
size: file.size,
});
if (uploaded) {
onProgress?.(100);
return { fileId, url: `/files/${fileId}` }; // ⭐ 秒传
}
// ④ 并发上传未完成的片(带限流)
const pending = chunks
.map((blob, index) => ({ blob, index }))
.filter(({ index }) => !uploadedChunkSet.has(index));
let completed = uploadedChunkSet.size;
const updateProgress = () => {
completed++;
onProgress?.(Math.round((completed / chunks.length) * 100));
};
await runWithConcurrency(pending, concurrency, async ({ blob, index }) => {
const fd = new FormData();
fd.append('file', blob);
fd.append('hash', fileHash);
fd.append('index', String(index));
fd.append('total', String(chunks.length));
await api.uploadChunk(fd, { signal });
onChunkDone?.(index);
updateProgress();
});
// ⑤ 合并
const result = await api.mergeChunks({ hash: fileHash, name: file.name, total: chunks.length });
return result;
}
// 并发限流(不引入库的最小实现)
async function runWithConcurrency<T>(items: T[], limit: number, worker: (item: T) => Promise<void>) {
let cursor = 0;
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (cursor < items.length) {
const index = cursor++;
await worker(items[index]!);
}
});
await Promise.all(runners);
}
// 文件哈希(用 Web Worker 加速,避免阻塞主线程)
async function computeFileHash(file: File): Promise<string> {
// ⚠️ 完整 md5 对大文件很慢 → 常见优化:取首尾 + 中间若干块算哈希
const SAMPLE = 2 * 1024 * 1024;
const samples: Blob[] = [];
for (let offset = 0; offset < file.size; offset += SAMPLE) {
samples.push(file.slice(offset, Math.min(offset + SAMPLE, file.size)));
}
// 简化:这里用 crypto.subtle 算 SHA-256(生产上常用 spark-md5)
const buffer = await new Blob([file.slice(0, SAMPLE), file.slice(-SAMPLE)]).arrayBuffer();
const digest = await crypto.subtle.digest('SHA-256', buffer);
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
}
// ✅ 组件里带进度与暂停/恢复
function BigFileUploader() {
const [percent, setPercent] = useState(0);
const [status, setStatus] = useState<'idle' | 'uploading' | 'paused' | 'done' | 'error'>('idle');
const controllerRef = useRef<AbortController | null>(null);
const fileRef = useRef<File | null>(null);
const start = async (file: File) => {
fileRef.current = file;
const controller = new AbortController();
controllerRef.current = controller;
setStatus('uploading');
try {
await chunkUpload({
file,
onProgress: setPercent,
signal: controller.signal,
});
setStatus('done');
} catch (err) {
if ((err as Error).name === 'AbortError') {
setStatus('paused'); // ⭐ 暂停(下次可续传)
return;
}
setStatus('error');
}
};
return (
<div>
<input type="file" onChange={(e) => e.target.files?.[0] && start(e.target.files[0])} />
<progress value={percent} max={100} />
{status === 'uploading' && (
<button onClick={() => controllerRef.current?.abort()}>暂停</button>
)}
{status === 'paused' && (
<button onClick={() => fileRef.current && start(fileRef.current)}>
继续上传 {/* ⭐ 续传:已上传的片会被检查接口跳过 */}
</button>
)}
</div>
);
}
| 能力 | 实现方式 |
|---|---|
| 切片 | file.slice(start, end) |
| 并发 | 手写限流(或 p-limit) |
| 秒传 | 前端算「文件哈希」→ 服务端查是否已存在 |
| 续传 | 上传前问「已上传哪些片」→ 只传缺的 |
| 进度 | 「已完成片数 / 总片数」(更真实) |
| 暂停 | AbortController.abort() |
| 合并 | 全部完成后调「合并接口」 |
| 限速 | 限制并发数 + 片之间加间隔(必要时) |
六、常见坑
// ① 用 fetch 做上传进度(没有原生支持)
// ② 手动设置 multipart 的 Content-Type(丢 boundary)
fetch('/upload', { headers: { 'Content-Type': 'multipart/form-data' }, body: fd }); // ❌
// ③ 认为 e.loaded / e.total 是「服务端已接收」
// → 只是「已发送到内核缓冲区」
// ④ 取消后没判断 AbortError(弹出「上传失败」提示)
// ⑤ 用 axios 的 CancelToken(已废弃)而不是 AbortController
// ⑥ 大文件不分片(一次失败全部重来)
// ⑦ 分片上传「并发数过高」(把带宽/服务端打满)→ 3~5 并发较稳
// ⑧ 分片上传「没做顺序保证或校验」(服务端应该校验 index 与总数)
// ⑨ 文件哈希在主线程算(大文件会「卡死界面」)→ 用 Web Worker
// ⑩ 没做「秒传」(重复上传同一个文件)
// ⑪ 没做「续传」(网络中断后从头传)
// ⑫ 分片大小不合适(太小 → 请求数多;太大 → 单次失败成本高)
// ✅ 常见 2~5MB;移动网络可小一些
// ⑬ 合并时「没有校验文件完整性」(应该用哈希校验)
// ⑭ 上传进度用「单片的 onprogress」而不是「已完成片数」
// → 单片进度会「在 90% 停很久」(因为最后一片还在传)
// ⑮ 服务端没做「分片过期清理」(大量临时片占满磁盘)
// ⑯ 忘记「上传前的类型/大小校验」(用户体验差 + 服务端浪费)
// ⑰ 没处理「token 过期」(长上传过程中 token 失效)
// ⑱ 移动端「后台切换」导致上传中断(需要考虑「切后台暂停/恢复」)
// ⑯ 的前端校验(应该在上传前做)
function validateFile(file: File) {
const MAX_SIZE = 100 * 1024 * 1024; // 100MB
const ALLOWED = ['image/jpeg', 'image/png', 'application/pdf'];
if (file.size > MAX_SIZE) throw new Error('文件不能超过 100MB');
if (!ALLOWED.includes(file.type)) throw new Error('只支持 JPG/PNG/PDF');
}
七、面试延伸
- 「
fetch能获取上传进度吗?」
不能(没有原生的上传进度事件)。XMLHttpRequest提供xhr.upload.onprogress(e.loaded/e.total/e.lengthComputable),axios的onUploadProgress也是基于 XHR 实现的。如果一定要用fetch:下载进度可以通过读取res.body(ReadableStream)边读边累加来算,但上传进度在目前的浏览器里没有标准 API(ReadableStream作为请求体的支持有限)。所以「需要上传进度」就用 XHR(或 axios)。 -
「上传进度到 100% 就说明服务端收到了吗?」
不一定。onprogress的loaded反映的是「已写入浏览器/操作系统发送缓冲区的字节数」,不代表「服务端已经接收并处理完」。所以大文件常见「进度条到 100% 后还要等一会儿才成功」。要得到「更真实的进度」,应该用分片上传——以「已成功返回的片数 / 总片数」作为进度(每片的成功响应意味着服务端确实收到了这一片)。 -
「怎么取消一个正在进行的请求?」
用AbortController:创建 controller,把controller.signal传给请求(fetch的signal选项 /axios的config.signal/ XHR 包装里监听signal并调xhr.abort()),需要取消时调controller.abort()。取消会让 Promise reject 一个AbortError(axios 是CanceledError,可用axios.isCancel(err)判断)。关键细节:① 在 React 里通常在useEffect的清理函数里 abort;②catch里要区分取消与真实错误(取消不应该弹提示)。 -
「为什么要分片上传?」
四个理由:① 可靠性——大文件一次上传容易超时/中断,失败要全部重来;分片后「只重传失败的片」;② 断点续传——可以「暂停后继续」(跳过已上传的片);③ 并发加速——多片并行上传(配合限流),充分利用带宽;④ 服务端友好——服务端处理小片的内存压力更小,也便于「多机/多存储」并行处理。 -
「分片上传的完整流程是什么?」
六步:① 切片——file.slice(start, end)按固定大小(常见 2~5MB)切分;② 算哈希——对文件算唯一标识(用于秒传与续传;大文件用「抽样哈希 + Web Worker」避免卡界面);③ 检查——调接口问「这个文件是否已存在(秒传)/ 已上传了哪些片(续传)」;④ 并发上传——只传缺失的片,用限流(3~5 并发)避免打满带宽;⑤ 合并——全部成功后调「合并接口」,服务端按 index 顺序拼接;⑥ 校验——服务端返回最终地址(最好做「整体哈希校验」确保完整)。 -
「什么是秒传?」
指「上传前先算文件的哈希,问服务端这个文件是否已存在」——如果存在,直接返回已有文件的地址(不上传任何数据)。它依赖「内容寻址」(相同内容的文件哈希相同)。实现要点:① 前端用 Web Worker 算哈希(避免卡主线程);② 大文件用「抽样哈希」(如取首尾 + 中间若干块)来加速;③ 服务端要维护「哈希 → 文件」的索引。它是网盘类产品的标配能力。 -
「大文件的哈希怎么算才不卡界面?」
三点:① 放到 Web Worker 里算(crypto.subtle.digest或spark-md5),主线程只负责展示进度;② 分块计算(用FileReader/Blob.arrayBuffer()边读边更新哈希状态),避免一次性读入内存;③ 抽样哈希——只取「文件头部 + 尾部 + 中间的若干块」参与哈希(速度提升几十倍,碰撞概率极低),实践中足以作为「秒传」的标识(若担心碰撞,可在服务端做二次校验)。 -
「上传/下载还有什么优化?」
四个方面:① 分片 + 并发 + 续传 + 秒传(上面已述);② 限速与优先级——限制并发数、必要时给「用户主动操作的文件」更高优先级;③ 压缩与转码——图片可以「前端压缩/生成缩略图」(用 Canvas 或createImageBitmap),减少上传体积;④ CDN 与直传——大文件走「客户端直传对象存储」(服务端只发「临时上传凭证」),避免经过自己的服务器(省带宽、更稳、更快)。
一句话速记
fetch没有上传进度(要用 XHR 的xhr.upload.onprogress或axios.onUploadProgress;下载进度可以用ReadableStream自己算);取消统一用AbortController(fetch/axios/XHR 包装都支持;catch里要区分AbortError不要弹提示;React 里在useEffect清理函数里 abort);大文件要分片上传——「切片(2~5MB)→ 算哈希(Web Worker + 抽样)→ 检查(秒传/续传)→ 限流并发(3~5)→ 合并 → 校验」,进度应该用「已完成片数 / 总片数」(onprogress的 100% 只代表「发进了缓冲区」);别手动设 multipart 的Content-Type;生产上大文件推荐客户端直传对象存储。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!