HarmonyOS Search 输入拦截怎么做:onWillInsert、inputFilter 和提交前校验怎么分工

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)
})
问题也很明显:
- 输入一个字就可能发一次请求;
- 中文输入法还没提交完整,就被中途改掉;
- 粘贴一段内容时,显示值、请求值、历史值可能不是同一个;
- 空字符串也可能进入请求或历史;
- 历史记录的去重逻辑被输入过程牵着走。
搜索框不应该这么累。输入过程只处理输入过程,真正发请求应该在提交时做。
案例一:粘贴复杂内容,先拦再统一
假设搜索框里已有 川菜,用户粘贴了一段内容:
@@@水煮鱼🙂
--辣
我希望最终得到的是:
川菜 水煮鱼 辣
这里有两个动作:
- 插入前先判断这一段内容会不会把输入框搞乱;
- 提交前再统一做一次归一化,保证请求值干净。
核心逻辑可以先抽成普通函数:
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 做细边界 |
粘贴、删除、特殊输入要精细控制 | 逻辑复杂时要配套测试 |
我更倾向第三种:输入阶段轻拦截,提交阶段重校验。只有在粘贴规则特别复杂时,再把 onWillInsert 和 onWillDelete 接进来。
可以沉淀成一个小工具
搜索框多了以后,不要每个页面都复制一遍正则。可以把规则收成一个工具:
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 组件负责收集输入,查询服务负责请求,历史仓库负责记录,页面只负责把这些状态展示出来。谁负责哪一段,先定清楚,后面的功能才不会越写越乱。
更多推荐



所有评论(0)