上周帮一个朋友看他后台里的一个列表页,两千多条记录,搜索框里敲一个字卡一下,退格删一个字又卡一下,他怀疑是框架本身不行。我把代码拉下来看了三分钟,问题跟框架没关系:他在watch里手动改一份filteredList,模板里又套了一层方法调用,filter回调里还现编译正则。这套组合拳下来,别说两千条,两百条都能给你卡出幻灯片的效果。
这个场景说穿了就是一句话:Vue input 输入框关键字筛选检索列表数据展示。它看起来是最没有技术含量的需求,任何入门教程都会告诉你computed配filter就完事了。但我做过不下十个类似的东西,从几十条的城市选择器,到几万条的日志检索面板,真要把它做扎实,牵扯的细节比想象中多得多:HTML5 输入框关键字搜索的原生行为(type="search"的清除按钮、回车键、移动端键盘的搜索键)、中文输入法的组合事件、防抖到底定多少毫秒、多字段怎么组织匹配、关键字高亮怎么不踩 XSS、数据量上万之后过滤和渲染到底谁才是瓶颈。
这篇东西写给已经能写 Vue、但想把一个搜索框做到“顺手”的人。刚入门的朋友也能看,我会把每一步为什么这么写讲清楚;做过几轮的同学可以重点看防抖计算、竞态处理和性能那几节,那几块是最容易埋雷的地方。
1. 需求拆解:一个“能用的搜索框”到底要满足什么
1.1 从标题里拆出来的四层需求
很多人写搜索框,脑子里只有“输入 → 过滤 → 显示”这一条线,写完发现用户输错一个字就白屏、清空之后列表回不来、切到别的页面再回来关键字没了。原因是从一开始就没把需求拆全。我习惯把它拆成四层,一层一层往上垒。
第一层是输入层,也就是那个input元素本身。它要负责接收字符、响应回车、响应清除、适配移动端键盘、处理输入法的组合态。第二层是数据层,把用户输入变成一次可执行的匹配逻辑,决定匹配哪些字段、是否忽略大小写、多个关键词之间是“且”还是“或”。第三层是渲染层,把过滤结果画到页面上,这里才是真正吃性能的地方,也是绝大多数卡顿的元凶。第四层是反馈层,包括结果条数提示、无匹配时的空状态、加载中的骨架、命中的关键字高亮。
这四层分开之后你会发现,每一层的技术手段完全不同:输入层是 DOM 事件和浏览器行为,数据层是纯函数,渲染层是列表虚拟化,反馈层是交互设计。混在一起写,代码很快就变成一坨;分开写,每一层都能单独测试、单独替换。我后来的项目里基本都会把过滤逻辑抽成一个独立的useFilter或者纯函数文件,组件只负责绑定和展示,这样调起来非常省事。
1.2 为什么我坚持用 computed 而不是 watch
这是最容易被写错的一处。新手常见写法是监听输入值,在回调里手动赋值一个新的数组:
// 不推荐的写法 watch(keyword, (val) => { filteredList.value = allList.value.filter(item => item.name.includes(val) ) })这段代码能跑,但它有三个隐性成本。第一,watch是命令式的,你手动维护了一个派生状态filteredList,它和allList、keyword之间的一致性全靠你自己保证,一旦哪天在别处改了allList忘了同步,bug 就来了。第二,watch默认是异步的(在微任务队列里刷),意味着输入事件触发的同一帧里,模板可能先渲染了旧数据再渲染新数据,肉眼看不出来,但配合动画或者滚动位置恢复时会出现闪一下的现象。第三,没有缓存,同一份数据在别的地方再算一次,你就得再写一遍逻辑。
computed的本质是带依赖追踪的惰性求值加缓存。它自己知道依赖了allList和keyword,两者只要有一个变,下次读取时重算;没变就直接拿缓存。模板里读一次、脚本里读一次、别的computed里再读一次,都是同一个结果。代码也短:
const filteredList = computed(() => { const kw = keyword.value.trim().toLowerCase() if (!kw) return allList.value return allList.value.filter(item => item._search.includes(kw)) })这里顺手加了一个短路:关键字为空时直接返回原数组,不做任何遍历。这个细节在几千条数据上省下来的时间可以忽略,但在几万条上就是实打实的几十毫秒,而且它保证了一个语义——空输入等于不过滤,而不是“匹配所有空字符串”,后者在某些实现里会给空字符串做一次全量遍历。
1.3 HTML5 原生能力和 Vue 的边界
input这个标签本身带了一堆被忽略的能力,用好了能省不少代码。最典型的是type="search":桌面端 Chrome 和 Safari 会在输入框右侧渲染一个原生的清除按钮(::-webkit-search-cancel-button),移动端键盘会从“换行”变成“搜索”。代价是这个按钮的样式在部分浏览器里没法完全自定义,如果你追求视觉统一,就得自己画一个清除图标然后把它隐藏掉:
input[type="search"]::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; }另一个常被忽略的是enterkeyhint,它告诉移动端键盘右下角的键显示成“搜索”“前往”“完成”还是“发送”。搜索场景下写enterkeyhint="search",用户的心理预期会明确很多。还有autocomplete="off",在搜索框上我一般是关掉的,因为浏览器记住的历史下拉会遮住你自己的联想列表,两个浮层打架的体验非常糟糕。
Vue 这一侧的边界要理清楚:v-model帮你做了值的双向绑定,并且在内部处理了输入法的组合态(下面会细讲),但它不处理防抖、不处理竞态、不处理 URL 同步。这些都得自己补。别指望加个.lazy修饰符就万事大吉,.lazy只是把监听的事件从input换成change,触发时机变成失焦或回车,搜索框里用户敲着敲着列表不动,会觉得卡了,体验反而更差。
2. 核心实现:从最小可用到生产可用
2.1 数据模型与可检索字段的预处理
先说数据结构。假设列表里的每一项长这样:
const rawList = [ { id: 1001, name: '华北区域订单', owner: 'A同学', tags: ['加急', '大客户'], status: 1 }, { id: 1002, name: '华南区域订单', owner: 'B同学', tags: ['常规'], status: 2 } ]如果每次过滤都对name、owner、tags挨个做toLowerCase().includes(),字段一多就是乘法级的开销。我习惯在拿到数据的第一时间做一次预处理,给每项拼一个用于检索的隐藏字符串:
function buildIndex(list) { return list.map(item => ({ ...item, _search: [ item.name, item.owner, (item.tags || []).join(' '), String(item.id) ] .filter(Boolean) .join(' ') .toLowerCase() })) }这里有几个讲究。第一,String(item.id)一定要加,用户搜订单号是极高频的操作,而数字类型的includes会直接报错。第二,filter(Boolean)是为了过滤掉null、undefined、空字符串,避免拼出多余的空格导致某些边界匹配失败。第三,字段之间用空格连接,这样多关键词搜索时可以直接按空格切分去匹配,逻辑最顺。
为什么不把拼接放在computed里做?因为computed每次重算都会重新拼,而这份数据其实只在rawList变化时才需要重建。把它做成一个独立的computed,检索索引和过滤结果各自缓存,重复计算的浪费就没了。数据量大的时候这一点差别很明显,一万条乘五个字段,每次过滤都要拼五万次字符串,那是几百毫秒量级的浪费。
2.2 绑定方式的取舍:.trim、.lazy和手动 trim
v-model的修饰符里,.trim是最容易被误用的一个。它的行为是在赋值时对值做 trim,这意味着用户输入订单(末尾带空格)后想接着输入下一个词,那个空格会被立刻吃掉,光标位置也会跟着跳,体验很别扭。我的做法是绑定时不加修饰符,把 trim 放到过滤函数里:
<input v-model="keyword" type="search" enterkeyhint="search" autocomplete="off" placeholder="搜索名称、负责人或订单号" aria-label="列表关键字搜索" />const filteredList = computed(() => { const kw = keyword.value.trim().toLowerCase() // ... })这样用户随便敲空格,过滤时统一处理,输入过程完全不被打断。同理,.number在搜索框上绝对不要用,用户输入1e或者-的时候会被parseFloat变成null,然后你就要面对一个诡异的问题:输入框里明明有内容,keyword却是空的。
.lazy前面提过,只在服务端搜索且请求很贵的时候考虑,而且最好配合明确的“搜索”按钮或者回车触发,让用户知道什么时候会发请求。纯前端过滤的场景下,用.lazy基本等于自废武功。
2.3 多关键词匹配:AND 逻辑才是符合直觉的
用户在搜索框里敲“华北 加急”的时候,他脑子里想的是“既属于华北、又标了加急”。这是 AND 语义。如果你按includes('华北 加急')整串去匹配,几乎永远匹配不到,用户会以为搜索坏了。所以必须切词:
const filteredList = computed(() => { const terms = keyword.value.trim().toLowerCase().split(/\s+/).filter(Boolean) if (terms.length === 0) return indexedList.value return indexedList.value.filter(item => terms.every(t => item._search.includes(t)) ) })split(/\s+/)而不是split(' '),是因为用户很可能从别处粘贴一段文本,里面混着制表符和全角空格。\s在 JavaScript 里能匹配全角空格\u3000吗?答案是不能,\s匹配的是\u3000吗——实际上\s的定义里包含\u3000(Unicode 空格分隔符),所以能覆盖。但如果数据里混了不间断空格\u00A0,\s也覆盖到了。真正需要额外处理的是全角字母数字,比如用户输入全角“ABC”,toLowerCase不会把它变成半角,匹配就会失败。稳妥的做法是对关键词做一次normalize('NFKC'):
const normalize = (s) => String(s).normalize('NFKC').toLowerCase()NFKC会把全角字符归一化成半角,同时处理一部分兼容字符。这个函数对中文没有副作用,可以放心用。我在索引构建和关键词处理两边都调用它,保证比对的是同一套形式。
至于 OR 逻辑(任一关键词命中即可),实现上就是把every换成some。什么时候用 OR?当用户明确知道自己在做“宽泛捞取”的时候,比如日志检索。列表筛选这种场景,AND 更符合预期,因为它是在逐步收敛范围。
2.4 关键字高亮:不要图省事上 v-html
高亮是搜索框的“加分项”,但也是一个典型的 XSS 入口。网上流传最广的写法是用v-html塞一段替换过<mark>的字符串:
// 危险写法,不要用 const html = item.name.replace(new RegExp(kw, 'gi'), `<mark>${kw}</mark>`)这里至少有两个坑。第一,kw里如果带正则元字符(.、*、(、[),new RegExp会直接抛错或者匹配出莫名其妙的结果,用户搜个(页面就炸了。第二,如果item.name本身含有用户可控的内容(比如用户自己填的备注名),v-html会把其中的标签原样渲染出来,这就是一个存储型 XSS。
我的做法是压根不用v-html,让过滤结果直接产出分段数组,模板里用v-for渲染:
function splitByKeyword(text, terms) { if (!terms.length) return [{ text, hit: false }] const escaped = terms .filter(Boolean) .map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) if (!escaped.length) return [{ text, hit: false }] const re = new RegExp(`(${escaped.join('|')})`, 'gi') return String(text) .split(re) .filter(s => s !== '') .map(s => ({ text: s, hit: re.test(s) && escaped.some(e => new RegExp(`^${e}$`, 'i').test(s)) })) }这段代码里replace(/[.*+?^${}()|[\]\\]/g, '\\$&')是标准的正则转义,必须加,否则用户输入一个点号就能让你的匹配逻辑失控。split带捕获组时,分隔符本身会保留在结果数组里,正好用来判断哪一段是命中项。模板侧长这样:
<span v-for="(seg, i) in splitByKeyword(item.name, terms)" :key="i"> <mark v-if="seg.hit">{{ seg.text }}</mark> <template v-else>{{ seg.text }}</template> </span>全程走文本插值,Vue 会自动转义,XSS 的口子直接堵死。代价是每次渲染都要跑一次分段计算,所以我会把它放在列表项组件里配合computed,或者在数据量大时预先算好只对可见行计算。后面讲虚拟列表的时候会再提这一点。
3. 输入体验:防抖、输入法与竞态
3.1 防抖时间到底定多少毫秒
防抖的数值不是拍脑袋定的,它跟人的输入节奏直接相关。我拿自己和小范围样本测过:英文连续输入时,相邻两次按键的间隔中位数在 120–180ms 之间,快的时候能到 80ms;中文拼音输入因为要看候选词,间隔会更长,平均在 200–400ms。这意味着如果你把防抖设成 100ms,英文输入时几乎每次按键都会触发一次过滤,等于没防抖;设成 500ms,用户输完一个词要等半秒列表才动,会觉得“卡”。
我的取值区间是250–300ms,默认用 300。这个数值覆盖了绝大多数正常输入间隔,同时延迟在人的感知阈值(大约 100ms 内算“即时”,300ms 内算“流畅”)边缘,实测下来最不容易被抱怨。另外有一类场景要单独处理:短列表(两百条以内)且过滤成本极低时,我干脆不防抖,直接每次输入都算,因为额外的 300ms 延迟带来的体验损失大于计算节省,用户会觉得搜索框“反应慢”而不是“算得快”。
代码上我不用 lodash 的debounce去包computed,因为computed是惰性的,包了也没意义。正确的位置是在输入层做一层“延迟写入”:
import { ref, watch } from 'vue' const keyword = ref('') // 输入框直接绑这个 const searchKey = ref('') // 真正参与过滤的 let timer = null watch(keyword, (val) => { clearTimeout(timer) timer = setTimeout(() => { searchKey.value = val }, 300) })然后用searchKey去做computed过滤。这样输入框的显示永远是实时的(用户打字有反馈),只有过滤这一步被节流。千万别反过来——把防抖加在v-model上,那会导致输入框本身回显延迟,用户打字像在泥里走,这是最糟糕的体验。
3.2 中文输入法:Vue 帮你挡了一半,剩下一半得自己挡
中文用户在这个场景里踩的坑最多。用拼音输入“华北”的过程中,输入框里会依次出现h、hu、hua、huab……直到选词确认才落成汉字。如果每一次中间态都触发过滤,列表会疯狂闪烁,而且这些字母根本匹配不到任何中文数据,结果就是“边打字边白屏”。
好消息是,用v-model时 Vue 内部已经处理了compositionstart/compositionend,组合期间不会把中间态写进绑定值。坏消息是,只要你绕开了v-model自己监听事件,这个保护就没了:
<!-- 有问题的写法 --> <input @input="e => keyword = e.target.value" />正确做法是显式判断组合态:
let composing = false function onCompositionStart() { composing = true } function onCompositionEnd(e) { composing = false keyword.value = e.target.value } function onInput(e) { if (composing) return keyword.value = e.target.value }还有一个更隐蔽的坑:回车键。用户在拼音输入时按回车是“选第一个候选词”,不是“确认搜索”。如果你绑了@keydown.enter去触发搜索,中文用户选词的时候会误触发一堆请求。解决办法有两个,一是用@keyup.enter(选词的回车通常不会传到 keyup),二是判断原生事件的isComposing:
function onEnter(e) { if (e.isComposing) return doSearch() }isComposing这个属性在现代浏览器里支持度很好,我现在的代码里基本都用它做守卫,比靠keyup兜底更可靠。
3.3 服务端搜索的竞态:慢请求会覆盖快请求
前端全量过滤不存在竞态问题,但只要搜索走了接口,就必须处理。典型现象:用户输入a发出请求 A,500ms 后又输入ab发出请求 B,结果 A 比 B 晚回来,列表被 A 的结果覆盖,用户看到的永远慢一拍。
两种解法,我一般两个都用。第一种是请求序号,每次发请求自增一个版本号,回来时比对:
let seq = 0 async function fetchList(kw) { const current = ++seq loading.value = true try { const res = await api.search(kw) if (current !== seq) return // 过期结果,直接丢弃 list.value = res.data } finally { if (current === seq) loading.value = false } }第二种是AbortController,主动取消上一个请求,省流量也省服务端压力:
let controller = null async function fetchList(kw) { controller?.abort() controller = new AbortController() try { const res = await api.search(kw, { signal: controller.signal }) list.value = res.data } catch (err) { if (err.name === 'AbortError') return throw err } }注意loading状态的收尾要和序号判断绑在一起,否则被丢弃的旧请求在finally里把loading置回false,会出现“明明还在加载,转圈却消失了”的诡异现象。这个 bug 我在两个项目里都见过,排查起来很费时间,因为表现不稳定,取决于请求返回顺序。
3.4 URL 同步:刷新之后关键字还在
这个需求提的人不多,但只要用户用过一次就会依赖。实现上就是读写路由 query:
const route = useRoute() const router = useRouter() // 初始化时从 URL 还原 const keyword = ref(route.query.q?.toString() || '') // 搜索值变化时写回,用 replace 避免污染历史栈 watch(searchKey, (val) => { router.replace({ query: { ...route.query, q: val || undefined } }) })两个细节。第一,用replace而不是push,否则用户输五个字按三次返回还在这个页面打转。第二,值为空时把键设成undefined,这样 URL 上不会挂一个丑丑的?q=。如果项目里没上路由,用history.replaceState也行,但要记得监听popstate把值同步回组件。
配合<keep-alive>的时候还要额外注意:页面被缓存后onMounted不会再执行,从详情页返回时搜索状态靠的是组件实例没销毁。这时候 URL 还原只在首次进入时生效,两者不冲突,但如果你在onActivated里又做了一次初始化,就会把用户刚输的关键字冲掉。这个坑很典型,判断依据就是“初始化只做一次”。
4. 性能优化:数据量上到万级之后
4.1 先算一笔账,别凭感觉优化
在动手优化之前,我习惯先估一下量级,不然很容易优化错方向。字符串includes的单次开销在几十纳秒量级,我们按 50ns 算:
| 数据量 | 检索字段数 | 比较次数 | 纯计算耗时 | 渲染 1000 行 DOM 耗时 |
|---|---|---|---|---|
| 1,000 | 5 | 5,000 | 约 0.3ms | 约 20–40ms |
| 10,000 | 5 | 50,000 | 约 3ms | 约 20–40ms |
| 100,000 | 5 | 500,000 | 约 25–50ms | 约 20–40ms |
结论很清楚:一万条以内,计算根本不是瓶颈,渲染才是。一次过滤三五毫秒,人眼完全无感;但如果你把一万条结果一股脑塞进 DOM,浏览器光布局和绘制就能给你 200ms 以上的卡顿。所以优化顺序应该是先砍渲染量,再谈计算优化。这个顺序搞反的人特别多,我见过花两天时间把手写循环优化成某种“更快”的写法,结果渲染那边一屏还是铺两千个节点,白忙。
4.2 索引复用与结果缓存
虽然计算不是主矛盾,但该做的还是做。前面 2.1 节的_search预拼接就是最有效的一招,它把“五次toLowerCase加五次字段访问”压成了“一次字符串包含”。再往上还有两个手段。
一是缓存最近几次关键字的过滤结果。用户退格的时候经常回到上一个关键字,比如订单→订单a→订单,如果缓存了订单的结果,第三次直接命中。用一个Map存,容量限制在 10–20 条,超出就删最早的一条:
const cache = new Map() function getFiltered(kw) { if (cache.has(kw)) return cache.get(kw) const result = indexedList.value.filter(it => it._search.includes(kw)) if (cache.size >= 20) cache.delete(cache.keys().next().value) cache.set(kw, result) return result }二是避免在filter回调里创建对象或正则。new RegExp的开销比includes高一个数量级,如果匹配逻辑能用includes表达,就别上正则。我见过有人在回调里为每个 item 现编译一次正则,一万条就是一万次编译,那个耗时能到几百毫秒。
顺手提一句测量方法,别猜,直接打点:
console.time('filter') const result = doFilter(kw) console.timeEnd('filter')然后在 DevTools 的 Performance 面板里录一段输入过程,看主线程上到底是filter占了大头还是Recalculate Style/Layout占了大头。这个判断直接决定你该往哪个方向优化。
4.3 渲染侧的三板斧:分页、虚拟列表、结果上限
渲染优化的手段按实施成本从低到高排,我一般是这么走的。
第一招,结果上限。搜索场景下用户不会翻到第 500 条,通常看前 20 条就够。直接截断:filteredList.slice(0, 100),底下加一行“仅展示前 100 条,请细化关键词”。这一招零成本,能挡住 80% 的问题。
第二招,分页或“加载更多”。适合需要浏览而不是精确检索的场景。注意分页时有个细节:关键字一变,页码必须重置到第一页,否则用户在第 8 页搜一个新词,结果只有 2 页,他会看到一个空白列表,以为搜不到东西。
第三招,虚拟滚动。真正要展示上万条的时候才上。原理是只渲染视口内的十几行,用一个撑高的占位元素模拟滚动条。手写也不算复杂,核心是三件事:固定行高、根据scrollTop算起始索引、用transform或padding撑开容器。用现成的库也行,但要注意它们通常要求行高固定或可测量,如果你的列表项高度不固定(比如带标签换行),需要额外处理测量逻辑,成本会上去。
const ITEM_HEIGHT = 48 const VISIBLE_COUNT = 20 const startIndex = computed(() => Math.max(0, Math.floor(scrollTop.value / ITEM_HEIGHT) - 2) ) const visibleList = computed(() => filteredList.value.slice(startIndex.value, startIndex.value + VISIBLE_COUNT + 4) )上下各多渲染两行做缓冲,滚动时才不会露白。这个缓冲行数是经验值,滚得快的时候可以加到 4–6 行。
4.4 Web Worker 的适用边界
很多人一听“大数据量过滤”就想到丢进 Worker,但 Worker 有个隐形成本:postMessage传数据要经过结构化克隆,几万条对象序列化一遍的耗时可能比你省下来的计算还多。我的判断标准是:数据量超过 5 万条、过滤逻辑重(比如带正则或拼音匹配)、且数据本身不常变,这三条同时满足才考虑。而且要用 Transferable 或者干脆只在 Worker 里存索引副本,主线程只传关键词进去,回传匹配的 ID 数组,避免来回搬大对象。
多数后台列表的场景根本到不了这个量级。真到了,更该考虑的是把搜索挪到服务端,让数据库去干这件事,而不是在前端硬扛。前端扛十万条,内存占用和首屏解析时间都是问题,那个成本比加一个接口高多了。
5. 常见问题与排查实录
5.1 问题速查表
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 输入时列表疯狂闪烁 | 未加防抖,或防抖加在了v-model上 | 防抖放在过滤值上,不放在绑定值上 |
| 中文输入过程中结果乱跳 | 绕开v-model自己监听input | 加compositionstart/end守卫 |
| 搜特殊符号页面报错 | 正则未转义 | `replace(/[.*+?^${}() |
| 搜数字编号搜不到 | 字段是 Number 类型 | 建索引时String(id) |
| 全角字母搜不到 | 未做字符归一化 | 用normalize('NFKC') |
| 清空输入后列表空着 | 短路逻辑写成了返回空数组 | 空关键字直接返回原数组引用 |
| 搜索后分页还是停留在第 8 页 | 页码未随关键字重置 | watch(searchKey, () => page.value = 1) |
| 服务端搜索偶尔显示旧结果 | 请求竞态 | 序号比对或AbortController |
| 高亮处出现奇怪标签 | 用了v-html | 改为分段数组 + 文本插值 |
| 从详情页返回搜索条件丢了 | 组件被销毁,未做状态持久化 | URL query 或keep-alive |
5.2 几个容易被忽略的细节
空状态要分两种。原始数据为空和“有数据但没匹配到”是两回事,文案也不该一样。前者说“暂无数据”,后者说“没有找到包含 XXX 的记录,换个关键词试试”,最好把用户输入的关键字回显出来,让他确认自己没打错字。这个小小的回显能减少很多“搜索坏了”的误报。
结果数量提示要有,但别用aria-live播报每一次变化。屏幕上显示“共 128 条”是好的,但如果你给这个节点加了aria-live="polite",读屏软件会在用户每敲一个字后念一遍数字,体验是灾难。正确的做法是节流后再播报,或者只在用户按回车确认时播报一次。
输入框的清除行为要和状态对齐。点击type="search"的原生清除按钮时,浏览器会触发search事件,有些实现里还会触发一次input。如果你的清除逻辑同时挂了按钮点击和input监听,可能会出现值被清两次、或者清完又被旧值覆盖的情况。稳妥的做法是只认一个来源:要么全部走v-model的绑定值变化,要么在search事件里统一处理,别混着来。
排序稳定性。过滤用的是Array.prototype.filter,它保持原顺序,这点没问题。但如果你在结果上又叠了一层排序(比如按状态优先),要确认排序算法是稳定的,否则相同状态的项在每次输入后位置都会跳,用户会看得眼花。现代浏览器的sort已经稳定了,但如果你的排序逻辑里混了随机或异步,就得自己保证。
别在filter里做副作用。我见过在回调里累加统计值、改标志位的写法,一旦computed因为别的依赖被重算,那些副作用就会重复执行,统计值翻倍。所有统计都应该是独立computed,从结果数组上派生出来。
移动端的键盘遮挡。搜索框在页面顶部时问题不大,但如果在底部固定栏,软键盘弹起会把它顶上去或者遮住。可以在聚焦时判断visualViewport高度,或者干脆把搜索框放在列表上方而不是固定在底部。这个属于体验细节,但用户投诉率高。
我个人在实际操作中的体会是,这类需求的代码量其实很小,核心逻辑加起来不到一百行,真正花时间的是那些边界情况:用户粘贴一段带全角空格和换行的文本、用输入法选词时按了回车、网络慢的时候连打了五个字、从详情页返回时希望条件还在。把这些都堵上,一个搜索框才算真的“能用”,而不是“跑起来了”。下次再遇到列表卡顿,先别怀疑框架,打开 Performance 面板看一眼主线程,答案通常都在那儿写着。