JavaScript中如何恢复已中断的文件上传请求?(不使用resumable.js/tus.io)
实现原生XHR文件上传的暂停与恢复
嘿,我之前做原生XHR断点续传的时候踩过不少坑,正好能给你捋清楚怎么实现暂停后的恢复功能——核心就是利用HTTP的Range请求机制,结合客户端记录上传进度、服务器支持断点续传这两点来搞,不用那些第三方库完全没问题。
核心原理
你已经用xhr.abort()实现了暂停,但要注意:abort后的XHR对象是没法复用的,恢复上传必须新建XHR实例。关键思路是:
- 客户端记录已成功上传的字节数
- 恢复时,告诉服务器“我要从第N个字节开始传剩下的内容”
- 服务器接收请求后,把后续字节追加到已有的文件中,而不是覆盖
具体实现步骤
1. 客户端记录上传进度
在上传过程中监听progress事件,实时更新已上传的字节数,暂停时把这个数值存下来(可以用全局变量,也存在localStorage里防止页面刷新丢失)。
2. 服务器端必须支持断点续传
这是最关键的一环,服务器需要处理两个核心逻辑:
- 能识别
Content-Range请求头,知道客户端要从哪个字节开始上传 - 支持以追加/续写的方式写入文件,而不是每次都覆盖原文件
- 可选:支持HEAD请求,让客户端能校验服务器上已接收的文件大小(避免客户端记录的进度和服务器不一致)
3. 恢复上传的客户端实现
新建XHR对象,设置Content-Range请求头,然后发送文件从已上传字节数到末尾的切片。
完整代码示例
客户端代码(浏览器端)
// 全局变量保存上传状态 let activeXHR = null; let uploadedBytes = 0; let targetFile = null; // 初始化上传(传入<input type="file">选中的文件) function initUpload(selectedFile) { targetFile = selectedFile; uploadFromByte(uploadedBytes); } // 从指定字节位置开始上传 function uploadFromByte(startByte) { activeXHR = new XMLHttpRequest(); // 这里的/upload是你的上传接口地址 activeXHR.open('PUT', '/upload', true); // 设置关键请求头:告诉服务器上传的字节范围 activeXHR.setRequestHeader('Content-Range', `bytes ${startByte}-/${targetFile.size}`); // 带上文件名,方便服务器识别文件(可以换成其他方式传递) activeXHR.setRequestHeader('X-File-Name', targetFile.name); activeXHR.setRequestHeader('Content-Type', targetFile.type); // 监听上传进度,实时更新已上传字节数 activeXHR.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { uploadedBytes = startByte + e.loaded; console.log(`上传进度:${Math.round((uploadedBytes / targetFile.size)*100)}%`); } }); // 上传完成/部分完成的回调 activeXHR.addEventListener('load', () => { if (activeXHR.status === 200) { alert('文件上传完成!'); uploadedBytes = 0; targetFile = null; } else if (activeXHR.status === 206) { // 206表示部分上传成功,一般用于分片上传的中间状态 console.log('部分字节上传成功,等待后续分片'); } else if (activeXHR.status === 416) { // 416表示请求的范围无效(比如已经上传完了) alert('文件已经上传完成,无需继续'); } }); // 上传出错的处理 activeXHR.addEventListener('error', () => { console.error('上传出错,当前已上传字节:', uploadedBytes); }); // 发送文件切片:从startByte到文件末尾 const fileSlice = targetFile.slice(startByte); activeXHR.send(fileSlice); } // 暂停上传 function pauseUpload() { if (activeXHR) { activeXHR.abort(); console.log('上传已暂停,已上传字节数:', uploadedBytes); activeXHR = null; } } // 恢复上传 function resumeUpload() { if (!targetFile || uploadedBytes >= targetFile.size) { alert('没有可恢复的上传任务'); return; } console.log('开始恢复上传,从字节', uploadedBytes, '开始'); uploadFromByte(uploadedBytes); }
服务器端示例(Node.js + Express)
const express = require('express'); const fs = require('fs'); const path = require('path'); const app = express(); // 确保uploads目录存在 const uploadDir = path.join(__dirname, 'uploads'); if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir); } // 处理上传请求 app.put('/upload', (req, res) => { const fileName = req.headers['x-file-name']; if (!fileName) { return res.sendStatus(400); } const filePath = path.join(uploadDir, fileName); const contentRange = req.headers['content-range']; // 没有Content-Range头,说明是首次上传或者覆盖上传 if (!contentRange) { const writeStream = fs.createWriteStream(filePath); req.pipe(writeStream); writeStream.on('finish', () => res.sendStatus(200)); writeStream.on('error', () => res.sendStatus(500)); return; } // 解析Content-Range头,获取起始字节和总大小 const rangeMatch = contentRange.match(/bytes (\d+)-\/(\d+)/); if (!rangeMatch) { return res.sendStatus(400); } let startByte = parseInt(rangeMatch[1]); const totalFileSize = parseInt(rangeMatch[2]); // 检查服务器上已有的文件大小,避免客户端进度和服务器不一致 fs.stat(filePath, (statErr, stats) => { let existingSize = 0; if (!statErr) { existingSize = stats.size; } // 如果服务器上的文件比客户端记录的已上传字节大,以服务器为准 if (existingSize > startByte) { startByte = existingSize; } // 已经上传完成,直接返回成功 if (existingSize === totalFileSize) { return res.sendStatus(200); } // 以续写模式打开文件,从startByte位置开始写入 const writeStream = fs.createWriteStream(filePath, { flags: 'r+', start: startByte }); req.pipe(writeStream); writeStream.on('finish', () => { fs.stat(filePath, (_, finalStats) => { if (finalStats.size === totalFileSize) { res.sendStatus(200); } else { // 返回206表示部分上传成功 res.status(206).send({ uploaded: finalStats.size }); } }); }); writeStream.on('error', () => res.sendStatus(500)); }); }); // 可选:处理HEAD请求,返回当前文件已上传的大小 app.head('/upload', (req, res) => { const fileName = req.headers['x-file-name']; if (!fileName) { return res.sendStatus(400); } const filePath = path.join(uploadDir, fileName); fs.stat(filePath, (err, stats) => { if (err) { res.setHeader('Content-Length', 0); res.sendStatus(200); } else { res.setHeader('Content-Length', stats.size); res.sendStatus(200); } }); }); app.listen(3000, () => { console.log('服务器运行在http://localhost:3000'); });
关键注意事项
- XHR无法复用:调用
abort()后的XHR对象已经终止,必须新建实例来恢复上传 - 进度校验很重要:恢复前最好发送HEAD请求确认服务器上的文件大小,避免客户端记录的进度和服务器实际接收的不一致(比如网络波动导致部分字节丢失)
- 大文件优化:如果是GB级的超大文件,建议把文件切成更小的分片(比如每片10MB),这样暂停后恢复的粒度更细,也能降低单次请求失败的影响
- 持久化进度:如果需要页面刷新后还能恢复上传,要把
uploadedBytes、文件名、文件大小这些信息存在localStorage或者后端数据库里 - 跨域问题:如果前端和服务器不在同一个域名下,要确保服务器配置了CORS,允许
Content-Range、X-File-Name这些自定义请求头
内容的提问来源于stack exchange,提问作者Karthik Ravichandran
相关产品推荐
相关产品推荐

