作为前端开发,你是否遇到过用户上传大文件时的崩溃场景?进度条卡住、上传失败后需要重新开始、服务器不堪重负... 这些问题不仅影响用户体验,更可能让用户直接放弃使用你的产品。
最近在开发一个 Next.js 全栈项目时,我就遇到了大文件上传的挑战。经过一番折腾,终于实现了一套分片上传 + 断点续传 + 并发控制的完整方案,今天就把这个过程中的实战经验分享给大家。
为什么需要特殊处理大文件上传?
普通的表单上传在处理几 MB 的小文件时没什么问题,但当面对几百 MB 甚至几个 GB 的大文件时,就会暴露很多问题:
- 单次请求体积过大,容易超时失败
- 一旦失败需要重新上传整个文件,非常浪费资源
- 服务器同时占用服务器带宽,影响其他服务
- 浏览器对单次请求的大小有限制
所以,我们需要一套套更智能的上传方案。
核心实现思路
我们的解决方案主要基于这几个关键点:
- 分片上传:把大文件切成小块逐个上传
- 并发控制:限制同时上传的分片数量,保护服务器
- 断点续传:支持暂停和继续,失败后不用从头再来
- 秒传功能:已上传过的文件直接确认完成
接下来,我们一步步看看具体怎么实现。
前端实现:分片处理与上传控制
首先从前端开始,我们需要处理文件的切割、哈希计算和上传控制。
文件选择与哈希计算
// app/upload/page.tsx
const handleFile = useCallback(async (f: File) => {
setFile(f);
setStatus('计算哈希中...');
workerRef.current?.postMessage({
type: 'HASH',
file: f,
chunkSize: CHUNK_SIZE,
} as HashWorkerIn)
setStatus('上传中...');
},[])
const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if(f){
handleFile(f)
}
setProgress(0)
}
这里有个关键点:计算文件哈希时使用了 Web Worker,避免长时间阻塞主线程导致页面卡顿。Web Worker 是 HTML5 提供的多线程解决方案,它允许在后台线程中运行脚本,不会阻塞主线程的执行。对于大文件的哈希计算这种耗时操作来说,这是非常必要的优化。
// 哈希计算相关的effect
useEffect(()=>{
const worker = new Worker(new URL('../hash.worker.ts', import.meta.url));
workerRef.current = worker;
worker.onmessage = (e: MessageEvent<HashWorkerOut>) => {
const msg = e.data
if(msg.type === 'PROGRESS'){
setStatus(`计算哈希中... ${(msg.progress * 100).toFixed(2)}%`)
}
if(msg.type === 'DONE'){
setHash(msg.hash)
setStatus(`哈希值:${msg.hash}`)
}
}
return ()=>{
workerRef.current?.terminate();
workerRef.current = null;
}
},[])
在这段代码中,我们创建了一个 Web Worker 实例,并通过postMessage方法向 Worker 发送消息,传递文件和分片大小等信息。Worker 在计算哈希的过程中,会通过postMessage向主线程发送进度信息和计算结果。主线程通过onmessage事件监听这些消息,更新页面状态。同时,在组件卸载时,我们调用terminate方法终止 Worker,避免内存泄漏。
文件哈希的作用很重要:它就像文件的唯一身份证,用于识别文件是否已上传过,以及确保分片属于同一个文件。即使文件名相同但内容不同,计算出的哈希值也会不同,这样可以准确区分文件。
分片切割与上传
我们把文件分成固定大小的分片(这里是 5MB):
// app/upload/page.tsx
const CHUNK_SIZE = 1024 * 1024 * 5; // 5M 一片
const MAX_CONCURRENCY = 4 // 最大并发数
分片大小的选择需要根据实际情况权衡。如果分片太小,会增加请求数量,给服务器带来额外压力;如果分片太大,又会失去分片上传的优势。5MB 是一个比较常见的选择,你可以根据项目需求调整。
上传前先与服务器确认状态:
const initUpload = async():Promise<InitResp> =>{
const res = await fetch('/api/upload/init',{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileHash: hash,
fileName: file!.name,
fileSize: file!.size,
chunkSize: CHUNK_SIZE,
totalChunks
})
})
return res.json() as Promise<InitResp>
}
这个初始化请求的作用是告诉服务器要上传的文件信息,并获取已上传的分片列表。这样可以实现断点续传和秒传功能。如果服务器检测到该文件已经完整上传过,就会返回complete: true,前端可以直接提示上传完成,实现秒传。
服务器会返回哪些分片已经上传过,这样我们就只需要上传缺失的分片:
// 上传分片的核心函数
const uploadChunk = async (idx: number, signal: AbortSignal) =>{
const start = idx * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file!.size);
const blob = file!.slice(start, end);
const res = await fetch('/api/upload/chunk',{
method: 'PUT',
headers: {
'x-file-hash': hash,
'x-chunk-index': String(idx)
},
body: blob,
signal,
})
if(!res.ok){
throw new Error(`上传分片 ${idx} 失败`)
}
return res.json()
}
在这个函数中,我们通过file.slice方法切割出对应索引的分片。slice方法不会修改原始文件,只是创建一个新的 Blob 对象指向原始文件的某一部分,这样可以节省内存。我们使用 PUT 方法上传分片,并通过自定义请求头x-file-hash和x-chunk-index传递文件哈希和分片索引,方便服务器识别和存储。signal参数来自 AbortController,用于实现上传暂停功能。
并发控制实现
最关键的部分是并发控制,我们用了 "工人池" 的思想:
// 并发限流 队列
const queue: number[] = []
for(let i=0;i<totalChunks;i++){
if(!uploaded.has(i)){
queue.push(i)
}
}
// 上传工作函数
const workers: Promise<void>[] = []
const next = async ()=>{
if(pauseRef.current) return; // 暂停
const idx = queue.shift()
if(idx === undefined) return;
try{
await uploadChunk(idx, abortRef.current!.signal)
done++
setProgress(Math.floor((done / totalChunks) * 100))
}finally{
if(queue.length) await next()
}
}
// 启动指定数量的 worker
for(let c=0;c<Math.min(MAX_CONCURRENCY,queue.length);c++){
workers.push(next())
}
这种方式既保证了上传效率,又不会因为并发过多给服务器带来过大压力。我们首先创建一个需要上传的分片索引队列,然后启动指定数量(MAX_CONCURRENCY)的 "工人" 函数 next。每个工人函数会从队列中取出一个分片索引进行上传,上传完成后如果队列中还有任务,就继续取下一个分片,直到队列清空。这种递归调用的方式可以保持并发数的稳定,避免瞬间创建过多请求。
暂停与继续功能
通过 AbortController 和状态标记实现暂停功能:
// 暂停
const pause = ()=>{
pauseRef.current = true;
abortRef.current?.abort();
setStatus('上传暂停')
}
// 继续
const resume = async()=>{
if(!file || !hash) return;
setStatus('继续上传')
pauseRef.current = false;
await startUpload();
}
当用户点击暂停按钮时,我们设置pauseRef.current为 true,阻止新的分片上传,并调用abortRef.current.abort()终止当前正在进行的上传请求。AbortController 的 abort 方法会触发 fetch 请求的中断,这样可以立即停止正在上传的分片,节省带宽。当用户点击继续按钮时,我们重新调用startUpload方法,此时会重新向服务器请求已上传的分片列表,然后继续上传剩余的分片。
后端实现:接收与合并分片
前端搞定了,后端也得配合好。我们需要处理分片接收、存储和合并。
初始化上传
export async function POST(req: NextRequest) {
const {fileHash,fileName,fileSize,chunkSize,totalChunks} = await req.json()
ensureUploadDirs(fileHash)
// 检查文件是否已存在,如果存在则直接返回秒传
if(fileAlreadyExist(fileHash,fileName)){
return NextResponse.json({
complete: true,
uploaded: [],
message: '秒传,文件已存在'
})
}
// 检查已上传的分片
const existed = readMeta(fileHash);
const upLoaded = listUploadedChunks(fileHash);
const meta = {
fileName,
fileSize,
chunkSize,
totalChunks,
uploadedChunks: upLoaded,
complete: false,
}
writeMeta(fileHash, {...(existed || {}),...meta});
return NextResponse.json({
complete: false,
uploaded: upLoaded,
message: '初始化成功'
})
}
在初始化接口中,首先调用ensureUploadDirs函数创建存储分片和元数据的目录。然后检查文件是否已经完整上传,如果是,就返回秒传信息。否则,读取已上传的分片列表和元数据,更新元数据后返回给前端。元数据包含了文件的基本信息和已上传的分片列表,这样前端就知道哪些分片需要上传。
接收分片
export async function PUT(req: NextRequest) {
// 从请求头获取文件哈希和分片序号
const fileHash = req.headers.get('x-file-hash')
const chunkIndex = Number(req.headers.get('x-chunk-index'))
if(!fileHash || Number.isNaN(chunkIndex)){
return NextResponse.json({
error: '缺少文件哈希或分片序号',
},{
status: 400,
})
}
// 读取二进制数据
const buf = Buffer.from(await req.arrayBuffer())
await saveChunk(fileHash, chunkIndex, buf)
// 更新元数据
const meta = readMeta(fileHash)
if(meta)
{
const set = new Set([...(meta.uploadedChunks ?? []),chunkIndex])
meta.uploadedChunks = Array.from(set).sort((a,b) => a - b)
writeMeta(fileHash, meta)
}
return NextResponse.json({
ok: true,
uploaded: listUploadedChunks(fileHash)
})
}
这个接口负责接收前端上传的分片。首先从请求头中获取文件哈希和分片索引,进行参数校验。然后读取请求体中的二进制数据,调用saveChunk函数将分片保存到磁盘。保存成功后,更新元数据中的已上传分片列表,确保下次前端请求时能获取到最新的状态。这里使用 Set 来存储已上传的分片索引,可以自动去重,然后排序后保存,方便后续合并分片时按顺序读取。
合并分片
所有分片上传完成后,需要合并成完整文件:
export async function POST(req: NextRequest) {
const { fileHash } = await req.json()
ensureUploadDirs(fileHash)
const meta = readMeta(fileHash)
if(!meta)
{
return NextResponse.json({
err: '文件元数据不存在',
},{
status: 404,
})
}
const { fileName ,totalChunks } = meta
// 合并所有分片
const finalPath = await mergeChunks(fileHash, fileName, totalChunks)
meta.complete = true
meta.finalPath = finalPath as string
writeMeta(fileHash, meta)
return NextResponse.json({
ok: true,
finalPath,
})
}
当前端检测到所有分片都已上传完成后,会调用这个合并接口。接口首先获取文件的元数据,然后调用mergeChunks函数将所有分片合并成完整文件。合并完成后,更新元数据,标记文件上传完成,并记录最终文件的路径。
合并分片的具体实现:
export async function mergeChunks(fileHash: string, fileName: string, totalChunks: number) {
const { chunkDir } = getUploadDir(fileHash);
const finalPath = finalFilePath(fileHash, fileName);
const stream = createWriteStream(finalPath);
// 按顺序写入所有分片
for(let i = 0; i < totalChunks; i++){
const chunkPath = join(chunkDir, `${i}.part`);
if(!existsSync(chunkPath)){
throw new Error(`缺少分片 ${i} `);
}
const chunk = readFileSync(chunkPath);
stream.write(chunk);
}
stream.end();
return new Promise((resolve, reject) => {
stream.on("finish", () => resolve(finalPath));
stream.on("error", reject);
})
}
合并分片的关键是按顺序读取每个分片并写入到最终文件中。我们使用createWriteStream创建一个可写流,然后循环读取每个分片文件的内容,通过流的write方法写入到最终文件中。这样可以避免一次性将所有分片内容加载到内存中,节省内存空间。最后,我们监听流的finish事件,当所有数据都写入完成后,返回最终文件的路径。如果过程中出现错误,会触发error事件,返回错误信息。
实际开发中的优化点
- Web Worker 计算哈希:避免大文件计算阻塞 UI,确保页面在计算哈希过程中仍然可以响应用户操作。
- 使用 useCallback 缓存函数:减少不必要的重渲染,提高组件性能。特别是对于频繁触发的函数,如文件处理函数,缓存可以显著提升性能。
- 自定义请求头传递元数据:让服务端快速识别分片归属,避免在请求体中传递这些信息,简化处理逻辑。
- 文件元数据管理:记录上传状态,支持断点续传。元数据包括文件基本信息、已上传分片列表等,确保在页面刷新或重新打开后仍能恢复上传进度。
- 秒传优化:通过文件哈希直接识别已上传文件,避免重复上传,节省带宽和时间。
遇到的坑与解决方案
- 哈希计算性能问题:大文件计算慢,解决方案是用 Web Worker 在后台计算。一开始直接在主线程计算哈希,导致页面卡顿甚至假死,使用 Web Worker 后,页面可以正常响应。
- 并发控制不当:一开始没限制并发,导致服务器报错,后来加入了 MAX_CONCURRENCY 控制。过多的并发请求会导致服务器连接数耗尽,适当的并发数可以在效率和服务器压力之间取得平衡。
- 分片顺序错误:合并时顺序乱了导致文件损坏,后来严格按索引顺序合并。分片必须按原始顺序合并,否则会导致文件内容错乱,无法正常使用。
- 内存占用过高:一次性读取所有分片导致内存暴涨,改成了流式写入。通过流的方式处理文件,可以减少内存占用,提高系统稳定性。
总结
大文件上传看似复杂,但拆分成 "分片 - 上传 - 合并" 这几个步骤后,就变得清晰多了。核心在于如何高效地切割文件、控制上传节奏,并在服务端正确地重组文件。
这个方案不仅解决了大文件上传的痛点,还通过断点续传和秒传功能极大提升了用户体验。在实际项目中,你可以根据需求调整分片大小和并发数量,找到最适合你业务场景的平衡点。
希望这篇文章能帮到正在解决类似问题的你,如果你有更好的方案或疑问,欢迎在评论区交流!