做 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,共 216=65,5362^{16} = 65,536 个码位) 已经足够容纳全世界所有人类语言的文字符号。于是,网景(Netscape)将 JavaScript 的内部字符串模型设计为以 16 位无符号整数(Code Unit,代码单元) 为最小基本单位。

然而到了 1996 年,Unicode 发现 65,536 个码位根本不够用(仅仅东亚汉字与历史古文字就远超此数)。于是 Unicode 扩展了规范,将编码空间扩大为 17 个代码平面(Plane 0 至 Plane 16),取值范围延伸至 U+0000U+10FFFF,共容纳超过 111 万个码位:

  • 基本多语言平面(BMP, Plane 0)U+0000U+FFFF,涵盖几乎所有现代常用文字;
  • 增补平面(Supplementary Planes, Plane 1–16)U+010000U+10FFFF,包含生僻汉字、Emoji 表情、古代象形文字与数学特殊符号。

为了向下兼容已经铺开的 16 位系统,Unicode 设计了 UTF-16 代理对(Surrogate Pairs) 机制: 在 BMP 中专门保留了两段未分配字符的“保留空洞”:

  • 高位代理(High Surrogate)0xD800 ~ 0xDBFF(共 1,024 个值);
  • 低位代理(Low Surrogate)0xDC00 ~ 0xDFFF(共 1,024 个值)。

任何超过 0xFFFF 的增补字符,必须拆分成一个高位代理单元和一个低位代理单元拼接表示:

码点=0x10000+(High0xD800)×0x400+(Low0xDC00)\text{码点} = 0x10000 + (\text{High} - 0xD800) \times 0x400 + (\text{Low} - 0xDC00)

这就是所有问题的起点: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 中有两种合法的表示方式:

  1. 预合成形式(Precomposed):单个码点 U+00E9(LATIN SMALL LETTER E WITH ACUTE);
  2. 分解组合形式(Decomposed):基础字母 eU+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].lengthArray.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;
}

在本站的字符计数器实现中,我们同时输出多维度的指标,让开发者看清全貌:

  1. 人类可见字符数(Graphemes):使用 Intl.Segmenter 计算真实独立符号;
  2. Unicode 码点数(Code Points):排除 UTF-16 代理对干扰后的标量值数量;
  3. UTF-16 存储单元(Code Units):传统的 JavaScript .length,决定前端内存与很多历史库的切片行为;
  4. UTF-8 编码字节数(Bytes):通过 new TextEncoder().encode(text).length 计算,决定传输网络流量与数据库存储占用。

6. 避坑清单:工业级系统中的工程防线

  1. 数据库主键与字段长度
    • MySQL 中的 VARCHAR(N)utf8mb4 字符集下指的是“码点数(Code Points)”而非字节数,但仍有上限;
    • 截断字符串时严禁直接使用 str.slice(0, N)str.substring()。如果截断点恰好落在一个代理对的中间(只留下了半个 High Surrogate),会导致非法的孤立代理(Lone Surrogate),存入数据库或 JSON 序列化时会被替换为不可逆的乱码符号 \uFFFD()。
  2. 安全截断算法: 若需截取前 KK 个可视字符,应基于 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;
    }
  3. 短信(SMS)与通知推送限制
    • 传统国际短信采用 GSM-7 编码(160 字符/条);一旦文本中混入任何一个非 GSM-7 字符(如一个中文汉字或 Emoji 图标),整条短信立即降级为 UCS-2 编码,单条字符上限暴跌为 70 个字,并按 UCS-2 代码单元计费,成本直接翻倍。