20-搜索越输越乱-用防抖序号和空态统一异步结果
HarmonyOS 搜索结果乱跳:用防抖、请求序号和统一结果态收住异步链路
摘要:搜索框“越输越乱”通常不是接口排序问题,而是旧请求晚回来后覆盖了新关键词的结果。本文以课程检索为例:输入层用防抖控制节奏,页面为每次查询递增序号,只有最新序号能提交状态;空输入、无结果、加载失败和成功结果由同一套状态模型渲染。
用户连续输入ArkTS,再删成Ark,网络较慢的ArkTS请求最后返回并覆盖列表——这就是典型乱序。解决它不靠取消 Promise 的侥幸,而靠让页面明确知道“哪个响应仍属于当前输入”。
搜索至少有五种可见状态
“空白列表”不能同时表示没输入、没匹配和请求失败。先把状态写清,页面才不会把旧数据留在错误场景里。
exporttypeSearchUiState=|{kind:'idle'}|{kind:'loading';keyword:string}|{kind:'empty';keyword:string}|{kind:'data';keyword:string;items:CourseCard[]}|{kind:'error';keyword:string;message:string}idle对应尚未输入,empty对应已经搜索却没有命中。它们的文案和下一步操作不同,不能合并成一个items.length === 0判断。
防抖只减少请求,不解决乱序
防抖应在输入变化时取消旧计时器,等用户停顿后再发起查询。它降低压力,但旧请求仍可能已经在路上,因此不能替代序号校验。
privatedebounceTimer:number=-1privateonKeywordChange(value:string):void{this.keyword=value.trim()clearTimeout(this.debounceTimer)if(this.keyword.length===0){this.searchState={kind:'idle'}return}this.debounceTimer=setTimeout(()=>this.search(this.keyword),300)}清空输入时必须立即把状态切回idle。否则等待中的旧请求回来后,会把已清空的页面重新填满。
序号是响应是否过期的唯一判断
页面维护一个递增的requestId。发起请求时捕获本次编号;响应回来后,只有编号仍等于最新编号时才可写入@State。
privatelatestRequestId:number=0privateasyncsearch(keyword:string):Promise<void>{constrequestId=++this.latestRequestIdthis.searchState={kind:'loading',keyword}constresult=awaitthis.searchService.searchCourses(keyword)if(requestId!==this.latestRequestId||keyword!==this.keyword){return}this.searchState=result}这段判断保护的是页面状态提交点,而非网络本身。即便旧请求无法取消,它也失去覆盖新界面的资格。
服务层把异常和空数组翻译成结果态
服务只接收已规范化关键词,返回页面能够直接消费的结果态;不要让页面逐个处理网络异常和空数组。
asyncsearchCourses(keyword:string):Promise<SearchUiState>{try{constitems=awaitthis.courseRepository.search(keyword)returnitems.length===0?{kind:'empty',keyword}:{kind:'data',keyword,items}}catch(error){return{kind:'error',keyword,message:'网络暂不可用,请重试'}}}服务层不关心某个TextInput,页面也不用接触底层异常文本。这是输入、查询和渲染三者的清晰分界。
不要在 ForEach 或 build 中发请求
ArkUI 的构建可能因状态变化多次执行。把搜索放进build、@Builder或ForEach内会制造重复请求并放大竞态。
| 放置位置 | 是否合适 | 原因 |
|---|---|---|
| 输入回调 | 合适 | 触发时机明确 |
| 防抖回调 | 合适 | 可合并快速输入 |
build/@Builder | 不合适 | 渲染次数不等于用户意图 |
ForEach | 不合适 | 每项渲染都会扩大请求 |
搜索动作只有一个入口onKeywordChange → search,排查时也更容易确认是否存在绕过序号的旧代码。
分支渲染让空态不再像故障
根据kind展示明确界面,而不是只靠列表是否为空。
if(this.searchState.kind==='idle'){Text('输入课程名称开始搜索')}elseif(this.searchState.kind==='loading'){LoadingProgress()}elseif(this.searchState.kind==='empty'){EmptyResultPanel({keyword:this.searchState.keyword})}elseif(this.searchState.kind==='error'){RetryPanel({message:this.searchState.message,onRetry:()=>this.search(this.keyword)})}重试仍使用当前keyword,而不是错误发生时可能已经过期的关键词。数据态再交给列表组件渲染,避免一个组件承担所有分支。
清空、离开页面和回前台都要失效旧请求
除了输入变化,离开页面也应推进序号,使未完成请求自然失效。
aboutToDisappear():void{this.latestRequestId++clearTimeout(this.debounceTimer)}无需为此硬取消每个请求;关键是它们再回来也无法写入已不属于它们的页面。若底层 API 支持取消,可作为节流优化,而不是正确性前提。
用时间顺序验证
| 操作 | 预期 |
|---|---|
| 快速输入后停下 | 只按最终关键词展示结果 |
| 输入后立即清空 | 显示输入提示,旧结果不回流 |
| 人为让旧请求更慢 | 旧响应被序号丢弃 |
| 无匹配 | 显示关键词对应的空态 |
| 断网后重试 | 显示错误,再按当前关键词重新查询 |
可用以下搜索检查调用点:
rg"searchCourses\(|latestRequestId|setTimeout"entry features common rg"ForEach.*search|build\(.*search"entry features common常见误区
| 误区 | 后果 | 修复 |
|---|---|---|
| 只加防抖 | 旧响应仍能覆盖新结果 | 增加请求序号 |
| 清空输入不变更状态 | 旧列表残留或回流 | 立即设为idle并推进序号 |
| 空结果和错误共用空白页 | 用户不知道下一步 | 使用不同结果态 |
| 在构建函数请求 | 请求次数不可控 | 只从输入事件触发 |
小结
搜索稳定性来自“输入版本”和“页面状态”两份明确事实:防抖控制何时开始,序号决定谁能结束,统一结果态决定页面展示什么。把这三层收口后,慢网和快速输入不会再把搜索界面带乱。
把关键词规范化也放进同一条链路
用户输入的空格、大小写和不可见字符会让“看起来相同”的关键词形成不同请求。规范化应发生在输入边界,服务与页面都只处理同一个normalizedKeyword,这样缓存键、请求序号和重试目标才不会分叉。
functionnormalizeKeyword(value:string):string{returnvalue.trim().replace(/\s+/g,' ')}如果产品需要拼音、同义词或历史记录,应继续保持这个边界:输入适配器负责把原始文字变成查询条件,搜索服务负责检索,页面只负责展示结果态。不要在列表渲染时临时改关键词,否则“当前输入”和“实际请求”会再次失去对应关系。
加载态不应遮住已确认的操作
列表已有结果、用户只是在微调关键词时,可以按产品选择保留旧列表并显示轻量加载提示;首次搜索则展示骨架或加载组件。无论视觉策略怎样,旧列表都不能被标记为新关键词的结果。
| 场景 | 建议呈现 | 数据语义 |
|---|---|---|
| 首次输入 | 加载占位 | 当前关键词尚无结果 |
| 修改关键词 | 轻量加载 + 可选旧列表 | 旧列表仅是过渡,不可当新结果 |
| 清空输入 | 输入提示 | 不存在查询结果 |
| 请求失败 | 错误与重试按钮 | 没有成功得到当前结果 |
这样设计可以兼顾响应感和正确性:用户始终知道列表是否真正属于当前关键词。
搜索历史不能绕过当前查询规则
历史词、热门词和语音输入也最终要进入onKeywordChange,不能直接改列表。这样它们自动获得规范化、防抖、序号和状态渲染保护;后续增加埋点时,也只需在这一个入口记录“发起了什么查询”,不记录不必要的结果内容。
对于本地历史记录,建议在用户主动提交或真正得到数据后再写入。每次键盘输入都持久化既浪费 I/O,也会把误输入和敏感片段长期留在设备上。
| 来源 | 正确入口 | 不能直接做的事 |
|---|---|---|
| 历史词点击 | onKeywordChange | 直接替换列表 |
| 热门词点击 | onKeywordChange | 绕过序号调用接口 |
| 语音识别结果 | onKeywordChange | 把原始结果直接持久化 |
结果项点击要绑定结果所属关键词
用户点击结果项时,页面可能已开始下一次搜索。不要根据当前输入框的文字反推点击来源;点击项自身应携带稳定的课程 ID。这样即使输入变化,打开详情也不会跳到错误对象,返回搜索页时也能决定是否保留当前查询状态。
这条边界把“搜索用于找到什么”与“点击后要打开哪个实体”分开,异步响应再复杂,路由参数仍是可靠的业务身份。
privateopenCourseFromResult(item:CourseCard):void{this.navPathStack.pushPathByName('CourseDetail',{courseId:item.id})}页面只传递稳定的courseId,详情页自行读取最新实体。这样旧列表项被点击也不会把临时查询文本带进路由契约。