You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

JavaScript中如何恢复已中断的文件上传请求?(不使用resumable.js/tus.io)

实现原生XHR文件上传的暂停与恢复

嘿,我之前做原生XHR断点续传的时候踩过不少坑,正好能给你捋清楚怎么实现暂停后的恢复功能——核心就是利用HTTP的Range请求机制,结合客户端记录上传进度、服务器支持断点续传这两点来搞,不用那些第三方库完全没问题。

核心原理

你已经用xhr.abort()实现了暂停,但要注意:abort后的XHR对象是没法复用的,恢复上传必须新建XHR实例。关键思路是:

  1. 客户端记录已成功上传的字节数
  2. 恢复时,告诉服务器“我要从第N个字节开始传剩下的内容”
  3. 服务器接收请求后,把后续字节追加到已有的文件中,而不是覆盖

具体实现步骤

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.20 11:33:36