先记住这个答案
JavaScript 字符串在内部按 UTF-16 编码存储,length 返回的是 16 位码元的个数,不是用户看到的字数。英文字母和常用汉字都在基本平面里,一个字一个码元;😀 这类表情的码点超过 U+FFFF,要用两个码元组成的代理对来存,所以 length 是 2。带肤色的点赞、家庭表情是好几个码点用连接符拼起来的,length 会是 4、8 甚至更多。
length数码元,不数用户看到的字- 常用汉字 1 个码元,大部分表情 2 个
[...str].length数码点,组合表情仍会多算- 按看到的字数统计用
Intl.Segmenter
同一个「字」,三种数法
把几类字符放在一起,分别用 length、展开运算符、Intl.Segmenter 数一遍,差别一目了然。
const list = [['英文', 'abc'], ['汉字', '中文'], ['表情', '😀'],
['带肤色', '👍🏽'], ['家庭', '👨👩👧'], ['组合重音', 'e\u0301']]
const seg = new Intl.Segmenter('zh', { granularity: 'grapheme' })
for (const [name, s] of list) {
const codePoints = [...s].length // 按码点数
const graphemes = [...seg.segment(s)].length // 按用户看到的字数
console.log(name, s.length, codePoints, graphemes)
}查看输出与解释
英文 3 3 3
汉字 2 2 2
表情 2 1 1
带肤色 4 2 1
家庭 8 5 1
组合重音 2 2 1每行三个数依次是 length、码点数、字素簇数。只有最后一列和用户的感受一致。
带肤色的 👍🏽 是「点赞」加「肤色修饰符」两个码点,每个都占两个码元,所以 length 是 4。家庭表情是三个人物表情中间夹两个零宽连接符(ZWJ),一共 5 个码点、8 个码元,屏幕上却只显示一个图案。
为什么是 UTF-16
JavaScript 诞生时,Unicode 还以为 65536 个字符就够用,16 位一个字符正好。后来字符数量不够,Unicode 扩展到了 U+10FFFF,UTF-16 用「代理对」来表示超出部分:一个高位代理加一个低位代理,两个码元合起来表示一个码点。
为了兼容,length、下标、charCodeAt、slice 这些老 API 都保持按码元工作。ES2015 之后新增的 for...of、展开运算符、codePointAt、String.fromCodePoint 才开始按码点处理。
组合字符是另一回事。「é」可以是一个预组合码点,也可以是字母 e 加一个组合重音符,后者是两个码点,length 为 2。这种情况可以用 normalize('NFC') 合并成一个码点。
项目里怎么数字数
输入框限制「最多 20 个字」,后端存储限制「最多 50 字节」,这是两个不同的需求。前者按用户看到的字数,用 Intl.Segmenter 最准确,不支持的环境退而用 [...str].length;后者要按 UTF-8 字节数算,可以用 new TextEncoder().encode(str).length,一个汉字 3 字节,一个表情 4 字节。
按规范,HTML 的 maxlength 属性也是按码元计数的,限制 10 的输入框大约只能输 5 个表情。做表单校验时,前端提示的字数口径要和这个限制对齐。
容易答错的地方
- 以为中文字符
length都是 2 - 常用汉字都在基本多文种平面内,一个字就是一个码元,
'中文'.length是 2 而不是 4。只有扩展区的生僻字才会占两个码元。说「中文算两个」是把 UTF-16 和 GBK 双字节的说法混在一起了。 - 以为
[...str].length就是真实字数 - 展开运算符按码点拆分,能正确处理单个表情,但组合表情、带肤色表情、组合重音仍然会被拆成多个。要和用户看到的一致,只能按字素簇拆。
- 用
slice按length截断昵称 nickname.slice(0, 10)按码元截,刚好截在代理对中间,末尾会留下半个表情,显示成一个问号方块。截断要先按字素簇拆成数组,再取前几项拼回去。
面试官还会怎么问?
怎么判断一个字符串里有没有表情?
可以用带 u 标志的正则配合 Unicode 属性转义,比如 /\p{Extended_Pictographic}/u.test(str)。不加 u 标志时,正则也按码元匹配,. 会只匹配半个表情。
charCodeAt 和 codePointAt 有什么区别?
charCodeAt(i) 返回第 i 个码元的值,遇到表情只拿到高位代理。codePointAt(i) 在 i 位置是高位代理时,会把后面的低位代理一起读出来,返回完整码点,比如 😀 得到 0x1F600。
反转含表情的字符串为什么会乱码?
str.split('').reverse().join('') 按码元拆分,代理对的高低位被颠倒,变成两个无效码元。改成 [...str].reverse().join('') 能处理单个表情,组合表情仍然需要按字素簇拆分后再反转。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。