1. 从"输入 zjl 能搜出张结林"说起:拼音转换到底解决哪些业务问题
Vue 项目里做中文汉字转拼音,最常见的起点根本不是技术选型,而是一句产品需求。我最近接手的一个客户管理后台,需求文档上就写了一行字:"联系人搜索框支持拼音首字母搜索,输入 zjl 要能搜出张结林。"看起来是一个小功能,真动手做就会发现,背后牵着四个独立问题:汉字怎么变成拼音、多音字怎么判、全拼和首字母怎么匹配、几千条数据实时转换会不会卡。
这篇文章不聊空泛的概念,只讲我在 Vue 项目里做中文汉字转拼音这件事的实际路径。核心结论先放这儿:转换本身不难,难的是选对库、管住缓存、处理好姓名里的多音字,以及算清楚它给打包体积带来的代价。适合已经会写 Vue 3 组合式函数、正在做通讯录 / 客户管理 / 组织架构 / 商品检索这类带中文列表的开发者,也适合只想搞清楚"这活儿到底该不该引第三方库"的同学。
1.1 五个最容易遇到拼音需求的界面
我把近两年做过的项目翻了一遍,中文汉字转拼音的落点其实高度集中,基本逃不出下面这五类:
- 通讯录 / 组织架构的 A-Z 索引:右侧那条从 A 到 Z 的字母条,点击滚动到对应分组,分组依据就是姓名首字母。这是最典型、也最必须用库的场景,因为
Intl.Collator只能给你排序结果,给不了"这个字属于哪个字母"。 - 搜索联想与模糊匹配:输入全拼
zhangjielin、首字母zjl、甚至混合的zhangjl,都要能命中。这个场景对转换结果的完整度要求最高。 - 列表排序:按姓名拼音升序排列。这个场景反直觉,往下看 1.2 节。
- 表单自动填充与账号生成:注册页输入中文名,自动生成拼音账号;导出文件时用中文标题的拼音做文件名,规避部分系统对中文文件名的兼容问题。
- SEO 友好的 slug:文章标题转拼音生成 URL 片段,替代一长串随机 ID。
五个场景可以归成两类:需要"字母"的(索引、账号、slug)和需要"顺序"的(排序)。分类之后,选型思路会清晰很多。
1.2 纯排序场景其实不需要引库:Intl.Collator 的一次实测
这是我最想先讲清楚的一个点,因为见过太多次"为了排序引了一整个拼音词典"的浪费。浏览器内置的Intl.Collator在zh-Hans-CN语言环境下,默认排序规则就是按拼音,而且对多音字、声调的处理比大多数前端库都要稳。
// 浏览器内置能力,零依赖 const collator = new Intl.Collator('zh-Hans-CN', { sensitivity: 'variant' }) const names = ['张三', '李四', '王五', '欧阳', '陈六'] names.sort(collator.compare) // ['陈六', '李四', '欧阳', '王五', '张三'] // 直接用 a.localeCompare(b, 'zh-Hans-CN') 也行,但循环里反复构造 // Collator 实例会有额外开销,大列表建议复用同一个实例实测下来,一万条姓名数据,Intl.Collator排序耗时在 20ms 到 40ms 之间,而用拼音库先生成拼音字符串再排序,耗时会翻好几倍,还要额外背上词典体积。所以我在项目里的判断标准很明确:
如果只需要"排序正确",用
Intl.Collator;如果还需要"取出首字母、取出全拼、做字符串匹配",才引拼音库。
唯一需要注意的是兼容性。这个方法在主流浏览器的现代版本里都没问题,但如果你的项目要兼容很老的运行时(比如某些嵌入式的 WebView),建议先写个小 Demo 在目标环境里跑一遍,把['张三','李四'].sort(collator.compare)的结果打印出来确认,别等上线后用户反馈排序乱了才发现。
2. 选库之前先把这三个硬指标定下来:体积、多音字、构建方式
选拼音库这件事,网上大多数对比文章只列功能,不列代价。而我的经验是,功能差异远没有体积和构建方式的差异致命。一个 gzip 后 40KB 的词典塞进首屏,比多音字偶尔错一个更让性能组抓狂。
2.1 pinyin-pro、tiny-pinyin、node-pinyin 的横向对比
这三个是我实际在项目里用过的,各自的定位差别很明显。下面的体积是量级参考(不同版本差异不小),具体数字一定要自己用产物分析工具实测。
| 维度 | pinyin-pro | tiny-pinyin | node-pinyin |
|---|---|---|---|
| 主要能力 | 全拼、首字母、声母韵母、声调、多音字枚举 | 全拼、首字母(简拼) | 全拼、首字母、声调、异读字 |
| 体积量级(gzip) | 偏大,词典占大头 | 很小,约几 KB | 中等,取决于加载的词典包 |
| 多音字处理 | 支持词组语境判断,准确率较好 | 基本不处理,按常用读音 | 支持,词典可裁剪 |
| TypeScript | 原生类型完备 | 类型一般 | 需要自己补类型声明 |
| 构建友好度 | 提供 ESM 产物,Vite 下直接可用 | 直接可用 | 偏 CommonJS,需注意互操作 |
| 适合场景 | 姓名搜索、多音字敏感的业务 | 只做首字母索引、极致省体积 | 老项目、需要细粒度控制词典 |
我的最终选择:默认用 pinyin-pro,只在"页面只需要首字母索引、且对首屏体积极度敏感"时才退而求其次用 tiny-pinyin。原因是首字母索引这个场景,姓名的多音字错一个,用户就会在字母条里找不到人,这个体验损失比几十 KB 体积更贵。
node-pinyin 我现在基本不用了,主要原因是它的产物偏 CommonJS,在 Vite 里虽然能自动互操作,但一旦遇上 SSR 或者打包配置调整,容易出现默认导出拿到的是对象而不是函数的情况,排查起来很烦。如果团队有历史代码在用,保持不动是可以的,新项目不建议再引入。
2.2 Vite 项目里三种库的引入方式和踩过的坑
先说 pinyin-pro 的基础用法,命名导出,按需引入即可:
import { pinyin } from 'pinyin-pro' // 带声调的全拼 pinyin('张结林') // 'zhāng jié lín' // 不带声调,按字返回数组,长度和汉字数量对齐 pinyin('张结林', { toneType: 'none', type: 'array' }) // ['zhang', 'jie', 'lin'] // 首字母 pinyin('张结林', { pattern: 'first', toneType: 'none', type: 'array' }) // ['z', 'j', 'l'] // 保留非中文字符的选项,中英混排时很有用 pinyin('Tom 张', { toneType: 'none', nonZh: 'consecutive' })tiny-pinyin 是默认导出,而且它的parse返回的是一个带type字段的词元数组,type === 2表示汉字:
import tinyPinyin from 'tiny-pinyin' if (tinyPinyin.isSupported()) { tinyPinyin.convertToPinyin('张结林') // 'zhangjielin' // 取首字母需要自己遍历词元 const initials = tinyPinyin.parse('张结林') .map(t => (t.type === 2 ? t.target[0] : t.target)) .join('') }踩过的坑有两个,都挺隐蔽。第一个是tiny-pinyin 需要先判断isSupported(),它依赖一部分环境能力,在某些裁剪过的运行时里可能不可用,这时候要有降级方案(直接返回原字符串,索引归到"其他"分组),别让整个列表渲染崩掉。
第二个是Vite 的依赖预构建。pinyin-pro 这类带大词典的包,开发阶段第一次启动时预构建会慢一点,这是正常的,不用慌;但如果你在optimizeDeps.exclude里手动排除了它,开发环境会频繁重新构建,反而更慢。我现在的做法是保持默认,什么都不配。
提示:引入拼音库之后,务必跑一次产物分析,看清楚这个库到底进了首屏 chunk 还是独立 chunk。如果只是"通讯录"这一个页面用,用动态
import()把它拆出去,首屏能省下来不少。
3. 把转换能力封成 composable:缓存、对齐与内存边界
直接在每个组件里import { pinyin } from 'pinyin-pro'然后随手调用,是项目后期最难维护的写法。原因有三个:同样的字符串被反复转换、不同地方用的参数不一致(有的带声调有的不带)、多音字的覆盖规则散落各处。我的做法是统一收敛到一个工具模块,再包一层组合式函数。
3.1 一个带 LRU 上限的转换函数
先说缓存。拼音转换是纯函数计算,输入相同结果必然相同,天然适合缓存。但缓存不能无限增长,一个持续滚动加载的通讯录,用户翻半小时就能塞进去几万条记录。
// src/utils/pinyin.js import { pinyin } from 'pinyin-pro' const CACHE_LIMIT = 5000 const cache = new Map() function buildKey(text, options) { return [ text, options.pattern || 'full', options.toneType || 'tone', options.multiple ? 'm' : 's' ].join('\u0001') } function writeCache(key, value) { // Map 保持插入顺序,超限就淘汰最早插入的一条 if (cache.size >= CACHE_LIMIT) { const oldest = cache.keys().next().value cache.delete(oldest) } cache.set(key, value) } export function pinyinArray(text, options = {}) { if (!text) return [] const merged = { toneType: 'none', type: 'array', ...options } const key = buildKey(text, merged) const hit = cache.get(key) if (hit) return hit const result = pinyin(text, merged) writeCache(key, result) return result } export function pinyinString(text, options = {}) { const sep = options.separator ?? '' return pinyinArray(text, options).join(sep) } export function getInitials(text) { return pinyinString(text, { pattern: 'first' }) }这里有两个细节值得展开。第一,type: 'array'是刻意选的。数组形式的好处是下标和汉字一一对应,后面做"拼音命中位置映射回汉字下标"时,这个对齐关系是刚需。返回一个拼接好的字符串虽然省事,但丢掉了位置信息,再想加高亮就得反过来解析。
第二,缓存上限的淘汰策略用的是最简单的 FIFO。有人会问为什么不上 LRU(每次命中就把它挪到队尾)。我的判断是,在通讯录这种"数据顺序相对固定"的场景下,LRU 带来的额外delete+set操作反而增加了开销,而命中率提升有限。如果你的场景是"用户反复搜同一批关键词",那 LRU 更合适,改起来也就是命中时多两行代码。
3.2 为什么不用全局过滤器:Vue 3 里该用 computed
Vue 2 时代很多人习惯写filters: { pinyin },模板里{{ name | pinyin }}。Vue 3 已经移除了过滤器,而且就算还在,我也不建议用在这个场景上,原因是模板里每次渲染都会重新触发转换,而转换本身开销不低。
正确的位置是computed,并且是"数据源变化时算一次"的那种:
<script setup> import { computed, toRef } from 'vue' import { pinyinString, getInitials } from '@/utils/pinyin' const props = defineProps({ contacts: { type: Array, default: () => [] } }) // 带索引的数据,只在 contacts 变化时重算一次 const indexedContacts = computed(() => props.contacts.map(item => ({ ...item, _full: pinyinString(item.name), _initial: (getInitials(item.name)[0] || '#').toUpperCase() })) ) </script>这里的性能差别很直观:一个 2000 条的列表,如果放在模板方法里,每次滚动、每次 hover 触发重渲染都可能重算一遍,体感就是列表卡顿;放在computed里,只有props.contacts真正变化时才走一遍转换。这个改动我在项目里做过一次,某个通讯录页面的滚动帧率直接从 40 出头回到满帧。
如果你的项目是多页面共用一个转换能力,也可以在入口用app.config.globalProperties.$pinyin = pinyinString注册全局方法。但我不太推荐,因为全局方法没有类型提示、没法 tree-shaking、也不好测试,computed加显式 import 的组合更清晰。
4. 多音字才是深水区:姓名、地名和"重庆"这种词的三种解法
拼音转换最容易翻车的地方,不是技术实现,是中文本身的歧义。"重庆"里的"重"读 chóng,"重要"里读 zhòng;"单"做姓读 shàn,做词读 dān;"解"做姓读 xiè,做动词读 jiě。这类问题在任何语言模型之外的工具里都不可能百分之百解决,所以务实的做法是接受不完美,然后用策略把影响面压到最小。
4.1 词典覆盖 + 最长匹配的思路
我在项目里用的是一个"三层过滤"的结构,从可靠到不可靠依次尝试。
第一层是业务自定义词典。这是准确率最高的一层,因为它是人为确认过的。比如公司里有个同事姓"苑",或者一批客户名字里有生僻多音字,直接维护成一张表:
// 项目自有的姓名/术语词典,优先级最高 const USER_DICT = { '重庆': 'chong qing', '单田芳': 'shan tian fang', '解晓东': 'xie xiao dong', '曾志伟': 'zeng zhi wei', '朴树': 'piao shu', '尉迟': 'yu chi' }第二层是最长匹配切分。为什么要最长匹配而不是逐字查表?因为"尉迟"这个复姓如果按单字查,会得到 wèi chí,而正确读音是 yù chí。所以切分的时候要从当前位置往后尝试尽可能长的词条:
const MAX_WORD_LEN = 4 function splitByDict(text, dict) { const segments = [] let cursor = 0 while (cursor < text.length) { let matched = '' for (let len = MAX_WORD_LEN; len >= 1; len--) { const slice = text.slice(cursor, cursor + len) if (slice.length === len && dict[slice]) { matched = slice break } } if (matched) { segments.push({ text: matched, dict: true }) cursor += matched.length } else { // 没命中词典的字符,先单独拿出来,稍后交给拼音库整体处理 let end = cursor + 1 while (end < text.length) { let hitLong = false for (let len = MAX_WORD_LEN; len >= 1; len--) { if (dict[text.slice(end, end + len)]) { hitLong = true; break } } if (hitLong) break end++ } segments.push({ text: text.slice(cursor, end), dict: false }) cursor = end } } return segments }第三层才是交给拼音库处理剩余片段。这里有个关键点:没命中词典的片段要整段送去转换,而不是拆成一个个单字。因为库内部是靠词组语境判断多音字的,你拆成单字等于把上下文丢掉了,"重庆"就再也判不对了。
顺便说一句,pinyin-pro 在一些较新的版本里内置了姓氏模式和自定义拼音能力。这套内置能力确实能省事,但我不建议把准确率完全押在它上面,原因很实际:升级版本的时候行为可能变化,而且自定义词典的写法各版本不完全一致。上面这套"业务词典 + 最长匹配 + 整段兜底"的逻辑是版本无关的,哪怕将来换库也能原样保留。
4.2 姓氏识别和自建词表的维护成本
有个现实问题必须说:自建词表是要长期维护的。我给团队定的规矩是三条。
第一条,词表只收"库里判错且业务上确实经常出现"的词,不收生僻字娱乐式的补充。曾经有同事想把百家姓全塞进去,被我拦了——真正会判错的就是那么十几个多音字姓氏,全量塞进去只会让词表膨胀、最长匹配变慢。
第二条,词表要写在独立的配置文件里,加注释说明为什么要加这一条,方便后人判断能不能删。我见过最离谱的一次是词表里有一条注释都没有,半年后没人敢动,最后变成一个谁都不敢碰的黑盒。
第三条,加一个最小的单元测试兜住回归。不需要写多复杂,一个数组跑一遍断言就行:
const cases = [ ['重庆', 'chong qing'], ['重要', 'zhong yao'], ['单田芳', 'shan tian fang'], ['Tom', 'Tom'] ]这个测试的价值在升级拼音库版本时会体现出来,跑一遍就知道新版本有没有把原来判对的词判错了。
5. 通讯录的 A-Z 索引条与拼音高亮搜索实现
前面都是铺垫,这一节是完整可复现的核心实现。通讯录这个组件看着简单,其实把拼音能力的所有要求都凑齐了:分组、排序、搜索、高亮。
5.1 分组与首字母取值
分组逻辑的关键是异常值兜底。数字、英文、符号开头的名字,它们没有合法的 A-Z 首字母,必须统一归到一个"#"分组,否则排序的时候undefined会到处乱窜。
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') function resolveLetter(name) { if (!name) return '#' const first = getInitials(name)[0] if (!first) return '#' const upper = first.toUpperCase() return LETTERS.includes(upper) ? upper : '#' } export function groupContacts(list) { const bucket = new Map() for (const item of list) { const letter = resolveLetter(item.name) if (!bucket.has(letter)) bucket.set(letter, []) bucket.get(letter).push({ ...item, _letter: letter }) } // 按 A-Z 排,# 放最后 return [...bucket.entries()] .sort((a, b) => { if (a[0] === '#') return 1 if (b[0] === '#') return -1 return a[0] < b[0] ? -1 : 1 }) .map(([letter, items]) => ({ letter, items: items.sort((x, y) => pinyinString(x.name).localeCompare(pinyinString(y.name)) ) })) }注意组内排序这一步:先转成无调拼音字符串,再直接字符串比较,不要转完了还去调localeCompare(b, 'zh')。因为转完之后已经是纯拉丁字母了,直接比较既快又避免了不同引擎对语言参数处理的差异。这个细节我在两个浏览器上对比过,结果是一致且稳定的。
右侧字母条的实现就简单了,LETTERS加上可能的'#'渲染成按钮,点击时用scrollIntoView定位到对应分组的锚点。滚动联动那部分需要监听滚动计算当前可视分组,如果项目里已经有虚拟列表组件,直接用它的滚动回调会更省事。
5.2 拼音命中下标怎么映射回汉字
这才是搜索高亮里真正有难度的部分。用户输入zjl,你要在"张结林"这三个字上把匹配的部分标红——可拼音是 3 个字母,汉字是 3 个字,怎么知道该标哪几个字?
思路是:首字母匹配时,拼音串上第 i 个字符天然对应第 i 个汉字,这个映射是直接的;全拼匹配时,拼音串的位置需要靠"每个字的拼音长度"累积换算回汉字下标。
// 逐字切片:利用 pinyin-pro 的数组结果与汉字位置对齐的特性 import { pinyin } from 'pinyin-pro' function sliceChars(text) { const full = pinyin(text, { toneType: 'none', type: 'array' }) return text.split('').map((char, i) => ({ char, full: (full[i] || char).toLowerCase(), initial: ((full[i] || char)[0] || '').toLowerCase() })) } function matchByInitial(chars, kw) { const str = chars.map(c => c.initial).join('') const i = str.indexOf(kw) if (i < 0) return null return { start: i, end: i + kw.length - 1 } } function matchByFull(chars, kw) { const prefix = [0] let acc = '' for (const c of chars) { acc += c.full prefix.push(acc.length) } const i = acc.indexOf(kw) if (i < 0) return null let start = 0 while (start + 1 < prefix.length && prefix[start + 1] <= i) start++ let end = start while (end < chars.length - 1 && prefix[end + 1] < i + kw.length) end++ return { start, end } } export function locate(text, keyword) { const kw = keyword.toLowerCase().replace(/\s/g, '') if (!kw) return null const chars = sliceChars(text) return matchByInitial(chars, kw) || matchByFull(chars, kw) }拿到{ start, end }之后,模板里把文字按区间切成三段渲染就行,v-for或者三个span都可以。别用v-html拼字符串,姓名是用户输入,直接拼 HTML 会有注入风险,这个坑我希望你一次都别踩。
注意:这里依赖"拼音数组与汉字位置一一对齐"这个前提。绝大多数情况成立,但遇到组合字符、部分特殊符号时可能错位。稳妥的做法是先断言
full.length === text.length,不相等就退化成整串匹配、不做高亮,宁可不高亮也不要标错位置。
6. 五千行通讯录卡住的三秒:性能问题的定位与三档解法
性能这块我踩过最惨的一次,是一个组织架构页面,一进去整个界面僵住两三秒,浏览器还弹了个"页面无响应"的提示。当时第一反应是渲染问题,查了半天 DOM 数量,最后发现瓶颈根本不在渲染,在拼音转换。
6.1 预热与缓存
先说怎么定位。我的做法是在转换函数里临时打点:
const t0 = performance.now() const result = pinyin(text, merged) const cost = performance.now() - t0 if (cost > 5) console.warn('slow pinyin:', text, cost)跑一遍就清楚了:第一次调用特别慢,因为词典要做初始化,之后单次调用就掉到零点几毫秒。5000 条姓名乘以 3 个字段(姓名、部门、备注),就是 15000 次调用,如果每次都撞上未命中缓存的冷路径,加起来就是好几秒。
第一个解法也是最有效的一个:预热 + 缓存复用。应用启动后、列表渲染前,先跑几个无意义的字符串把词典加载起来;同时确保同一批数据只转换一次(就是在 3.2 节说的computed)。这两步做完,我那个页面的首屏白屏时间从两秒多降到了 300ms 以内。
第二个解法是只转必要的字段。很多人的做法是把整条记录的所有字段都转一遍,但搜索其实只需要匹配姓名和部门。把转换范围收窄到真正参与搜索的字段,工作量立刻减半。
6.2 Web Worker 与后端预生成
如果数据量再上一个量级,比如一次性加载几万条客户数据,前端缓存也扛不住,这时候有两个正规解法。
方案一,Web Worker 把转换挪出主线程。主线程只负责发数据和收结果,界面不会卡。代价是数据结构要通过postMessage传输,大数组的序列化本身也有成本,所以必须批量传、分批收。
// pinyin.worker.js import { pinyin } from 'pinyin-pro' self.onmessage = (e) => { const { id, list, fields } = e.data const prepared = list.map(item => { const extra = {} for (const f of fields) { extra[f + '_py'] = pinyin(item[f] || '', { toneType: 'none' }).replace(/\s/g, '') extra[f + '_init'] = pinyin(item[f] || '', { pattern: 'first', toneType: 'none' }).replace(/\s/g, '') } return { ...item, ...extra } }) self.postMessage({ id, prepared }) }方案二,也是我更推荐的:让后端在返回数据时就把拼音字段带上。理由很朴素——这份数据落库的时候就能算好,一次计算永久受益,前端只需要消费。而且后端算完还能顺便建索引,搜索性能不是一个量级。前端要做的工作就只剩"如果接口没返回拼音字段,就走本地兜底转换",兼容性也很容易兜住。
我现在的默认策略是:新项目一律先跟后端确认能不能加拼音字段,能加就加;加不了的存量接口,走 Worker;数据量小于 1000 条,直接主线程加缓存就够了。三档分级,别一上来就上 Worker,那是过度设计。
7. 打包体积、繁简混排和生僻字:上生产前必须过一遍的检查清单
前面讲的是怎么做,这一节讲怎么不出事。拼音库这个依赖有个特点:它安静地待在那里,平时不惹麻烦,一旦出问题就是打包体积超标或者某类字符渲染异常。
7.1 体积怎么测、怎么拆
不要凭感觉判断体积。用产物分析工具跑一次,找到拼音库所在的 chunk,看清楚它 gzip 后到底多大。我一般的处理顺序是:
- 确认它没有进首屏 chunk。如果只有"通讯录""客户列表"这几个页面用,一定用动态
import()拆成独立的异步 chunk,用户不打开这些页面就不下载。 - 检查有没有被重复打包。多入口项目里,如果每个入口都
import了拼音库,构建工具可能给你打进去多份,这个问题在分析图上一眼就能看出来。 - 评估能不能接受这个体积。如果通讯录是核心功能,词典必须下;如果是边缘功能,考虑用简版库。
// 页面级懒加载,拼音能力跟着页面走 const OpenContacts = defineAsyncComponent(() => import('./pages/Contacts.vue'))另外提醒一句,预构建缓存和依赖缓存这种东西,加库之后第一次启动慢是正常的,别急着去改构建配置,先多跑两次看看。
7.2 边界字符的处理策略
下面这张表是我这两年积累的边界情况清单,每一条都是实际遇到过问题的:
| 输入情况 | 表现 | 处理策略 |
|---|---|---|
| 中英混排,如 "Tom张" | 英文部分不该被逐字母拆开 | 保留连续非中文字符,不要按字符强行切分 |
| 全角空格、换行符 | 拼音结果里混入空白,搜索时匹配不上 | 入库前统一 trim 并归一化空白字符 |
| 繁体字 | 部分库能处理,部分直接原样返回 | 先做简繁判断,必要时先转换再转拼音 |
| 生僻字 | 返回原字符而不是拼音 | 首字母取值时兜底成 "#",不要让它变成 undefined |
| 纯数字、纯符号 | 无拼音 | 索引归到 "#" 分组,排序按原字符 |
| 空字符串、null | 报错或返回空数组 | 函数入口先判空,返回空数组或空字符串 |
有一类问题我要单独强调:拼音转换返回的结果,长度不一定等于输入的字符数。比如某些库对连续英文单词会整体保留,这时候按位置对齐的逻辑就会错位。所以我在sliceChars里写了那个对齐断言,不是为了好看,是真的踩过——上线后有人搜一个带英文名的联系人,高亮标在了错误的位置上。
还有一个容易被忽略的是姓氏兜底。如果用户输入的名字首字母是数字或者符号,你的字母条上就没有对应的位置可以跳转,这时候要么归到 "#",要么干脆不显示索引点。我在项目里的做法是:始终渲染一个 "#" 分组,哪怕它是空的,避免字母条高度跳动。
关于这套东西的后续扩展,我个人觉得最有价值的方向是走服务端。前端做拼音转换,本质上是在重复计算一份本可以持久化的数据。等哪天后端愿意加一个拼音字段和对应的索引,前端这边的词典、缓存、Worker 全都可以删掉,代码量和体积同时下降一大截。我上一次推动这个改造花了两周对齐接口,收益是首屏减了三十多 KB、搜索响应从百毫秒级降到十毫秒级,这笔账怎么算都划算。
最后分享一个小习惯:我在每个用到拼音转换的项目里都会留一个pinyin.test.js,里面放二三十条真实姓名和公司名,包含那些已知的多音字坑。升级依赖、调整词典、改匹配逻辑之后跑一下,两秒钟就知道有没有把原来对的东西改坏。这比上线后靠用户来告诉你哪个名字搜不到,成本低太多了。