1. 一个"长度是 11"的字符串,是怎么把我逼疯的
先说个真事。前阵子做用户昵称校验,产品要求"昵称最多 10 个字符"。我随手写了句 if (nickname.length > 10),测试同学反手就贴了个"👨👩👧👦"过来,说"这个 emoji 的长度是 11,进不了库,你是不是搞错了?"
我盯着控制台里 "👨👩👧👦".length 返回的 11 愣了好几秒。肉眼看上去这就是一个"字符",一个家庭团聚的符号,凭什么它算 11?更离谱的是,这个长度在不同语言里还不一样——有的返回 7,有的返回 4,有的返回 25。
这个问题的根源,就是字符串世界里的"长度谎言":几乎所有编程语言提供的 length,量出来的都不是你眼睛看到的"字符数",而是某种底层存储单位的数量。你以为是"字符数",它给你的可能是"字节数""UTF-16 代码单元数""Unicode 码点数",偶尔才是"用户感知字符数"。
所以这篇不是教你背结论,而是把"字符串长度"这层窗户纸彻底捅破,让你知道:
length到底是什么单位算出来的;- 为什么 emoji、组合字符、旗帜、变体选择符会把长度搞得奇奇怪怪;
- 不同语言之间的
length有什么坑; - 当你真正需要"用户看到的字符数"时,应该怎么处理。
这篇文章适合所有写过后端校验、前端表单、算法题,或者处理过用户输入的程序员。看完你会有一种"原来这些年我都被 length 骗了"的感觉,但更重要的是——以后你再也不会被它骗了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从 ASCII 到 Unicode:length 的单位是怎么一步步变乱的
要搞清楚"长度谎言",得先知道 length 背后量的到底是什么。这玩意儿不是程序员拍脑袋定义的,而是跟着字符编码标准一路演化过来的。
2.1 早期时代:一个字符 = 一个字节
ASCII 时代,一个字母、数字、标点,都用一个字节(8 bit)表示。'A'.length 是 1,'hello'.length 是 5,没有任何争议。因为当时世界上根本没有"多字节字符"这回事,字符、字节、长度三者是严格等价的。
后来有了各种本地化编码,比如我们国内用过的 GB2312 / GBK,一个汉字占两个字节,但很多语言在设计上依然让 length 返回"字符数"而不是"字节数"。所以那个年代,你在 C 语言里对中文字符串用 strlen() 会得到字节数(比如"你好"是 4),而在 Java 里 "你好".length() 是 2。分歧从这里就开始了。
2.2 Unicode 时代:同一个字符,三种打包方式
Unicode 的野心很大,它想把地球上所有文字、符号、emoji 统一编成一张大表,每个字符有一个唯一的编号,这个编号叫 码点(Code Point),通常写成 U+1F600 这样的十六进制形式。
但"有编号"和"存进计算机"是两回事。于是出现了三种主流编码方案:
| 编码方案 | 存储单位 | 一个常见中文字符占 | 一个 emoji 占 |
|---|---|---|---|
| UTF-8 | 字节 | 3 字节 | 4 字节 |
| UTF-16 | 16 位代码单元(2 字节) | 2 字节(有些字 4 字节) | 4 字节(代理对) |
| UTF-32 | 32 位代码单元(4 字节) | 4 字节 | 4 字节 |
这相当于同一个货品,用不同大小的箱子打包。UTF-8 喜欢用小箱子,所以可变长,省空间,但"货物数"和"箱子数"差距很大;UTF-32 用统一的大箱,最浪费但数箱子最准;UTF-16 居中,但有个致命的问题——它想通吃 16 位以内的字符,结果发现 emoji 和一些生僻字编号超过了 16 位能表示的 0xFFFF,只好搞出了 代理对(Surrogate Pair),用两个 16 位代码单元拼出一个字符。
这一下,乱子就来了。
打个比方:UTF-16 就像一家快递公司,大部分包裹一个箱子装下,但大件物品拆成两个箱子。你去取件时,快递柜告诉你"有两个箱子",可你手上明明只有一件货。
2.3 一句话概括:"length" 有五个层次
现在你就能理解,字符串的"长度"其实可以分五个层次:
- 字节长度(Byte Length):整串字符存储时占多少字节。
- 代码单元长度(Code Unit Length):按 UTF-16 或 UTF-8 的存储单位数。JavaScript、Java 的
length就是这个。 - 码点数量(Code Point Count):Unicode 表里有多少个独立编号。Python 的
len()基本在这个层次。 - 字形簇长度(Grapheme Cluster Length):用户肉眼看到的"一个字符"。
- 渲染宽度(Display Width):显示出来占多宽,跟字体和排版有关。
大部分时候,你以为自己要的是第 4 种,结果语言给你的是第 1、2、3 种之一。这就是"长度谎言"的全部秘密。
3. 为什么 '👨👩👧👦'.length === 11?拆开给你看
回到开头的例子。在 JavaScript 里,'👨👩👧👦'.length 为什么是 11?我一步步拆给你看。
3.1 Unicode 码点清单
'👨👩👧👦' 这个"家庭"emoji,并不是 Unicode 表里单独定义的一个字符,而是由 7 个码点拼出来的:
| 序号 | 码点 | 含义 | 备注 |
|---|---|---|---|
| 1 | U+1F468 |
👨 男人 | 需要代理对 |
| 2 | U+200D |
零宽连接符(ZWJ) | 不可见 |
| 3 | U+1F469 |
👩 女人 | 需要代理对 |
| 4 | U+200D |
零宽连接符 | 不可见 |
| 5 | U+1F467 |
👧 女孩 | 需要代理对 |
| 6 | U+200D |
零宽连接符 | 不可见 |
| 7 | U+1F466 |
👦 男孩 | 需要代理对 |
在 UTF-16 编码下,每个 U+1F468 这样的码点因为超出基本多文种平面(BMP),需要拆成两个代理项(Surrogate),也就是 2 个代码单元。而 U+200D 在 BMP 内,占 1 个代码单元。
所以代码单元总数是:
- 4 个 emoji 码点 × 2 = 8
- 3 个零宽连接符 × 1 = 3
- 合计 = 11
所以 '👨👩👧👦'.length === 11 不是一个谜题,而是 JavaScript 在告诉你:"这串字符要占 11 个 UTF-16 代码单元"。它从头到尾都没说过"这是 11 个用户看到的字符"。
3.2 你以为是"一个 emoji",其实是"积木拼成的表情"
很多人以为 emoji 就是 Unicode 表里的一个独立条目,这个印象对早期的 emoji(如 😀 U+1F600)成立,但现在越来越多 emoji 是"拼出来"的:
- 家庭、情侣、职业 emoji:通过 ZWJ 把多个 emoji 拼成一个形象。
- 肤色修饰:像是 👍 可以拼成 👍🏻、👍🏼、👍🏽等,多出来的肤色字符也是额外码点。
- 变体选择符:比如 ❤ 本来只是个心形符号,加上
U+FE0F(变体选择符 16)才会明确显示为 emoji 样式。 - 旗帜:比如 🇨🇳 是
U+1F1E8(区域指示符 C)+U+1F1F3(区域指示符 N)两个码点拼出来的。
这些"隐藏玩家"都是不可见的,但它们真实存在于字符串里,自然会被 length 统计进去。
3.3 实战验证:在 Node.js 里数一数
你可以打开 Node.js 终端试一下:
javascript复制const family = '👨👩👧👦';
// 1. length:UTF-16 代码单元数量
console.log(family.length); // 11
// 2. 用 Array.from 按码点拆分
console.log(Array.from(family));
// ['👨', '\u200D', '👩', '\u200D', '👧', '\u200D', '👦']
// 3. 用扩展运算符,本质也是码点级
console.log([...family].length); // 7
// 4. 用 Intl.Segmenter 按字形簇拆分(用户看到的字符数)
const segmenter = new Intl.Segmenter('zh', { granularity: 'grapheme' });
const graphemes = Array.from(segmenter.segment(family), x => x.segment);
console.log(graphemes.length); // 1
跑完这段代码,你就能直观感受到"三种长度"的差别:11、7、1。
注意:
Array.from不区分码点后面的组合符号,它按"码点"拆分,所以['👨', '\u200D', ...]中 ZWJ 依然作为一个独立元素存在。只有Intl.Segmenter才会把整个"家庭"形象合并成一个字形簇。
4. 各语言的 length 都在量什么?一张表看明白
很多人换语言写代码时,带着上一个语言的直觉,这是踩坑的根源。下面这张表,基本覆盖了日常开发的主流语言,建议直接收藏。
| 语言 | length 返回什么 |
示例 | 需要怎么拿到"用户感知字符数" |
|---|---|---|---|
| JavaScript | UTF-16 代码单元数 | '👍'.length 为 2 |
Intl.Segmenter 或第三方库 |
| Java | UTF-16 代码单元数 | "👍".length() 为 2 |
codePointCount() + 额外处理 |
| Python 3 | Unicode 码点数 | len('👍') 为 1 |
需借助 grapheme 库(组合字符时) |
| Go | 字节数 | len("👍") 为 4 |
utf8.RuneCountInString() 得到码点数 |
| Rust | 字节数 | "👍".len() 为 4 |
s.chars().count() 得到码点数 |
| Swift | 字形簇数量 | "👨👩👧👦".count 为 1 |
默认就是最接近用户感知的 |
| Ruby | 字符数(Ruby 对 Unicode 处理接近字形簇) | "👍".length 为 1 |
一般不用特殊处理 |
4.1 关键差异逐个说
JavaScript / Java 系
它们是 UTF-16 代码单元派,历史原因是在 Unicode 还没膨胀时就把字符串设计成了"16 位整数数组"。今天看来这个设计很尴尬,但为了向后兼容只能一路扛着。所以在 JS 里 '👍'.length 是 2,'𠮷'.length 也是 2(生僻汉字),完全不看你眼里有几个字。
Java 稍微多给了点希望:String 虽然也是 UTF-16 代码单元,但提供了 codePointCount() 方法来数码点。如果你只是处理 BMP 内的字符,codePointCount() 基本够用;一旦遇上 ZWJ 序列,它也只能返回 7(码点数量),仍然不是用户看到的 1。
Python 3
len('👍') 是 1,因为 Python 3 的字符串在内存里是 Unicode 码点序列。听着挺完美,但千万别高兴太早——len('👨👩👧👦') 是 7,不是 1。因为 Python 的 len 只认码点,不认"字形簇"。同理,len('e\u0301')(e + 重音符号)是 2,但你在屏幕上看到的是"é"一个字符。
Go / Rust
这两兄弟都是字节派,len() 就是看有多少个字节。对中文和 emoji 极不友好,一个汉字在 Go 里 len("中") 是 3(UTF-8 编码),一个 emoji 是 4。Go 有 utf8.RuneCountInString() 能数到码点层级,但同样解决不了组合字符。
Swift
Swift 是我见过的默认处理最人性化的:count 直接返回字形簇数量。"👨👩👧👦".count 就是 1。代价是性能不如纯整数遍历,且早期版本踩过一堆 Unicode 规范更新的坑,但方向是对的。
4.2 没有"正确长度",只有"你要哪个长度的"
看到这里你会发现,与其争论哪个语言对,不如先回答一个问题:你要拿这个长度做什么?
- 如果是限制用户输入长度,应该用字形簇数量。
- 如果是分配存储空间、计算网络带宽,应该用字节数。
- 如果是算法题里遍历字符,大多数时候只要码点数就够了。
- 如果是判断是否为空字符串,所有层级的结果都一样,不用纠结。
最怕的是用一种长度的思路去处理另一种长度的需求,然后写出"输入 10 个字符却塞不下 3 个 emoji"这种 bug。
5. 实战:在 JavaScript 里拿到"用户眼中的字符数"
既然 JavaScript 的 length 最反直觉、用的又是最广泛,我就重点讲讲在 JS 里怎么破局。
5.1 现代方案:Intl.Segmenter
Intl.Segmenter 是浏览器和 Node.js 提供的国际化分段器,支持按字形簇(grapheme)切分字符串。它是目前 JS 里最接近"用户感知字符数"的方案:
javascript复制function graphemeCount(str) {
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
return Array.from(segmenter.segment(str)).length;
}
console.log(graphemeCount('👨👩👧👦')); // 1
console.log(graphemeCount('👍🏻')); // 1
console.log(graphemeCount('❤️')); // 1
console.log(graphemeCount('🇨🇳')); // 1
console.log(graphemeCount('á')); // 1
Segmenter.segment() 返回一个可迭代对象,每个分段代表一个用户感知的字符(即字形簇)。不仅是 emoji,连"e + 重音符号"这种组合字符也能正确处理。
5.2 旧环境怎么办?第三方库兜底
Intl.Segmenter 虽然现代浏览器基本都支持,但总有些老项目跑在旧内核上。这种时候可以用 grapheme-splitter 这个库,它实现了 Unicode UAX #29 字形簇边界规则:
javascript复制const GraphemeSplitter = require('grapheme-splitter');
const splitter = new GraphemeSplitter();
console.log(splitter.countGraphemes('👨👩👧👦')); // 1
如果连第三方库都不想引,还有一个贫民版办法:用正则配合 u 标志配合 Unicode 属性转义,但说实话这个方案的捕获规则非常繁琐,遇到未来新的 emoji 组合有可能失效,不如直接上库。
5.3 截断字符串:不要在 10 这个位置动手
做列表展示时经常要截断字符串,比如"最多显示 12 个字"。很多人的第一反应是 str.slice(0, 12)——如果你展示的内容里有 emoji,恭喜你,你会在中间看到半个"家庭"或者直接出现乱码方块。
正确的截断思路是先按字形簇切成数组,再按数量取:
javascript复制function truncateByGrapheme(str, max) {
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
const parts = Array.from(segmenter.segment(str), x => x.segment);
if (parts.length <= max) return str;
return parts.slice(0, max).join('') + '…';
}
console.log(truncateByGrapheme('👨👩👧👦 的日常', 3)); // '👨👩👧👦 的…'
注意:这里 slice(0, 3) 切的是字形簇数组,而不是原始字符串的下标,所以不会从 ZWJ 或代理对中间切断。这是所有字符串 UI 处理中最容易踩、也最容易被忽视的坑。
5.4 输入框 maxlength 的限制
HTML 的 maxlength 属性在浏览器里按 UTF-16 代码单元计数。你设 maxlength="8",用户可以输入 4 个"家庭"emoji。如果产品要求"最多 8 个字符",你得自己监听输入事件,用上面的 graphemeCount 判断,超了就截断,并同步修正光标位置:
javascript复制input.addEventListener('input', () => {
const max = 8;
const parts = Array.from(
new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(input.value),
x => x.segment
);
if (parts.length > max) {
input.value = parts.slice(0, max).join('');
}
});
5.5 数据库存储字段长度估算
MySQL 的 VARCHAR(255) 里的 255 是"字符数"吗?在 MySQL 8 的 utf8mb4 字符集下,VARCHAR(255) 的 255 是 255 个字符(码点),但每字符最多占 4 字节,所以存储上限是 1020 字节。如果你用 VARCHAR(20) 存用户的"名字",用户输入 20 个 emoji 头像符号,每个 4 字节,显然没问题;但如果某个字符集的单字占用不同,估算就要按最大字节数算。这个细节来自我实际调优时踩过的坑:有一次我把用户昵称字段从 VARCHAR(50) 改成 VARCHAR(50) 但换了字符集,结果存量数据直接超长,导入时一堆报错。
所以结论是:凡是涉及底层存储和网络传输的地方,一律按字节算;凡是涉及用户界面和交互限制的地方,一律按字形簇算。这个原则可以帮你避免 90% 的"长度" bug。
6. 不只是 emoji:组合字符和规范化(Normalization)
emoji 引发的长度混乱是显性的,但字符串里还有一种"隐身杀手"——组合字符。它们不会像 ZWJ 那样一眼引起注意,但同样能让 length 变得难以理解。
6.1 重音符号:同一个 "é" 的两种写法
Unicode 里,字符"é"有两种表示方式:
- 预组合形式(Precomposed):
U+00E9,一个码点直接就是"é"。 - 分解形式(Decomposed):
U+0065(e)+U+0301(组合重音符号),两个码点拼出"é"。
在 JS 里:
javascript复制const precomposed = '\u00E9'; // é
const decomposed = 'e\u0301'; // e + 组合重音
console.log(precomposed.length); // 1
console.log(decomposed.length); // 2
但在屏幕上,两者长得一模一样。如果你在比较两个用户输入的时候不做处理,'é' !== 'e\u0301',直接导致字符串匹配失败。
解决方案是 Unicode 规范化(Normalization)。JS 提供了 String.prototype.normalize() 方法:
javascript复制const nfc = decomposed.normalize('NFC'); // 转成预组合形式
console.log(nfc === precomposed); // true
常用的四种规范化形式:
| 形式 | 含义 | 适用场景 |
|---|---|---|
| NFC | 优先组合(能拼就拼) | 大多数数据存储和比较场景 |
| NFD | 优先分解(全拆开) | 需要统一处理重音符号的场景 |
| NFKC | 兼容分解再组合 | 处理全角/半角、罗马数字等兼容字符 |
| NFKD | 兼容分解全拆开 | 搜索、过滤场景 |
6.2 全角与半角:看上去差不多的字,码点完全不同
"A"(全角 A,U+FF21)和 "A"(半角 A,U+0041)是两个码点。前者长度是 1,后者长度也是 1,但它们不相等。做搜索或清洗数据时,NFKC 规范化可以把全角转半角:
javascript复制console.log('ABC'.normalize('NFKC')); // "ABC"
这种"看似相同、实则不同"的例子在韩文、阿拉伯文、梵文里更多。所以文本处理中,规范化应该是一个标配动作,而不是可选项。
6.3 排序和搜索时也要小心
很多数据库和搜索索引对字符做排序时按码点顺序排。带重音符号的字符和基本字符经常排不到一起,甚至同一种语言在不同平台下排序规则都不一样。如果你让用户搜索"café",数据库里存的是"café"(分解形式),不带规范化或 collation 配置,搜索"cafe"可能就匹配不上。
遇到这种需求,我通用的做法是:入库前统一 normalize('NFC'),搜索时对关键词也做同样处理,必要的时候建一个"排序键"字段专门存规范化后的字符串。这样既省心,又不会因为底层存储细节导致功能错误。
7. 那些年在字符串长度上踩过的真实案例
讲原理容易,真正的痛点是你不踩一次不会记住。我把自己和同事们踩过的、以及社区里高频出现的坑整理一下,你可能正在经历其中一个。
7.1 案例一:短信平台把 emoji 拆成乱码
一个做短信通知的项目,需求是"内容不能超过 70 个字符"。后端同事按 str.length 判断,然后交给运营商通道。结果用户发来一条带 emoji 的短信,App 里看着只有 50 个字,到运营商那边却变成了 100 多个字符,被拆成两条,费用翻倍,而且第二条开头还是个乱码。
原因就是:短信的计费单位是字节或 70 个 GSM 字符,很多网关后台用的是 length 按 UTF-16 或字节计算,和用户看到的"字数"差了十万八千里。
7.2 案例二:活字印刷般的推荐位标题
另一个项目是首页 banner 推荐位,标题最多 20 个字,多出的部分用 CSS 截断。测试人员反馈说:"标题有 19 个字,但显示被截断了。"
查下来发现,标题里有一个"🔥"。🔥 在 UTF-16 里占 2 个代码单元,CSS 的 max-width 截断不是按代码单元,而是按渲染宽度,但后端存储和校验是按 length 来的。一条 length 只有 19 的字符串,渲染出来偏偏超了宽。
这个案例的结论是:显示层的问题不要用 length 修,要用 CSS 或字形簇逻辑解决;输入长度限制的问题不要用渲染宽度修,要先确定按哪个"字符"标准。
7.3 案例三:用户输入 100 个 ZWJ,数据库字段爆了
一个留言功能,数据库字段 VARCHAR(140)。有用户提交了一条看起来只有 20 个字的留言,后端按 graphemeCount 校验通过,但存库时报"Data too long"。
原因是:用户复制了一段从社交平台弄来的内容,里面带了大量零宽连接符和不可见字符。这些字符在 UI 上完全不可见,但按字节算体积巨大。我的建议是:存储层长度限制必须按"编码后的字节数"校验,同时在前端过滤或剔除不可见控制字符(除了必要的 ZWJ 之类)。这也是为什么很多平台的"字数统计"和"存储限制"是两套逻辑。
7.4 案例四:算法题里的字符串反转陷阱
很多人在刷题时写过"字符串反转"。遇到 '👨👩👧👦' 这类 emoji,如果按 split('') 反转,ZWJ 会被拆开,显示出一堆支离破碎的"爸爸、妈妈、女儿、儿子"。正确做法是按字形簇切分再反转:
javascript复制function reverseGraphemes(str) {
const parts = Array.from(
new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(str),
x => x.segment
);
return parts.reverse().join('');
}
这虽然看起来像"炫技",但在处理用户输入、做文本编辑器、做富文本工具时,这是基本操作。
8. 遇到"长度"问题,先问自己三个问题
结合前面的坑,我给你总结一个实操清单。每次碰到字符串"长度"相关的需求,先回答这三个问题,再动手写代码:
第一个问题:你要的长度,服务的目的是什么?
如果是限制输入框字数,要的是"用户看到的字符数";如果是控制数据库字段长度,要的是"编码后字节数";如果是遍历字符串做处理,只要码点数就够;如果是做 UI 截断,请直接用 CSS 或字形簇逻辑。目的决定单位,单位决定方法。
第二个问题:你用的是哪个语言的 length?它的单位是什么?
不要假设所有语言的 length 是一样的。JavaScript 和 Java 是 UTF-16 代码单元;Python 是 Unicode 码点;Go、Rust 是字节数;Swift 是字形簇。换语言时先确认这个语言对字符串的底层抽象,再决定要不要自己补处理逻辑。
第三个问题:你处理的数据源可能包含哪些"特殊字符"?
只要是用户输入的数据,就要默认它可能包含 emoji、组合重音、变体选择符、零宽连接符、全角符号、不可见控制字符。数据来源越杂(社交平台复制、不同系统输入法、国际化用户),越要在入库前做规范化处理和过滤。
这三个问题想清楚了,字符串长度问题基本就能从"玄学"变成"工程学"。
9. 最后几个小建议:别把长度当圣旨
做开发这些年,我最大的体会是:"length" 只是一个底层工具,它不是业务语义。你要在业务层给它重新定义,转化成业务语言。比如产品说"标题最多 20 个字",技术方案应该明确"这 20 个字是按字形簇计数",而不是简单写个 maxlength。
如果你在团队里,建议把"字符串长度"的约定沉淀成公共函数或工具库,统一命名,比如 countGraphemes()、countBytes()、truncateByGrapheme(),不要让人在业务代码里裸用 str.length。这样即使有新人加入,他大概率也不会重新踩一遍我们踩过的坑。
还有一个容易被忽视的点:不同版本的 Unicode 标准对 emoji 和字形簇的定义会更新。同样的字符串,在旧版浏览器和新的浏览器上,Segmenter 的分段结果可能不完全一致。如果涉及需要长期稳定比较的场景,最好把规范化处理和分段逻辑固定在某一个版本上,并且写单元测试覆盖关键 emoji 样本。
最后再分享一个小技巧:测试的时候别只测"普通字符串",把下面这些样本加到你的用例里:
'👨👩👧👦'(ZWJ 家庭)'👍🏽'(肤色修饰)'❤️'(带变体选择符)'🇨🇳'(区域指示符旗帜)'e\u0301'(组合重音)'ABC'(全角字符)'a\u0300\u0315'(多重组合变音符号)
这七个样本,足以打败 99% 的"长度校验"实现。
字符串的长度从来就不是一个简单的数字,它是编码标准、语言实现、用户感知三者之间的折中。看清这层折中,你就从"被 length 骗的人"变成了"看透 length 的人"。
