🤫 你不知道的 JavaScript:`"👦🏻".length` 竟然不是 1?

猜猜下面几行 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 来精确计算字符长度非常简单:

  1. 创建一个 Intl.Segmenter 实例,并将 granularity 选项设置为 'grapheme'
  2. 调用 .segment() 方法处理你的字符串,它会返回一个可迭代的对象。
  3. 将这个可迭代对象转换为数组,然后获取其 .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 变得普及之前,社区也探索了一些其他方法:

  1. 扩展运算符(Spread Operator) [...str]Array.from(str)

从 ES2015 开始,我们可以用扩展运算符将字符串转换为码位数组,这能正确处理需要代理对的 emoji。

javascript 复制代码
console.log([...'😂'].length); // 输出:1

局限性:这种方法可以处理单个码位组成的字符,但无法处理由多个码位(如 emoji + 肤色修饰符)组合而成的字形簇。

javascript 复制代码
console.log([...'👦🏻'].length); // 输出:2
  1. 使用正则表达式

一些复杂的正则表达式可以尝试匹配 Unicode 的各种组合情况,但这种方法非常复杂、难以维护,而且很容易随着 Unicode 标准的更新而失效。

相比之下,Intl.Segmenter 是一个由浏览器原生实现、遵循 Unicode 标准的解决方案,它更健壮、更准确,也更面向未来。

相关文档

下次当你的产品经理再抱怨用户昵称长度计算不准时,你就可以自信地告诉他,你有一个"王炸"级的解决方案了!

相关推荐
掘金一周3 小时前
凌晨零点,一个TODO,差点把我们整个部门抬走 | 掘金一周 9.11
前端·人工智能·后端
沐怡旸3 小时前
【底层机制】稀疏文件--是什么、为什么、好在哪、实现机制
c++·面试
用户8174413427483 小时前
kubernetes核心概念 Service
前端
xingkongv3 小时前
从“调接口仔”到“业务合伙人”:前端的 DDD 初体验
javascript·前端框架
东北南西3 小时前
Web Worker 从原理到实战 —— 把耗时工作搬到后台线程,避免页面卡顿
前端·javascript
Zz_waiting.3 小时前
案例开发 - 日程管理 - 第六期
前端·javascript·vue.js·路由·router
A 风3 小时前
封装日期选择器组件,带有上周,下周按钮
开发语言·javascript·vue.js
袁煦丞3 小时前
企业微信开发者的‘跨网穿梭门’:cpolar内网穿透实验室第499个成功挑战
前端·程序员·远程工作
Simon_He3 小时前
vue-markdown-renderer:比 vercel streamdown 更低 CPU、更多节点支持、真正的流式渲染体验
前端·vue.js·markdown