HarmonyOS Search 输入拦截怎么做

Search 组件不难用,难的是边界。页面上一个搜索框,通常会同时承担这些事:输入时要拦掉不合法字符,粘贴时要清洗一大段内容,点搜索时要避免空请求,还要把搜索历史排到最新位置。

很多问题就是从这里来的:所有逻辑都写进 onChange。刚开始能跑,后面一加中文输入、一加粘贴、一加历史记录,就开始出现显示值和提交值不一致。

官方 Search 文档里能看到这些能力:inputFilter 可以做输入过滤,onWillInsert / onDidInsert 能观察插入前后的内容,onWillDelete / onDidDelete 能处理删除前后的边界,maxLength 控制长度,SearchController 可以处理光标和编辑状态。我的做法不是把它们都堆上,而是先把职责分开。

这里参考的官方入口主要有三类:Search 组件属性和事件、SearchController 的编辑控制、文本输入类组件的输入过滤和插入删除回调。它们的共同点是都在处理输入,但位置不一样。inputFilter 更靠近“字符能不能进来”,onWillInsert 更靠近“这一段插入前要不要放行”,onSubmit 更靠近“这个值能不能进入查询链路”。如果这三个位置不分清,页面后面一定难排查。

先把输入链路拆成三层

我一般按这三层处理:

层级 负责什么 不负责什么
输入阶段 拦明显不该进来的字符,比如控制字符、危险符号、超长粘贴 不发请求,不写历史
提交阶段 统一 trim、空格归一、长度兜底、空关键词拦截 不直接改组件内部编辑过程
历史阶段 去重、排序、限制数量、持久化 不参与输入法组合态

这样做的好处是很直接的:输入框显示什么、最终提交什么、历史记录存什么,三件事不会互相抢职责。

第一种容易出错的写法:把所有规则塞进 onChange

下面这种写法看起来省事:

Search({ value: this.keyword, placeholder: '搜索菜谱' })
  .onChange((value: string) => {
    this.keyword = value.replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, '')
    this.search(this.keyword)
    this.saveHistory(this.keyword)
  })

问题也很明显:

  • 输入一个字就可能发一次请求;
  • 中文输入法还没提交完整,就被中途改掉;
  • 粘贴一段内容时,显示值、请求值、历史值可能不是同一个;
  • 空字符串也可能进入请求或历史;
  • 历史记录的去重逻辑被输入过程牵着走。

搜索框不应该这么累。输入过程只处理输入过程,真正发请求应该在提交时做。

案例一:粘贴复杂内容,先拦再统一

假设搜索框里已有 川菜,用户粘贴了一段内容:

  @@@水煮鱼🙂
--辣

我希望最终得到的是:

川菜 水煮鱼 辣

这里有两个动作:

  1. 插入前先判断这一段内容会不会把输入框搞乱;
  2. 提交前再统一做一次归一化,保证请求值干净。

核心逻辑可以先抽成普通函数:

function normalizeKeyword(raw: string): string {
  return (raw ?? '')
    .normalize('NFKC')
    .replace(/[\u0000-\u001f\u007f]/g, ' ')
    .replace(/[^\p{Script=Han}\p{Letter}\p{Number}\s_-]/gu, ' ')
    .replace(/[-_]{2,}/g, ' ')
    .replace(/\s+/g, ' ')
    .trim()
}

function limitKeyword(raw: string, limit: number = 24): string {
  return Array.from(normalizeKeyword(raw)).slice(0, limit).join('')
}

function buildNextKeyword(
  currentValue: string,
  insertValue: string,
  selectionStart: number,
  selectionEnd: number
): string {
  const before = currentValue.slice(0, selectionStart)
  const after = currentValue.slice(selectionEnd)
  return limitKeyword(before + insertValue + after)
}

放到 Search 上时,onWillInsert 更适合做第一层判断,onSubmit 更适合做最终提交:

@Entry
@Component
struct SearchGuardDemo {
  @State keyword: string = ''
  @State resultText: string = '还没有提交'

  normalizeKeyword(raw: string): string {
    return (raw ?? '')
      .normalize('NFKC')
      .replace(/[\u0000-\u001f\u007f]/g, ' ')
      .replace(/[^\p{Script=Han}\p{Letter}\p{Number}\s_-]/gu, ' ')
      .replace(/[-_]{2,}/g, ' ')
      .replace(/\s+/g, ' ')
      .trim()
  }

  commitSearch(raw: string): void {
    const keyword = Array.from(this.normalizeKeyword(raw)).slice(0, 24).join('')
    if (!keyword) {
      this.resultText = '关键词为空,不发请求'
      return
    }
    this.keyword = keyword
    this.resultText = `准备搜索:${keyword}`
  }

  build() {
    Column({ space: 16 }) {
      Search({ value: this.keyword, placeholder: '搜索菜谱、食材或做法' })
        .maxLength(32)
        .inputFilter('[\\u4e00-\\u9fa5a-zA-Z0-9_\\-\\s]+')
        .onChange((value: string) => {
          this.keyword = value
        })
        .onSubmit((value: string) => {
          this.commitSearch(value)
        })

      Text(this.resultText)
        .fontSize(15)
        .fontColor('#334155')
    }
    .padding(20)
  }
}

这里我没有在 onChange 里请求接口。onChange 只同步显示值。提交按钮、键盘回车、搜索按钮触发时,再进入 commitSearch

案例二:搜索历史不要跟着输入过程乱动

第二个问题更常见。搜索历史如果在输入阶段就写入,用户输入 红烧红烧肉,历史里可能会出现三条半成品。

我会把历史记录放到提交阶段:

class SearchHistoryStore {
  private items: string[] = []

  push(raw: string): string[] {
    const keyword = Array.from(raw.trim()).slice(0, 24).join('')
    if (!keyword) {
      return this.items
    }
    this.items = [keyword, ...this.items.filter((item) => item !== keyword)].slice(0, 8)
    return this.items
  }

  list(): string[] {
    return [...this.items]
  }
}

页面里只在提交成功后写历史:

@State keyword: string = ''
@State histories: string[] = []
private historyStore: SearchHistoryStore = new SearchHistoryStore()

submitKeyword(raw: string): void {
  const keyword = Array.from(this.normalizeKeyword(raw)).slice(0, 24).join('')
  if (!keyword) {
    return
  }
  this.keyword = keyword
  this.histories = this.historyStore.push(keyword)
}

这时候再看几个边界:

操作 应该发生什么
输入空格后提交 不发请求,不写历史
重复提交同一个词 移到历史第一位,不新增重复项
粘贴超长关键词 按字符截断,不拆半个中文字符
粘贴符号和表情 先清洗,再提交

这个规则比“输入一次写一次历史”稳定很多。

删除动作也要单独看

输入拦截不是只管插入。搜索框还有删除、清空、选中后替换这些动作。如果页面把删除也当成普通 onChange 来处理,容易出现一个现象:用户明明只是清空搜索词,页面却立刻发了一次“空关键词搜索”,列表被刷新成默认状态,历史记录还被写了一条空值。

我会把删除动作分成两类:

删除动作 页面应该怎么处理
用户逐字删除 只更新输入框显示值,不立即请求
用户点清除按钮 清空显示值,同时把当前结果恢复到默认列表
选中一段后替换 按插入流程重新清洗,不直接复用旧关键词
删除后按搜索 进入提交阶段,空值直接拦截

这类规则不一定全部写在 Search 事件里。更稳的方式是让 Search 只发出“值变了”“提交了”“清空了”三个信号,页面状态由一个小的 ViewModel 或状态对象统一处理。

class SearchStateMachine {
  keyword: string = ''
  submitted: string = ''
  histories: string[] = []

  input(value: string): void {
    this.keyword = value
  }

  clear(): void {
    this.keyword = ''
    this.submitted = ''
  }

  submit(value: string): boolean {
    const keyword = SearchKeywordGuard.normalize(value)
    if (!keyword) {
      return false
    }
    this.keyword = keyword
    this.submitted = keyword
    this.histories = [keyword, ...this.histories.filter((item) => item !== keyword)].slice(0, 8)
    return true
  }
}

这段代码的重点不是“状态机”这个名字,而是把三个动作拆开。输入只是输入,清空只是清空,提交才会进入搜索链路。

SearchController 不要太早调用

还有一个坑是焦点。页面一进来就想让搜索框自动聚焦,或者弹窗打开后立刻把光标放到 Search 里。如果组件还没挂好,Controller 调用就可能没效果。

我的处理方式是给焦点动作排队:页面 ready 之后再执行。比如可以封装一个很薄的调度器:

class FocusJobQueue {
  private ready: boolean = false
  private pending: Array<() => void> = []

  markReady(): void {
    this.ready = true
    const jobs = this.pending.splice(0)
    jobs.forEach((job) => job())
  }

  run(job: () => void): void {
    if (this.ready) {
      job()
      return
    }
    this.pending.push(job)
  }
}

页面里不要在对象刚创建时就直接调 Controller,而是在组件可见、弹窗打开完成、或者页面生命周期进入可交互状态后再处理。这样可以避免“偶尔能聚焦、偶尔不聚焦”的问题。

排查时看四个值

Search 输入问题不要只盯着一个 keyword。我通常会打印四个值:

含义 出问题时能看出什么
rawInput 组件刚给出来的原始值 输入法、粘贴、删除是否正常进入页面
normalizedInput 清洗后的显示值 过滤规则有没有误伤
submittedKeyword 真正请求用的值 空请求、重复请求、脏字符请求从哪里来
historyItems 搜索历史 是否出现半成品、重复项、空项

如果这四个值混成一个变量,调试时就只能猜。拆开之后,哪一层错了会很快暴露出来。

本地验证结果

我用独立脚本把两个案例跑了一遍,结果如下:

{
  "caseOne": {
    "input": "川菜 + pasted noisy text",
    "normalized": "川菜 水煮鱼 辣",
    "changed": true,
    "commit": {
      "ok": true,
      "keyword": "川菜 水煮鱼 辣",
      "history": ["川菜 水煮鱼 辣", "川菜", "粤菜"]
    }
  },
  "caseTwo": {
    "emptySubmit": {
      "ok": false,
      "reason": "empty_keyword",
      "keyword": "",
      "history": ["宫保鸡丁"]
    },
    "duplicateSubmit": {
      "ok": true,
      "keyword": "粤菜 早茶 套餐",
      "history": ["粤菜 早茶 套餐", "宫保鸡丁"]
    }
  }
}

验证重点不是输出一段漂亮日志,而是确认四件事:

  • 粘贴内容能被清洗;
  • 空关键词不会进入请求链路;
  • 重复关键词不会堆历史;
  • 最终提交值和页面显示值能对上。

几种写法怎么选

写法 适合场景 风险
只用 inputFilter 简单字符过滤 复杂清洗、历史去重、提交兜底不够
只在 onChange 里处理 很轻的本地显示同步 容易误伤输入法组合态,也容易重复请求
inputFilter + 提交前归一化 搜索、筛选、历史记录 需要多写一层封装
onWillInsert / onWillDelete 做细边界 粘贴、删除、特殊输入要精细控制 逻辑复杂时要配套测试

我更倾向第三种:输入阶段轻拦截,提交阶段重校验。只有在粘贴规则特别复杂时,再把 onWillInsertonWillDelete 接进来。

可以沉淀成一个小工具

搜索框多了以后,不要每个页面都复制一遍正则。可以把规则收成一个工具:

export class SearchKeywordGuard {
  static normalize(raw: string, limit: number = 24): string {
    return Array.from((raw ?? '')
      .normalize('NFKC')
      .replace(/[\u0000-\u001f\u007f]/g, ' ')
      .replace(/[^\p{Script=Han}\p{Letter}\p{Number}\s_-]/gu, ' ')
      .replace(/[-_]{2,}/g, ' ')
      .replace(/\s+/g, ' ')
      .trim())
      .slice(0, limit)
      .join('')
  }

  static valid(raw: string): boolean {
    return SearchKeywordGuard.normalize(raw).length > 0
  }
}

页面里只保留调用:

const keyword = SearchKeywordGuard.normalize(value)
if (!SearchKeywordGuard.valid(keyword)) {
  return
}

这样以后改规则,只改一处。比如要允许 /、要限制不能输入纯数字、要把全角空格统一掉,都不会散落在多个页面里。

以后怎么避免这类问题

我的检查顺序是:

  • onChange 只做显示同步,别顺手发请求;
  • 提交前一定重新归一化,不相信输入阶段已经处理干净;
  • 历史记录只在提交成功后写;
  • 长度限制按字符处理,不要直接按字符串下标硬切;
  • 粘贴、删除、输入法组合态要单独测;
  • Search、TextInput、TextArea 的规则不要混用,先看组件支持的事件和属性。

搜索框问题表面上是输入问题,实际是状态边界问题。把输入、提交、历史三层拆开,后面再接联想词、搜索建议、最近搜索、服务端请求节流,都不会把一条链路搅成一团。

再往后扩展,Search 还可以和本地缓存、远程建议词、页面路由参数一起用。原则还是一样:Search 组件负责收集输入,查询服务负责请求,历史仓库负责记录,页面只负责把这些状态展示出来。谁负责哪一段,先定清楚,后面的功能才不会越写越乱。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐