前两天接手一个内部小工具,需要做一个文件上传功能,前端不能引第三方库,也没法上构建工具,就一个纯静态页面加原生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-Type,boundary就丢失了,服务端解析不出文件边界,自然收不到文件。如果非要手动设置,一定要带上完整的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也会触发,而且某些浏览器会把它包装成status=0的结果。如果在上面的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写出来虽然代码朴素,但每一步都在自己的掌控范围里,查起问题来也是清清楚楚。
前两天接手一个内部小工具,需要做一个文件上传功能,前端不能引第三方库,也没法上构建工具,就一个纯静态页面加原生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-Type,boundary就丢失了,服务端解析不出文件边界,自然收不到文件。如果非要手动设置,一定要带上完整的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也会触发,而且某些浏览器会把它包装成status=0的结果。如果在上面的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写出来虽然代码朴素,但每一步都在自己的掌控范围里,查起问题来也是清清楚楚。