20 字限制卡住一个 👨👩🏫:emoji 的码点、字节与 grapheme cluster
现象:按“字”限制,用户被判超长
表单限制“20 个字”。用户输入一个 👨👩🏫,前端或后端按字节截断(UTF-8 substr)或按码点截断,提交后页面显示 👨 加一个方块。原因很具体:把“一个 emoji”当成“一个字符”来计数。
👨👩🏫 由 5 个码点组成(U+1F468、U+200D、U+1F469、U+200D、U+1F3EB),UTF-8 下 25 字节,用户看到的是 1 个字符。三种“长度”没有一种对应另一种。
emoji 是序列,不是码点(UTS #51)
UTS #51 定义的 emoji_sequence:
emoji_sequence := emoji_core_sequence | emoji_zwj_sequence | emoji_tag_sequence
emoji_core_sequence := emoji_character | emoji_presentation_sequence
| emoji_keycap_sequence | emoji_modifier_sequence | emoji_flag_sequence
emoji_zwj_sequence := emoji_zwj_element ( ZWJ emoji_zwj_element )+
ZWJ := U+200D
七种编码形式,逐个记:
- 单码点:🧛 U+1F9DB
- 码点 + VS16:☹ U+2639 + U+FE0F = ☹️;VS15 U+FE0E 走相反的文本呈现
- 肤色修饰:🤵 U+1F935 + U+1F3FD = 🤵🏽,修饰符区间 U+1F3FB..U+1F3FF
- ZWJ 序列:👨 U+1F468 + U+200D + 🏭 U+1F3ED = 👨🏭
- 旗帜:区域指示符成对,🇦 U+1F1E6 + 🇱 U+1F1F1 = 🇦🇱
- tag 序列:🏴 U+1F3F4 +
gbsct(U+E0067 U+E0062 U+E0073 U+E0063 U+E0074)+ U+E007F = 苏格兰旗 - keycap:
*+ U+FE0F + U+20E3 = *️⃣
UTS #51 的结论是:所有 emoji 序列都是单个 grapheme cluster,序列内部不存在簇边界。这直接影响光标移动、退格删除、word break 和 line break。
UAX #29 里对应规则是 GB11:Extended_Pictographic ZWJ × Extended_Pictographic。Extended_Pictographic 这个属性单独存在,是为了让将来新增的 ZWJ 序列也落在同一个簇里,不切断边界。
计数会翻车的具体数字
| 序列 | 码点 | UTF-8 字节 | grapheme cluster |
|---|---|---|---|
| 👨👩👧👦 | 7 | 25 | 1 |
| 👨🏽❤️💋👨🏿 | 10 | 35 | 1 |
👨👩👧👦 = U+1F468 ZWJ U+1F469 ZWJ U+1F467 ZWJ U+1F466。
JS 的 .length 数的是 UTF-16 单元:SMP 码点各占 2 个单元,ZWJ 与 U+FE0F 各占 1 个。所以同一个序列在 JS 里 .length、码点数、UTF-8 字节数、用户看到的字符数互不相等。拿 .length 做校验,就是在拿 UTF-16 存储块数当业务长度。
按 grapheme cluster 处理
各语言的现成入口:
- C/C++/Java:ICU,
BreakIterator::getCharacterInstance - Rust:
unicode-segmentation,UAX #29 预计算表 - Go:
github.com/rivo/uniseg,GraphemeClusterCount/StepString - JS:
Intl.Segmenter,granularity: 'grapheme',Node 16+ 与现代浏览器可用,老环境需要 polyfill - PHP:
intl扩展的grapheme_strlen/grapheme_substr/grapheme_str_split,或symfony/string的u()系列 - Python:
regex模块的\X,或grapheme库
Go:计数 + 按簇截断
// go mod init example && go get github.com/rivo/uniseg
package main
import (
"fmt"
"github.com/rivo/uniseg"
)
// take 返回前 n 个 grapheme cluster,不会切开 ZWJ 或 VS16
func take(s string, n int) string {
g := uniseg.NewGraphemes(s)
end := 0
for i := 0; i < n && g.Next(); i++ {
_, end = g.Positions() // 字节偏移
}
return s[:end]
}
func main() {
s := "👨👩👧👦a"
fmt.Println(len(s), len([]rune(s)), uniseg.GraphemeClusterCount(s)) // 26 8 2
fmt.Printf("%q\n", take(s, 1)) // "👨👩👧👦"
}
len(s) 是字节,len([]rune(s)) 是码点,GraphemeClusterCount 才是用户看到的字符数。
JS:Intl.Segmenter
const seg = new Intl.Segmenter('zh', { granularity: 'grapheme' });
const clusters = (s) => [...seg.segment(s)].map((x) => x.segment);
const take = (s, n) => clusters(s).slice(0, n).join('').normalize('NFC');
const s = '👨🏽❤️💋👨🏿';
console.log(s.length, [...s].length, new TextEncoder().encode(s).length, clusters(s).length);
// 15 10 35 1 —— UTF-16 单元 / 码点 / UTF-8 字节 / 簇
console.log(take('👨👩👧👦a', 1)); // 👨👩👧👦
第一行打印的四个数就是四种“长度”的来源。业务校验只能取最后一个。
PHP:intl 的 grapheme_* 系列
<?php
// 需要 ext-intl 与 ext-mbstring
$s = "👨👩👧👦";
printf("%d %d %d\n", strlen($s), mb_strlen($s, 'UTF-8'), grapheme_strlen($s));
// 25 7 1 —— 字节 / 码点 / grapheme cluster
function take(string $s, int $n): string
{
if (grapheme_strlen($s) > $n) {
$s = grapheme_substr($s, 0, $n);
}
return Normalizer::normalize($s, Normalizer::FORM_C);
}
echo take("👨👩👧👦a", 1), "\n"; // 👨👩👧👦
grapheme_str_split 需要 PHP 7.4+。没有 intl 扩展时,mb_strlen 只能给到码点数,仍会把家庭 emoji 算成 7。
截断:只能按簇切,切完做一次 NFC
substr、byte slice、码点 slice 三种截断都会留下悬挂的 ZWJ 或孤立 VS16。悬挂 ZWJ 让渲染器等下一个元素拼接,视觉上是方块或者把后续文字吞掉;孤立 VS16 让前一个码点按 emoji 呈现,宽度和字节都变。
按簇截断后再做一次 NFC 规范化,保证同样的可见输入产出确定的字节序列——存储层、缓存 key、去重逻辑都依赖这一点。
存储:utf8mb4 与列宽口径
MySQL 必须 utf8mb4。utf8mb3 遇到 4 字节码点直接报 Incorrect string value。
VARCHAR(20) 计的是字符(码点),不是簇。上面那个 7 码点的家庭 emoji 一次吃掉 7 个位置,剩下的可用空间比“20 个字”的口径少得多。按码点定义的列宽在 emoji 场景下明显偏紧。
排序规则决定 emoji 之间怎么比较,utf8mb4_0900_ai_ci 与 utf8mb4_bin 的结果不同。做唯一索引或去重时要先确定用哪一套。
Postgres 的 text 没有长度限制问题,但 varchar(n) 同样按字符(码点)算,口径和 MySQL 一致。
终端宽度:2 列,但宽度表不归编码层管
emoji 通常显示为 2 列,ZWJ 序列整体也是 2 列,不会按元素叠加成 6 列。
宽度表由终端自己维护,没法从码点推出来。Neovim PR #30014 里的例子:1️⃣ = U+31 + U+FE0F + U+20E3 是多码点序列,宽度被判为模糊(utf_ambiguous_width),整行后续文字跟着错位;setcellwidths() 按设计不处理簇,没法用它把这类序列单独修正。这部分行为依赖终端与 Neovim 版本,上述是 issue 中的记录,未做跨终端实测。
工程上的可行做法是:终端渲染宽度不作为数据层事实,只用于 UI 排版;需要对齐时用簇计数而不是列宽做兜底。