做 Web 开发时,“算一下文本长度”看起来只要随手调个 text.length。但几乎每个工程师都在生产环境踩过这个暗坑:数据库字段因莫名其妙截断而出错、短信按 70 字计费时突然多算了一倍费用、或者用户在输入框里贴了个表情包表单校验直接报“超出长度”。
不信你直接打开浏览器的 F12 控制台,敲入这几行代码试一试:
"A".length; // 1
"中".length; // 1
"𠮷".length; // 2(生僻字“吉”)
"é".length; // 1 或 2(取决于 NFD 还是 NFC)
"👨👩👧👦".length; // 11(家庭 Emoji)
明明肉眼看起来就是一个“一家四口”的 Emoji,为什么在 JavaScript 眼里却占了 11 个字符?生僻字又凭什么算 2 个字符?
这篇文章我们聊透 Unicode 底层的历史包袱,拆解代码单元(Code Unit)、码点(Code Point)与字形簇(Grapheme Cluster)的三层模型,并结合本站 字符数统计工具 里的方案,聊聊现代 Web API 是怎么完美解决这个问题的。
1. 根源:JavaScript 的 UCS-2 / UTF-16 历史包袱
要理解为什么长度会出错,必须回到 1995 年 JavaScript 诞生之初。
当时 Unicode 联盟发布不久,设计者认为 16 位定长编码(UCS-2,共 个码位) 已经足够容纳全世界所有人类语言的文字符号。于是,网景(Netscape)将 JavaScript 的内部字符串模型设计为以 16 位无符号整数(Code Unit,代码单元) 为最小基本单位。
然而到了 1996 年,Unicode 发现 65,536 个码位根本不够用(仅仅东亚汉字与历史古文字就远超此数)。于是 Unicode 扩展了规范,将编码空间扩大为 17 个代码平面(Plane 0 至 Plane 16),取值范围延伸至 U+0000 到 U+10FFFF,共容纳超过 111 万个码位:
- 基本多语言平面(BMP, Plane 0):
U+0000到U+FFFF,涵盖几乎所有现代常用文字; - 增补平面(Supplementary Planes, Plane 1–16):
U+010000到U+10FFFF,包含生僻汉字、Emoji 表情、古代象形文字与数学特殊符号。
为了向下兼容已经铺开的 16 位系统,Unicode 设计了 UTF-16 代理对(Surrogate Pairs) 机制: 在 BMP 中专门保留了两段未分配字符的“保留空洞”:
- 高位代理(High Surrogate):
0xD800~0xDBFF(共 1,024 个值); - 低位代理(Low Surrogate):
0xDC00~0xDFFF(共 1,024 个值)。
任何超过 0xFFFF 的增补字符,必须拆分成一个高位代理单元和一个低位代理单元拼接表示:
这就是所有问题的起点:JavaScript 的 .length 属性返回的根本不是“字符数”,而是“UTF-16 代码单元(Code Units)的数量”!
const char = "𠮷"; // U+20BB7
console.log(char.length); // 2
console.log(char.charCodeAt(0).toString(16)); // "d842" (高位代理)
console.log(char.charCodeAt(1).toString(16)); // "dfb7" (低位代理)
2. 进阶陷阱:变音符号与规范化(Normalization)
除了代理对,另一个常见的坑是变音符号(Combining Diacritical Marks)。
例如带重音符的法文字母 é,在 Unicode 中有两种合法的表示方式:
- 预合成形式(Precomposed):单个码点
U+00E9(LATIN SMALL LETTER E WITH ACUTE); - 分解组合形式(Decomposed):基础字母
e(U+0065)+ 独立的组合重音符´(U+0301)。
const s1 = "\u00E9"; // "é"
const s2 = "e\u0301"; // "é"
console.log(s1 === s2); // false!肉眼完全相同,但值不相等
console.log(s1.length); // 1
console.log(s2.length); // 2
如果用户从 macOS 文件系统复制文件名,或者在某些特殊输入法下输入,就会产生这种长度不一的组合序列。
解决该问题的方法是调用 ES6 的规范化 API:
console.log(s1.normalize("NFC") === s2.normalize("NFC")); // true
console.log(s2.normalize("NFC").length); // 1
3. 终极挑战:Emoji 与 ZWJ 胶水序列
如果说代理对和变音符号只是让长度多算了一倍,那么 Emoji 表情则是让 .length 彻底失效的元凶。
现代 Emoji 规范允许使用 零宽连字(ZWJ, Zero Width Joiner, U+200D) 将多个独立的 Emoji“粘合”成一个全新的复合 Emoji:
以家庭表情 👨👩👧👦 为例,它在底层的完整组成是:
👨 (男人, U+1F468, 需 2 个 UTF-16 单元)
+ [ZWJ, U+200D, 需 1 个 UTF-16 单元]
+ 👩 (女人, U+1F469, 需 2 个 UTF-16 单元)
+ [ZWJ, U+200D, 需 1 个 UTF-16 单元]
+ 👧 (女孩, U+1F467, 需 2 个 UTF-16 单元)
+ [ZWJ, U+200D, 需 1 个 UTF-16 单元]
+ 👦 (男孩, U+1F466, 需 2 个 UTF-16 单元)
==============================================
总计:4 个增补字符 + 3 个胶水字符 = 7 个码点,共消耗 2 + 1 + 2 + 1 + 2 + 1 + 2 = 11 个代码单元!
类似的情况还包括:
- 国旗 Emoji:如 🇨🇳 由两个区域指示符(Regional Indicator Symbol)
U+1F1E8+U+1F1F3组合而成,长度为 4; - 肤色变体:如 👍🏽 由
👍(U+1F44D)+ 肤色修饰符U+1F3FD组合,长度为 4; - 职业与性别序列:如 👩💻(女程序员)由
👩+ZWJ+💻组合,长度为 5。
4. 三层认知模型:代码单元、码点与字形簇
为了在工程中准确处理文本,我们必须在团队内统一三层度量模型:
| 层次 | 概念 | JavaScript 原生表达 | 示例 "👨👩👧👦" 的计算结果 |
|---|---|---|---|
| 第一层:代码单元(Code Unit) | 底层 UTF-16 存储单元(16-bit word) | str.length |
11 |
| 第二层:码点(Code Point) | Unicode 独立分配的字符编号 | [...str].length 或 Array.from(str).length |
7 |
| 第三层:字形簇(Grapheme Cluster) | 人类肉眼感知的一个独立书写符号(User-Perceived Character) | Intl.Segmenter |
1 |
常见误区:为什么 [...str].length 依然不够好?
许多开发者知道 str.length 处理不了代理对,于是推荐用 ES6 的解构语法 [...str].length。
解构语法确实基于码点遍历器(实现了迭代器协议 Symbol.iterator),能将代理对合并为单个码点(因此 "𠮷" 可以被正确识别为长度 1)。
但是,码点迭代器无法识别 ZWJ 连字符和变音符!
在家庭表情面前,[...str].length 返回的是 7;在带音标分解字母 "e\u0301" 面前,它返回的是 2。
5. 现代标准方案:利用 Intl.Segmenter 实现精准统计
现代浏览器(Chrome 87+, Safari 14.1+, Firefox 125+, Node.js 16+)已原生支持 ECMAScript 国际化 API Intl.Segmenter。这是目前唯一在规范层面完美遵循 Unicode UAX #29(Unicode 文本分词标准)的原生方案。
/**
* 精准计算人类感知的字形簇(Grapheme Cluster)字符数
*/
export function getGraphemeCount(text: string, locale: string = 'en'): number {
if (!text) return 0;
// 现代环境首选:Intl.Segmenter
if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) {
const segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
let count = 0;
for (const _ of segmenter.segment(text)) {
count++;
}
return count;
}
// 降级兼容:利用正则匹配代理对与组合标记(接近码点级)
return Array.from(text.normalize('NFC')).length;
}
在本站的字符计数器实现中,我们同时输出多维度的指标,让开发者看清全貌:
- 人类可见字符数(Graphemes):使用
Intl.Segmenter计算真实独立符号; - Unicode 码点数(Code Points):排除 UTF-16 代理对干扰后的标量值数量;
- UTF-16 存储单元(Code Units):传统的 JavaScript
.length,决定前端内存与很多历史库的切片行为; - UTF-8 编码字节数(Bytes):通过
new TextEncoder().encode(text).length计算,决定传输网络流量与数据库存储占用。
6. 避坑清单:工业级系统中的工程防线
- 数据库主键与字段长度:
- MySQL 中的
VARCHAR(N)在utf8mb4字符集下指的是“码点数(Code Points)”而非字节数,但仍有上限; - 截断字符串时严禁直接使用
str.slice(0, N)或str.substring()。如果截断点恰好落在一个代理对的中间(只留下了半个 High Surrogate),会导致非法的孤立代理(Lone Surrogate),存入数据库或 JSON 序列化时会被替换为不可逆的乱码符号\uFFFD()。
- MySQL 中的
- 安全截断算法:
若需截取前 个可视字符,应基于
Intl.Segmenter遍历迭代截取,避免肢解复合 Emoji 或拆分变音符:export function truncateGraphemes(text: string, limit: number): string { const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' }); const segments = segmenter.segment(text); let result = ''; let count = 0; for (const { segment } of segments) { if (++count > limit) break; result += segment; } return result; } - 短信(SMS)与通知推送限制:
- 传统国际短信采用 GSM-7 编码(160 字符/条);一旦文本中混入任何一个非 GSM-7 字符(如一个中文汉字或 Emoji 图标),整条短信立即降级为 UCS-2 编码,单条字符上限暴跌为 70 个字,并按 UCS-2 代码单元计费,成本直接翻倍。