找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

6159

积分

1

好友

774

主题
发表于 前天 22:34 | 查看: 0| 回复: 0

产品提了个再简单不过的需求:昵称输入框,最多 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、生僻字、带调号的字母,三者就会分道扬镳。

JavaScriptlength,数的从来不是前两种,而是第三种——内存里的码元。它不是出错了,它从一开始答应你的就不是“字数”。

这篇文章就把这三层东西一层层剥开,看看字符串这个“最简单”的类型,到底藏着多少误会。

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,另外三列各说各话。

一个字符到底有几种数法?至少四种,分别属于四个层面:

字符编码四层结构:字素簇、码点、UTF-16码元、UTF-8字节

从上到下,每个层面都是上一层的“实现细节”:字素簇由码点组成,码点编码成码元,码元再编码成字节。我们平时写的每一行字符串操作,都必须先回答一个问题——你现在站在哪一层?

开头的 bug 就是站错了层:产品的需求“最多 10 个字”说的是第一层,而 name.length 数的是第三层。需求在楼上,代码在地下室,中间隔着的两层,就是 bug 生长的地方。

下面把每一层拆开看。

2. Unicode 码点:给全世界的字发门牌号

先看第二层,因为它是理解一切的起点。

Unicode 做的事说起来很朴素:给全世界每一个字符编一个唯一的号码,这个号码叫码点(Code Point),写作 U+ 加十六进制:

a    →  U+0061
中   →  U+4E2D
👍   →  U+1F44D

整个编码空间是 U+0000U+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 验证):

UTF-16 代理对计算:从码点 U+1F44D 到高位代理 0xD83D 和低位代理 0xDC4D

两个代理各自占住了 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)——也就是用户认知中的“一个字符”。

UTF-16 编码原理:眼睛看到的1个字由7个码点和11个码元构成

到这一步,结论已经浮出来了:数码点比数码元更接近“字数”,但它仍然不是“字数”。 用户输入昵称时心里的“字”,永远是字素簇。

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("");
}

而选择哪一层,取决于你在解决什么问题:

字符串计数决策图:用户看到的字用 Intl.Segmenter,存储用 TextEncoder,算法按码点拆分

顺手说一句很多人会混淆的事:大模型的 token 数,是第五个概念。tokenizer 按词频切分文本,和上面四层都对不上——👨‍👩‍👧‍👦 是 1 个字、7 个码点、11 个码元、25 个字节,而它消耗几个 token,取决于具体模型的词表,又是另一笔账。估算 token 成本时拿 length 去算,会错得离谱。

写在最后

回到开头那个昵称输入框。

“最多 10 个字”这个需求,从头到尾没有人说错话:产品说的“字”是眼睛看到的字,用户理解的“字”也是眼睛看到的字,唯一错位的是代码——length 数的从来不是字,是内存里的 16 位格子。这个 API 三十年没变过,变的是我们往字符串里装的东西:从英文,到中文,到 emoji,到用胶水粘起来的一家人。

字符串这个“最简单的类型”,其实一直是个三明治:用户看到的是字素簇,程序编码的是码点,内存里躺的是码元。 三层各有各的数法,各有各的用途,谁都没有错——错的是把任何一层当成唯一的那一层。

所以下次再写字符串的长度校验、截断、遍历,先停一秒,问自己一个问题:“我要数的,是谁的长度?” 是用户的、编码的、内存的,还是网络的。想清楚了这一层,length 就再也不会对你撒谎。




上一篇:毕奥-萨伐尔定律:用高速公路车流与旋风理解电流元、距离与叉乘
下一篇:Codex 负责人访谈:Rust 核心、开源策略与代码审查变革
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-9-13 18:05 , Processed in 1.213143 second(s), 45 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表