regex 总览待审核
字符的真面目 · grapheme cluster

字素簇:用户眼里的「一个字符」到底多长

Basic_Emoji / RGI 序列两页拆过 👨‍👩‍👧 是 5 个 code point。换个著名例子 🤦🏼‍♂️(扶额男·浅肤色): Python 数出长度 5、JS/Java/C# 数出 7、Rust 数出 17、Swift 只数出 1—— 同一个「一个字符」,各语言结果全不同,差别只在按哪一层数:UTF-8 字节 / UTF-16 码元 / code point / grapheme cluster(扩展字素簇)。只有最后一层,才是人眼感知的「一个字符」

四种长度,数的是不同的层

输入一段文本,四个计数器同时数。🤦🏼‍♂️ 正好复现 17 / 7 / 5 / 1:

对应各语言的「默认 length」数的是哪层:Rust .len() = UTF-8 字节; JS .length / Java/C# = UTF-16 码元;Python 3 len() = code point; Swift .count / Elixir = grapheme cluster。所以「字符串长度」这个问题本身就有歧义,得先问「哪一层」。

分段器:把文本切成「一个个字符」

用浏览器原生 Intl.Segmenter(granularity: 'grapheme')切。每个虚线框是一个字素簇,框里是它含的 code point——一个簇可能裹着好几个码点:

不靠 Intl.Segmenter,自己切:UAX #29 规则引擎

上面用的是浏览器内置 Intl.Segmenter。但它内部并不神秘——切分规则全部写在 Unicode UAX #29 里,核心就两步:

  1. 分类:给每个 code point 标一个 Grapheme_Cluster_Break 属性值(下表),如 Extend / ZWJ / RI / Control
  2. 判边界:一张固定的规则表(GB3~GB999)只看相邻两个 code point 的属性值(外加少量跨字符状态),决定它们之间断(÷,起新字符)还是连(×,并入当前字符)
Extend组合记号 / 肤色 modifier / VS,黏住前一格
ZWJU+200D 零宽连接符,粘合 emoji
RI区域指示符,两两拼成一面旗
L/V/T/LV/LVT韩文谚文音节的拼合零件
Prepend极少数前置格式符,黏住后一格
Control控制 / 行分隔符,两侧必断
Other普通字符,默认各自成簇

下面是同一套规则的手写实现。每两格之间标出 ÷/× 与命中的规则号,末尾把切出的簇与 Intl.Segmenter 的结果对账:

÷ 断:此处起一个新字素簇 × 连:并入当前字素簇
手写 UAX #29 字素簇切分 (节选,完整版即 regex.js)分类 + 规则表

  
一个已知分歧:试试预设里的 क्षि(Devanagari 连写)——手写版切成 2 段,Intl.Segmenter 切成 1 段。 差别来自 GB9c(Indic 连写,InCB 规则),它是 Unicode 15.1 才新增的,且依赖 JS \p{} 查不到的 Indic_Conjunct_Break 属性表,本节的简化引擎故意没实现。这恰好印证了上一节的结论: 切分规则逐年随 Unicode 版本变,自己照着旧规则手搓,迟早会和跟着版本走的 Intl.Segmenter / ICU 分道扬镳。

字符串反转:为什么不能 split('').reverse()

把字符串倒过来,按不同层反转结果天差地别。按码元反转会把代理对、ZWJ 序列切碎成乱码;按字素簇反转才保住每个「字符」完整:

把 emoji 几页串起来:Basic_Emoji / RGI 序列两页拆的 ZWJ 序列、肤色 modifier、VS16、区域码旗,本质都是 「多个 code point 组成一个字素簇」的具体形态。grapheme cluster 就是这一现象的统称。
另一个注意点:字素簇的切分规则每年随 Unicode 版本变——同一段字符串,不同年份的库可能切出不同段数。 要正确处理,别自己按码点凑,交给 Intl.Segmenter / ICU / Swift 标准库这类跟着 Unicode 版本更新的实现。