第28篇|图片上传卡住:用任务状态机和可取消重试链路处理
摘要:图片上传卡住时,继续给按钮加 loading 通常解决不了。上传有文件校验、网络状态、进度回调、取消、重试、成功写入业务记录等多个阶段。更稳的做法是把上传建成任务状态机,页面只展示任务状态,Service 负责启动、取消和重试,长时间无进度时进入可恢复状态,而不是让用户卡在一个转圈里。
我处理过一个头像上传问题:用户选图后按钮显示“上传中”,进度一直停在 0%,返回再进仍然不能重新选图。后来发现页面只保存了一个uploading=true,没有记录任务 id、文件路径、进度更新时间和失败原因。网络断开或系统代理上传没有回调时,页面就不知道该取消还是重试。
这篇文章解决四个实际问题:
- 如何把上传拆成 pending、uploading、failed、done 等明确状态。
- 如何处理长时间没有进度的卡住情况。
- 如何让取消和重试都回到同一个任务模型。
- 如何验证断网、慢网、大图和页面返回路径。
上传卡住先看状态是否可解释
一个uploading布尔值无法解释上传卡住。
| 状态 | 用户应该看到 | 处理方式 |
|---|---|---|
pending | 等待上传 | 可以取消或开始 |
uploading | 进度和取消入口 | 监听进度更新时间 |
stalled | 长时间无进度 | 提供重试和取消 |
failed | 明确失败原因 | 允许重试 |
done | 上传完成 | 写入业务记录 |
状态能解释,页面才知道按钮该不该禁用、用户能不能返回、失败后是否可以重试。
定义上传任务模型
上传任务要带任务 id、文件信息、进度和最后一次进度时间。
exporttypeUploadStatus='pending'|'uploading'|'stalled'|'failed'|'done'|'canceled'exportinterfaceImageUploadTask{taskId:stringlocalUri:stringfileName:stringsizeBytes:numberstatus:UploadStatus progress:numberlastProgressAt:numbererrorText:stringremoteUrl:string}exportfunctioncreateImageUploadTask(localUri:string,fileName:string,sizeBytes:number):ImageUploadTask{constnow=Date.now()return{taskId:`${now}_${fileName}`,localUri,fileName,sizeBytes,status:'pending',progress:0,lastProgressAt:now,errorText:'',remoteUrl:''}}lastProgressAt是处理卡住的关键。没有它,页面只能看到“还在上传”,无法判断是否已经长时间无进度。
上传前先做本地校验
大图、空路径、类型不支持,这些问题不要等上传接口返回才处理。
exportclassImageUploadValidator{validate(task:ImageUploadTask):string{if(task.localUri.length===0){return'图片路径无效,请重新选择'}if(!task.fileName.endsWith('.jpg')&&!task.fileName.endsWith('.png')){return'仅支持 JPG 或 PNG 图片'}if(task.sizeBytes>8*1024*1024){return'图片超过 8MB,请压缩后再上传'}return''}}校验失败时任务可以停在failed,并给出明确原因。不要让用户看到一个永远不会成功的上传进度。
Service 负责启动、进度和取消
页面不直接调用上传 API。Service 把底层上传能力包装成任务状态更新。
exportinterfaceUploadProgress{sentBytes:numbertotalBytes:number}exportinterfaceUploadTransport{start(task:ImageUploadTask,onProgress:(progress:UploadProgress)=>void):Promise<string>cancel(taskId:string):Promise<void>}exportclassImageUploadService{constructor(privatereadonlytransport:UploadTransport,privatereadonlyvalidator:ImageUploadValidator){}asyncstart(task:ImageUploadTask,update:(task:ImageUploadTask)=>void):Promise<void>{consterror=this.validator.validate(task)if(error.length>0){update({...task,status:'failed',errorText:error})return}update({...task,status:'uploading',errorText:''})constremoteUrl=awaitthis.transport.start(task,(progress:UploadProgress)=>{constpercent=Math.floor(progress.sentBytes*100/progress.totalBytes)update({...task,status:'uploading',progress:percent,lastProgressAt:Date.now()})})update({...task,status:'done',progress:100,remoteUrl})}asynccancel(task:ImageUploadTask):Promise<void>{awaitthis.transport.cancel(task.taskId)}}这里的UploadTransport可以接系统上传下载能力,也可以接项目已有网络层。文章重点是状态边界,而不是把底层 API 写死在页面里。
长时间无进度要变成可恢复状态
卡住不是失败,但必须可见、可取消、可重试。
exportclassUploadWatchdog{constructor(privatereadonlystallMs:number=15000){}markIfStalled(task:ImageUploadTask):ImageUploadTask{if(task.status!=='uploading'){returntask}if(Date.now()-task.lastProgressAt<this.stallMs){returntask}return{...task,status:'stalled',errorText:'上传长时间没有进度,请检查网络后重试'}}}页面可以定时调用这段逻辑,也可以在进度回调缺失时由 Service 触发。关键是不要让用户只看到一个无限转圈。
页面按钮跟状态绑定
上传页只展示任务状态和触发动作,不拼接底层上传细节。
@Componentstruct ImageUploadPanel{@Statetask:ImageUploadTask|null=nullprivateservice:ImageUploadService=createImageUploadService()privatecanRetry():boolean{returnthis.task?.status==='failed'||this.task?.status==='stalled'}@BuilderprivateActionButtons(){Row({space:12}){if(this.task?.status==='uploading'){Button('取消上传').onClick(()=>this.cancelUpload())}if(this.canRetry()){Button('重试').onClick(()=>this.retryUpload())}}}}这里没有在 Builder 里直接拼复杂判断。canRetry()返回安全布尔值,按钮状态和任务状态一一对应。
验证清单
- 选择超过限制的大图,确认不发起上传并给出原因。
- 断网后上传,确认进入 failed 或 stalled,而不是无限 loading。
- 上传中取消,确认底层任务被取消,页面可重新选择。
- 慢网 0% 停留超过阈值,确认出现重试入口。
- 上传成功后写入业务记录,再返回页面不丢 remoteUrl。
- 连续选择两张图,旧任务不能覆盖新任务状态。
上传结果写入业务前要二次确认
图片上传成功不等于业务保存成功。头像、课程封面、笔记图片通常还要把remoteUrl写入用户资料或课程记录。如果上传成功但业务写入失败,也要给用户明确状态。
| 阶段 | 失败后状态 | 用户能做什么 |
|---|---|---|
| 本地校验失败 | failed | 重新选择图片 |
| 上传中断 | stalled或failed | 重试或取消 |
| 上传成功但业务保存失败 | done但未绑定业务 | 重试保存引用 |
| 用户取消 | canceled | 重新选择 |
我会把“上传文件”和“绑定业务记录”拆开。这样上传成功后即使保存课程失败,也不需要重新上传同一张图,只需要重试绑定。
给上传任务补一个绑定阶段
exportinterfaceImageBindPayload{businessId:stringremoteUrl:stringusage:'avatar'|'course_cover'|'note_image'}exportclassImageBindService{constructor(privatereadonlyrepository:ImageReferenceRepository){}asyncbindUploadedImage(payload:ImageBindPayload):Promise<void>{if(payload.remoteUrl.length===0){thrownewError('remote url is required')}awaitthis.repository.saveReference(payload)}}这段逻辑防止上传层和业务层互相越界。上传 Service 不知道图片最终挂在哪里,业务绑定 Service 也不负责重新上传文件。排查时先看任务状态,再看绑定结果。
小结:上传是一条任务链路
图片上传卡住时,不要只修按钮。把上传建成任务状态机,记录进度更新时间,Service 负责启动取消重试,页面只展示状态。这样断网、慢网、大图、取消和重试都有明确位置,用户不会被困在一个没有解释的 loading 里。
(payload)
}
}
这段逻辑防止上传层和业务层互相越界。上传 Service 不知道图片最终挂在哪里,业务绑定 Service 也不负责重新上传文件。排查时先看任务状态,再看绑定结果。 ## 小结:上传是一条任务链路 图片上传卡住时,不要只修按钮。把上传建成任务状态机,记录进度更新时间,Service 负责启动取消重试,页面只展示状态。这样断网、慢网、大图、取消和重试都有明确位置,用户不会被困在一个没有解释的 loading 里。