后台列表页加搜索框这件事,我做了很多年,每次都觉得十分钟能收工,每次都能在新的地方翻车。最典型的一次是给一个商品列表加 HTML5 输入框关键字搜索,页面逻辑本身只有四十来行,结果验收时用拼音输入法打字,列表先跳了三次才停在正确结果上——输入框里还写着从没提交过的拼音串,列表已经按 z、zh、zho 各筛选了一遍。那一刻我才真正意识到,Vue 输入框关键字筛选列表数据展示这件事,难点从来不在filter()那一行,而在“输入”这件事本身有多脏:输入法、大小写、全半角、粘贴、防抖、请求竞态,任何一环没兜住,用户看到的就是一个抽风的搜索框。
这篇内容聊的就是怎么把这个看似玩具的功能做扎实。它适合刚接手第一个后台列表页的新手,也适合已经写过十几遍过滤逻辑、但总觉得哪里不对劲的老手。核心动作只有一个:一个输入框、一份列表数据、一条从“用户敲字”到“结果上屏”的完整链路。下面我会把链路上每一段的取舍讲清楚,包括为什么这么写、不这么写会出什么问题,以及几处只有真正上线后才会冒出来的细节。代码以 Vue 3 组合式 API 为主,Vue 2 的写法差异会单独点出来,两边都能用。
1. 一个搜索框真正要接管的是四件事,不是一件
1.1 输入状态、过滤变换、结果呈现、副作用反馈
很多人脑子里的模型是“输入框的值变了,就把列表 filter 一下”。这个模型在 demo 里成立,在真实页面里一定会缺胳膊少腿,因为它只覆盖了中间那一环。完整拆开看,一个搜索框至少接管四层职责,每一层都有自己的坑。
第一层是输入状态。这里存的不只是一个字符串,而是“用户正在打字的中间态”和“用户已经确认的终态”两个东西。输入法在组合阶段会不断吐出input事件,这些事件里的值大多是半成品拼音或候选前的字符,如果直接拿去当过滤条件,就会出现开头说的那种列表连跳。所以状态层要么加一个“组合中”的标志位,要么干脆维护两个变量:keyword存输入框原值,appliedKeyword存真正参与过滤的值,中间隔一个节流或一次确认动作。
第二层是过滤变换。变换不只是includes,还包括字段归一化(大小写、全半角、首尾空格)、多字段匹配、排序稳定性、以及“要不要在这一层做分页”。这几项的顺序会直接影响结果,比如先分页后过滤,就会出现“第一页筛出 0 条,第二页却有 3 条”的诡异现象。
第三层是结果呈现。空结果的时候页面长什么样、关键字要不要在列表里高亮、结果条数要不要显示、搜索后滚动条要不要回顶,这些都属于呈现层。它们不影响逻辑正确性,但直接影响用户“这个搜索框是不是坏了”的判断。我见过太多页面,搜索没结果时就是一片空白,用户以为加载失败,反复刷新。
第四层是副作用反馈。清空按钮、焦点保持、地址栏同步、埋点上报、请求取消,这些都挂在搜索动作的边上。它们最容易漏,因为开发时你自己手动输入、手动删除,一切正常;等用户打开页面直接粘贴一长串、再按回车,问题全出来了。
把这四层在心里分清楚,后面所有的写法取舍才有依据。比如“防抖该加在哪一层”这个问题,答案就明确了:它加在第一层和第二层之间,而不是加在filter()里面。
1.2 前端过滤和服务端检索的分界线在哪
第二个必须先做的决定是:这个搜索到底在前端算,还是发请求让后端算。这个决定做错了,后面写多优雅都没用。
我通常用一张表来判断,实际项目里比较准:
| 判断维度 | 倾向前端过滤 | 倾向服务端检索 |
|---|---|---|
| 数据总量 | 两千条以内,单条文本量不大 | 上万条,或单条含长文本字段 |
| 数据完整性 | 一次性接口已经把全量拉下来了 | 本来就在分页拉取 |
| 匹配复杂度 | 几个字段的字符串包含 | 需要分词、同义词、拼音、相关度排序 |
| 实时性要求 | 敲一下就出结果,零延迟体验 | 可以接受 200ms 以上延迟 |
| 权限与脱敏 | 数据已全部可见 | 部分字段不该下发到前端 |
经验阈值我说得更具体一点:列表总量在两千条以内、每条记录参与匹配的文本加起来不到几百字时,前端过滤的耗时代码里基本感知不到,随便怎么写都行。超过一万条,尤其用户还会连续快速输入时,纯前端过滤会开始掉帧,因为每次输入都要重新遍历、重新渲染整份列表。
中间那段灰色地带,比如三五千条,最实用的做法不是硬扛,而是前端过滤加渲染分页:过滤逻辑照旧在前端跑,但只渲染前五十条,配合“加载更多”。这样计算量还在可接受范围,渲染压力直接砍掉两个数量级。真到了后端检索那一档,前端要补的是另外两件事——请求竞态和状态同步,这个放在第 6 节细说。
2. 从输入框到结果列表的那条数据流,怎么搭才不出岔
2.1 为什么不要在 @input 里手动改数组
新手最常见的写法是这样:监听输入事件,在回调里拿list = list.filter(...)重新赋值。问题在于这里的list通常是接口返回的原始数据,你在它身上做过滤,第二次输入时过滤的就是“上一次过滤后的结果”,越筛越少,删掉关键字也回不来。
正确的心智模型是:原始数据只读,结果永远是新数组。用一个computed从原始数据推导出展示数据,这样关键字变化时自动重算,关键字清空时自然回到全集,中间不存在任何“污染源”。
const rawList = ref([]); // 接口原始数据,任何地方都不许改 const keyword = ref(''); // 输入框原值 const filteredList = computed(() => { const kw = keyword.value.trim().toLowerCase(); if (!kw) return rawList.value; return rawList.value.filter(item => item.name.toLowerCase().includes(kw)); });computed在这里不只是省几行代码,它带缓存:只要keyword和rawList没变,重复读取不会重新计算。列表页里模板可能多处引用这个结果(渲染列表、显示条数、判断空态),用computed只算一次,用方法调用就会算三次。
Vue 2 里要注意一个额外问题:数组的filter本身返回新数组,没问题,但如果你习惯用splice去原地改一个数组来“模拟过滤”,Vue 2 对索引赋值和长度修改的响应式是有缺口的,会出现数据变了视图不动。老老实实返回新数组,两个版本都不会踩这个坑。
还有一个细节值得单独提:computed里不要做副作用。我见过有人在高亮逻辑里顺手document.querySelector改 DOM,结果列表虚拟滚动一复用节点,高亮就串到别人身上了。所有渲染相关的东西交给模板,computed只负责算数据。
2.2 多字段匹配:把字段表抽出来,别硬写一堆 ||
真实列表几乎不会只按一个字段搜。用户输入“张”,他期望的是姓名、手机号、备注里任何一处含“张”都算命中。最直白的写法是:
item.name.includes(kw) || item.phone.includes(kw) || item.remark.includes(kw)三个字段还能忍,八个字段就开始难看了,而且后续加字段要改逻辑、要重新测。更稳的做法是把“参与搜索的字段”做成配置,过滤函数只负责遍历:
const SEARCH_FIELDS = ['name', 'phone', 'remark', 'code']; function matchItem(item, kw) { return SEARCH_FIELDS.some(field => { const v = item[field]; if (v == null) return false; return String(v).toLowerCase().includes(kw); }); }这样做有三个实际好处。第一,加字段只改数组,不动逻辑。第二,字段可能为null或数字这件事被统一兜住了——String(null)会变成字符串 "null",所以必须先判== null再转,否则搜“ll”会把所有空字段的记录都命中,这个 bug 我在两个项目里见过。第三,后面要做“加权排序”(比如姓名命中排在备注命中前面)时,只需要在这个函数里返回分数而不是布尔值,改动面很小。
顺带说一个字段优先级的问题。如果业务上确实关心“谁排在前面”,光靠filter保不住顺序,因为filter保留的是原始顺序。这时候要改成先map出分数、再sort:
const scored = rawList.value .map(item => ({ item, score: scoreOf(item, kw) })) .filter(x => x.score > 0) .sort((a, b) => b.score - a.score);注意sort在分数相同时的稳定性,现代浏览器的Array.prototype.sort是稳定的,但如果你手动写了return a.score - b.score之外的比较逻辑,要小心把相等的情况处理成 0,否则同分记录的顺序会随机跳,用户删一个字再打回去,列表顺序就变了,体验很怪。
2.3 归一化:大小写、全半角、多余空格这三件小事
toLowerCase()只能解决大小写。实际输入里还有一堆“看起来一样但字符不同”的东西,最典型的是全角空格\u3000(中文输入法下按空格经常吐出来)和全角字母数字。用户从文档里复制一个编号粘进来,前后带的很可能是全角空格,你trim()掉的是半角空格,匹配直接失败,用户会觉得“我明明复制对了”。
我一般的处理是在归一化函数里一次性清干净:
function normalize(str) { return String(str ?? '') .replace(/[\u3000\s]+/g, ' ') // 全角空格、制表、换行统一成半角空格 .trim() .toLowerCase(); }如果业务里编号、型号这类字段确实可能带全角字母,还要再加一步把全角转半角(\uFF01到\uFF5E的区间整体偏移)。这一步成本很低,但能省掉大量“为什么搜不到”的沟通。
另一个细节是多关键字的处理。用户输入“张三 北京”,是当成一个整体字符串匹配,还是拆成两个条件做“与”匹配?我倾向于后者,因为它更符合直觉,实现也不复杂:按空格切开,每个词都要命中(跨字段也算):
const words = normalize(kw).split(' ').filter(Boolean); return words.every(w => SEARCH_FIELDS.some(f => normalize(item[f]).includes(w)));这里有个性能提醒:normalize在每次比较时都被调用,列表一大就重复计算了。优化方式是在数据拿到之后预处理一遍,给每条记录加一个_searchText字段(把参与搜索的字段拼成一个已归一化的字符串),过滤时只对这个字段做includes。这个做法在几千条数据上能把耗时降到原来的几分之一,代码也更短。代价是多占一点内存,以及原始数据更新时要记得同步这个字段——这就是取舍,没有免费的性能。
3. 中文输入法:边打字边搜索最容易翻车的地方
3.1 composition 事件的真实触发顺序
这是标题里“HTML5 输入框”这个关键词最该展开的一段,因为它是 Web 输入和原生输入最大的差异点。中文、日文、韩文输入法在打字时都有一个“组合中”的阶段:你敲zhong,输入框里显示的是拼音,候选框弹出,直到你按空格或数字选中汉字,字符才真正落定。
在这个阶段,浏览器会持续触发compositionstart、input、compositionupdate,选中后再触发compositionend和一个最终的input。关键点在于:组合期间的input事件里的值,是半成品。如果你在这个事件里更新关键字,列表就会跟着拼音串一起跳。
更麻烦的是浏览器差异。有的浏览器在compositionend之后还会再补一个input,有的不会;Vue 的v-model在不同版本对组合事件的处理也不完全一致,Vue 3 内部已经做了compositionstart打标记、compositionend之后再同步的逻辑,但只要你用的是@input手动监听,这些保护就全没了。
我实测下来最稳的判断方式是用事件对象上的标志位,而不是自己维护一个布尔变量:
function onInput(e) { if (e.isComposing || e.target.composing) return; // 组合中,先不理 keyword.value = e.target.value; }e.isComposing是原生事件属性,Safari 老版本支持不好,所以要兼容性地再看一眼e.target.composing这个自定义标记。两手都判,基本覆盖所有主流环境。
3.2 一份可以直接抄的输入法安全写法
把上面这些拼起来,我现在的标准写法是这样,Vue 3 和 Vue 2 都能改一改就用:
<template> <div class="search-box"> <input ref="inputRef" :value="draft" placeholder="输入姓名、编号或备注" @compositionstart="composing = true" @compositionend="onCompositionEnd" @input="onInput" /> <button v-if="draft" @click="clear">清空</button> </div> </template> <script setup> import { ref, watch } from 'vue'; const draft = ref(''); // 输入框原值 const keyword = ref(''); // 真正参与过滤的值 const composing = ref(false); const inputRef = ref(null); function onInput(e) { draft.value = e.target.value; if (composing.value || e.isComposing) return; keyword.value = draft.value; } function onCompositionEnd(e) { composing.value = false; draft.value = e.target.value; keyword.value = e.target.value; // 组合结束,一次性提交 } function clear() { draft.value = ''; keyword.value = ''; inputRef.value?.focus(); // 清空后焦点不能丢 } </script>几个容易忽略的点。第一,draft和keyword分开存是这套写法的核心,模板里绑定的是draft,过滤用的是keyword,中间隔了一道闸门。第二,清空之后必须手动把焦点还回输入框,否则用户点一下清空按钮,光标就没了,得重新点一次才能继续打字——这个细节小到没人会在需求里写,但每个用户都会感觉到别扭。第三,如果你的搜索框外面还套了表单或者form元素,记得清空按钮的type写成button,不然它会触发提交,整个页面刷一下。
3.3 拼音首字母检索到底要不要做
中文场景下经常有人提“能不能打 zw 就搜到张伟”。这个功能体验确实好,但它的成本和你想象的不一样。
前端实现的主流做法是引一个拼音转换库,在数据加载时给每条记录预生成一份全拼和首字母串,塞进_searchText里,过滤时一起匹配。难点不在转换本身,而在多音字:姓氏里“曾”“单”“解”“查”这类字,库的默认读音大概率是错的,你会搜不到人。要修就得维护一张姓氏读音覆盖表,工作量一下子上来了。
我的建议是分场景。如果是内部后台、用户量几十上百、数据条数不多,做个基础版就够了,多音字错了影响也不大,甚至可以在文档里写一句“部分多音字可能需要输入全拼”。如果是对外的产品,尤其是可能被拿来搜人名的场景,要么不做,要么就把读音覆盖表当正经需求排期,别指望一个库开箱即用。
还有个折中方案:服务端检索时用数据库的拼音字段或者搜索引擎的分词能力,前端只管把关键字原样发过去。这样多音字、简繁、错别字都能在后端统一处理,前端代码反而最简单。这也是我前面说“分界线要早定”的原因之一——它决定了你要不要在前端背这套拼音逻辑。
4. 关键字高亮:v-html 好用,但有三处必须堵住
4.1 正则特殊字符不转义,用户输入一个点就能毁掉高亮
高亮的常见做法是把关键字拼成正则,替换成带<mark>的字符串。问题来了:用户输入的关键字里可能含.、*、(、[这些正则元字符。搜一个“1.5”,.在正则里匹配任意字符,整个列表全被标红;搜一个(,new RegExp直接抛异常,页面白屏。
所以第一步永远是转义:
function escapeReg(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }这一步不能省,也不能只在前端过滤那里省——过滤用的是includes所以没事,但高亮用的是RegExp,必须转。同一份关键字在两个地方用,处理方式不同,这是很容易漏的点。
另外多关键字的情况下要注意,拼出来的正则要加g和i标志,g是为了全局替换,i是为了大小写不敏感,跟过滤时的归一化保持一致。少了i,用户输小写能看到结果,但列表里一个字都不高亮,会让人以为高亮坏了。
4.2 先转义 HTML 再高亮,顺序反了就是漏洞
第二个坑更严重。如果列表里的字段是用户自己填的(备注、昵称、地址),里面可能带<script>或者<img onerror=...>。你把这些原始字符串塞进v-html,就等于把一个注入点交给了每个能填表单的人。
正确顺序是:先做 HTML 转义,再做高亮替换。
const ESC_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; const escapeHtml = s => String(s).replace(/[&<>"']/g, c => ESC_MAP[c]);但这里有个连带的细节,很多人转义完就发现高亮不灵了:如果关键字本身含<,你先转义了文本,文本里的<已经变成<,而关键字还是原始的<,两边对不上,永远匹配不到。所以关键字也必须走一遍同样的转义,才能保证在转义后的文本里定位。
还有个更隐蔽的问题:如果关键字恰好是amp,转义后的文本里&中的amp会被匹配并加上<mark>,结果 HTML 实体被打断,页面上显示出一堆乱码。这类问题在小概率下才出现,测试根本测不到。
4.3 更稳的路子:切片段渲染,彻底不生成 HTML
被上面这些问题折腾几次之后,我现在的默认方案是放弃v-html,改成把字符串切成“命中段”和“非命中段”的数组,模板里用普通的插值渲染。这样压根不存在 HTML 拼接,XSS 和实体打断的问题一起消失。
function highlightSegments(text, kw) { const src = String(text ?? ''); const words = kw.trim().split(/\s+/).filter(Boolean).map(escapeReg); if (!words.length) return [{ text: src, hit: false }]; const re = new RegExp(`(${words.join('|')})`, 'gi'); const out = []; let last = 0; for (const m of src.matchAll(re)) { // 防御:空匹配会让循环卡死 if (m[0] === '') { re.lastIndex++; continue; } if (m.index > last) out.push({ text: src.slice(last, m.index), hit: false }); out.push({ text: m[0], hit: true }); last = m.index + m[0].length; } if (last < src.length) out.push({ text: src.slice(last), hit: false }); return out; }模板侧就是一段v-for,hit为真时包一个<mark>,否则直接输出文本。Vue 的插值默认会转义,安全性是白送的。
<template> <span> <template v-for="(seg, i) in segments" :key="i"> <mark v-if="seg.hit">{{ seg.text }}</mark> <span v-else>{{ seg.text }}</span> </template> </span> </template>代价是每行文本都要算一次切片,几千条同时渲染会有开销。实际做法是只对当前可见的行做高亮——配合分页或者虚拟滚动,可见行撑死几十条,完全不是问题。这也是为什么我一直说“渲染量”比“数据量”更值得关注。
5. 数据量涨上来之后的取舍:防抖、虚拟滚动与服务端检索
5.1 防抖参数到底定多少
防抖的直觉是“少算几次”,但参数定错了,体验会比不防抖更差。定太短(比如 50ms)等于没防;定太长(比如 800ms),用户打完字盯着屏幕等结果,会觉得卡。
我实测下来,纯前端过滤的场景,150 到 200ms 是比较舒服的区间,因为它刚好能跨过两次按键的间隔,又快到让人觉得是即时的。如果过滤本身很轻(两千条以内、只匹配一个字段),干脆不防抖也行,computed的重算开销比定时器还小。
如果是发请求的服务端检索,参数就得拉长到 300ms 左右,因为要算上网络往返。更讲究一点的做法是“防抖 + 最小请求间隔”:防抖负责合并连续输入,最小间隔负责防止用户慢慢打字时每一击都发一个请求。这两个逻辑叠在一起,代码会多几行,但对后端的压力能降一个量级。
还有一个常被忽略的场景:回车键。用户习惯在输入框里按回车“确认搜索”。这时候应该绕过防抖立即执行,而不是也等 300ms。实现上加一个@keyup.enter,里面先clearTimeout再直接触发。同理,清空按钮也应该立即生效,让用户马上看到完整列表。
5.2 一万条数据在前端过滤,卡在哪个环节
很多人以为是filter慢,其实不是。一万条数据的filter,在现代浏览器上通常只花个位数毫秒,真正慢的是两件事:字符串归一化重复计算和DOM 渲染。
第一件事前面提过,解决办法是预处理_searchText。第二件事才是大头:一万条列表,就算过滤后只剩五十条,如果模板里每一行还有七八个元素、带图标带标签,渲染一次就是几百个节点的增删。你连续打字,每 150ms 重建一次 DOM,掉帧是必然的。
所以优化顺序应该是:先减少渲染量,再优化计算量。具体做法是过滤结果直接分页,或者上虚拟滚动。虚拟滚动只渲染可视区那十几行,滚动时动态换数据,配合过滤使用时要注意一个细节——过滤条件变化后必须把滚动位置重置到顶部,否则新结果集只有五条,而滚动容器还停在第 800 行,用户看到的是空白。
5.3 虚拟列表和关键字高亮放在一起的注意事项
虚拟滚动和高亮组合时,最容易出的问题是“复用节点导致高亮错位”。原因是虚拟列表会复用 DOM 节点,只改内容。如果你的高亮逻辑依赖某些节点上的状态标记(比如给匹配的元素加个 class),复用之后老状态会残留到新行上。
规避方式是让渲染完全由数据驱动:每一行的 key 用记录的唯一 ID,而不是索引;高亮用第 4 节那种切片渲染,纯靠数据计算,不碰 DOM。这两个约束守住,虚拟列表换得再快也不会串。
还有一个实际经验:给列表项加v-memo(Vue 3.2+)能进一步减少无用更新,但前提是依赖数组写对。我一般只在确认有性能问题之后才加,因为写错依赖导致“数据变了视图不更新”的排查成本,远高于它带来的那点收益。先上分页,再做虚拟滚动,最后才考虑细粒度的更新控制,这个顺序别乱。
6. 改成服务端检索后,必须补上的两件事
6.1 请求竞态:搜 A 显示 B 的结果
只要搜索变成发请求,竞态就一定会出现。场景很具体:用户先输入“北京”,请求 A 发出;紧接着改成“上海”,请求 B 发出。如果 A 的网络慢一点,B 先回来了,页面先渲染上海的结果,然后 A 回来了,把列表覆盖成北京的结果。用户看着输入框里的“上海”,列表里全是北京的记录,会认为搜索彻底坏了。
这个问题的根源是“响应回来的顺序不确定”,所以修复思路只有两个:要么让旧请求作废,要么只认最新一次请求的结果。
第一种用AbortController,发新请求前把上一个取消掉:
let controller = null; async function fetchList(kw) { controller?.abort(); controller = new AbortController(); const res = await fetch(`/api/list?kw=${encodeURIComponent(kw)}`, { signal: controller.signal, }); return res.json(); }注意取消会抛出一个AbortError,catch里要把它单独放过,不能当成真的失败弹提示,否则用户快速打字时会看到一串错误飘窗。
第二种更通用,也不依赖fetch的实现,用一个自增序号,回来时对一下号:
let seq = 0; async function fetchList(kw) { const my = ++seq; const data = await request(kw); if (my !== seq) return; // 已经有更新的请求了,这次结果丢弃 list.value = data; }两种都行,我一般两个一起用:abort省流量,序号兜底。项目里如果用的是老式封装的请求库,abort未必支持,序号那套是一定能用的。
6.2 把关键字同步到地址栏,刷新和回退都不丢
搜索状态只存在内存里,会遇到两个很烦的场景:用户筛好列表点进详情,浏览器回退,过滤条件没了;用户把链接发给同事,对方打开看到的是全量列表,得重新输一遍。这类问题在后台系统里被投诉的频率相当高。
解决办法是把关键字同步到路由参数里,用替换而不是新增历史记录:
watch(keyword, (val) => { router.replace({ query: { ...route.query, kw: val || undefined } }); });这里必须用replace,用push会让每次输入都进一条历史,用户按一次返回键只删掉一个字,体验灾难。取值为空时传undefined,路由库会自动把参数去掉,地址栏干净。
初始化的时候反向读一次:
const keyword = ref(String(route.query.kw || ''));用keep-alive缓存列表页的项目还要多考虑一步:组件被缓存后,onMounted不会再次执行,用户从详情返回,关键字还在、但列表数据可能是旧的。这时候在onActivated里判断是否需要重新拉数据,比在onMounted里写逻辑要靠谱得多。我踩过这个坑——在onMounted里读路由参数,结果缓存命中后关键字变了,列表却还是第一次进页面时的那份。
7. 上线之后才会冒出来的几个细节
7.1 空状态提示要带上下文,别只写“暂无数据”
列表筛出 0 条的时候,直接显示“暂无数据”是最省事也最差的做法,因为用户分不清是“本来就没数据”还是“搜索没匹配到”。这两种情况的下一步动作完全不同:前者要去新建,后者要改关键字。
我现在统一写成带上下文的提示,并且给一个“清空筛选”的按钮:
<div v-if="!filteredList.length" class="empty"> <p v-if="keyword">没有找到与“{{ keyword }}”相关的内容</p> <p v-else>这里还没有数据</p> <button v-if="keyword" @click="clear">清空关键字</button> </div>顺带把结果条数显示出来,“共 37 条”,筛选后变成“筛选出 3 条 / 共 37 条”。这个小信息能极大缓解用户的困惑,成本几乎为零,我建议每个搜索框都配上。
7.2 那些测试用例覆盖不到的输入
最后列一批我实际被用户“喂”过的输入,都是正常测试很难想到的:
- 粘贴带换行:从 Excel 复制一列编号粘进来,值里带
\n,页面上看不出来,匹配全失败。归一化时统一替换成空格,问题就没了。 - 首尾空格:用户复制时习惯性多带一个空格,看起来完全一样,就是不匹配。
trim()必须做,而且要放在归一化的第一步。 - 搜纯数字编号:如果编号字段在数据里是
Number类型,includes会报错,必须先String()。而前导零的情况更麻烦,0012用数字存就变成12,搜00永远搜不到。这类字段应该在数据源头就用字符串。 - 搜符号:
+、#、&这些在高亮正则里都有特殊含义,第 4 节的正则转义必须覆盖全。 - 超长关键字:用户误粘贴一大段文字,长度上千。这个时候正则会变得很慢,加个长度上限截断(比如 100 字符)是性价比最高的防御。
- 连续快速输入后立刻点清空:如果清空只改了
keyword没改draft,输入框里的字还在,但列表已经是全量了,看起来像搜索失效。两个变量必须一起清,这一点在第 3 节那套写法里已经内置了。
我个人在实际项目里的体会是,搜索框这个功能的代码量永远很小,出问题的永远是边上那些“顺手就能加”的东西:焦点、空态、地址栏、请求取消。每次接手新的列表页,我会先花五分钟把这几个边角列一遍,再动手写那行filter。经验告诉我,把这几处提前想清楚,比事后一遍遍改要省太多事了。