'😀'.length === 2:写了十年代码,我才搞懂「长度」是什么
- 作者:Bougie
- 创建于:2026-09-29
上周有个用户投诉,说我们的昵称输入框明明写着「最多 10 个字符」,他打了 6 个 emoji 进去,保存按钮就灰了。
我当时的第一反应是去看校验代码,一行 value.length > 10,看不出任何问题。然后在控制台敲了一下:
'😀'.length // 2
'😀😀😀😀😀😀'.length // 12
12 大于 10,校验没写错,是「长度」这个词从一开始就没人定义清楚。

# 一、三把尺子
问题的根源是:字符串有三种都能自圆其说的「长度」,而我们从小写 str.length 写得太顺,默认以为只有一种。
第一把尺子:码位(code point)。 Unicode 给每个符号编的号,写成 U+1F600 这样。这是最贴近「一个符号」的那一层——😀 是一个码位,é 通常是一个码位,汉字的「中」是一个码位。
第二把尺子:码元(code unit)。 码位在计算机里存下来要占空间,UTF-16 的最小单位是两个字节。BMP 里的字符(U+0000 到 U+FFFF)一个码元装得下,超出去的就得用两个码元拼——这一对叫代理对(surrogate pair)。😀 是 U+1F600,超出 BMP,所以要两个码元。
第三把尺子:字素簇(grapheme cluster)。 用户眼睛里看到的「一个字」。这一层最反直觉:👨👩👧 在用户看来是一个人(一个家庭 emoji),但它其实是五个码位——三个 emoji 加两个零宽连接符 ZWJ(U+200D)。
三把尺子量同一个字符串,答案完全不同:
const s = '👨👩👧'
s.length // 8 ← 码元数(3 个 emoji 各 2 + 2 个 ZWJ 各 1)
[...s].length // 5 ← 码位数
// 字素簇数:1,得用 Intl.Segmenter 才量得出来
我们那个表单量出来的是 8(码元),用户脑子里是 1(字素簇)。差了八倍,他当然觉得你在耍他。

# 二、为什么 JS 偏偏选中了第二把尺子
这事得怪 1995 年。
Brendan Eich 写 JavaScript 那会儿,Unicode 1.0 才出来没几年,收了七千多个字符,全都在 BMP 那一层里。所有人都觉得两字节、六万多个位置怎么都用不完了——毕竟 ASCII 只用了 128 个。Java 就是在这个背景下选了 UCS-2,一种固定两字节、和 BMP 一一对应的编码。JavaScript 跟着走了同一条路。
然后 1996 年 Unicode 2.0 加了补充平面,字符数冲破了 65536。为了兼容已经写好的 UCS-2,标准委员会想了个补丁:用两个 BMP 里专门留空的码位(U+D800 到 U+DFFF)拼起来表示一个超出范围的字符,也就是代理对。UCS-2 从此变成了 UTF-16。
JS 的字符串模型就是在那一刻定死的,三十年来没人敢改——改了全世界一半的网页会碎。所以 .length、下标访问、charAt、charCodeAt 全都按 UTF-16 码元走,一直走到今天。
我不是在抱怨这个决定,1995 年那么选完全合理。我只是想说清楚一件事:你现在写的 str.length 是在量 1995 年那把尺子,不是量用户看到的字符。
# 三、具体会咬人的地方
知道了原理,下面这些坑就都能解释了。我按自己被咬的顺序排。
下标会把 emoji 切两半。
'😀'[0] // '\ud83d' —— 半个 emoji,打出来就是问号
'😀'.charAt(0) // 同上,charAt 也不认代理对
'😀'.split('') // ['\ud83d', '\ude00', ...] 一列碎掉的半截
只要你对字符串做过 split('') 再处理,emoji 就已经坏掉了,而且是静默地坏——不会报错,只是显示成 �。
codePointAt 是个好人,但它的参数是码元下标。 这是最容易用错的一个:
'😀'.codePointAt(0) // 128512 ← 完整码位,对的
'😀'.codePointAt(1) // 56832 ← 掉进了低代理,返回的是 0xDE00 的数值
它不做「第几个字符」的语义,它只是「从这个码元位置开始读一个完整码位」。想按字符遍历,别用它配 for (let i = 0; ...)。
展开和 for...of 走的是迭代器,按码位来。 这是 ES6 免费送的修正:
[...'👨👩👧'].length // 5
Array.from('👨👩👧').length // 5
for (const ch of '👨👩👧') {} // 遍历 5 次
够用了,但记住它停在码位这一层——那个家庭 emoji 还是会被拆成 5 份,ZWJ 也会单独出来一份。

正则不加 u 就全是错的。 这条我见过太多次:
/^.$/.test('😀') // false
/^.$/u.test('😀') // true
'😀'.match(/./g) // ['\ud83d', '\ude00'] ← 两个碎块
'😀'.match(/./gu) // ['😀']
u 标志让正则引擎按码位而不是码元去理解整个模式,量词、\p{...}、. 全都跟着变。ES2024 又加了个 v 标志,在 u 的基础上支持集合运算,比如「希腊字母但不是 β」可以直接写 [\p{Script=Greek}--β],还能用 \q{} 匹配多码位字符串。能用 v 的地方我建议直接上 v,它是 u 的超集。
# 四、想按「用户看到的字」来量,只有一个正解
浏览器早就给了答案,只是知道的人不多:
const seg = new Intl.Segmenter('zh', { granularity: 'grapheme' })
;[...seg.segment('👨👩👧')].length // 1
;[...seg.segment('😀😀😀')].length // 3
Intl.Segmenter 从 2024 年起就是 Baseline 了(Chrome 87、Safari 14.1、Firefox 125),今天放心用。它是唯一一个真正按字素簇切的标准 API——ZWJ 序列、肤色修饰符、变体选择符、国旗,全都能正确合成一个。
顺手放一个我现在在用的截断函数,替代所有 slice(0, n):
function truncate(str, n) {
const seg = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
let out = ''
let i = 0
for (const { segment } of seg.segment(str)) {
if (i++ >= n) break
out += segment
}
return i > n ? out + '…' : out
}
truncate('👨👩👧一家人', 4) // '👨👩👧一家人'
对比一下 slice(0, 4) 会给你 '👨👩'——两个 emoji 加一个悬空的 ZWJ,渲染出来是个诡异的形状。这类 bug 在测试环境里永远发现不了,因为没人会用 emoji 当测试数据。
granularity 还能给 word 和 sentence。中文没有空格,word 依赖 ICU 的分词,效果时好时坏,做「统计字数」够用,做精确切词别指望。

# 五、变体选择符和国旗:字素簇为什么这么难
有人会觉得「一个符号一个码位」是常识,实际上 Unicode 早就不这么玩了。现在一个「看得见的字」经常是好几个码位拼出来的:
| 你看到的 | 组成 | .length | [...s].length | 字素簇 |
|---|---|---|---|---|
| ❤ | U+2764 | 1 | 1 | 1 |
| ❤️ | U+2764 + VS16 (U+FE0F) | 2 | 2 | 1 |
| 👍🏽 | 👍 + 肤色 U+1F3FD | 4 | 2 | 1 |
| 🇨🇳 | 两个区域指示符 | 4 | 2 | 1 |
| 👨👩👧 | 3 emoji + 2 ZWJ | 8 | 5 | 1 |
那个 VS16 是变体选择符,作用是告诉字体「我这个符号要彩色的、emoji 风格的版本」。不加它就是黑白的心形,加了就是红心 emoji。用户眼里这是完全不同的两个东西,程序眼里只差一个看不见的控制符。
所以只有字素簇那一列是稳的,其余两列随你怎么写都会飘。这也是为什么我现在的态度是:凡是要呈现给用户的「第 N 个字」,一律走 Intl.Segmenter,别自作聪明用码位近似。
# 六、 normalize:同一个「é」可以有两副面孔
另一个咬得很狠的坑,跟长度无关,但同源。
é 有两种写法:直接一个码位 U+00E9(预组合),或者 e 后面跟一个组合用重音符 U+0301(分解)。两个在屏幕上长得一模一样,但:
const a = 'é' // 预组合:U+00E9,一个码位
const b = 'é' // 分解:e + U+0301,两个码位
a === b // false
a.length // 1
b.length // 2
上面这两个 é 在屏幕上长得一模一样,区别只有码位——顺带说,这正是这个坑难自查的原因:你手写在代码里的那个 é 到底是哪种写法,取决于输入法和编辑器给了你哪种,你根本看不见。
真正能稳定复现的写法是用转义:
const a = '\u00e9' // U+00E9
const b = 'e\u0301' // e + U+0301
a === b // false,真的
normalize() 就是干这个的:
a.normalize('NFC').length // 1,合成一个码位
a.normalize('NFD').length // 2,拆成基字符 + 组合符
a.normalize('NFC') === b.normalize('NFC') // true
这事儿在文件名上特别要命:macOS 的 APFS/HFS+ 用 NFD 存文件名,Linux 和 Windows 用 NFC。 同一个文件在两台机器上看起来一样,提交到 git 里会变成两个不同的路径,diff 里一片红,你盯着屏幕怀疑人生。我们项目里有个资源目录被这个折腾过一整周,最后是在 CI 上加了一步归一化检查才收场。
别乱用 NFKC——它会把 ① 变成 1、全角变半角、连字拆开,是不可逆的语义改写。做搜索索引和排序键可以,做存储不要。
# 七、存储这一层还有个老坑

就算前端全对了,数据库那一层还能给你来一记。MySQL 的 utf8 是个陷阱:它是 utf8mb3 的别名,最多只支持三个字节,而所有 emoji 都是四字节。于是:
Incorrect string value: '\xF0\x9F\x98\x80' for column 'nickname'
要的是 utf8mb4。MySQL 8.0 起默认已经是 utf8mb4 了,但 5.7 及更早的默认还是 utf8mb3,而大量老项目就是从这个版本迁过来的。改的时候别忘了排序规则也得一起换(utf8mb4_0900_ai_ci 或 utf8mb4_unicode_ci)。
还有个更隐蔽的:VARCHAR(255) 里的 255 是字符数,但 InnoDB 的索引长度限制算的是字节。utf8mb4 下一个字符最多 4 字节,一列 VARCHAR(255) 做全列索引在老的 COMPACT 行格式下会撞上 767 字节那条线。这类问题不会在创建表的时候报错,而是在某次 ALTER 或者主从版本不一致的时候突然跳出来。
顺带说一句 JSON:emoji 在 JSON 里不需要转义,直接放 UTF-8 字节就行。但 Python 的 json.dumps 默认 ensure_ascii=True,会把 😀 输出成 \ud83d\ude00 这种代理对转义——写出去能读回来,可一旦中间有人把这一对拆开了,下游拿到的就是两个孤立代理,解析直接炸。跨语言传字符串的时候值得看一眼。
# 八、终端里对不齐的表格
最后一个,纯属个人怨念。
我写过一个 CLI,用 padEnd 对齐输出几列中文和状态符号,跑出来参差得像被狗啃过。原因很简单:终端里东亚字符和大部分 emoji 是双宽的,占两格,而 '中'.length 是 1,'😀'.length 是 2——这个 2 是码元数,跟显示宽度没有任何关系,纯属巧合地对上了。
JS 没有内建的 wcwidth,.length 也不是它的替代品。Node 生态里直接用 string-width 就行,别自己算。
import stringWidth from 'string-width'
stringWidth('中') // 2
stringWidth('😀') // 2
stringWidth('a') // 1
# 九、我现在的清单
踩完这一圈,规则其实就几条,贴在编辑器旁边够了:
- 要展示给用户的「字数」「第 N 个字」「截断」 ——
Intl.Segmenter,granularity 给grapheme - 内部校验长度上限(比如数据库的 VARCHAR 约束)—— 用码元或字节都行,但别拿它当「字数」提示给用户看
- 正则一律带
v(至少带u),尤其是有\p{...}或者.的地方 - 别用
split('')和下标访问处理可能含非 BMP 字符的字符串 - 入库前
normalize('NFC')统一一遍,文件名和唯一键尤其要做 - 数据库用
utf8mb4,索引长度按 4 字节算 - 终端对齐用
string-width,不要用.length - 排序比较用
localeCompare,不要用<;顺带记得toUpperCase()在土耳其语下i会变成İ,需要无 locale 变换时用toUpperCase()而不是toLocaleUpperCase() - emoji 要进测试用例。这条最重要,前面八条都可以靠它兜住
# 十、一点想法
我以前觉得「字符串长度」是个天经地义的属性,跟「这根绳子多长」一样,答案就在那儿。
现在我觉得它更像「这根绳子多长」——在你决定用米还是英尺之前,这个问题没有答案。字符串麻烦的地方在于,三把尺子同时存在,而且没有哪把是错的:UTF-16 码元是内存布局的事实,码位是编码标准的事实,字素簇是用户认知的事实。三者都对,只是量的不是同一个东西。
我们之所以能糊弄十年还过得挺好,是因为 ASCII 时代三把尺子量出来恰好一样。英文世界里所有字符都在 BMP 里、都不组合、都单宽,于是 length 这个值长期以来是唯一解,谁也不会去怀疑它。等 emoji 和各国文字真正进入日常输入,三把尺子才分家,我们才发现自己从来没定义过到底要量什么。
这类事情我最近遇到得越来越多。它不深奥,就是一层一直没被问过的前提。写这篇文章之前我大概修过七八次这类 bug,每次都是「哦,又是代理对」,然后改掉那一行,从没想过为什么会有这一行。
那次用户投诉唯一的价值,是逼我把前提问出来了一次。
(本文涉及的支持度截至 2026 年 9 月底:Intl.Segmenter 为 Baseline 2024,主流浏览器均已支持;正则 v 标志属 ES2024,Chrome 112+ / Safari 17+ / Firefox 116+ 可用。MySQL 8.0 默认字符集为 utf8mb4,5.7 及更早默认 utf8mb3。)