浏览器 → 上传服务端(单文件 / 分片 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. 小结
- 浏览器 → 服务端:小文件单传,大文件 multipart(串行分片即可)。
Blob.slice是视图,不会单独撑爆堆;勿预物化全部分片字节。- 全量哈希宜放 Worker;抽样指纹只适合秒传 L1。
- 杀后台要靠 IndexedDB 存
multipartId+ 文件身份,重选文件后续传。