前阵子有个同事跑来找我,说用户一提交昵称就报错,提示“昵称长度不能超过20个字符”。用户完全无法理解:自己明明只输入了一个“👨👩👧👦”家庭组合表情,怎么就超了?同事把那段字符串拿给我看,我打开控制台随手敲了一行 "👨👩👧👦".length,屏幕上赫然印着一个数字:11。
然后我就开始跟他解释,这个看似离谱的数字背后,其实是“字符串长度”这个最基础的概念,在不同编程语言、不同编码方案里,各自拿着一把完全不同的尺子。你可能也遇到过类似的情况:数据库里 varchar(10) 明明够长,存中文或 emoji 却报错;表单里限制了 20 个字符,用户输入一串 emoji 却被拒绝;同一个字符串,在 JavaScript 里是 11,在 Python 里是 7,到了 Swift 里却变成了 1。这不是某个语言的 bug,而是字符串长度本身就是一门很微妙的“谎话学”。
这篇文章我会从“为什么长度是 11”这个具体问题出发,把 Unicode 码点、UTF-8、UTF-16、代理对、组合字符、变体选择符和 ZWJ 序列这些东西全部拆开,最后给出写代码时真正可用的长度处理方案。如果你写过前端表单校验、后端字段长度校验、数据处理脚本,或者只是单纯好奇 emoji 到底怎么存储的,这篇都值得看完。
1. 同一个“字符”,五种语言五个数字:先让问题现场还原
1.1 先从那个“11”开始:复现现场
我用几种主流的开发环境分别测了一下 👨👩👧👦 这个家庭组合 emoji 的“长度”,结果很有意思:
javascript复制// JavaScript
"👨👩👧👦".length; // 11
"😀".length; // 2
python复制# Python 3
len("👨👩👧👦") # 7
len("😀") # 1
java复制// Java
"👨👩👧👦".length(); // 11
"👨👩👧👦".codePointCount(0, "👨👩👧👦".length()); // 7
go复制// Go
len("👨👩👧👦") // 25
utf8.RuneCountInString("👨👩👧👦") // 7
swift复制// Swift
"👨👩👧👦".count // 1
"😀".count // 1
同样一段字符串,不同语言算出来的长度从 1 到 25 都有。如果你再把它写进 MySQL,还会发现 CHAR_LENGTH 返回 7,而 LENGTH 返回 25;在 SQL Server 里用 LEN 可能又给你一个 11。是不是已经开始晕了?
那咱们先把结论放在这里:这些数字没有一个是“错的”,它们只是在统计不同的对象。JavaScript 的 length 数的是 UTF-16 编码单元的数量,Python 3 的 len 数的是 Unicode 码点的数量,Swift 的 count 数的是用户肉眼可感知的“字素簇”数量,而 Go 的 len 数和 MySQL 的 LENGTH 数的是“字节数”。我在 👨👩👧👦 上的长度是 11,是因为它在 JavaScript 内部由 11 个 UTF-16 编码单元组成。
1.2 把“字符”这个词拆开看
很多误解的根源在于,我们日常说的“一个字符”对应到计算机里其实有四个不同的层级,从上到下分别是:
| 层级 | 名称 | 它统计的是什么 | 👨👩👧👦 数量 |
|---|---|---|---|
| 第 1 层 | 字素簇(Grapheme Cluster) | 用户视觉上认为的“一个字符” | 1 |
| 第 2 层 | Unicode 码点(Code Point) | Unicode 给每个字符分配的编号 | 7 |
| 第 3 层 | 编码单元(Code Unit) | UTF-8/UTF-16 编码后的最小片段 | 11(UTF-16)/ 25(UTF-8 字节) |
| 第 4 层 | 字节(Byte) | 存储时实际占用的空间 | 25(UTF-8 下的字节数) |
打个比方,这就像你问一套房子“面积是多大”,有人回答建筑面积 120 平,有人回答套内面积 95 平,有人回答土地证面积 70 平,还有人回答实际使用面积 85 平。你不能说谁报错了,只能说大家量的是不同的东西。字符串长度的问题本质上一模一样。
在这四个层级里,字素簇是离用户最近的,也是产品经理和用户口中的“一个字”;码点是 Unicode 标准里的“户口编号”;编码单元则是具体编码方案(UTF-8、UTF-16 等)在存储时切出来的块;字节就是最终的物理存储量。JavaScript 报 11,选的是第 3 层中 UTF-16 编码单元这把尺子。而 Swift 报 1,选的是第 1 层字素簇这把尺子。理解了这一点,后面所有问题就都顺了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 真正的底层账本:Unicode 码点、UTF-8 与 UTF-16
2.1 码点:Unicode 给每个字符发的“身份证号”
咱们先把最底层的东西讲清楚。Unicode 的核心工作,就是给世界上几乎所有文字系统的字符都分配一个唯一的数字编号,这个编号叫“码点”(Code Point)。它的取值范围从 U+0000 到 U+10FFFF,总共能容纳 1114112 个编号。这里面包含字母、数字、标点、符号、中日韩文字,以及各种表情符号。
在 Unicode 的世界里,从 U+0000 到 U+FFFF 这段区域叫基本多文种平面(BMP,Basic Multilingual Plane),我们日常用的绝大多数文字都在这个平面内。而 U+10000 到 U+10FFFF 这段区域叫辅助平面(Supplementary Planes),里面主要放着一些生僻汉字、古文字、数学符号,以及大家每天离不开的 emoji。
举个具体例子,😀 这个笑脸对应的码点是 U+1F600,它不在 BMP 里面,而是一个辅助平面字符。这一点非常关键,因为很多语言的老式实现都是围绕 BMP 设计的,BMP 之外的字符处理起来就容易出现各种“意外”,JavaScript 的 length 会返回 2,就跟它落在辅助平面有直接关系。
2.2 UTF-8:按字节“见字拆字”
有了码点之后,还得把它变成真正能存进硬盘的字节序列。UTF-8 是目前最常见的一种编码方式,它的核心思路是变长编码:不同的码点用不同数量的字节表示,规则如下:
| 码点范围 | 二进制模板 | 字节数 |
|---|---|---|
U+0000 ~ U+007F |
0xxxxxxx |
1 字节 |
U+0080 ~ U+07FF |
110xxxxx 10xxxxxx |
2 字节 |
U+0800 ~ U+FFFF |
1110xxxx 10xxxxxx 10xxxxxx |
3 字节 |
U+10000 ~ U+10FFFF |
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
4 字节 |
注意看,除了第一段,后面的字节开头全都有 10 前缀,这叫“续字节”,用来标记“我是多字节字符的延续部分”。这种设计让 UTF-8 可以自同步:从任何位置都能通过判断字节前缀来知道当前字节是单字节字符开头,还是某个多字节字符的中间部分。
回到 😀,它的码点是 U+1F600,落在第 4 段,所以用 UTF-8 编码后占 4 个字节。你可以在终端里用 xxd 看看它的实际字节:
bash复制printf '😀' | xxd
# 输出:f0 9f 98 80
这四个字节就是 F0 9F 98 80。这时候你再回头看 Go 语言的 len("😀") 为什么返回 4,就完全明白了:它在统计字符串时,数的是底层字节个数,不是字符个数。
2.3 UTF-16 与代理对:为什么 JavaScript 会数出“11”
UTF-16 是另一套编码思路。它的设计很简单:直接用两个字节(16 位)表示一个编码单元。对于 BMP 里的字符,一个码点正好就是一个编码单元,比如 A 的码点 U+0041,在 UTF-16 里就是 0x0041。但问题来了:辅助平面里的码点超过了 16 位能表示的范围,一个编码单元装不下怎么办?
Unicode 的解决方案叫“代理对”(Surrogate Pair)。它把辅助平面的码点拆成两个 16 位编码单元,其中第一个叫高代理项(High Surrogate),范围在 U+D800 ~ U+DBFF,第二个叫低代理项(Low Surrogate),范围在 U+DC00 ~ U+DFFF。这两个代理项必须成对出现,才能还原出原始码点。
具体换算过程是这样的,拿 😀(U+1F600)举例:
- 先减去
0x10000,得到0xF600。 - 把
0xF600拆成高 10 位和低 10 位。高 10 位是0x3D,低 10 位是0x200。 - 高代理项 =
0xD800 + 0x3D = 0xD83D。 - 低代理项 =
0xDC00 + 0x200 = 0xDE00。
所以 😀 在 UTF-16 里就是两个编码单元:0xD83D 和 0xDE00。在 JavaScript 里,字符串内部正是以 UTF-16 编码单元为基本单位存储的,而 length 属性统计的也正是这种编码单元的个数,所以 "😀".length 返回 2,不是 1。
现在回到标题里的 👨👩👧👦。这个家族 emoji 实际是由 4 个 emoji 和 3 个零宽连接符(ZWJ,U+200D)拼出来的序列:
👨,码点U+1F468,UTF-16 下是 2 个编码单元;ZWJ,码点U+200D,它落在 BMP 里,UTF-16 下是 1 个编码单元;👩,码点U+1F469,UTF-16 下是 2 个编码单元;ZWJ,1 个编码单元;👧,码点U+1F467,UTF-16 下是 2 个编码单元;ZWJ,1 个编码单元;👦,码点U+1F466,UTF-16 下是 2 个编码单元。
把所有这些编码单元加起来:2 + 1 + 2 + 1 + 2 + 1 + 2 = 11。这就是“长度是 11”的完整来历。不是 JavaScript 算错了,而是它对“长度”的定义就是这样:数的是 UTF-16 编码单元数量。
3. 主流语言为何答案不一:length 背后的统计口径
3.1 JavaScript、Java 的“半字符”索引
JavaScript 是在 1995 年诞生的,那时候 Unicode 还没引入辅助平面,BMP 之外的角色根本不在设计考虑范围内。所以语言规范把字符串定义为“UTF-16 编码单元的序列”,length 也就顺理成章地数编码单元。这个历史包袱一直留到今天,导致 "😀".length === 2 这个让无数人困惑的现象。
Java 的情况完全一样。JVM 里 char 类型固定占 16 位,所以 String.length() 也是按 UTF-16 编码单元来统计的。Java 后来在 String 上补了 codePointCount() 方法,就是用来弥补 length() 在辅助平面字符上“只数一半”的问题。
这些语言的共同特点是:它们所谓的“字符索引”并不是真正的字符索引。比如在一些对字符串做截取、反转、遍历的旧 API 里,一个 emoji 可能会被拦腰截断,生成一个完全畸形的半字符。前端做输入框字数限制时如果直接用 length,就会出现用户明明感觉只输入了几个字,却显示“已超出长度限制”的情况。
3.2 Python、Go、Swift:三种不同的选择
Python 3 没有像 JavaScript 那样背历史包袱。它的字符串以码点为单位,len() 返回的是码点数量,所以一个 😀 的长度是 1,一个 👨👩👧👦 的长度是 7。但这里也有坑:如果你把字符串转成字节流再用 len(),得到的又是字节数。也就是说,Python 本身是“双轨制”,你得清楚自己的代码处理的是 str 还是 bytes。
Go 语言更直接,字符串本质上就是一个只读的字节切片,len() 返回字节数。遍历字符串时如果直接 for i := 0; i < len(s); i++,你拿到的是一个个字节,而不是字符。Go 标准库专门提供了 utf8.RuneCountInString() 来计算码点数量,如果你需要按 Unicode 码点逐个处理,更好的方式是直接用 for range 循环,因为 Go 的 range 循环天然会按码点来迭代,遇到非法的 UTF-8 字节还会把它转成 U+FFFD 替换字符。
Swift 算是里面最“实在”的。它的 String.count 统计的是字素簇(Grapheme Cluster),也就是用户肉眼看出来的“一个字符”。👨👩👧👦 在 Swift 里就是一个字素簇,所以 .count 返回 1。这也是它在表格里显得最“另类”的原因。代价是,Swift 的 count 性能相对较差,因为每次统计都要从头遍历一遍确定字素簇边界,而 JavaScript 给你返回一个 length 只需要读取一个内部字段。
3.3 数据库与文件系统:多数人忽略的“长度”场景
除了编程语言,数据存储层的“长度”问题同样很容易踩坑。MySQL 早期只有 utf8 字符集(其实是 utf8mb3),最多支持 3 个字节的 UTF-8 编码,而 emoji 是 4 字节的,所以老版本的 MySQL 遇到 emoji 会直接报 Incorrect string value。后来才推出的 utf8mb4 解决了这个问题,所以现在新建表基本都应该用 utf8mb4。
这里有一个被问烂了的问题:MySQL 里 varchar(10) 到底能存几个 emoji?如果你用 utf8mb4,varchar(10) 的意思是能存 10 个码点,不是 10 个字节,也不是 10 个“用户感知字符”。所以一个 👨👩👧👦 总共 7 个码点,放进 varchar(10) 是没问题的,但放进 varchar(5) 就会报 Data too long for column。而如果你用 LENGTH() 函数去查,它返回的是字节数 25,需要特别留意和 CHAR_LENGTH() 的区别。
SQL Server 则是另一套逻辑。它的 NVARCHAR 类型内部按 UTF-16 存储,LEN() 统计的也是编码单元数,所以一个 👨👩👧👦 在 SQL Server 里会被算成 11。文件系统也有类似的“口径”问题,比如 Windows 的 NTFS 文件路径长度限制,很多地方就是按 UTF-16 编码单元来计算的,你看到一个路径“看起来没多长”,实际却可能已经逼近上限。所有这些问题背后,都是同一个道理:长度定义取决于实现,你不能带着“一个字符应该等于一个字节或一个数字”的固有印象去看。
4. 组合字符、变体选择符与 ZWJ 序列:比代理对更深的坑
4.1 组合字符:视觉是一个字,底层是多个码点
如果说代理对还能用“一个字符拆成两个编码单元”来解释,那接下来要说的这几个场景就更反直觉了:视觉上一个字,根本就是多个码点拼出来的。
最典型的是带声调的字符。在 Unicode 里,é 有两种表示方式:一种是用预组合字符 U+00E9,直接就是一个码点;另一种是分解序列,e(U+0065)加上一个组合重音符号(U+0301),它是两个码点。这两种方式在视觉上完全一样,但用 JavaScript 测长度,一个是 1,一个是 2;在 Python 里一个是 1,另一个是 2。如果你写了一个字符去重或者字符比较的逻辑,这两种写法会被当成不同的“东西”,非常坑。
按照之前 1.2 节里说的四个层级,这类“可见字符由多个码点叠出来”的情况,正是导致字素簇这个层级存在的根本原因。用户不管什么码点不码点,他看 é 就是一个字,计算机底层却可能要两个甚至更多的码点才能表达。实际开发中,如果你做文本搜索、排序、去重,最好先把字符串做规范化,通常用 NFC 把分解形式统一成预组合形式,这样同一个字才不会被当成两种文本。
4.2 变体选择符:悄悄改变长度的“隐藏字符”
你可能会觉得,是不是只有声调这种文本字符才有这种问题?其实 emoji 里也有类似的机制,而且还很常见。
举个最简单的例子:普通的心形符号 ❤,码点是 U+2764,在 JavaScript 里 "❤".length 返回 1。但我们在微信、手机系统里经常输入的彩色爱心 ❤️,实际是两个码点:U+2764 加上一个变体选择符 U+FE0F。这个 U+FE0F 的作用是告诉渲染系统:把这个符号用 emoji 风格显示,而不是普通文本风格。于是 "❤️".length 在 JavaScript 里返回 2。
于是就会出现一个让人哭笑不得的场景:同一个“爱心”,用户在手机里输入的是彩色版,长度是 2;在网页上看到的是文本版,长度是 1。如果你的业务逻辑做了“最多 1 个字符”的限制,彩色爱心可能就过不去。这就是变体选择符带来的“隐藏长度”。
4.3 ZWJ 序列、区域指示符与肤色修饰符
再往深了走,就是重头戏:零宽连接符(ZWJ)序列。标题里的 👨👩👧👦 就是利用 ZWJ 把爸爸、妈妈、女儿、儿子四个人物 emoji 粘成一个家庭组合。这个机制的本质是:渲染系统看到序列 😀 + ZWJ + 😃 时,如果字体支持,就把它们画成一个新的图形;如果不支持,就按普通方式拆开显示。
类似的情况还有肤色修饰符。👍 是默认肤色,👍🏽 是深肤色版本。第二个其实是由 U+1F44D(👍)加上 U+1F3FD(肤色修饰符)两个码点组成的。还有国旗类 emoji,它是由两个区域指示符字母组成的,比如某个国家旗帜就是 U+1F1E8 + U+1F1F3。这两个码点单独看都是毫无意义的字母,组合起来才形成旗帜。
所以你会发现,在正确处理字素簇的语言里,👍🏽 的 count 可能是 1,但在按码点统计的语言里 len 是 2,在按 UTF-16 编码单元统计的语言里 length 可能是 4。同一个东西,三种数字都很“正确”。
4.4 连带影响:排序、去重和搜索
很多人觉得字符串长度只是“算个数”的问题,但实际上,编码底层对排序、去重和搜索的影响也是全方位的。
先看排序。如果按码点值排序,同一段文本可能会有奇怪的表现。举个简单例子:字母带重音符号的字符,预组合形式和分解形式的码点顺序不同,明明视觉上是同一个字符,排序时却不挨在一起。再比如 emoji,如果按 UTF-16 编码单元排序,代理对被拆开后排序顺序跟按码点排序完全不同,会打乱你预期的接近度。
再看去重。当你需要对一批用户昵称做去重时,如果直接对原始字符串比较,é 的两种写法会被当成两个不同的值,用户看到的效果却完全一样。更麻烦的是,某些 emoji 在不同平台上可能有不同的底层序列,比如同一面旗帜在不同系统上可能分别由区域指示符对组成,视觉一致但字节不一致,这也会给去重和匹配带来麻烦。
所以生产环境里处理文本,第一步永远是先明确规范化策略,再谈后面的排序、去重和搜索,不然你后面接的各种逻辑都是建立在一个摇摆的地基上。
5. 面向实战:到底该怎么统计字符串长度
5.1 先明确需求:用户长度、存储长度还是传输长度
讲了这么多底层原理,最后落到工程上。我见过非常多的项目,前端限制输入框字数用的是 value.length,后端校验用的是 len(value),数据库字段又按字符集另设限制,三套口径完全自说自话,最后用户输入一个正经的 emoji 就开始连环报错。
所以第一步不是选 API,而是先把需求问清楚:你要限制的是“用户能感知到的字符数”,还是“数据库能存下的字节数”,或者是“网络传输时的长度”。三种需求对“长度”的定义完全不同。
- 如果产品说“昵称最多 20 个字”,用户理解的是字素簇数量,应该按第 1 层统计;
- 如果 DBA 说“这个字段最大能存 100 字节”,那讨论的就是第 4 层字节数;
- 如果协议层说“消息长度最多 200 字节”,那同样是字节数。
我自己的习惯是后端同时做两层校验:第一层按字素簇限制用户可感知长度,第二层按 UTF-8 字节数限制存储上限。前者保住体验,后者保住数据不会撑爆库表。
5.2 正确处理“用户可见字符数”的代码
不管你在哪个技术栈,统计用户可见字符数的理想工具都是基于 Unicode 字素簇边界来处理的。我把自己常用的几种写法放这里:
javascript复制// JavaScript:优先用 Intl.Segmenter
const countGraphemes = (s) => {
const segmenter = new Intl.Segmenter("zh", { granularity: "grapheme" });
return [...segmenter.segment(s)].length;
};
countGraphemes("👨👩👧👦"); // 1
python复制# Python:内置 len 只能数码点,需要按字素簇统计就上正则
import regex
def count_graphemes(s):
return len(regex.findall(r"\X", s))
count_graphemes("👨👩👧👦") # 1
java复制// Java:用 BreakIterator 按字素簇切分
import java.text.BreakIterator;
public static int countGraphemes(String s) {
BreakIterator it = BreakIterator.getCharacterInstance();
it.setText(s);
int count = 0;
for (int start = it.first(), end = it.next();
end != BreakIterator.DONE;
start = end, end = it.next()) {
count++;
}
return count;
}
go复制// Go:目前标准库还没有现成的字素簇统计函数
// 可以直接用第三方包 github.com/rivo/uniseg
count := uniseg.GraphemeClusterCount("👨👩👧👦") // 1
这里提醒一下:JavaScript 里常见的 Array.from("👨👩👧👦").length 返回结果是 7,不是 1,因为 Array.from 按码点切分,而不是按字素簇切分,它已经比 length 强很多了,但仍然不等于“用户感知长度”。前端如果不想引入过重的能力,也可以接受“按码点统计”的方案,但产品那边必须明确这一点,别到时候用户又投诉。
5.3 数据库字段设计与接口层兜底
数据库方面,MySQL 用户记住一条原则:utf8mb4 + CHAR_LENGTH()。表结构建好之后,字段长度语义是“码点数”,不是字节数。所以在设计字段时别拍脑袋写个 varchar(255) 就完事,要结合业务里可能出现的最长字素簇场景去评估,比如如果允许用户输入 ZWJ 序列或组合字符,一个“看起来只有 10 个字”的昵称底层可能有四五十个码点;如果允许输入中文、emoji 混排,单个码点在 UTF-8 下最多占 4 字节,那 100 个码点最多需要 400 字节的存储空间,varchar(100) 在 utf8mb4 下正好是按 100 个码点算的,能撑住。
后端接口层我强烈建议做“字节数兜底”。原因很简单:不管前端怎么限制,恶意请求永远可以绕过页面直接命中接口。此时你拿到的输入可能充满各种组合字符、变体选择符,char_length() 看起来没超,转成 UTF-8 字节却可能非常大。一个稳妥的做法是:接口层先按字节数做一个硬上限,再按字素簇数量做一个软上限,两个条件都满足才放行。
5.4 一个前端表单的完整示例
最后给你一个我实际用过的组合方案。假设业务需求是:用户昵称最多 20 个“用户感知字符”,并且单个昵称的 UTF-8 编码长度不能超过 100 字节(作为硬性防御):
javascript复制const MAX_GRAPHEMES = 20;
const MAX_BYTES_UTF8 = 100;
function validateNickname(input) {
const graphemeCount = countGraphemes(input);
const byteLength = new TextEncoder().encode(input).length;
if (graphemeCount > MAX_GRAPHEMES) {
return `昵称最多 ${MAX_GRAPHEMES} 个字(表情按 1 个字算哦)`;
}
if (byteLength > MAX_BYTES_UTF8) {
return "昵称编码后过长,请精简一些字符或表情";
}
return null;
}
这里有一个容易忽略的细节:new TextEncoder().encode(input).length 统计的是 UTF-8 字节数,而同一个 emoji 在 UTF-16 的 JavaScript 内部是 2 个编码单元,转到 UTF-8 后却是 4 个字节。如果业务方只告诉你“数据库最多存 100 字节”,你用字节校验,再用字素簇校验用户体验,两头都堵死了,基本不会再出现用户在表单里被“一个表情顶爆长度”的尴尬。
文本长度这件事,我在不同项目里踩过好几轮才知道里面的坑有多深。最开始做聊天输入框的时候,我天真地以为“统计输入长度”是个 trivial 的事,直到碰到用户输入组合 emoji 时提示异常,才被迫老老实实把 Unicode 这块啃了一遍。后来再遇到“为什么这个 emoji 的长度是 11”这类问题,我基本都能一眼看出对方代码里选的是哪把尺子。写代码这么多年,我对字符串长度的态度就是一句话:不要相信任何语言的 length,除非你非常清楚这个 length 在统计什么。希望这篇能帮你也建立这个意识。
