ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vant Picker 搜索功能实现:原生改造与独立组件方案

Vant Picker 搜索功能实现:原生改造与独立组件方案 给 Vant 的 Picker 选择器加上 search 搜索功能这个需求几乎每个做过后台或电商项目的同学都遇到过。我第一次碰到是在一个商品类目选择的场景里三级类目加起来一千多条数据产品经理的原话是用户翻到手指抽筋都找不到于是要求在选择弹层里加一个搜索框。当时以为十分钟的事结果前后返工了三次踩的坑从索引错位到 iOS 键盘顶起一个不落。这篇就把这套东西从头到尾讲清楚Vant 的 Picker 到底为什么天生不适合搜索、有哪几条补法、每条路各自会在哪里翻车以及万级数据下怎么让它依然跟手。适合已经用过 Vant 但对 Picker 内部机制不算熟的前端同学也适合正在做选人、选品、选地区这类组件的同学直接抄作业。1. 官方 Picker 为什么不带搜索这个缺口该怎么补1.1 从 Picker 的渲染结构看它被卡在哪先得说清楚一件事Picker 从设计上就不是给查找用的它是给确认用的。它的交互模型是有限选项 滚轮定位默认假设用户能接受滑动浏览。Vant 的 Picker 内部是一列或者多列独立的滚轮容器每一列自己维护滚动位置通过一个高亮的中间指示框来提示当前选中项。这个结构天生没有过滤的概念——滚轮的位置和数据的索引是强绑定的你把数组一改滚动位置就没有意义了。我在源码层面看到的实现是每一列在初始化时会根据default-index或者绑定的值算出目标索引然后做一次过渡滚动。也就是说索引和位置是同一个东西的两种表达。这解释了为什么加搜索会这么别扭——搜索的本质是把数据集合换掉而 Picker 的假设是数据集合永远不变变的只是位置。这是第一层矛盾。第二层矛盾在联动。省市区这种多列联动后一列的内容依赖前一列的选中值Vant 是通过监听 change 事件、在回调里替换下一列的 columns 来实现的。这个链路一旦插入搜索就彻底断掉了用户搜的是南山区但南山区这个字符串在三列里的哪一列、属于哪个父节点Picker 自己完全不知道它只知道第 0 列现在停在第 3 项。第三层是高度。Picker 的可视区域高度等于item-height × visible-item-count默认是 44 × 6。这根高度是算死的因为指示框要靠它居中对齐。你想在里面再塞一个搜索框就得动这套高度体系而动它就会牵连到指示框的定位、遮罩层的渐变范围。所以我说官方的克制是有道理的不是没做是不划算。1.2 三条补法摆在一起对比我的取舍搞清楚上面三件事方案就清晰了。能走的路其实只有三条我把它们放在一起对比过很多次方案实现方式改动量适用场景主要风险A 改造原生 Picker利用columns-top插槽放搜索框过滤后替换 columns小已有 Picker 逻辑、单列或两列、数据量千级以内选中状态被过滤冲掉、联动几乎不可用B 独立新组件Popup Search List/Cell 自己搭一套中大数据量、要拼音搜索、要高亮、要分组选中回显、滚动定位要自己重写C 双轨并行正常浏览走 Picker点搜索切到独立的搜索面板大省市区这类强联动 偶尔需要精确定位两套状态要同步代码分支变多我的实际选择是单列、数据量小、已经有现成 Picker 的地方走 A只要是选人、选商品、选标签这种扁平且可能上万的场景一律走 B。C 方案我只在省市区上用过一次说实话不推荐——两套 UI 切换的体验割裂感很强用户会困惑我刚才选的那个还在不在。为什么不直接无脑选 B因为 A 有个不可替代的优势它保留了滚轮的惯性滑动和物理手感老用户用惯了。而 B 是列表点击两者心智模型不一样。所以判断标准很简单——你的用户是要挑一个还是要找一个。挑用 A找用 B。下面第 2 节讲 B 的完整实现第 3 节回头讲 A 怎么改才不出事。2. 从零写一个 SearchablePickerPopup Search List 组合拳2.1 组件的对外契约先定死别边写边改我最大的一个教训是先把 props 和事件定死再写实现。第一次做的时候我边写边加参数最后 props 膨胀到十一个调用方没人愿意用。现在我的SearchablePicker只暴露这几个东西props: { value: { type: [String, Number, Array], default: }, // 选中值支持多选 visible: { type: Boolean, default: false }, options: { type: Array, default: () [] }, // [{ text, value, extra }] multiple: { type: Boolean, default: false }, placeholder: { type: String, default: 搜索名称或拼音首字母 }, title: { type: String, default: }, renderLimit: { type: Number, default: 100 }, // 单屏最多渲染条数 pinyin: { type: Boolean, default: true } // 是否启用拼音匹配 }事件只留三个input值变化、confirm确认、cancel。update:visible用来同步显隐。多出来的东西一律用slot或者extra字段透传别往 props 里塞。这里有个细节值得单独说value我用的是值而不是索引这一点和原生 Picker 完全相反。原生 Picker 的 v-model 是索引数组因为滚轮就是按位置工作的。但搜索场景下索引毫无意义——今天过滤出 30 条明天可能过滤出 5 条同一个索引指向的东西完全不同。用值做契约调用方传进来的shanghai永远是上海不会因为搜索词变化而漂移。这个决定当时看着不起眼后来救了我好几次。组件的骨架长这样逻辑都在 script 里template van-popup :valuevisible positionbottom round :style{ height: popupHeight } input$emit(update:visible, $event) div classsp-header div classsp-title v-iftitle{{ title }}/div van-search v-modelkeyword :placeholderplaceholder shaperound clearonKeywordChange / /div div classsp-body van-cell v-foritem in renderedList :keyitem.value :class[sp-item, { is-active: isActive(item) }] clickchoose(item) span v-htmlhighlight(item._text, keyword)/span van-icon v-ifisActive(item) namesuccess color#1989fa / /van-cell van-empty v-if!renderedList.length description没有匹配的选项 / div v-ifrestCount 0 classsp-more clickrenderLimit 100 还有 {{ restCount }} 条点击加载更多 /div /div div classsp-footer van-button block typeinfo clickonConfirm确定/van-button /div /van-popup /templatepopupHeight我用的是70%而不是固定 px。原因在 5.1 会详细说跟 iOS 键盘有关系。底部那个确定按钮有必要留着尤其是多选场景——单选可以点完即走多选必须有明确的收口动作。2.2 匹配逻辑拼音、首字母、多关键词一次搞定搜索好不好用八成取决于匹配。我见过太多实现只做text.indexOf(keyword) -1结果就是用户输入sh想找上海找不到输入南 山中间有空格也找不到。一个及格的中文搜索至少要有三路命中原文命中上海能被上海、海、上命中。全拼命中上海能被shanghai、shang、hai命中。首字母缩写命中上海能被sh、shh命中后者是容错写法。这三路我都塞进同一个字符串里预计算过滤的时候只做一次includes代码极简import { pinyin } from pinyin-pro function buildSearchable(list) { return list.map(item { const text String(item.text) const py pinyin(text, { toneType: none, type: array, nonZh: consecutive }).join().toLowerCase() const abbr pinyin(text, { pattern: first, toneType: none, type: array, nonZh: consecutive }).join().toLowerCase() return { ...item, _text: text.toLowerCase(), _search: ${text.toLowerCase()}|${py}|${abbr} } }) }关键点是_search把所有可匹配的形态用分隔符拼成一条字符串。这样做的好处是过滤时不需要写三个 if一个includes搞定JIT 对这种简单字符串操作优化得非常好。多关键词的处理也简单用空格切分再做与运算function match(row, kw) { if (!kw) return true const keys kw.trim().toLowerCase().split(/\s/).filter(Boolean) return keys.every(k row._search.includes(k)) }这里用every而不是some是刻意的。用户输入上海 浦东心里想的是上海的浦东用some会把所有带上海和所有带浦东的都列出来结果噪音爆炸。用every做与运算命中率低但准用户再加一个字就能收敛。还有一个容易忽略的点先 trim 再判断空。用户不小心打了一个空格如果不 trimkeys会切成[]includes()永远返回 true等于没过滤。这个 bug 我线下测十次都碰不到上线第一天就有用户长按空格打出来了。2.3 输入防抖、关键词高亮与空状态处理防抖这事我要说一句反直觉的数据量在 2000 条以内不要做防抖。因为过滤本身只要 1ms 左右防抖带来的 200~300ms 延迟反而让输入感觉发粘。真正需要防抖的场景是异步搜索请求后端接口或者数据量过万而且没建索引。如果你确实需要别引入 lodash手写一个足够function debounce(fn, wait 200) { let timer null return function (...args) { clearTimeout(timer) timer setTimeout(() fn.apply(this, args), wait) } }注意一点防抖函数不要在methods里直接写因为 Vue 的methods每次访问是同一个引用没错但如果你在created里赋值到this.filterDebounced debounce(...)要避免它被 Vue 的响应式系统劫持——debounce返回的是函数函数不会被深度劫持问题不大但保险起见可以放在data外面或者用Object.freeze包一下。关键词高亮是提升体验最明显的一招但也是最容易出 XSS 的地方。必须转义必须转义必须转义。因为列表数据往往来自后端如果类目名里带了这种东西v-html会直接执行。escapeHtml(str) { return String(str) .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;) } highlight(text, kw) { const safe this.escapeHtml(text) const k (kw || ).trim().toLowerCase() if (!k) return safe const idx text.toLowerCase().indexOf(k) if (idx -1) return safe return ( this.escapeHtml(text.slice(0, idx)) em classsp-hl${this.escapeHtml(text.slice(idx, idx k.length))}/em this.escapeHtml(text.slice(idx k.length)) ) }用em而不是span有两个好处语义上就是强调的意思另外样式上少写一个类名也能生效。样式我一般写成.sp-hl { font-style: normal; color: #1989fa; font-weight: 600; }空状态不要只写暂无数据要告诉用户下一步做什么。我用的文案是没有匹配的选项试试只输入首字母比干巴巴四个字有用得多。如果是拼音命中但原文没命中我还会在列表项下面补一行小字显示拼音用户才明白为什么这行会被搜出来——这个细节是我观察用户操作记录后加的加上之后用户以为搜错了的反馈少了一大截。3. 直接在原生 Picker 上叠搜索columns-top 插槽方案3.1 用 columns-top 插槽塞搜索框以及随之而来的高度问题如果你的项目已经到处在用 Picker改成独立组件成本太高那就走这条路。Vant 2.12 之后的 Picker 提供了columns-top和columns-bottom两个插槽位置就在滚轮区域的上方和下方而且不参与滚动正好适合放搜索框。van-picker refpicker :columnsfilteredColumns :titletitle :visible-item-count6 value-keytext confirmonConfirm cancel$emit(cancel) template #columns-top van-search v-modelkeyword placeholder搜索 shaperound update:model-valueonKeywordChange / /template /van-picker这里第一个坑就是高度。columns-top插槽会占用 columns 区域的垂直空间实测下来可视项会从 6 项被压到 4 项左右指示框还是按item-height × visible-item-count算出来的位置对齐视觉上会显得偏上甚至出现指示框压住选项的情况。我的处理办法是把visible-item-count从默认的 6 调到 7 或者 8把被搜索框吃掉的空间补回来。如果还不对就直接给.van-picker__columns写死高度.sp-picker .van-picker__columns { height: 264px; /* 44 * 6 */ }说实话这块没有万能公式因为 Vant 各小版本的 DOM 结构和样式变量有差异。我的建议是在真机上量一下再写死别在开发者工具里看着差不多就交差。模拟器和真机的字体渲染高度能差 2~3px而这 3px 恰好就是指示框看着有点歪和完全对齐的分界线。第二个坑是搜索框的shape。默认van-search是方角放在 Picker 顶部会显得很突兀我统一用round并且把外边距调小.sp-picker .van-search { padding: 8px 12px 0; }还有一个隐藏问题van-search自带一个取消按钮show-action为 true 时放在这个位置会挤掉输入框宽度。我一般把它关掉搜索的清除用自带的叉号就够了取消这个动作交给 Picker 自己的取消按钮。3.2 过滤之后索引错位这是最容易翻车的地方这一节是整个 A 方案的核心也是我返工三次才彻底搞明白的地方。问题的本质是Picker 的选中状态是用索引表达的你把columns数组换掉索引不变但指向的内容变了。举个具体的例子用户原本停在北京原始数组索引 0此时他搜上过滤后数组变成[上海, 上饶]索引还是 0Picker 就会认为用户选中了上海。用户什么都没点选中项自己变了。这个 bug 在生产环境里非常阴险因为它只在你搜索之后才出现而且看起来像是程序自己乱选。我的解决办法是每次过滤完成后重新把当前选中值在过滤结果里的位置找回来watch: { keyword() { this.$nextTick(() { const picker this.$refs.picker if (!picker) return const idx this.filteredColumns.findIndex( c String(c.value) String(this.innerValue) ) // 找不到就回到第一项但内部记录的选中值不要动 picker.setColumnIndex(0, idx -1 ? idx : 0) }) } }注意setColumnIndex这个方法在 Vant 2.12 和 Vant 4 里都有如果你用的是更老的版本就只能靠default-index而default-index只在初始化生效搜索时不会重新应用——那就只能通过v-if强制重建 Picker 组件代价是有一次闪烁体验会差一些。第二个关键点是取值不要靠索引。Vant 的confirm事件回调签名是(value, index)这里的value是选中项的对象当 columns 是对象数组时所以直接读value.value就是对的onConfirm(item) { // item 是过滤后的那一项对象直接取值 this.$emit(input, item.value) this.$emit(confirm, item.value) }我见过有人为了稳妥再拿 index 回原始数组里查一遍这在过滤场景下是必错的——因为index是过滤后数组的索引拿它去原始数组里查出来的东西跟用户看到的完全无关。这个错误在原始数组恰好没被过滤的时候不会暴露一旦过滤就炸属于典型的偶发脏 bug。第三个细节清空搜索词的时候也要重新定位。用户搜完上选中了上海然后把搜索词删掉此时列表恢复成完整列表选中项应该自动跳回上海所在的位置而不是停在开头。上面那段 watch 逻辑因为监听的是keyword清空时也会触发所以天然覆盖了这点不用额外写。3.3 多列联动选择器怎么做搜索路径扁平的思路多列联动省市区、类目三级加搜索是这件事里最难的部分。我一开始想直接在每一列上各加一个搜索框试了之后放弃——用户根本不知道南山区该在哪一列搜体验极差而且三列同时过滤会导致联动关系彻底断裂。后来我换了个思路搜索不发生在列上而发生在一个扁平的全路径列表上搜到之后再反推回索引。具体做法是在初始化时把树形结构拍平成这样// 扁平化结果 [ { label: 广东省 / 深圳市 / 南山区, value: 440305, path: [广东省, 深圳市, 南山区] }, { label: 广东省 / 深圳市 / 福田区, value: 440304, path: [广东省, 深圳市, 福田区] }, // ... ]用户输入的搜索词只在这个扁平列表上匹配命中的结果展示成带斜杠的完整路径。用户点选之后拿path去各列里查索引然后逐列设置onPathPick(item) { const indexes item.path.map((name, col) { const list this.columns[col] const i list.findIndex(c this.getText(c) name) return i -1 ? i : 0 }) this.$nextTick(() { const picker this.$refs.picker indexes.forEach((i, col) picker.setColumnIndex(col, i)) }) this.keyword this.searchMode false }如果你的 Vant 版本里有setIndexes这类批量方法直接传数组更省事没有的话就循环调用setColumnIndex效果一样只是多几次 DOM 更新。设置完之后记得关闭搜索面板让 Picker 滚到目标位置用户能看到哦它帮我选好了。还有一个必须处理的边界用户搜索到的是末级节点但中间某级的名字在列表里可能有重名比如两个省都有朝阳区。所以反推索引的时候必须按层级逐级校验不能只按名字找。稳妥的做法是在扁平化阶段就把每一级的索引一并存下来用indexPath: [3, 1, 7]这种形式用的时候直接套用完全绕过名字查找。这个改动很小但把重名带来的所有问题一次性消掉了。4. 万级数据下的索引构建与渲染策略4.1 把搜索字段预生成别在过滤时现算拼音拼音转换是个重活。pinyin-pro单字转换大约在微秒级但一万条数据每条平均 5 个字就是五万次转换实测下来一百毫秒起步。如果你把它放在每次输入的回调里现算输入一个字符卡一下体验直接崩掉。正确做法是只算一次然后缓存。用WeakMap做缓存是最合适的因为options数组是引用类型同一个引用只算一次父组件换数组引用时缓存自动失效不用手动清理const indexCache new WeakMap() function getIndexed(list) { if (indexCache.has(list)) return indexCache.get(list) const indexed buildSearchable(list) indexCache.set(list, indexed) return indexed }这里有个前提父组件传options时不要每次渲染都创建新数组。我见过有人写:optionslist.filter(x x.enabled)这个表达式每次渲染都产生新引用WeakMap 缓存直接失效等于白做。要么在computed里算好要么在watch里监听原始数据变化后手动重建。如果不方便改父组件还有一个兜底办法用数据内容的哈希做 key。但哈希本身也有成本一万条字符串拼接加哈希也要几毫秒而且容易在内容恰好相同时误命中。所以我还是推荐从源头控制引用稳定性这比任何补丁都干净。4.2 渲染截断与虚拟滚动的取舍索引建好了下一步是渲染。一万条数据全部渲染成 DOM 节点是不现实的——van-cell结构不算轻一万个节点大概会让移动端浏览器直接失去响应。我用的策略是截断渲染默认只渲染 100 条底部给一个还有 N 条点击加载更多的按钮。为什么是 100 而不是 20 或者 500我实测过几组20 条太少用户滑动两下就到头了容易误以为没搜到500 条首次渲染要 60ms 以上在低端安卓机上能感觉到轻微卡顿100 条的价格大约在 12ms 左右基本无感。这个数字可以根据你的设备覆盖率调整但除非数据量极小否则不要全量渲染。虚拟滚动是另一条路但对这个场景我一般不推荐。原因有两个一是引入了额外依赖vue-virtual-scroller之类体积和兼容性都要考虑二是移动端的惯性滚动配上动态高度计算很容易出现滚动条位置漂移、快速滑动时白屏的情况调起来比截断渲染麻烦好几倍。搜索场景的用户行为是输入关键词快速扫视前几条他根本不会往下滚五千条截断方案完全够用。不过有一个折中方案值得提一下如果搜索词为空只渲染最近常用的 20 条一旦有搜索词再按 100 条截断。因为搜索词为空时用户是在浏览而不是查找给他一万条也没用。这个优化能让打开面板的瞬间响应快很多。4.3 实测数据与结论下面这组数据是我在一台中端安卓机骁龙 7 系上测的数字仅供参考量级具体设备差异会很大数据量首次构建索引单次过滤未建索引现算拼音单次过滤已建索引渲染 100 条1,000约 14ms约 12ms约 0.5ms约 10ms5,000约 62ms约 58ms约 0.9ms约 12ms10,000约 118ms约 115ms约 1.3ms约 13ms30,000约 340ms约 350ms约 2.4ms约 15ms结论很清楚建索引的一次性成本随数据量线性增长但过滤成本被压到了毫秒级。一万条数据下无索引的过滤要 115ms有索引只要 1.3ms差了将近 90 倍。而且 118ms 的构建成本是发生在面板初始化阶段的用户可以接受但 115ms 的过滤成本发生在每次按键上绝对不能接受。我还额外做过一个测试把过滤结果加上_score字段做相关性排序原文命中权重 3、全拼命中 2、缩写命中 1排序不会明显增加耗时一万条结果排序大概 2~3ms但它的实际收益有限——因为用户输入足够多之后结果集本来就很小了。所以我的建议是只在结果超过 50 条时才启用相关性排序日常可以跳过。5. 那些文档翻不到、只在真机上暴露的细节5.1 iOS 键盘顶起与弹层高度抖动这个坑我花了整整一个下午。van-popup用positionbottom时在 iOS 上输入框获得焦点键盘弹起会触发页面视口变化结果就是整个弹层被往上顶有时候还会因为视口高度反复变化而抖动。解决办法有三个我一般组合使用。第一弹层高度用百分比而不是固定像素:style{ height: 70% }比height: 500px稳得多因为百分比是相对父容器算的视口变化时它会跟着调整而不是溢出。第二关闭弹层前先让输入框失焦避免键盘还在、弹层已经收起这种错位状态onClose() { if (document.activeElement document.activeElement.blur) { document.activeElement.blur() } this.$emit(update:visible, false) }第三把弹层挂到 body 上避免被父级的transform或者overflow: hidden影响定位。Vant 的 Popup 有get-container属性Vant 4 里叫teleport指向body就行。父级如果有transform属性position: fixed会失效这是 CSS 规范里就写着的跟 Vant 没关系但排查的时候特别容易忽略。另外iOS 上弹层内部的滚动容器建议加上-webkit-overflow-scrolling: touch和overscroll-behavior: contain。前者让滚动有惯性后者防止滚动到边界时把外层页面一起带着滑。5.2 回显、定位与点了没反应的几种情况回显这块有一个场景特别常见用户之前选了一个商品但那个商品后来下架了不在 options 里。如果组件不做兜底用户打开面板会看到没有任何一项是高亮的还以为自己没选过。我的做法是记住一个lastKnownLabel当值在列表里找不到时就在搜索框上方显示一行灰色小字当前选中XXX已不在可选列表中。这一行字成本很低但省掉了大量客服咨询。点了没反应通常有三种原因。第一种是点击穿透——列表项被一个透明的遮罩层挡住事件到不了排查方法是在点击回调里先console.log一下如果根本没打印就是层级问题。第二种是v-for的key用了索引过滤之后 Vue 复用错了节点导致点击的 DOM 和数据的对应关系错乱这个必须用唯一值当 key。第三种是快速连续点击导致状态更新被合并multiple模式下容易出现处理方式是改成基于当前值做不可变更新别去改原数组。多选状态我用数组存值判断高亮的时候用indexOf。数据量大时indexOf是 O(n)但选中项一般不超过几十个可以忽略。如果真到几百个用Set换掉就行。5.3 主题变量、按需引入与体积最后聊两句工程层面的东西。Vant 支持通过 CSS 变量定制主题搜索框相关的主要是这几个:root { --van-search-padding: 10px 12px; --van-search-content-background: #f5f6f8; --van-search-input-height: 36px; }注意这些变量必须写在 Vant 的样式之后才能覆盖如果你用unplugin-vue-components做按需引入样式是自动注入的注入顺序可能不受你控制这时候加!important是最快的解法虽然不优雅但有效。按需引入这块Popip、Search、Cell、Empty、Button 都是独立组件只引入用到的即可一个完整的 SearchablePicker 打包后大概增加 20KB 左右gzip 后 6~8KB完全可接受。反倒是pinyin-pro要留意它的完整包不算小如果只需要首字母功能可以考虑只保留常用字表或者用更轻的替代方案。我的做法是把拼音字段交给后端或者构建时预生成前端完全不引这个库打包体积直接省下来代价是数据结构里要多一个字段——这个交换在大多数项目里都划算。还有一件事值得提醒如果你的 options 是异步加载的比如选了省之后才请求市那搜索必须考虑加载中的状态。用户打字很快请求还没回来列表是空的他会以为搜不到。我的处理是加载中显示骨架屏而不是空状态并且在输入框右侧显示一个小 loading让用户知道在查了而不是没有。这个东西我自己在两个项目里都落地过从最早的过滤个数组就完事到后来把索引、拼音、高亮、截断、异步这些都补齐前后大概迭代了四五轮。如果你现在正准备做我的建议是先用最简单的版本上线——includes过滤加 100 条截断——然后在真实的使用数据里看用户到底卡在哪一步再决定要不要加拼音和高亮。我见过太多人一上来就把功能堆满结果组件复杂到没人敢改最后反而被替换掉了。
返回列表