“为什么这个emoji的长度是11?”——上个月同事抱着电脑过来,指着控制台里的'👨👩👧👦'.length问我,说这个看起来明明只有一个“家庭”图标,怎么就数出11了?我当时笑了,因为这问题我几年前第一次处理用户昵称时就撞到过。屏幕上明明一个字符,程序却告诉你它由11个字符组成,这不是编程语言疯了,而是字符串里的“长度”从来就比我们肉眼看到的要复杂。
这篇文章就把这事彻底聊透:从JavaScript的length为什么会数出11,到UTF-16编码单元、代理对、零宽连接符这些概念,再到截断、反转、输入框限制、数据库存储这些真实业务场景里会踩到的坑。不管你是前端、后端还是客户端开发,只要写过一行字符串处理代码,这文章都值得看完。
1. 11 这个数字是怎么跳出来的:一次实际案例拆解
1.1 同事的疑问是怎么来的
先还原一下当时的场景。业务里有个用户昵称,明文限制“最多20个字符”,前端用str.length做了个即时校验,超过了就提示。有个用户把昵称设置成了👨👩👧👦这个家庭emoji,代码一跑,str.length === 11,校验自然是放行了,数据也存进去了。结果到了另一个列表页面,同样用length去截断昵称、加省略号,这个“超长昵称”就显示成半个残缺符号,后面还得跟着一个黑色问号方块,丑得不行。
你可能会说,那把这个家庭emoji当成一个字符不就行了?问题是JavaScript不这么认为。它在length这个属性上从来就不数“你眼里看到的东西”,它数的是UTF-16编码单元。而我这位同事输进去的👨👩👧👦,在计算机内存里根本不是1个字符,而是7个Unicode码点拼起来的一长串:爸爸、零宽连接符、妈妈、零宽连接符、女孩、零宽连接符、男孩。这串东西里面,表情符号本身又各自占2个UTF-16编码单元,于是最终账就变成了:
- 👨:2个编码单元
- ZWJ零宽连接符:1个编码单元
- 👩:2个编码单元
- ZWJ:1个编码单元
- 👧:2个编码单元
- ZWJ:1个编码单元
- 👦:2个编码单元
一共2+1+2+1+2+1+2=11。这就是11的来源,拆开来看一个不多一个不少。
1.2 用 charCodeAt 把 11 个编码单元摆到台面上
光说不够直观,直接在浏览器控制台把每个编码单元打印出来看,你马上就能明白:
javascript复制const family = '👨👩👧👦';
console.log(family.length); // 11
for (let i = 0; i < family.length; i++) {
console.log(i, family.charCodeAt(i).toString(16));
}
输出结果会是这样:
code复制0 d83d
1 dc68
2 200d
3 d83d
4 dc69
5 200d
6 d83d
7 dc67
8 200d
9 d83d
10 dc66
前两个d83d dc68对应👨,中间那个200d就是零宽连接符,后面依次是妈妈、女孩、男孩。这里有一个很微妙的细节:charCodeAt一次只能取一个16位编码单元,所以看起来一个“字符”占了数组里的两个位置。也就是说,在你看到的表情符号背后,藏着两段互补的16位数字,它们配对出现,才能组合成一个完整的Unicode码点。
这种高低位配对的结构,专业术语叫“代理对”(surrogate pair)。理解了代理对,你就能明白为什么'😀'.length也是2,因为笑脸😀的Unicode码点是U+1F600,大于0xFFFF,没办法写进一个16位编码单元里,必须拆成高位0xD83D和低位0xDE00两个部分存。
1.3 这不是 JS 的 bug,而是“用什么计数”的问题
这是整个话题里最重要的一句话:length不是bug,因为它从一开始就没承诺过返回“可感知字符数”。它承诺的是返回这个字符串包含多少个UTF-16编码单元。就像一把尺子,拿厘米量就是11厘米,拿米量就是0.11米,数字不同不是尺子坏了,是单位不同。JavaScript选的是UTF-16编码单元,Java的String.length()也这么选,因为这是JVM和V8引擎内部最自然的字符串存储口径。
但问题在于,绝大多数业务需求要的不是“编码单元数”,而是“用户能看到几个字”。做字数统计、做输入框限制、做昵称截断、做字符串逆序,这些场景如果直接用length做单位,遇到emoji、生僻字、组合符号就会出各种诡异问题。所以接下来必须往底层再走一步,搞清楚编码单元、码点、字素簇这三层概念分别对应什么。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 藏在背后的 UTF-16:length 数的不是“字符”,是“编码单元”
2.1 从码点到 UTF-16 编码单元
在Unicode的世界里,每个“字符”先有一个唯一编号,叫“码点”(code point),范围是从U+0000到U+10FFFF,总容量超过110万个位置。这里面日常用到的大部分汉字、英文、标点都落在前65536个位置,这一块叫“基本多语言平面”(BMP)。BMP内的码点直接用16位二进制就能装下,所以UTF-16编码里,一个BMP字符就对应一个编码单元,这就是'中'.length === 1的原因。
可问题来了,Unicode一共计划了17个平面,BMP之外还有16个辅助平面,里面装了大量冷门文字、数学符号,以及我们今天讨论的主角——大部分emoji。它们的码点都大于0xFFFF,用16位装不下。为了继续用16位编码单元去表示它们,Unicode设计了代理机制:把辅助平面里一个码点拆成两个16位编码单元,一个叫高位代理(high surrogate),范围0xD800~0xDBFF,一个叫低位代理(low surrogate),范围0xDC00~0xDFFF。这两个配对起来,才能还原出原始码点。
所以一个辅助平面的字符,在UTF-16里占2个编码单元,而在JavaScript里,length统计的是编码单元,一个辅助平面的字符自然就算成2。这不是某一个字符特殊,而是所有不在BMP内的字符都这样。
2.2 代理对:为什么一个 😀 也是 2
拿最典型的笑脸emoji来算一笔账。😀的码点是U+1F600,大于0xFFFF,所以JavaScript里:
javascript复制console.log('😀'.length); // 2
console.log('😀'.charCodeAt(0).toString(16)); // d83d
console.log('😀'.charCodeAt(1).toString(16)); // de00
你看到d83d de00,这就是一对标准的代理对。如果把这两个编码单元拆开、单独存或者单独打印,你在终端里看到的通常是一个“�”或者完全空白,因为孤立的高位代理或低位代理都不是一个合法字符。
这里能解释一个很多面试题里喜欢问的现象:为什么'𠮷'.length是2而不是1。因为这个“𠮷”的码点是U+20BB7,同样落在辅助平面。它和你每天见到的汉字“吉”长得几乎一样,但在Unicode里的位置完全不同,因此在UTF-16里也要占两个编码单元。
2.3 从码点反推代理对的公式
如果只知道码点,怎么算出它的代理对?其实有一套非常机械的公式。以U+1F600为例:
javascript复制function toSurrogatePair(codePoint) {
const offset = codePoint - 0x10000; // 0x1F600 - 0x10000 = 0xF600
const high = 0xD800 + (offset >> 10); // 0xD800 + 0x3D = 0xD83D
const low = 0xDC00 + (offset & 0x3FF); // 0xDC00 + 0x200 = 0xDE00
return [high, low];
}
console.log(toSurrogatePair(0x1F600)); // [0xD83D, 0xDE00]
理解这个公式的意义不在于让你手写编码器,而是让你明白:当你在JavaScript里charCodeAt(0)拿到0xD83D时,绝不能把它当成一个独立字符去处理。凡是要遍历、截断、反转字符串的地方,都必须把“代理对作为一个整体”看待。如果一个处理函数没有处理代理对的意识,它大概率会在某个emoji上出问题。
顺带补充一句,UTF-8里这些辅助平面字符占4个字节,所以同一个😀在Go语言的len("😀")里返回的是4而不是2。不同编程语言对“长度”的口径完全不同,平时做前后端联调、做存储容量评估时,千万不能默认两边算出来的数字一致。
3. 长度谎言不止一种:变体选择符、肤色修饰符、国旗
3.1 文本变体 / emoji 变体:❤ 和 ❤️ 长度不一样
代理对只是第一层陷阱,就算一个字符落在BMP内、length是1,它也可能在视觉上被“附加”成看起来完全一样的另一个东西。最典型的是红心。你在网页源码里直接写❤,它码点是U+2764,在BMP内,'❤'.length === 1。但你从手机输入法里打出来的红心,也就是我们平时在聊天里看到的那个红色爱心,后面往往带了一个不可见的变体选择符U+FE0F,告诉渲染引擎“请用彩色emoji风格显示我”。于是:
javascript复制console.log('❤'.length); // 1
console.log('❤️'.length); // 2
两个字符在用户眼里长得几乎一模一样,但程序统计出来的长度一个1一个2。这类变体选择符还包括U+FE0E(要求文本风格显示)和U+FE0F(要求emoji风格显示),它们本身不占视觉宽度,却实实在在占着字符串的长度。对用户来说这属于“不可见字符”,对程序来说它们就是普通编码单元。
3.2 肤色与职业组合:可感知字符的膨胀
比变体选择符更夸张的是肤色修饰符。Unicode定义了5种肤色修饰符,码点从U+1F3FB到U+1F3FF,用于修饰手势、人物、职业类emoji。举个直白的例子:
javascript复制console.log('👍'.length); // 2,U+1F44D
console.log('👍🏽'.length); // 4,U+1F44D + U+1F3FD
这里👍🏽显示为“拇指+中等偏深肤色”,看起来仍然是一个符号,但length已经翻倍。如果职业人物再叠加零宽连接符,膨胀速度就更快了。比如程序员emoji🧑💻,它由“人”+零宽连接符+“笔记本电脑”组成,length为5。再比如护士、科学家、教师等等,这一类全是“人物+职业元素”的组合结构,单个可感知字符的length动辄5到8。
这里最关键的概念是零宽连接符(ZWJ,U+200D)。它像胶水一样把两个emoji粘在一起,让渲染引擎把它们当成一个复合emoji展示。注意,“当成一个复合emoji展示”是渲染层面的行为,在字符串数据层面并没有任何机制把它们合并成单一项。所以length仍然按底层编码单元累加,家庭emoji才会出现11这个数字。
3.3 国旗:两个区域指示符拼出来的“字符”
还有一个很容易忽略的坑是国旗。🇯🇵看起来是一个日本国旗,实际上它由两个“区域指示符”字母组合而成:U+1F1EF(区域指示符J)+U+1F1F5(区域指示符P)。每个区域指示符本身在辅助平面,各占2个编码单元,所以:
javascript复制console.log('🇯🇵'.length); // 4
有些特殊旗帜,比如英格兰旗🏴,它还要加上“黑旗+标签字符+英格兰标签+取消标签”这一大串,length甚至能到14。这类实现在数据层面完全不是一个字符。
我之所以把变体选择符、肤色修饰符、区域指示符放在一起说,是因为它们共同揭示了一个事实:用户看到的“一个字符”在Unicode数据模型里可能是“一个基础字符+若干组合修饰符”的组合。你无法通过简单的str.length拿到用户可感知的字符数量,也无法通过一个写死的substring下标去安全地截取用户输入。
4. 想要 JS 里拿到“真正的字符数”,从码点计数到字素簇
4.1 按码点计数:for...of 与 Array.from
既然length数的是编码单元,那最简单的补救办法就是“按码点”来数。ES6以后,字符串是可迭代的,迭代器恰好按码点遍历,因此:
javascript复制const family = '👨👩👧👦';
console.log([...family].length); // 7
console.log(Array.from(family).length); // 7
...family和Array.from(family)会把代理对合并成一个元素,所以家庭emoji在这里被拆成了7个部分:爸爸、零宽连接符、妈妈、零宽连接符、女孩、零宽连接符、男孩。这比length的11进步了,但仍然不是1。因为ZWJ序列没有参与合并,它只是在代理对层面合并了。
按码点统计能解决“代理对被劈开”的问题,但解决不了“组合序列被拆散”的问题。对于一般生僻字,比如𠮷,[...'𠮷'].length是1,这已经够用了。但在处理完整emoji的业务场景里,7和11都不是用户眼中的“1”,所以还得继续往下走。
4.2 按字素簇计数:Intl.Segmenter
如果我们真的希望把👨👩👧👦统计成1,把👍🏽统计成1,把🇯🇵统计成1,那就必须按“字素簇”(grapheme cluster)来分。字素簇的概念就是“用户感知的一个字符单位”,它由Unicode标准明确定义,包含了ZWJ序列、肤色修饰符、变体选择符、区域指示符组合等所有情况。
现代JavaScript里最可靠的做法是使用Intl.Segmenter:
javascript复制const segmenter = new Intl.Segmenter('zh', { granularity: 'grapheme' });
const family = '👨👩👧👦';
const segments = Array.from(segmenter.segment(family));
console.log(segments.length); // 1
console.log(segments[0].segment); // 👨👩👧👦
同一个Intl.Segmenter作用在👍🏽上,得到的长度是1;作用在🇯🇵上,得到的长度也是1。这才真正对齐了用户眼中的“字符数”。所以凡是做字数统计、昵称限制、敏感词过滤这类业务,我都建议优先用Intl.Segmenter,而不是自己维护一套复杂的正则。
兼容性方面需要提一句,Intl.Segmenter在现代浏览器和Node.js 16+里都可用,但如果你要支持很老的环境,就得准备降级方案。降级通常只能做到“按码点统计”,即退回到Array.from,至少不会拆散代理对。精确的字素簇识别在没有原生API的情况下非常难自己实现,不推荐手写。
4.3 不同语言对 length 的口径也各不相同
做全栈开发的人尤其要注意,不同语言对“长度”的统计口径是五花八门的。我列一个简表方便对照:
| 语言/环境 | “长度”口径 | '😀'的长度 |
说明 |
|---|---|---|---|
| JavaScript | UTF-16编码单元 | 2 | str.length |
| Java | UTF-16编码单元 | 2 | String.length() |
| Python 3 | Unicode码点 | 1 | len(str) |
| Go | UTF-8字节数 | 4 | len("😀"),元素个数才是1 |
| Rust | 字节数 | 4 | str.len(),chars().count()是1 |
| MySQL utf8mb4 | 字符数 | 1 | CHAR_LENGTH按字符算,LENGTH按字节算 |
这表意味着什么?假设前端用js的length做了“最多50个字符”的校验,一个包含20个emoji的字符串在前端提示“已经40字符了”,到后端Java里一算还是40,这个口径是一致的;但如果后端是Python或MySQL,可能算出20,两边就会对不上。而如果后端是Go,错误的方向会反过来:前端算40,Go算80。所以在设计接口时,最好明确约定“字符数”到底按什么口径计算,或者干脆让后端统一返回处理结果,前端只负责展示。
5. 线上真实踩过的坑:截断、反转、输入框、数据库
5.1 substring/slice 截断成半截代理对
这是最常见的线上事故。很多老代码喜欢这样截断昵称:
javascript复制function truncate(str, max) {
return str.length > max ? str.slice(0, max) + '…' : str;
}
这个函数遇到纯英文和中文没问题,遇到emoji就会出事。假设str = '你好😀',max = 3,str.slice(0, 3)返回的是“你好”加上😀的高位代理0xD83D,缺少低位代理0xDE00。这个残缺的字符串如果渲染到页面上,会变成一个替换字符“�”;如果写入MySQL的utf8mb4字段,可能直接报Invalid surrogate pair错误;如果继续往后端传,可能触发JSON解析异常。
修复方案很简单,截断的时候不要按编码单元切,至少按码点切:
javascript复制function truncateByCodePoint(str, max) {
const arr = Array.from(str);
return arr.length > max ? arr.slice(0, max).join('') + '…' : str;
}
但注意,这个方案还是会把家庭emoji拆成“爸爸+ZWJ+妈妈”这种半吊子组合,在视觉上变成一个断裂的序列。所以更严谨的截断应该按字素簇来:
javascript复制function truncateByGrapheme(str, max) {
const segmenter = new Intl.Segmenter('zh', { granularity: 'grapheme' });
const parts = Array.from(segmenter.segment(str), (s) => s.segment);
return parts.length > max ? parts.slice(0, max).join('') + '…' : str;
}
5.2 split('').reverse().join('') 反转乱序
字符串反转也是重灾区。经典写法:
javascript复制function reverse(str) {
return str.split('').reverse().join('');
}
如果传入'你好😀',它的执行过程是:先按编码单元拆成['你', '好', '\uD83D', '\uDE00'],再反转成['\uDE00', '\uD83D', '好', '你'],最后拼接。结果就是低位代理跑到了高位代理前面,显示成�好你。这就是很多人疑惑的“为什么一反转emoji就乱码”。
正确的反转方式至少按码点来:
javascript复制function reverseByCodePoint(str) {
return Array.from(str).reverse().join('');
}
这样'你好😀'会反转成'😀好你'。但如果字符串里有ZWJ序列,比如'👨👩👧👦你好',反转后会把家庭emoji内部的零宽连接符顺序打乱,变成'好你👦👧👩👨'之类,虽然代理对没拆开,组合序列却散架了。要彻底解决,需要使用字素簇数组反转:
javascript复制function reverseByGrapheme(str) {
const segmenter = new Intl.Segmenter('zh', { granularity: 'grapheme' });
const graphemes = Array.from(segmenter.segment(str), (s) => s.segment);
return graphemes.reverse().join('');
}
5.3 输入框 maxlength 与后端校验的偏差
HTML的maxlength属性也是按UTF-16编码单元计算的。一个<input maxlength="5">,用户想要输入2个完整的家庭emoji👨👩👧👦需要占22个编码单元,浏览器直接不让你输完。但如果你设置maxlength="11",用户可以完整输1个家庭emoji,而输入2个就会在第2个中途被截断。这个行为在各浏览器里基本一致,但对用户来说非常反直觉。
更麻烦的是,很多后端语言的口径跟前端不一致。如果前端用maxlength卡住了“11个编码单元”,传到Java后端,Java的String.length()也是11,这倒是没问题;但如果用了Python的len(),同样内容算出来可能只有7,后端校验可能认为没有超长。反过来,如果前端按“字素簇长度”做了限制,后端却按字节数限制,一个看起来只有5个字的emoji串会在后端直接报“超长”。这类问题在前后端分离的项目里非常隐蔽,通常只有用户报告“我输入明明很短,却提示超长”时才会暴露。
我建议的做法是:前端交互层尽量用字素簇计数来展示“剩余可输入字数”,后端存储层用字节数或字符数做硬限制,并在接口文档里写明口径。前后端各用各的约束没关系,但不能一个按UTF-16、一个按字节,否则必然有边界case打架。
5.4 数据库字段长度:按字符、按字节、按编码单元的差别
数据库字段长度的坑同样分类似。MySQL里VARCHAR(20)在utf8mb4字符集下,指的是20个“字符”(码点),而LENGTH()函数返回的是字节数。一个👨👩👧👦在utf8mb4下占多少个字节?它内部包含4个emoji和3个零宽连接符,每个emoji是4字节,每个零宽连接符也是3字节(U+200D在UTF-8里是3字节),总共4×4+3×3=25字节。所以一个家庭emoji,CHAR_LENGTH返回的是7(因为ZWJ算1个字符),LENGTH返回的是25字节。如果你在定义表结构时只给了VARCHAR(10),却想把一个由8个家庭emoji组成的昵称存进去,会直接报Data too long。
早期MySQL的utf8字符集最多支持3字节的UTF-8编码,根本存不了emoji,存一个就报错或者变成问号。后来升级到utf8mb4才能存4字节的emoji。这也是很多老系统“用户一输入emoji就变问号”的根本原因。
6. 我沉淀下来的处理清单与工具函数
6.1 一个兼顾兼容性的长度/截断工具函数
既然踩过这么多坑,我后来在项目里沉淀了一个小的文本处理模块,专治各种emoji和组合字符的“长度”问题。核心思路是:优先用Intl.Segmenter获取字素簇,不支持的环境退回到Array.from按码点处理。
javascript复制function getGraphemes(str) {
if (typeof Intl !== 'undefined' && Intl.Segmenter) {
const segmenter = new Intl.Segmenter('zh', { granularity: 'grapheme' });
return Array.from(segmenter.segment(str), (s) => s.segment);
}
return Array.from(str);
}
function visibleLength(str) {
return getGraphemes(str).length;
}
function visibleTruncate(str, max, suffix = '…') {
const graphemes = getGraphemes(str);
if (graphemes.length <= max) return str;
return graphemes.slice(0, max).join('') + suffix;
}
function visibleReverse(str) {
return getGraphemes(str).reverse().join('');
}
实际用的时候,我会把visibleLength用在所有面向用户的字数统计上,把visibleTruncate用在昵称列表、评论摘要、标题缩略这些场景。尤其在涉及用户输入的环节,哪怕你觉得“正常人不会用这种复杂emoji当昵称”,也一定要处理,因为总会有人用,而且一旦坏掉就只有他一个人能看到,这种bug反馈率极低、修复成本极高。
6.2 三个适用场景的判断清单
在处理这类问题时,我慢慢总结出一个判断优先级:
- 如果只是判断“字符串有没有超过N个字符”,并且业务对精度要求不高,优先用
Array.from(str).length,它至少不会拆代理对,成本也低。 - 如果业务里有用户可见的截断、字数提示、敏感词过滤、文本反转,或者需要跟iOS/Android原生端的显示保持一致,那就不要省事,直接用
Intl.Segmenter按字素簇处理。 - 如果是后端接口、数据库存储层,则必须明确按“字节”还是“字符”限制,不要用前端语言的口径硬套。前端用“字素簇”做体验限制,后端用“字节数”做资源上限,两者各司其职,但文档里必须写清楚。
另外还要留意一个很多人忽略的点:正则里的.通配符默认匹配一个码点,不是匹配一个编码单元,所以/^.$/.test('😀')是true,这一点和length数法并不一致。如果你之前写过str.replace(/./g, ...)这种逻辑,也要想想它是不是会把一个家庭emoji拆成好几段。这个问题不展开,但值得每个做字符串处理的人多留个心眼。
6.3 最后的一点个人体会
回到最开始的11。为什么我要把这个问题从头到尾展开讲?因为字符串的“长度谎言”不会只骗你一次。你在前端被骗一次,去后端看会再被骗一次,落到数据库可能还会变一个数字。如果不理解编码单元、码点、字素簇这三层差别,你只能在每个出bug的半夜里用charCodeAt一个数字一个数字地去排查,效率低且痛苦。
我现在的习惯是,写任何跟字符串有关的工具函数,第一行先想一个问题:这里的“长度”到底是给谁看的?如果是给机器看的,那用哪个口径都行,只要前后端一致。如果是给用户看的,那别犹豫,直接按字素簇来。把这个问题想清楚,比背一百个emoji编码表都有用。
