28-图片上传卡住-用任务状态机和可取消重试链路处理 第28篇图片上传卡住用任务状态机和可取消重试链路处理摘要图片上传卡住时继续给按钮加 loading 通常解决不了。上传有文件校验、网络状态、进度回调、取消、重试、成功写入业务记录等多个阶段。更稳的做法是把上传建成任务状态机页面只展示任务状态Service 负责启动、取消和重试长时间无进度时进入可恢复状态而不是让用户卡在一个转圈里。我处理过一个头像上传问题用户选图后按钮显示“上传中”进度一直停在 0%返回再进仍然不能重新选图。后来发现页面只保存了一个uploadingtrue没有记录任务 id、文件路径、进度更新时间和失败原因。网络断开或系统代理上传没有回调时页面就不知道该取消还是重试。这篇文章解决四个实际问题如何把上传拆成 pending、uploading、failed、done 等明确状态。如何处理长时间没有进度的卡住情况。如何让取消和重试都回到同一个任务模型。如何验证断网、慢网、大图和页面返回路径。上传卡住先看状态是否可解释一个uploading布尔值无法解释上传卡住。状态用户应该看到处理方式pending等待上传可以取消或开始uploading进度和取消入口监听进度更新时间stalled长时间无进度提供重试和取消failed明确失败原因允许重试done上传完成写入业务记录状态能解释页面才知道按钮该不该禁用、用户能不能返回、失败后是否可以重试。定义上传任务模型上传任务要带任务 id、文件信息、进度和最后一次进度时间。exporttypeUploadStatuspending|uploading|stalled|failed|done|canceledexportinterfaceImageUploadTask{taskId:stringlocalUri:stringfileName:stringsizeBytes:numberstatus:UploadStatus progress:numberlastProgressAt:numbererrorText:stringremoteUrl:string}exportfunctioncreateImageUploadTask(localUri:string,fileName:string,sizeBytes:number):ImageUploadTask{constnowDate.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.length0){return图片路径无效请重新选择}if(!task.fileName.endsWith(.jpg)!task.fileName.endsWith(.png)){return仅支持 JPG 或 PNG 图片}if(task.sizeBytes8*1024*1024){return图片超过 8MB请压缩后再上传}return}}校验失败时任务可以停在failed并给出明确原因。不要让用户看到一个永远不会成功的上传进度。Service 负责启动、进度和取消页面不直接调用上传 API。Service 把底层上传能力包装成任务状态更新。exportinterfaceUploadProgress{sentBytes:numbertotalBytes:number}exportinterfaceUploadTransport{start(task:ImageUploadTask,onProgress:(progress:UploadProgress)void):Promisestringcancel(taskId:string):Promisevoid}exportclassImageUploadService{constructor(privatereadonlytransport:UploadTransport,privatereadonlyvalidator:ImageUploadValidator){}asyncstart(task:ImageUploadTask,update:(task:ImageUploadTask)void):Promisevoid{consterrorthis.validator.validate(task)if(error.length0){update({...task,status:failed,errorText:error})return}update({...task,status:uploading,errorText:})constremoteUrlawaitthis.transport.start(task,(progress:UploadProgress){constpercentMath.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):Promisevoid{awaitthis.transport.cancel(task.taskId)}}这里的UploadTransport可以接系统上传下载能力也可以接项目已有网络层。文章重点是状态边界而不是把底层 API 写死在页面里。长时间无进度要变成可恢复状态卡住不是失败但必须可见、可取消、可重试。exportclassUploadWatchdog{constructor(privatereadonlystallMs:number15000){}markIfStalled(task:ImageUploadTask):ImageUploadTask{if(task.status!uploading){returntask}if(Date.now()-task.lastProgressAtthis.stallMs){returntask}return{...task,status:stalled,errorText:上传长时间没有进度请检查网络后重试}}}页面可以定时调用这段逻辑也可以在进度回调缺失时由 Service 触发。关键是不要让用户只看到一个无限转圈。页面按钮跟状态绑定上传页只展示任务状态和触发动作不拼接底层上传细节。Componentstruct ImageUploadPanel{Statetask:ImageUploadTask|nullnullprivateservice:ImageUploadServicecreateImageUploadService()privatecanRetry():boolean{returnthis.task?.statusfailed||this.task?.statusstalled}BuilderprivateActionButtons(){Row({space:12}){if(this.task?.statusuploading){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):Promisevoid{if(payload.remoteUrl.length0){thrownewError(remote url is required)}awaitthis.repository.saveReference(payload)}}这段逻辑防止上传层和业务层互相越界。上传 Service 不知道图片最终挂在哪里业务绑定 Service 也不负责重新上传文件。排查时先看任务状态再看绑定结果。小结上传是一条任务链路图片上传卡住时不要只修按钮。把上传建成任务状态机记录进度更新时间Service 负责启动取消重试页面只展示状态。这样断网、慢网、大图、取消和重试都有明确位置用户不会被困在一个没有解释的 loading 里。(payload)}}这段逻辑防止上传层和业务层互相越界。上传 Service 不知道图片最终挂在哪里业务绑定 Service 也不负责重新上传文件。排查时先看任务状态再看绑定结果。 ## 小结上传是一条任务链路 图片上传卡住时不要只修按钮。把上传建成任务状态机记录进度更新时间Service 负责启动取消重试页面只展示状态。这样断网、慢网、大图、取消和重试都有明确位置用户不会被困在一个没有解释的 loading 里。