各位同学,大家好!
欢迎来到今天的讲座。我是你们的“资深文件搬运工”,今天我们不聊什么高深的算法,也不谈什么微服务架构,我们来聊点“痛”——大文件上传。
我知道,当你在前端界摸爬滚打久了,你一定见过这样的场景:用户选了一个 5GB 的视频,点“上传”,浏览器转圈圈,你看着进度条卡在 1%,心里咯噔一下。然后你切出去喝杯咖啡,回来一看,浏览器崩了,或者进度条回到了 0%。
那一刻,用户的心碎声,隔着屏幕我都能听到。
所以,今天我们的主题是:如何在 React 组件里,优雅地、像瑞士军刀一样,实现一个可中断、可续传、还能监控状态的分片上传系统。这不仅是技术实现,更是一种对用户体验的关怀,一种“哪怕天塌下来,我也要把这块饼干上传完”的执着。
准备好了吗?让我们把代码敲起来。
第一部分:为什么要搞分片上传?(别急着写代码,先理解哲学)
很多同学一上来就问:“老师,为什么不能直接把文件扔给后端?”
这就好比你要把一座山搬回家。你不会直接扛着山走,你会用炸药把它炸成碎石,装在袋子里,一趟一趟背。这就是分片上传的核心哲学:化整为零,各个击破。
为什么这么做?理由有三:
- 网络不稳定?不怕。 如果是 1GB 的文件,传到 90% 时断了,你只能重来。如果是 1GB 的文件分成了 100 个 10MB 的块,传到第 90 个块断了,你只需要重传第 90 个块,前面的 89 个块白跑了吗?不,它们已经在服务器里了!这就是续传。
- 进度条?必须有。 上传 1GB 文件,你告诉我 0% 到 1% 需要传多久?用户会疯的。分片后,每个 10MB 的块传完,进度条走 1%,这叫“反馈”,这叫“确定性”。
- 并发控制?省资源。 浏览器虽然快,但也不能同时向 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.status 和 state.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 而卡死,用户还能看到“正在计算指纹…”的提示。
第十部分:实战演练——完整流程回顾
让我们把这些点串起来,想象一下用户操作的完整流程:
- 选择文件:用户选了一个 4GB 的视频。组件初始化,状态为
IDLE。 - 点击开始:组件调用
generateChunks。虽然 4GB 切成 200 个 20MB 的块很快,但 UI 还是卡顿了 0.1 秒(Web Worker 优化后可忽略)。状态变为SPLITTING,然后SPLIT_DONE。 - 并发上传:调度器启动,同时发 3 个请求。
- 请求 1:上传第 1 块。成功。Reducer 更新进度到 1.5%。
- 请求 2:上传第 2 块。成功。Reducer 更新进度到 3%。
- 请求 3:上传第 3 块。成功。Reducer 更新进度到 4.5%。
- 断网:用户突然拔了网线。第 4 个请求失败。Reducer 状态变为
ERROR,显示错误信息。 - 重试:用户插上网线,点击“重试”。
- 调度器再次运行,但这次它检查了
uploadedChunks列表。发现第 1、2、3 块已经上传成功,直接跳过。 - 它只发起新的第 4 块的请求。
- 调度器再次运行,但这次它检查了
- 暂停:用户想喝口水,点击“暂停”。所有请求被 AbortController 取消,状态变为
PAUSED。 - 继续:用户回来,点击“继续”。状态变回
UPLOADING,调度器从第 4 块继续。 - 完成:进度条走到 100%,状态变为
COMPLETED,显示“上传成功”。
第十一部分:总结与“避坑指南”
好了,讲了这么多,我们来总结一下这个“状态机监控”的核心要点:
- 不要相信单文件上传:除非你确定网络永远不挂,否则必须分片。
- 状态机是灵魂:用
useReducer管理状态,而不是一坨useState混在一起。状态转换要有逻辑,不能乱跳。 - 并发控制是生命线:
Promise.all是并发,但我们需要更精细的Promise.allSettled或者递归控制,以防止浏览器崩溃。 - 中断要彻底:
AbortController必须配合useEffect的清理函数,否则组件卸载了请求还在跑,那是内存泄漏。 - 续传要持久化:没有服务器端记录的续传是耍流氓。每次上传前,先问服务器:“大哥,我上次传到哪了?”
最后,我想说,大文件上传虽然麻烦,但它体现了前端工程化的一种“韧性”。一个好的分片上传系统,就像一个尽职尽责的保镖,无论环境多么恶劣(网络抖动、用户中途离开),它都能保证任务(文件)最终安全送达。
希望今天的讲座能让你在处理大文件上传时,少掉几根头发,多几分自信。如果代码跑不通,先检查一下你的 chunkSize 和 concurrency,别把服务器累死了。
下课!