ARTICLE DETAIL

资讯详情

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

Vue 中文转拼音实战:选型、多音字、缓存与 A-Z 索引

Vue 中文转拼音实战:选型、多音字、缓存与 A-Z 索引 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-protiny-pinyinnode-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 带来的额外deleteset操作反而增加了开销而命中率提升有限。如果你的场景是用户反复搜同一批关键词那 LRU 更合适改起来也就是命中时多两行代码。3.2 为什么不用全局过滤器Vue 3 里该用 computedVue 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里面放二三十条真实姓名和公司名包含那些已知的多音字坑。升级依赖、调整词典、改匹配逻辑之后跑一下两秒钟就知道有没有把原来对的东西改坏。这比上线后靠用户来告诉你哪个名字搜不到成本低太多了。
返回列表