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 或文件标识
这里有两个容易混淆的概念:
- 浏览器上传请求的进度:当前 HTTP 请求已经发送了多少字节。
- 整个文件的进度:所有分片已经成功发送了多少字节。
如果某个分片失败并重试,第一次失败请求发送过的字节不能直接计入“已成功上传”的文件进度。因此,生产实现应以服务端确认成功的分片字节数为基础,而不是简单累加所有请求的 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 中应该如何建模上传状态
上传任务至少包含三类状态:
- 文件状态:文件名、大小、类型、预览 URL;
- 协议状态:
uploadId、分片大小、已完成分片; - 界面状态:排队、上传中、成功、失败、取消和错误信息。
不要只保存一个 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表示文件总字节数。
则整体进度是:
代码如下:
const progress =
state.file && state.file.size > 0
? Math.min(100, (state.uploadedBytes / state.file.size) * 100)
: 0;
Math.min 不是为了掩盖错误,而是防止重复确认、整数误差或异常服务端响应导致界面显示 101%。真正的重复计数问题仍应在状态和协议层修复。
三、拖拽上传:事件、默认行为和文件过滤
拖拽上传依赖 DragEvent.dataTransfer.files。浏览器默认可能会把拖入的文件直接打开,因此必须阻止 dragover 和 drop 的默认行为。
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"
}
服务端必须在此时重新确认:
- 所有分片都存在;
- 分片编号没有重复或缺失;
- 每个分片大小符合预期;
- 合并后的大小正确;
- 可选地校验完整文件哈希;
- 当前用户有权完成该上传。
客户端发出 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);
});
}
onprogress 的 loaded 表示当前请求已经交给浏览器发送的字节,不代表服务端已经持久化。服务端返回 2xx 后,客户端才能把该分片算作成功。
如果页面使用跨域 API,服务端还必须正确处理 CORS,尤其是自定义请求头和 PUT 方法对应的预检请求。否则 XHR 会在浏览器侧失败,服务端可能根本没有收到分片。
七、重试:只重试可恢复错误,并采用退避
重试不是“失败后立刻再发一次”。连续立即重试会在服务端或网络已经拥堵时进一步放大压力。
常见可重试情况:
- 网络连接暂时中断;
- 请求超时;
- HTTP
408、429; - HTTP
500、502、503、504。
通常不应自动重试:
400:请求格式错误;401、403:认证或权限错误;413:文件过大;415:媒体类型不支持;- 服务端明确返回业务校验失败。
指数退避可以表示为:
其中:
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();
取消的实际效果是:
- 浏览器停止等待或发送该请求;
- 当前 XHR 触发
abort; - 客户端 Promise 以
AbortError失败; - 服务端可能已经收到部分或全部请求体。
最后一点很重要:网络取消是竞态过程。客户端调用 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:
- 第一次发送到
3 MiB时,显示3 MiB; - 网络失败;
- 重试发送到
2 MiB时,显示2 MiB,而不是5 MiB; - 重试收到成功响应后,
uploadedBytes增加完整的8 MiB; - 下一个分片开始时,整体进度从
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,
});
},
);
并行版本有三个新的边界:
- 进度更新顺序不确定:分片 5 可能先于分片 1 成功;
- 取消需要中止多个 XHR:所有请求必须共享同一个
AbortSignal; - 不要无限并发:并发数过高会增加内存、连接、服务端负载和失败概率。
浏览器对同一来源的连接数量、移动网络的带宽和服务端限流都会影响实际结果。并发数不是越大越快,应通过实际网络和服务端指标调整。
更复杂的并行实现还应维护每个分片的局部进度:
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 到一个最终文件
因为并行上传时分片到达顺序不确定。正确方式通常是:
- 为
uploadId建立临时目录; - 将分片按编号分别保存;
- 记录每个分片的大小、哈希和状态;
complete时按编号读取0...totalChunks-1;- 依次写入临时合并文件;
- 校验最终大小或完整哈希;
- 原子地移动到最终存储位置;
- 更新数据库状态为完成。
例如文件大小为 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. 拖拽后页面直接打开文件
表现:浏览器离开当前页面,显示文件内容。
原因:没有在 dragover 或 drop 中调用 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;
}
预期行为是:
- 点击区域或按 Enter 打开文件选择器;
- 拖入文件时区域改变样式;
- 选择图片后显示本地预览;
- 点击开始后调用初始化接口;
- 客户端按服务端返回的分片大小切割文件;
- 每个分片通过 XHR 上传,并在成功后推进整体进度;
- 临时网络错误按退避策略重试;
- 点击取消后中止当前请求;
- 全部分片成功后调用完成接口;
- 服务端返回最终文件信息后显示完成。
如果初始化失败,任务应停留在错误状态;如果某个分片多次失败,任务也应停止,不应继续上传后续分片,因为最终合并必然缺少该分片。用户可以保留 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 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React Hook Form:字段注册、Schema、动态表单和性能
- 下一篇:React 拖拽交互:排序、跨容器、触摸、键盘和状态一致性
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论