
前两天接手一个内部小工具需要做一个文件上传功能前端不能引第三方库也没法上构建工具就一个纯静态页面加原生JavaScript。算了一圈最后回到最基础的XMLHttpRequest也就是大家常说的原生xhr。很多人觉得xhr太老但真正上手之后会发现在文件上传这个场景里原生xhr的进度事件、取消机制、超时控制都是完整可用的而且逻辑非常透明没有黑盒。这篇文章就把我这次实践的过程和踩过的坑完整写出来包括最小可用代码、进度条的实时计算、上传中断与重试、多文件并发控制以及几个特别容易被忽略的细节。适合想把上传功能搞清楚、或者需要在没有框架依赖的环境里快速实现上传的开发者。1. 为什么文件上传这件事原生xhr依然没有退出历史舞台1.1 同fetch相比xhr独有的上传进度事件fetch是ES6之后非常方便的请求方案但它的设计目标是面向普通API请求流式读取进度一直需要借助ReadableStream并且浏览器支持参差不齐。xhr天生就有progress事件而且专门区分了下载进度和上传进度xhr.upload对象可以单独监听progress拿到loaded和total这两个值直接对应已经发送的字节数和总字节数。这意味着我们不需要额外依赖不需要解析流只需要几行代码就能给用户一个实时百分比。一个很直接的对比是fetch想拿到上传进度需要自己封装Request body的流还要处理不同浏览器对duplex属性的支持问题很多时候写着写着就变成调试浏览器兼容性。而xhr的upload.onprogress从IE时代就存在到现在所有主流浏览器都支持使用体验非常稳定。1.2 同axios库相比原生方案更轻量、可控axios底层其实也是xhr它只是包了一层Promise做了请求拦截、响应拦截、错误归一化这些封装。但如果项目里只是要一个文件上传功能为了它引入整个axios不仅增加体积还多了一层抽象。遇到问题时比如上传二进制大文件、需要取消请求、或者想拿到网络层原始状态码反而要去扒axios源码才能知道它做了什么。原生xhr的逻辑很直白open指定方法和地址setRequestHeader设置头部send发送数据事件监听器处理状态变化。每个环节都暴露在面前我可以精确控制哪里加header、哪里加参数、哪里做超时处理。对于学习了基础前端的人来说熟悉原生xhr也有助于理解axios的封装原理。1.3 适用场景与选型建议并不是说所有项目都要退回原生xhr。如果项目本身已经在用axios就完全没必要把上传逻辑单独拆成原生写法统一用axios的FormData上传即可。但如果遇到下面这些情况原生xhr会是更合适的选择项目是零依赖的老页面或者轻量工具页不想为了一个功能引入资源。需要精细控制上传进度、取消上传、即时重试而且不想被Promise的catch逻辑干扰网络错误细节。需要兼容老版本浏览器尤其是企业内网环境。想真正理解HTTP上传的底层细节适合学习或做一些底层的上传SDK。我在实践中的体会是对于简单的上传场景原生xhr不仅代码量少而且调试起来比封装的库更直观打开开发者工具能看到非常清晰的网络请求过程。2. 从零实现一个最小可用的xhr上传模块2.1 核心代码FormData xhr.send文件上传最基础的方式就是FormData。它可以把文件对象安放到一个multipart/form-data格式的请求体里浏览器会自动生成boundary分隔符并把每个字段的name、文件名、Content-Type都带上。下面是最小实现function uploadFile(file, url, onProgress) { return new Promise((resolve, reject) { const xhr new XMLHttpRequest(); const formData new FormData(); formData.append(file, file); xhr.open(POST, url, true); xhr.upload.onprogress (e) { if (e.lengthComputable onProgress) { const percent Math.round((e.loaded / e.total) * 100); onProgress(percent, e.loaded, e.total); } }; xhr.onload () { if (xhr.status 200 xhr.status 300) { resolve(xhr.responseText); } else { reject(new Error(HTTP ${xhr.status}: ${xhr.statusText})); } }; xhr.onerror () reject(new Error(network error)); xhr.send(formData); }); }这段代码已经覆盖了80%的基础需求。FormData.append(file, file)把File对象放进去浏览器会把文件名作为filename字段发送。xhr.open第三个参数必须是true确保异步执行否则UI会卡死。send(formData)会由浏览器自动设置Content-Type: multipart/form-data; boundary...不需要手动指定。2.2 关键细节为什么不要手动设置Content-Type很多人会习惯性地加一行xhr.setRequestHeader(Content-Type, application/json)然后上传就失败了。原因是FormData需要使用的是带boundary的multipart/form-data格式而这个boundary是浏览器在send时动态生成了插入请求体的。如果手动设置一个固定的Content-Typeboundary就丢失了服务端解析不出文件边界自然收不到文件。如果非要手动设置一定要带上完整的boundary值而且这个值必须和请求体里实际使用的分隔符完全一致。实际开发中根本没必要这么做正确的做法就是让浏览器自己处理FormData的Content-Type。如果需要额外传递参数用formData.append(token, token)添加到表单里而不是用Header。2.3 服务端如何接收以Node环境为例如果服务端使用框架路由一般需要内置的body解析器处理multipart/form-data。这里给一个简单思路使用Koa框架时可以配合中间件解析上传的文件文件到达后会被存成临时文件或Buffer。使用Express框架时可以使用对应的上传中间件把上传的文件信息挂在req.file或req.files上。服务端拿到文件之后要验证文件大小、MIME类型、扩展名再落盘或者转存到对象存储。这里有一个重要的安全点文件名不能直接使用客户端传来的原始文件名拼接路径必须做白名单校验和重命名。否则遇到../../这种路径名会出大问题。我在测试时遇到过一个问题本地postman直接传文件没问题但前端xhr传上去之后服务端报字段名不存在。排查到最后发现是前端formData.append(file, file)中的字段名是file而后端的中间件默认接收的字段名也是file看起来没有错。问题出在用户选择文件后我传了一个dataURL字符串而不是Blob对象导致字段类型不对。后来改成用e.target.files[0]获取原始File对象立即就通了。2.4 一个可以直接用的函数封装在实际项目里除了基础上传还要处理超时和取消。我把常用配置做成了参数对象function uploadFileWithOptions({ url, file, name file, data {}, timeout 30000, withCredentials false, onProgress, }) { return new Promise((resolve, reject) { const xhr new XMLHttpRequest(); const formData new FormData(); formData.append(name, file); Object.entries(data).forEach(([key, value]) { formData.append(key, value); }); xhr.open(POST, url, true); xhr.timeout timeout; xhr.withCredentials withCredentials; xhr.upload.onprogress (e) { if (e.lengthComputable onProgress) { onProgress(e.loaded, e.total); } }; xhr.onload () { if (xhr.status 200 xhr.status 300) { let result; try { result JSON.parse(xhr.responseText); } catch { result xhr.responseText; } resolve(result); } else { reject(new Error(HTTP ${xhr.status})); } }; xhr.onerror () reject(new Error(network error)); xhr.ontimeout () reject(new Error(timeout)); xhr.send(formData); }); }这里把withCredentials透传出来是为了处理需要携带Cookie的跨域请求。如果是同源页面不需要设置它。超时时间我习惯给30秒但如果是视频或大压缩包要根据实际网络环境调整网络差的公司内网建议放宽到2-5分钟。3. 上传进度条的实时计算与展示优化3.1 progress事件的数据结构xhr.upload的progress事件会返回ProgressEvent对象里面有四个关键字段loaded已经上传到服务器的字节数不是内存里读完的数据而是浏览器确认发送出去的字节数。total本次上传总字节数通常等于file.size但不一定总是有值。lengthComputable布尔值表示能否计算总长度。如果服务器或浏览器无法得知总长度这个值会是false此时total可能是0。特别说明一下监听对象是xhr.upload不是xhr本身。很多人写xhr.onprogress结果拿到的响应下载进度上传时一直不触发然后以为是浏览器问题。3.2 进度百分比、上传速度和剩余时间的计算百分比计算比较简单const percent e.lengthComputable ? Math.round((e.loaded / e.total) * 100) : 0;上传速率和剩余时间需要自己维护历史数据。我一般会记录上次的loaded和timestamp每次都计算差值let lastLoaded 0; let lastTime Date.now(); let speedBytesPerSec 0; function onProgress(e) { const now Date.now(); const deltaTime now - lastTime; if (deltaTime 200) { const deltaLoaded e.loaded - lastLoaded; speedBytesPerSec deltaLoaded / (deltaTime / 1000); lastLoaded e.loaded; lastTime now; } const remainingBytes e.total - e.loaded; const remainingSecs speedBytesPerSec 0 ? Math.ceil(remainingBytes / speedBytesPerSec) : 0; }注意这里要做时间间隔判断因为progress事件触发频率可能非常高如果每次都计算速度数值会跳动得很厉害。200毫秒的间隔既能保证UI的响应感又能平滑速度波动。3.3 节流与动画刷新避免过度渲染progress事件在快速网络下可能一秒钟触发几十次如果每一次都去操作DOM更新进度条页面会频繁重绘。我的做法是借助requestAnimationFrame做标记达到节流效果let rafId null; function onProgress(e) { if (rafId) return; rafId requestAnimationFrame(() { updateUI(e); rafId null; }); }这样即使事件风暴来了每帧最多更新一次不再浪费性能。如果是纯文本百分比数字也可以用节流函数限制到100ms更新一次效果一样。这里有一个经验进度条数值不要在90%以上反复跳动如果服务和前端没有做最后完成确认就把它固定在90%等真实的load事件否则用户会奇怪为什么最后10%卡那么久。3.4 文件大小未知时的兜底方案有些场景下e.total会是0。比如某些老旧的浏览器或者从流式来源读取文件时lengthComputable为false。这种情况下不能显示百分比可以改为显示已上传的字节数比如已上传 3.2MB或者用一个不确定进度的动画条。我的处理逻辑是if (e.lengthComputable) { progressContainer.style.width ${percent}%; } else { progressContainer.classList.add(indeterminate); }不确定进度条用CSS动画去循环滑动用户至少知道进程还在跑而不是卡死。4. 上传取消、超时与网络异常的处理4.1 abort()的正确姿势上传过程中用户点了取消需要调用xhr.abort()。关键是不要只调abort()要理解abort之后触发什么事件。abort()会停止当前请求并触发xhr.onabort。同时xhr.upload会上抛一个progress事件但此时loaded不会增长。正确做法是let xhr null; function cancelUpload() { if (xhr) { xhr.onabort () { // 在这里清状态而不是在外部再判断一次 setUploadStatus(cancelled); }; xhr.abort(); } }有个常见的坑是调用abort之后onload也会触发而且某些浏览器会把它包装成status0的结果。如果在上面的Promise封装里abort后onload执行并走了reject但马上外部又把xhr置空了会导致回调混乱。所以封装时最好增加一个isAborted标志let aborted false; xhr.onabort () { aborted true; reject(new Error(upload aborted)); }; xhr.onload () { if (aborted) return; // 原有逻辑 };这样abort事件和load事件不会互相干扰。4.2 timeout和网络错误的分场景处理上传大文件时如果网络波动可能很久没有任何数据发送出去。xhr.timeout设置的是请求超时时间但它表示的是整个请求从开始到结束的最大时间还是空闲超时不同浏览器对超时的定义不完全一致。规范上是请求超时但实际体验中我观察到的行为是如果在超时前有任何数据传输计时会重置或至少不会精确按总时长掐断。因此靠timeout不太适合做断网后马上提示失败的需求。更好的做法是用setTimeout做兜底检测比如每30秒检测一次最近是否还有progress事件如果没有任何进展就主动abort并提示上传停滞。这样才能避免一次大文件上传卡在99%用户干等了十分钟。4.3 重试机制前端能做与不能做的简单的重试逻辑就是捕获到错误后再次调用上传函数。但要注意区分错误类型网络错误xhr.onerror触发可能是断网、DNS解析失败、连接被拒绝。这种可以重试但要设定次数上限比如最多3次且每次间隔递增。HTTP错误比如413(文件过大)、403(权限不足)、422(文件类型不允许)这些重试没有意义应该直接把错误展示给用户。超时如果是网络拥堵导致超时可以重试但最好提醒用户检查网络。前端的重试很容易做到但有一个关键点是当上传失败时服务端可能已经接收到了一部分数据。如果你用的是一次性POST整个文件那么服务端必须提供幂等性保障否则重试可能会造成重复数据或半截文件。简单的方案是每次上传时生成一个唯一的uploadId服务端根据这个ID判断同一文件的重复上传内容。4.4 与axios的cancelToken对比axios提供CancelToken来取消请求但本质上它的取消机制仍然是调用底层的xhr.abort()。在axios里取消上传会触发cancel事件并不会抛给.catch一个普通Error。如果对原生xhr的abort事件理解到位了再看axios的cancelToken就很简单不过是你自己包了一层Promise而已。我个人不喜欢为了取消一次上传就引入一个第三方库因为原生xhr给的能力已经完全够了。5. 多文件、并发与队列控流5.1 多文件上传的界面与数据结构多文件上传的交互一般有两种一次选择多个文件逐个上传或者用户多次选择文件先加入列表再统一上传。我倾向于第二种因为用户可以修改、删除然后再批量提交。数据结构可以这样组织const fileList []; // 每个文件条目 { uid: file_${Date.now()}_${index}, file: File对象, status: pending | uploading | success | error | cancelled, progress: 0, response: null, error: null, xhr: null, }每个条目保存自己的xhr引用是为了支持单独取消。如果某个文件上传失败只修改这一条的状态并在UI上显示重试按钮。5.2 并发数限制的实现简单Promise池如果一次性上传几十个大文件所有请求同时发出浏览器会因为连接数限制而排队还可能占用大量内存。比较好的做法是限制并发数比如同时最多3个请求。实现思路不复杂维护一个待上传队列始终保证正在执行的请求数不超过并发上限。async function runWithConcurrency(tasks, limit) { const results []; const executing []; for (const task of tasks) { const promise Promise.resolve().then(() task()); results.push(promise); if (limit tasks.length) { const whenComplete promise.then( () executing.splice(executing.indexOf(whenComplete), 1) ); executing.push(whenComplete); if (executing.length limit) { await Promise.race(executing); } } } return Promise.all(results); }这个Promise池每次推入一个任务当执行数量达到上限时等待任意一个任务完成再继续推入。如果有100个文件限制并发3内存中同时只有3个xhr对象整个队列很平稳。5.3 断点续传的前置思路切片上传严格来说断点续传需要服务端配合。前端能做的核心工作就是切片。思路是把文件读成若干块比如每块2MB依次上传每一块同时记录哪些块已经成功。xhr上传单个切片时两边的进度都非常好计算失败后重试时只需要把未成功的切片重新传一遍。切片上传有几个细节文件名要带上文件的hash或唯一标识避免服务端把不同文件的同名切片混淆。每个切片请求要有序号服务端按序号拼接。上传完成所有切片后客户端发一个合并请求告知服务端“可以合并了”。切片大小不是越细越好太细会导致请求数激增反而降低效率2MB-8MB通常是比较合理的区间。用xhr做切片不需要额外能力只需要循环读取File.slice(begin, end)创建新的Blob再放进FormData发送。这个能力即使是在老浏览器里也足够用。5.4 上传性能与内存占用大文件上传时不要用FileReader把整个文件读成dataURL或ArrayBuffer再上传会把内存打爆。直接用FormData.append(file, file)传入File对象浏览器内部会以流的方式读取文件内存占用非常小。这一点是原生xhr的优势之一。如果要在上传前预览图片也用URL.createObjectURL(file)生成临时URL用完之后调用URL.revokeObjectURL释放。如果直接把图片文件阵列成base64再上传页面会很明显地卡顿。6. 实战中踩过的坑与排查手记6.1 文件名和字符集问题上传中文文件名的时候如果服务端用filename解析文件名可能会出现乱码。标准做法是前端保证文件名以UTF-8编码并且服务端解析时也按UTF-8解码。但有些网关或中间件有Bug会按ISO-8859-1解码。我在项目里遇到过一种情况文件名里带空格和特殊字符服务端解析时被截断。稳妥的做法是前端除了把文件原样放进FormData之外再额外通过一个自定义字段传递文件名服务端优先读取这个字段作为保存文件名不依赖multipart头里的filename字段。6.2 请求头被服务端解析成字符串或数组如果服务端是Node的某些body解析中间件req.body里可能会把表单字段解析成字符串或者把同名多个字段解析成数组。上传时在同名字段里塞了多个文件服务端只取了一个就会丢失文件。解决方式是前端对每个文件都用不同的字段名比如file_0、file_1或者统一观察服务端收到的字段结构再调整。有一次我调试了很久才发现是字段名重复导致的覆盖行为。6.3 进度事件不触发或total为0进度事件完全不触发的常见原因有三个监听绑定在xhr而不是xhr.upload上。使用了file://协议本地打开页面且请求的地址是跨域的浏览器提前拦截上传根本没有开始。在xhr.send()之后才绑定upload.onprogress有些浏览器允许这样但为了兼容性应该先绑定所有事件再send。total为0则常见于服务端不支持Content-Length计算或者使用了Transfer-Encoding: chunked。如果必须显示百分比最好由前端通过file.size自己传入总大小而不是依赖事件里的total。6.4 跨域携带Cookie的问题当上传接口和页面不同源时默认情况下Cookie不会带上。这时需要xhr实例设置withCredentials true同时服务端返回响应时必须携带Access-Control-Allow-Credentials: true并且Access-Control-Allow-Origin不能是*必须是指定的完整源地址。这个配置不熟悉的话排查起来很容易让人崩溃因为浏览器控制台可能只提示一句泛泛的CORS错误看不到真实原因。如果遇到401先检查Cookie有没有带上如果请求到达了服务端但请求头没带Cookie基本都是withCredentials没设。6.5 最终识别的几个好习惯xhr.upload.onprogress、xhr.onload、xhr.onerror、xhr.ontimeout这些事件要在send()之前全部绑定完成尤其是封装成Promise时顺序千万不要乱。上传成功后务必xhr null或者释放引用避免长列表页面的内存被无用的xhr对象占用。如果同一个文件已经被用户移除但上传还在内存中应主动调用abort()中断避免无效的数据继续跑到服务端。写在最后可能有点返璞归真但我越来越觉得做前端不能只看封装好的库很多底层的库最终也是封装xhr或者Fetch实现的。文件上传这个功能看起来简单里面的进度、取消、超时、队列、并发、错误恢复一整套做下来足够撑起一个完整的模块。如果刚接触这块建议先不用库遇到底层API不太懂的地方就查文档运行一遍再下结论很多印象中的“历史遗留Bug”其实早就被修好了。原生xhr写出来虽然代码朴素但每一步都在自己的掌控范围里查起问题来也是清清楚楚。