产品提了个再简单不过的需求:昵称输入框,最多 10 个字。
你花了三十秒写完:
function isNicknameValid(name) {
return name.length <= 10; // 超过 10 个字就不让提交
}
上线第一天,客服收到投诉。一位用户说,他的昵称“明明只有 5 个表情”,系统却提示超长。你打开后台一看,他输入的是:
🇨🇳👨•👩•👧•👦🏳️•🌈❤️👍
你数了数,5 个 emoji,怎么就超了?回到控制台敲了一下:
"🇨🇳👨•👩•👧•👦🏳️•🌈❤️👍".length
// 25
25。 眼睛看到 5 个字符,length 说是 25 个。
更诡异的事情还在后面。另一位用户的昵称显示出来是个豆腐块问号 �,查了半天,是截断逻辑把某个 emoji 从中间“拦腰砍断”了:
"👍".slice(0, 1) // 砍出来的是 "\ud83d",渲染成一个孤零零的
问题来了:字符串可以说是编程里最简单的东西,每个程序员第一天就会用。为什么一个 length、一个 slice,在 emoji 面前会集体撒谎?
实际上,你以为的“一个字符”,在计算机里其实有三种东西:眼睛看到的字(字素簇)、Unicode 编码的字(码点)、内存里存储的字(UTF-16 码元)。
对普通的中英文,三者刚好相等,你一辈子不会发现它们的区别;一旦遇上 emoji、生僻字、带调号的字母,三者就会分道扬镳。
而 JavaScript 的 length,数的从来不是前两种,而是第三种——内存里的码元。它不是出错了,它从一开始答应你的就不是“字数”。
这篇文章就把这三层东西一层层剥开,看看字符串这个“最简单”的类型,到底藏着多少误会。
1. 一个字符的四种“长度”
先看一组实测数据。下面每个值都是我在 Node 里真实跑出来的:
| 字符 |
眼睛看到 |
.length |
码点数 |
UTF-8 字节数 |
a |
1 |
1 |
1 |
1 |
中 |
1 |
1 |
1 |
3 |
𠮷(生僻字) |
1 |
2 |
1 |
4 |
| 👍 |
1 |
2 |
1 |
4 |
| ❤️ |
1 |
2 |
2 |
6 |
| 1️⃣ |
1 |
3 |
3 |
7 |
| 🇨🇳 |
1 |
4 |
2 |
8 |
| 👍🏽 |
1 |
4 |
2 |
8 |
| 🏳️🌈 |
1 |
6 |
4 |
14 |
| 👨👩👧👦 |
1 |
11 |
7 |
25 |
注意看这张表的规律:“眼睛看到的”那一列永远是 1,另外三列各说各话。
一个字符到底有几种数法?至少四种,分别属于四个层面:

从上到下,每个层面都是上一层的“实现细节”:字素簇由码点组成,码点编码成码元,码元再编码成字节。我们平时写的每一行字符串操作,都必须先回答一个问题——你现在站在哪一层?
开头的 bug 就是站错了层:产品的需求“最多 10 个字”说的是第一层,而 name.length 数的是第三层。需求在楼上,代码在地下室,中间隔着的两层,就是 bug 生长的地方。
下面把每一层拆开看。
2. Unicode 码点:给全世界的字发门牌号
先看第二层,因为它是理解一切的起点。
Unicode 做的事说起来很朴素:给全世界每一个字符编一个唯一的号码,这个号码叫码点(Code Point),写作 U+ 加十六进制:
a → U+0061
中 → U+4E2D
👍 → U+1F44D
整个编码空间是 U+0000 到 U+10FFFF,理论上能装 110 多万个字符,目前装下了十几万个——汉字、emoji、天城文、线性文字 B,全都在里面有门牌号。
到这里为止,故事很美好:一个字符,一个号码。如果 JS 字符串就是“码点的数组”,那 length 就永远不会撒谎。
问题在于:码点只是逻辑编号,不是存储格式。 一个号码怎么变成内存里的 0 和 1,是另一件事。而 JS 在这件事上,做了一个三十年前的历史选择。
3. UTF-16 码元:三十年前的选择,今天的 length
当年以为 65536 够用
JS 诞生于 1995 年。那几年,整个计算机行业都在往 Unicode 迁移,而当时大家的判断是:全世界的字符,65536 个号码(2 的 16 次方)足够装了。
于是 JS 做了一个当时看来无比正确的决定:字符串用 16 位定长编码(UCS-2),一个字符固定占 2 个字节。 length 就是“有多少个 2 字节”,取字符、截断、遍历全都按 2 字节走,简单又飞快。
UCS-2 的世界观:
┌──────┬──────┬──────┐
│ 2字节 │ 2字节│ 2 字节│ 每个格子 = 一个字符,整整齐齐
└──────┴──────┴──────┘
然后 Unicode 扩容了。emoji 来了,生僻汉字来了,历史文字来了——65536 装不下,编码空间扩到了 U+10FFFF,多出来的部分叫“增补平面”。
代理对:用两个 16 位拼一个字符
JS 没有选择换编码(换就意味着全世界的 JS 代码重写),而是升级到 UTF-16,打了一个补丁:增补平面的字符,用两个 16 位码元拼出来。 这两个码元叫代理对(Surrogate Pair)。
拼接的过程完全是机械的位运算。以 👍(U+1F44D)为例,下面这张图是它真实的计算过程(每一步的值都可以在控制台用 charCodeAt 验证):

两个代理各自占住了 Unicode 里专门留出来的两段保留区——高位代理是 0xD800~0xDBFF,低位代理是 0xDC00~0xDFFF。引擎读到 0xD83D,一看落在高位保留区,就知道“这不是一个完整的字,后面还跟着另一半”,于是把两个格子拼回一个字符渲染出来。
看明白这个结构,开头的怪事就全解释通了:
length 数的是码元格子,不是字符。 增补平面的字符占两个格子,length 就如实报 2。它没有撒谎,它只是承诺的本来就是“我有多少个 16 位格子”。
所以:
"中".length // 1:U+4E2D 在基本平面,一个格子
"𠮷".length // 2:U+20BB7 是增补平面的生僻字,两个格子
"👍".length // 2:U+1F44D 也在增补平面,两个格子
"🇨🇳".length // 4:两个区域指示符 U+1F1E8、U+1F1F3,各两个格子
半个码元:豆腐块是这样诞生的
既然字符和格子不再一一对应,按格子截断就可能把字符砍成两半:
"👍".slice(0, 1)
// "\ud83d" —— 只砍走了高位代理那一半
0xD83D 单独存在时不对应任何字符,它是一扇门的“上半截”。字体引擎拿到它,无法渲染,只能给你一块豆腐 �(替换字符 U+FFFD)。
这就是开头那位用户昵称变成问号的全部原因:截断代码站在第三层按格子砍,砍断的却是第二层的一个完整字符。
4. 字素簇:眼睛认的“一个字”,码点说了不算
故事如果到这里就结束了,那换个思路就行:别数码元,数码点不就好了?JS 确实给了你这个能力:
[...("👍")].length // 1:扩展运算符按码点拆,不是按码元拆
Array.from("𠮷").length // 1:同样按码点
好,数码点。然后你撞上这个:
[...("👨👩👧👦")].length // 7 —— 眼睛明明只看到一个字
把这个“一家四口”的 emoji 拆成码点看:
👨👩👧👦 的码点构成:
U+1F468(👨 男人) ── U+200D(ZWJ)── U+1F469(👩 女人)
│
U+200D(ZWJ)──────┤
│
U+1F467(👧 女孩) ── U+200D(ZWJ)── U+1F466(👦 男孩)
ZWJ = Zero Width Joiner,零宽连接符
作用:告诉渲染引擎"把相邻的码点粘在一起画"
这个 emoji 是 4 个人形字符用 3 个胶水字符粘出来的。 Unicode 没有为“一家四口”单独发门牌号,它发的是“胶水”——渲染引擎看到 ZWJ,就把前后两个字符合二为一,画成一个字。
同样的把戏还有好几个变种:
🇨🇳 = U+1F1E8 + U+1F1F3
两个"区域指示符"字母 C、N 拼出中国国旗
👍🏽 = U+1F44D + U+1F3FD
点赞手势 + 肤色修饰符,拼出"中等肤色的点赞"
é = U+0065 + U+0301
字母 e + 组合用声调符,拼出带调号的 é
(它和单个码点 U+00E9 长得一模一样,却是两种不同编码!)
1️⃣ = U+0031 + U+FE0F + U+20E3
数字 1 + 变体选择符 + 键帽框,三个码点拼一个按键
你的眼睛看到的“一个字”,在码点层面可能是一串字符的团队合作。 这个“团队合作的结果”有个正式名字:字素簇(Grapheme Cluster)——也就是用户认知中的“一个字符”。

到这一步,结论已经浮出来了:数码点比数码元更接近“字数”,但它仍然不是“字数”。 用户输入昵称时心里的“字”,永远是字素簇。
5. 正确的数法:先问站在哪一层,再选工具
四个层面,对应四种数法,JS 各自给了工具:
consts="👨•👩•👧•👦";
// 第三层:UTF-16 码元数——length 的本能
s.length; // 11
// 第二层:码点数——扩展运算符或 Array.from 按码点拆
[...s].length; // 7
// 第四层:UTF-8 字节数——编码后数字节
new TextEncoder().encode(s).length; // 25
// 第一层:字素簇数——用户眼中的"字数",用 Intl.Segmenter
const seg = new Intl.Segmenter("zh", { granularity: "grapheme" });
[...seg.segment(s)].length; // 1
前三层你可能早就见过,重点是最后一个:Intl.Segmenter。它是专门按“用户看到的字”来切分字符串的 API,把 ZWJ 粘合、肤色修饰、组合符号这些规则全部内置了。它在前几年的旧浏览器里支持不全,如今主流浏览器和 Node 都已经可用——但涉及具体环境时,还是建议先确认一下你的目标运行环境版本。
回到开头的需求,正确实现是这样:
const seg = new Intl.Segmenter("zh", { granularity: "grapheme" });
function isNicknameValid(name) {
// 按字素簇数数:用户看到几个字,就是几个字
return [...seg.segment(name)].length <= 10;
}
function truncateNickname(name, max) {
// 按字素簇截断:永远不会把 emoji 拦腰砍断
return [...seg.segment(name)]
.slice(0, max)
.map((part) => part.segment)
.join("");
}
而选择哪一层,取决于你在解决什么问题:

顺手说一句很多人会混淆的事:大模型的 token 数,是第五个概念。tokenizer 按词频切分文本,和上面四层都对不上——👨👩👧👦 是 1 个字、7 个码点、11 个码元、25 个字节,而它消耗几个 token,取决于具体模型的词表,又是另一笔账。估算 token 成本时拿 length 去算,会错得离谱。
写在最后
回到开头那个昵称输入框。
“最多 10 个字”这个需求,从头到尾没有人说错话:产品说的“字”是眼睛看到的字,用户理解的“字”也是眼睛看到的字,唯一错位的是代码——length 数的从来不是字,是内存里的 16 位格子。这个 API 三十年没变过,变的是我们往字符串里装的东西:从英文,到中文,到 emoji,到用胶水粘起来的一家人。
字符串这个“最简单的类型”,其实一直是个三明治:用户看到的是字素簇,程序编码的是码点,内存里躺的是码元。 三层各有各的数法,各有各的用途,谁都没有错——错的是把任何一层当成唯一的那一层。
所以下次再写字符串的长度校验、截断、遍历,先停一秒,问自己一个问题:“我要数的,是谁的长度?” 是用户的、编码的、内存的,还是网络的。想清楚了这一层,length 就再也不会对你撒谎。