React 基础体系 · 第 37/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 文件上传:拖拽、分片、进度、取消、重试和预览

文件上传表面上是一个 <input type="file">,但一旦文件较大、网络不稳定,或者界面需要显示真实进度,问题就会变成一个跨越浏览器、React 状态管理和服务端存储协议的系统:

  • 拖拽解决用户如何把文件交给页面;
  • 预览解决用户如何确认选中了什么;
  • 分片解决大文件不能一次性上传、失败后不应全部重传的问题;
  • 进度解决客户端如何知道上传到了哪里;
  • 取消解决用户如何终止正在进行的请求;
  • 重试解决临时网络错误如何恢复;
  • 服务端协议则决定分片是否真的能续传、合并和校验。

本文以 React 19、现代 TypeScript 和浏览器标准 API 为基础,使用 XMLHttpRequest 获取上传进度。React 只负责交互状态和组件生命周期;文件分片保存、鉴权、校验、合并和持久化必须由服务端负责。


一、先明确上传系统的边界

一次完整的大文件上传通常包含以下步骤:

sequenceDiagram
    participant U as 用户
    participant R as React 页面
    participant S as 上传服务
    participant O as 对象存储

    U->>R: 选择或拖拽文件
    R->>R: 创建预览和本地任务
    R->>S: 初始化上传,获得 uploadId
    S-->>R: uploadId、chunkSize、已上传分片
    loop 每个未完成分片
        R->>S: 上传 chunk(index, bytes)
        S->>S: 校验并持久化分片
        S-->>R: 分片成功
    end
    R->>S: complete(uploadId)
    S->>S: 检查所有分片并合并
    S->>O: 写入最终文件
    S-->>R: 文件 URL 或文件标识

这里有两个容易混淆的概念:

  1. 浏览器上传请求的进度:当前 HTTP 请求已经发送了多少字节。
  2. 整个文件的进度:所有分片已经成功发送了多少字节。

如果某个分片失败并重试,第一次失败请求发送过的字节不能直接计入“已成功上传”的文件进度。因此,生产实现应以服务端确认成功的分片字节数为基础,而不是简单累加所有请求的 loaded

1. 文件对象是什么

浏览器通过 File 表示用户选中的本地文件。File 继承自 Blob,具有:

interface File {
  readonly name: string;
  readonly size: number;
  readonly type: string;
  readonly lastModified: number;
}

File 的内容由浏览器管理,网页不能任意读取用户磁盘上的其他文件。用户必须通过文件选择器或拖拽显式授予访问该文件的权限。

file.type 来自浏览器对文件类型的判断,可能为空,也不能作为服务端安全校验的唯一依据。服务端仍应检查文件大小、扩展名、实际内容格式和权限。

2. 分片是什么

分片是将一个文件按字节范围切成多个 Blob

const chunk = file.slice(start, end);

其中:

  • start 是起始字节,包含;
  • end 是结束字节,不包含;
  • 分片大小为 end - start
  • 最后一个分片通常小于标准分片大小。

例如,一个 10 MiB 文件使用 4 MiB 分片:

分片编号 起始位置 结束位置 大小
0 0 4 MiB 4 MiB
1 4 MiB 8 MiB 4 MiB
2 8 MiB 10 MiB 2 MiB

分片的核心价值不是“让代码看起来复杂”,而是把一次不可恢复的大请求变成多个可独立确认的小请求:

  • 第 2 个分片失败,不必重传第 0、1 个分片;
  • 页面刷新后可以查询已经完成的分片;
  • 多个分片可以并行上传;
  • 服务端可以限制单个请求的内存和超时时间。

分片并不会自动实现断点续传。只有当服务端持久化了 uploadId 和已完成分片,客户端重新连接后查询并跳过这些分片,才称得上可恢复上传。


二、React 中应该如何建模上传状态

上传任务至少包含三类状态:

  1. 文件状态:文件名、大小、类型、预览 URL;
  2. 协议状态uploadId、分片大小、已完成分片;
  3. 界面状态:排队、上传中、成功、失败、取消和错误信息。

不要只保存一个 isUploading。因为“上传中但某个分片正在重试”和“用户已经取消但取消请求尚未返回”需要不同的行为。

可以把状态建模为有限状态机:

stateDiagram-v2
    [*] --> idle
    idle --> preparing: 选择或拖拽文件
    preparing --> uploading: 初始化成功
    preparing --> error: 初始化失败
    uploading --> uploading: 分片成功/重试
    uploading --> cancelling: 用户取消
    uploading --> error: 不可恢复失败
    uploading --> completed: 所有分片完成并合并成功
    cancelling --> cancelled: 请求终止
    error --> uploading: 用户重试
    completed --> [*]
    cancelled --> [*]

状态转换比多个互相独立的布尔值更可靠。例如以下组合不应出现:

{
  status: "completed",
  error: "network error"
}

useReducer 管理任务状态

useState 适合简单状态;上传任务涉及初始化、分片确认、进度、重试和取消,useReducer 更适合集中描述状态转换。

type UploadStatus =
  | "idle"
  | "preparing"
  | "uploading"
  | "cancelling"
  | "completed"
  | "cancelled"
  | "error";

type UploadState = {
  status: UploadStatus;
  file: File | null;
  uploadId: string | null;
  chunkSize: number;
  totalChunks: number;
  completedChunks: Set<number>;
  uploadedBytes: number;
  error: string | null;
  previewUrl: string | null;
};

const initialState: UploadState = {
  status: "idle",
  file: null,
  uploadId: null,
  chunkSize: 8 * 1024 * 1024,
  totalChunks: 0,
  completedChunks: new Set(),
  uploadedBytes: 0,
  error: null,
  previewUrl: null,
};

type Action =
  | { type: "select"; file: File; previewUrl: string }
  | {
      type: "initialized";
      uploadId: string;
      chunkSize: number;
      totalChunks: number;
      completedChunks: number[];
      uploadedBytes: number;
    }
  | { type: "chunk-completed"; index: number; bytes: number }
  | { type: "progress"; uploadedBytes: number }
  | { type: "cancelling" }
  | { type: "completed" }
  | { type: "cancelled" }
  | { type: "error"; message: string };

function uploadReducer(
  state: UploadState,
  action: Action,
): UploadState {
  switch (action.type) {
    case "select":
      return {
        ...initialState,
        status: "preparing",
        file: action.file,
        previewUrl: action.previewUrl,
      };

    case "initialized":
      return {
        ...state,
        status: "uploading",
        uploadId: action.uploadId,
        chunkSize: action.chunkSize,
        totalChunks: action.totalChunks,
        completedChunks: new Set(action.completedChunks),
        uploadedBytes: action.uploadedBytes,
      };

    case "chunk-completed": {
      const completedChunks = new Set(state.completedChunks);
      completedChunks.add(action.index);

      return {
        ...state,
        completedChunks,
        uploadedBytes: state.uploadedBytes + action.bytes,
      };
    }

    case "progress":
      return {
        ...state,
        uploadedBytes: action.uploadedBytes,
      };

    case "cancelling":
      return { ...state, status: "cancelling" };

    case "completed":
      return { ...state, status: "completed" };

    case "cancelled":
      return { ...state, status: "cancelled" };

    case "error":
      return { ...state, status: "error", error: action.message };

    default:
      return state;
  }
}

这里的 Set 用于避免同一个分片被重复计入。服务端重试或客户端重复响应时,不能因为重复确认而让进度超过 100%。

对于 React 状态,Set 不能原地修改后直接返回原对象:

// 错误:React 可能认为状态引用没有变化
state.completedChunks.add(index);
return state;

正确方式是创建新的 Set

const next = new Set(state.completedChunks);
next.add(index);
return { ...state, completedChunks: next };

计算文件总进度

假设:

  • uploadedBytes 表示服务端已经确认成功的字节;
  • file.size 表示文件总字节数。

则整体进度是:

progress=uploadedBytesfile.size×100progress = \frac{uploadedBytes}{file.size} \times 100

代码如下:

const progress =
  state.file && state.file.size > 0
    ? Math.min(100, (state.uploadedBytes / state.file.size) * 100)
    : 0;

Math.min 不是为了掩盖错误,而是防止重复确认、整数误差或异常服务端响应导致界面显示 101%。真正的重复计数问题仍应在状态和协议层修复。


三、拖拽上传:事件、默认行为和文件过滤

拖拽上传依赖 DragEvent.dataTransfer.files。浏览器默认可能会把拖入的文件直接打开,因此必须阻止 dragoverdrop 的默认行为。

import { useRef, useState } from "react";

type DropZoneProps = {
  onFile: (file: File) => void;
};

export function DropZone({ onFile }: DropZoneProps) {
  const inputRef = useRef<HTMLInputElement>(null);
  const [dragging, setDragging] = useState(false);

  function acceptFile(file: File | undefined) {
    if (!file) return;

    const maxSize = 10 * 1024 * 1024 * 1024; // 10 GiB
    if (file.size > maxSize) {
      window.alert("文件不能超过 10 GiB");
      return;
    }

    onFile(file);
  }

  return (
    <div
      role="button"
      tabIndex={0}
      aria-label="选择或拖拽文件"
      className={dragging ? "drop-zone is-dragging" : "drop-zone"}
      onClick={() => inputRef.current?.click()}
      onKeyDown={(event) => {
        if (event.key === "Enter" || event.key === " ") {
          event.preventDefault();
          inputRef.current?.click();
        }
      }}
      onDragEnter={(event) => {
        event.preventDefault();
        setDragging(true);
      }}
      onDragOver={(event) => {
        event.preventDefault();
        event.dataTransfer.dropEffect = "copy";
      }}
      onDragLeave={(event) => {
        event.preventDefault();
        setDragging(false);
      }}
      onDrop={(event) => {
        event.preventDefault();
        setDragging(false);
        acceptFile(event.dataTransfer.files[0]);
      }}
    >
      <p>拖拽文件到这里,或点击选择</p>

      <input
        ref={inputRef}
        hidden
        type="file"
        accept="image/*,video/*,.pdf"
        onChange={(event) => {
          acceptFile(event.target.files?.[0]);
          // 允许再次选择同一个文件时仍触发 change
          event.currentTarget.value = "";
        }}
      />
    </div>
  );
}

这里有几个关键点:

  • preventDefault() 使 drop 事件真正交给组件处理;
  • dropEffect 只是提示光标效果,不是安全校验;
  • accept 只是文件选择器的提示和过滤,不能阻止恶意请求;
  • 键盘事件使伪装成按钮的拖拽区仍可访问;
  • 清空 input.value 后,再次选择相同文件也能触发 change

dragleave 在拖入子元素时可能频繁触发,复杂拖拽区通常需要用计数器或判断 event.currentTarget.contains(event.relatedTarget as Node),而不是简单地把每次 dragleave 都当成离开整个区域。单一文本区域通常不明显,嵌套预览和按钮时容易出现闪烁。


四、预览:对象 URL 的生命周期必须由组件管理

对于图片、视频和音频,可以使用:

const url = URL.createObjectURL(file);

这会返回一个指向浏览器内部 Blob 数据的 URL。它不等于把文件上传到了服务器,也不是一个永久 URL。

使用完成后必须释放:

URL.revokeObjectURL(url);

否则用户反复选择大文件时,浏览器可能长期保留这些对象 URL 关联的资源。

import { useEffect, useState } from "react";

export function FilePreview({ file }: { file: File | null }) {
  const [url, setUrl] = useState<string | null>(null);

  useEffect(() => {
    if (!file) {
      setUrl(null);
      return;
    }

    const nextUrl = URL.createObjectURL(file);
    setUrl(nextUrl);

    return () => {
      URL.revokeObjectURL(nextUrl);
    };
  }, [file]);

  if (!file || !url) return null;

  if (file.type.startsWith("image/")) {
    return (
      <img
        src={url}
        alt={file.name}
        style={{ maxWidth: 320, maxHeight: 240 }}
      />
    );
  }

  if (file.type.startsWith("video/")) {
    return <video src={url} controls width={320} />;
  }

  if (file.type.startsWith("audio/")) {
    return <audio src={url} controls />;
  }

  return <p>已选择:{file.name}</p>;
}

useEffect 的清理函数会在依赖变化前和组件卸载时执行,因此旧文件的对象 URL 可以被释放。React Strict Mode 在开发环境可能额外执行一次 setup/cleanup,用来暴露不对称的副作用;只要创建和释放成对存在,这种行为不会造成生产逻辑错误。

预览还存在安全边界:

  • 图片预览可以直接显示,但服务端仍需检查真实内容;
  • 对不可信 HTML、SVG、PDF 使用 iframe 或直接插入页面可能带来脚本或内容安全风险;
  • file.name 只能作为显示文本,渲染时不要拼接到 innerHTML
  • 视频和音频预览不代表服务端接受该媒体格式;
  • 预览前可以限制文件大小,避免浏览器为超大媒体分配过多内存。

五、分片协议:客户端和服务端必须共同定义

客户端不能凭空决定“上传完了”。至少需要以下服务端接口:

1. 初始化

POST /api/uploads/init
Content-Type: application/json
Authorization: Bearer <token>

{
  "name": "video.mp4",
  "size": 20971520,
  "contentType": "video/mp4",
  "lastModified": 1710000000000
}

响应:

{
  "uploadId": "u_abc123",
  "chunkSize": 8388608,
  "completedChunks": []
}

服务端可以根据文件大小、账户权限和存储策略返回最终分片大小。客户端不能假设服务端一定接受任意大小。

2. 上传单个分片

PUT /api/uploads/u_abc123/chunks/0
Content-Type: application/octet-stream
Content-Length: 8388608
X-Chunk-Index: 0
X-Chunk-Size: 8388608
X-File-Size: 20971520
Authorization: Bearer <token>

<binary bytes>

服务端至少应验证:

  • uploadId 属于当前用户;
  • 分片编号在合法范围内;
  • 分片大小与预期范围一致;
  • 已存在的分片是否与本次内容一致;
  • 存储操作是否真正完成后再返回成功。

如果服务端使用 Content-Range,可以写成:

Content-Range: bytes 0-8388607/20971520

这里的结束位置仍然是包含的,而 Blob.slice(start, end)end 是不包含的,两者转换时容易出现一个字节的偏差。

3. 查询上传状态

GET /api/uploads/u_abc123

响应:

{
  "uploadId": "u_abc123",
  "size": 20971520,
  "chunkSize": 8388608,
  "completedChunks": [0],
  "status": "uploading"
}

刷新页面或重新打开任务时,客户端通过该接口恢复进度。

4. 完成合并

POST /api/uploads/u_abc123/complete
Content-Type: application/json

{
  "totalChunks": 3,
  "name": "video.mp4",
  "contentType": "video/mp4"
}

服务端必须在此时重新确认:

  1. 所有分片都存在;
  2. 分片编号没有重复或缺失;
  3. 每个分片大小符合预期;
  4. 合并后的大小正确;
  5. 可选地校验完整文件哈希;
  6. 当前用户有权完成该上传。

客户端发出 complete 不代表服务端一定合并成功。只有收到服务端返回的最终文件标识或 URL,任务才能标记为 completed


六、使用 XMLHttpRequest 获取上传进度

浏览器的 fetch 适合请求响应,但普通 fetch API 并没有像 XMLHttpRequest.upload.onprogress 一样被广泛支持的上传进度事件。不要把下载响应进度误当成请求体上传进度。

因此,本文用 XMLHttpRequest 上传单个分片:

type XhrUploadOptions = {
  url: string;
  body: Blob;
  headers?: Record<string, string>;
  signal?: AbortSignal;
  onProgress?: (loaded: number, total: number) => void;
};

function uploadBlobWithXhr({
  url,
  body,
  headers = {},
  signal,
  onProgress,
}: XhrUploadOptions): Promise<void> {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();

    let settled = false;

    const finish = (callback: () => void) => {
      if (settled) return;
      settled = true;
      signal?.removeEventListener("abort", abort);
      callback();
    };

    const abort = () => {
      xhr.abort();
    };

    if (signal?.aborted) {
      reject(new DOMException("The operation was aborted.", "AbortError"));
      return;
    }

    signal?.addEventListener("abort", abort, { once: true });

    xhr.open("PUT", url);

    for (const [key, value] of Object.entries(headers)) {
      xhr.setRequestHeader(key, value);
    }

    xhr.upload.onprogress = (event) => {
      if (event.lengthComputable) {
        onProgress?.(event.loaded, event.total);
      } else {
        onProgress?.(event.loaded, body.size);
      }
    };

    xhr.onerror = () => {
      finish(() => reject(new Error("网络错误")));
    };

    xhr.onabort = () => {
      finish(() =>
        reject(new DOMException("The operation was aborted.", "AbortError")),
      );
    };

    xhr.onload = () => {
      finish(() => {
        if (xhr.status >= 200 && xhr.status < 300) {
          resolve();
        } else {
          reject(
            new Error(`分片上传失败,HTTP 状态码:${xhr.status}`),
          );
        }
      });
    };

    xhr.send(body);
  });
}

onprogressloaded 表示当前请求已经交给浏览器发送的字节,不代表服务端已经持久化。服务端返回 2xx 后,客户端才能把该分片算作成功。

如果页面使用跨域 API,服务端还必须正确处理 CORS,尤其是自定义请求头和 PUT 方法对应的预检请求。否则 XHR 会在浏览器侧失败,服务端可能根本没有收到分片。


七、重试:只重试可恢复错误,并采用退避

重试不是“失败后立刻再发一次”。连续立即重试会在服务端或网络已经拥堵时进一步放大压力。

常见可重试情况:

  • 网络连接暂时中断;
  • 请求超时;
  • HTTP 408429
  • HTTP 500502503504

通常不应自动重试:

  • 400:请求格式错误;
  • 401403:认证或权限错误;
  • 413:文件过大;
  • 415:媒体类型不支持;
  • 服务端明确返回业务校验失败。

指数退避可以表示为:

delayk=min(Dmax,D0×2k)+jitterdelay_k = \min(D_{max}, D_0 \times 2^k) + jitter

其中:

  • k 是已经失败的次数;
  • D0 是初始等待时间;
  • Dmax 是最大等待时间;
  • jitter 是随机抖动,用于避免大量客户端同时重试。
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    const timer = window.setTimeout(resolve, ms);

    const abort = () => {
      window.clearTimeout(timer);
      reject(new DOMException("The operation was aborted.", "AbortError"));
    };

    if (signal?.aborted) {
      abort();
      return;
    }

    signal?.addEventListener("abort", abort, { once: true });
  });
}

function isRetryableError(error: unknown): boolean {
  if (!(error instanceof Error)) return true;
  if (error.name === "AbortError") return false;
  return true;
}

async function withRetry(
  operation: () => Promise<void>,
  options: {
    maxRetries: number;
    signal?: AbortSignal;
    baseDelayMs?: number;
    maxDelayMs?: number;
  },
): Promise<void> {
  const {
    maxRetries,
    signal,
    baseDelayMs = 500,
    maxDelayMs = 8000,
  } = options;

  for (let attempt = 0; ; attempt++) {
    try {
      await operation();
      return;
    } catch (error) {
      if (
        attempt >= maxRetries ||
        !isRetryableError(error) ||
        signal?.aborted
      ) {
        throw error;
      }

      const exponential = Math.min(
        maxDelayMs,
        baseDelayMs * 2 ** attempt,
      );
      const jitter = Math.random() * 250;

      await sleep(exponential + jitter, signal);
    }
  }
}

这个示例把所有普通 Error 都视为可能重试,真实项目中应让错误对象携带 HTTP 状态码,再按状态码判断。否则 413 等确定性错误可能被无意义地重试。

分片重试还有一个协议要求:服务端的分片写入应当幂等。相同的 uploadId + chunkIndex 重复提交时,服务端应返回已有结果,或者比较内容哈希后安全覆盖,而不是把分片追加两次。


八、取消:AbortController 取消的是请求,不是自动清理服务端数据

AbortController 可以通过 signal 取消 XHR:

const controller = new AbortController();

uploadBlobWithXhr({
  url,
  body: chunk,
  signal: controller.signal,
});

// 用户点击取消
controller.abort();

取消的实际效果是:

  1. 浏览器停止等待或发送该请求;
  2. 当前 XHR 触发 abort
  3. 客户端 Promise 以 AbortError 失败;
  4. 服务端可能已经收到部分或全部请求体。

最后一点很重要:网络取消是竞态过程。客户端调用 abort() 后,服务端可能已经完成写入。因此服务端不能只依赖客户端“取消”消息判断是否没有数据。

生产协议通常需要额外的清理接口:

DELETE /api/uploads/u_abc123
Authorization: Bearer <token>

它负责删除临时分片和上传记录。即使客户端崩溃、浏览器断电或页面关闭,服务端还应通过过期时间和后台清理任务回收长期未完成的上传。


九、一个可运行的分片上传核心实现

下面的代码展示一个串行上传版本:一次只上传一个分片。它更容易验证状态、取消和重试逻辑,吞吐量不如并行上传,但可以作为可靠的基础实现。

假设初始化接口返回:

type InitResponse = {
  uploadId: string;
  chunkSize: number;
  completedChunks: number[];
};

客户端实现:

type UploadCallbacks = {
  onInitialized?: (data: InitResponse) => void;
  onProgress?: (uploadedBytes: number) => void;
  onCompleted?: (result: unknown) => void;
};

type UploadConfig = {
  file: File;
  signal: AbortSignal;
  callbacks?: UploadCallbacks;
  maxRetries?: number;
};

async function parseJson<T>(response: Response): Promise<T> {
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }
  return response.json() as Promise<T>;
}

async function initUpload(
  file: File,
  signal: AbortSignal,
): Promise<InitResponse> {
  const response = await fetch("/api/uploads/init", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      name: file.name,
      size: file.size,
      contentType: file.type || "application/octet-stream",
      lastModified: file.lastModified,
    }),
    signal,
  });

  return parseJson<InitResponse>(response);
}

async function completeUpload(
  uploadId: string,
  file: File,
  totalChunks: number,
  signal: AbortSignal,
): Promise<unknown> {
  const response = await fetch(`/api/uploads/${uploadId}/complete`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      name: file.name,
      contentType: file.type || "application/octet-stream",
      totalChunks,
    }),
    signal,
  });

  return parseJson(response);
}

export async function uploadFile({
  file,
  signal,
  callbacks,
  maxRetries = 3,
}: UploadConfig): Promise<void> {
  const init = await initUpload(file, signal);
  callbacks?.onInitialized?.(init);

  if (!Number.isInteger(init.chunkSize) || init.chunkSize <= 0) {
    throw new Error("服务端返回了无效的 chunkSize");
  }

  const totalChunks = Math.ceil(file.size / init.chunkSize);
  const completed = new Set(init.completedChunks);
  let uploadedBytes = 0;

  for (const index of completed) {
    const start = index * init.chunkSize;
    const end = Math.min(start + init.chunkSize, file.size);
    uploadedBytes += end - start;
  }

  callbacks?.onProgress?.(uploadedBytes);

  for (let index = 0; index < totalChunks; index++) {
    if (completed.has(index)) {
      continue;
    }

    const start = index * init.chunkSize;
    const end = Math.min(start + init.chunkSize, file.size);
    const chunk = file.slice(start, end);

    let currentChunkLoaded = 0;

    await withRetry(
      () =>
        uploadBlobWithXhr({
          url: `/api/uploads/${init.uploadId}/chunks/${index}`,
          body: chunk,
          signal,
          headers: {
            "Content-Type": "application/octet-stream",
            "X-Chunk-Index": String(index),
            "X-Chunk-Size": String(chunk.size),
            "X-File-Size": String(file.size),
          },
          onProgress: (loaded) => {
            // 当前分片的进度只替换当前临时值,不重复累加
            currentChunkLoaded = Math.min(loaded, chunk.size);
            callbacks?.onProgress?.(
              uploadedBytes + currentChunkLoaded,
            );
          },
        }),
      {
        maxRetries,
        signal,
      },
    );

    // 只有该分片收到成功响应后,才成为已确认字节
    uploadedBytes += chunk.size;
    completed.add(index);
    callbacks?.onProgress?.(uploadedBytes);
  }

  const result = await completeUpload(
    init.uploadId,
    file,
    totalChunks,
    signal,
  );

  callbacks?.onCompleted?.(result);
}

这段实现如何避免进度错误

假设第 0 个分片大小为 8 MiB

  1. 第一次发送到 3 MiB 时,显示 3 MiB
  2. 网络失败;
  3. 重试发送到 2 MiB 时,显示 2 MiB,而不是 5 MiB
  4. 重试收到成功响应后,uploadedBytes 增加完整的 8 MiB
  5. 下一个分片开始时,整体进度从 8 MiB 继续。

currentChunkLoaded 是当前请求的瞬时进度,uploadedBytes 是之前已经确认成功的累计字节。两者不能混成一个变量。

React 组件接入

import { useEffect, useReducer, useRef } from "react";

export function FileUploader() {
  const [state, dispatch] = useReducer(
    uploadReducer,
    initialState,
  );
  const controllerRef = useRef<AbortController | null>(null);

  function selectFile(file: File) {
    const previewUrl = URL.createObjectURL(file);
    dispatch({ type: "select", file, previewUrl });
  }

  async function startUpload() {
    if (!state.file) return;

    const controller = new AbortController();
    controllerRef.current = controller;

    try {
      await uploadFile({
        file: state.file,
        signal: controller.signal,
        callbacks: {
          onInitialized: ({
            uploadId,
            chunkSize,
            completedChunks,
          }) => {
            const uploadedBytes = completedChunks.reduce(
              (sum, index) => {
                const start = index * chunkSize;
                const end = Math.min(
                  start + chunkSize,
                  state.file!.size,
                );
                return sum + (end - start);
              },
              0,
            );

            dispatch({
              type: "initialized",
              uploadId,
              chunkSize,
              totalChunks: Math.ceil(
                state.file!.size / chunkSize,
              ),
              completedChunks,
              uploadedBytes,
            });
          },
          onProgress: (uploadedBytes) => {
            dispatch({ type: "progress", uploadedBytes });
          },
          onCompleted: () => {
            dispatch({ type: "completed" });
          },
        },
      });
    } catch (error) {
      if (error instanceof DOMException && error.name === "AbortError") {
        dispatch({ type: "cancelled" });
      } else {
        dispatch({
          type: "error",
          message:
            error instanceof Error ? error.message : "上传失败",
        });
      }
    } finally {
      controllerRef.current = null;
    }
  }

  function cancelUpload() {
    if (
      state.status === "uploading" ||
      state.status === "preparing"
    ) {
      dispatch({ type: "cancelling" });
      controllerRef.current?.abort();
    }
  }

  useEffect(() => {
    return () => {
      controllerRef.current?.abort();

      if (state.previewUrl) {
        URL.revokeObjectURL(state.previewUrl);
      }
    };
  }, [state.previewUrl]);

  const progress =
    state.file && state.file.size > 0
      ? Math.min(
          100,
          (state.uploadedBytes / state.file.size) * 100,
        )
      : 0;

  return (
    <section>
      <DropZone onFile={selectFile} />

      {state.file && (
        <>
          <FilePreview file={state.file} />
          <p>
            {state.file.name}:{progress.toFixed(1)}%
          </p>

          <progress
            max={100}
            value={progress}
            aria-label="上传进度"
          />

          {state.status === "preparing" && (
            <button onClick={startUpload}>开始上传</button>
          )}

          {(state.status === "uploading" ||
            state.status === "cancelling") && (
            <button
              onClick={cancelUpload}
              disabled={state.status === "cancelling"}
            >
              {state.status === "cancelling"
                ? "正在取消"
                : "取消上传"}
            </button>
          )}

          {state.status === "completed" && <p>上传完成</p>}
          {state.status === "cancelled" && <p>上传已取消</p>}
          {state.status === "error" && (
            <p role="alert">{state.error}</p>
          )}
        </>
      )}
    </section>
  );
}

这段组件有一个需要注意的 React 闭包问题:startUpload 内部的 state.file 是开始上传时那次渲染中的值。如果允许上传期间替换文件,应在开始前冻结任务对应的 File 和元数据,或者把完整任务放入独立的任务对象,而不能依赖不断变化的全局选择状态。

更稳妥的做法是为每次上传生成不可变任务:

type UploadTask = {
  taskId: string;
  file: File;
  uploadId: string | null;
};

界面选择新文件时创建新任务,旧任务的取消控制器和状态不再与新文件混用。


十、并行上传:吞吐量增加,状态复杂度也增加

串行上传的时序是:

chunk 0 完成
  -> chunk 1 完成
    -> chunk 2 完成

并行上传可能是:

chunk 0 ─────完成
chunk 1 ───失败──重试──完成
chunk 2 ─────────完成
chunk 3 ──完成

并行上传通常需要一个固定大小的工作池,例如并发数为 3

async function runWithConcurrency(
  indexes: number[],
  concurrency: number,
  worker: (index: number) => Promise<void>,
): Promise<void> {
  let cursor = 0;

  async function consume() {
    while (true) {
      const current = cursor++;
      if (current >= indexes.length) return;
      await worker(indexes[current]);
    }
  }

  const workers = Array.from(
    { length: Math.min(concurrency, indexes.length) },
    () => consume(),
  );

  await Promise.all(workers);
}

每个 worker 负责一个分片,分片成功后更新已确认集合:

await runWithConcurrency(
  pendingIndexes,
  3,
  async (index) => {
    const chunk = file.slice(
      index * chunkSize,
      Math.min((index + 1) * chunkSize, file.size),
    );

    await withRetry(
      () =>
        uploadBlobWithXhr({
          url: `/api/uploads/${uploadId}/chunks/${index}`,
          body: chunk,
          signal,
        }),
      { maxRetries: 3, signal },
    );

    // 这里需要保证同一个 index 只进入一次
    dispatch({
      type: "chunk-completed",
      index,
      bytes: chunk.size,
    });
  },
);

并行版本有三个新的边界:

  1. 进度更新顺序不确定:分片 5 可能先于分片 1 成功;
  2. 取消需要中止多个 XHR:所有请求必须共享同一个 AbortSignal
  3. 不要无限并发:并发数过高会增加内存、连接、服务端负载和失败概率。

浏览器对同一来源的连接数量、移动网络的带宽和服务端限流都会影响实际结果。并发数不是越大越快,应通过实际网络和服务端指标调整。

更复杂的并行实现还应维护每个分片的局部进度:

const inFlightBytes = new Map<number, number>();
const confirmed = new Set<number>();

function calculateDisplayedBytes() {
  let value = 0;

  for (const index of confirmed) {
    value += getChunkSize(index);
  }

  for (const [index, loaded] of inFlightBytes) {
    if (!confirmed.has(index)) {
      value += Math.min(loaded, getChunkSize(index));
    }
  }

  return value;
}

这里显示的是“已确认字节 + 当前请求已发送字节”,而不是最终成功字节。若需要严格的服务端确认进度,应只使用 confirmed 计算;若需要更平滑的视觉进度,可以显示两者,但应明确这是估算值。


十一、断点续传和文件身份

断点续传依赖服务端保存上传会话。客户端重新初始化时,需要让服务端知道这是原来的文件,而不是一个同名新文件。

仅使用文件名是不安全的,因为以下两个文件可能同名但内容不同:

report.pdf,大小相同,内容不同

一个常见的弱文件标识是:

fileKey = name + size + lastModified

它能降低误匹配概率,但不能证明文件内容相同。更严格的做法是计算哈希,例如 SHA-256:

async function sha256(blob: Blob): Promise<string> {
  const buffer = await blob.arrayBuffer();
  const digest = await crypto.subtle.digest("SHA-256", buffer);

  return Array.from(new Uint8Array(digest))
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
}

完整读取超大文件可能消耗大量内存和 CPU。浏览器支持的增量哈希能力、第三方库和服务端协议需要根据项目环境选择;不能为了获得哈希而无条件把整个几十 GB 文件读入内存。

服务端也可以对每个分片计算哈希:

chunkHash = SHA-256(chunk bytes)

客户端重试相同分片时,服务端比较分片索引和哈希,能够检测重复提交或内容不一致。


十二、服务端合并的关键一致性条件

服务端不能简单执行:

收到分片就 append 到一个最终文件

因为并行上传时分片到达顺序不确定。正确方式通常是:

  1. uploadId 建立临时目录;
  2. 将分片按编号分别保存;
  3. 记录每个分片的大小、哈希和状态;
  4. complete 时按编号读取 0...totalChunks-1
  5. 依次写入临时合并文件;
  6. 校验最终大小或完整哈希;
  7. 原子地移动到最终存储位置;
  8. 更新数据库状态为完成。

例如文件大小为 20 MiB、分片大小为 8 MiB

totalChunks = ceil(20 / 8) = 3
chunk 0 = 8 MiB
chunk 1 = 8 MiB
chunk 2 = 4 MiB

如果服务端只收到了 [0, 2],即使总字节数看起来接近,也不能完成,因为中间缺少分片 1。服务端必须按编号检查,而不能仅检查“收到的分片总大小”。

服务端还需要设置上传会话过期时间:

创建时间:2025-01-01 10:00
过期时间:2025-01-08 10:00

过期后:

  • 查询接口返回会话不存在或已过期;
  • 分片接口拒绝继续写入;
  • 后台任务删除临时分片;
  • 客户端重新初始化,而不是无限重试旧的 uploadId

十三、错误处理和诊断路径

1. 拖拽后页面直接打开文件

表现:浏览器离开当前页面,显示文件内容。

原因:没有在 dragoverdrop 中调用 preventDefault()

诊断:在 onDrop 第一行打印日志,确认事件是否进入组件;检查事件是否被父级处理器阻止。

2. 选择同一个文件第二次没有反应

表现:第一次选择有效,取消后再次选择相同文件没有触发回调。

原因:文件 input 的 value 没有清空。

修复

event.currentTarget.value = "";

3. 进度超过 100%

表现:重试后出现 103%

原因:把每次 XHR 的 loaded 都累加,重复计算了失败请求和重试请求。

修复:当前分片使用临时进度,只有收到成功响应才将完整分片字节加入确认总量。

4. 取消按钮无效

表现:按钮变灰,但请求仍在后台继续。

原因:没有保存 AbortController,或者上传函数内部没有把 signal 传给每个请求。

诊断:浏览器 Network 面板查看 XHR 是否变为 canceled;服务端日志可能仍然显示请求已经收到部分数据,这是正常竞态,不代表 abort() 没有执行。

5. 刷新后从零开始

表现:页面刷新后所有分片重新上传。

原因:服务端没有查询已完成分片,或者客户端没有保存并恢复 uploadId

修复:实现状态查询接口,并将 uploadId、文件身份和任务状态保存到合适的位置。不要只把临时状态放在 React 内存中。

6. 预览越来越占内存

表现:反复选择大图片或视频后页面变慢。

原因:每次 createObjectURL 后没有 revokeObjectURL

诊断:浏览器 Performance 或 Memory 面板观察对象 URL 和 Blob 相关资源;检查 useEffect 清理函数是否与创建逻辑成对存在。

7. 服务端返回 413 或 415

表现:每个分片都失败,重试没有意义。

原因

  • 单个分片超过反向代理或服务端请求上限;
  • 媒体类型被服务端拒绝;
  • 客户端声明的 Content-Type 与服务端策略不符。

处理:读取服务端响应的错误码,不要把确定性错误当成网络错误重试。


十四、不要把客户端校验当成安全边界

客户端校验的作用是尽早反馈:

function validateClientFile(file: File): string | null {
  const maxSize = 2 * 1024 * 1024 * 1024;

  if (file.size > maxSize) {
    return "文件过大";
  }

  const allowedTypes = new Set([
    "image/jpeg",
    "image/png",
    "application/pdf",
  ]);

  if (!allowedTypes.has(file.type)) {
    return "文件类型不支持";
  }

  return null;
}

但攻击者可以直接调用 HTTP 接口,绕过 React 页面。因此服务端必须重新校验:

  • 当前用户是否有上传权限;
  • 文件总大小和单分片大小;
  • 分片数量和编号;
  • MIME 类型和文件魔数;
  • 文件名是否需要清洗;
  • 病毒、恶意脚本和压缩炸弹;
  • 临时文件是否属于当前用户;
  • 最终文件是否需要访问控制或签名 URL。

文件名不可直接作为磁盘路径:

// 危险思路
path.join(uploadDir, userProvidedName);

应使用服务端生成的不可预测 ID 保存文件,原始文件名只作为元数据。下载时也不能仅依赖前端传入的 URL,否则可能形成越权访问。


十五、useEffect、事件处理和 React 19 的生命周期边界

上传启动、取消和拖拽处理都属于用户事件驱动的副作用,通常应该在事件处理函数中执行:

<button onClick={startUpload}>开始上传</button>

不要因为 state.status 变化,就在 useEffect 中无条件启动上传:

// 容易造成重复启动、Strict Mode 开发行为混淆
useEffect(() => {
  if (status === "uploading") {
    startUpload();
  }
}, [status]);

useEffect 更适合管理:

  • 对象 URL 创建与释放;
  • 组件卸载时取消请求;
  • 与外部系统建立和清理连接;
  • 订阅上传任务状态。

React 19 的 Actions、表单相关能力可以帮助组织表单提交和待处理状态,但它们不会自动提供:

  • XHR 上传字节进度;
  • 文件分片协议;
  • 服务端断点续传;
  • 大文件哈希;
  • 分片合并。

因此,上传进度和取消仍需通过浏览器网络 API 与服务端协议实现。React 版本升级也不会改变 HTTP 服务端必须持久化分片这一事实。


十六、选择分片大小和并发数时的取舍

分片越小:

  • 失败重试的代价越低;
  • 分片元数据和请求数量越多;
  • 初始化、调度和合并成本增加。

分片越大:

  • 请求数量较少;
  • 单次失败损失更大;
  • 代理、网关和服务端超时限制更容易触发;
  • 并发上传时内存占用更高。

例如,1 GiB 文件:

  • 8 MiB 分片约有 128 个;
  • 64 MiB 分片约有 16 个。

前者更适合不稳定网络,后者请求管理成本更低。没有脱离网络环境、代理限制和后端实现的固定最佳值。服务端返回 chunkSize,并在初始化阶段根据用户权限和文件大小决定,是更容易统一约束的方案。

并发数同样存在边界:

并发数 = 1:逻辑简单,吞吐量可能较低
并发数 = 3:通常能利用更多带宽,状态仍较易管理
并发数过高:可能触发限流、内存压力和更多失败

这些是工程取舍,不是 React 规范保证。


十七、一个完整的最小页面组合

将前面的组件组合起来:

export function UploadPage() {
  return (
    <main>
      <h1>文件上传</h1>
      <FileUploader />
    </main>
  );
}

CSS 只负责视觉反馈,不改变上传协议:

.drop-zone {
  width: 360px;
  padding: 32px;
  border: 2px dashed #999;
  border-radius: 8px;
  text-align: center;
  cursor: pointer;
}

.drop-zone.is-dragging {
  border-color: #1677ff;
  background: #f0f7ff;
}

button {
  margin: 8px 8px 8px 0;
}

预期行为是:

  1. 点击区域或按 Enter 打开文件选择器;
  2. 拖入文件时区域改变样式;
  3. 选择图片后显示本地预览;
  4. 点击开始后调用初始化接口;
  5. 客户端按服务端返回的分片大小切割文件;
  6. 每个分片通过 XHR 上传,并在成功后推进整体进度;
  7. 临时网络错误按退避策略重试;
  8. 点击取消后中止当前请求;
  9. 全部分片成功后调用完成接口;
  10. 服务端返回最终文件信息后显示完成。

如果初始化失败,任务应停留在错误状态;如果某个分片多次失败,任务也应停止,不应继续上传后续分片,因为最终合并必然缺少该分片。用户可以保留 uploadId 后再次重试,也可以删除临时上传并重新开始。


十八、生产实现需要额外考虑的协议问题

鉴权和授权

每个初始化、查询、分片和完成请求都应验证用户身份。服务端必须检查 uploadId 是否属于当前用户,不能因为用户知道一个 ID 就读取或覆盖其他人的临时文件。

幂等性

客户端可能因为响应丢失而重试一个实际已经写入成功的分片。服务端应使以下操作幂等:

PUT /uploads/{uploadId}/chunks/{index}

重复提交相同内容应返回成功或明确的已有状态;不同内容应拒绝并记录冲突。

超时和代理限制

浏览器、CDN、反向代理和应用服务都可能有不同的请求体大小和超时限制。客户端将文件分片,并不意味着中间代理允许任意分片大小。应在部署环境中验证:

  • 单请求最大体积;
  • 上传请求超时;
  • 空闲连接超时;
  • HTTP/2 或 HTTP/3 下的并发行为;
  • 请求取消后服务端资源是否释放。

存储位置

如果使用对象存储,服务端可以把每个分片上传到对象存储的 multipart upload,会话和分片编号由服务端或对象存储管理。客户端也可以获得短期签名 URL,但签名 URL 必须限制:

  • 对象路径;
  • HTTP 方法;
  • 有效时间;
  • 内容长度;
  • 当前上传会话。

客户端不应持有长期存储密钥。

大文件哈希和完整性

文件大小相同不代表内容相同。完整哈希可以用于:

  • 秒传判断;
  • 断点续传身份确认;
  • 合并后完整性校验;
  • 检测传输或存储异常。

但完整哈希计算会消耗客户端 CPU 和读取时间。是否计算、计算哪种哈希、由客户端还是服务端计算,应根据安全和性能需求决定。


结语:把上传看成一个可恢复的状态协议

一个可靠的 React 文件上传功能不是“给 input 加一个进度条”,而是以下关系的组合:

用户交互
  -> File 对象
  -> 本地预览
  -> uploadId
  -> 分片请求
  -> 可重试的幂等确认
  -> 可取消的请求生命周期
  -> 已完成分片状态
  -> 服务端合并与校验
  -> 最终文件资源

React 管理的是任务状态、事件和生命周期;浏览器负责读取 File、创建对象 URL、发送 XHR 和中止请求;服务端负责鉴权、分片持久化、幂等处理、完整性验证、合并和清理。

只有当这些边界都明确时,拖拽、分片、进度、取消、重试和预览才会从彼此独立的界面功能,变成一个能够应对大文件、刷新、网络失败和并发请求的完整上传系统。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。