多文件上传队列、拖拽上传与 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 压缩的基本思路
可以理解成两层:
- 主线程负责收文件、管理队列、更新状态
- 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 怎么配
更常见的结构不是“所有文件先一起压缩完再上传”,而是:
- 文件入队
- 队列 worker 先压缩当前文件
- 压缩完成后继续上传
- 上传成功后处理下一个
这样有几个好处:
- 内存更稳
- 失败更容易定位
- 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 组件:拖拽、列表、进度、错误态
更适合先补哪几层
多文件上传这类功能,比较稳的推进顺序通常是:
- 单文件上传
- 多文件列表
- 并发队列
- 拖拽上传
- 单文件重试 / 取消
- worker 压缩
- 分片续传
这样不容易 一开始就把整条链路拧得太重。