React 大文件分片上传:在组件内实现可中断、可续传的文件上传状态机监控

各位同学,大家好!

欢迎来到今天的讲座。我是你们的“资深文件搬运工”,今天我们不聊什么高深的算法,也不谈什么微服务架构,我们来聊点“痛”——大文件上传。

我知道,当你在前端界摸爬滚打久了,你一定见过这样的场景:用户选了一个 5GB 的视频,点“上传”,浏览器转圈圈,你看着进度条卡在 1%,心里咯噔一下。然后你切出去喝杯咖啡,回来一看,浏览器崩了,或者进度条回到了 0%。

那一刻,用户的心碎声,隔着屏幕我都能听到。

所以,今天我们的主题是:如何在 React 组件里,优雅地、像瑞士军刀一样,实现一个可中断、可续传、还能监控状态的分片上传系统。这不仅是技术实现,更是一种对用户体验的关怀,一种“哪怕天塌下来,我也要把这块饼干上传完”的执着。

准备好了吗?让我们把代码敲起来。

第一部分:为什么要搞分片上传?(别急着写代码,先理解哲学)

很多同学一上来就问:“老师,为什么不能直接把文件扔给后端?”

这就好比你要把一座山搬回家。你不会直接扛着山走,你会用炸药把它炸成碎石,装在袋子里,一趟一趟背。这就是分片上传的核心哲学:化整为零,各个击破

为什么这么做?理由有三:

  1. 网络不稳定?不怕。 如果是 1GB 的文件,传到 90% 时断了,你只能重来。如果是 1GB 的文件分成了 100 个 10MB 的块,传到第 90 个块断了,你只需要重传第 90 个块,前面的 89 个块白跑了吗?不,它们已经在服务器里了!这就是续传。
  2. 进度条?必须有。 上传 1GB 文件,你告诉我 0% 到 1% 需要传多久?用户会疯的。分片后,每个 10MB 的块传完,进度条走 1%,这叫“反馈”,这叫“确定性”。
  3. 并发控制?省资源。 浏览器虽然快,但也不能同时向 100 个 URL 发送请求,那会把服务器打挂,也会把自家电脑打挂。我们需要一个“队列”,控制同时上传的块数量。

好,理解了哲学,我们开始动手。

第二部分:架构设计——我们的“状态机”大脑

在 React 里,最怕的就是状态满天飞。文件在选,上传中,暂停了,又开始了,又失败了。如果只用几个 useState,你会发现代码逻辑像意大利面一样纠缠不清。

我们需要一个状态机

想象一下,我们的上传组件是一个有性格的人,它有以下几种状态:

  • IDLE: 闲着没事干,等文件。
  • SPLITTING: 正在切文件(生成切片),虽然快,但也要给点时间。
  • UPLOADING: 正在飞,并发上传中。
  • PAUSED: 喝水休息中。
  • COMPLETED: 搬完了,收工。
  • ERROR: 搞砸了,报错。

我们要用 useReducer 来管理这个状态机。为什么不用 useState?因为状态之间的转换是有逻辑的(比如不能从 PAUSED 直接跳到 COMPLETED,中间得先 UPLOADING),useReducer 配合 switch 语句,逻辑清晰得像教科书。

第三部分:核心代码实现——第一步,切分文件

首先,我们需要一个 Hook,叫 useChunkUploader。它要接收文件对象,然后返回所有的控制权。

我们先来写“切文件”的逻辑。这很简单,利用 File 对象自带的 slice 方法。

// 伪代码风格,实际是 TypeScript
const generateChunks = (file: File, chunkSize = 2 * 1024 * 1024): File[] => {
  const chunks = [];
  let start = 0;
  let index = 0;

  while (start < file.size) {
    const end = start + chunkSize;
    // slice 返回一个新的 File 对象(继承自 Blob),完美!
    const chunk = file.slice(start, end);
    chunks.push({
      file: chunk,
      index: index++,
      size: chunk.size,
      // 计算进度百分比:当前块大小 / 总文件大小
      percent: ((start / file.size) * 100).toFixed(2)
    });
    start = end;
  }
  return chunks;
};

注意看,我们在切片对象里加了一个 index。这个 index 是续传的关键!如果服务器告诉我,第 5 块已经传过了,我就不用再传第 5 块了,直接跳过。

第四部分:核心代码实现——第二步,状态机与并发控制

现在,我们把这些切片放入状态机。

const initialState = {
  status: 'IDLE' as 'IDLE' | 'SPLITTING' | 'UPLOADING' | 'PAUSED' | 'COMPLETED' | 'ERROR',
  chunks: [] as any[], // 这里应该用具体的 Chunk 类型
  progress: 0,
  currentSpeed: 0, // 速度监控
  uploadedCount: 0,
  error: null as string | null
};

const uploadReducer = (state, action) => {
  switch (action.type) {
    case 'START':
      return { ...state, status: 'SPLITTING' };
    case 'SPLIT_DONE':
      return { ...state, status: 'UPLOADING', chunks: action.chunks };
    case 'UPLOAD_CHUNK_SUCCESS':
      // 上传成功一个块
      return {
        ...state,
        uploadedCount: state.uploadedCount + 1,
        progress: (state.uploadedCount / state.chunks.length) * 100
      };
    case 'PAUSE':
      return { ...state, status: 'PAUSED' };
    case 'RESUME':
      return { ...state, status: 'UPLOADING' };
    case 'ERROR':
      return { ...state, status: 'ERROR', error: action.payload };
    default:
      return state;
  }
};

这里有个坑:useReducer 是同步的。但是上传网络请求是异步的。我们不能在 reducer 里直接发请求。我们需要一个中间层,一个“调度员”。

第五部分:并发控制——不要让浏览器“吐血”

假设我们分了 100 个块。如果我们瞬间发起 100 个请求,浏览器会直接给你一个 ERR_INSUFFICIENT_RESOURCES 或者直接闪退。我们需要限制并发数。

通常,并发数设为 3 到 5 比较合适。

const CHUNK_CONCURRENCY = 3; // 每次同时跑 3 个小弟

const uploadChunks = (state, dispatch, api) => {
  // 1. 找出还没上传的块
  const pendingChunks = state.chunks.filter(c => !c.uploaded);

  if (pendingChunks.length === 0) return;

  // 2. 拿出前 N 个块
  const chunksToUpload = pendingChunks.slice(0, CHUNK_CONCURRENCY);

  // 3. 遍历并发上传
  chunksToUpload.forEach(chunk => {
    const formData = new FormData();
    formData.append('file', chunk.file);
    formData.append('chunkIndex', chunk.index);

    // 发起请求
    fetch(api, {
      method: 'POST',
      body: formData
    })
    .then(res => {
      if (res.ok) {
        // 成功!告诉 reducer 更新状态
        dispatch({ type: 'UPLOAD_CHUNK_SUCCESS', index: chunk.index });

        // 递归调用自己,看看还有没有剩下的块要传
        uploadChunks(state, dispatch, api);
      } else {
        // 失败处理...
        dispatch({ type: 'ERROR', payload: `Chunk ${chunk.index} failed` });
      }
    })
    .catch(err => {
      // 网络错误...
      console.error(err);
    });
  });
};

看懂了吗?这就是递归的魅力。每次上传完一个块,我们就再检查一次,如果还有没传的,就再起一波小弟。这就像是一个自动售货机,你投币(发起请求),它吐出一瓶可乐(成功),然后自动检查还有没有别的瓶子要卖。

第六部分:中断与续传——救命稻草

现在,用户点击了“暂停”。我们要怎么断?

fetch 时代,我们通常使用 AbortController。这就像是你给快递员打了个电话:“别送了,我改主意了!”请求会被立即取消。

// 在组件里
const abortController = new AbortController();

const cancelUpload = () => {
  if (abortController) {
    abortController.abort();
  }
  // 同时,我们需要停止所有的递归调用,这有点难。
  // 更好的做法是,我们在 state 里存一个 isPaused 标志。
  // 在 uploadChunks 的每一层递归里都检查这个标志。
  dispatch({ type: 'PAUSE' });
};

但是,如果浏览器崩溃了或者断网了怎么办?这就是续传的战场。

续传的核心逻辑是:断点续传 = 服务器端记录 + 客户端检查

假设我们的后端 API /upload 会在返回头里告诉我们哪些块已经上传过了,或者我们在上传成功后,把已上传的块索引存在 localStorage 里。

这里我们模拟一下后端返回的“已存在块”列表:

const checkExistingChunks = async (file, chunkSize) => {
  // 模拟请求后端,获取已上传的块索引
  const response = await fetch(`/api/check-progress?fileId=${fileId}`);
  const data = await response.json(); // { uploadedChunks: [0, 1, 2, 5, 10] }

  return data.uploadedChunks;
};

useReducer 的初始化阶段,或者点击“继续上传”时,我们先把这些已上传的块标记一下。

const resumeUpload = (state, dispatch, api) => {
  dispatch({ type: 'RESUME' });

  // 过滤掉已上传的块
  const pendingChunks = state.chunks.filter(c => !c.uploaded);

  if (pendingChunks.length === 0) {
    dispatch({ type: 'COMPLETED' });
    return;
  }

  // 重新开始并发上传逻辑
  uploadChunks(state, dispatch, api);
};

第七部分:UI 层的渲染——让用户看到进度

有了状态机,UI 就很简单了。我们只需要根据 state.statusstate.progress 来渲染不同的东西。

const ChunkUploader = ({ file }) => {
  const [state, dispatch] = useReducer(uploadReducer, initialState);

  // 绑定各种事件
  const handleStart = async () => {
    // 1. 生成切片
    const chunks = generateChunks(file);
    dispatch({ type: 'START' });

    // 2. 告诉 reducer 切片完成了,开始上传
    dispatch({ type: 'SPLIT_DONE', chunks });

    // 3. 开始并发调度
    uploadChunks(state, dispatch, '/api/upload');
  };

  const handlePause = () => {
    dispatch({ type: 'PAUSE' });
  };

  // 状态对应的 UI 映射
  const renderStatus = () => {
    switch (state.status) {
      case 'IDLE':
        return <button onClick={handleStart}>开始上传</button>;
      case 'SPLITTING':
        return <div>正在把大文件切成小饼干...</div>;
      case 'UPLOADING':
        return (
          <div>
            <progress value={state.progress} max="100" />
            <span>{state.progress}%</span>
            <button onClick={handlePause}>暂停</button>
          </div>
        );
      case 'PAUSED':
        return (
          <div>
            <span>已暂停,点击继续</span>
            <button onClick={() => resumeUpload(state, dispatch, '/api/upload')}>继续上传</button>
          </div>
        );
      case 'COMPLETED':
        return <div>上传成功!🎉</div>;
      case 'ERROR':
        return <div style={{ color: 'red' }}>上传失败: {state.error}</div>;
    }
  };

  return (
    <div className="upload-box">
      <h3>{file.name}</h3>
      <div className="status-area">
        {renderStatus()}
      </div>
      <div className="progress-bar-bg">
        <div 
          className="progress-bar-fill" 
          style={{ width: `${state.progress}%` }}
        ></div>
      </div>
    </div>
  );
};

第八部分:高级优化——速度监控与内存管理

上面的代码能跑,但作为一个资深专家,我们得考虑更细的东西。

1. 速度监控

用户喜欢看速度。怎么算速度?当前块大小 / (当前时间 - 上一个块时间)

// 在 reducer 里加个 lastUploadTime
case 'UPLOAD_CHUNK_SUCCESS':
  const now = Date.now();
  const duration = now - state.lastUploadTime;
  const speed = (chunk.size / duration) * 1000 / 1024 / 1024; // MB/s
  return {
    ...state,
    currentSpeed: speed,
    lastUploadTime: now,
    // ...
  };

2. 内存泄漏

我们在 useEffect 里开启上传,记得在 useEffect 的返回函数里清理。如果用户卸载组件,必须把所有正在进行的请求全部 abort 掉。

useEffect(() => {
  let controller = new AbortController();

  const start = async () => {
    const chunks = generateChunks(file);
    dispatch({ type: 'SPLIT_DONE', chunks });
    uploadChunks(state, dispatch, '/api/upload', controller.signal);
  };

  if (state.status === 'IDLE') {
    start();
  }

  return () => {
    controller.abort(); // 组件卸载,强制停止
  };
}, [file, state.status]);

第九部分:那个“致命”的细节——校验

我之前说过,分片上传有个隐患:数据完整性

假设你把文件切成 10 块,传了 9 块,最后 1 块传错了(网络抖动导致包损坏),服务器拼起来是个坏文件。用户下载下来发现打不开。

所以,必须有校验

最简单的方法是:上传每个切片之前,先计算这个切片的 MD5 值(或 SHA-1)。上传时,把 MD5 值一起传给服务器。服务器存下来,最后合并文件时,再校验一遍。

注意:计算 MD5 很耗 CPU。不要在主线程算!要用 Web Worker

// 伪代码
const worker = new Worker('md5-worker.js');
worker.postMessage({ chunk: file.slice(0, 1024) });
worker.onmessage = (e) => {
  const hash = e.data.hash;
  // 发送 hash 到服务器
};

这样,你的 UI 不会因为计算 MD5 而卡死,用户还能看到“正在计算指纹…”的提示。

第十部分:实战演练——完整流程回顾

让我们把这些点串起来,想象一下用户操作的完整流程:

  1. 选择文件:用户选了一个 4GB 的视频。组件初始化,状态为 IDLE
  2. 点击开始:组件调用 generateChunks。虽然 4GB 切成 200 个 20MB 的块很快,但 UI 还是卡顿了 0.1 秒(Web Worker 优化后可忽略)。状态变为 SPLITTING,然后 SPLIT_DONE
  3. 并发上传:调度器启动,同时发 3 个请求。
    • 请求 1:上传第 1 块。成功。Reducer 更新进度到 1.5%。
    • 请求 2:上传第 2 块。成功。Reducer 更新进度到 3%。
    • 请求 3:上传第 3 块。成功。Reducer 更新进度到 4.5%。
  4. 断网:用户突然拔了网线。第 4 个请求失败。Reducer 状态变为 ERROR,显示错误信息。
  5. 重试:用户插上网线,点击“重试”。
    • 调度器再次运行,但这次它检查了 uploadedChunks 列表。发现第 1、2、3 块已经上传成功,直接跳过。
    • 它只发起新的第 4 块的请求。
  6. 暂停:用户想喝口水,点击“暂停”。所有请求被 AbortController 取消,状态变为 PAUSED
  7. 继续:用户回来,点击“继续”。状态变回 UPLOADING,调度器从第 4 块继续。
  8. 完成:进度条走到 100%,状态变为 COMPLETED,显示“上传成功”。

第十一部分:总结与“避坑指南”

好了,讲了这么多,我们来总结一下这个“状态机监控”的核心要点:

  1. 不要相信单文件上传:除非你确定网络永远不挂,否则必须分片。
  2. 状态机是灵魂:用 useReducer 管理状态,而不是一坨 useState 混在一起。状态转换要有逻辑,不能乱跳。
  3. 并发控制是生命线Promise.all 是并发,但我们需要更精细的 Promise.allSettled 或者递归控制,以防止浏览器崩溃。
  4. 中断要彻底AbortController 必须配合 useEffect 的清理函数,否则组件卸载了请求还在跑,那是内存泄漏。
  5. 续传要持久化:没有服务器端记录的续传是耍流氓。每次上传前,先问服务器:“大哥,我上次传到哪了?”

最后,我想说,大文件上传虽然麻烦,但它体现了前端工程化的一种“韧性”。一个好的分片上传系统,就像一个尽职尽责的保镖,无论环境多么恶劣(网络抖动、用户中途离开),它都能保证任务(文件)最终安全送达。

希望今天的讲座能让你在处理大文件上传时,少掉几根头发,多几分自信。如果代码跑不通,先检查一下你的 chunkSizeconcurrency,别把服务器累死了。

下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注