多文件上传是前端面试必考题,也是实际项目中最容易写成"boolean 意面"的模块。本文拆解一个多文件上传组件,看它如何用一个 Map 和三个状态,消除了 5 个本应存在的 boolean flag。
一、多文件上传的复杂度在哪
单文件上传很简单:一个文件,一个进度条,成功或失败。三个状态,一把梭。
多文件上传的麻烦在于:每个文件有独立的上传生命周期,但它们又共同决定了组件层面的状态。
想象这个场景:用户拖拽 5 个文件进来,其中 2 个正在上传,1 个已完成,1 个失败了,还有 1 个在排队。此时页面上需要展示:
- 每个文件独立的进度条和状态
- 组件整体的状态标识(是否可以提交表单?是否还有上传进行中?)
- 删除按钮的行为(删除已完成文件 vs 取消正在上传的文件,逻辑完全不同)
如果用 boolean flag 来写,你至少需要:
arduino
isUploading // 有文件在上传吗
isAllSuccess // 所有文件都成功了吗
hasError // 有文件失败了吗
isWaiting // 有文件在排队吗
hasFiles // 有文件吗
然后这些 boolean 之间存在复杂的约束关系------isUploading 和 isAllSuccess 不能同时为 true,hasError 和 isAllSuccess 互斥,hasFiles 和 isWaiting 的边界条件模糊。当第 6 个 boolean 出现时,你就知道这条路走错了。
二、方案:每个文件一个状态,全局状态从 Map 派生
FileUploadMul 的核心数据结构就两个:
typescript
// 1. 每个文件的状态枚举------只有三种
export enum FileStatus {
UPLOADING = 'uploading',
SUCCESS = 'success',
ERROR = 'error'
}
// 2. 上传任务管理------一个 Map 管所有
const uploadTasks = new Map<string, {
controller: AbortController
raw: UploadRawFile
}>()
单个文件的状态模型是一个简单三态机:
vbnet
┌──────────┐
│ UPLOADING │
└─────┬─────┘
success / \ error
/ \
┌─────┴─┐ ┌┴─────┐
│SUCCESS│ │ERROR │
└───────┘ └──────┘
Map<uid, { controller, raw }> 同时承担了两个职责:
- 跟踪上传中的任务 :
uploadTasks.size就是"有多少文件正在上传" - 支持取消操作 :
controller.abort()直接终止网络请求
一个 Map 替代了 isUploading + uploadingCount + cancelTokenMap 三个数据结构。
三、组件级状态:从 Map 派生的 computed
组件级别的 isUploading 不需要手动维护,直接从 Map 派生:
typescript
const isUploading = computed(() =>
fileList.value.some((item) => {
if (item.status === FileStatus.UPLOADING) return true
const p = item.progress ?? 0
return p > 0 && p < 100
})
)
这里注意到一个细节:不仅判了 status === UPLOADING,还判了 progress 在 0~100 之间。这是因为:
status是组件内部设置的,和实际网络请求状态可能有时序偏差progress是上传回调触发的,更接近真实的网络状态- 两个条件取 OR,构成一道双重保险
这是一个很务实的兜底设计------不是两种冗余判断,而是两个各自覆盖不同边界情况的互补条件。
四、阶段流转:watcher 里的状态机
比 isUploading 更精妙的是阶段检测 。组件在 waiting / uploading / finished 三个宏观阶段之间流转:
typescript
type UploadPhase = 'waiting' | 'uploading' | 'finished'
let previousStatus: UploadPhase = 'waiting'
watch(
fileList,
() => {
const uploading = isUploading.value
const currentStatus: UploadPhase = uploading
? 'uploading'
: previousStatus === 'uploading'
? 'finished'
: 'waiting'
if (currentStatus !== previousStatus) {
emit('status-change', currentStatus)
previousStatus = currentStatus
}
},
{ deep: true, immediate: true }
)
核心逻辑在第三行 :previousStatus === 'uploading' ? 'finished' : 'waiting'
arduino
当前 isUploading = false 时:
→ 上一次是 uploading → 说明"刚刚传完",进入 finished
→ 上一次不是 uploading → 说明"从未开始上传",保持 waiting
这个微量逻辑解决了一个实际问题:区分"还没开始传"和"已经传完了" 。如果不做这个区分,isUploading = false 可能意味着两种完全不同的状态,父组件无法据此决定下一步行为(例如:表单是否可以提交)。
阶段流转图:
markdown
┌──────────┐
│ waiting │
└────┬─────┘
开始上传 │
┌───┴────────┐
│ uploading │──── 又有新文件开始 ──→ uploading(不变)
└──┬─────┬───┘
全部完成 │ │ 最后一个文件完成
│ │
┌──┴─────┴──┐
│ finished │
└────────────┘
注意:
finished之后如果用户继续添加文件,状态会重新进入uploading。
五、进度条上限 99%:一个容易被忽略的体验细节
typescript
onUploadProgress: (event: ProgressEvent) => {
const total = event.total
if (!total) return
const row = fileList.value.find((i) => i.uid === uid)
if (row) {
// 最多 99%,避免字节传输完成但服务端未响应时进度条误显示完成
row.progress = Math.min(Math.round((event.loaded / total) * 100), 99)
}
}
这里有一个非常细微但精妙的设计:进度条上限卡在 99%,而不是 100%。
原因在于:onUploadProgress 触发在 TCP 层面字节传输完成时,但此时服务端可能还在:
- 将文件写入磁盘
- 做病毒扫描
- 做文件格式校验
- 生成缩略图
这中间可能有几百毫秒到几秒的窗口期------如果进度条已经到 100%,用户会困惑"为什么 100% 了还没完成?"。卡在 99% 给后端处理留了一个视觉缓冲。
100% 只在 status 真正变为 SUCCESS 之后才展示------这是"状态驱动 UI"的又一个实例。
六、删除操作的双重人格
同一个删除按钮,对不同状态的文件执行完全不同的操作:
typescript
function handleDelete(item: IFileItem) {
const uid = item.uid
// 情况 1:文件正在上传 → 取消上传
if (item.status === FileStatus.UPLOADING) {
const task = uploadTasks.get(uid)
if (task) {
task.controller.abort('已取消上传') // 终止 HTTP 请求
fileUploadRef.value?.handleRemove?.(task.raw) // 清理 el-upload 内部状态
uploadTasks.delete(uid) // 从任务 Map 中移除
}
}
// 情况 2:清理 blob 预览 URL
if (item.url?.startsWith('blob:')) {
URL.revokeObjectURL(item.url)
}
// 情况 3:从列表中移除
fileList.value = fileList.value.filter((f) => f.uid !== uid)
handleUploadSuccess()
}
每种状态的删除逻辑完全不同:
| 文件状态 | 删除行为 |
|---|---|
UPLOADING |
AbortController 取消请求 + 清理 el-upload 内部追踪 + 从 Map 移除 |
SUCCESS |
移除列表项(服务端文件由后端负责清理) |
ERROR |
移除列表项,清除 blob URL |
如果不按状态分流,这段代码会变成一个巨大的 if-else 嵌套。 状态枚举让每个分支的意图一目了然。
七、Watch modelValue 的防冲突保护
typescript
watch(
() => props.modelValue,
(value) => {
// 有上传任务进行中时跳过,避免覆盖正在上传的文件状态
if (uploadTasks.size > 0) return
fileList.value = (value || []).map((item) => ({
...item,
status: FileStatus.SUCCESS,
progress: 100
}))
},
{ immediate: true }
)
这个 watch 的职责是:当父组件传入新的 modelValue(例如编辑回显),组件需要同步展示已有文件。
但注释里的 guard 很关键:if (uploadTasks.size > 0) return。如果没有这一行,可能发生:
- 文件正在上传中
- 父组件的某个响应式更新触发了
modelValue变化 - watch 执行,覆盖了
fileList,正在上传的文件状态丢失 uploadTasksMap 里还有任务记录,但fileList里已经找不到对应项了- 状态不一致,UI 异常
这个 guard 的语义是:新数据同步的优先级低于正在进行的用户操作。这是一个重要的优先级排序------用户当前正在执行的上传动作,比外部数据同步更重要。
八、所有任务完成后的统一回调
typescript
finally {
uploadTasks.delete(uid)
// 所有任务结束后才触发回调
if (uploadTasks.size === 0) {
handleUploadSuccess()
}
}
handleUploadSuccess 不是每个文件成功后都调用,而是等到 uploadTasks Map 完全为空才触发。这意味着:
- 批量上传 5 个文件 → 只在第 5 个完成时触发一次回调
- 上传 1 个文件 → 该文件完成时触发
- 上传期间删除某文件 → 不影响其他文件的回调时机
用 uploadTasks.size === 0 而非计数,自动处理了中途删除导致计数偏移的边缘情况。
九、设计模式总结:状态机替代 boolean 的通用范式
把这个组件的设计提取成一个可迁移的范式:
ini
┌─────────────────────────────────────────────┐
│ │
│ 1. 为每个实体定义状态枚举,而非 boolean 集合 │
│ FileStatus = UPLOADING | SUCCESS | ERROR│
│ │
│ 2. 用 Map<id, entity> 管理多实体的并发状态 │
│ uploadTasks = Map<uid, task> │
│ │
│ 3. 全局状态从实体状态 computed 派生 │
│ isUploading = some(status === UPLOADING)│
│ │
│ 4. 阶段流转由 watcher 状态机驱动 │
│ waiting → uploading → finished │
│ │
│ 5. 所有操作按当前状态分流 │
│ delete → switch(status) │
│ │
└─────────────────────────────────────────────┘
适用场景:
| 场景 | 实体 | 状态枚举 |
|---|---|---|
| 批量导入 | 每行数据 | pending → validating → importing → done → error |
| 消息发送 | 每条消息 | composing → sending → sent → failed |
| 审批流程 | 每个节点 | pending → processing → approved → rejected |
| 任务队列 | 每个任务 | queued → running → success → failed → canceled |
| 支付流程 | 每笔订单 | created → paying → paid → refunding → refunded |
所有这些场景都共享同一个根本问题:多个独立实体各自经历生命周期,但需要聚合出一个全局视角。 boolean 做不到这一点------它们天然是"全局的",无法归属到具体实体。状态枚举 + Map 恰好两者兼顾。
十、这个组件也不完美
同样实事求是:
getCurrentInstance() as any:直接取了proxy来做$modal.msgError和$filePreview.open,耦合了全局注入的方法。更干净的做法是通过 props 回调或 provide/inject 解耦handleChange的类型标注 :参数data没有类型,是anyIFileType的类型定义:文件类型枚举里有很多重复值(m4a/m4v 等出现了多次),应该是复制粘贴的遗留问题- blob URL 管理可以更系统 :目前是散落在各处的
createObjectURL/revokeObjectURL调用,如果抽取成一个useBlobUrlhook 会更内聚
十一、总结
FileUploadMul 的多文件上传管理,核心是三个决策:
- 不给组件加 boolean,给文件加 status------状态的归属决定了代码的可扩展性
- 用 Map 而非计数器------Map 天然处理了增删改查,计数器的边界条件需要手动维护
- 用 watcher 状态机区分"没开始"和"已完成" ------两个都对应
isUploading = false,但语义完全不同
从 5 个 boolean flag 到一个 3 状态的枚举,这不是代码量的减少,而是认知负荷的降低。boolean 要求你记住它们的组合语义("a=true 且 b=false 且 c=true 是什么意思来着?"),状态枚举让语义自解释。
好的状态设计,让代码读起来像文档。