qinghuan
← 回到知识探索

计算机趣闻 / 动手实验

一个 emoji,为什么会算成好几个字?

把同一段文字按码元、码点、字素簇和字节各数一次,就能看清“长度”为什么不同。

展开本文目录
  1. 先动手看看
  2. 同一段文字,有四种常见量法
  3. 把一起显示的部分作为一组
  4. 先猜结果,再运行四把尺子
  5. 给限制写上单位,才不会互相打架
  6. 记住这三点
  7. 别把例子用过头
  8. 自己试一小步
  9. 查原始资料
  10. 找一份资料继续练
开始前,会这些就够了
  • 能运行一小段 JavaScript,认识字符串即可
遇到不熟悉的词,先看这 6 个解释
字节
在这里介绍的现代文件和网络格式中,一个字节由八个位组成,常用来计量数据大小。它有二百五十六种位组合,但这些组合代表什么仍由格式规定。
字符编码
字符编码是把文字与可存储的数据对应起来的规则。写入时按规则产生数据,读取时按相应规则还原文字;双方使用不同规则,就可能看到乱码或替代符号。
Unicode
它是一套统一处理世界文字和符号的标准,为字符分配编号,并规定相关属性与处理规则。程序因此能交换多种语言的文本,不必为每种语言另建互不相通的编号。
UTF-8
它是把统一字符编号写成字节序列的一种编码方式,长度并不固定。一个可编码的字符编号会使用一到四个字节,所以文字数量与文件字节数通常不能直接画等号。
码点
码点是字符编号空间中的一个数值位置,可以分配给文字、符号或其他用途。它帮助程序识别文本中的基本成员,但不是屏幕宽度,也不是文件里实际使用的字节数。
字素簇
字素簇是程序按文字分段规则划出的一组码点,尽量接近读者认为的一个字符。处理光标移动、截取昵称或字数限制时,它通常比逐个编号切开更符合使用直觉。
打开完整术语小抄 ↗

你在昵称框里只放了一个表情,网页却说占了两个字符;把“女程序员”表情删掉一半,还可能留下一个普通人物。这不一定是输入法坏了,而是界面和程序正在用不同的尺子量同一段文字。

先约定问题:我们是想限制用户看见的字数,还是限制上传的数据大小?两件事都需要计数,但不能共用一个没有解释的“长度”。下面用四把尺子观察,先不背编码表。

一个表情,究竟算几个?

输入一小段文字,比较同一内容的四种计数方式。

UTF-16 代码单元
7
Unicode 码点
4
字素簇
1
UTF-8 字节
15

码点:U+1F469 · U+1F3FD · U+200D · U+1F4BB

UTF-8 十六进制:F0 9F 91 A9 F0 9F 8F BD E2 80 8D F0 9F 92 BB

这里只在当前页面计算。字素簇近似表示用户看到的一个字符;具体切分受浏览器所用 Unicode 版本影响。UTF-8 字节数不等于模型的 Token 数。

同一段文字,有四种常见量法

码点是 Unicode 给文字元素分配的编号。字母、汉字、组合重音和连接符都可能各有编号。一个看起来完整的符号,未必只用一个码点;例如字母 e 后面再放组合重音,两个编号可以一起显示成一个带重音字母。

JavaScript 的字符串 length 数的是 UTF-16 码元。你可以先把码元理解成这种编码使用的存储单位:常见汉字通常占一个,一些 emoji 占两个。字节则是保存或发送数据时的计量单位;换成 UTF-8 后,同一文字的字节数量又会改变。

把一起显示的部分作为一组

字素簇是更接近用户所说“一个字”的分组方式。一个字母与后面的重音通常属于同一组;某些 emoji 序列通过连接符组成一个整体。字符串展开写法 [...text] 能按码点遍历,却仍会把组合重音、连接符和表情组成部分分别列出来。

Intl.Segmenter 可以按字素簇划分。它比直接用 length 更适合昵称计数、选取前几个可见字符等任务,但不等于测量屏幕宽度:一个字素簇可能很宽,两个字素簇也可能由字体连成一幅字形。字体、语言和运行环境的数据版本仍会影响显示与分段。

先猜结果,再运行四把尺子

代码依次输出文字、码元数、码点数、字素簇数、UTF-8 字节数。第三项输入故意用 e 加组合重音,而不是键盘上可能直接得到的预组合字母。先把自己的预测写在纸上,再看哪些列相同、哪些不同。

遇到看似相同却计数不同的文字,不要立刻删掉“多余字符”。重音和连接符可能正是用户想表达的内容。下面只统计完整字符串,没有演示任意位置截断;按存储单位切开,有可能切断一对码元或一个表情组合。

小实验 · javascript
const segmenter = new Intl.Segmenter("zh", { granularity: "grapheme" });
for (const text of ["A", "😀", "e\u0301", "👩‍💻"]) {
  console.log(JSON.stringify(text), text.length, [...text].length,
    [...segmenter.segment(text)].length, new TextEncoder().encode(text).length);
}

怎样运行:保存为 count-text.js,用支持 Intl.Segmenter 的 Node.js 运行:node count-text.js。也可在支持该 API 的现代浏览器控制台运行。

运行后对照结果
"A" 1 1 1 1
"😀" 2 1 1 4
"é" 2 2 1 3
"👩‍💻" 5 3 1 11

给限制写上单位,才不会互相打架

假设产品规定昵称最多十二个用户可见字符,同时接口最多接收一百个 UTF-8 字节。这两个限制可以同时存在:前者照顾输入体验,后者限制数据量。界面要说清是哪一项超了,服务端也要采用相同的规则,不能前端按字素簇、后端突然按码元报“字数太多”。

还要区分计数与相等。两段文字看起来一样,不代表底层序列一定相同;统一 Unicode 规范化形式可以解决一部分等价写法,却不能证明两个相似昵称是同一个人。身份识别、搜索和存储各有规则,不能靠一个更聪明的长度函数包办。

记住这三点

  • 先写明长度的单位:码元、码点、字素簇或字节。
  • 面向用户的字符计数通常优先考虑字素簇;传输大小另算字节。
  • 分组数量不等于字体宽度,也不等于身份或字符串相等判断。

别把例子用过头

  • [...text].length 数码点,仍可能把一个组合表情算成多个。
  • 字素簇是通用分段规则,不是对所有语言和字体的完美视觉测量。

自己试一小步

为一个昵称输入框设计两条能解释清楚的长度规则。

  1. 自选普通汉字、补充平面汉字、带重音字母和组合 emoji 各一项。
  2. 用四种方式计数,再用字素簇截取前两个单位;不要用原始下标硬切。
  3. 写出面向用户的超限提示,并说明前后端要共同约定什么。

做到这些,就算完成

  • 每列都写清单位,保留实际输入而不只贴截图。
  • 截取结果没有孤立的半个码元,且能解释组合符号为何被保留。
  • 区分界面字数限制和数据字节上限,没有把一种计数宣称为万能。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

找一份资料继续练

先选一项,按下面的范围继续学;不必从头读完整门课程。