第26篇|页面返回后请求重复发:用请求门闩和结果序号收口
摘要:页面返回后请求重复发,通常不是网络层问题,而是页面生命周期、返回刷新和手动重试同时触发了同一批加载。HarmonyOS 页面里如果aboutToAppear、返回回调、下拉刷新都直接调用 Service,慢网下会出现三次相同请求,最后还可能让旧结果覆盖新结果。更稳的做法是用请求 key 合并同条件请求,再用结果序号保证页面只消费最新返回。
实际项目里我见过一个列表页:从详情页返回时,列表会自动刷新;用户又顺手下拉一次,日志里同一个接口发了三次。网络快时看不出来,网络慢时旧请求最后返回,反而把刚更新的列表覆盖掉。继续在每个入口加if loading不够,因为不同筛选条件、不同页面实例和手动重试都需要被区分。
这篇文章解决四个实际问题:
- 如何判断重复请求来自生命周期、返回刷新还是手动重试。
- 如何用请求 key 表达一次加载的业务条件。
- 如何合并同条件请求,并避免旧结果覆盖新结果。
- 如何用日志和弱网路径验证请求确实被收口。
先把重复请求拆成三类
重复请求不能只看接口次数,要看触发来源。
| 触发来源 | 常见现象 | 正确处理 |
|---|---|---|
| 页面首次出现 | aboutToAppear自动加载 | 允许加载,但要带请求 key |
| 从详情页返回 | 返回后希望看到最新数据 | 只在数据可能变化时刷新 |
| 手动下拉刷新 | 用户明确要求重新拉取 | 可以绕过短缓存,但仍要有序号 |
| 慢网旧结果返回 | 后发请求先成功,先发请求后成功 | 页面只消费最新序号 |
这个拆分能避免把所有入口都粗暴包在一个loading里。loading只能挡住当前页面的交互,不知道两个请求是不是同一组业务条件。
请求 key 要包含业务条件
请求 key 不要只写页面名。筛选条件、分页、用户域、刷新来源都会影响结果。
exportinterfaceCourseListQuery{userId:stringkeyword:stringcategoryId:stringpageNo:number}exportfunctionbuildCourseListKey(query:CourseListQuery):string{return['course_list',query.userId,query.categoryId,query.keyword.trim(),`${query.pageNo}`].join('|')}这段代码的边界是“同样条件才算同一个请求”。关键词不同、分类不同、页码不同,就不能合并;条件完全相同,返回刷新和手动刷新才有机会复用。
RequestGate 合并同条件请求
请求门闩只处理并发合并,不替代业务 Service。它保存当前正在执行的 Promise,同一个 key 进来时直接复用。
exportclassRequestGate<T>{privaterunning:Map<string,Promise<T>>=newMap()run(key:string,task:()=>Promise<T>):Promise<T>{constcurrent=this.running.get(key)if(current){returncurrent}constnext=task().finally(()=>{this.running.delete(key)})this.running.set(key,next)returnnext}}这里不要把结果长期缓存进 Gate。Gate 只解决“同一时间重复发”的问题,数据缓存是否存在、缓存多久,仍然应该由 Service 或 Repository 决定。
页面用序号消费最新结果
只合并请求还不够。不同条件的请求可能同时存在,页面要保证旧结果不能覆盖新状态。
@Entry@Componentstruct CourseListPage{@Staterows:CourseRowVm[]=[]@Stateloading:boolean=false@StateerrorText:string=''privatequerySeq:number=0privateservice:CourseListService=createCourseListService()asyncloadCourses(query:CourseListQuery):Promise<void>{constseq=++this.querySeqthis.loading=truetry{constrows=awaitthis.service.loadRows(query)if(seq!==this.querySeq){return}this.rows=rowsthis.errorText=''}catch(err){if(seq===this.querySeq){this.errorText='课程加载失败,请重试'}}finally{if(seq===this.querySeq){this.loading=false}}}}querySeq是页面自己的消费边界。它不关心网络层发了几次,只保证当前页面不会把过期结果写回 UI。
Service 决定返回是否需要重拉
从详情页返回后不一定要重拉。更稳的做法是让写入动作更新数据版本,列表页返回时只在版本变化时加载。
exportclassCourseListService{privategate:RequestGate<CourseRowVm[]>=newRequestGate()constructor(privatereadonlyrepository:CourseRepository,privatereadonlyadapter:CourseRowAdapter){}asyncloadRows(query:CourseListQuery):Promise<CourseRowVm[]>{constkey=buildCourseListKey(query)returnthis.gate.run(key,async()=>{constrecords=awaitthis.repository.list(query)returnthis.adapter.toRows(records)})}}页面只传查询条件,Service 负责读取和转换。这样以后增加缓存、分页或离线兜底,不需要到每个页面入口里补判断。
用日志确认只发了一次
排查重复请求时,日志要带 key、序号和来源。
rg"loadCourses\(|buildCourseListKey|RequestGate|querySeq"entry features common rg"aboutToAppear|onPageShow|onRefresh|back"entry features common运行时日志建议记录source=appear/back/refresh、key=course_list|...、seq=3。如果同一个 key 在 200ms 内出现多次,就先看是否绕过了 Gate。
验证清单
- 首次进入列表,只发一次当前查询请求。
- 进入详情后返回,没有数据变化时不重拉。
- 详情修改课程后返回,列表刷新一次。
- 返回瞬间下拉刷新,同条件请求被合并。
- 快速切换分类,旧分类结果不会覆盖新分类。
- 弱网失败后错误态可见,点击重试只发一次。
真实项目里最容易漏掉的入口
重复请求经常不是主入口制造的,而是几个“看起来合理”的辅助入口叠在一起。
| 入口 | 为什么容易漏 | 建议处理 |
|---|---|---|
列表页aboutToAppear | 每次返回都会触发 | 只表达加载意图,交给 RequestGate 判断 |
| 详情页保存后返回 | 需要刷新最新数据 | 保存成功后只更新数据版本,不直接拉列表 |
| 下拉刷新 | 用户主动触发 | 可以绕过短缓存,但仍复用同 key 并发 |
| 筛选切换 | 条件变化快 | 用序号丢弃旧结果 |
| 网络失败重试 | 用户会连续点 | 按 key 合并重试中的请求 |
我会把这些入口全部列出来,再看每个入口最终是否都走loadCourses(query)。如果某个入口直接调用 Repository 或网络层,就说明请求边界还没有收口。
给请求留下可排查的上下文
重复请求问题要能从日志里还原出来。只记录“请求开始”不够,至少要带来源、key、序号和结果。
exportinterfaceRequestLogContext{source:'appear'|'back'|'refresh'|'retry'key:stringseq:number}exportfunctionformatRequestLog(ctx:RequestLogContext,state:string):string{return`[course-list]${state}source=${ctx.source}key=${ctx.key}seq=${ctx.seq}`}有了这组字段,排查时就能判断是同一个 key 被重复触发,还是不同筛选条件的正常请求。发布前再按key聚合日志,能很快发现某个返回路径是不是仍在绕过门闩。
我会怎样复现这个问题
复现重复请求时,不要只看 Network 面板里有没有两条接口。更稳的复现顺序是:先打开列表页等首次加载完成;进入详情页修改一条数据;返回列表页的同时快速下拉刷新;再切换一次筛选条件。这个组合能同时触发首次缓存、返回刷新、手动刷新和条件变化。如果日志里同一个 key 出现两次以上,就说明门闩没有接住;如果不同 key 的旧结果覆盖当前列表,就说明页面序号没有生效。
复现时我会记录三项结果:
- 同一个请求 key 在一次返回路径里出现了几次。
- 最后写入页面状态的是哪个
seq。 - 失败重试有没有绕过
RequestGate。
小结:返回刷新要有条件,不靠生命周期硬拉
页面返回后请求重复发,核心不是少写一个生命周期回调,而是给每次加载建立可解释的边界:请求 key 表达业务条件,RequestGate 合并同条件并发,页面序号只消费最新结果,Service 决定数据怎么读取。这样首次进入、返回刷新、手动重试和弱网乱序都能被解释。
后写入页面状态的是哪个seq。
3. 失败重试有没有绕过RequestGate。
小结:返回刷新要有条件,不靠生命周期硬拉
页面返回后请求重复发,核心不是少写一个生命周期回调,而是给每次加载建立可解释的边界:请求 key 表达业务条件,RequestGate 合并同条件并发,页面序号只消费最新结果,Service 决定数据怎么读取。这样首次进入、返回刷新、手动重试和弱网乱序都能被解释。