React+Express 助你快速理解大文件断点续传,通过完整Demo快速复现

127 阅读4分钟

想象一下
你正在上传一个10GB的视频文件,突然网络中断... 💥
重新上传?浪费1小时! 🚀 如何优雅解决?本文将教你实现大文件的「断点续传」!

核心功能
✅ 自动分块(1MB切片)
✅ 实时进度显示(精确到0.01%)
✅ 断网自动续传(支持暂停/继续)
✅ 生产级优化(分片秒传/动态分片)
👉 文末附可直接部署的完整代码仓库!

文章中提供了React+Express的前后端具体实现,因为我觉得单纯以前端的视角来学习断点续传是不够完全理解的,因此我们将从前后端的一个具体实现来完全理解和掌握文件的断点续传。

一、断点续传核心机制图解

image.png

1.1 核心三要素

  • 分片策略:将N * MB大小的文件拆分为N个1MB切片(可配置)
  • 唯一指纹:文件hash + 分片index生成唯一标识
  • 断点记录:服务端持久化已接收分片信息

确定好以上三要素,那么问题就很好解决了,接下来我们就一步一步实现以上要素即可。

二、手把手实现核心模块

2.1 前端核心逻辑

2.1.1 切片处理

不难理解分片就是为了将一个大文件分割成多个片段进行传输,至于多个片段如何进行传输就涉及到了流的概念,这里不做讲解,有兴趣的同学可以自行了解。

const CHUNK_SIZE = 1024 * 1024; // 1MB切片

// 文件分片处理
const createFileChunks = (startByte,file) => {
  const chunks = []
  let cur = startByte
  while (cur < file.size) {
    chunks.push(file.slice(cur, cur + CHUNK_SIZE))
    cur += CHUNK_SIZE
  }
  return chunks
}

2.1.2 获取文件已上传大小

后端根据对应的文件标识去查找相应的文件信息,并通过响应头返回文件的大小,这里我们通过接口把响应头中content-leng的值取出来即可。

  // 获取已上传大小
  const getUploadedSize = async (filename: string) => {
    try {
      const response = await axios.head(
        `http://localhost:8080/upload/${filename}`,
      );
      return parseInt(response.headers['content-length']) || 0;
    } catch {
      return 0;
    }
  };

2.1.3 上传 & 上传暂停/继续

文件分片生成后,我们通过Content-Range请求头传递当前切片的起始字节位置(如bytes 0-10000/100000)。这个格式需要前后端预先约定,确保服务端能正确解析切片的位置信息。
需要注意的是我们需要在请求头的Content-Type设置为application/octet-stream,这是二进制流数据的标准 MIME 类型,确保数据能被正确识别和接收。

上传的暂停/继续则利用了AbortController接口特性,允许你根据需要中止一个或多个 Web 请求。

接下来让我看下在axios中的具体实现:

// 创建useRef来保存AbortController实例
  const controllerRef = useRef<AbortController>(null);
  
  // 开始上传
  const handleUpload = async () => {
    controllerRef.current = new AbortController();
    // 每次上传前获取当前文件已上传的大小
    let startByte = await getUploadedSize(filename);
    // 获取文件切片
    const chunks = createFileChunks(startByte, file);
    for (const chunk of chunks) {
        const contentRange = `bytes ${startByte}-${startByte + chunk.size - 1}/${file.size}`;
        await axios.put(`http://localhost:8080/upload/${filename}`, chunk, {
          headers: {
            'Content-Range': contentRange,
            'Content-Type': 'Uploadlication/octet-stream',
          },
          // 将实例的signal传递给axios的signal属性
          signal: controllerRef.current.signal,
        });
        startByte += chunk.size;
        uploadedSizeRef.current = startByte;
        // 记录当前已上传进度
        setProgress(((startByte / file.size) * 100).toFixed(2));
    }
   }
   
  // 暂停上传
  const handlePause = () => {
    if (controllerRef.current) {
      controllerRef.current.abort();
      setUploading(false);
    }
  };

核心实现逻辑:

  1. 文件分块:使用slice方法将文件切割为指定大小的块
  2. 续传机制:通过HEAD请求获取已上传的字节数
  3. 进度跟踪:通过已上传字节数计算百分比
  4. 暂停功能:使用AbortController中断正在进行的请求
  5. 请求头处理:正确设置Content-Range和Content-Type

访问 codesandbox或者GitHub - FileUploadDemo查看完整代码

2.2 后端核心逻辑(node + express实现)

2.2.1 上传目录初始化

确保上传文件的目录存在,若不存在则递归创建。

const fs = require('fs');
const path = require('path');
const UPLOAD_DIR = './uploads';

// 确保上传目录存在
if (!fs.existsSync(UPLOAD_DIR)) {
  fs.mkdirSync(UPLOAD_DIR, { recursive: true });
}

2.2.2 处理文件上传(断点续传)

app.put("/upload/:filename", async (req, res) => {
  try {
    const { filename } = req.params;
    const contentRange = req.headers["content-range"];

    // 检查 Content-Range 是否存在
    if (!contentRange) {
      return res.status(400).send("Content-Range header required");
    }

    // 验证 Content-Range 格式
    const match = contentRange.match(/bytes (\d+)-(\d+)/(\d+|*)/);
    if (!match) {
      return res.status(400).send("Invalid Content-Range format");
    }

    const start = parseInt(match[1], 10);
    const filePath = path.join(UPLOAD_DIR, filename);

    // 获取当前文件大小
    let currentSize = 0;
    try {
      const stats = await fs.promises.stat(filePath);
      currentSize = stats.size;
    } catch (err) {
      if (err.code !== "ENOENT") throw err;
    }

    // 验证起始位置
    if (start !== currentSize) {
      res.set("Content-Range", `bytes */${currentSize}`);
      return res.status(416).send("Invalid chunk position");
    }

    // 创建可写流(追加模式)
    const writeStream = fs.createWriteStream(filePath, { flags: "a" });
    req.pipe(writeStream);

    writeStream.on("finish", () => {
      res.status(200).send("Chunk uploaded successfully");
    });

    writeStream.on("error", (err) => {
      console.error("Write error:", err);
      res.status(500).send("Upload failed");
    });
  } catch (err) {
    console.error(err);
    res.status(500).send("Server error");
  }
});


2.2.3 获取文件信息(用于续传)

app.head("/upload/:filename", async (req, res) => {
  try {
    const { filename } = req.params;
    const filePath = path.join(UPLOAD_DIR, filename);
    const stats = await fs.promises.stat(filePath);

    res
      .header({
        "Content-Length": stats.size,
        "Accept-Ranges": "bytes",
      })
      .end();
  } catch (err) {
    res.status(404).end();
  }
});

2.2.4 启动服务器

const PORT = 8080;
app.listen(PORT, () => {
  console.log(`Server running at http://localhost:${PORT}`);
});

功能特点

  • 支持大文件分块上传:将大文件拆分为多个分片进行上传,降低传输压力。
  • 自动验证分片连续性:通过验证 Content-Range 确保分片按顺序上传。
  • 支持 HTTP Range 请求实现下载续传:可根据请求的范围返回文件的指定部分。
  • 使用追加写入模式保证文件完整性:避免文件覆盖问题,确保数据完整。
  • 提供 HEAD 接口查询上传进度:前端可通过该接口获取已上传大小。
  • 支持 CORS 跨域访问:方便不同域名下的前端应用调用接口。

三、极速复现指南

3.1 模拟断网测试

  1. 启动前后端服务
  2. 选择大于100MB的测试文件
  3. 上传过程中关闭网络或降低速率(使用Chrome DevTools进行控制)
  4. 恢复网络后点击继续上传

image.png

四、生产级优化方案

  1. 分片秒传:服务端预校验文件hash
  2. 动态分片:根据网络质量自动调整分片大小
  3. 断点持久化:使用IndexDB存储本地进度
  4. 错误重试:对失败分片实施指数退避重试

五、配套测试沙箱获取

访问 codesandbox 快速体验

访问 GitHub - FileUploadDemo 获取DEMO代码:

  • 前端React完整实现
  • 后端Node.js服务

实例拓展及优化: 感兴趣的小伙伴可以clone代码仓库,尝试以下挑战

  1. 在代码仓库中修改分片大小为5MB,观察上传速度变化
  2. 模拟断网场景,验证续传是否准确
  3. 添加文件MD5校验,防止分片篡改
  4. 支持并行上传(多个分块同时上传)
  5. 显示上传历史记录
  6. 显示上传速度/剩余时间

把以上挑战都实现,可以说是完整的实现了一个健壮且完善的文件上传业务模块。

👇 遇到问题的小伙伴,欢迎在评论区留下你的问题