告别 Emoji 与复杂字符的“长度”陷阱

129 阅读6分钟

猜一猜

"hello".length = ? "🌝".length = ? "🇮🇳".length = ?

解答

"hello".length = 5

"🌝".length = 2

"🇮🇳".length = 4

你答对了吗?没答对的话接着往下看吧

看一看

在日常的 JavaScript 开发中,我们习惯于使用 .length 属性来获取字符串的长度,或用 split('') 来分割字符。然而,当面对日益复杂的文本,尤其是包含 Emoji、特殊符号和多语言字符时,这些传统方法往往会给出与用户视觉感知不符的错误结果。

让我们看一看一些著名的产品是如何处理 Emoji 的:

掘金的评论区:

image.png

即梦的效果发布页:

image.png

可以看到,掘金将 🌝 当成一个字符处理,即梦将 🌝 当成两个字符处理,为什么会有这些差异呢?让我们继续探索吧。

刚刚测试了一些著名产品,让我们来看看最近很火的 AI 又是怎么处理 Emoji 的

如果你让 kimi 返回你输入的 👨‍👩‍👧‍👦 ,可以发现中间会闪过 👨、👩 ...,这又是为什么呢?

image.png

核心原理:理解 Unicode “字素簇” (Grapheme Cluster)

Unicode : 是计算机科学领域里的一项业界标准,包括字符集、编码方案等。Unicode 是为了解决传统的字符编码方案的局限而产生的,它为每种语言中的每个字符设定了统一并且唯一的二进制编码,以满足跨语言、跨平台进行文本转换、处理的要求。

  • 码点: Unicode 给每个字符分配的一个“身份证号”,用十六进制表示,通常写作 U+XXXX 的形式。

    • 汉字 的码点是:U+4F60
  • 编码方式: Unicode 有多种编码方式,常见的有 UTF - 8、UTF - 16 和 UTF - 32。

Emoji 的编码

  • 维基百科解释:

    • Emoji(日语:絵文字/えもじ emoji */?),是使用在网页和聊天中的形意符号,最初是日本在无线通信中所使用的视觉情感符号(图画文字)。
  • 基本 emoji:是指在 Unicode 编码表中用 1 个唯一码点表示的 emoji

  • 为什么同一个 emoji 在不同设备、不同软件中显示不同?

    • Unicode 只是约定了码点到 emoji 的映射关系,并没有约定 emoji 图形,每个 emoji 字体文件可以按照自己的想法设计 emoji。
    • MacOS/IOS 的字体:Apple Color EmojiWindow 的字体:Segoe UI Emoji

image.png

image.png

字素集:在 Unicode 中通常一个码点对应一个字符,但是 Unicode 引入了特定的机制允许多个 Unicode 码点组合成一个字形符号。这样由多个码点组合成的一个字符称作字素集。比如 '🤦🏼♂️'.length=7,由多个基础 emoji 和修饰符、连接符组成。

肤色修饰符:大多数人形相关的 Emoji 默认是黄色的,为 emoji 引入肤色,引入了五个新码点作为修饰符:

  • 1F3FB 🏻、1F3FC 🏼、1F3FD 🏽、1F3FE 🏾、1F3FF 🏿

  • 肤色修饰符追加到现有的 emoji 后面则形成新的变种,如:👋 U+1F44B+ 🏽U+1F3FD= 👋🏽

    • 👋 的 UTF-16 值是'\uD83D\uDC4B'
    • 🏾 的 UTF-16 值是'\uD83C\uDFFD'
    • 🏻 的 UTF-16 值是 '\uD83C\uDFFB'
    • 🏿 的 UTF-16 值是 '\uD83C\uDFFF'

  • 5种肤色取值基于菲茨帕特里克度量

      • 菲茨帕特里克度量(Fitzpatrick scale)是一种人类肤色的分类模式。它是由美国皮肤科医生托马斯·B·菲茨帕特里克于1975年发明的

        • I 型,皮肤最白但有雀斑
        • II型,肤色浅,但比I型深(在 emoji 肤色中, I 和 II 进行了合并)
        • III型,肤色为橄榄色
        • IV型,肤色为棕色
        • V型,肤色为深棕色
        • VI型,肤色最深
    • 菲茨帕特里克修饰符使用示例

零宽度连接符(ZWJ):Unicode 通过多个基础 emoji 组合的形式表示某些复杂 emoji。组合的方式是在两个 emoji 之间添加一个U+200D,即:零宽度连接符(ZERO-WIDTH JOINER,简写为 ZWJ)

  • 比如:

    • 👩 + ZWJ+ 🌾 = 👩🌾
    • 👩 + ZWJ+ 💻 = 👩💻

键位符:共有12个键位符,包括 #️⃣ *️⃣ 0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️⃣ 8️⃣ 9️⃣ 。

  • 规则:井号 + U+FE0F 变成 emoji,再加上 U+20E3 变成带框框的键位符

其他连接符:变体选择符、旗帜·双字母连字、标签序列

所以讲到这里,是不是知道为什么有的 emoji 的 length 和视觉看到的不一样了呢?(因为他们可能由多个码点组成,或者可能有若干个连接符)

扩展: Emoji 网站emojipedia.org/

  • 专门用于解释和展示各种表情符号(emoji)的含义和用法

一轮带着笑脸的满月,就像月中人一样。通常将月亮描绘成一个黑色圆盘,上面画着一张略带微笑的人脸和鼻子。

另见🌕满月。也可以用来更广泛地表示月亮。通常使用笑脸。偶尔也用于讽刺和挖苦

苹果和WhatsApp的眼睛向左看,像是斜视。三星和Facebook的表情则直视前方。谷歌的面部表情类似于😏傻笑脸,Twitter的类似于🙂微微微笑的脸。

谷歌的表情此前让人联想到莱尼脸,微软的眼睛看起来像是闭着的,Twitter的表情类似于🌚新月脸,而Facebook的表情则是一个简单的黄色笑脸。

满月脸于2010年以“带脸的满月”的名称被批准为Unicode 6.0的一部分,并于2015年添加到Emoji 1.0中。

解决方案 grapheme-splitter 与 Intl.Segmenter

www.npmjs.com/package/gra…

grapheme-splitter 是一个轻量级 JavaScript 库,用于把任意字符串拆分成“用户眼中”的一个个视觉字符(Unicode 官方术语叫 grapheme cluster)。

在 JavaScript 里,如果直接用 split('') 或者 .length,常常会把一个“看起来是一个字符”的 emoji、带附加符号的字母或某些复杂脚本拆成多个码点,导致计数、截断、光标移动等逻辑出错。

实践提醒:涉及用户输入长度限制、光标移动、文本截断、字符串反向删除等场景,切勿直接使用 lengthsplit('')。请基于“字素集(grapheme)”进行分割与计数,保证与用户视觉一致。

npm 源码分析

核心原理在于它完整实现了 Unicode UAX-29 标准 ,这是处理文本字素簇(grapheme clusters)的官方规范。

API:Intl.Segmenter

developer.mozilla.org/zh-CN/docs/…

  • Intl.Segmenter 对象支持语言敏感的文本分割,允许你将一个字符串分割成有意义的片段(字、词、句)。
  • 原理:基于 Unicode UAX #29 标准,使用 ICU 库在底层进行语言敏感的文本边界检测。
  • 使用方法:
function getGraphemeLength(str) {
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  const segments = segmenter.segment(str);
  return [...segments].length;
}

console.log(getGraphemeLength("🇮🇳")); // 输出:1
console.log(getGraphemeLength("中")); // 输出:1
console.log(getGraphemeLength("👦🏻")); // 输出:1
console.log(getGraphemeLength("😂")); // 输出:1
console.log(getGraphemeLength("👩👩👧👦")); // 输出:1 (家庭emoji)
console.log(getGraphemeLength("👨•👩•👧•👦")); // 输出 7
  • 版本限制:要求 node 版本 > 16

参考文档