ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vue input 输入框关键字搜索筛选列表与性能优化

Vue input 输入框关键字搜索筛选列表与性能优化

输入框关键字搜索这件事,看着简单,真做起来全是细节。Vue input 输入框关键字筛选检索列表数据展示,几乎是我带每个新人时都会让他们先做一遍的小练习——不是因为它难,而是它把状态管理、派生数据、事件时机、渲染性能这几件事一次性全串起来了。需求方嘴里那句“列表上面加个搜索框,输入关键字实时筛选”,翻译成代码只需要一个v-model加一个computed,但要做得手感顺、数据准、大数据量不卡,中间能抠的东西远比想象中多。

这篇东西适合三类人看:刚上手 Vue、想搞明白“为什么我写的搜索框输中文会跳字符”的新手;做过几个列表页、想把手写筛选逻辑沉淀成可复用方案的初中级开发;以及要处理几千上万条数据、发现直接filter会卡顿的同学。我会从需求拆解讲到选型,再给两套能直接复制的完整实现——一套是常规量级下的 computed 方案,一套是带防抖、高亮、空状态和性能优化的进阶版,最后把实际踩过的坑整理成速查表。代码基于 Vue 3 组合式 API,关键处会标注 Vue 2 的等价写法,原生 HTML5 input 无框架场景也给一版平替。

1. 需求拆解:搜索框到底要解决什么问题

1.1 用户按下第一个键的时候,他在期待什么

大部分人以为搜索框的功能是“过滤”,但从交互体验上看,它真正提供的是即时反馈的确定感。用户输“张”,期待列表立刻缩到只剩姓张的几条;输错了删一个字符,期待列表立刻弹回来。这个“立刻”就是整个功能的技术核心——它决定了你是用同步计算还是异步请求,是用computed还是watch加防抖。

我在实际项目里做过一个粗略统计:本地数据量在 500 条以内时,用户对“即时”的容忍阈值大约是 100 毫秒,超过这个数就会觉得“卡了一下”;超过 300 毫秒,人会开始怀疑自己是不是没输进去,然后重复敲键。所以前端过滤方案的第一条硬指标就是:从按键到列表重绘完成,控制在 100 毫秒内。这个数字不是拍脑袋来的,它基本等于人眼对连续动作的感知边界。

另一个容易被忽略的点是搜索的范围。用户输“张”,他到底想匹配姓名、还是工号、还是备注?如果只匹配姓名,他输工号搜不到就会骂人;如果全字段匹配,输“1”可能命中几百条,他又觉得这搜索没用。这个取舍必须在动手写代码前跟需求方敲定,写进字段配置里,而不是等上线后被反馈再改。

1.2 三种搜索形态的取舍:前端过滤、后端检索、混合模式

这是整个功能里最重要的一个决策,选错了后面全是返工。我用一张表把三种形态的关键差异摆出来,这张表基本可以直接拿去跟产品对齐。

对比维度纯前端过滤纯后端检索混合模式
数据来源首屏一次性拿全量每次输入都发请求首屏拿一页,输入时查后端
适用数据量500 条以内比较舒服,1000 条是上限无上限无上限
响应速度极快,通常小于 10 毫秒受网络影响,200 毫秒起步首次慢,后续取决于接口
服务端压力几乎为零每次输入一个请求需要防抖控制请求量
实现复杂度低,一个 computed 搞定中,要处理竞态和加载态高,两套逻辑要合并
典型场景标签选择、人员选择、配置项列表订单查询、日志检索、商品搜索长列表无限滚动的后台列表

注意:判断用哪种形态,别看“现在”的数据量,要看“一年后”的数据量。我见过一个配置项列表上线时只有 80 条,用前端过滤很舒服,一年后涨到 4000 条,页面一输入就卡住,最后不得不重构成后端检索。

1.3 标题里两个关键词的差异:原生 input 与 Vue 双向绑定

标题里同时出现了“html5 输入框”和“vue input 输入框”,这两者其实不是一回事,混着用容易出问题。

原生 HTML5 的<input type="search">自带一些浏览器行为:部分浏览器会在右侧渲染一个清除按钮,按 Esc 会清空内容,移动端弹出键盘的确认键文案会变成“搜索”。这些是白送的能力,但清除按钮的样式各浏览器不一致,::-webkit-search-cancel-button这个伪元素在部分环境下还得手动改样式,所以很多人干脆用type="text"自己画一个清除图标,控制力更强。

Vue 的v-model则是另一层东西——它把“读值”和“写值”绑在一起,并且在内部对输入法组合事件做了处理。这里有个关键认知:v-model不是简单地监听input事件,Vue 在文本输入上会跟踪compositionstart和compositionend,在中文输入法拼写阶段不去更新绑定的值。这个细节决定了你手写@input监听时,中文输入会出现“拼一个字母跳一次”的抖动,而v-model不会。后面第 5 章我会专门讲这个坑。

2. 技术选型与整体思路设计

2.1 为什么绝大多数列表页该优先考虑前端过滤

先说结论:在数据量可控的前提下,前端过滤的体验是碾压性的。它的优势不只是快,而是整个交互链路没有异步状态。没有 loading、没有请求失败、没有“输入太快导致后到的请求覆盖了先到的结果”这种经典竞态问题。

竞态这事儿值得展开说一下。假设你用了后端检索,用户快速输入“张三”,会发出“张”和“张三”两个请求。如果“张”的请求因为网络抖动后返回,你的列表最后展示的就是“张”的结果,而输入框里明明写着“张三”。用户看到的就是“搜索不准”。解决它只有两条路:给每个请求打上序号,只认最新一次的响应;或者用AbortController取消旧请求。两条路都能走通,但都是额外的心智负担。

前端过滤就没有这个问题。输入即状态,状态即结果,中间没有任何异步环节可以出错。所以我的一般判断标准是:首屏能一次性拿全、且半年内不太可能翻十倍的数据,一律走前端过滤。人员选择器、标签选择器、字典配置项、几十上百个菜单项,都属于这个范畴。

2.2 数据流设计:输入 → 状态 → 派生 → 渲染

把整个功能抽象成一条单向数据流,思路就清楚了。源数据是rawList(只读,永远不动),输入是keyword(响应式状态),中间产物是filteredList(派生状态),终点是模板里的v-for。

这里有个我坚持的原则:永远不要原地修改源数据。我见过太多人这么写:

// 反例:原地过滤,过滤完原数据就没了 this.list = this.list.filter(item => item.name.includes(this.keyword))

这段代码的问题在于this.list一旦被覆盖,原始数据就再也回不来了。用户删掉关键字想恢复全量列表,你只能重新请求接口。正确的做法是让rawList保持只读,用计算属性派生一个新数组:

const filteredList = computed(() => { const kw = keyword.value.trim().toLowerCase() if (!kw) return rawList.value // 空关键字直接返回原引用,零开销 return rawList.value.filter(item => item._searchText.includes(kw)) })

注意if (!kw) return rawList.value这一行——它返回的是原数组的引用,不产生任何新数组,等于空搜索时连一次遍历都省了。这个小细节在几千条数据下的收益很直观。

2.3 组件拆分与目录结构

小功能没必要过度设计,但我建议至少把“搜索能力”抽成一个可复用的组合式函数,因为这种东西一个项目里往往要写七八遍。我自己常用的结构是这样:

src/ composables/ useKeywordFilter.js # 纯逻辑:关键字过滤 + 高亮 components/ SearchInput.vue # 搜索框 UI,带清除按钮 KeywordList.vue # 列表展示,接收过滤后的数据 views/ DemoPage.vue # 组装页面

拆成三层的好处是,搜索框和列表可以各自替换。比如某个页面要换成带下拉建议的搜索框,只换SearchInput就行,过滤逻辑一行不用动。useKeywordFilter里只放纯函数和响应式引用,不碰 DOM,这样它既能用在列表页,也能用在表格的列筛选上。

3. 核心细节拆解:关键字匹配的几种实现

3.1 简单 includes 匹配与它的边界

最朴素的匹配就是String.prototype.includes,它区分大小写,不支持通配。所以实战里几乎都要先做一次toLowerCase()归一化。真正容易翻车的是多字段和非字符串类型这两个边界。

如果数据结构是{ name: '张三', age: 28, tags: ['销售', '华东'] },直接对对象做includes是没用的。你必须在过滤时显式地把各个字段拼起来。而tags是数组、age是数字,直接join有时会得到[object Object]这种垃圾字符串,导致搜什么都命中。我的做法是抽一个取值函数,把各种类型统一成字符串:

function toSearchable(value) { if (value === null || value === undefined) return '' if (Array.isArray(value)) return value.map(toSearchable).join(' ') if (typeof value === 'object') return Object.values(value).map(toSearchable).join(' ') return String(value) }

这个函数处理了嵌套对象和数组,虽然递归深度一般只有两三层,但能避免 90% 的“搜不到”问题。这里用join(' ')而不是join('')是有讲究的:加个空格分隔,能防止两个字段的值粘一起产生假命中。比如name: 'ab'、code: 'cd',用空字符串拼成abcd,用户搜bc就会命中一条其实并不包含bc的记录。

3.2 多字段联合搜索的配置化写法

硬编码字段名在字段变化时很痛苦,所以我会把可搜索字段做成配置。配置化之后,产品说“再加上备注字段”,你只需要动一行数组。

// 可搜索字段配置:支持点号路径取值 const SEARCH_FIELDS = ['name', 'code', 'owner.name', 'remark'] function pickByPath(obj, path) { return path.split('.').reduce((acc, key) => (acc == null ? acc : acc[key]), obj) } function buildSearchText(item, fields) { return fields .map(f => toSearchable(pickByPath(item, f))) .join(' ') .normalize('NFKC') // 全角转半角,顺带处理兼容字符 .toLowerCase() }

normalize('NFKC')这一行解决的是全角半角问题。用户在中文输入法下面很容易打出全角的A、1,而数据里存的是半角。不做归一化,搜“A001”永远搜不到“A001”。这个坑我在两个项目里都踩过,加一行 normalize 就彻底解决。

3.3 匹配方式对比:包含、前缀、分词、拼音

不同的匹配策略带来的体感差异很大,选之前得想清楚用户会怎么输。

匹配方式实现成本适用场景主要问题
子串包含 includes极低通用场景,字段值都不长短关键字命中过多
前缀匹配 startsWith极低编码、单据号类字段用户记不住开头就搜不到
多关键字与低用户习惯空格分隔需要拆分和清洗空串
拼音首字母中中文人名、部门名需要引入拼音库并预建索引
分词模糊高长文本、文章标题中文分词效果不稳定

我个人的默认选择是“子串包含 + 多关键字与”。多关键字与的意思是:把输入按空格拆开,要求每个词都命中才算匹配。比如用户输“张 销售”,就要求这条记录同时包含“张”和“销售”。这个策略在真实使用中特别好用,因为它允许用户用两个模糊线索交叉定位,比单个长关键字的命中率高得多。

const terms = kw.split(/\s+/).filter(Boolean) return list.filter(item => terms.every(t => item._searchText.includes(t)))

注意是every不是some。用some就变成了“或”逻辑,输的词越多结果越多,完全违背直觉。

3.4 高亮的实现与那个必须避开的 XSS 坑

关键字高亮是这个功能的“加分项”,但它也是安全风险最集中的地方。因为高亮的常见做法是把命中的片段包成<mark>标签,然后交给v-html渲染。

危险在于:如果关键字本身或数据字段里含有 HTML 特殊字符,直接拼接会有注入风险。正确的顺序是先转义、再替换,而不是反过来。

function escapeHtml(str) { return String(str) .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#39;') } function highlight(text, keyword) { const safeText = escapeHtml(text) const kw = keyword.trim() if (!kw) return safeText // 关键:正则里的元字符必须转义,否则用户输入 ( 或 * 会直接抛错 const pattern = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const re = new RegExp(`(${pattern})`, 'gi') return safeText.replace(re, '<mark>$1</mark>') }

两个细节必须说清楚。第一,escapeHtml要在替换前执行,否则数据里原本的<script>会被原样输出;而<mark>是你自己加的安全标签,不受影响。第二,正则元字符转义绝对不能省,用户输一个左括号(,new RegExp('(')会直接抛出SyntaxError,整个组件崩掉。这个 bug 我在线上见过一次,报错信息还特别难懂。

4. 完整实操:从零实现一个可复用的搜索筛选组件

4.1 基础版:30 行搞定的 computed 过滤

先给一版最朴素的,数据量在几百条以内完全够用,也最适合新手先跑通概念。

<template> <div class="search-wrap"> <input v-model.trim="keyword" type="search" placeholder="输入关键字筛选" autocomplete="off" class="search-input" /> <span class="count">共 {{ filteredList.length }} 条</span> <ul class="list"> <li v-for="item in filteredList" :key="item.id"> <span class="name" v-html="highlight(item.name, keyword)"></span> <span class="code">{{ item.code }}</span> </li> </ul> <p v-if="filteredList.length === 0" class="empty"> 没有匹配「{{ keyword }}」的结果 </p> </div> </template> <script setup> import { ref, computed } from 'vue' const rawList = ref([ { id: 1, name: '张三', code: 'A001', _searchText: '张三 a001' }, { id: 2, name: '李四', code: 'B002', _searchText: '李四 b002' }, { id: 3, name: '张伟', code: 'A003', _searchText: '张伟 a003' } ]) const keyword = ref('') const filteredList = computed(() => { const kw = keyword.value.trim().toLowerCase() if (!kw) return rawList.value const terms = kw.split(/\s+/).filter(Boolean) return rawList.value.filter(item => terms.every(t => item._searchText.includes(t)) ) }) </script>

这里我把_searchText直接写死在了数据里,是为了突出“预建索引”这个思路——字段一旦确定,搜索文本应该在数据进组件时就算好,而不是每次过滤时重新拼接。真实项目里用rawList.value.map(buildSearchText)生成就行。

v-model.trim这个修饰符值得单独提一句:它会自动去掉首尾空格。好处是用户输入时手滑打了空格不会导致搜不到,坏处是输入过程中的中间空格也会被处理,如果用户想输“张 三”两个词,中间那个空格在输入过程中会被 trim 影响吗?答案是会的——.trim只在赋值时处理首尾,中间空格不受影响,所以多关键字搜索是安全的。

4.2 进阶版:抽出可复用 composable,带防抖与竞态处理

数据量上到两三千,或者需要走接口的时候,就得换方案了。我把过滤逻辑抽成 composable,同时给它加上防抖和可选的异步模式。

// composables/useKeywordFilter.js import { ref, computed, watch, onBeforeUnmount } from 'vue' export function useKeywordFilter(source, options = {}) { const { fields = [], debounce = 0, // 0 表示纯同步过滤 multiTerm = true // 是否支持空格分词 } = options const keyword = ref('') const applied = ref('') // 防抖后真正生效的关键字 let timer = null watch(keyword, (val) => { if (!debounce) { applied.value = val; return } clearTimeout(timer) timer = setTimeout(() => { applied.value = val }, debounce) }) onBeforeUnmount(() => clearTimeout(timer)) const filteredList = computed(() => { const kw = applied.value.trim().toLowerCase().normalize('NFKC') const list = Array.isArray(source) ? source : source.value if (!kw) return list const terms = multiTerm ? kw.split(/\s+/).filter(Boolean) : [kw] return list.filter(item => { const text = item._searchText ?? '' return terms.every(t => text.includes(t)) }) }) return { keyword, applied, filteredList } }

这里的关键设计是双状态:keyword是输入框绑定的实时值,applied是真正参与过滤的值。这样输入框的显示永远跟手,而过滤计算被节流了。为什么不用v-model.lazy?因为.lazy是把更新时机换到change事件(失焦或回车),反馈太迟钝;防抖是延迟 200 毫秒后照样实时,体验完全不同。

防抖时长的选择有讲究。纯本地过滤我一般给 120 到 150 毫秒,人感觉不到延迟;走接口的话给 300 毫秒左右,能把快速输入时的请求量压到原来的十分之一。给个参考:输入“张三丰”四个字,人大概用 600 毫秒,300 毫秒防抖能把 4 次请求压成 2 次。

4.3 大列表渲染优化:从 1000 条到 10000 条

过滤本身很快,真正拖慢页面的是渲染。1000 个<li>一次性塞进 DOM,光创建节点就要一两百毫秒,滑动还会掉帧。这时候有两个方向可以走。

第一个方向是分页或虚拟滚动。虚拟滚动只渲染视口内的十几二十个节点,滚动时复用 DOM,思路是“总高度用一个占位元素撑住,可见区域按scrollTop计算起始索引”。它的实现成本不低,但对上万条数据是唯一可行的方案。

第二个方向更轻,是 Vue 3 的v-memo。它能让某个列表项在依赖没变化时跳过更新:

<li v-for="item in filteredList" :key="item.id" v-memo="[item.id, applied]"> {{ item.name }} </li>

它的效果是:切换关键字时,如果某条记录的数据和关键字都没变,这个<li>的更新直接被跳过。在“筛掉一部分、保留一部分”的场景下收益明显。

还有一点常被忽略:别在v-for里调用方法。像v-for="item in getFiltered(keyword)"这种写法,每次重渲染都会重跑一遍过滤,等于把计算属性的缓存优势全扔了。这也是为什么我在 4.1 里坚持用computed而不是方法。

4.4 无框架场景:原生 HTML5 input 的平替写法

有时候项目里就那么一个页面,不值得上框架,原生也能写得很干净。核心就三件事:监听输入、过滤数组、重绘列表。

<input id="kw" type="search" placeholder="输入关键字筛选" autocomplete="off" /> <ul id="list"></ul> <script> const rawList = [ { id: 1, name: '张三', code: 'A001' }, { id: 2, name: '李四', code: 'B002' } ] const kwEl = document.getElementById('kw') const listEl = document.getElementById('list') let composing = false function render() { const kw = kwEl.value.trim().toLowerCase() const data = kw ? rawList.filter(i => (i.name + i.code).toLowerCase().includes(kw)) : rawList listEl.innerHTML = '' const frag = document.createDocumentFragment() data.forEach(i => { const li = document.createElement('li') li.textContent = i.name // 用 textContent,天然免疫注入 frag.appendChild(li) }) listEl.appendChild(frag) } kwEl.addEventListener('compositionstart', () => { composing = true }) kwEl.addEventListener('compositionend', () => { composing = false; render() }) kwEl.addEventListener('input', () => { if (!composing) render() }) render() </script>

原生版本有两个点比框架更容易出错:一是没有v-model的输入法保护,必须自己加composing标志位;二是重绘时用innerHTML拼接字符串会带来注入风险,我上面用createDocumentFragment加textContent就是为了避开这个,性能上也比字符串拼接后一次性赋值更稳。

5. 常见问题与排查技巧实录

5.1 中文输入法下“输一个字母跳一次”的成因与解法

这大概是这个功能最高频的报障。现象是:用拼音输入“张三”,拼音还没上屏,输入框里出现“zhangsan”,列表已经开始按字母过滤,跳得乱七八糟。

根因是输入法的组合过程会连续触发input事件。Vue 的v-model内部对compositionstart/compositionend做了处理,所以绑v-model时不会出这个问题。但只要你把@input手动接上去,就绕过了这层保护:

<!-- 反例:手写 @input,中文输入时会抖 --> <input :value="keyword" @input="e => keyword = e.target.value" /> <!-- 正例一:直接用 v-model --> <input v-model="keyword" /> <!-- 正例二:必须手写时,自己加锁 --> <input :value="keyword" @compositionstart="composing = true" @compositionend="e => { composing = false; keyword = e.target.value }" @input="e => { if (!composing) keyword = e.target.value }" />

注意:还有个更隐蔽的情况是“中文上屏后没能触发过滤”。原因是compositionend事件的触发顺序在部分浏览器里晚于input,如果只在compositionend里读值,某些环境会读到旧值。稳妥做法是在compositionend里用事件对象上的e.target.value重新读一次,而不是依赖上一次input里存下来的值。

5.2 空结果、空搜索、脏数据这三种状态要分清

这三种状态在代码里都是“列表为空”,但给用户的反馈必须完全不同,混淆了就会显得很业余。

状态触发条件应该展示什么
首次加载中数据还没到骨架屏或加载提示
空搜索无结果有数据但关键字没命中“没有匹配「关键字」的结果” + 清除按钮
源数据为空接口返回空数组“暂无数据” + 新建引导
关键字仅空格trim 后为空字符串展示全量列表,不显示空态

表格里最后一行是重点:如果用户不小心按了空格,你的filteredList会走“有关键字”的分支,而所有记录的_searchText里都不含空格(除非原字段有),结果就是全被过滤掉,页面显示“无结果”。这就是典型的“空态误报”。解法很简单,在计算属性里先trim()判断,为空直接返回原列表,我在前面每一版代码里都留了这行。

5.3 常见问题速查表

下面这张表是我这几年攒下来的,基本都是文档里不会写、但一定会遇到的东西。

现象大概率原因处理方式
搜英文搜不到大小写未归一构建_searchText时统一toLowerCase()
搜全角字符搜不到全角半角未归一加normalize('NFKC')
输(页面崩溃正则元字符未转义替换前做 `replace(/[.*+?^${}()
关键字越多结果越多用了some而非every多词匹配改every
手机上键盘不显示“搜索”input 类型不对用type="search",配合enterkeyhint="search"
输入时列表闪烁每次渲染都重算过滤改用computed,别在模板里调方法
清空关键字后列表没恢复原地过滤覆盖了源数据源数据只读,派生新数组
首屏输入框有历史建议浏览器自动填充加autocomplete="off",必要时name也设随机值
删除时明显卡顿列表项过多,重渲染成本高上虚拟滚动,或加v-memo
高亮把标签渲染成了文本转义顺序反了先escapeHtml再插<mark>

表格里“手机上键盘不显示搜索”这一条值得补充。type="search"在多数移动端浏览器里会把软键盘的回车键文案变成“搜索”,但这个行为不保证;更可靠的是显式加enterkeyhint="search",这是 HTML 标准的属性,兼容性比依赖浏览器默认行为好得多。

5.4 几条让我少返工的实操心得

心得一:把“预建索引”当默认动作。只要列表超过 200 条,我就在数据进来时立刻算好_searchText。别小看这一步,它把“每次按键都遍历所有字段并拼接字符串”变成了“一次拼接 + N 次子串查找”,实测在 2000 条数据下,单词过滤耗时从 12 毫秒降到 3 毫秒以内。

心得二:本地过滤和接口查询不要混在一个函数里。我见过有人写一个doSearch(),里面if判断走本地还是走网络,结果两个分支的状态互相污染,加载态和空态经常同时出现。干净的做法是让它们成为两个独立的函数,在页面层根据配置决定调哪个,各自只管自己的状态。

心得三:给搜索框加一个“无结果时仍然可以清空”的交互。空态提示里如果只有一行白字,用户想退回去只能手动全选删除,体验很糟。最简单的处理是在空态下方放一个“清除筛选”的按钮,点击后把keyword置空。这个改动只要几行代码,但用户抱怨会少一大半。

心得四:别在watch里做过滤再赋值。有些人习惯watch(keyword, () => { list.value = filter(...) }),这会导致一次额外的渲染周期,而且list变成了可变状态,容易出 bug。除非你确实需要防抖或异步,否则computed永远是更好的选择——它是惰性的、有缓存的、依赖清晰。

心得五:测试用例一定要覆盖这三个输入。只输空格、输全角字符、输正则元字符(比如.或()。这三个几乎覆盖了所有线上事故类型,我在交接项目时都会把这三条写进自测清单。

最后再分享一个后续可以扩展的方向:如果你的列表数据里带层级(比如部门树),可以把_searchText的构建逻辑扩展成“把自己的文本和所有祖先的文本拼起来”,这样搜父级部门名也能把子部门带出来。实现上就是在构建索引时递归一下父链,改动不大,但对层级筛选的体验提升非常明显。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进