告别 boolean 地狱:一个文件上传组件的状态机实践

多文件上传是前端面试必考题,也是实际项目中最容易写成"boolean 意面"的模块。本文拆解一个多文件上传组件,看它如何用一个 Map 和三个状态,消除了 5 个本应存在的 boolean flag。


一、多文件上传的复杂度在哪

单文件上传很简单:一个文件,一个进度条,成功或失败。三个状态,一把梭。

多文件上传的麻烦在于:每个文件有独立的上传生命周期,但它们又共同决定了组件层面的状态

想象这个场景:用户拖拽 5 个文件进来,其中 2 个正在上传,1 个已完成,1 个失败了,还有 1 个在排队。此时页面上需要展示:

  • 每个文件独立的进度条和状态
  • 组件整体的状态标识(是否可以提交表单?是否还有上传进行中?)
  • 删除按钮的行为(删除已完成文件 vs 取消正在上传的文件,逻辑完全不同)

如果用 boolean flag 来写,你至少需要:

arduino 复制代码
isUploading        // 有文件在上传吗
isAllSuccess       // 所有文件都成功了吗
hasError           // 有文件失败了吗
isWaiting          // 有文件在排队吗
hasFiles           // 有文件吗

然后这些 boolean 之间存在复杂的约束关系------isUploadingisAllSuccess 不能同时为 true,hasErrorisAllSuccess 互斥,hasFilesisWaiting 的边界条件模糊。当第 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 }> 同时承担了两个职责:

  1. 跟踪上传中的任务uploadTasks.size 就是"有多少文件正在上传"
  2. 支持取消操作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。如果没有这一行,可能发生:

  1. 文件正在上传中
  2. 父组件的某个响应式更新触发了 modelValue 变化
  3. watch 执行,覆盖了 fileList,正在上传的文件状态丢失
  4. uploadTasks Map 里还有任务记录,但 fileList 里已经找不到对应项了
  5. 状态不一致,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 恰好两者兼顾。


十、这个组件也不完美

同样实事求是:

  1. getCurrentInstance() as any :直接取了 proxy 来做 $modal.msgError$filePreview.open,耦合了全局注入的方法。更干净的做法是通过 props 回调或 provide/inject 解耦
  2. handleChange 的类型标注 :参数 data 没有类型,是 any
  3. IFileType 的类型定义:文件类型枚举里有很多重复值(m4a/m4v 等出现了多次),应该是复制粘贴的遗留问题
  4. blob URL 管理可以更系统 :目前是散落在各处的 createObjectURL / revokeObjectURL 调用,如果抽取成一个 useBlobUrl hook 会更内聚

十一、总结

FileUploadMul 的多文件上传管理,核心是三个决策:

  1. 不给组件加 boolean,给文件加 status------状态的归属决定了代码的可扩展性
  2. 用 Map 而非计数器------Map 天然处理了增删改查,计数器的边界条件需要手动维护
  3. 用 watcher 状态机区分"没开始"和"已完成" ------两个都对应 isUploading = false,但语义完全不同

从 5 个 boolean flag 到一个 3 状态的枚举,这不是代码量的减少,而是认知负荷的降低。boolean 要求你记住它们的组合语义("a=true 且 b=false 且 c=true 是什么意思来着?"),状态枚举让语义自解释。

好的状态设计,让代码读起来像文档。


相关推荐
摸着石头过河的石头1 小时前
从 Webpack 到 RSBuild:前端构建工具的进化之路
前端
竹林8181 小时前
Solana DApp 开发踩坑实录:从零用 @solana/web3.js 实现链上数据查询与交易签名
前端·javascript
狂师1 小时前
测试工程师的AI 技能库:推荐5个让你效率翻倍的Skills
前端·后端·测试
李明卫杭州1 小时前
Vue3 watch 与 watchEffect 深度解析
前端
CodeSheep1 小时前
DeepSeek正式官宣摇人,夯!
前端·后端·程序员
用户059540174461 小时前
Redis持久化踩坑实录:这个数据丢失Bug让我排查了6小时
前端·css
用户2136610035721 小时前
VueRouter进阶-动态路由与嵌套路由
前端·vue.js
梯度不陡1 小时前
Signal #17:Agent 开始进入组织系统
前端·javascript
何智超1 小时前
AI 微前端性能优化之旅(上):复盘
前端·vibecoding