资讯动态

原生XHR文件上传实战:从进度条到分片全解析

发布时间:2026/10/21 13:23:18 来源:尧图企业网站定制
上传文件这个功能前端项目里太常见了。我见过不少人平时用框架和工具库都用得很熟练一遇到不用第三方库、直接手写上传的场景就不知道该从哪一步下手。尤其是碰到进度条需求、取消上传需求、或者需要把多个文件按字段一次性发给后端的时候很多人直接被卡住。这篇文章就用原生 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 typefile idfileInput acceptimage/* button iduploadBtn上传/button div idprogressWrap styleheight:16px;border:1px solid #ccc; div idprogressBar styleheight:100%;width:0;background:#4caf50;/div /div div idstatusText请选择文件/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...ofFileList 不是数组但可迭代 // 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。但acceptimage/*只是文件选择框的一个筛选提示用户完全可以从“所有文件”里选一个非图片文件绕过限制所以代码里必须再判断一次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超时、取消、网络错误会不会互相干扰。这几个点想清楚了上传模块怎么写都不会出大问题。我在项目里见过太多因为这几种事件混为一谈而排查半天的情况提前梳通一次后面能省下不少事。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑