首页 / 文章

使用 Intl.Segmenter 在 JavaScript 中实现安全处理表情符号的字符串截断

为何 slice() 及基于展开运算的截取方式会破坏表情符号和带重音的文本,真实产品为何因此出问题,以及应如何改用基于图素边界的字符串截取方式。

发布者:REACTAPP.TOP阅读约 10 分钟2341 词

来源: I Was Truncating Strings Wrong

几乎所有包含生物信息字段、评论预览或通知摘要的代码库,都会使用一个小工具来将文本截断到指定字符数以内。这类工具通常看起来正常,能够通过所有为它编写的测试,但一旦遇到输入内容在截断点附近包含表情符号的用户,就会悄悄破坏其文本。本文将解释在调用 .length 和 .slice() 时 JavaScript 实际上是在统计什么,分析那些具有相同根本原因的知名产品中的实际故障,并展示如何按照用户实际看到的边界进行截断。

众人常用的辅助工具

以下是在无数代码库和教程中出现的实现方式:如果字符串长度超过限制,就将其截断并添加省略号。

function truncate(str, max) {
  return str.length > max ? str.slice(0, max) + '…' : str;
}

对于英文文本,它的表现非常完美,这也正是它能长期使用的原因。只有在输入内容包含两个表情符号且长度限制恰好落在它们之间时才会出现问题。此时界面不会显示更短的字符串,而是会展示一个替代字符:带问号的黑色菱形。乍看之下该函数的实现并无错误,而且由ASCII字符串构成的测试套件也永远无法检测出这个问题。

.length实际上统计的是什么

JavaScript字符串是由UTF-16编码单元组成的序列。.length和.slice()都是针对这些16位编码单元进行操作的,而非人类概念中的字符。

对于拉丁字母、数字以及最常见的标点符号,一个代码单元对应一个字符,因此两者之间没有区别。而基本多文种平面之外的字符——包括大多数表情符号——则需要两个代码单元。这样的组合被称为代理对:一个高代理字符 followed by 一个低代理字符,共同编码一个代码点。

'🐶'.length   // 2

屏幕上显示的只是一个长度为2的表情符号。如果使用.slice()在中间截取,得到的不会是更小的表情符号,而是一个单独的代理字符,它本身并非有效的Unicode字符。浏览器通常会将其显示为替换字符,而更严格的程序则会抛出异常。

这是问题的第一层表现。还有第二层更为隐蔽的问题,将在下文阐述。首先需要明确的是,这并非纯理论上的问题。

四种实际产品中存在的相同漏洞

Twitter的14字符国旗标识

多年来,Twitter大致通过代码点来计算推文长度。一个普通的点赞表情符号占用2个字符,加上肤色选项后则变为7个字符,而某些国旗符号甚至需要14个字符。这是因为肤色变体和国旗符号是由多个代码点组合而成的,每个代码点都会被单独计数。

2018年10月,Twitter改变了规则,规定无论表情符号包含多少代码点,每个都计为2个字符。这一权重规则至今仍存在于其开源的twitter-text库中。可参阅关于表情符号计数规则变更的相关介绍以及X平台的字符计数文档。

这个案例很有意义,因为它区分了两个容易混淆的问题:

  • 用户能看到多少个可见字符?
  • 产品应如何针对不同类型的文本定价或设置限制?

Twitter对第二个问题的答复是一项产品政策,这与测量屏幕上显示的内容并非同一回事。

Yjs与lib0:导致同步中断的拆分表情符号问题

Yjs是一种广泛用于协同编辑的库,TipTap便是基于它开发的。Yjs依赖一个名为lib0的小型工具包,该工具包中的splice函数在内部使用了.slice()方法。

当协同编辑操作恰好发生在某个表情符号的两部分之间时,lib0会生成一个包含孤立替代字符的字符串。这个字符串随后会被传递给encodeURIComponent函数,而该函数在遇到单独的替代字符时就会抛出URIError异常。由于没有程序捕获到这个异常,同步功能便直接停止了。关于lib0替代字符漏洞有详细的记载。

最糟糕的是,这个问题根本看不见。编辑器仍在本地正常工作。用户看到自己的输入内容并继续打字,却没有任何提示表明他们的更改已无法传递给其他人。上游修复方案能够检测到切割后的字符串末尾存在的无效字符,并用替换字符将其替换。

React Native TextInput与maxLength

React Native的TextInput组件提供了maxLength属性,自2016年以来就有关于其表情符号处理行为的缺陷报告,例如问题10964和问题28774。

这些报告都遵循相同的模式:设置maxLength,输入到该限制值后再加上一个表情符号。系统不会直接拒绝或接受整个表情符号,而是会在代码单元边界处将其截断。有用户反映看到的表情符号与他们输入的不同,或者出现一个包含“A”字的框,而在iOS系统中则会导致整个输入字段被清空。

如果你的应用有带有最大长度限制的用户名或个人简介字段,并且你从未在边界处输入过肤色相关表情符号,那么不妨试一试。一些快速的手动测试用例可以覆盖主要情况:

Hello 👋
Hello 👋🏽
Hello 🇮🇳
Hello 👨‍👩‍👧‍👦

那四行代码分别包含一个简单的表情符号、一个带有肤色修饰符的表情符号、一个标志以及通过零宽连接符连接的系列表情符号。如果你的代码将它们每个都视为一个单独的字符,那很可能是把其他内容也算进去了。

Gemini CLI的truncateString函数

2026年3月,人们发现Google的Gemini CLI中存在一个名为truncateString的辅助函数,它会按照maxLength参数来截断字符串。当输入的字符串以哭泣表情符号结尾,且截断限制位于该表情符号内部时,该辅助函数会返回单独的\uD83D——即高代理字符本身(问题编号22753)。

这不仅仅是外观上的问题。单独的代理字符会导致字符串变为无效的UTF-16格式,若将其传递给JSON编码器或API,可能会在后续处理中引发故障。最终的解决方案也是所有团队都会采用的:绝不能让截断点落在代理对内部。

四种产品,四个不同年份,却存在同一个根本原因。表情符号只是更明显的问题症状:文本并不会按照JavaScript索引所指示的位置进行分割。

“字符”的三个层级

可以将截断策略视为在三个层面运作,每个层面对应不同的计数单位。

第1层:使用.length和.slice()的代码单元

几乎所有人都会首先选择这种方法,因为它是最显而易见的API。在三个层面中,也只有它能够拆分代理对。如果长度限制为20,而第20个代码单元位于某个表情符号的中间,那么得到的结果将是损坏的文本,而非更短的字符串。Yjs、Gemini CLI和React Native中的错误都出在这一层。

这个问题背后的概念差异其实很简单:JavaScript是按UTF-16代码单元进行索引的,而开发者往往误以为自己是在对字符进行索引。对于大多数英文文本而言,这两种方式恰好一致,这种一致性掩盖了问题。

第2层:通过拆分字符串来处理代码点

一种常见的解决方法是将字符串转换为数组,这样就可以按代码点而非代码单元进行遍历。

const codePoints = [...str];

这种方式能让代理对保持在一起,从而使单个狗的emoji保持完整。这确实是一种改进,许多开发者就此认为问题已解决。

但实际上并非如此,因为人们视为一个字符的许多内容实际上是由多个代码点构成的:

  • 家庭emoji是由多个人物emoji通过零宽连接字符连接而成的。
  • 国旗由两个区域标识符号组成,每个国家代码的字母对应一个符号。
  • 肤色变体会在基础emoji后添加菲茨帕特里克修正符。
  • 带重音的字母则是由基础字母加上组合标记构成的。

按代码点截断仍可能在这些序列的中间停止。虽然这样能避免生成无效的UTF-16编码,但用户仍可能看到不完整的字符组合、单独的区域标识字母,或是缺少应有肤色的手形图案。

[...'👋🏽']
// more than one item

数组中包含多个元素,但用户看到的却只有一只挥动的手。代码点与用户实际感知到的字符是两回事,而正是这种差异让其他错误得以隐藏。

第3级:使用Intl.Segmenter处理图形群组

与用户所见内容相匹配的单位是图形群组,而用于确定图形边界的内置方法则是Intl.Segmenter,并设置granularity: 'grapheme'参数。下面的辅助函数会对字符串进行分割,收集每个分割段的文本,然后截断处理后的数组而非原始字符串。

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

function truncateGraphemes(str, max) {
  const graphemes = Array.from(segmenter.segment(str), s => s.segment);
  return graphemes.length > max
    ? graphemes.slice(0, max).join('') + '…'
    : str;
}

truncateGraphemes('Loving my new puppy 👨‍👩‍👧‍👦🐶🐶🐶!', 22);
// → "Loving my new puppy 👨‍👩‍👧‍👦🐶…"

注意示例调用中的情况:家族表情符号被视为一个图素并完整保留,第一只狗的字符数在22的限制之内,且在第二只狗之前就完成了分割。由于该函数从未直接对字符串进行索引操作,因此没有任何字符片段会被拆分。

图素分割功能用于处理多码点表情符号、带重音的组合字符,以及泰文或天城文等脚本——在这些语言中,一个可见字符通常由多个码点组成。Intl.Segmenter是ECMA-402国际化规范的一部分。Chrome和Safari分别在2020年和2021年加入了该功能,Firefox在2024年4月加入,Node.js从16版本起就已内置该功能,因此在当前环境下没有必要为了规避它而使用替代方案。

人们很容易想直接使用专门处理表情符号的库,但这只是治标不治本。Intl.Segmenter则解决了根本问题,即文本边界是由 Unicode 规则而非数组索引来确定的。

需要准确处理的细节

采用字形分析方法时,有几点改进值得考虑:

  • 省略号属于额外的字符。如果长度限制是硬性要求,比如数据库列或 API 字段的大小限制,可以为它预留一个字形,或者直接忽略它。
  • 示例在截断之前会先将整个字符串分割成片段。对于非常长的输入,可以逐个遍历这些片段,在达到长度限制时立即停止,这样可以避免生成过大的数组。
  • 字素计数并不等同于显示宽度。一个CJK字符或表情符号可能占据两列终端空间,因此需要按列对齐的命令行界面除了要进行字符分割外,还需计算宽度。
  • 为检测或修复已包含孤立替代字符的字符串,现代运行时提供了String.prototype.isWellFormed()和toWellFormed()方法,这些方法在调用encodeURIComponent或向API发送数据之前作为前置检查非常有用。
  • 需要考虑字素特性的截断场景

    并非所有字符串都需要此功能。对于内部日志行或仅包含ASCII字符的标识符,进行字素分割属于不必要的操作。

    不过,请看看上述错误出现的地点:推文、共享文档、表单字段以及命令行输出。每一个地方都是用户生成的文本会被限制长度后再展示给用户的场所。正是在这些地方,多写的那行代码就能发挥作用。这也是为什么错误在本地测试中不会出现——因为测试数据是ASCII格式的——而会在生产环境中出现在人们最常查看的那些账号和消息中。

    需要审核的典型情况:

    1. 个人简介和资料字段。所谓的“20个字符”限制指的是用户能看到的20个字符,而非20个UTF-16编码单元。
    2. 评论预览。如果预览内容以半个表情符号结尾,即便完整评论没有问题,也会显得不正常。
  • 通知功能。推送通知中出现损坏的字符会非常显眼,会让整个产品显得不可靠。
  • 协作编辑器。文本会在不同用户之间不断被拆分和合并,因此边界处理会直接影响数据完整性。
  • 命令行输出。终端工具越来越多地显示真实用户输入的内容,因此Unicode处理已不再只是浏览器需要考虑的问题。
  • 实际操作原则很简单:凡是可能包含用户可见的Unicode文本的字符串,都应用基于图形的截断方式替代.slice(0, n),并且优先使用Intl.Segmenter而非展开运算符的技巧。展开运算符只能保护代理对,而分段处理则能按照人类视为一個字符的方式来处理文本。

    常见问题

    Intl.Segmenter 在 Node.js 中可用吗? 可以。从 Node.js 16 开始,它无需任何标志即可使用。如果您仍在使用较旧的运行时环境,请先确认情况,再决定是否需要补丁。

    它的性能比 slice() 慢吗? 略慢,因为它执行的是真正的字符分割而非基于索引的截取。对于每次渲染仅截断一次的简介字段,这种差异几乎无法察觉。但如果您在高频循环中要处理成千上万的字符串,则需要对比处理前后的性能。

    除了表情符号之外,这还有意义吗? 有。重音符号以及许多南亚和东南亚的文字系统,包括泰文、天城文、泰米尔文和孟加拉文,其一个代码点并不对应一个可见字符。Intl.Segmenter 能正确处理这些情况;而 .slice() 和展开运算符则可能出错。

    那我应该用 twitter-text 吗?只有在你需要遵循 Twitter 的特定计数规则时才适用,该规则规定每个表情符号计2分,URL 计23分。这只是业务规则,并非为了解决正确性问题。若要统计用户实际看到的内容,Intl.Segmenter才是标准的内置工具。

    总结

    长期以来,“如何截断字符串”的教程中默认采用的都是 .slice() 方法,而这导致了 Twitter 上的字符限制计算错误、协作编辑器中的无声同步故障以及 React Native 中的长期问题。解决方法其实并不复杂:首先明确你所设定的限制实际上针对的是哪个单位,如果涉及用户可见内容,则应在截断前先按图素群进行分割。如果你的代码库中已有 truncate 辅助函数,只需几分钟就能弄清楚它是在哪三个层级上运行的,并用四行测试字符串来验证即可。