猜猜下面几行 JS 的结果是什么:
matlab
"hello".length
"🌝".length
"🇮🇳".length
"👨•👩•👧•👦".length
"दरबार".length
答案是否和你预期的一样(手动狗头):
arduino
"hello".length // 5
"🌝".length // 2
"🇮🇳".length // 4
"👨•👩•👧•👦".length // 11
"दरबार".length // 5
😵 问题现象:length
属性的"谎言"
我们先来看几个简单的例子:
arduino
console.log('a'.length); // 输出:1
console.log('中'.length); // 输出:1
console.log('👦🏻'.length); // 输出:5 ?!
console.log('😂'.length); // 输出:2 ?!
对于简单的英文字母和中文字符,.length
属性表现得符合预期。但一旦遇到包含肤色修饰的表情符号(如 👦🏻
)或一些复杂的 emoji,它的返回值就变得难以理解。
如果你的业务依赖 .length
来做文本长度校验,比如限制用户昵称不能超过10个字符,那么用户可能输入两三个复杂的 emoji 就无法再输入了,这显然不是我们想要的结果。
🤔 原因分析:JavaScript 字符串与 Unicode 的历史包袱
要理解这个问题,我们得回顾一下 JavaScript 内部是如何存储字符串的。
JavaScript 和大多数其他现代编程语言中创建的字符串值都使用某种 Unicode 编码,这是一组预先编写的关于如何表示文本的 规则。最初,每个字符只占用 1 个字节,但美国人很快意识到,世界其他地方也想用他们自己的语言来使用计算机。于是,Unicode 诞生了。
JavaScript 引擎在内部使用 UTF-16 编码来表示字符串。在 Unicode 字符集中,每个字符都有一个唯一的码位(Code Point)。
- 对于大部分常用字符(如英文字母、数字、常见汉字等),它们位于基本多文种平面(BMP) ,可以用一个16位的**码元(Code Unit)**来表示。
- 然而,对于像大多数 emoji 这样超出 BMP 范围的字符,则需要用两个16位的码元(即一个代理对,Surrogate Pair)来表示。
String.prototype.length
属性返回的正是码元的数量,而不是我们视觉上看到的"字符"数量。
这就是为什么 '😂'.length
会返回 2
,因为它需要一个代理对来表示。而情况在 👦🏻
这里变得更复杂,它实际上是由多个 Unicode 码位组合而成的:
👦
(男孩)🏻
(浅肤色修饰符)
这种由多个码位组合成一个视觉字符的单位,在 Unicode 中被称为字形簇(Grapheme Cluster) 。这才是用户真正关心的"一个字符"的单位。
✅ 解决方案:现代化的 Intl.Segmenter
API
为了解决这个历史遗留问题,ECMAScript 引入了 Intl
国际化 API,其中就包括 Intl.Segmenter
。这个 API 能够根据不同语言的规则,将字符串分割成有意义的片段,如字、词或句子。
它以**字形簇(grapheme)**为单位进行分割,完美解决了我们前面遇到的问题。
实践指南
使用 Intl.Segmenter
来精确计算字符长度非常简单:
- 创建一个
Intl.Segmenter
实例,并将granularity
选项设置为'grapheme'
。 - 调用
.segment()
方法处理你的字符串,它会返回一个可迭代的对象。 - 将这个可迭代对象转换为数组,然后获取其
.length
即可。
javascript
function getGraphemeLength(str) {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const segments = segmenter.segment(str);
return [...segments].length;
}
console.log(getGraphemeLength('a')); // 输出:1
console.log(getGraphemeLength('中')); // 输出:1
console.log(getGraphemeLength('👦🏻')); // 输出:1
console.log(getGraphemeLength('😂')); // 输出:1
console.log(getGraphemeLength('👩•👩•👧•👦')); // 输出:1 (家庭emoji)
看,无论多复杂的 emoji 组合,Intl.Segmenter
都能准确地识别出我们视觉上的单个字符。
深入探索:其他方法及其局限性
在 Intl.Segmenter
变得普及之前,社区也探索了一些其他方法:
- 扩展运算符(Spread Operator)
[...str]
或Array.from(str)
从 ES2015 开始,我们可以用扩展运算符将字符串转换为码位数组,这能正确处理需要代理对的 emoji。
javascript
console.log([...'😂'].length); // 输出:1
局限性:这种方法可以处理单个码位组成的字符,但无法处理由多个码位(如 emoji + 肤色修饰符)组合而成的字形簇。
javascript
console.log([...'👦🏻'].length); // 输出:2
- 使用正则表达式
一些复杂的正则表达式可以尝试匹配 Unicode 的各种组合情况,但这种方法非常复杂、难以维护,而且很容易随着 Unicode 标准的更新而失效。
相比之下,Intl.Segmenter
是一个由浏览器原生实现、遵循 Unicode 标准的解决方案,它更健壮、更准确,也更面向未来。
相关文档
- MDN - Intl.Segmenter :
Intl.Segmenter
的官方 API 文档,包含了详细的用法和示例。 - Unicode® Standard Annex #29 - UNICODE TEXT SEGMENTATION:详细解释了字形簇(Grapheme Cluster)背后的技术标准和复杂边界情况。
下次当你的产品经理再抱怨用户昵称长度计算不准时,你就可以自信地告诉他,你有一个"王炸"级的解决方案了!