
一家人那个 emoji 有三条长度:.length 给 11,[...str].length 给 7,按字素簇数只有 1。
三个数都对,只是数的单位不一样。.length 数 UTF-16 码元,展开运算符数码点,Intl.Segmenter 数字素簇,也就是屏幕上连成一块、人眼当成一个字符的那段东西。换个单位去数,同一个字符串能从 1 排到 11。
数字来自本机 node v26.8.1 的一份脚本,ICU 78.3。
三种长度并排放在一张表里
== 0. 运行环境 ==
node 版本: v26.8.1
平台: darwin arm64
ICU 版本: 78.3
Intl.Segmenter 可用: true
== 1. 同一个字符串的三种长度 ==
字符串 .length [...str].length 字素
a 1 1 1
é 预组合 1 1 1
é 组合式 2 2 1
𠮷 2 1 1
❤️ 2 2 1
1️⃣ 3 3 1
👍🏽 4 2 1
🇨🇳 4 2 1
👨👩👧👦 11 7 1
a👍🏽 5 3 2
각 1 1 1
三种长度的最大值与最小值差: 10 个码元
| 字符串 | .length |
[...str].length |
字素簇 |
|---|---|---|---|
a |
1 | 1 | 1 |
é 预组合 |
1 | 1 | 1 |
é 组合式 |
2 | 2 | 1 |
𠮷 |
2 | 1 | 1 |
❤️ |
2 | 2 | 1 |
1️⃣ |
3 | 3 | 1 |
👍🏽 |
4 | 2 | 1 |
🇨🇳 |
4 | 2 | 1 |
👨👩👧👦 |
11 | 7 | 1 |
a👍🏽 |
5 | 3 | 2 |
각 |
1 | 1 | 1 |
十一行里三个数全都一样的只有两行,单字母 a 和 각。
从 𠮷 那一行开始分叉:.length 给 2,码点给 1。这个字码位超过 65535,UTF-16 得用两个码元拼。
é 和 각 那两行是另一个方向的分叉:字素簇还是 1,码元数却被拆开了,原因在被喂进来时的写法。
最右边一列钉在 1,密得反常。❤️、👍🏽、🇨🇳 到家庭 emoji,码元从 2 排到 11,字素簇始终是 1。屏幕上一个字符,.length 能数到 11。
代理对之外还有 ZWJ 序列
== 2. 代理对:一个汉字占两个码元 ==
字符: "𠮷" 长度为 2
charCodeAt(0): d842 charCodeAt(1): dfb7
codePointAt(0): 20bb7 十进制 134071
首个码元是否落在代理区: true
码元数: 2 码点数: 1 字素数: 1
UTF-8 字节数: 4
取 ji.slice(0,1) 的码元: d842 是半个代理对: true
== 3. ZWJ 序列:一个家庭 emoji 的 11 个码元 ==
字符串: "👨👩👧👦"
码元数 .length: 11
码点数 [...]: 7
字素数: 1
字素段数: 1 唯一段内容: "👨👩👧👦"
拆码点看: 男人 ZWJ 女人 ZWJ 女孩 ZWJ 男孩
UTF-8 字节数: 25
码元与码点差: 4 码点与字素差: 6
𠮷 的两个码元是 d842 和 dfb7,一个高代理配一个低代理,合起来才是 U+20BB7。.length 只看码元,两个都算上;写进字节流又是另一个数,4 个字节。
家庭 emoji 更绕:四个 emoji 加三个 ZWJ 拼起来,拆开是男人 ZWJ 女人 ZWJ 女孩 ZWJ 男孩。
四个 emoji 各占两个码元,三个 ZWJ 各占一个,合起来 11 个码元;按码点是 7 个,按字素簇是 1 个,因为 ZWJ 的意思就是让渲染器把前后粘成一个图形。
四层单位在这里叠得最清楚:11 个码元、7 个码点、1 个字素簇,另外还有 25 个 UTF-8 字节。
同一个字,两种写法两个长度
== 4. 归一化:NFC 与 NFD 的长度差 ==
组合式 e + 组合尖音符 码元: 2 字素: 1
NFC 归一后 码元: 1 字素: 1
NFD 归一后 码元: 2 字素: 1
两种写法相等 (==): false
NFC 与 NFD 的码元数之差: 1
码点序列 NFC: e9
码点序列 NFD: 65 301
한글 각 归一前码元: 1 NFD 归一后码元: 3 字素: 1
각 的 NFD 码点: 1100 1161 11a8
한글 NFC 码元: 2 NFD 码元: 6 字素各为: 2 2
两个 NFC 写法相等: true
带尖音符的 é 有两种写法:预组合是一个码点 U+00E9,长度 1;基础字母 e 加组合符 U+0301,长度 2。两个字素簇都是 1,长相也一样,pre === dec 却是 false。
normalize("NFC") 把长度压回 1,normalize("NFD") 撑到 2,代价是改写字节;不改就会出现两个看起来一样的键。
韩文更极端。각 是一个字素,NFC 下 1 个码元,NFD 拆成 1100 1161 11a8 三个码元,长度翻三倍,字素簇还是 1。한글 两个字在 NFC 下占 2 个码元,NFD 下占 6 个。
slice 切在第几个码元上
== 5. slice 按码元切,会把字符劈开 ==
原串: "a👍🏽b" 码元 6 码点 4 字素 3
split('') 的段数: 6 [...str] 的段数: 4
𠮷 split('') 的段数: 2 [...𠮷] 的段数: 1
slice(0,1): "a" 码元 1 码点 1 字素 1 格式完整: true
slice(0,2): "a\ud83d" 码元 2 码点 2 字素 2 格式完整: false
slice(0,3): "a👍" 码元 3 码点 2 字素 2 格式完整: true
slice(0,4): "a👍\ud83c" 码元 4 码点 3 字素 3 格式完整: false
slice(0,5): "a👍🏽" 码元 5 码点 3 字素 2 格式完整: true
slice(0,6): "a👍🏽b" 码元 6 码点 4 字素 3 格式完整: true
家庭 emoji 按码元切:
slice(0,1): "\ud83d" 码元 1 字素 1 格式完整: false
slice(0,2): "👨" 码元 2 字素 1 格式完整: true
slice(0,3): "👨" 码元 3 字素 1 格式完整: true
slice(0,4): "👨\ud83d" 码元 4 字素 2 格式完整: false
slice(0,5): "👨👩" 码元 5 字素 1 格式完整: true
slice(0,8): "👨👩👧" 码元 8 字素 1 格式完整: true
slice(0,10): "👨👩👧\ud83d" 码元 10 字素 2 格式完整: false
劈开后的字节与还原:
slice(0,4) 的码元序列: d83d dc68 200d d83d
isWellFormed(): false
转成 UTF-8 字节: f0 9f 91 a8 e2 80 8d ef bf bd
用 TextDecoder 读回来: "👨�"
读回来的字素数: 2
toWellFormed() 修完: "👨�" 字素: 2
中文标签按码元切 5 个: "日期📅备" 字素: 4
slice 按码元下标切。a👍🏽b 一共 6 个码元,切前 2 个得到字母 a 和一个孤立的高代理,isWellFormed() 立刻给 false。
再切一步就不可逆。家庭 emoji 砍掉前 4 个码元,剩下男人 ZWJ 和一个孤立代理。TextEncoder 把孤立代理写成 EF BF BD,读回来是男人加一个「�」,字素簇从 1 变成 2。toWellFormed() 能修成合法的串,修不回原来那个人。
split('') 和 slice 站在同一边:a👍🏽b 切开是 6 段,[...str] 是 4 段。
切得对不对和数字好不好看无关。家庭 emoji 切 5 个码元正好停在女人后面,切出来的串合法;切 10 个又停在男孩中间,isWellFormed() 回到 false。
按字素切回来才不会缺胳膊
== 6. 按字素切就不会劈开 ==
字素段: "a" "👍🏽" "b"
段数: 3
取前 3 个字素拼回: "👨👩👧👦🇨🇳👍🏽" 码元数: 19 字素数: 3
取前 2 个字素拼回: "👨👩👧👦🇨🇳" 码元数: 15 字素数: 2
5 个家庭 emoji 的码元: 55 字素: 5
按 20 个字素上限截断: 码元 55 字素 5
按 20 个码元截断: 码元 20 字素 2 格式完整: true
Intl.Segmenter 按 grapheme 粒度切,每段带 segment、index、isWordLike。👨👩👧👦🇨🇳👍🏽 取前 3 段拼回来是 19 个码元、3 个字素簇,没有半个代理对,也没丢变体选择符。
反差最大的一行是 5 个家庭 emoji:.length 是 55,字素簇是 5。一个限 20 个字符的输入框,按 .length 判只装得下 1 个多,按字素算能写 20 个,用户看到的是还能输入,输入框已经不让打字了。
字素返回的是迭代器,先转成数组再 slice,拼回去用 map 取 segment 再 join。
词和句也能按另一套单位切
== 7. Intl.Segmenter 的另外两种粒度 ==
样本: "Hello world. 你好,世界。This is fine."
码元 .length: 32 码点: 32
按词切 (isWordLike): 7 词: Hello world 你好 世界 This is fine
按句切: 3 句: "Hello world. " "你好,世界。" "This is fine."
混合文本: "今天天气👍🏽不错" 码元 10 码点 8 字素 7
单个家庭 emoji 按词切: 1 段 isWordLike: false
把 granularity 换成 word 或 sentence 就换了另一套切法。那句中英混排的样本 .length 是 32,按词切出 7 个 isWordLike 为 true 的段,按句切成 3 句。中文没有空格,按词切靠 ICU 的词典。
家庭 emoji 按词切是 1 段,isWordLike 却是 false,词粒度把它当成标点一样的东西。数 emoji 个数得用字素粒度,数词才用词粒度。
单位选哪个,看这个长度给谁用
输入框的字数上限和光标位置按字素簇算,用户数的是屏幕上的图形。存储上限、协议字段长度按字节算,Buffer.byteLength 给的就是这个数。
去重和 Map 键要先选一层再固定住。日志截断和短标题兜底至少要退到码点这一层,用 [...str].slice(0, n) 代替 slice(0, n),不会产出半个代理对。
要把可见字符数当真,只有字素簇这一条路。Intl.Segmenter 的实例建一次就能反复用,别在渲染循环里逐帧 new。
老环境没有这个接口时只能退回码点这一层,多出来的可见字符数怎么算,我没实测过。真降级到那一步,宁可少算几个字符,也别让截断后的串带着半个代理对往下走。