“ajax上传文件监听上传进度”这个需求,我估计不少前端同学都遇到过。但多数人只会用form原生提交,文件一甩,页面刷一下跳转,等半天也不见结果,用户也不知道到底传没传完。你如果是做后台管理系统,又或者做涉及视频、图片压缩包的内部工具,这种体验基本上是过不了验收的。所以,真正解决这个核心痛点的方案,就是自己用 AJAX 控制整个上传过程,并且监听XMLHttpRequest的进度事件。这篇文章我不绕弯子,直接讲讲我在实际项目里是怎么写上传模块的,包括走过哪些坑,怎么把进度百分比、已传大小、上传速度这些功能稳定地跑起来。
适合谁看?你如果是刚接触FormData上传的小白,能看懂;你要是正在优化现有上传代码的老手,大概率也能从这里找到一两个实用细节。我不会只贴一段代码就完事,而是把为什么这样写、踩了哪些坑、生产环境要怎么处理,一并说清楚。
1. 核心思路与方案选型
动手写代码之前,我们得先想明白一件事:为什么选 AJAX 上传文件,而不是继续用传统的表单submit方式?这个问题的答案是整个方案的基石。
传统表单上传文件,其实是浏览器直接把文件以multipart/form-data编码格式发给服务器,页面要跳转或者用隐藏iframe假装无刷新。这种方式最大的问题有两个:一是无法精确拿到文件上传的实时进度;二是交互上很“笨重”,你没法在传输中途做取消、重试、校验这类精细操作。
而XMLHttpRequest(XHR) 级别的 AJAX 上传,能让你直接挑战传输层细节。XHR 对象里有一个直属属性叫upload,它本身也是一个XMLHttpRequestEventTarget,会暴露loadstart、progress、load、loadend、error、abort这些传输过程中的事件。其中progress事件就是我们的“眼睛”,它会持续不断地反馈当前已传输字节数loaded和总字节数total。拿这两个值一做除法,就能得到精准的百分比,从而驱动进度条。这就是整个方案的核心原理。
选型上还有一串问题需要考虑,我也会结合项目规模来谈。如果你的项目只是一个简单的上传按钮,不想引入重型框架,那么原生 XHR 就是最合适的选择;如果项目里已经有Axios,那完全可以用它的onUploadProgress配置项,因为 Axios 内部封装的就是 XHR。至于Fetch API,它虽然重量级更高、长得也漂亮,但截至标准的普遍实现里,fetch还是没有提供原生上传进度事件(ReadableStream做分块传输是另一套高阶玩法)。所以,在“监听上传进度”这个核心需求面前,原生 XHR 才是默认正确答案。
1.1 核心需求拆解
再把这个标题拆开看,其实它至少包含了三个相互独立又有关联的子需求:
- 上传动作本身:把文件从客户端传送至服务端,要求携带文件元信息、参数,还要保证二进制内容的完整性。
- 进度实时反馈:让用户知道传了多少、还要等多久,减少“是不是挂了”的焦虑感。
- 异常与边界处理:上传不总是一帆风顺的,文件超大小、断网、服务器返回错误,都需要有清晰的处理策略。
我在自己负责的某套内部系统中,就完整经历过这个改造过程。刚开始也是用普通表单上传一个 500MB 的压缩包,用户体验就是:点击按钮,然后整个页面卡住,大概 1 分钟后浏览器提示完成,中间没有任何反馈。后来我彻底删掉那套传统做法,全面改用 AJAX 上传加进度监听,才第一次在浏览器里看到平滑增长的进度条。所以这篇文章写的不是理论的代码片段,而是已经把进度机制跑在真实项目里的方案。
2. 上传进度的监听原理
这一节我们往底层看一看。当你需要“监听上传进度”,本质上是在监听一个不可见的传输通道。要理解这个通道,就必须弄明白XMLHttpRequest对象的设计结构。
一个 XHR 实例被创建出来之后,它主要体现在两个方向上监听事件:一个是关于整个请求/响应周期的事件,比如load、error、timeout;另一个就是专门针对上传过程的事件,集中在xhr.upload这个对象上。我一开始学这个 API 时容易混淆:什么时候用xhr.addEventListener,什么时候用xhr.upload.addEventListener?一个简单记忆口诀是:凡是和“数据发往服务器”这件事相关的事件,都挂在xhr.upload上;凡是和“整个请求生命周期或响应返回”相关的事件,都挂在xhr本身上。
2.1 progress事件的对象属性
progress事件回调的参数是一个ProgressEvent对象。这里面最核心的三个只读属性是:
lengthComputable:布尔值,表示进度信息是否可计算。如果服务器或者浏览器拿不到总长度,这个值会是false,这时候强行计算百分比会得到NaN。loaded:已经传输的字节数。total:需要传输的总字节数。
以下是一个我用在多个项目里的基础判断公式:
xhr.upload.addEventListener('progress', (event) => { if (event.lengthComputable) { const percentComplete = Math.round((event.loaded / event.total) * 100); console.log(`上传进度:${percentComplete}%`); } });这个lengthComputable判断不是多余的。我测试过非常规文件上传(比如后台用分块编码响应),如果后端接口对上传数据流没有给出明确的长度头,浏览器对total的估算就可能不准确。所以在写通用组件时,务必要加上这个防御性条件,否则有些场景下进度条会莫名其妙跳到 100%,特吓人。
2.2 浏览器兼容性实测
关于浏览器兼容性,在实际开发里我见过不少团队成员担心得过度。其实现代主流浏览器对xhr.upload的progress事件支持非常完整,包括桌面端和移动端主流的 WebView。唯一会遇到问题的老环境,是早就应该淘汰的旧版浏览器。
一个值得注意的兼容点不是事件本身,而是网络请求的预检。如果你在做跨域上传,浏览器会先发送一个OPTIONS请求做 CORS 预检。这个预检请求不携带业务数据,但progress事件对它不会有任何反馈。很多新人遇到“为什么进度条没反应”的时候,第一反应是代码写得不对,其实是接口跨域配置缺失,请求根本没走到发送数据这一步。排查方法很简单:浏览器开发者工具里看网络面板,如果发现只有一条OPTIONS请求且报跨域错误,那一定不是前端监听出了问题,是后端没设置正确的Access-Control-Allow-Origin头。
处理预检问题还有一个额外细节。如果后端配置了Access-Control-Max-Age,浏览器会缓存预检结果,这样后续上传同一个接口的多个文件就不用每次重新发OPTIONS请求,上传体验更顺滑。
3. 一个能直接跑的完整上传实现
原理看完了,下面进入实操阶段。我会分两部分来讲:前端部分就是大家熟悉的上传控件,后端部分我用一个最小可运行的 Node 接口来模拟接收文件,方便你本地验证。前端代码我会写得“啰嗦”一点,因为很多关键的细节就藏在这些啰嗦里面。
3.1 前端核心代码拆解
先看 HTML 部分,我们需要一个文件选择按钮、一个进度条容器、以及一个能显示文本状态的区域。这里为了让示例足够简单,不考虑样式美化,只保证功能链路完整。
<div id="uploadArea"> <input type="file" id="fileInput" /> <button id="uploadBtn">开始上传</button> <div class="progress-wrapper"> <div id="progressBar" class="progress-bar" style="width: 0%;"></div> </div> <span id="progressText">0%</span> <span id="statusText"></span> </div>接下来是核心 JavaScript。第一步,把用户选择的文件对象抓出来,塞进FormData里,然后创建一个 XHR 请求。
const fileInput = document.getElementById('fileInput'); const uploadBtn = document.getElementById('uploadBtn'); const progressBar = document.getElementById('progressBar'); const progressText = document.getElementById('progressText'); const statusText = document.getElementById('statusText'); uploadBtn.addEventListener('click', () => { const file = fileInput.files[0]; if (!file) { alert('请先选择文件'); return; } // 创建 FormData,一个 key 对应一个文件 const formData = new FormData(); formData.append('file', file); // 如果你有其他业务字段,比如文件所属分类、上传人 ID,直接 append 进来 formData.append('categoryId', '1024'); formData.append('uploader', 'blog-reader'); // 1. 创建 XHR 实例 const xhr = new XMLHttpRequest(); // 2. 设置请求参数:第三个参数 true 表示异步 xhr.open('POST', '/api/upload', true); // 3. 给上传事件挂监听 xhr.upload.addEventListener('loadstart', () => { statusText.textContent = '开始连接服务器...'; }); xhr.upload.addEventListener('progress', (event) => { if (event.lengthComputable) { const percent = Math.round((event.loaded / event.total) * 100); progressBar.style.width = percent + '%'; progressText.textContent = percent + '%'; } }); xhr.upload.addEventListener('load', () => { statusText.textContent = '上传完成,等待服务器响应...'; }); xhr.upload.addEventListener('error', () => { statusText.textContent = '上传过程中发生网络错误'; }); xhr.upload.addEventListener('abort', () => { statusText.textContent = '上传已取消'; }); // 4. 监听请求整体完成的回调 xhr.addEventListener('load', () => { if (xhr.status >= 200 && xhr.status < 300) { try { const response = JSON.parse(xhr.responseText); statusText.textContent = `上传成功,服务器返回:${response.url || '已保存'}`; } catch (e) { statusText.textContent = '上传成功,但服务器返回了非 JSON 内容'; } } else { statusText.textContent = `上传失败,HTTP 状态码:${xhr.status}`; } }); xhr.addEventListener('error', () => { statusText.textContent = '请求遇到网络错误'; }); // 5. 真正发送数据,FormData 会由浏览器自动设置 Content-Type 为 multipart/form-data,带 boundary xhr.send(formData); });我想特意解释一下第 5 步,很多新人容易在这里犯错。如果你自己手动给 XHR 设置了Content-Type: multipart/form-data,然后直接把FormData塞进去,很可能导致上传失败。因为multipart/form-data的请求体需要带一个boundary参数来分隔多个文件片段,这是由浏览器自动生成的。你一旦手动指定 Content-Type 而遗漏了boundary,服务器解析不了数据体就会报错。正确做法是:
// 错误示范:不要手动设置 Content-Type xhr.setRequestHeader('Content-Type', 'multipart/form-data'); // 正确做法:不主动设置,交给 FormData 自动处理 let formData = new FormData(); formData.append('file', file); xhr.send(formData);这个坑我真实踩过。当时我用fetch做过一次 API 请求,习惯性设置了头部,后面换到 XHR 也顺手写了一段,结果文件上传一直失败,打开控制台才发现boundary丢失导致整个数据体解析不了。后来刷教训的经验就是:手动设置Content-Type可能需要,但要分场景。上传 FormData 时,请让规范和浏览器帮你搞定。
3.2 后端最小接收Demo
我不打算在这里写一个工业级后端,只给一个本地可验证的最小例子。以 Node.js 的内置 http+ 第三方formidable为例,因为formidable处理 multipart 数据非常稳定且配置简单。
const http = require('http'); const formidable = require('formidable'); const fs = require('fs'); const path = require('path'); const server = http.createServer((req, res) => { if (req.method === 'POST' && req.url === '/api/upload') { const uploadDir = path.join(__dirname, 'uploads'); if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir); } const form = new formidable.IncomingForm(); form.uploadDir = uploadDir; // 保持原有文件后缀,方便检查 form.keepExtensions = true; form.parse(req, (err, fields, files) => { if (err) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: '上传失败' })); return; } const uploadedFile = files.file && files.file[0]; console.log('接收到的业务字段:', fields); console.log('保存的文件路径:', uploadedFile ? uploadedFile.filepath : '无'); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ url: `/uploads/${uploadedFile ? uploadedFile.newFilename : ''}` })); }); return; } res.writeHead(404); res.end(); }); server.listen(3000, () => { console.log('测试服务器已启动: http://localhost:3000'); });注意formidable不同版本的 API 略有差异。我在例子中使用的是新版的“数组式返回”(files.file是数组),如果你用的还是老版本,可能要改成files.file直接是对象。具体以你安装的版本为准。
前端启动时,要把上传接口地址改成http://localhost:3000/api/upload。不出意外,你选好文件点按钮,就能在页面上看到平滑变化的进度了。如果上传大文件,建议你把浏览器开发者工具切到 Network 面板,盯着那条 API 请求的 Time 时间轴看,很有意思。
4. 从能用走向靠谱:细节打磨与生产级扩展
走通基础链路并不难,但一个能接受真实用户考验的上传模块,远不止进度条显示百分比这么简单。实际生产中,你还得面对文件大小校验、上传速度估算、取消上传、断线重试等问题。我按自己的实践经验,把关键的打磨点一个个列出来。
4.1 限制文件类型与大小
前端做校验是用户体验的第一道防线,但这道防线并非安全边界。后端的校验才是必须的。我遇到过一个情况:内部同事上传一个 5GB 的设计源文件,前端没做限制,后端也没有限制,结果服务器磁盘直接写满。那次事故之后,我的上传模块一定会做两层限制。
前端部分,在用户选中文件时马上检查:
const MAX_SIZE_MB = 500; // 单位MB const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'application/zip', 'application/pdf']; fileInput.addEventListener('change', () => { const file = fileInput.files[0]; if (!file) return; // 类型判断 if (!ALLOWED_TYPES.includes(file.type)) { alert('不支持的文件类型'); fileInput.value = ''; return; } // 大小判断 if (file.size > MAX_SIZE_MB * 1024 * 1024) { alert(`文件不能超过 ${MAX_SIZE_MB}MB`); fileInput.value = ''; return; } });不过有一点要提一下,file.type有时候可能是空字符串,比如某些系统导出的无扩展名文件。这时候不能只靠 MIME 类型判断,还要结合文件后缀名。更稳妥的方案是读文件头部的魔数(Magic Number),不过这对一般业务场景来说有点过度设计了,我通常只做后缀名 + MIME 的双重判断。
4.2 计算上传速度与剩余时间
进度条只有百分比还不够,用户更关心的其实是“还要等多久”。在上传大文件时,我习惯额外展示实时速度和预估剩余时间。
实现思路很简单:我们在progress事件里,不仅仅记录当前百分比,还记录这个事件发生时的时间戳。用两次事件之间的字节差除以时间差,就能得到平均速度。至于剩余时间,就是剩余字节数除以速度。
代码可以这么写:
let lastLoaded = 0; let lastTime = Date.now(); let speed = 0; xhr.upload.addEventListener('progress', (event) => { if (!event.lengthComputable) return; const now = Date.now(); const timeDiff = (now - lastTime) / 1000; // 秒 if (timeDiff > 0) { speed = (event.loaded - lastLoaded) / timeDiff; // 字节/秒 lastLoaded = event.loaded; lastTime = now; } const percent = Math.round((event.loaded / event.total) * 100); const remainingBytes = event.total - event.loaded; const remainingSeconds = speed > 0 ? Math.ceil(remainingBytes / speed) : '--'; progressText.textContent = `${percent}%`; speedText.textContent = `速度:${formatSpeed(speed)}`; if (typeof remainingSeconds === 'number') { timeText.textContent = `剩余:${formatTime(remainingSeconds)}`; } }); function formatSpeed(bytesPerSecond) { if (!bytesPerSecond || bytesPerSecond <= 0) return '0 B/s'; const units = ['B/s', 'KB/s', 'MB/s', 'GB/s']; let index = 0; let value = bytesPerSecond; while (value >= 1024 && index < units.length - 1) { value /= 1024; index++; } return `${value.toFixed(1)} ${units[index]}`; } function formatTime(totalSeconds) { if (totalSeconds === '--') return '--'; const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; if (hours > 0) return `${hours}小时${minutes}分${seconds}秒`; if (minutes > 0) return `${minutes}分${seconds}秒`; return `${seconds}秒`; }这里有个小提示,由于progress事件触发的频率非常高,如果你在回调里做 DOM 操作,可能造成不必要的性能损耗。对于进度条这种高频更新的场景,我建议用requestAnimationFrame做节流,确保一帧只更新一次 DOM:
let rafId = null; xhr.upload.addEventListener('progress', (event) => { if (rafId) return; rafId = requestAnimationFrame(() => { // 更新真实 DOM 操作在这里 updateProgress(event); rafId = null; }); });而且这样一个小改动,在高分屏和低端设备上的体验差异非常明显,进度条会更平滑,页面交互也不发卡。
4.3 支持取消上传
用户传错了文件怎么办?或者传了一分钟发现格式不对想重新选?没有取消按钮的上传模块,用户体验是不完整的。XHR 提供了一个开箱即用的方法:xhr.abort()。
实现上,我们需要在用户点击取消按钮时调用abort(),并且注意事件回调的区分——abort事件会触发,而不是error事件。这时候statusText应该明确提示用户“上传已取消”,而不是笼统的“上传失败”。
let xhr = null; cancelBtn.addEventListener('click', () => { if (xhr) { // abort 会触发 xhr.upload 上的 abort 事件 xhr.abort(); // 重置界面状态 progressBar.style.width = '0%'; progressText.textContent = '0%'; } });另外,取消之后记得把全局变量xhr置空,避免下次点击上传时拿到一个已终止的实例。
4.4 断点续传的简单思路
严格意义上的断点续传,需要后端配合支持 Range 请求,前端还要做切片(分块)上传。这个主题就算单独写三篇文章也讲不完,但我可以在这里给出一个演进路径,让你有个方向感。
第一步:不要想着直接做断点续传,先把上传列队和失败重试机制做好。比如,把文件切成 10MB 一片的分片,每片单独发送一个请求,服务端记录每个分片的上传状态。上传到一半失败了,前端只需重传失败的几片,而不是重新上传整个文件。
第二步:为每个分片生成一个唯一标识,比如文件名 + 文件大小 + 分片序号做哈希,用来做幂等控制。服务端判断这个分片是否已经存在,如果存在就直接返回成功,前端就可以跳过该分片。
这个方案的技术核心依旧是 XHR 或 Fetch,但会引入并发控制、切片算法、哈希计算等额外复杂度。如果不是业务确实需要,比如上传 10GB 以上的大文件,我不建议一开始就上这套方案,成本和维护风险都不低。
5. 常见问题与排查技巧实录
写到这里,我想到自己在真实项目里排查过的各种上传问题。这些问题如果你没遇到过,可能觉得没必要看,但真要遇到一个,排查起来还是很耗时间的。我把它们整理成一个速查表,再加一些细节说明,方便你直接对照。
5.1 典型问题速查表
| 问题现象 | 可能原因 | 排查思路 |
|---|---|---|
| 进度条不动,一直在0% | 跨域预检失败或后端接口未通 | 打开Network面板,确认请求是否发出,查看状态码和响应内容 |
| 进度条瞬间跳到100%,但接口报错 | 后端已经快速拒绝请求,浏览器提前结束进度 | 检查后端日志,看具体报错原因,一般不是前端问题 |
| 进度达到100%但界面没有返回成功 | 服务器还在处理文件保存 | 需要在xhr的load事件里做业务判断,不是xhr.upload的load |
| 大文件上传到99%卡住 | 后端处理超时或代理服务器主动断开 | 排查服务端配置,考虑加大超时时间,或者使用分片上传 |
| 上传期间浏览器假死 | progress回调触发了大量重操作 | 检查是否在回调里做了复杂计算或频繁DOM操作,引入requestAnimationFrame节流 |
事件监听了xhr.upload但却不触发 | 请求没有把数据发送到服务端,可能被CORS拦截 | 确认跨域配置,以及是否开启了异步(open第三个参数是否为true) |
5.2 另一个独门排查点:慎用gzip
在排查过程中我曾经遇到过一个隐蔽问题:后端开启了对上传接口的gzip压缩,导致Content-Length和实际传输字节不一致,进度条表现得很奇怪。实际上对于文件上传这种二进制流,实施传输压缩通常是不合适的(图片、视频本身已压缩)。如果后端对上传接口误开gzip,甚至可能在服务端解压时出现流错误。
排查思路很简单:Network 面板里查看上传请求的Content-Encoding响应头,如果是gzip,且上传的是不可进一步压缩的二进制文件,就要考虑把该接口从压缩中间件里排除。
5.3 后端输出日志经验
你可能觉得排查问题主要靠前端控制台。但我要说一个我这几年养成的重要习惯:上传类接口,后端日志一定要包含三个关键要素——客户端IP、文件名称、文件大小。前端出问题时,后端日志是定位的第一手资料。
我自己的后端日志输出一般是这样:
console.log(`[上传请求] IP=${req.socket.remoteAddress}, 文件=${fileName}, 大小=${fileSize}字节, 用户代理=${req.headers['user-agent']}`);为什么这么重要?因为上传涉及网络链路,可能经过负载均衡、网关代理。如果前端反馈“上传总是失败”,而后端日志里连请求都没收到,那就说明问题出在网络上,而不是服务端代码。这类问题如果不配合日志,排查方向很容易跑偏。
6. 我的几点实在体会
最后分享一些结构上没法安放,但我又觉得非常值得说的经验。
第一,关于upload事件的load和xhr自身的load,这两个事件前后顺序别搞混。用一句话总结就是:xhr.upload上的load代表“浏览器已经把所有数据发出去了”,而xhr上的load代表“服务器已经完整返回了响应”。有些新手在xhr.upload的load事件里就去解析服务器返回的 JSON,结果拿到的xhr.responseText是空的或者不完整。这个坑比较隐蔽,我也见别人踩过,值得提前避开。
第二,本地调试上传大文件时,如果你用的是本地网络,速度往往极快,进度条一闪而过,看不出真实效果。建议你用开发者工具的 Network 面板里的网络节流模拟一个慢速环境(比如 100Mbps),然后选中一个 50MB 左右的文件,就能看到非常漂亮的进度增长动画了。
第三,上传文件的成功判断,最好不要只依赖 HTTP 状态码。我会在后端接口设计约定里固定格式:返回 JSON 且success=true才算业务成功,否则就按失败处理。因为有些网关在某些场景下可能返回 200 但其实把错误消息放在了响应体里。用xhr.status判断网络层,用业务字段判断逻辑层,这套规则能帮你少踩很多莫名其妙的坑。
第四,如果你做的是一个面向很多用户的公共站点,不要把所有上传动作都揉进一两个全局方法里。建议至少抽象出上传单文件、批量上传、校验文件、取消上传这几个独立的模块函数,方便不同页面按需复用。上传逻辑看起来简单,但塞进业务代码后,复杂度会不受控制地增长。早一点抽象好,后面心情能好很多。
这个上传组件的改造,从最初的原生form提交,到后来具备进度条、取消、速度估算、分片上传接口,前后迭代了不少版本。但最值得借鉴的经验,永远是先把XMLHttpRequest上传这条链路彻底吃透。你懂了这个基础,后面无论是换Axios的封装,还是接更复杂的断点续传,都会觉得各种套路似曾相识。