☰
原生XHR文件上传进度监听实战与踩坑指南
2026/10/14 4:59:52 网站建设 项目流程

直接说明为什么这个需求值得单独写一篇:如果你搜索过“ajax上传文件监听上传进度”,大概率已经在用某种方式做文件上传了,但网上能搜到的方案里,至少有一半停留在“用FormData拼个请求发出去”的程度,完全没有进度反馈。剩下的一半要么用了某个UI框架自带的Upload组件,要么用fetch凑合,进度条要么不做,要么只做一个假的加载动画。这篇文章就是聊清楚一件事:浏览器原生能力里,怎么用ajax老老实实地监听文件上传的真实进度,以及在这个过程里你会遇到哪些网上教程不会明说的坑。

1. 为什么监听上传进度必须走XMLHttpRequest而不是fetch

1.1 进度监听的核心在于底层有没有上报机制

先搞清楚一个基础事实:浏览器里能发异步请求的API有两套,一套是传统的XMLHttpRequest,另一套是较新的fetch。如果只看代码简洁程度,fetch明显更讨人喜欢,服务端接的也是同一套HTTP协议,传文件和普通表单请求在服务器眼里差别不大。但一旦涉及“上传进度”这种需求,fetch就成了摆设,因为fetch虽然支持请求体的流式处理,却不暴露任何上传进度事件,你无法知道此刻浏览器已经把多少字节的数据送进了网络。

为什么fetch做不到?这不是前端开发者能靠hack解决的,属于浏览器实现层面的API设计取舍。fetch定位是面向数据交互的通用请求API,关注的是请求结果而不是过程,而XMLHttpRequest的设计年代更早,反而保留了更多底层细节。xhr对象身上挂着一个叫upload的属性,这个属性本身是一个XMLHttpRequestUpload对象,它有onprogress、onload、onloadstart、onloadend、onerror、ontimeout这一整套事件,专门用于反馈请求体(文件数据)从浏览器发往服务器的实时状态。这才是监听的真正入口。

结论很直接:要做文件上传进度,原生方案里XMLHttpRequest是唯一靠谱的选择。这不是技术路线之争,而是功能边界问题。

1.2 方案选型时的真实考量

选型不是看哪个API新,而是看哪个API能解决实际问题。我整理了一下实际项目里会用到的对比维度,放在下面方便你直接参考:

对比维度XMLHttpRequestfetch
上传进度事件有xhr.upload.onprogress,原生支持没有内置上传进度事件
取消上传支持xhr.abort()支持AbortController,但已发出的请求体停不下来
超时控制支持xhr.timeout+ontimeout需借助AbortController模拟
请求头控制可精确控制,但手动设Content-Type有坑更灵活,但同样要小心
回调风格事件回调Promise
兼容性所有环境都支持现代浏览器

有一点需要解释:fetch配合请求体的ReadableStream理论上也能实现所谓“上传进度”,你可以在流式写入的时候自己统计每次write的数据量。但FetchAPI目前规范里明确不支持从浏览器环境读取请求体发送进度的回调,导致这个思路只停留在理论层面。我已经在不止一个技术群里看到有人试图用这个思路硬写,最后都无疾而终。务实一点,老老实实用XMLHttpRequest。

还有个细节值得注意:既然XHR能监听进度,为什么现在很多项目还是用封装好的上传组件?因为组件帮你处理了断点续传、分片、重试这些更复杂的逻辑,但如果你不知道底层原理,遇到组件解决不了的问题时就只能干瞪眼。这篇先讲清楚最核心的原生实现,后面再说怎么在它的基础上做扩展。

1.3 上传进度到底指的是什么进度

很多人把“上传进度”和“请求耗时”混为一谈,这是个误区。上传进度特指请求体(文件数据)从客户端网络栈发往服务端的进度,它和你看到的那种转圈loading不是一回事。

举个例子:你选了一个50MB的视频文件点击上传,进度条从0走到100%,这个过程是浏览器在把文件数据读取出来、压缩打包、通过网络发送出去,这一段时间内的进度反馈就是xhr.upload.onprogress给出来的。一旦进度达到100%,并不代表服务器已经把文件保存到磁盘了,它只代表网络层发出去了。服务器那边可能还在接收、校验、写库,这个阶段是没有任何客户端进度可言的。所以不要设计出一个进度条,在进度到100%之后还算上服务端处理时间,那个时间在技术属性上已经不属于上传进度了。

理解了这一层,再去写代码时就不会把逻辑搞混。下面进入正题。

2. 核心细节:upload对象、progress事件与数据字段

2.1 xhr.upload到底是什么

平时用xhr发请求,最常打交道的接口是open、send,以及onreadystatechange。但xhr对象下面还挂着一个upload属性,这个属性在发普通GET请求时几乎不会被用到,只有在发POST、PUT这类带请求体的请求时才有意义。

upload本身是XMLHttpRequestUpload类型的实例,它和xhr一样内置了事件模型。你可以直接在上面挂事件处理函数:

const xhr = new XMLHttpRequest(); xhr.upload.onprogress = (event) => { // 这里的event是ProgressEvent }; xhr.upload.onload = () => { // 上传成功 }; xhr.upload.onerror = () => { // 上传失败 };

需要注意:upload上的事件和xhr对象上的事件是两套监听体系。upload上面挂的是请求体发送阶段的事件,xhr上面挂的是整个请求生命周期的事件,包括状态码变化、响应接收完成等。平时判断上传“成没成”,主要看xhr.status和xhr.readyState,而不是upload.onload。upload.onload只代表请求体发送完毕,之后服务端能不能正常响应、返回什么状态码,还是要回到xhr本身去判断。

如果不想在upload上逐个挂事件,也可以直接用addEventListener注册,效果一样。区别在于:直接赋值onprogress这种写法一次只能绑定一个处理函数,后绑定的会覆盖前面的;addEventListener可以绑定多个。在封装一个通用上传组件时,如果多个模块都要感知进度,addEventListener更合适。

const xhr = new XMLHttpRequest(); xhr.upload.addEventListener('progress', (event) => { // do something });
2.2 progress事件对象里面到底有什么

progress事件是ProgressEvent类型的实例,核心有三个属性,每个都要搞清楚含义:

  • loaded:已经发送的字节数,单位是byte。
  • total:本次请求体总字节数,如果服务器或浏览器能确定长度就有值,否则为0。
  • lengthComputable:布尔值,表示total是否可计算。当这个值为false时,loaded/total算出来永远是Infinity,这个情况一定要做防御处理。

实际场景里,通过FormData上传文件时,total由浏览器根据文件大小和附加的字段自动计算,大部分情况下lengthComputable都是true。但如果你在请求体里用了某些流式数据源,或者请求体长度不确定,就可能出现total为0的情况。判断代码建议写成这样:

if (event.lengthComputable) { const percent = Math.round((event.loaded / event.total) * 100); progressElement.style.width = percent + '%'; }

还有一个更隐蔽的点:FormData里如果除了文件还带其他普通字段,total是整个请求体的总大小,不只是文件大小。文件本身只占一部分,所以进度条的百分比是全体的百分比。如果用户关心的是文件本身的发送进度,误差很小(字段占用几十字节,对比几MB文件基本可忽略),但如果传的是小文件,比如几KB,字段开销导致的误差就变明显了。介意的话可以做到分片层再精确计算,后面章节会讲到。

2.3 字节换算怎么处理才直观

progress事件里loaded和total的单位是字节,直接展示成数字没人看得懂。做进度条显示时通常两种展示方式:一种是只显示百分比,一种是在百分比旁边再挂一个当前上传量,比如“3.2MB / 15.6MB”。

换算函数网上很多版本,但很多都写得不严谨。我这里给一个自己一直在用的版本:

function formatSize(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }

这个函数用对数运算求出字节落在哪个量级,避免了一长串if else判断。边界情况下bytes为0、为负数(理论不会但还是要防)、为超过TB(极少数视频素材库会遇到)都能正常处理。

百分比计算也有讲究,不要在每次事件回调里都用Math.round取整,因为取整后进度条在频繁刷新时可能出现视觉上的回退感。举个实例:真实进度是376/1000,上一次事件触发时是374/1000,Math.round之后都是37%,看起来没动;等下一次变成378/1000,取整变38%。这是正常的。但有一种坑,自己写的四舍五入逻辑在进度接近100时把99.6%直接取整成了100%,而请求体其实还差一点点才发完,进度条提前满格了,之后再怎么刷新都停在100%,就会有人说“进度条不准”。

我的建议是:显示层保留一个整数百分比用于进度条宽度,另外保留一个带小数的原始数值用于界面上的数字文本,保证数字不准时视觉至少是连续的。

3. 实操:从零搭建上传监听示例

3.1 一个最小可运行的上传Demo

先直接给一个能跑通的完整示例,代码里包含文件选择、上传请求、进度监听三块基础功能,你把它复制到任意支持现代浏览器的环境里就能测。

const uploadInput = document.getElementById('fileInput'); const progressBar = document.getElementById('progressBar'); const progressText = document.getElementById('progressText'); const uploadBtn = document.getElementById('uploadBtn'); uploadBtn.addEventListener('click', () => { const file = uploadInput.files[0]; if (!file) { alert('请先选择一个文件'); return; } const formData = new FormData(); formData.append('file', file); formData.append('uploadType', file.type || 'unknown'); const xhr = new XMLHttpRequest(); // 请求体发送阶段的进度监听 xhr.upload.addEventListener('progress', (event) => { if (event.lengthComputable) { const percent = Math.round((event.loaded / event.total) * 100); progressBar.style.width = percent + '%'; progressText.textContent = formatSize(event.loaded) + ' / ' + formatSize(event.total) + ' (' + percent + '%)'; } }); // 请求体的其他生命周期事件 xhr.upload.addEventListener('load', () => { // 请求体已发送完,不代表服务端处理成功 }); xhr.upload.addEventListener('error', () => { console.error('上传过程中网络出错'); }); // 请求的整体状态变化 xhr.addEventListener('readystatechange', () => { if (xhr.readyState === 4) { if (xhr.status >= 200 && xhr.status < 300) { progressText.textContent = '上传完成,服务器返回:' + xhr.responseText; } else { progressText.textContent = '服务器异常,状态码:' + xhr.status; } } }); xhr.open('POST', '/api/upload'); xhr.send(formData); });

上面代码里的progressBar是一个普通的div元素,宽度初始0%,随着进度事件更新宽度。这是做进度条最朴素的思路,不涉及任何框架,放到React、Vue项目里逻辑也大同小异,区别只在DOM更新的方式不同。

在Vue里你可以把百分比放进一个ref,在React里放进state,但没有必要每触发一次progress就立刻setState。React的setState是异步批处理的,高频率触发时可能出现中间状态被合并的情况,进度条看起来跳变。后面会讲一个更平滑的优化方案。

3.2 请求体与参数的正确打开方式

这段是我的重点提醒,也是实际项目里最容易采坑的部分。

FormData的append顺序不是随意的,服务端如果依赖字段顺序解析(绝大多数后端不会,但个别老系统会做这种设计),那你就得按服务端预期的顺序append。更关键的是:如果要同时支持上传多个字段,比如一个图片文件加一个缩略图文件,就要append两个不同字段名的文件:

formData.append('original', file); formData.append('thumbnail', thumbFile);

服务端那边这两个字段需要不同key去接收,如果你都用同一个key,最终只有一个文件能正确进入。

另一个经典问题是Content-Type设置。直接用xhr.send(formData)时,浏览器会自己生成一个multipart/form-data的请求体,并自动带上一个包含boundary的Content-Type头。你不需要也不应该手动设置Content-Type,否则大概率会拿到一个少boundary的请求头,服务端解析FormData时会直接失败。

有的人会好奇:如果不手动设置,怎么知道boundary长什么样?浏览器在构造请求时自动生成,你看Network面板能看到完整请求头,但代码里不需要关心。你只需要保证没有手动改过这个头就行。

3.3 进度条UI刷新技巧:用requestAnimationFrame节流

upload.onprogress这个事件触发频率没有固定标准,不同浏览器差异很大,实测下来Chrome大约每50毫秒左右触发一次,网络波动时可能忽快忽慢。如果每一次事件都直接改DOM样式或驱动状态更新,高频场景下不仅性能不理想,还会出现进度条卡顿感。

我之前有个项目上传大文件,进度条实际表现是那种“一卡一卡”的顿挫感,不是丝滑的连续上涨。排查到最后发现原因就是事件触发频率不稳定,UI直接绑在事件上更新,网络层突发抖一下,UI就跟着跳一下。

改进方案是给刷新动作加上节流,只保留浏览器下一帧唯一一次的更新机会:

let lastUpdateTime = 0; const throttleInterval = 100; // 至少间隔100ms更新一次UI xhr.upload.addEventListener('progress', (event) => { const now = Date.now(); if (now - lastUpdateTime < throttleInterval) { return; } lastUpdateTime = now; updateProgressUI(event.loaded, event.total); });

如果想要更跟手的效果,可以用requestAnimationFrame结合一个“是否有待处理更新”的标记位:

let pendingUpdate = false; let cachedLoaded = 0; let cachedTotal = 0; xhr.upload.addEventListener('progress', (event) => { if (!event.lengthComputable) return; cachedLoaded = event.loaded; cachedTotal = event.total; if (pendingUpdate) return; pendingUpdate = true; requestAnimationFrame(() => { updateProgressUI(cachedLoaded, cachedTotal); pendingUpdate = false; }); });

这种写法避免了一个问题:progress事件可能在两帧之间触发多次,但DOM只需要更新一次。最后一次触发时缓存的值是最新的,所以画面不见得会丢进度。

3.4 大文件分片后如何计算总进度

文件超过2GB或者网络环境差时,一般会考虑分片上传。分片之后,进度监听就变成了两层:每个分片有自己独立的进度事件,整体进度需要自己聚合。

思路是:先用file.slice把文件切成固定大小的块,然后按顺序或并发上传,每个分片独立计算上传进度,再把所有分片已上传大小累加起来,除以文件总大小,得到整体进度。

const CHUNK_SIZE = 1024 * 1024 * 5; // 5MB一个分片 const chunks = Math.ceil(file.size / CHUNK_SIZE); let uploadedBytes = 0; async function uploadChunk(index) { const start = index * CHUNK_SIZE; const end = Math.min(start + CHUNK_SIZE, file.size); const blob = file.slice(start, end); const formData = new FormData(); formData.append('chunk', blob); formData.append('chunkIndex', index); formData.append('totalChunks', chunks); return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.upload.addEventListener('progress', (event) => { if (event.lengthComputable) { // 当前分片的已上传大小 + 之前分片已上传大小 const currentChunkUploaded = event.loaded; const totalUploaded = uploadedBytes + currentChunkUploaded; const overallPercent = Math.round((totalUploaded / file.size) * 100); // update UI with overallPercent } }); xhr.addEventListener('readystatechange', () => { if (xhr.readyState === 4) { if (xhr.status >= 200 && xhr.status < 300) { uploadedBytes += blob.size; resolve(); } else { reject(new Error('chunk upload failed')); } } }); xhr.open('POST', '/api/upload/chunk'); xhr.send(formData); }); } async function uploadFile() { for (let i = 0; i < chunks; i++) { await uploadChunk(i); } }

这种写法是顺序上传,进度会稳定增长。如果做并发上传,整体进度的计算逻辑一样成立,但是进度条可能不是一路线性增长,而是多条并发线叠加出来的,属于正常现象,不要看到跳变就以为是bug。

4. 踩坑记录:真实项目中遇到过的进度监听问题

4.1 本地环境测试时的“上传秒完成”现象

很多人在localhost环境测试上传功能,发现几十MB的文件进度条一闪而过,甚至还没看清就直接100%。这不代表代码有问题,而是本地回环网络速度极快,实际文件数据写进本机网络栈几乎不耗时。你在本地看进度条,基本就是在看浏览器读取文件、构造请求体的过程。

这种情况很容易误导对进度的判断,如果你要验证进度算法的准确性,最好用开发者工具里的网络节流功能,比如把上传速率限制为100kb/s,再传一个几十MB的文件,这时候进度条会明显慢下来,也就能仔细看百分比和字节数是否匹配。调Network面板里的Online切换成Slow 4G或自定义低速即可。

另一个本地环境的坑:某些情况下upload.onprogress事件全程只触发一次,而且是在请求结束时触发。很多新手遇到这个会怀疑自己的代码写错了,其实可能是浏览器对特殊请求体做了缓冲处理,比如请求体极小(几KB),一次就发完了。这个不是bug,把测试文件加大到几十MB就能复现正常的连续进度事件。

4.2 手动设置Content-Type导致上传失败

这个坑前面提过,但值得再展开说一遍。场景往往是开发者照着某篇老博客的代码写,里面有类似这样一行:

xhr.setRequestHeader('Content-Type', 'multipart/form-data');

然后发现自己上传后,服务端怎么都解析不到文件字段。原因就是multipart/form-data这种Content-Type必须带一个boundary参数,浏览器在send(FormData)时自动生成并写入,手动设置就把自动生成的过程覆盖了,请求发出去的Content-Type缺少boundary,服务端自然无法正确拆分各个字段。

正确做法只有一种:不要手动设置这个头,把FormData直接传给send,剩下的交给浏览器。

4.3 进度条回退、跳变的常见原因

进度条已经走到了80%,突然跳回60%,这种体验是绝对不能被接受的。排查这类问题要看两个层面:

第一层是计算逻辑错误。比如没有使用累计值,而是用某个临时变量不断覆盖,导致每次进度事件都只反映最后一次分片的状态,多分片上传时就会出现整体进度来回跳。这类问题在分片上传场景极其常见。

第二层是视觉错觉。如果UI把状态更新做成“淡入淡出”或“回弹动画”,即使底层进度是单调递增的,动画过程也会让人感觉有回退。检查一下是不是动画库在中间插入了过渡值。

还有一种不算常见但确有其事的情况:服务器端做请求体直接转发(比如网关代理转发到另一个存储服务),进度条显示的是浏览器到网关的进度,网关内部再转发时的卡顿不会反映到进度条上,但用户在界面看到的就是“进度条卡住不动了”。这种问题只能靠服务端状态接口辅助,不属于前端进度监听的范畴。

4.4 上传中断与取消的细节处理

进度监听不只是展示百分比,还要和取消操作联动。用户在进度80%时点了取消按钮,你要做的事情有两件:调用xhr.abort()中断请求,同时销毁或标记掉相关的进度更新回调,避免取消之后还在乱更新UI。

我在一个项目里踩过这个坑:取消上传后,因为progress事件还在继续零星触发(发生在abort调用前的最后几帧),UI把进度条从80%拉到了85%,然后才彻底停止,看起来像是取消后反而继续上传了。解决方案是abort后立刻置一个flag:

let cancelled = false; cancelBtn.addEventListener('click', () => { cancelled = true; xhr.abort(); }); xhr.upload.addEventListener('progress', (event) => { if (cancelled) return; // update UI });

还有一种情况是中途断网。xhr.upload.onerror会触发,但如果你只监听了progress而不监听error,用户就只能在进度条卡住之后靠超时才能看到报错。建议至少监听error、abort、load三个事件,分别对应网络错误、用户取消、发送完成,这样用户侧能给出不同的提示文案。

4.5 常见问题速查表

这个表里的内容都来自真实工作里排查过的问题,可以收藏备用。

问题现象可能原因排查/解决方向
全部代码都对,但没有进度事件忘监听xhr.upload,误监听xhr确认是xhr.upload.onprogress而非xhr.onprogress
total为0,百分比异常请求体是不可计算长度判断event.lengthComputable后走兜底逻辑
上传完成后控制台报CORS错误服务端没开启跨域允许,或缺少对应请求头服务端配置Access-Control-Allow-Origin、Allow-Headers
进度条瞬间到100%本地测试网络快,或文件太小用网络节流+大文件复测
服务端收不到文件字段手动设置了Content-Type头删除setRequestHeader(Content-Type)
进度显示的是整个FormData大小FormData附带字段过多接受误差或改用分片精细计算
abort后UI还在动取消后忘了置标记位设cancelled标志,并在所有回调入口检查

5. 进阶:封装一个可复用的上传工具函数

5.1 一个支持进度回调的上传封装

日常项目中每个页面都写一遍事件绑定不现实,更好的方式是封装出一个带进度回调的上传函数,参数包括文件、附加数据、回调函数。下面是完整实现:

function uploadFileWithProgress({ file, formData, url, onProgress, onSuccess, onError }) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); // 允许外部传入额外的FormData字段 if (!formData) { formData = new FormData(); } formData.append('file', file); xhr.upload.addEventListener('progress', (event) => { if (!event.lengthComputable) return; const percent = Math.round((event.loaded / event.total) * 100); if (onProgress) { onProgress({ percent: percent, loaded: event.loaded, total: event.total, }); } }); xhr.addEventListener('readystatechange', () => { if (xhr.readyState !== 4) return; if (xhr.status >= 200 && xhr.status < 300) { if (onSuccess) onSuccess(xhr.responseText); resolve(xhr.responseText); } else { const err = new Error('upload failed with status ' + xhr.status); if (onError) onError(err); reject(err); } }); xhr.upload.addEventListener('error', () => { const err = new Error('network error during upload'); if (onError) onError(err); reject(err); }); xhr.open('POST', url); xhr.send(formData); }); }

调用方式就简化为:

uploadFileWithProgress({ file: selectedFile, formData: formData, url: '/api/upload', onProgress: (progress) => { progressBar.style.width = progress.percent + '%'; }, onSuccess: (responseText) => { console.log('upload done', responseText); }, onError: (err) => { console.error(err); } });

封装之后,跨页面复用就非常舒服了。如果项目里用了TypeScript,还可以把入参和回调的类型定义出来,团队协作时约束更强。

5.2 断点续传、秒传的扩展思路

有了上面的基础,就能在这个基础上扩展更高级的能力。断点续传的核心是记录已上传的分片索引,下次上传时跳过这些分片。秒传的核心是服务端先算文件的hash值,发现相同哈希已经存在就直接返回成功,不再实际传输文件体。

具体实现思路可以这样设计:先在客户端用文件对象的arrayBuffer计算哈希,但大文件直接算哈希会卡住主线程,需要借助Web Worker。在worker里用分块读取加增量哈希的方式处理,比如每5MB计算一段哈希,最后汇总成文件的唯一标识。拿到hash后,先请求一个问询接口,服务端返回“该文件已存在”或“已上传到第N个分片”,客户端决定是跳过上传还是从第N+1片开始续传。

这一套逻辑写起来不算特别复杂,但涉及的状态管理、错误重试、并发控制细节不少。关键点在于:整个体系的正确运行依赖分片上传的稳定性,而分片上传的稳定性又依赖本篇文章讲的进度监听和事件处理是否扎实。这就是为什么不管项目里有没有组件库,都应该先把这个基础摸透。

5.3 关于安全与合规的提醒

最后聊两句关于文件上传的合规性问题。任何上传功能上线前,都要确认三件事:上传文件的类型是否符合业务预期,文件大小是否有限制,服务端是否正确校验了文件的真实格式。浏览器端限制type属性只是前端的友好提示,真正可靠的判断必须在服务端完成。进度监听本身不涉及这些,但它往往是用户感知上传功能的第一步,如果文件校验不通过而你又在进度条上给了“上传完成”的错觉,用户很容易产生歧义。所以在上传接口里建议无论如何都做两层判断,进度条只负责进度,成功或失败以服务端最终响应为准。

实际项目里我对进度监听的体会是:它不属于“炫技”型技术点,但它是文件上传体验的基石。用户能不能放心地传一个几百MB的视频,很大程度上取决于进度条是否真实、顺滑、可预期。把这一层做扎实了,再往上去研究分片、断点续传、秒传,心里才有底。如果只是套组件,进度条永远是一个黑盒,遇到问题你连查都不知道从哪里查起。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询