先记住这个答案
JavaScript 字符串是 UTF-16 编码的,😀 这类 emoji 在内部占两个代码单元,叫高代理项和低代理项,必须按固定顺序挨在一起才能组成一个字符。split('') 按代码单元拆分,会把这一对拆开,reverse() 之后顺序变成「低、高」,已经不是合法的字符,所以显示成乱码。改用 [...str] 或 Array.from(str),它们按码点拆分,一个 emoji 是一个整体。但家庭 emoji、国旗、带组合重音的字母是多个码点拼成的,要彻底正确,得用 Intl.Segmenter 按用户看到的字符来拆。
split('')按代码单元拆,会把 emoji 切成两半[...str]按码点拆,单个 emoji 不会坏- 国旗、家庭 emoji、组合重音要按字素簇反转
Intl.Segmenter的grapheme粒度就是字素簇
为什么 split('') 会出乱码
面试里这道题一般是从「写一个字符串反转」开始的,你写了 str.split('').reverse().join(''),面试官接着问:如果字符串里有 emoji 呢?
const text = 'A😀B'
console.log(text.split('').length) // 4:emoji 被切成了两个单位
const bad = text.split('').reverse().join('')
console.log(JSON.stringify(bad)) // 用 JSON 转义看清楚内部顺序
console.log([...text].reverse().join('')) // 按码点拆,emoji 完整
console.log(Array.from(text).reverse().join('')) // 和展开写法等价查看输出与解释
4
"B\ude00\ud83dA"
B😀A
B😀A😀 的编码是高代理项 \ud83d 加低代理项 \ude00。split('') 把它们拆成两个元素,反转后变成 \ude00\ud83d,低的跑到了前面。这两个代码单元单独都不代表任何字符,页面上通常显示成两个替换字符 �。
[...text] 能修好,是因为展开语法调用的是字符串的迭代器,而字符串迭代器是按码点前进的:遇到一个高代理项,会连同后面的低代理项一起作为一项产出。Array.from 走的也是同一个迭代器。
按码点反转还不够的三种情况
很多人答到 Array.from 就停了。但有不少「一个字」是由多个码点拼成的,按码点反转会把它们拆散。
const family = '👨\u200D👩\u200D👧' // 爸爸、妈妈、女儿用零宽连接符串起来
console.log(family.length, [...family].length) // 8 个代码单元,5 个码点
const flags = '🇨🇳🇯🇵' // 每个国旗是两个区域指示符字母
console.log([...flags].reverse().join('') === '🇵🇯🇳🇨') // 被重新配对成了别的组合
const cafe = 'cafe\u0301' // é 写成 e + 组合重音
console.log([...cafe].reverse()[0] === '\u0301') // 重音跑到了最前面查看输出与解释
8 5
true
true家庭 emoji 由三个人物 emoji 和两个零宽连接符 \u200D 组成,零宽连接符本身不显示,只负责把前后两个 emoji 粘成一个图形。按码点反转后,连接符和人物的顺序全乱了,显示成几个散开的人。
国旗更隐蔽。🇨🇳 其实是区域指示符 C 加 N,🇯🇵 是 J 加 P。四个字母整体反转后变成 P、J、N、C,浏览器会把它们两两配对显示,得到的不是 🇯🇵🇨🇳,而是两个别的组合。不报错、不乱码,但内容错了。
组合重音同理,\u0301 本应跟在 e 后面,反转后跑到了字符串开头,没有前一个字符可以叠加。
按字素簇反转:Intl.Segmenter
用户眼里的「一个字」在 Unicode 里叫字素簇(grapheme cluster),它有一套专门的切分规则。浏览器和 Node.js 都内置了 Intl.Segmenter,把 granularity 设成 'grapheme' 就是按这个规则切。
const family = '👨\u200D👩\u200D👧'
const segmenter = new Intl.Segmenter('zh', { granularity: 'grapheme' })
function reverseText(str) {
// segment() 返回可迭代对象,每一项的 segment 属性是一个字素簇
return Array.from(segmenter.segment(str), (s) => s.segment).reverse().join('')
}
console.log(reverseText('ab' + family) === family + 'ba') // 家庭 emoji 保持完整
console.log(reverseText('🇨🇳🇯🇵')) // 两个国旗整体交换位置
console.log(reverseText('cafe\u0301') === 'e\u0301fac') // 重音仍跟着 e查看输出与解释
true
🇯🇵🇨🇳
truesegmenter 可以在函数外创建一次重复使用,每次调用都 new 一个没有必要。Intl.Segmenter 在主流浏览器的新版本里都已经支持,如果项目要兼容很老的环境,可以用遵循 Unicode 字素切分规则的第三方库兜底。
容易答错的地方
- 以为
Array.from就能处理所有 emoji Array.from只保证码点不被拆开。带肤色的手势、家庭、情侣、国旗这些 emoji 都是多码点组合,按码点反转照样会坏。- 用
str.length去截断或统计字数 - 反转的问题和截断、计数是同一个根源。按
length截取昵称前 10 个字,可能刚好从一个 emoji 中间切开。统计「用户输入了几个字」也应该用字素簇的数量,而不是length。 - 以为给正则加
u可以解决 /./gu按码点匹配,和Array.from的效果一样,对组合 emoji 无能为力。正则的v标志配合\p{RGI_Emoji}能匹配完整 emoji,但只管 emoji,不管组合重音,通用做法还是Intl.Segmenter。
面试官还会怎么问?
乱码显示成 �,这个字符是什么?
它是替换字符 U+FFFD。当渲染器或解码器遇到不合法的编码,比如一个落单的代理项,就用它来占位。所以看到 �,基本可以判断是编码被从中间截断或者拆坏了。
不用 reverse(),手写一个反转怎么写?
可以从尾到头遍历拼接,但如果按下标从 str.length - 1 往前走,遇到的仍然是代码单元,问题一样。正确做法是先得到码点数组或字素簇数组,再倒着遍历。本质上,问题不在反转算法,而在「按什么单位拆」。
反转阿拉伯文或希伯来文有意义吗?
这类从右往左书写的文字,显示顺序由 Unicode 双向算法决定,存储顺序和显示顺序本来就不一样。简单地倒序字素簇,得到的文本在界面上未必是「反过来」的样子。实际业务里很少需要反转自然语言文本,面试官真正想确认的是你清不清楚字符串的编码单位。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。