跳到主要内容

多文件上传队列、拖拽上传与 Worker 压缩

文件上传一旦从“单文件按钮”走到真实业务,通常很快会碰到这几层:

  • 一次选多个文件
  • 上传中需要排队
  • 支持拖拽进入
  • 图片压缩不能卡主线程
  • 某个文件失败后要单独重试

这篇把这条链路继续往前补。

多文件上传为什么要做队列

多文件场景如果直接一股脑全发出去,常见问题会很快出现:

  • 并发太高
  • 浏览器内存占用上来
  • 后端瞬时压力过大
  • 某个文件失败后很难单独恢复

所以项目里更稳的做法通常是上传队列。

一个常见的上传项结构

type UploadStatus =
| 'idle'
| 'waiting'
| 'uploading'
| 'success'
| 'error'
| 'canceled'

interface UploadItem {
id: string
file: File
progress: number
status: UploadStatus
errorMessage?: string
previewUrl?: string
}

把文件抽成 UploadItem 之后,很多事情就顺了:

  • 列表渲染
  • 状态管理
  • 单文件重试
  • 单文件取消

一个简单的并发队列思路

async function runUploadQueue(
items: UploadItem[],
concurrency: number,
worker: (item: UploadItem) => Promise<void>
) {
const queue = [...items]
const running: Promise<void>[] = []

async function next() {
const item = queue.shift()
if (!item) return
await worker(item)
await next()
}

for (let i = 0; i < concurrency; i += 1) {
running.push(next())
}

await Promise.all(running)
}

这类写法够轻,也比较适合先把项目跑起来。后面如果要接暂停、恢复、优先级,再继续拆也不晚。

React 里一个常见的 hook 结构

function useUploadQueue() {
const [items, setItems] = useState<UploadItem[]>([])

function appendFiles(files: File[]) {
const nextItems = files.map((file) => ({
id: crypto.randomUUID(),
file,
progress: 0,
status: 'waiting' as const,
}))

setItems((prev) => [...prev, ...nextItems])
}

return {
items,
appendFiles,
}
}

真实项目里,这一层还会继续补:

  • 移除文件
  • 重试文件
  • 取消文件
  • 开始队列
  • 暂停队列

拖拽上传怎么接

拖拽上传的核心并不复杂,主要是浏览器事件要收住。

function UploadDropzone() {
const [dragging, setDragging] = useState(false)

function onDragOver(event: React.DragEvent<HTMLDivElement>) {
event.preventDefault()
setDragging(true)
}

function onDragLeave(event: React.DragEvent<HTMLDivElement>) {
event.preventDefault()
setDragging(false)
}

function onDrop(event: React.DragEvent<HTMLDivElement>) {
event.preventDefault()
setDragging(false)

const files = Array.from(event.dataTransfer.files)
console.log(files)
}

return (
<div onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}>
{dragging ? 'Release to upload' : 'Drag files here'}
</div>
)
}

这类交互最容易忘的是 event.preventDefault()。不拦住的话,浏览器可能会直接把文件打开。

拖拽上传常见还要补什么

  • 高亮拖拽态
  • 只允许某些类型
  • 文件数量限制
  • 文件夹拖拽支持
  • 和点击选择文件共用一套校验逻辑

比较稳的方式是:

  • 拖拽入口只负责拿文件
  • 校验和入队继续复用既有上传逻辑

多文件进度怎么展示

多文件场景一般会同时有两层进度:

  • 单文件进度
  • 总体进度

一个简单的总体进度计算:

function getTotalProgress(items: UploadItem[]) {
if (items.length === 0) return 0

const total = items.reduce((sum, item) => sum + item.progress, 0)
return Math.round(total / items.length)
}

如果文件体积差异很大,也可以改成按字节加权。

单文件失败,为什么不要整队回滚

多文件上传里,比较稳的体验通常是:

  • 成功的文件保持成功
  • 失败的文件单独标错
  • 允许只重试失败项

这样用户不需要因为一张图失败,就把十几张图全部重来。

Worker 压缩为什么值得单独做

图片压缩如果全放主线程,大图和多图场景里会明显卡 UI。

尤其是这些页面:

  • 批量上传商品图
  • 设计稿管理
  • 多图表单
  • 移动端相册选择

这时把压缩挪到 Web Worker,体感会好很多。

Worker 压缩的基本思路

可以理解成两层:

  1. 主线程负责收文件、管理队列、更新状态
  2. Worker 负责压缩,再把结果回传

主线程:

const worker = new Worker(new URL('./image-worker.ts', import.meta.url), {
type: 'module',
})

worker.postMessage({
file,
quality: 0.8,
})

worker.onmessage = (event) => {
const compressedFile = event.data.file as File
console.log(compressedFile)
}

Worker:

self.onmessage = async (event) => {
const { file, quality } = event.data
const bitmap = await createImageBitmap(file)
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height)
const ctx = canvas.getContext('2d')

ctx?.drawImage(bitmap, 0, 0)

const blob = await canvas.convertToBlob({
type: 'image/jpeg',
quality,
})

const nextFile = new File([blob], file.name.replace(/\.\w+$/, '.jpg'), {
type: 'image/jpeg',
})

self.postMessage({ file: nextFile })
}

这类结构比较适合:

  • Vite
  • Webpack 5
  • 支持 module worker 的现代项目

Worker 压缩时更该注意什么

  • 不是所有环境都支持同一套 API
  • OffscreenCanvas 兼容性要单独看
  • 图片太大时,worker 里也可能占不少内存
  • 文件传进传出 worker 本身也有成本

所以 worker 压缩很适合“确实会卡”的场景,不必所有上传都默认上。

队列和 worker 怎么配

更常见的结构不是“所有文件先一起压缩完再上传”,而是:

  1. 文件入队
  2. 队列 worker 先压缩当前文件
  3. 压缩完成后继续上传
  4. 上传成功后处理下一个

这样有几个好处:

  • 内存更稳
  • 失败更容易定位
  • UI 进度更清楚

一个更接近真实项目的分层

src/
api/
file.ts
workers/
image-worker.ts
hooks/ 或 composables/
use-upload-queue.ts
components/
upload-dropzone.tsx
upload-list.tsx

这时每一层职责会更清楚:

  • api/file.ts:上传、合并、状态查询
  • image-worker.ts:图片压缩
  • use-upload-queue.ts:排队、取消、重试
  • UI 组件:拖拽、列表、进度、错误态

更适合先补哪几层

多文件上传这类功能,比较稳的推进顺序通常是:

  1. 单文件上传
  2. 多文件列表
  3. 并发队列
  4. 拖拽上传
  5. 单文件重试 / 取消
  6. worker 压缩
  7. 分片续传

这样不容易一开始就把整条链路拧得太重。

相关文章