🚀大文件上传的那些事

浏览器 → 上传服务端(单文件 / 分片 multipart、哈希、杀后台后的草稿续传)

上传字节走 HTTP/FormData(或等价 multipart 协议)。


前言:前两天看到JY分享的大文件上传知识突然想到之前搞的大文件上传好像存在一些问题遂回顾并改造了下之前的项目。

1. 上传策略总览

graph TD A[选择文件 append] --> B{文件大小} B -->|小于等于阈值| C[单文件 FormData 直传] B -->|大于阈值| D[Multipart 分片上传] C --> E[服务端返回 fileId] D --> E
模式 适用 要点
单文件上传 小文件 一次 HTTP 传完整File
Multipart 大文件 Init → 按块上传 → Complete;服务端用multipartId 汇总
任务调度 多文件 每文件一任务,最多同时上传6

2. 单文件 vs 分片

2.1 阈值(示例)

常量(示意) 典型值 含义
单文件上限 20 MB ≤ 此值走单文件接口
分片大小 10 MB 每个 part 的切片大小
ts 复制代码
const SINGLE_FILE_MAX = 20 * 1024 * 1024
const CHUNK_SIZE = 10 * 1024 * 1024

if (file.size > SINGLE_FILE_MAX) {
  await uploadWithMultipart(file)
} else {
  await uploadSingle(file)
}

2.2 Multipart 流程

text 复制代码
1. Init
   → multipartId

2. Upload parts(建议串行,便于进度与取消)
   对每个 Blob.slice(start, end):
   FormData { multipartId, file: chunk, partNumber }

3. Complete
   → fileId / 业务结果

4. Cancel(可选)
   → 清理服务端未完成任务
ts 复制代码
type PartProgress = (loaded: number, total: number) => void

/**
 * 上传单个分片到服务端
 * - partNumber 从 1 开始
 * - chunk 为 Blob.slice 视图,由浏览器读盘后放入 multipart body
 */
async function uploadPart(params: {
  multipartId: string
  chunk: Blob
  partNumber: number
  fileName?: string
  signal?: AbortSignal
  onProgress?: PartProgress
}): Promise<void> {
  const { multipartId, chunk, partNumber, fileName, signal, onProgress } = params

  const formData = new FormData()
  formData.append('multipartId', multipartId)
  formData.append('file', chunk, fileName)
  formData.append('partNumber', String(partNumber))

  // fetch 示意(亦可用 axios + onUploadProgress + signal)
  const res = await fetch('/api/multipart/upload-part', {
    method: 'POST',
    body: formData,
    signal,
  })

  // 若用 XHR / axios,可在此挂钩分片内进度:
  // onUploadProgress: (e) => onProgress?.(e.loaded, e.total)

  if (!res.ok) {
    throw new Error(`uploadPart failed: part=${partNumber} status=${res.status}`)
  }
}

/** axios 等价写法示意 */
async function uploadPartWithAxios(params: {
  multipartId: string
  chunk: Blob
  partNumber: number
  fileName?: string
  signal?: AbortSignal
  onProgress?: PartProgress
}) {
  const formData = new FormData()
  formData.append('multipartId', params.multipartId)
  formData.append('file', params.chunk, params.fileName)
  formData.append('partNumber', String(params.partNumber))

  await axios.post('/api/multipart/upload-part', formData, {
    signal: params.signal,
    timeout: 60 * 60 * 1000, // 大文件单分片也可能较久
    onUploadProgress: (e: ProgressEvent) => {
      if (e.total) params.onProgress?.(e.loaded, e.total)
    },
  })
}
ts 复制代码
// 示意:串行上传全部分片
async function uploadWithMultipart(
  file: File,
  signal?: AbortSignal,
  onPartProgress?: (partIndex: number, loaded: number, total: number) => void
) {
  const parts = buildFileParts(file, CHUNK_SIZE) // 见 §3
  const { multipartId } = await initMultipart({
    fileName: file.name,
    fileSize: file.size,
    partSize: CHUNK_SIZE,
    partNumber: parts.length,
  })

  for (const part of parts) {
    if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')

    await uploadPart({
      multipartId,
      chunk: part.chunk,
      partNumber: part.index + 1, // 服务端一般 1-based
      fileName: file.name,
      signal,
      onProgress: (loaded, total) => onPartProgress?.(part.index, loaded, total),
    })
  }

  return completeMultipart(multipartId) // → fileId
}
graph LR S{是否超过分片阈值} -->|否| Single[单文件 FormData] S -->|是| Init[Init 得到 multipartId] Init --> Parts[顺序上传分片] Parts --> Done[Complete 得到 fileId] Single --> Done

2.3 同会话断点续传

服务端提供 meta(如已传 maxPartNumber / partSize / fileStatus)时:

ts 复制代码
async function resumeMultipart(multipartId: string, file: File, transmitted: number) {
  const meta = await getMultipartMeta(multipartId)
  if (meta.fileStatus !== 'init') throw new Error('RESUME_FAILED')

  if (isAllPartsDone(meta)) {
    return completeMultipart(multipartId)
  }

  // 从 maxPartNumber 之后继续切剩余分片
  const parts = buildFileParts(file, meta.partSize, {
    from: meta.partSize * meta.maxPartNumber,
    startIndex: meta.maxPartNumber,
  })

  for (const part of parts) {
    await uploadPart({
      multipartId,
      chunk: part.chunk,
      partNumber: part.index + 1,
      fileName: file.name,
    })
  }
  return completeMultipart(multipartId)
}

暂停:取消当前 HTTP(AbortSignal / CancelToken)。 杀后台后仅靠内存不够 → 见 §5 IndexedDB。


3. 分片切法与内存:Blob.slice

ts 复制代码
interface FilePart {
  index: number
  chunk: Blob
  size: number
  name: string
}

/** 预建分片「视图」列表(不读字节进堆) */
function buildFileParts(
  file: File,
  sizePerChunk = 10 * 1024 * 1024,
  opts?: { from?: number; startIndex?: number }
): FilePart[] {
  const from = opts?.from ?? 0
  let index = opts?.startIndex ?? 0
  const parts: FilePart[] = []

  for (let cur = from; cur < file.size; cur += sizePerChunk) {
    // 与 file.slice(...) 等价;显式走原型可避免被覆盖的 slice
    const chunk: Blob = Blob.prototype.slice.call(file, cur, cur + sizePerChunk)
    parts.push({
      index: index++,
      chunk,
      size: chunk.size,
      name: file.name,
    })
  }
  return parts
}
操作 是否把文件字节读进 JS 堆
file.slice / Blob.slice (字节范围视图)
readAsArrayBuffer / arrayBuffer() ,当前这一块
Base64 / DataURL ,体积再膨胀约 33%
FormData 挂单个 chunk 上传 浏览器按需读盘流式发送

slice 本身不会因 5GB 文件撑爆堆。危险写法(勿用):

ts 复制代码
// ❌ 预物化所有分片字节 → 极易 OOM
const buffers: ArrayBuffer[] = []
for (let cur = 0; cur < file.size; cur += sizePerChunk) {
  buffers.push(await file.slice(cur, cur + sizePerChunk).arrayBuffer())
}

推荐:切视图 + 一块一块上传/哈希,当前块处理完后交给 GC。


4. 文件哈希(可选:完整性 / 秒传)

4.1 主线程增量哈希(可用,但 GB 级会卡顿)

ts 复制代码
import SparkMD5 from 'spark-md5'

async function getFileMd5MainThread(file: File, signal?: AbortSignal): Promise<string> {
  const spark = new SparkMD5.ArrayBuffer()
  const parts = buildFileParts(file)

  for (const part of parts) {
    if (signal?.aborted) break
    const buf = await part.chunk.arrayBuffer() // 峰值约一个分片
    spark.append(buf) // 同步 CPU,主线程会短卡
  }
  return spark.end()
}
环节 是否卡主线程
arrayBuffer() / FileReader 否(异步 I/O)
spark.append(~10MB) (同步 CPU)
await 下一块 会让出事件循环

4.2 改版:丢进 Web Worker(推荐)

流程图:

ts 复制代码
// md5.worker.ts
import SparkMD5 from 'spark-md5'

const CHUNK = 10 * 1024 * 1024

self.onmessage = async (e: MessageEvent<{ file: File; requestId: string }>) => {
  const { file, requestId } = e.data
  const spark = new SparkMD5.ArrayBuffer()

  for (let offset = 0; offset < file.size; offset += CHUNK) {
    const blob = file.slice(offset, offset + CHUNK)
    const buf = await blob.arrayBuffer()
    spark.append(buf)
    self.postMessage({
      type: 'progress',
      requestId,
      transmitted: Math.min(offset + CHUNK, file.size),
      total: file.size,
    })
  }

  self.postMessage({ type: 'done', requestId, md5: spark.end() })
}
ts 复制代码
// 主线程:只调度
export function getFileMd5(
  file: File,
  signal?: AbortSignal,
  onProgress?: (transmitted: number, total: number) => void
): Promise<string> {
  return new Promise((resolve, reject) => {
    const worker = new Worker(new URL('./md5.worker.ts', import.meta.url), { type: 'module' })
    const requestId = crypto.randomUUID()

    const onAbort = () => {
      worker.terminate()
      reject(new DOMException('Aborted', 'AbortError'))
    }
    signal?.addEventListener('abort', onAbort, { once: true })

    worker.onmessage = (e: MessageEvent) => {
      if (e.data.requestId !== requestId) return
      if (e.data.type === 'progress') {
        onProgress?.(e.data.transmitted, e.data.total)
        return
      }
      if (e.data.type === 'done') {
        signal?.removeEventListener('abort', onAbort)
        worker.terminate()
        resolve(e.data.md5)
      }
    }
    worker.onerror = (err) => {
      worker.terminate()
      reject(err)
    }

    worker.postMessage({ file, requestId })
  })
}

Worker 不会明显缩短墙钟 ,但 UI 可保持可交互。备选:主线程读块,postMessage(buf, [buf]) 只把 append 放 Worker。

4.3 抽样指纹(快速 L1)

ts 复制代码
async function sampleFingerprint(file: File): Promise<string> {
  const head = 2 * 1024 * 1024
  const tail = 2 * 1024 * 1024
  const midSize = 128 * 1024
  const mids = 4
  const spark = new SparkMD5.ArrayBuffer()

  const slices: Blob[] = [file.slice(0, Math.min(head, file.size))]
  for (let i = 1; i <= mids; i++) {
    const start = Math.floor((file.size * i) / (mids + 1))
    slices.push(file.slice(start, start + midSize))
  }
  if (file.size > head) {
    slices.push(file.slice(Math.max(0, file.size - tail)))
  }

  // 必须纳入 size,降低冲突面
  const sizeBuf = new TextEncoder().encode(String(file.size))
  spark.append(sizeBuf.buffer)

  for (const s of slices) {
    spark.append(await s.arrayBuffer())
  }
  return spark.end()
}
用途 是否合适
秒传 / 去重L1 候选 ✅;命中后再全量 hash / 服务端确认(L2)
替代全量内容校验

5. 状态持久化(杀后台)· IndexedDB

text 复制代码
杀后台 → 堆清空 → 只能重新选文件、可能整文件重传
(服务端或仍留有未完成 multipart,但浏览器对不上)

5.1 原则

只持久化续传票据存整文件:

  • 存:multipartId、文件身份、transmitted、phase
  • 不存:File / Blob(杀进程后句柄通常也没了)

5.2 草稿模型 + Store 完整示意

ts 复制代码
interface UploadTaskDraft {
  draftId: string
  multipartId?: string
  fileId?: string
  phase: 'uploading' | 'done' | 'failed'
  fileName: string
  fileSize: number
  lastModified: number
  sampleFingerprint?: string
  transmitted: number
  updatedAt: number
}
ts 复制代码
// UploadDraftStore.ts(示意,可用 localforage)
import localForage from 'localforage'

const DRAFT_TTL_MS = 7 * 24 * 60 * 60 * 1000

export class UploadDraftStore {
  #store: LocalForage

  constructor(userKey: string) {
    this.#store = localForage.createInstance({
      driver: [localForage.INDEXEDDB, localForage.WEBSQL, localForage.LOCALSTORAGE],
      name: 'uploadDraftDB',
      storeName: `upload_draft-${userKey}`,
      version: 1.0,
    })
  }

  async save(draft: UploadTaskDraft) {
    await this.#store.setItem(draft.draftId, { ...draft, updatedAt: Date.now() })
  }

  async patch(draftId: string, partial: Partial<UploadTaskDraft>) {
    const prev = await this.#store.getItem<UploadTaskDraft>(draftId)
    if (!prev) return undefined
    const next = { ...prev, ...partial, draftId: prev.draftId, updatedAt: Date.now() }
    await this.#store.setItem(draftId, next)
    return next
  }

  async get(draftId: string) {
    return (await this.#store.getItem<UploadTaskDraft>(draftId)) ?? undefined
  }

  async listIncomplete() {
    const now = Date.now()
    const out: UploadTaskDraft[] = []
    for (const key of await this.#store.keys()) {
      const d = await this.#store.getItem<UploadTaskDraft>(key)
      if (!d) continue
      if (now - d.updatedAt > DRAFT_TTL_MS || d.phase === 'done' || d.phase === 'failed') {
        await this.#store.removeItem(key)
        continue
      }
      out.push(d)
    }
    return out.sort((a, b) => b.updatedAt - a.updatedAt)
  }

  async remove(draftId: string) {
    await this.#store.removeItem(draftId)
  }

  async flush(draft: UploadTaskDraft) {
    try {
      await this.save(draft)
    } catch (e) {
      console.warn('[UploadDraftStore] flush failed', e)
    }
  }
}

5.3 写入时机(调用示例)

时机 调用
任务创建 save({ ...identity, phase: 'uploading' })
multipart Init 成功 patch({ multipartId })最关键
分片进度 patch({ transmitted })节流
Complete remove 或标 done
失败 / 取消 remove / failed
pagehide / visibilitychange(hidden) flush
ts 复制代码
const store = new UploadDraftStore(userId)
const draftId = crypto.randomUUID()

// 1) 建任务
await store.save({
  draftId,
  phase: 'uploading',
  fileName: file.name,
  fileSize: file.size,
  lastModified: file.lastModified,
  transmitted: 0,
  updatedAt: Date.now(),
})

// 2) Init 成功后立刻落盘
const { multipartId } = await initMultipart(...)
await store.patch(draftId, { multipartId })

// 3) 进度节流(例:每 ≥1MB 或 ≥2s)
let lastFlush = 0
async function onPartProgress(loaded: number) {
  const now = Date.now()
  if (loaded - lastWritten < 1024 * 1024 && now - lastFlush < 2000) return
  lastWritten = loaded
  lastFlush = now
  await store.patch(draftId, { transmitted: loaded })
}

// 4) 完成 / 取消
await store.remove(draftId)

// 5) 进后台 flush
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    void store.flush({ /* 当前草稿快照 */ })
  }
})
window.addEventListener('pagehide', () => {
  void store.flush({ /* 当前草稿快照 */ })
})

5.4 恢复流程 + 代码

graph TD Open[页面启动] --> Load[listIncomplete] Load --> List[展示待继续列表] List --> Bind{用户重选同一文件} Bind -->|是| Match[校验 size lastModified 抽样指纹] Match --> Resume[resumeMultipart] Bind -->|否| Keep[草稿保留并提示需选文件]
ts 复制代码
function assertSameFile(draft: UploadTaskDraft, file: File) {
  if (file.name !== draft.fileName) throw new Error('file name mismatch')
  if (file.size !== draft.fileSize) throw new Error('file size mismatch')
  if (file.lastModified !== draft.lastModified) throw new Error('lastModified mismatch')
  // 可选:再比 sampleFingerprint(file) === draft.sampleFingerprint
}

async function continueUpload(draftId: string, file: File, store: UploadDraftStore) {
  const draft = await store.get(draftId)
  if (!draft?.multipartId) throw new Error('draft not resumable')

  assertSameFile(draft, file)
  const fileId = await resumeMultipart(draft.multipartId, file, draft.transmitted)
  await store.remove(draftId)
  return fileId
}

// 启动时
const drafts = await store.listIncomplete()
// UI 展示 drafts;用户点「继续」并选文件后调用 continueUpload

流程图:

无草稿 IndexedDB 草稿
杀后台后再进 进度清零,易整文件重来 列表可续;绑同一文件后从已传分片继续
File 随页死亡 仍需重选 + identity 校验
风险 简单 TTL、多标签写冲突、隐私清理

一句话 :落盘的是 multipartId 等票据,不是整文件;回来重绑 File 后调服务端续传。


6. 多文件并发

ts 复制代码
const MAX_PARALLEL = 6 // 最多同时上传 6 个文件

class UploadQueue {
  #running = 0
  #waiting: Array<() => Promise<void>> = []

  enqueue(task: () => Promise<void>) {
    this.#waiting.push(task)
    this.#pump()
  }

  async #pump() {
    while (this.#running < MAX_PARALLEL && this.#waiting.length > 0) {
      const task = this.#waiting.shift()!
      this.#running++
      task()
        .catch(console.error)
        .finally(() => {
          this.#running--
          this.#pump()
        })
    }
  }
}

// queue 就是 UploadQueue 的实例
const queue = new UploadQueue()

// 使用:每个 File 一个任务
for (const file of files) {
  queue.enqueue(() => uploadFileToServer(file))
}
  • 一个文件 = 一个上传任务
  • 最多同时上传 6 个,其余进入排队
  • 单文件内若分片串行,则不会「一个文件同时传多个分片」;多文件时仍最多 6 路在途
  • 可对启动做轻微错峰,避免瞬时打满连接

7. 小结

  1. 浏览器 → 服务端:小文件单传,大文件 multipart(串行分片即可)。
  2. Blob.slice 是视图,不会单独撑爆堆;勿预物化全部分片字节。
  3. 全量哈希宜放 Worker;抽样指纹只适合秒传 L1。
  4. 杀后台要靠 IndexedDB 存 multipartId + 文件身份,重选文件后续传。
相关推荐
不好听6131 小时前
前端路由完全指南(中篇):React Router Hooks 完全指南
前端·react.js
hunterandroid1 小时前
[鸿蒙从零到一] ArkUI 声明式 UI 基础实战
前端
To_OC1 小时前
LC 39 组合总和:回溯入门必刷题,我踩过的两个坑都在这了
javascript·算法·leetcode
七牛开发者1 小时前
RAG 工程里的上下文剪枝:如何减少 68% 的上下文
前端·css·github
CaffeinePro1 小时前
四⼤极简架构原则KISS/DRY/YAGNI/LOD
设计模式·架构
Dreamland工坊1 小时前
从AI 视频到可用资产:聊浏览器端图像编辑器的架构设计
架构
橘子星2 小时前
从零理解流式输出 —— 一个 Vue + DeepSeek 的前端实战
前端·javascript·人工智能
智驭未来掌门人2 小时前
如何让你的前端服务在服务器上nohub运行
前端
颜进强2 小时前
Claude Code - 18 从 Figma 到 React:一套让 AI 生成代码"不瞎编"的实战工作流
前端·后端·ai编程