第 3 讲 / 共 10 讲

一个“字”到底有多长?码点、码元、字素簇与字形

用户看到一个表情,程序却说它占五个单位;两个看起来一样的姓名,一个能匹配,另一个不能。问题通常不在于谁数错了,而在于“一个字”没有唯一的技术含义。把码元、码点、字素簇和字形分别讲清楚,就能为输入上限、昵称截断和复制检查选对工具。

Copy2Paste约 11 分钟阅读

同一段文字,可以回答四种不同的问题

码元是某种编码形式的基本单位,码点是 Unicode 编号空间中的位置,字素簇是分段规则识别出来的一组码点,而字形是字体与排版系统实际画出的形状。它们彼此相关,却不是四个可以互换的术语。一个数字若没有注明在数哪一层,就很容易在接口之间传错意思。

例如,界面想知道用户输入了几个完整符号,网络接口关心请求占多少字节,字符串方法需要一个内部偏移量,设计稿则关心文本最终占多宽。四种需求都合理,但不存在一个万能的 length 能同时准确回答。先确定需求,再选择计数方法,比先找一个更聪明的函数更重要。

本文采用默认扩展字素簇作为接近用户感知字符的常用单位,同时保留它的限制:具体语言、编辑器和排版行为可能需要专门处理。字素簇是有规则的工程近似,不是宣称所有语言对“字”的理解完全相同。带着这个前提,下面的示例才不会从一种过度简化跳进另一种。

码元:JavaScript 的 length 为什么常常出乎意料

JavaScript 字符串的元素是16 位数值,length 返回元素数量。因此 A 和中各占一个单位,而 😀 占两个。对更复杂的职业表情 👩‍💻,结果是五:女性人物需要两个 UTF-16 码元,零宽连接符需要一个,电脑再需要两个。它显示成一个图案,并没有改变底层序列。

这不是某个浏览器计算失误,而是语言接口本身的计量方式。用字符串下标访问内容时,得到的也可能只是一个代理项。假设开发者允许昵称最多十个“字”,前端按这个 length 阻止输入,用户就可能发现同样长度的中文名字和表情名字受到不同待遇。

更稳妥的设计是把技术限制说清楚。如果限制来自存储或接口,就使用准确单位并在需要时解释;如果限制来自可见长度,就采用相应分段方式。不要先在页面宣传“十个字符”,再期待用户理解各种内部编码。界面术语是产品约定的一部分,应该与实际实现相符。

码点:改善遍历,但还没有走到用户眼中的字符

JavaScript 的字符串迭代器会将有效代理对作为一个码点对应的片段处理,因此展开字符串得到的数组,比直接按下标切割更适合观察码点。对 😀,展开后的长度是一;对 👩‍💻,却是三,因为人物、连接符和电脑仍然分别占据码点位置。

把遍历改成按码点进行,是有意义的进步,但不能保证后续操作完整保留所有可见符号。若将职业表情的三个码点分别加空格或反转,就会改变连接关系。程序没有切断代理对,却仍可能破坏组合序列。类似问题也会出现在带组合附加符号的文字中。

因此,修复“乱码”之前最好把异常具体化。是产生了未配对代理项,还是有效字符的顺序被改变,抑或只是字体未能合成预期图案?这些结果在截图里都可能看起来不对,但修复位置不同。码点列表能把问题从模糊的视觉印象变成可以逐项比较的文本事实。

重音字母:看起来相同,并不保证序列相同

é 可以写成 U+00E9,也可以由 U+0065 的 e 后接 U+0301 组合尖音符构成。前者一个码点,后者两个码点;默认字素分段通常把它们各识别为一个完整单位。仅凭显示结果,读者未必能发现两种形式之间的差别,逐个比较码点的程序却会发现它们不同。

设想一个订阅名单从两套系统导入,两位编辑输入的是同一个人的姓名,却恰好使用两种形式。去重前如果只比较原始序列,就可能保留两条;为了“去重”直接删除所有重音,又会把本应不同的姓名合并。识别规范等价关系与删掉语言信息,是两种完全不同的操作。

规范化可以帮助建立某些比较规则,但本篇先保留一个更基础的习惯:展示原始序列,并说明任何转换。分段回答哪里可以形成一个单位,规范化回答如何选择等价表示,它们相互配合却不能替代。处理真实姓名和原始资料时,保留输入与生成比较键通常比悄悄覆盖原文更容易追溯。

旗帜和职业表情:组合规则会改变计数

🇨🇳 由两个区域指示符码点组成,👩‍💻 由女性人物、零宽连接符和电脑三个码点组成。在本篇验证的运行时中,它们都被 Intl.Segmenter 的字素模式识别为一个分段。但能否在设备上画成预期图案,仍取决于显示支持;分段结果与图像渲染不是同一道判断题。

由此可以设计一个有用的测试:把同一序列分别放在普通文本框、网页和目标应用中,先比较复制后的码点,再比较显示。若序列保持一致而外观不同,就不应通过删除连接符来“修正编码”。这样做可能使当前设备出现几个熟悉图案,却改变了要传递的内容。

也不要把所有包含连接符的任意序列都称为标准表情。码点存在、序列可保存、分段为一组、字体渲染成一个图案,是不同条件。一个工具若向用户展示这些层次,比只给出“有效”或“无效”更有帮助;尤其在新版本支持不一致时,细分结果能避免误导。

使用 Intl.Segmenter,让计数方式接近编辑需求

Intl.Segmenter 可以按字素、词或句子进行分段。下面明确选择 grapheme,并统计返回的分段数量。对本文样本,得到的数量依次为一、一、一、一、一、一,而 UTF-16 码元和码点计数分别变化。所有数字都来自同一字符串,只是提出的问题不同。

这段代码适合作为观察工具,不能直接替代所有输入验证。运行时需要提供该接口,其内置 Unicode 数据和分段实现也可能随版本更新。若产品必须在多端严格一致,应记录依赖版本并使用共同的样例测试,而不是假定所有浏览器仅凭相同 API 名称就会对未来所有字符给出完全相同结果。

还有一个容易踩的细节:分段结果中的 index 是原字符串中的索引,在 JavaScript 环境中按 UTF-16 码元定位。不要把它误当作第几个字素簇。若要显示“第三个字符”,使用遍历的序号;若要从原字符串定位该片段,则使用返回的索引。两种数字需要各归其位。

javascript
const segmenter = new Intl.Segmenter('zh', { granularity: 'grapheme' });
for (const text of ['A', '中', '😀', 'e\u0301', '👩‍💻', '🇨🇳']) {
  console.log(text, {
    utf16Units: text.length,
    codePoints: [...text].length,
    graphemes: [...segmenter.segment(text)].length
  });
}
//                    UTF-16 码元 / 码点 / 字素簇
// A                   1 / 1 / 1
// 中                  1 / 1 / 1
// 😀                  2 / 1 / 1
// e + U+0301          2 / 2 / 1
// 👩‍💻                5 / 3 / 1
// 🇨🇳                  4 / 2 / 1

截断昵称:保住完整单位,还要检查业务边界

如果产品约定昵称预览只展示前六个字素簇,可以先分段,再取前六段并拼接,最后按需添加省略号。这样处理不会像直接 slice 那样随意切开代理对,也不会像按码点截取那样轻易拆掉本篇的组合样本。不过,最终是否需要省略号,应比较实际分段数量,而不是比较 UTF-16 长度。

这仍然不等于所有容量问题都解决了。一个字素簇可以包含多个组合记号,所以限制十个字素簇并不能严格限制字节数。公开输入通常还需要独立的总体资源上限,避免极端长度的数据占用过多处理时间。可见字符上限与资源上限可以同时存在,只要错误提示和实现分别清楚。

可以用三个步骤验证截断:先确认输出仍由完整分段构成,再确认输出是原文的预期前缀,最后确认复制结果没有混入额外空格或不可见标记。不要只看预览截图,因为字体可能掩盖某些差别。对于编辑器本身的光标与删除行为,还应尊重平台和语言的专门约定。

字形:为什么一个图案也可能对应多个字符

字形是渲染层的结果。字体可能把 f 和 i 排成一个连字图案,但默认字素分段仍然把它们看作两个单位。反过来,一个字素簇也不必总由一个简单图形绘制。画面上的形状数量,不能直接推导码点数量或编辑边界,更不能靠截图可靠还原底层文本。

汉字的地区字形、阿拉伯文字的连写以及组合记号的位置,都提醒我们:排版不仅是把一张字符图片接在另一张后面。对网页来说,正确的语言信息、字体覆盖和布局支持与字符保存同样重要。字符检查器能解释序列,却不能替代目标设备上的真实阅读检查。

这也是复制工具与图片工具的区别。前者应保留精确文本,让目标应用自行渲染;后者固定一次视觉结果,可能更适合海报,却不保留同样的可搜索和可编辑能力。选择输出形式时,应从读者要做什么出发,而不是因为当前字体显示不理想,就无提示地把文字换成图片。

相同长度,不代表相同内容

完成计数以后,还要防止另一种误解:两个字符串拥有相同的字素簇数量,并不说明它们相等。两个完全不同的名字可能长度相同;两个不同脚本中的字母也可能看起来相像。长度检查只能回答数量问题,不能承担身份匹配、去重或内容验证的全部责任。

反过来,拥有不同码点数量的两段文字,也可能属于规范等价的表示。建立搜索功能时,应分别规定原文怎样保存、比较键如何生成、搜索结果怎样解释。将这些规则写成小例子,比只承诺“智能识别”更便于审查。用户应该能知道为什么两条记录被合并,而不是只能看到一个无法追溯的最终数量。

如果需要向同事报告异常,可以同时提供可复制原文、码点列表、每种计数和预期操作。例如说明“取前三个字素簇后,第四个符号的一部分仍留在输出里”,会比“字符串长度不对”更准确。这样一份报告不依赖截图使用的字体,也能让另一台设备上的人重现同一个序列,快速检查问题发生在计数、截取还是显示环节。

把“支持 Unicode”变成几项可验证的承诺

一次小而有效的检查,可以同时放入普通英文、常见汉字、补充平面字符、组合重音和连接表情。分别测试计数、前缀截取、删除、复制、保存再读回,并在每一步观察原始序列。只测试“你好”很难发现代理对问题,只测试一个笑脸又很难发现字素簇问题。

本篇样例在 Node.js 22、Unicode 17数据和 ICU 78环境下验证。它说明这些既有样例的输出,不代表该运行时已经拥有任何后来版本的完整数据。标准、运行时和字体各自更新,报告问题时附上版本和具体码点,通常比一句“某个 emoji 不兼容”更容易让别人复现。

对普通使用者,最实用的习惯是保留原文,先检查再清理;对开发者,则是给每个长度变量写明单位,并为真正要支持的语言准备样例。当“一个字”被拆成可验证的问题,昵称上限、搜索去重和复制异常就不再需要靠猜。你也可以把遇到的文本放进 Unicode 检查器,亲自比较这几种计数。

参考资料

  1. UTR #17: Unicode Character Encoding Model ↗
  2. UAX #29: Unicode Text Segmentation ↗
  3. ECMAScript: The String Type ↗
  4. Unicode FAQ: Characters and Combining Marks ↗
  5. ECMA-402: Intl.Segmenter ↗
  6. Unicode FAQ: Chinese and Japanese ↗
  7. UAX #15: Unicode Normalization Forms ↗
  8. UTS #51: Unicode Emoji ↗
  9. ECMAScript: String Iterator Objects ↗
  10. Unicode 18.0, Chapter 2: General Structure ↗