Axios 进度捕获完全指南:onUploadProgress / onDownloadProgress 原理与实战
本文基于 axios 官方文档 进度捕获 编写,系统讲解如何在浏览器与 Node.js 环境中捕获 HTTP 请求的上传/下载进度:包括 AxiosProgressEvent 各字段的确切含义、进度事件被限流到每秒 3 次的实现机制、Node.js 下流式上传进度捕获的正确姿势,以及 follow-redirects 缓冲内存陷阱的规避方法。读完后你将能独立实现进度条、ETA 估算与速度显示,并理解进度事件从 xhr/http/fetch 三个适配器汇聚到同一回调的底层链路。
一、为什么需要进度捕获,以及 axios 给出的约束
上传大文件、下载大资源时,用户界面通常需要展示“已传 X%、剩余 Y 秒、当前速度 Z KB/s”这类信息。axios 在浏览器(xhr/fetch 适配器)和 Node.js(http 适配器)环境下同时支持捕获请求的上传与下载进度,两个入口是请求配置中的两个回调:
onUploadProgress:上传进度回调onDownloadProgress:下载进度回调
文档明确给出一个关键约束:进度事件的触发频率被强制限制为每秒最多 3 次,目的是避免浏览器被过高的进度事件频率压垮(例如上传一个 1GB 文件时,浏览器原生的 progress 事件可能每秒触发数百次)。这个限制不是可配置项,而是写死在源码 lib/helpers/progressEventReducer.js 中的默认参数 freq = 3。
二、AxiosProgressEvent 字段全解
文档给出的捕获示例如下,两个回调接收同一个 AxiosProgressEvent 对象,通过 upload/download 标识区分方向:
await axios.post(url, data, {
onUploadProgress: function (axiosProgressEvent) {
/*{
loaded: number;
total?: number;
progress?: number; // 范围 [0..1]
bytes: number; // 自上次触发以来传输的字节数(增量)
estimated?: number; // 预计剩余时间(秒)
rate?: number; // 上传速度(字节/秒)
upload: true; // 上传标识
}*/
},
onDownloadProgress: function (axiosProgressEvent) {
/*{
loaded: number;
total?: number;
progress?: number;
bytes: number;
estimated?: number;
rate?: number; // 下载速度(字节/秒)
download: true; // 下载标识
}*/
},
});
结合 TypeScript 声明 index.d.ts 中的 AxiosProgressEvent 接口,可以给出比文档注释更完整的字段表:
| 字段 | 类型 | 说明 |
|---|---|---|
loaded |
number |
已传输的字节数(绝对值,单调不减) |
total |
number? |
总字节数;仅当对端/传输层可计算总长时才有值 |
progress |
number? |
loaded / total,范围 [0..1];total 未知时为 undefined |
bytes |
number |
自上次触发以来新增的字节数(增量,非累计) |
rate |
number? |
传输速度,字节/秒;采样窗口不足时可能为 undefined |
estimated |
number? |
预计剩余时间(秒),由 (total - loaded) / rate 推出 |
upload / download |
boolean? |
方向标识,二者互斥 |
event |
any? |
底层原始事件(如浏览器 ProgressEvent),源码中额外附带 |
lengthComputable |
boolean? |
是否可计算总长度,源码中额外附带 |
几个容易踩坑的细节,均可在 progressEventReducer.js 中得到印证:
progress与total是可选的。服务端未返回Content-Length(如分块传输、chunked 编码的流式响应)时,total、progress都是undefined,此时只能依赖loaded和bytes做纯增量展示。loaded做了防回退与截断保护:源码中loaded = Math.max(0, total != null ? Math.min(rawLoaded, total) : rawLoaded),即不会出现loaded > total或负值。rate与estimated是推导值:速度由滑动窗口采样器计算(下文详述),前 250ms 内样本不足时rate为undefined,estimated则要求rate与total同时存在。event字段在文档注释中未提及,但 index.d.ts 声明了event?: BrowserProgressEvent,源码在 progressEventReducer.js 中将其原样透传,高级场景可据此读取底层事件。
三、每秒 3 次限流是怎么实现的
“每秒最多 3 次”这一约束由两个工具函数协作实现,这是理解整条进度链路的核心。
3.1 throttle:节流 + 尾冲刷(trailing flush)
lib/helpers/throttle.js 实现的不是一个“丢事件”的简单节流器:
function throttle(fn, freq) {
let timestamp = 0;
let threshold = 1000 / freq; // freq=3 时约 333ms
let lastArgs;
let timer;
const invoke = (args, now = Date.now()) => { /* 立即触发一次 fn(...) */ };
const throttled = (...args) => {
const now = Date.now();
const passed = now - timestamp;
if (passed >= threshold) {
invoke(args, now); // 距上次触发超过阈值:立即触发
} else {
lastArgs = args; // 否则记录最后一次参数
if (!timer) {
timer = setTimeout(() => {
timer = null;
invoke(lastArgs); // 窗口结束时补发最后一次
}, threshold - passed);
}
}
};
const flush = () => lastArgs && invoke(lastArgs);
return [throttled, flush]; // 注意:返回 [节流函数, 冲刷函数] 元组
}
两个设计要点:
- 返回的是
[throttled, flush]元组而非单个函数。这是为了在传输结束时把被节流缓存的最后一次事件补发出去,保证进度条能精确停在 100%,而不是停在 98%。 - 尾冲刷机制(trailing call)保证即使两次触发间隔不足 333ms,窗口结束时也会用最后一次参数触发,因此进度值不会“丢帧”。
3.2 speedometer:滑动窗口测速
rate(速度)字段的来源是 lib/helpers/speedometer.js。progressEventReducer.js 中以 speedometer(50, 250) 构造实例,即环形缓冲区最多保留 50 个采样、最短采样窗口 250ms:
export const progressEventReducer = (listener, isDownloadStream, freq = 3) => {
let bytesNotified = 0;
const _speedometer = speedometer(50, 250);
return throttle((e) => {
if (!e || typeof e.loaded !== 'number') {
return;
}
const rawLoaded = e.loaded;
const total = e.lengthComputable ? e.total : undefined;
const loaded = Math.max(0, total != null ? Math.min(rawLoaded, total) : rawLoaded);
const progressBytes = Math.max(0, loaded - bytesNotified); // 增量
const rate = _speedometer(progressBytes); // 滑窗测速
bytesNotified = Math.max(bytesNotified, loaded);
const data = {
loaded,
total,
progress: total ? loaded / total : undefined,
bytes: progressBytes,
rate: rate ? rate : undefined,
estimated: rate && total ? (total - loaded) / rate : undefined,
event: e,
lengthComputable: total != null,
[isDownloadStream ? 'download' : 'upload']: true,
};
listener(data);
}, freq);
};
progressEventReducer 将任意来源的“原始进度”归一化为统一的 AxiosProgressEvent 并交给用户回调;isDownloadStream 参数决定打上 download: true 还是 upload: true 标识——这解释了为什么同一个对象类型能同时服务上传与下载回调。
四、三个适配器中的进度链路
4.1 浏览器 XHR 适配器
在 lib/adapters/xhr.js 中,进度事件直接来自原生 XMLHttpRequest 的 progress 事件:
// Handle progress if needed
if (onDownloadProgress) {
[downloadThrottled, flushDownload] = progressEventReducer(onDownloadProgress, true);
request.addEventListener('progress', downloadThrottled);
}
// Not all browsers support upload events
if (onUploadProgress && request.upload) {
[uploadThrottled, flushUpload] = progressEventReducer(onUploadProgress);
request.upload.addEventListener('progress', uploadThrottled);
request.upload.addEventListener('loadend', flushUpload);
}
注意源码特意处理了兼容性:request.upload 并非所有浏览器都有(注释 “Not all browsers support upload events”),没有 upload 对象时上传进度回调会被静默跳过。另外 loadend 上挂的 flushUpload 正是 3.1 节提到的尾冲刷,用来保证上传结束时补发最后一次进度。
4.2 Node.js HTTP 适配器
Node 端的原生 HTTP 流没有 progress 事件,axios 的做法是把请求体/响应流串入一个自定义 Transform 流来统计字节数。lib/adapters/http.js 中上传侧的接线如下:
if (data && (onUploadProgress || maxUploadRate)) {
if (!utils.isStream(data)) {
data = stream.Readable.from(data, { objectMode: false });
}
data = stream.pipeline(
[
data,
new AxiosTransformStream({
maxRate: utils.toFiniteNumber(maxUploadRate),
}),
],
utils.noop
);
onUploadProgress &&
data.on(
'progress',
flushOnFinish(
data,
progressEventDecorator(
contentLength,
progressEventReducer(asyncDecorator(onUploadProgress, scheduleProgress), false, 3)
)
)
);
}
其中 progressEventDecorator(total, throttled)(见 progressEventReducer.js)负责把“已读字节数”包装成 { lengthComputable, total, loaded } 形态,total 即请求头里的 Content-Length。这也意味着:Node 上传进度能显示 total/progress 的前提是设置了 Content-Length——这正是文档示例中显式带上 headers: { "Content-Length": contentLength } 的原因。
[lib/helpers/AxiosTransformStream.js](https://gitcode.com/GitHub_Trending/ax/axios/blob/2d2a21af8a433089474a2149781799c93acbcf3c/lib/helpers/AxiosTransformStream.js?utm_source=gitcode_repo_files#L8-L50) 是这条链路的心脏:
class AxiosTransformStream extends stream.Transform {
constructor(options) {
options = utils.toFlatObject(
options,
{
maxRate: 0,
chunkSize: 64 * 1024,
minChunkSize: 100,
timeWindow: 500,
ticksRate: 2,
samplesCount: 15,
},
null,
/* ... */
);
/* ... */
}
/* _transform 中每推入一个 chunk 就累计 bytesSeen,
若有人监听了 'progress' 事件则 this.emit('progress', internals.bytesSeen) */
}
它按 chunkSize(默认 64KB)切分数据块,每推入一块就累计 bytesSeen 并 emit progress 事件;当配置了 maxUploadRate/maxDownloadRate 时,它还顺带完成按时间窗的限速。下载侧在 http.js 中以同样方式把 AxiosTransformStream 插入响应流管道,total 取自响应头 content-length。
4.3 Fetch 适配器
lib/adapters/fetch.js 中,当请求体是流时,axios 通过 lib/helpers/trackStream.js 把流重新包装成一个 ReadableStream,在每次 pull 读到一个 chunk 后累计字节并回调 onProgress:
if (_request.body) {
const [onProgress, flush] =
(onUploadProgress &&
progressEventDecorator(
requestContentLength,
progressEventReducer(asyncDecorator(onUploadProgress))
)) ||
[];
data = trackRequestStream(_request.body, onProgress, flush);
}
下载侧(fetch.js)同样走 progressEventDecorator + progressEventReducer。从源码结构看,三个适配器最终都汇聚到同一对 progressEventReducer / progressEventDecorator 工具函数,这是 AxiosProgressEvent 在各环境中字段语义完全一致的根本原因。
五、Node.js 下流式上传进度的实战写法
文档给出的第二个示例面向一个高频场景:请求体本身是一个可读流(例如从磁盘逐块读取大文件上传)。此时不能先读完整体,而要靠流式进度事件实时渲染:
const { data } = await axios.post(SERVER_URL, readableStream, {
onUploadProgress: ({ progress }) => {
console.log((progress * 100).toFixed(2));
},
headers: {
"Content-Length": contentLength,
},
maxRedirects: 0, // 避免缓冲整个流
});
这段代码中每个配置项都有明确的底层依据,不可随意删减:
| 配置项 | 作用 | 底层依据 |
|---|---|---|
headers: { "Content-Length": contentLength } |
让进度事件携带 total,从而 progress 有值 |
Node 侧 total 取自请求头 Content-Length(http.js 中 utils.toFiniteNumber(headers.getContentLength())),缺失时 progress 为 undefined |
maxRedirects: 0 |
避免整个流被缓冲进内存 | 见下文 danger 说明 |
流式 readableStream 作为 data |
内存占用恒定 | http 适配器会把任意 data 转为 Readable 串入管道(http.js) |
5.1 为什么必须 maxRedirects: 0
文档中的 danger 提示值得逐字重视:Node.js 环境中上传流时建议通过 maxRedirects: 0 禁用重定向,因为 follow-redirects 包在不遵循“背压(backpressure)”算法的情况下会把整个流缓冲到内存(RAM)中。
也就是说,如果你上传一个 10GB 的流且目标 URL 发生 30x 重定向,follow-redirects 为了在重定向后重放请求体会把整个 10GB 读到内存里——这与流式上传“恒定内存”的初衷完全相悖。maxRedirects: 0 让重定向在第一次跳转时直接失败(返回 30x 状态码),由应用层决定如何处理,从而保住流的背压语义。
5.2 已知限制:Node.js 不支持 FormData 上传进度
文档明确警告:Node.js 环境目前不支持捕获 FormData 上传进度。从源码结构看,这与 Node 端 FormData 会被序列化为流且难以预知边界长度有关,上传进度链路依赖 Content-Length 与可计数字节流,multipart 流难以满足这一前提。如果业务必须同时满足“multipart 上传 + 进度条”,从源码结构看可以考虑改用浏览器端 fetch 适配器或自行按固定块上传(如 tus 类分片协议),但不宜指望 onUploadProgress 在 Node + FormData 组合下产生有效事件。
六、完整实战:进度条 + 速度 + ETA
把前面各节拼起来,一个可直接运行的上传进度实现如下(Node.js,请求体为 Readable 流):
import axios from 'axios';
import fs from 'node:fs';
const file = fs.createReadStream('./large-file.bin');
const contentLength = fs.statSync('./large-file.bin').size;
await axios.post('https://example.com/upload', file, {
headers: { 'Content-Length': contentLength },
maxRedirects: 0, // 保留背压,避免 follow-redirects 全量缓冲
onUploadProgress: ({ loaded, total, progress, rate, estimated }) => {
const pct = progress !== undefined ? (progress * 100).toFixed(1) : '??';
const speed = rate ? (rate / 1024).toFixed(1) + ' KB/s' : '--';
const eta = estimated ? Math.ceil(estimated) + 's' : '--';
console.log(`[${pct}%] ${loaded} / ${total ?? '?'} bytes | ${speed} | ETA ${eta}`);
},
});
浏览器端则无需 Content-Length 与 maxRedirects 配置,直接监听 XHR 的 progress 事件即可,写法与文档第一个示例一致;total 是否可算由服务端是否返回 Content-Length 决定,前端代码应对 progress === undefined 做兜底。
七、测试佐证与延伸阅读
进度功能在仓库中有覆盖多个环境的测试集,可作为行为验证依据:
- 浏览器端到端:tests/browser/progress.browser.test.js(覆盖上传/下载进度、FormData 等 8 处断言)
- Node 适配器:tests/unit/adapters/http.test.js 与 tests/unit/adapters/fetch.test.js
- 跨运行环境冒烟测试:tests/smoke/cjs/tests/progress.smoke.test.cjs、tests/smoke/esm/tests/progress.smoke.test.js、tests/smoke/bun/tests/progress.smoke.test.ts
- 工具函数单测:tests/unit/helpers/progressEventReducer.test.js
核心文件索引,便于继续深挖:
- 归一化与限流入口:lib/helpers/progressEventReducer.js
- 节流器(含 flush 尾冲刷):lib/helpers/throttle.js
- 滑动窗口测速器:lib/helpers/speedometer.js
- Node 流计数/限速 Transform:lib/helpers/AxiosTransformStream.js
- Fetch 流包装器:lib/helpers/trackStream.js
- 各适配器接线:lib/adapters/xhr.js、lib/adapters/http.js、lib/adapters/fetch.js
- 类型定义
AxiosProgressEvent:index.d.ts - 英文原文档:docs/pages/advanced/progress-capturing.md
小结:axios 的进度捕获对外暴露为 onUploadProgress/onDownloadProgress 两个回调和统一的 AxiosProgressEvent 结构,对内则由 progressEventReducer(字段归一化)、throttle(3 次/秒限流 + 尾冲刷)、speedometer(滑窗测速)三件套支撑,并借助 AxiosTransformStream/trackStream 在 Node 与 Fetch 的流场景下统计字节数。工程实践上有三条硬约束需要牢记:进度事件固定约 3 次/秒、total/progress 依赖 Content-Length、Node 端流式上传必须 maxRedirects: 0 且暂不支持 FormData 上传进度。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00