在浏览器控制台里输入 '😀'.length,返回的是 2。但页面上明明只有一个表情。再试着输入 '👨👩👧'.length,返回的却是 5,而这个表情看起来是「一家人」的图标。同一个问题,两种结果,答案取决于你在问「几个字符」还是「几个码元」。
码元和码点不是一回事
JavaScript 的字符串用 UTF-16 编码存储,它的基本单位是 16 位的码元。基本多文种平面(BMP)内的字符,一个码元就够;而 emoji 大多落在 BMP 之外,需要用一对码元来表示,这就是所谓的代理对。
'😀'.length 返回 2,正是因为它在 UTF-16 里由一对代理项组成。
而 Unicode 层面说的是码点,也就是每个字符在码表里的编号。😀 是一个码点,编号 U+1F600。
这两个口径的差异,导致同一个字符串在不同语言、不同工具里的「长度」不一样。按码点计数更符合人的直觉——用户看到几个符号,就应该是几个字符。
组合序列会更复杂
比代理对更绕的是组合序列。有些 emoji 不是单个码点,而是由多个码点用零宽连接符(ZWJ,U+200D)拼起来的。
以「一家人」表情为例,它由「男人」「零宽连接符」「女人」「零宽连接符」「女孩」这五个码点组成。按码点计数就是 5,虽然视觉上它只是一个图标。
类似的还有肤色修饰符(基础表情 + 肤色码点,两个码点)、旗帜(两个区域指示符号码点)、带变体选择符的符号等。这些序列在支持完整的系统上渲染成一个图形,在不支持的系统上会拆成几个独立符号显示。
所以看到一个 emoji 被统计成多个字符时,先别急着判断工具算错了,很可能它本来就是一个组合序列。
那些不属于任何类别的字符
除了汉字、字母、数字、标点、空白,还有一批字符无法归入这些类别:数学符号、货币符号、图形符号、私用区字符,以及 emoji。
在字符统计里,这些通常被归到「其他字符」一栏。当「其他字符」不为零时,往往说明文本里混进了表情、特殊符号或者从别处粘贴来的不可见字符。
不可见字符尤其值得留意。零宽空格、字节顺序标记(BOM)、软连字符这类字符肉眼看不见,但会被计入字符总数。从网页或文档复制文本时,它们经常被一起带过来,导致「看起来 100 个字,统计出 103 个」。排查这类问题时,先看「其他字符」的数量是否异常。
对实际开发的影响
第一,字段长度要留余量。如果一个昵称字段限制 20 个字符,用户输入 10 个 emoji 就占满了,而这些 emoji 在字节层面可能是 40 个字节。设计时要把字符限制和字节限制一起考虑。
第二,截断会把 emoji 切坏。按字节或按码元截断字符串时,如果切点正好落在代理对中间,剩下的一半是无效序列,显示出来就是一个替换字符。正确做法是按码点或按字素簇截断,而不是按码元。
第三,正则里的 . 不一定匹配完整字符。在没有开启 Unicode 模式的 JavaScript 正则里,. 匹配的是码元而不是码点,用它来切分含 emoji 的字符串会把一个表情切成两半。需要处理 emoji 时,应开启 u 标志。
检查清单
- 按码点计数符合直觉,按 UTF-16 码元计数会出现 emoji 算 2 的情况
- 组合序列(ZWJ 拼接、肤色修饰符、旗帜)本身由多个码点组成,计数大于 1 是正常的
- 「其他字符」不为零时,留意混入的 emoji 与不可见字符
- 设计字段长度时同时考虑字符数与字节数,给 emoji 留余量
- 截断字符串按码点或字素簇进行,不要按码元截
- 处理 emoji 的正则要开启 Unicode 模式,否则
.会切坏代理对