从 0 到 1 实现大文件上传:我在 Next.js 项目中踩过的坑与解决方案

110 阅读7分钟

作为前端开发,你是否遇到过用户上传大文件时的崩溃场景?进度条卡住、上传失败后需要重新开始、服务器不堪重负... 这些问题不仅影响用户体验,更可能让用户直接放弃使用你的产品。

最近在开发一个 Next.js 全栈项目时,我就遇到了大文件上传的挑战。经过一番折腾,终于实现了一套分片上传 + 断点续传 + 并发控制的完整方案,今天就把这个过程中的实战经验分享给大家。

为什么需要特殊处理大文件上传?

普通的表单上传在处理几 MB 的小文件时没什么问题,但当面对几百 MB 甚至几个 GB 的大文件时,就会暴露很多问题:

  • 单次请求体积过大,容易超时失败
  • 一旦失败需要重新上传整个文件,非常浪费资源
  • 服务器同时占用服务器带宽,影响其他服务
  • 浏览器对单次请求的大小有限制

所以,我们需要一套套更智能的上传方案。

核心实现思路

我们的解决方案主要基于这几个关键点:

  1. 分片上传:把大文件切成小块逐个上传
  2. 并发控制:限制同时上传的分片数量,保护服务器
  3. 断点续传:支持暂停和继续,失败后不用从头再来
  4. 秒传功能:已上传过的文件直接确认完成

接下来,我们一步步看看具体怎么实现。

前端实现:分片处理与上传控制

首先从前端开始,我们需要处理文件的切割、哈希计算和上传控制。

文件选择与哈希计算

// 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-hashx-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事件,返回错误信息。

实际开发中的优化点

  1. Web Worker 计算哈希:避免大文件计算阻塞 UI,确保页面在计算哈希过程中仍然可以响应用户操作。
  2. 使用 useCallback 缓存函数:减少不必要的重渲染,提高组件性能。特别是对于频繁触发的函数,如文件处理函数,缓存可以显著提升性能。
  3. 自定义请求头传递元数据:让服务端快速识别分片归属,避免在请求体中传递这些信息,简化处理逻辑。
  4. 文件元数据管理:记录上传状态,支持断点续传。元数据包括文件基本信息、已上传分片列表等,确保在页面刷新或重新打开后仍能恢复上传进度。
  5. 秒传优化:通过文件哈希直接识别已上传文件,避免重复上传,节省带宽和时间。

遇到的坑与解决方案

  1. 哈希计算性能问题:大文件计算慢,解决方案是用 Web Worker 在后台计算。一开始直接在主线程计算哈希,导致页面卡顿甚至假死,使用 Web Worker 后,页面可以正常响应。
  2. 并发控制不当:一开始没限制并发,导致服务器报错,后来加入了 MAX_CONCURRENCY 控制。过多的并发请求会导致服务器连接数耗尽,适当的并发数可以在效率和服务器压力之间取得平衡。
  3. 分片顺序错误:合并时顺序乱了导致文件损坏,后来严格按索引顺序合并。分片必须按原始顺序合并,否则会导致文件内容错乱,无法正常使用。
  4. 内存占用过高:一次性读取所有分片导致内存暴涨,改成了流式写入。通过流的方式处理文件,可以减少内存占用,提高系统稳定性。

总结

大文件上传看似复杂,但拆分成 "分片 - 上传 - 合并" 这几个步骤后,就变得清晰多了。核心在于如何高效地切割文件、控制上传节奏,并在服务端正确地重组文件。

这个方案不仅解决了大文件上传的痛点,还通过断点续传和秒传功能极大提升了用户体验。在实际项目中,你可以根据需求调整分片大小和并发数量,找到最适合你业务场景的平衡点。

希望这篇文章能帮到正在解决类似问题的你,如果你有更好的方案或疑问,欢迎在评论区交流!