上传文件这个功能,前端项目里太常见了。我见过不少人,平时用框架和工具库都用得很熟练,一遇到不用第三方库、直接手写上传的场景,就不知道该从哪一步下手。尤其是碰到进度条需求、取消上传需求、或者需要把多个文件按字段一次性发给后端的时候,很多人直接被卡住。这篇文章就用原生 xhr 把上传文件这件事拆到底,从 FormData 组装、请求发送,到进度事件读取、超时取消,再到大文件分片上传的思路,全部过一遍。适合刚接触上传功能、或者想在项目里少依赖几个第三方库的开发者。
浏览器原生的 XMLHttpRequest 确实不如 axios 这种库用起来舒服,但它才是真正掌握主动权的方案。你不需要为了一个上传功能去引入一整层封装,也不需要担心“某天这个库不维护了怎么办”。理解了原生 xhr 的上传机制,再去用任何封装库都是降维打击,出问题排查起来也能一眼看出根因。
这篇文章不绕圈子,直接从为什么选 xhr 讲起,接着给出可复制的完整代码,再讲进度、取消、超时这三个体验关键点,最后把多文件、分片上传这些进阶场景的坑提前排掉,按顺序看完就能直接套进自己项目里。
1. 为什么还用原生 xhr,而不是一上来就引库
1.1 不引库的最大理由,不只是体积
很多人觉得用原生 xhr 是“倒退”,但实际情况恰恰相反。axios 在浏览器环境里的底层就是 xhr,它只是帮你封装了响应拦截、请求拦截、泛型返回这些便利功能。如果你只需要一个上传接口,连带拦截器、错误类型、取消机制全都引进来,项目里多了一段完全用不到的代码,这在维护上是一种负担。
更重要的是理解底层。当我直接操作原生 xhr 时,我对“上传这件事到底发生了什么”有一个完整的掌控:状态码在哪里读、进度事件挂在哪个对象上、什么时候该打断请求、multipart 数据是怎么拼装出来的。这些认知在排查问题时价值极大。我遇到过不止一次,有人用 axios 上传文件失败,通过 console 一查发现是请求头被某个全局拦截器改掉了,但因为他只看到 axios 的封装层,根本想不到问题出在底层请求头里。假如他先理解原生 xhr 的请求生命周期,这个坑一眼就能定位。
不引库的另一个实际好处是:在一个老项目中,可能存在第三方库版本冲突、上传相关 API 被覆盖、或者构建工具限制了某些依赖注入的情况。直接用原生 xhr 写一个独立的上传模块,完全不依赖框架环境,这段代码在任何地方都能跑,这是一个很现实的好处。
1.2 原生 xhr 相比 fetch 的核心优势
可能有人会问,现在不都推荐用 fetch 吗?真相是 fetch 做普通 GET/POST 没问题,但在文件上传这个场景有硬伤:fetch 没有上传进度事件。浏览器提供 XMLHttpRequestUpload 对象,专门用来监听 upload 过程,而这个能力在 fetch 方案里根本不存在。如果你要做一个带进度条的上传组件,fetch 几乎没法优雅实现,只能在服务端配合分块读取数据再推送到前端,成本高、体验也不好。
fetch 在设计上更像一个干净的请求标准,它把请求和响应抽象得很彻底,但也因此舍弃了 xhr 的一些底层能力。xhr 既支持 upload 进度的实时回调,也支持 aborted 状态下的完整事件触发,还允许你手动设置各种请求头并在 send 之前调整参数,这些对上传场景来说都是必需的能力。
还有一个容易被忽略的差异是超时。fetch 默认没有超时概念,需要结合 AbortController 才能实现;而 xhr 只需设置一个 timeout 属性就能触发 ontimeout 事件,逻辑天然就是一个完整闭环。在做真实的文件上传业务时,网络抖动、服务端处理慢、代理缓冲时间长,这些问题遇到一次就知道超时控制有多重要。
2. 基础版本:把文件真正发出去
2.1 第一步:让用户选中文件
选文件的入口是 input 标签。如果你做的上传组件需要同时支持点击选文件和拖拽上传,点击选文件这部分永远是基础,先把它跑通再说。
<input type="file" id="fileInput" accept="image/*"> <button id="uploadBtn">上传</button> <div id="progressWrap" style="height:16px;border:1px solid #ccc;"> <div id="progressBar" style="height:100%;width:0;background:#4caf50;"></div> </div> <div id="statusText">请选择文件</div>用户选中文件后,通过files[0]就能拿到一个 File 对象。File 对象本身就是 Blob 的升级版,包含 name、size、type、lastModified 这些元信息,这些字段在后面的文件校验环节会全部用到,需要先建立一个认知:这不是一个“路径字符串”,而是一个带着二进制数据的内存对象。
如果 input 设置了multiple属性,那么files就是一个 FileList 对象,类型不是数组,但可以循环遍历,后面讲多文件上传时会涉及。现在先把单文件跑通。
2.2 第二步:用 FormData 打包文件
把文件发给服务端,最标准的方式是放进 FormData。
const fileInput = document.getElementById('fileInput'); const uploadBtn = document.getElementById('uploadBtn'); const progressBar = document.getElementById('progressBar'); const statusText = document.getElementById('statusText'); uploadBtn.addEventListener('click', () => { const file = fileInput.files[0]; if (!file) { statusText.textContent = '请先选择一个文件'; return; } const formData = new FormData(); formData.append('file', file); formData.append('businessType', 'avatar'); const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/upload'); xhr.upload.onprogress = (e) => { if (e.lengthComputable) { const percent = Math.round((e.loaded / e.total) * 100); progressBar.style.width = percent + '%'; statusText.textContent = percent + '%'; } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { statusText.textContent = '上传成功'; } else { statusText.textContent = '上传失败,HTTP ' + xhr.status; } }; xhr.onerror = () => { statusText.textContent = '网络异常,上传中断'; }; xhr.send(formData); });FormData 的本质是一个键值对集合,append时第一个参数是服务端接收时的字段名,第二个参数是文件对象或普通字符串。你可以理解成它是一个快递包裹,append每次就是往包裹里放一件东西,服务端收到包裹后按标签整理。除了文件,你可以随时追加业务参数,比如这里的businessType,很多后端接口都需要文件之外的信息来决定存储目录或业务归属。
这段代码里最关键的一个细节是:不要手动设置 Content-Type 请求头。send(formData)时,浏览器会自动把请求体编码成 multipart/form-data,并自动生成一段 boundary 分隔字符串,这个 boundary 会把每个字段分割开。如果你手动设置了Content-Type: application/json,后端解析器就找不到 multipart 分隔边界,拿到的 body 直接是乱掉的串,最终表现就是服务端收不到文件、或者解析不了表单字段。
2.3 第三步:发送请求并拿到结果
xhr.send(formData)之后,请求就发出去了。这时候要注意事件挂载的位置,这块是初学者最容易踩坑的地方。
xhr.upload.onprogress:监听文件上传进度,也就是请求体往服务端发送的过程。xhr.onprogress:监听响应下载进度,也就是服务端返回数据的过程。xhr.onload:请求完成,无论 HTTP 状态是 200 还是 500,都会触发 onload。xhr.onerror:网络层出错,比如断网、连接被重置,这时 onload 不会触发。xhr.ontimeout:超过预设时间仍未完成请求时触发。
很多新手以为放一个onload就行,结果发现接口报错了也没提示,原因就是没区分“HTTP 返回了 500”和“请求根本没到服务端”是两种不同的异常。处理逻辑上,onload里一定要先判断xhr.status,状态在 200 到 299 之间才代表业务上的成功,否则就要走错误分支。
我见过有人把超时和错误混在一起处理,项目上线后大文件上传一慢就疯狂重试,把服务端打挂了。原因就是timeout事件没单独处理,代码把超时当成网络错误,死循环式地重新上传。后面会专门讲超时和取消的配合用法。
3. 进度、取消与超时:上传体验的三个关键
3.1 进度条到底是怎么算出来的
进度事件对象里有三个关键属性:loaded(已发送的字节数)、total(总字节数)、lengthComputable(是否可以计算长度)。进度百分比的核心代码就是前面那段:
xhr.upload.onprogress = (e) => { if (e.lengthComputable) { const percent = Math.round((e.loaded / e.total) * 100); progressBar.style.width = percent + '%'; statusText.textContent = percent + '%'; } };很多人忽视lengthComputable为 false 的情况。假如服务端启用了 chunked 传输编码,或者某些代理层不告诉浏览器总长度,total就是 0,直接算百分比会得出 Infinity 或者一直是 0。所以必须先用lengthComputable判断一次,这项判断几乎是所有可靠进度条的前提。
进度条是上传体验里最直观的部分,一个高质量进度条会让用户觉得系统可靠。反过来说,一个卡在 99% 很久的进度条,会让用户怀疑上传卡死了,实际上数据早已发送完毕,只是在等服务端处理。所以不要在进度到 100% 就立刻提示成功,应该等 onload 事件确认服务端返回成功后,再更新“上传成功”,这样能避免用户看到进度条 100%,却收到一条失败提示的诡异体验。
3.2 取消上传要处理的状态问题
用户选错了文件,或者传到大半突然想放弃,这时候调用xhr.abort()就能取消请求。但有一个细节:abort 之后会触发onabort事件,而onload不会再触发。如果代码里只监听 onload 和 onerror,取消操作就会没有任何反馈,用户看起来就像“按钮点了一下没反应”。
正确的做法是监听onabort,在取消后把 UI 状态复位,比如恢复默认文案、清空进度条、让上传按钮重新可用。我还会在调用 abort 前加一个标志位,避免取消逻辑和错误逻辑互相覆盖。
let isCanceled = false; cancelBtn.addEventListener('click', () => { isCanceled = true; xhr.abort(); }); xhr.onabort = () => { if (isCanceled) { statusText.textContent = '上传已取消'; } }; xhr.onerror = () => { if (!isCanceled) { statusText.textContent = '网络异常,上传中断'; } };加这个标志位的原因很简单:abort 有时会在网络异常时被浏览器内部触发,如果不对它做区分,真正的网络错误会显示成“已取消”,误导用户重试方向。上传功能做久了你会发现,很多问题不是功能本身有多难,而是多个事件互相切换时状态没有理清。
3.3 超时控制不是设个秒数那么简单
xhr.timeout = 120000表示整个请求 120 秒内没完成就触发ontimeout。对小文件上传来说,这个值设短一点问题不大;但大文件上传时,超时时间需要认真评估,因为它覆盖的是整个请求生命周期,包括上传时间和服务端处理时间。
举个实际例子:一个 200MB 的文件在普通家用宽带下传完可能要 40 秒,服务端如果在保存时还要做压缩或转码,再加 20 秒,那么 30 秒的超时就一定会触发超时。合理做法是把超时设置得比“最保守的耗时预期”再宽松一些,以 100MB 文件做基准测试后再确定具体值。另一个经验是,超时后不要立刻自动重传,先对比服务端日志确认文件到底有没有到达,很多时候文件早就到了,只是响应延迟,此时重传会造成重复数据。
4. 多文件上传与文件校验
4.1 多文件字段的处理方式
在上面的 input 标签里加上multiple属性,用户在文件选择框里就能选多个文件。这时fileInput.files是一个 FileList,需要循环处理:
const files = fileInput.files; const formData = new FormData(); for (let i = 0; i < files.length; i++) { formData.append('files', files[i]); } // 也可以直接 for...of,FileList 不是数组但可迭代 // for (const file of files) { // formData.append('files', file); // }这里要理解一个关键点:多个文件共用同一个字段名files,服务端可以通过这个字段名取出一个文件数组。如果后端要求每个文件的字段名不同,那就改成formData.append('file_' + i, files[i])这样带动态字段名。
多文件上传的进度计算和单文件不一样。如果你想让进度条反映“所有文件的整体进度”,需要先算出所有文件大小的总和,再在每个文件的 upload.onprogress 里累计已有字节数。我在实际项目里更推荐“每个文件一个独立上传任务”的方式,也就是循环内创建各自的 xhr 和进度回调,这样能分别展示每个文件的状态,用户更容易明白当前哪几个文件失败、哪几个成功。一次性把所有文件塞进同一个 FormData 的大请求,只要其中一个文件拖后腿,整个任务就会被拖住,体验很差。
4.2 文件类型、大小与数量的策略
前端校验永远只是第一道防线,服务端必须重复校验,但前端校验的意义在于尽早反馈,避免用户浪费时间上传一个注定会被拒绝的文件。
类型校验通过file.type判断 MIME 类型。例如图片是image/jpeg、image/png。但accept="image/*"只是文件选择框的一个筛选提示,用户完全可以从“所有文件”里选一个非图片文件绕过限制,所以代码里必须再判断一次:
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; if (!allowedTypes.includes(file.type)) { statusText.textContent = '仅支持 JPG、PNG、WebP 图片'; return; }大小校验是一个简单但容易写错的地方。file.size的单位是字节,20MB 要写成20 * 1024 * 1024。很多人习惯写成20 * 1000 * 1000,这在小文件上差别不大,但到了 500MB 级别的文件就相差几十 MB,可能导致服务端配置明明允许 500MB,前端却判断成 480MB 就拦截了。
数量校验常被忽略。当用户一次性拖入 100 个文件时,如果限制最多 10 个,就直接取前 10 个并提示用户被截断的数量。这个比默默全传成功更友好,用户至少知道哪些文件没有被处理。
5. 大文件分片上传思路
5.1 为什么要分片,分片到底解决什么问题
单个文件太大时直接整体上传会遇到几个实际痛点。首先是服务器超时风险,前面提到过,还没传完就被 timeout 掐断;其次是失败成本太高,一个 1GB 的文件传到一半断网,重头再来非常折磨人;最后是服务端内存压力,如果服务端把整个请求体一次性读入内存再做后续处理,几十个用户同时传大文件,服务端很容易被拖垮。
分片上传的核心思路是:把 File 对象用切片方法切成多个小块,每一块单独变成一个上传请求,服务端接收后按顺序存储,等所有分片到齐再合并。这样单个请求的体积小,网络波动时只需要重传失败的那一块,而不是整个文件。
File 对象继承了 Blob 的slice方法,切片过程不涉及文件的磁盘 IO,只是按字节范围构建一个新的 Blob 引用,所以切片的开销很小。最常见的切片大小是 5MB 或 10MB,这个值兼顾了请求数量和单请求体积的平衡:分片太大,单个请求超时风险升高;分片太小,请求数暴增,服务端接收合并的开销也变大。
5.2 分片上传的代码与并发策略
先看一段核心的分片逻辑:
function uploadInChunks(file, fileId) { const CHUNK_SIZE = 5 * 1024 * 1024; const totalChunks = Math.ceil(file.size / CHUNK_SIZE); let currentChunk = 0; const uploadNextChunk = () => { if (currentChunk >= totalChunks) { notifyMerge(fileId, totalChunks); return; } const start = currentChunk * CHUNK_SIZE; const end = Math.min(start + CHUNK_SIZE, file.size); const chunk = file.slice(start, end); const formData = new FormData(); formData.append('file', chunk); formData.append('fileId', fileId); formData.append('index', currentChunk); formData.append('totalChunks', totalChunks); const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/upload/chunk'); xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { currentChunk++; uploadNextChunk(); } else { // 单独重试当前分片,或记录失败状态 } }; xhr.send(formData); }; uploadNextChunk(); }第一次写分片上传时,用“传完一块再传下一块”的串行方式最稳。代码逻辑清晰、服务器压力小,也方便调试。后续再改成并发也不迟。
如果要做并发,需要用 Promise 控制一个有限队列,比如同时 3 个分片在传。并发数不建议太高,家用宽带上传带宽有限,并发绕过一定阈值后速度提升非常有限,反而会给服务端带来不必要的连接压力。
async function uploadAllChunks(file, fileId) { const CHUNK_SIZE = 5 * 1024 * 1024; const totalChunks = Math.ceil(file.size / CHUNK_SIZE); const concurrency = 3; const tasks = []; for (let i = 0; i < totalChunks; i++) { const start = i * CHUNK_SIZE; const end = Math.min(start + CHUNK_SIZE, file.size); const chunk = file.slice(start, end); tasks.push(uploadOneChunk(fileId, i, totalChunks, chunk)); } for (let i = 0; i < tasks.length; i += concurrency) { const batch = tasks.slice(i, i + concurrency); await Promise.all(batch); } notifyMerge(fileId, totalChunks); } function uploadOneChunk(fileId, index, totalChunks, chunk) { return new Promise((resolve, reject) => { const formData = new FormData(); formData.append('file', chunk); formData.append('fileId', fileId); formData.append('index', index); formData.append('totalChunks', totalChunks); const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/upload/chunk'); xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(); } else { reject(new Error('分片 ' + index + ' 上传失败')); } }; xhr.onerror = () => reject(new Error('网络异常,分片 ' + index + ' 上传失败')); xhr.send(formData); }); }服务端要做的是:根据 fileId 找到同一个临时目录或表记录,根据 index 落盘,所有分片到齐后按 index 顺序拼接文件。这里要注意,分片上传的文件名、总大小这些元信息应该在第一个分片里一起传上去,或者在合并通知接口里传,服务端用它校验合并后的文件大小是否等于原文件大小。
分片上传一旦实现,断点续传就只是顺带的事:给每个分片记录上传状态,下次开始前询问服务端哪些分片已经存在,跳过已存在的分片继续传剩下的,体验就完整了。这个方案唯一的复杂度在后端,前端只需要维护一个文件 ID 和分片状态队列即可。
6. 常见问题与排查实录
6.1 服务端收不到文件的排查顺序
这个问题排在前端上传问题里的第一位。服务端日志显示一个请求进来,但req.files是空的,或者文件字段完全解析不了。按以下顺序排查,基本都能解决:
| 现象 | 直接原因 | 解决方案 |
|---|---|---|
| 服务端拿不到文件字段 | 手动设置了 Content-Type | 删掉手动设置的请求头,让浏览器自动生成 multipart |
| 字段名对不上 | append 的第一个参数和后端不一致 | 和后端核对字段名,通常压缩包传 file,后端就叫 file |
| 请求根本没发到服务端 | 本地路径写错或跨域拦截 | 检查 Network 面板请求状态,确认是否发出 |
| 后端报 post body 大小超限 | 服务端上传大小限制 | 调整 Nginx 或应用框架的 body 大小配置 |
手动设置 Content-Type 这个坑我在项目里见过太多次,一旦后端解析不了,表现还特别迷惑,因为请求能正常发出去,后端只返回一个 400 或 500,不容易联想到是请求头的问题。排查时可以直接在 Network 面板里看请求头,如果 Content-Type 里没有 multipart/form-data 和 boundary,基本上就是这个原因了。
6.2 进度条不动、跳变、永远到不了100%
进度条的行为异常,通常不是前端代码的问题,而是整个传输链路的问题。最常见的原因是lengthComputable为 false,此时 total 不是真实大小,前端永远算不准。出现这种情况可以去检查服务端的响应头是否使用了 chunked 编码,或者是否经过某个代理层把 Content-Length 吞掉了。
另一个容易忽略的点是进度条反应“太快”反而有问题。如果文件只有几十 KB,网络好时上传过程就 1 秒不到,进度条从 0 跳到 100% 是正常的。但如果是一个 100MB 的文件,进度条几秒内从 20% 冲到 99% 然后卡住很久,说明网络传输早就完成了,剩下的时间全是服务端处理和等待响应。这种场景不要等 onload 才提示成功,如果后端有“文件接收完成”和“业务处理完成”两个阶段,可以考虑单独做个接口轮询或服务端推送,把阶段状态反映到 UI 上。
6.3 跨域场景下的特殊处理
跨域上传在开发环境里极其常见,比如前端跑在本地 8080 端口,后端接口在 3000 端口。xhr 跨域请求默认不发 cookie,也不带凭据,如果接口需要登录态,就要设置xhr.withCredentials = true。
同时服务端必须配置Access-Control-Allow-Origin,注意这个响应头不能是*,必须明确指定来源域名,因为浏览器规定withCredentials为 true 时不允许使用通配符。还有一个容易忽略的点:自定义请求头会触发浏览器预检请求,也就是 OPTIONS 请求。如果你在代码里用setRequestHeader设置了Authorization、X-Requested-With这类头,服务端需要在 OPTIONS 请求的响应里明确允许对应的方法和请求头,否则就算主请求逻辑没问题,浏览器也会先拦截掉预检。
这个问题的排查方式很简单,看 Network 面板里失败请求下方有没有一条红色的 OPTIONS 请求记录。如果有,问题就在 CORS 预检配置上,先补服务端配置,前端不需要做改动。
多说了几句,最后分享一个小经验。原生 xhr 写上传功能时,别急着写代码,先把请求生命周期在脑子里过一遍:事件挂载在 xhr 上还是 xhr.upload 上,onload 里要不要区分 status,超时、取消、网络错误会不会互相干扰。这几个点想清楚了,上传模块怎么写都不会出大问题。我在项目里见过太多因为这几种事件混为一谈而排查半天的情况,提前梳通一次,后面能省下不少事。