搜了半天没结果还越搜越卡:vibe 项目的搜索框实战
搜索框看着最简单,却站在用户目的最明确的时刻:防抖 300 毫秒、取消旧请求不让它盖掉新结果、搜不到给纠错和退路、匹配词高亮、键盘走全程、中文输入法不捣乱,以及上线前必须跑的七条检查。

搜了三个字,服务器收到三十次请求:先讲一个虚构的教训
先讲一个虚构的教学例子。阿远一个人用 AI 搭了个菜谱应用“小火慢炖”,里面收了两千多道家常菜。上线第一周,有个用户在搜索框里输入“红烧肉”三个字,页面卡了整整四秒才出结果;更糟的是,结果出来之后又闪了一下,变成了“红烧”的结果——少了一个字,出来的全是红烧茄子、红烧鱼,红烧肉反而排到了第二页。用户在反馈里只写了一句话:“你们家搜索是不是坏的?”
阿远去查日志才发现问题出在最不起眼的地方:他的搜索框是每敲一个字就发一次请求。用户输入“红烧肉”三个字,加上一次删改,短短两秒发了七次请求;七次请求几乎同时到达服务器,返回的顺序却不一定按发送的顺序来——最后到达的那次是搜“红烧”的旧请求,它把“红烧肉”的正确结果盖掉了。与此同时,后端为了省事,每次请求都对两千多道菜做了一次全文扫描,七次扫描叠在一起,直接把接口拖慢到四秒。
这就是搜索框最常见的死法:把它当成一个普通的输入框,而不是一次有来有回的对话。搜索框看起来是产品里最简单的元件——一个框、一个放大镜图标、回车出结果。但它恰恰站在用户目的最明确的时刻出现:用户已经知道自己要什么,只想快点找到它。搜索框做得好不好,用户三秒钟就能感觉出来:是立刻给反馈,还是卡在那里转圈;是越搜越准,还是结果乱跳;是搜不到就给条路,还是只剩一句冷冰冰的“没有找到”。
本文讲的就是怎么给 vibe 项目做好搜索框:输入之后先等一等再发请求、旧请求怎么取消才不会盖掉新结果、搜不到的时候说什么、匹配的词怎么高亮才一眼看懂、键盘怎么走、无障碍怎么做,以及上线前必须跑的检查清单。以下示例均为虚构的教学示例,应用、人物与数据均为编造,仅用于说明做法;文中出现的具体数字(如防抖 300 毫秒、建议上限)为本指南给出的工程经验建议,非引用数据。

一、先划界:搜索框不是搜索页,也不是筛选器
动手之前先把三个容易混的东西分开。搜索框是用户输入关键词、立刻给反馈的入口;搜索结果页是用户按下回车之后看到的完整列表;筛选器是用户在结果里按分类、价格、时间再缩小范围的工具。三件事的责任不一样,混在一起做,最后三件都做不好。
搜索框只负责三件事:第一,接住用户的输入,并且立刻给一点反馈,让用户知道“我收到你的输入了,正在找”;第二,在用户还在打字的时候就给出最可能的前几条候选,也就是常说的联想提示;第三,把用户最终确认的关键词干净地交出去,交给结果页去负责翻页、排序和筛选。搜索框不负责分页,不负责复杂的筛选条件,也不负责“猜你喜欢”。
这个划界直接决定了你要写多少代码。很多 vibe 项目的搜索框越做越臃肿,就是因为没有划界:有人在搜索框里塞了历史记录、热门搜索、分类快捷入口、广告位,最后下拉框比正文还长,用户找个候选要翻半天。判断标准很简单:打开搜索框,用户三秒之内能不能看到他想要的那一条?能,说明这个框是合格的;不能,说明里面塞了不该塞的东西。
与已有的站内指南划界:本篇只讲搜索框本身的交互与实现——防抖、请求取消、空态、高亮、键盘导航。不讲搜索结果页的排序算法与全文检索建索引,那是后端检索的活;不讲筛选器的组合逻辑;也不讲搜索数据的埋点分析。分工清楚,每篇只解决一个问题。

二、防抖:先别急着发请求,等用户停下来再说
防抖是搜索框的第一道防线,原理一句话就能说清:用户每敲一个字,就把上一次的定时器清掉、重新计时;只有用户停下来超过设定的时间,才真正发一次请求。这样用户输入“红烧肉”三个字,最终只发一次请求,而不是七次。
防抖时间设多少?本指南的建议是 300 毫秒。这个数字不是拍脑袋定的:打字快的人两个字之间大约间隔 80 到 150 毫秒,300 毫秒意味着用户停顿了大约两个字的间隔,基本可以判断他这一段话打完了;同时 300 毫秒的延迟人几乎感觉不到,不会觉得搜索“慢半拍”。如果你的用户主要是老年人或者输入法切换频繁,可以放宽到 400 毫秒;如果你的数据量很小、纯前端过滤,可以缩到 150 毫秒。但不要设成 0,也不要设到 800 毫秒以上——前者等于没做防抖,后者会让用户觉得搜索框“没反应”,转身就去点回车,反而多发一次请求。
防抖还有一个容易被忽略的细节:输入框获得焦点、用户还没打字的时候,不要发请求;用户把输入框清空的时候,要立刻清空结果,不要等防抖时间走完。清空是用户明确的“我不要了”,等 300 毫秒再清空,结果列表会多闪一下,体验很别扭。
下面是一段可以直接抄的防抖代码,思路比花样重要:
function debounce(fn, wait = 300) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
};
}
const searchInput = document.querySelector('#search-input');
const onSearch = debounce((keyword) => {
const q = keyword.trim();
if (!q) { renderEmpty(); return; }
runSearch(q);
}, 300);
searchInput.addEventListener('input', (e) => onSearch(e.target.value));
这段代码里有三个判断值得你抄走:第一,防抖函数只管“等一等”,不负责发请求,职责分开,以后改时间只改一个数字;第二,请求之前先 trim,去掉首尾空格——用户不小心多敲一个空格,不应该搜出“没有结果”;第三,空关键词直接走清空逻辑,不发请求。很多线上事故的根因就是空字符串也被发到后端,后端返回全量数据,前端一下子渲染几千条,直接卡死。
防抖和节流不要搞混。节流是“每隔一段时间最多执行一次”,适合滚动、窗口 resize 这种持续发生的事件;防抖是“等停下来再执行一次”,适合搜索这种“一句话说完再动手”的场景。搜索框用节流是错的:用户打字过程中每 300 毫秒发一次,输入“红烧肉”还是会发三到四次,省不了多少请求。
三、请求竞态:旧结果回来晚了,绝不能盖掉新结果
只做防抖还不够。用户输入“红烧”,防抖后发出请求 A;接着又补了一个“肉”字,发出请求 B。如果请求 A 因为网络抖动回来得比 B 晚,页面上最后显示的就会是“红烧”的结果——这就是阿远那个菜谱应用里发生的事故。解决办法有两个,两个都要做。
第一个办法是取消旧请求。浏览器原生的 AbortController 就是干这个的:每次发新请求之前,先把上一次的控制器 abort 掉,旧请求在半路就被掐断,不会再回来捣乱。代码不长,但它是搜索框里最值得写的一段:
let controller = null;
async function runSearch(keyword) {
if (controller) controller.abort();
controller = new AbortController();
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`, {
signal: controller.signal,
});
const data = await res.json();
renderResults(data);
} catch (err) {
if (err.name === 'AbortError') return; // 被新请求取消,不算错
renderError();
}
}
注意 catch 里对 AbortError 的处理:被取消不是错误,不要弹“搜索失败”,不要记一条错误日志。很多项目在这里踩坑——取消被当成报错上报,监控面板上全是假警报,真的报错反而淹没在里面。
第二个办法是给每次请求编个号,只认最新的。取消请求能解决大部分问题,但在某些场景下旧请求已经回来了、取消不掉(比如请求已经在后端跑完、正在回来的路上),这时候就需要序号兜底:每次发请求序号加一,收到响应时先对一下号,不是最新序号的直接丢弃。两个办法叠加,竞态问题才算真正关上门。
还有一个相关的判断:搜索请求不要做浏览器缓存共享。用户搜“红烧肉”和搜“红烧鱼”是两次不同的请求,缓存键必须带上完整的关键词;如果缓存键只取了前两个字,搜“红烧鱼”会直接命中“红烧肉”的缓存,结果全错。缓存可以做,但键要精确到完整关键词加排序方式,缺一个都不行。
四、空态:搜不到也要给条路,别只剩一句“没有找到”
空态是搜索框最容易被敷衍的部分。很多项目搜不到就显示一行小字“没有找到相关结果”,然后就没有然后了。用户站在原地,不知道是自己打错了字、还是这个东西真的没有、还是应该换个词再试一次。空态的任务,就是把这三个问题一次回答清楚。
一个合格的空态至少包含四样东西。第一,原样回显用户搜了什么:“没有找到与‘红烧肉盖饭’相关的内容。”回显关键词有两个作用:用户能立刻确认是不是自己打错了字;同时也告诉他,系统确实理解了他的输入,不是没反应。第二,给一个最可能的纠正:拼写相近的词、去掉一个字之后的建议,比如“试试搜‘红烧肉’”。第三,给两到三条退路:看看热门内容、逛逛分类、或者换个关键词。第四,如果这个东西确实没有,给用户一个“求一个”的入口——收藏这个关键词、订阅上新提醒。退路不是装饰,是把一次失败的搜索变成下一次访问的理由。
空态还有两个细节。第一个是区分“没搜到”和“还没搜”:用户刚打开搜索框、一个字都没打的时候,下拉框里应该放热门搜索和最近搜索,而不是空态。把“还没搜”做成空态的样子,用户会以为你的内容库是空的。第二个是区分“没搜到”和“搜错了”:用户输入了一个明显不存在的词,比如把“红烧肉”打成“红烧rou”,先给纠错建议,再给空态;顺序反了,用户会觉得你的搜索连拼音都看不懂。
空态的文案要说人话。“未检索到匹配项”这种话,用户看了只会更烦。直接写“没找到‘红烧肉盖饭’,试试‘红烧肉’?”——一句话,问题、建议全在里面。文案越短,用户越愿意接着试。
五、高亮:让用户一眼看到为什么这条匹配上了
高亮的作用不是好看,是让用户在半秒之内判断“这条是不是我要的”。用户扫一眼结果列表,眼睛会先落在加粗变色的那几个字上,再决定要不要点进去。没有高亮,用户要把每条标题从头读到尾才能判断,十条结果读下来,耐心早就没了。
高亮的规则有三条。第一,只高亮真正匹配上的词,不要整句加粗。用户搜“红烧肉”,标题“红烧肉的三种做法”里只把“红烧肉”三个字标出来;如果把整句都标粗,高亮就失去了意义。第二,匹配要做归一化再比对:大小写不敏感、全角半角统一、去掉首尾空格。用户搜“hongshao rou”拼音的时候不要高亮——拼音匹配是另一套逻辑,不要硬凑。第三,高亮必须防注入:关键词是用户输入的,直接拼进 HTML 就是 XSS 漏洞。正确做法是先把标题和关键词都转义,再对转义后的文本做替换,或者用 DOM 节点逐段拼接,绝不能直接 innerHTML 拼用户输入。
高亮还有一个进阶做法:摘要里也高亮。标题匹配上了,用户点进去之前还想确认“这篇文章到底讲什么”——摘要里把关键词标出来,用户不用点进去就知道这篇对不对。这个做法成本很低:后端返回结果时顺带返回一小段带关键词的摘要,前端按同样的规则高亮就行。很多搜索体验的差距就差在这一步。
六、键盘导航:手别离开键盘,就能完成一次搜索
搜索框是键盘最密集的交互之一。用户在输入框里打字,眼睛盯着下拉框,理想状态是手不碰鼠标就能完成“选一条、回车、看结果”。做不到这一点,搜索框就只完成了一半。
键盘导航的规矩是固定的,照着做就行:上下方向键在候选里移动,当前选中的那一条要有明显的高亮背景;回车确认选中的那一条,如果没有选中任何一条,回车就按当前输入框里的关键词去搜;Esc 关闭下拉框,并且把焦点留在输入框里,不要让焦点丢到页面别处;输入框获得焦点时按斜杠键“/”快速聚焦搜索框,是很多效率工具的标配,有就加上,没有也不扣分。
这里有一个很容易踩的坑:方向键移动的时候,输入框里的文字要不要跟着变?本指南的建议是:跟着变,但要保留用户原本输入的词。用户输入“红烧”,按向下键选中“红烧肉”,输入框变成“红烧肉”;再按一下回到“红烧排骨”,输入框跟着变;按 Esc 或者按向上键回到顶端,输入框恢复成用户自己打的“红烧”。这个做法在各大搜索引擎里都是标准动作,用户已经形成了肌肉记忆,你不照做,用户会觉得“这个框怎么不听使唤”。
无障碍是键盘导航的另一半。搜索框要加上 role="combobox"、aria-expanded、aria-activedescendant,候选列表用 role="listbox"、每条用 role="option",屏幕阅读器才能正确播报“第 3 条,共 8 条”。加载状态要通过 aria-live 区域播报“正在搜索”,而不是只靠一个转圈的图标——看不见图标的用户也需要知道发生了什么。对比度也要注意:候选的高亮背景和文字之间至少要有 4.5:1 的对比度,浅灰配浅灰在强光下根本看不清。
七、移动端:键盘会顶起半个屏幕,输入法也在捣乱
移动端的搜索框和桌面端是两个世界。第一,虚拟键盘弹起来会顶起半个屏幕,下拉框如果定位在输入框下方,很可能被键盘盖住。做法是:移动端搜索框获得焦点时,把搜索界面做成全屏浮层,输入框固定在顶部,下拉结果占满剩余空间,键盘再怎么顶也盖不住结果。第二,中文输入法在拼字过程中会触发多次 input 事件,拼音字母“h”、“ho”、“hon”都会触发一次搜索。解决办法是监听 compositionstart 和 compositionend:正在拼字的时候不要发请求,等用户选完字、compositionend 触发之后再走防抖流程。这个细节不做,中文用户的搜索请求量会比英文用户多三倍,空态也会乱跳。
第三,移动端要把“搜索”按钮做大、做明显。桌面端用户习惯按回车,手机用户习惯点键盘上的“搜索”键或者页面上的按钮。虚拟键盘的回车键在搜索场景下要设置成 search 类型(enterkeyhint="search"),点下去直接触发搜索,而不是换行。按钮本身至少要有 44 像素的高度,太小了拇指点不中,用户会以为按钮是坏的。
八、联想提示:下拉框里的候选怎么排,用户才愿意点
防抖和请求都做好了,下拉框里的候选怎么排,是决定用户点不点的最后一环。候选的排序原则只有一条:按用户此刻最可能想要的排,而不是按你的数据最好排的排。具体来说,候选分三档。第一档是精确匹配:用户输入的词完整出现在标题开头的结果,比如搜“红烧肉”,标题以“红烧肉”开头的菜谱排最前面。第二档是包含匹配:关键词出现在标题中间或者摘要里的结果。第三档是纠错与联想:用户可能打错的字、或者意思相近的词,比如搜“红烧肉”的时候顺带给一条“红烧排骨”。三档之间要有视觉上的区隔——精确匹配和纠错建议不要混在同一个列表里不加区分,用户分不清哪条是真匹配、哪条是“你可能还想找”,点错一次,对搜索的信任就掉一截。
候选的数量也要克制。本指南的建议是最多 8 条,手机上最多 6 条。超过这个数,用户要滚动下拉框才能看完,搜索框的意义就没了——用户要的是“瞄一眼就点”,不是“再翻一页”。如果你的匹配结果有几十条,截断的时候要优先保住第一档,宁可少给两条第二档,也不要把第一档挤出去。截断之后在列表底部加一行“查看全部结果”,点进去就是完整的搜索结果页,联想提示和结果页的衔接就顺了。
候选里还要放什么、不放什么,也值得定一条规矩。最近搜索可以放,但只放用户自己搜过的词,不要把别人的热门搜索伪装成他的历史;热门搜索可以放,但要标清楚“热门”两个字,不要让用户以为是他自己搜过的。广告位不要放进联想提示里——用户在搜索框里输入的每一个字都是明确的意图,意图最明确的地方塞广告,转化率看着好看,信任掉得更快。还有一个细节:候选的每一行都要能点,而且整行都是点击区,不要只让标题那几个字能点;手机上手指的落点本来就比鼠标粗,点不中标题、又点到了行与行之间的缝隙,用户会以为下拉框是坏的。
最后是候选的更新节奏。用户每敲一个字、新的联想结果回来之后,下拉框要立刻更新,但更新的时候不要整个列表闪一下重排——正在被用户盯着的那几条如果突然换了位置,手指还没落下去、目标已经跑了。正确的做法是:新结果回来之后,先比对一下和当前列表的差异,只有确实变了的条目才更新,没变的条目保持原位;用户正在用方向键选中的那一条,在新结果回来之前不要动它的位置。这一条听起来琐碎,但它是“搜索框稳不稳”的分水岭:列表闪一下,用户可能没留意;列表闪一下的同时点错了,用户会记住很久。
九、上线前必须跑的检查清单
搜索框上线之前,把下面这张清单逐条跑一遍,跑完再发。第一,输入“红烧肉”三个字,只发一次请求——打开开发者工具的网络面板数一下,超过一次就回去查防抖。第二,快速输入“红烧”再补“肉”,最后显示的必须是“红烧肉”的结果,不是“红烧”的。第三,断网之后搜一次,页面要显示“网络不太好,检查一下再试试”,而不是一直转圈。第四,搜一个肯定没有的词,比如“红烧肉炒月饼”,空态里要有纠错建议和退路,不能只有一句“没有找到”。第五,只用键盘完成一次搜索:斜杠聚焦、打字、下方向键选一条、回车进结果,全程不碰鼠标。第六,用屏幕阅读器走一遍,确认候选条数能被正确播报。第七,在手机上用中文输入法打“红烧肉”,确认拼字过程中没有发请求、没有闪空态。
最后再说一句判断标准。搜索框做得好不好,不用看代码写了多少行,看三个数字:用户从打开搜索框到点进一条结果平均要几秒、搜不到之后继续试第二个词的用户占多少、因为搜索卡顿而流失的反馈还有没有。第一个数字越小越好,第二个越高越好,第三个最好是零。这三个数字往好的方向走,说明你的搜索框真的在帮用户省时间;反过来,功能堆得再多,也只是在给用户添堵。搜索框的终点不是“能搜”,是“搜得快、搜得准、搜不到也有路”。
评论 (0)
相关文章

图片是页面里最重的资源,也是最容易失控的:原生懒加载一行属性先顶九成场景,快进入视口提前 200 像素再接管、宽高比占位站住位置、模糊小图与主色填充、首屏图优先、失败兜底,以及上线前必须跑的七条检查。

Toast 看着不起眼,却站在用户最脆弱的时刻出现:删了东西、付了钱、传完文件。什么时候该弹、文案怎么写、显示多久、放哪里、撤销怎么做才真能撤销,以及上线前必须跑的五组测试。

“3 分钟前”还是“2026-11-06T02:53:33.198Z”?一张可直接抄的阈值表、服务端只存 UTC 时间戳的铁律、前端分档刷新、夏令时与多语言边界,以及上线前必须跑的五组测试。