Vue 3 + Vant 4实现通讯录A-Z排序:从拼音分组到IndexBar索引栏实战
发布时间:2026/10/2 16:15:41来源:尧图网络
做通讯录模块的时候A-Z排序几乎是绕不开的需求。不管是OA系统里的员工花名册、IM工具的好友列表还是电商后台的收货人管理用户早就习惯了右侧一列字母索引随手点一下就能跳到对应分组。我之前在项目里用Vue 3 Vant 4完整实现过一次这个功能从拼音转换、分组排序到IndexBar索引栏联动整个过程踩了不少坑。这篇就把完整的实现思路和可复用的代码整理出来给正在做类似功能的朋友做个参考。如果你还没接触过Vant这里先交代一下背景Vant是有赞团队开源的一套移动端组件库目前Vant 4全面支持Vue 3组件风格偏轻量、交互也贴近移动端习惯。它的IndexBar索引栏组件就是专门为通讯录这类字母分组 快速定位场景设计的自带右侧索引条和锚点吸顶比我最初准备自己从零写一套的方案靠谱得多。1. 方案选型自己写排序还是直接上IndexBar1.1 先搞清楚通讯录排序的核心流程通讯录A-Z排序表面看起来就是按拼音首字母排个序真正拆开其实有四步先把中文姓名转成拼音再提取每个姓名的首字母接着按字母分组最后在页面上渲染分组列表并支持索引定位。这四步里最容易被低估的是第三步。直接用localeCompare做排序在英文场景下没问题但中文姓名的排序牵扯到多音字、生僻字、非中文字符的处理并不是简单调一个API就能搞定的。比如曾这个姓普通话读zēng但用作姓氏也常读céng拼音库选不好就会排错位置。另外一个容易被忽略的是非字母字符的处理。如果通讯录里有用户填了10086、Alex Chen、张三丰这样的条目数字和英文应该归到A-Z的哪个分组业界比较通用的做法是英文按首字母归组数字和其他符号统一放到#分组。Vant的IndexBar组件支持自定义索引列表和锚点这种混合场景处理起来没什么压力。1.2 为什么选Vant IndexBar而不是手写滚动监听早期移动端H5要实现字母索引定位常规做法是监听右侧索引条的touch事件手算每个字母分组在页面中的偏移量再用scrollIntoView或者window.scrollTo跳转。这套方案看着不难真做起来问题一堆滚动容器不一定是window如果页面嵌在某个内部滚动的div里偏移量计算全得重来锚点吸顶效果需要监听滚动位置动态判断当前分组逻辑很绕软键盘弹出、地址栏收起等因素会导致window.innerHeight变化滚动位置容易错乱。Vant的IndexBar组件把这套逻辑全都封装好了。它提供两个核心子组件van-index-bar是外层容器负责接收索引列表、控制锚点吸顶van-index-anchor是分组锚点放在需要定位的分组标签上。组件内部自己处理了滚动监听、触摸跳转、吸顶偏移这些脏活累活我要做的就是喂给它正确分好组的数据。注意Vant 4的IndexBar依赖vant/use里的useScrollParent和useRect这两个工具会向上查找可滚动的父容器。如果通讯录页面做了局部滚动比如外层套了一个overflow-y: auto的div记得让IndexBar的滚动容器判定生效后面我会专门讲这个问题。1.3 数据流设计组件只负责展示逻辑全部抽离我在做这个功能的时候定了一个原则数据逻辑和视图展示完全分离。通讯录的数据处理拼音转换、分组、排序全部放在一个独立的utils/contact.js模块里组件内部只保留把分组数据传给IndexBar这一件事。这样设计有几个好处。第一后续如果要从Vant换成其他UI库只需要改组件层数据处理代码一行不用动第二拼音处理、分组逻辑可以单独写单元测试排查问题的时候不用在组件里翻来翻去第三如果项目里还有其他页面也要用A-Z分组比如城市列表、商品品牌列表直接复用同一个工具函数就行。2. 核心逻辑中文转拼音与分组排序的细节2.1 拼音转换方案对比pinyin-pro还是其他库中文转拼音是整个功能的地基这一步如果数据不准后面渲染做得再漂亮都白搭。目前前端常用的拼音库主要有三个库名包体积多音字支持性能维护状态pinyin较大基础支持中停止维护较久pinyin-pro中等好支持自定义较好活跃tiny-pinyin小弱好较久未更新我最终选择的是pinyin-pro。主要原因有三个第一它支持按pattern: first直接提取首字母省去了我自己截取的步骤第二多音字处理相对智能pinyin库在遇到重庆时会固执地输出chóng qìng还是zhòng qìng全凭运气pinyin-pro可以根据常见词汇库做上下文判断第三它支持customPinyin自定义接口真遇到项目里的特殊多音字我可以手动指定读音。不过pinyin-pro也不是没有缺点。完整的pinyin-pro包体积不小如果你所在项目对首屏包体积非常敏感可以考虑按需导入只引入用到的pinyin函数。Vite项目里这样做很方便// 按需引入只导入第一个字母提取功能 import { pinyin } from pinyin-pro; // 提取张三的首字母 - Z const initial pinyin(张三, { pattern: first, toneType: none });toneType: none这个参数记得要加上否则默认输出的拼音是带声调的zhāng提取首字母时会干扰判断。2.2 分组算法实现从原始列表到字母分组数据处理的整体流程是原始联系人数组 → 遍历计算每个联系人的首字母 → 按首字母聚合 → 字母排序 → 组装成前端需要的分组结构。先看具体的分组代码// utils/contact.js import { pinyin } from pinyin-pro; /** * 获取姓名的首字母非字母字符统一归为 # * param {string} name * returns {string} */ export function getInitial(name) { if (!name || !name.trim()) return #; const trimmedName name.trim(); // 处理英文开头的情况Alex、Bob、Cindy const firstChar trimmedName.charAt(0); if (/[a-zA-Z]/.test(firstChar)) { return firstChar.toUpperCase(); } // 处理数字和其他符号 if (!/[\u4e00-\u9fa5]/.test(firstChar)) { return #; } // 中文走拼音转换 const initial pinyin(firstChar, { pattern: first, toneType: none }); const upperInitial initial.charAt(0).toUpperCase(); // 万一拼音库返回异常兜底归为 # return /[A-Z]/.test(upperInitial) ? upperInitial : #; } /** * 将原始联系人列表按首字母分组并排序 * param {Array{ name: string, [key: string]: any }} contacts * returns {Array{ letter: string, list: Array }} */ export function groupContactsByInitial(contacts) { const groupMap {}; contacts.forEach(contact { // 注意这里取的是第一个字符的首字母 // 有些通讯录会取整个名字的首字母如张三 - ZS // 但A-Z索引场景通常是按姓氏首字母分组更符合用户习惯 const letter getInitial(contact.name); if (!groupMap[letter]) { groupMap[letter] []; } groupMap[letter].push(contact); }); // 对所有分组内的联系人再做一次内部排序保证同一组内也有序 Object.keys(groupMap).forEach(letter { groupMap[letter].sort((a, b) { return a.name.localeCompare(b.name, zh-CN); }); }); // 按字母排序#分组固定排在最后 const sortedLetters Object.keys(groupMap).sort((a, b) { if (a #) return 1; if (b #) return -1; return a.localeCompare(b); }); return sortedLetters.map(letter ({ letter, list: groupMap[letter] })); }这段代码有三个细节需要特别说明。第一判断中文的正则/[\u4e00-\u9fa5]/只匹配基本汉字区域如果你处理的是日文、韩文或者繁体生僻字可能需要扩展正则范围。就通讯录场景来说基本汉字区域已经够用。第二取首字母的时候我只取了名字的第一个字符而不是整个名字的拼音首字母串。以欧阳娜娜为例取第一个字欧的首字母O分组归到O下面如果取整个名字的拼音首字母串oynn分组时就不知道该归到哪个单字母下面了。所以按姓氏首字母分组是通讯录功能里最符合用户心智的。第三分组内排序用了localeCompare(b, zh-CN)。这个API在中文环境下会按拼音排序但在不同的JavaScript引擎上表现并不完全一致实测下来核心问题不多但如果你要求非常严格可以在分组内也按拼音首字母排序这块后面在常见问题里会展开说。2.3 边界情况多音字、生僻字和特殊字符的处理通讯录数据的脏程度往往超出预期。我这里遇到过几种典型的边界情况多音字。上面说到的曾、解、单这类姓氏在不同名字里有不同读法。pinyin-pro对常见词汇有多音字辅助判断但解晓东是读xiè还是jiě它并没有把握。项目里如果明确知道某些人的姓名读音可以用customPinyin做兜底import { customPinyin } from pinyin-pro; customPinyin({ 解晓东: xie xiao dong, 单田芳: shan tian fang, });生僻字。有些汉字在拼音库里没有收录pinyin()会返回原字符这时候正则判断就派上用场了。上面代码里对非A-Z的结果统一归到#分组就是这个原因。英文名和混合字符。像Alex、Mike Chen这样的联系人直接取首字母A、M就好。但Mike Chen这类英文名中文姓的组合业务上到底按M还是按C分组需要提前和产品确认。我当时的处理是按字符串第一个字符分也就是M因为大多数用户扫一眼列表是按照自己输入的习惯来找人的他们记得自己存的名字长什么样。空名字和纯符号。这类条目虽然少见但一旦出现会导致pinyin()报错。所以我在getInitial里加了空值判断非字母非中文的情况全部引导到#分组宁可让用户去#里找也不能让整个页面白屏。3. 页面搭建与核心代码实现3.1 Vant IndexBar组件的基本用法van-index-bar和van-index-anchor的组合使用方式是这样的van-index-bar :index-listindexList :stickytrue changeonIndexChange !-- 每个分组一个锚点index属性用来标识字母 -- van-index-anchor indexA / div v-foritem in groupA :keyitem.id classcontact-item {{ item.name }} /div van-index-anchor indexB / div v-foritem in groupB :keyitem.id classcontact-item {{ item.name }} /div !-- ... -- /van-index-bar其中有几个关键属性和事件index-list右侧索引栏展示的字母数组比如[A, B, C, ..., #]。不传的话默认是A-Z。sticky是否开启锚点吸顶。开启后分组标签滚动到顶部时会固定住直到下一个分组标签把它顶上去。change当前高亮索引改变时触发参数是当前字母。可以用来做字母提示气泡或者自定义交互。实际开发中index-list我都是从分组数据里动态生成的避免出现在indexList里声明了Q但数据里没有Q分组的尴尬情况。3.2 通讯录页面完整实现先看页面组件的完整代码然后再逐段拆解template div classcontacts-page !-- 顶部搜索框非必须但通讯录一般都会配 -- van-search v-modelkeyword placeholder搜索联系人 update:model-valueonSearch / !-- 索引栏容器注意加了 class 和 ref -- div classcontacts-content refscrollWrapRef van-index-bar :index-listindexList :stickytrue sticky-offset-top0 changeonIndexChange template v-iffilteredGroups.length template v-forgroup in filteredGroups :keygroup.letter van-index-anchor :indexgroup.letter / div v-foritem in group.list :keyitem.id classcontact-item clickonContactClick(item) div classcontact-item__avatar{{ item.name.charAt(0) }}/div div classcontact-item__info div classcontact-item__name{{ item.name }}/div div classcontact-item__phone{{ item.phone || 暂无电话 }}/div /div /div /template /template div v-else classcontacts-empty van-empty description没有找到相关联系人 / /div /van-index-bar /div /div /template script setup import { ref, computed, onMounted } from vue; import { showToast } from vant; import { groupContactsByInitial, getInitial } from /utils/contact; import { fetchContacts } from /api/contact; const keyword ref(); const contacts ref([]); // 原始数据加载 onMounted(async () { const data await fetchContacts(); contacts.value data; }); // 搜索过滤按姓名或手机号模糊匹配 const filteredContacts computed(() { const kw keyword.value.trim().toLowerCase(); if (!kw) return contacts.value; return contacts.value.filter(item { return ( item.name.toLowerCase().includes(kw) || (item.phone item.phone.includes(kw)) ); }); }); // 核心分组数据 const groupedContacts computed(() { return groupContactsByInitial(filteredContacts.value); }); // 动态生成索引列表 const indexList computed(() { return groupedContacts.value.map(group group.letter); }); // 右侧索引变化时触发 const onIndexChange (index) { // 这里可以做字母提示气泡 // console.log(当前索引, index); }; // 点击联系人 const onContactClick (item) { showToast(点击了${item.name}); }; /script style scoped .contacts-page { height: 100vh; display: flex; flex-direction: column; } .contacts-content { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; } .contact-item { display: flex; align-items: center; padding: 12px 16px; background: #fff; border-bottom: 1px solid #f5f5f5; } .contact-item__avatar { width: 40px; height: 40px; border-radius: 50%; background: #1989fa; color: #fff; font-size: 18px; display: flex; align-items: center; justify-content: center; margin-right: 12px; flex-shrink: 0; } .contact-item__info { flex: 1; overflow: hidden; } .contact-item__name { font-size: 16px; color: #323233; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .contact-item__phone { font-size: 13px; color: #969799; margin-top: 4px; } /style3.3 关键代码逐段解析分组数据的computed缓存。groupedContacts用了computed而不是普通函数调用原因是IndexBar的index-list、模板里的v-for都会依赖这个数据。用computed可以让它在filteredContacts变化时自动更新避免重复计算。动态索引列表。indexList直接从分组数据里映射出来而不是写死一个[A,B,C...]的常量。这样数据里没有的分组右侧索引条也不会显示页面看起来更干净。唯一的副作用是如果分组特别少比如搜索张只有Z一组右侧索引条会显得很孤单这时候可以自己判断一下索引条是否显示。搜索过滤与分组联动。搜索框用了van-search的update:model-value事件Vant 4语法配合computed实现实时过滤。注意搜索时也要走一遍分组逻辑这样用户搜索张的时候列表只剩Z组索引栏也只剩一个Z体验是一致的。滚动容器的处理。.contacts-content是实际滚动的容器IndexBar默认会向父级查找滚动容器。如果你的通讯录页面不是全屏布局而是嵌在某个overflow-y: auto的容器里需要给IndexBar传listen-to属性指向滚动容器。van-index-bar :index-listindexList :stickytrue listen-to.contacts-content 这里有个容易踩的坑listen-to接收的是选择器字符串且这个选择器是从IndexBar组件内部向上查找的。如果.contacts-content不是IndexBar的父级这个选择器会失效页面表现就是锚点吸顶失效或者点击索引条无响应。3.4 搜索空状态的细节处理加了搜索框之后空状态的处理也要跟上。上面代码里filteredGroups.length为0时显示van-empty组件。但注意这里有个小陷阱van-index-bar内部有自己的一套滚动逻辑如果分组数据为空但它依然渲染控制台有时候会报警告。更稳妥的做法是直接v-if控制整个van-index-bar的渲染空数据的时候连IndexBar本身都不渲染van-index-bar v-iffilteredGroups.length :index-listindexList !-- 分组渲染 -- /van-index-bar van-empty v-else description没有找到相关联系人 /4. 常见问题与排查技巧实录4.1 问题一点击右侧字母索引页面没有跳转这是IndexBar组件最常遇到的问题。我排查过几次之后总结出三个高频原因原因1滚动容器识别错误。如果页面的滚动容器不是window而组件没有正确识别到索引点击事件就找不到应该滚动的目标。解决方法是显式指定listen-to属性指向实际的滚动容器。有同事遇到过把listen-to的值写成了选择器但作用域不对IndexBar内部用了document.querySelector去全局找结果找到了页面里另一个同名元素这种情况改用ref调用会更稳妥。原因2锚点的index与分组数据不匹配。IndexBar点击跳转的原理是找到van-index-anchor中index属性等于当前点击项的锚点然后滚动到它所在位置。如果锚点index重复或者缺失跳转就会失败。排查时可以打印一下渲染出来的锚点index列表和indexList比对是否完全一致。原因3锚点被v-if或v-show隐藏。某些情况下分组列表会配合v-if做懒加载索引点击时锚点还没渲染出来自然跳不过去。解决思路是确保v-if的判定条件在首屏就是true或者改用v-show。4.2 问题二锚点吸顶生效但吸顶位置有偏移sticky属性开启后垂直滚动时锚点标签会固定下来。默认情况下锚点固定是紧贴容器顶部但如果页面有头部导航栏或者搜索框占据了一部分高度固定的位置就会不对——锚点会躲到头部后面去。Vant提供了一个sticky-offset-top属性用来设置吸顶时距离顶部的偏移量。如果通讯录页面顶部有64px的导航栏就设sticky-offset-top64。这个属性在Vant 4里接收的是数字单位是px不需要写px后缀。注意sticky-offset-top是相对于window顶端计算的。如果滚动容器不是window这个偏移量可能依然不准。我遇到过一次页面嵌在iframe里的情况偏移量怎么调都差一点最后直接把滚动容器改成window才解决。4.3 问题三中文排序不稳定同一个姓氏出现在不同分组这通常是拼音转换库和排序API之间配合的问题。localeCompare在Chrome和Safari里对中文的处理有差异zh-CN语言环境下绝大多数字符能按拼音排但某些生僻字或者特殊符号会被排到意想不到的位置。我采用的方案是分组用拼音首字母组内排序用拼音全拼。也就是说计算每个联系人的完整拼音不带声调组内排序时直接比较拼音字符串。这种方式比localeCompare更稳定import { pinyin } from pinyin-pro; // 给联系人缓存一个拼音字段 function attachPinyin(contact) { contact._pinyin pinyin(contact.name, { toneType: none, type: array }).join(); } // 组内排序时比较拼音 group.list.sort((a, b) { if (a._pinyin b._pinyin) return -1; if (a._pinyin b._pinyin) return 1; return 0; });当然这样做的代价是拼音转换会多执行一次。数据量小无所谓如果通讯录有几千人建议在拉取数据后统一预处理把拼音结果缓存到联系人对象上后面排序和搜索都用缓存值。4.4 问题四大数据量渲染卡顿通讯录数据上千条时一次性渲染所有联系人列表即使Vue的虚拟DOM做了优化页面滚动还是会掉帧。有两条优化路径路径1分组懒渲染。只渲染可视区域附近的分组。这个方案实现起来有复杂度需要动态计算每个分组的位置非必要不建议自己造轮子。路径2上拉加载。先渲染前几个分组滚动到底部时再加载后续分组。用Vant的van-list组件可以配合实现但注意van-list和van-index-bar的滚动事件可能会冲突需要把van-list放在每个分组内部而不是包在IndexBar外层。路径3虚拟滚动。如果数据量大到两千条以上建议认真考虑虚拟滚动。Vant本身没有内置虚拟滚动列表可以配合vue-virtual-scroller或者tanstack/vue-virtual使用。这里有一个思路IndexBar的索引跳转依然保留但列表区改用虚拟滚动渲染两者通过当前激活的分组关联起来。实现成本不低但对长列表的性能提升是质的飞跃。我在实际项目里用过一个折中方案右侧索引条保留完整的A-Z列表区加van-list做分页懒加载。每次用户点击索引只渲染目标分组的前20条滚动到底部再加载更多。这样既保留了索引跳转的交互又不会一次渲染上千个节点体验和数据量之间做了个平衡。4.5 问题五iOS上滚动不流畅、锚点吸顶闪烁移动端H5很容易在iOS的WebView里出现滚动不流畅的问题。解决方案有两处一是给滚动容器加上-webkit-overflow-scrolling: touch;这个老牌属性在iOS 13之后其实已经被系统默认启用了但加了也无妨二是确保滚动容器内部没有大量复杂阴影和渐变减少滚动时的重绘压力。锚点吸顶闪烁的问题和position: sticky在iOS Safari上的历史bug有关。Vant内部用position: sticky实现吸顶某些旧版本iOS会抖动。升级到Vant 4.8以上官方已经修复了大部分吸顶闪烁问题。如果项目强制使用旧版Vant也可以把sticky属性关掉牺牲一点体验换取稳定性。5. 性能优化与功能扩展方向5.1 数据预处理从接口层就开始分好组通讯录数据和普通列表数据不一样它天然适合在服务端就做好分组和排序前端拿到的就直接是分好组的结构化数据。我踩过一次坑之后和后台同事约定了一个接口格式{ code: 0, data: { A: [{ id: 1, name: Alice }], B: [{ id: 2, name: Bob }], #: [{ id: 3, name: 12306 }] } }前端拿到这个结构只做一次Object.entries转换就能直接渲染省掉了前端拼音转换的计算开销。但要实现这个方案前提是后端能够使用同样的拼音库做数据处理。如果后端技术人员明确表示无法处理拼音转换那就只能前端自己扛。一个折中方案是前端做拼音转换转换结果只作为接口请求参数传回去由后端做排序GET /api/contacts?sortpinyininitialA这种做法的好处是前端不用保存一份完整数据在内存里通讯录数据量大的时候页面内存占用会小很多。5.2 搜索联动的优化防抖和拼音搜索van-search组件绑定的keyword在用户每次输入时都会触发更新如果通讯录数据量大每一次输入都重新走一遍分组逻辑和渲染性能消耗不小。标准的优化手段是防抖import { watch, ref } from vue; import { debounce } from lodash-es; const keyword ref(); const onSearch debounce((value) { keyword.value value; }, 300);另外一个容易被忽略的需求是拼音搜索。用户搜索zhangsan期望能匹配到张三。这个功能在通讯录场景里出现频率很高实现也不复杂给联系人数据增加一个拼音全拼字段搜索时同时匹配姓名、手机号和拼音全拼const filteredContacts computed(() { const kw keyword.value.trim().toLowerCase(); if (!kw) return contacts.value; return contacts.value.filter(item { return ( item.name.toLowerCase().includes(kw) || (item.phone item.phone.includes(kw)) || (item._pinyin item._pinyin.includes(kw)) ); }); });注意_pinyin字段要在数据加载时统一计算好如果每次filter都现场转拼音性能就是灾难。5.3 右侧索引条的自定义与增强Vant的IndexBar右侧索引条默认就是字母排列如果想加一些额外的交互效果可以通过van-index-bar的插槽或者CSS样式定制。比如很多通讯录App会在手指滑过索引条时在屏幕中间显示一个当前字母的大写气泡这个效果Vant没有内置需要自己实现。实现思路是监听change事件拿到当前字母用一个v-show控制的气泡组件展示van-index-bar changeonIndexChange !-- 分组内容 -- /van-index-bar div classletter-toast v-showcurrentLetter :class{ show: showToast } {{ currentLetter }} /div在实际交替体验中气泡组件的开关时间控制非常关键显示太快用户看不清显示太久又挡住内容。我最后调成了touch开始显示、touch结束150ms后隐藏效果勉强接近原生App的手感。5.4 扩展到城市列表、品牌列表等场景A-Z分组的应用场景并不局限于通讯录电商App里的城市选择器、商品品牌筛选、甚至歌曲榜单都大量使用这种交互。只要能复用groupContactsByInitial这类工具函数改成城市名或者品牌名再换成对应的业务数据功能就完成了。我在项目里把分组工具函数抽成了通用模块入参是列表 要分组的字段名export function groupByInitial(list, fieldName name) { const groupMap {}; list.forEach(item { const value item[fieldName]; const letter getInitial(value); if (!groupMap[letter]) { groupMap[letter] []; } groupMap[letter].push(item); }); // ... 后续排序逻辑 }这样通讯录用groupByInitial(contacts, name)城市列表用groupByInitial(cities, cityName)一个函数到处复用维护成本降了不少。我实际开发中还有一个经验A-Z分组的数据结构最好在状态管理里就保持不分组的原始数组和分组后的视图数据两份。原始数组用于搜索、编辑、批量操作分组数据只用于渲染。如果直接拿分组后的数据去操作改一个联系人的姓名可能要从多个分组里增删数据非常容易出bug。最后再分享一个Vant组件库版本相关的小建议如果你用的还是Vant 2Vue 2项目IndexBar组件的部分API名和Vant 4不同比如Vant 2的index-list默认是A-Z全量渲染Vant 4则默认只渲染标明的列表。升级Vant大版本时这类组件API的变化是最容易被忽略的建议升级前仔细查一下官方迁移文档里和IndexBar相关的变更记录能省掉不少排查时间。
网站建设高端定制企业官网