第 10 章

String 与 Unicode:UTF-16、代理对与码点陷阱

'😀'.length 在 JavaScript 中等于 2,而不是 1。这个事实揭示了 JavaScript 字符串最深层的设计:它不是 Unicode 字符序列,而是 UTF-16 码元序列——这个1995年做出的决策,至今仍在折磨每一个处理国际化文本的开发者。

🔹 Level 1 · 你需要知道的

JS 字符串是 UTF-16,不是 UTF-8

// 基本 ASCII 字符:一切正常
'hello'.length          // 5(每个字符1个码元)
'hello'[0]              // 'h'

// 中文字符(BMP 范围,U+4E00-U+9FFF):通常正常
'你好'.length           // 2(每个汉字1个码元)
'你好'[0]              // '你'

// Emoji(补充平面,U+10000+):这里开始出问题
'😀'.length             // 2(一个 emoji 占两个码元!)
'😀'[0]                // '\uD83D'(高代理,孤立无效字符!)
'😀'[1]                // '\uDE00'(低代理,孤立无效字符!)

// 部分不常用汉字也是两个码元
'𠀋'.length             // 2(U+2000B,超出BMP)

// 有些汉字是 BMP 的,有些不是(取决于 Unicode 码点)
'龘'.length             // 1(U+9F98,在 BMP 内)
'𱒀'.length             // 2(U+31480,超出 BMP)

受影响的 API 一览

const str = '😀Hello😀';  // 真实长度:7个字符,但 length=9

// ❌ 按码元操作,可能断开代理对
str.length              // 9(不是7)
str[0]                  // '\uD83D'(孤立高代理)
str.charAt(0)           // '\uD83D'(同上)
str.charCodeAt(0)       // 55357(高代理码元的数值)
str.slice(0, 1)         // '\uD83D'(孤立代理!)
str.indexOf('😀')       // 0(但按码元索引)
str.substring(1, 3)     // '\uDE00H'(断开了emoji!)

// ✅ 正确处理码点的 API
str.codePointAt(0)      // 128512(完整的 emoji 码点)
String.fromCodePoint(128512)  // '😀'
[...str]                // ['😀', 'H', 'e', 'l', 'l', 'o', '😀'](7个元素!)
Array.from(str)         // ['😀', 'H', 'e', 'l', 'l', 'o', '😀']
[...str].length         // 7(正确!)

// 正则:需要 /u 标志才能正确处理代理对
/^.$/u.test('😀')      // true(/u 把代理对当一个字符)
/^.$/.test('😀')       // false(没有 /u,. 不匹配换行外的两码元序列)

日常开发规范

// ❌ 不要直接用 length 计算字符数
function countChars(str) {
  return str.length;  // 可能多算 emoji
}

// ✅ 用扩展运算符或 Array.from
function countChars(str) {
  return [...str].length;  // 正确计算 Unicode 字符数
}

// 或用 Intl.Segmenter(最准确,处理组合字符)
function countChars(str) {
  const segmenter = new Intl.Segmenter();
  return [...segmenter.segment(str)].length;
}

// ❌ 截断字符串时可能破坏 emoji
'😀hello'.slice(0, 2)   // '\uD83D\uDE00'... 不对

// ✅ 安全截断
function safeSlice(str, maxChars) {
  return [...str].slice(0, maxChars).join('');
}
safeSlice('😀hello', 3)  // '😀he'

// ❌ charAt 无法处理补充平面字符
'😀'.charAt(0)           // '\uD83D'(错误)

// ✅ codePointAt + String.fromCodePoint
'😀'.codePointAt(0)      // 128512(正确码点)
String.fromCodePoint(128512)  // '😀'(正确字符)

🔸 Level 2 · 它是怎么运行的

Unicode 基础概念

Unicode 码点范围:U+0000 到 U+10FFFF

┌────────────────────────────────────────────────────────┐
│ Unicode 码空间划分                                       │
│                                                        │
│ U+0000 ~ U+FFFF    基本多文种平面(BMP)                 │
│   共 65536 个码点                                       │
│   包含:ASCII、大部分汉字、常见符号                        │
│   UTF-16 编码:1个16位码元(直接存储)                    │
│                                                        │
│ U+10000 ~ U+1FFFF  第1补充平面(SMP)                   │
│ U+20000 ~ U+2FFFF  第2补充平面(CJK 扩展)               │
│ U+30000 ~ U+3FFFF  第3补充平面(罕见字符)               │
│    ...                                                 │
│ U+E0000 ~ U+EFFFF  第14平面(标签字符)                  │
│ U+F0000 ~ U+FFFFF  第15平面(私用区A)                   │
│ U+100000 ~ U+10FFFF 第16平面(私用区B)                  │
│                                                        │
│ 补充平面共约 100 万个码点                                │
│ UTF-16 编码:2个16位码元(代理对)                        │
└────────────────────────────────────────────────────────┘

UTF-16 代理对编码算法

代理对(Surrogate Pair)编码原理:

高代理范围:U+D800 到 U+DBFF(1024个值)
低代理范围:U+DC00 到 U+DFFF(1024个值)
可编码补充字符数:1024 × 1024 = 1,048,576(足够容纳所有补充平面)

编码算法(码点 → 代理对):
┌────────────────────────────────────────────────────┐
│ 输入:码点 C(U+10000 ≤ C ≤ U+10FFFF)            │
│                                                    │
│ 步骤1:C' = C - 0x10000                           │
│   C' 的范围:0x00000 到 0xFFFFF(20位)            │
│                                                    │
│ 步骤2:分割 C' 的20位:                            │
│   高10位 → H(范围 0x000 到 0x3FF)               │
│   低10位 → L(范围 0x000 到 0x3FF)               │
│                                                    │
│ 步骤3:计算代理码元:                               │
│   高代理 = 0xD800 + H(范围 D800-DBFF)           │
│   低代理 = 0xDC00 + L(范围 DC00-DFFF)           │
└────────────────────────────────────────────────────┘

示例:😀 的码点是 U+1F600

步骤1:C' = 0x1F600 - 0x10000 = 0xF600
步骤2:H = 0xF600 >> 10 = 0x3D(高10位)
        L = 0xF600 & 0x3FF = 0x200(低10位)
步骤3:高代理 = 0xD800 + 0x3D = 0xD83D
        低代理 = 0xDC00 + 0x200 = 0xDE00

验证:
'😀'.charCodeAt(0).toString(16)  // "d83d"(高代理)
'😀'.charCodeAt(1).toString(16)  // "de00"(低代理)
代理对解码(代理对 → 码点):

输入:高代理 H_sur(D800-DBFF),低代理 L_sur(DC00-DFFF)
步骤1:H = H_sur - 0xD800
步骤2:L = L_sur - 0xDC00
步骤3:C' = (H << 10) | L
步骤4:C = C' + 0x10000

示例:D83D + DE00 → 😀
  H = 0xD83D - 0xD800 = 0x3D
  L = 0xDE00 - 0xDC00 = 0x200
  C' = (0x3D << 10) | 0x200 = 0xF400 | 0x200 = 0xF600
  C = 0xF600 + 0x10000 = 0x1F600 = U+1F600 = 😀 ✓

Unicode 规范化(NFC/NFD)

某些字符可以用多种方式编码,这会导致看起来相同的字符串实际上不相等:

"é" 的两种表示方式:

方式1:预组合字符(Precomposed,NFC)
  U+00E9 (é) ← 单一码点,一个字符

方式2:基字符 + 组合变音符(Decomposed,NFD)
  U+0065 (e) + U+0301 (´) ← 两个码点,视觉上是同一个字符

在 JavaScript 中:
  '\u00E9' === '\u0065\u0301'  // false!
  '\u00E9'.length              // 1
  '\u0065\u0301'.length        // 2

  // 视觉相同,但代码认为不同!
  const str1 = 'café';          // U+00E9 预组合
  const str2 = 'cafe\u0301';   // e + 组合符
  str1 === str2  // false
  str1.length    // 4
  str2.length    // 5

解决方案:字符串规范化:

// normalize() 方法
const nfc = str.normalize('NFC');   // 预组合形式(推荐存储)
const nfd = str.normalize('NFD');   // 分解形式
const nfkc = str.normalize('NFKC'); // 兼容预组合(规范化宽字符等)
const nfkd = str.normalize('NFKD'); // 兼容分解

// 比较前先规范化
function normalizedEqual(a, b) {
  return a.normalize('NFC') === b.normalize('NFC');
}
normalizedEqual('café', 'cafe\u0301')  // true

// 搜索时也需要规范化
const text = 'résumé'.normalize('NFC');
const query = 're\u0301sume\u0301'.normalize('NFC');
text.includes(query)  // true(规范化后可以正确搜索)

🔺 Level 3 · 规范怎么定义的

6.1.4 The String Type

规范原文(ECMA-262 第 6.1.4 节):

6.1.4 The String Type

The String type is the set of all ordered sequences of zero or more 16-bit unsigned integer values ("elements") up to a maximum length of 2^53 - 1 elements. The String type is generally used to represent textual data in a running ECMAScript program, in which case each element in the String is treated as a UTF-16 code unit value. Each element is considered to be a value at a position within the sequence; the first element is at index 0, the next at index 1, and so on. The length of a String is the number of elements (i.e., 16-bit values) within it. The empty String has length zero and therefore contains no elements.

Where ECMAScript operations interpret String values, each element is interpreted as a single UTF-16 code unit. However, ECMAScript does not place any restrictions on or requirements for the sequence of code units in a String value, so they may be ill-formed when interpreted as UTF-16 code unit sequences. Operations that do not interpret String contents treat them as sequences of undifferentiated 16-bit unsigned integers. The function StringToCodePoints takes a String value argument and returns a List of Unicode code points that result from interpreting the String as UTF-16 encoded Unicode text.

关键点:规范明确允许"不规范的 UTF-16 序列"(ill-formed),即孤立的代理码元(单独的高代理或低代理,没有配对)。这意味着 JavaScript 可以合法地存储 '\uD800'(孤立高代理),即使它不是合法的 Unicode 文本。

StringToCodePoints 抽象操作

规范第 11.1.1 节定义了如何将 String 转为码点列表:

StringToCodePoints ( string )

  1. Let codePoints be a new empty List.
  2. Let size be the length of string.
  3. Let position be 0.
  4. Repeat, while position < size, a. Let cp be CodePointAt(string, position). b. Append cp.[[CodePoint]] to codePoints. c. Set position to position + cp.[[CodePointCount]].
  5. Return codePoints.

CodePointAt(string, position):

  1. Let size be the length of string.
  2. Let first be the numeric value of the code unit at index position within string.
  3. If first is not a leading surrogate or trailing surrogate, then a. Return the Record { [[CodePoint]]: first, [[CodeUnitCount]]: 1, [[IsUnpairedSurrogate]]: false }.
  4. If first is a trailing surrogate or position + 1 = size, then a. Return the Record { [[CodePoint]]: first, [[CodeUnitCount]]: 1, [[IsUnpairedSurrogate]]: true }.
  5. Let second be the numeric value of the code unit at index position + 1 within string.
  6. If second is not a trailing surrogate, then a. Return the Record { [[CodePoint]]: first, [[CodeUnitCount]]: 1, [[IsUnpairedSurrogate]]: true }.
  7. Let cp be UTF16SurrogatePairToCodePoint(first, second).
  8. Return the Record { [[CodePoint]]: cp, [[CodeUnitCount]]: 2, [[IsUnpairedSurrogate]]: false }.

这就是 [...str] 能正确处理代理对的原因:迭代器使用了 CodePointAt 算法,每次取1个或2个码元(根据是否构成合法代理对)。

Unicode 规范化的规范定义

规范第 22.1.3.12 节(String.prototype.normalize):

This method normalizes the code points of the String value according to the form specified by form. The following forms are supported:

  • "NFC", the Canonical Decomposition, followed by Canonical Composition.
  • "NFD", the Canonical Decomposition.
  • "NFKC", the Compatibility Decomposition, followed by Canonical Composition.
  • "NFKD", the Compatibility Decomposition.

💎 Level 4 · 边界与陷阱

陷阱1:'😀'.length === 2

// 完整的代理对机制演示
const emoji = '😀';

// length 是码元数量,不是字符数量
emoji.length    // 2

// 访问个别码元(孤立代理!无效的Unicode字符)
emoji[0]        // '\uD83D'(高代理,U+D83D)
emoji[1]        // '\uDE00'(低代理,U+DE00)

// charCodeAt 返回码元的数值
emoji.charCodeAt(0)  // 55357(= 0xD83D)
emoji.charCodeAt(1)  // 56832(= 0xDE00)

// codePointAt 正确处理代理对
emoji.codePointAt(0)  // 128512(= 0x1F600,正确的 emoji 码点)
emoji.codePointAt(1)  // 56832(低代理的码点值,但这是"错误的"位置)

// 验证:还原码点
String.fromCodePoint(128512)   // '😀'(正确)
String.fromCharCode(55357, 56832)  // '😀'(也正确,手动提供代理对)

// 实际应用:计算 emoji 字符串的真实字符数
function countRealChars(str) {
  return [...str].length;  // 展开运算正确处理代理对
}
countRealChars('😀😂🎉')  // 3(三个 emoji)
'😀😂🎉'.length           // 6(六个码元)

陷阱2:'😀'[0] 得到孤立高代理

// 孤立代理的危险性
const half = '😀'[0];   // '\uD83D',孤立高代理

// 孤立代理在某些操作中会导致问题
half.length              // 1(看起来是1个字符)
encodeURIComponent(half) // '%ED%A0%BD'(不合法的 UTF-8 编码!)
JSON.stringify(half)     // '"\uD83D"'(某些JSON解析器会拒绝)

// WTF8(有时称为"病态 UTF-8")就是这种情况
// 某些操作会产生包含孤立代理的字符串,可能导致:
// 1. 跨语言传输时乱码
// 2. JSON 解析错误(ECMAScript 2019 的 JSON.stringify 会转义孤立代理)
// 3. TextEncoder 跟孤立代理交互的混乱

// ES2019+ JSON.stringify 改进:转义孤立代理
JSON.stringify('\uD800')  // '"\uD800"'(ES2018前可能抛错,ES2019后输出转义序列)

// 检测孤立代理
function hasLonelySuprogates(str) {
  return /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(str);
}
hasLonelySuprogates('😀')     // false(完整代理对)
hasLonelySuprogates('\uD800')  // true(孤立高代理)

陷阱3:正则 . 默认不匹配代理对

// 没有 /u 标志时,. 匹配单个码元
/^.$/.test('😀')    // false!(😀 是两个码元)
/^..$/.test('😀')   // true(两个码元各匹配一个 .)

// 有 /u 标志时,. 匹配完整的码点(包括代理对)
/^.$/u.test('😀')   // true!(/u 把代理对当一个字符)
/^..$/.test('😀')   // false

// 字符类也受影响
/[\uD800-\uDFFF]/.test('😀')   // true(匹配第一个码元 D83D)
/[\u{1F600}]/u.test('😀')      // true(/u 标志下的 Unicode 转义)

// 实际影响:表单验证
// ❌ 错误:不能正确计算emoji字符串的字符数
const maxLen = 10;
const regex = new RegExp(`^.{0,${maxLen}}$`);
regex.test('😀'.repeat(10))  // true(10个emoji = 20个码元,超过预期!)

// ✅ 正确:加上 /u 标志
const regexU = new RegExp(`^.{0,${maxLen}}$`, 'u');
regexU.test('😀'.repeat(10))   // false(10个emoji,确实是10个字符)
regexU.test('😀'.repeat(10) + 'x')  // false(11个字符)

陷阱4:'café' === 'café' 可能为 false

// NFC vs NFD 规范化差异
const nfc = '\u00E9';        // é(预组合,NFC)
const nfd = 'e\u0301';      // é(基字母 + 组合变音符,NFD)

nfc === nfd    // false!(不同的码点序列)
nfc.length     // 1
nfd.length     // 2

// 视觉上看起来完全相同,但在代码中是不同的字符串
console.log(nfc);  // é
console.log(nfd);  // é(看起来一样)
nfc === nfd        // false

// 这个问题在哪里出现?
// 1. macOS 的文件系统(HFS+)倾向于使用 NFD
// 2. 从不同数据源合并文本(API、数据库、用户输入)
// 3. 不同语言的库之间传递字符串

// 实际 bug 案例
const userInput = 'café';    // 用户输入(NFC)
const dbRecord = 'cafe\u0301'; // 数据库记录(NFD)
userInput === dbRecord         // false!搜索失败!
userInput.includes(dbRecord)   // false!文本匹配失败!

// ✅ 解决:比较前规范化
userInput.normalize('NFC') === dbRecord.normalize('NFC')  // true

陷阱5:Intl.Segmenter 处理复杂 emoji

某些 emoji 由多个码点组成(通过 ZWJ 序列),这使得正确计算字符数更加复杂:

// 家庭 emoji:多个码点组合
const family = '👨‍👩‍👧‍👦';
family.length              // 11(多个代理对 + ZWJ 连接符)
[...family].length         // 7(7个码点,但视觉上是1个字符!)

// ZWJ(零宽连接符,U+200D)把多个 emoji 连成一个
// 👨 + ZWJ + 👩 + ZWJ + 👧 + ZWJ + 👦 = 👨‍👩‍👧‍👦

// Intl.Segmenter 是正确处理 grapheme cluster(字形簇)的方法
const segmenter = new Intl.Segmenter('zh', { granularity: 'grapheme' });

function countGraphemes(str) {
  return [...segmenter.segment(str)].length;
}

countGraphemes('😀')        // 1(单个 emoji)
countGraphemes('👨‍👩‍👧‍👦')  // 1(家庭 emoji,视觉上是1个字符)
countGraphemes('café')      // 4(4个字形)
countGraphemes('e\u0301')  // 1(e + 组合变音符 = 1个字形)

// 输入框限制(按用户感知的字符数限制)
function limitInput(str, maxGraphemes) {
  const segments = [...segmenter.segment(str)];
  if (segments.length <= maxGraphemes) return str;
  return segments.slice(0, maxGraphemes).map(s => s.segment).join('');
}
limitInput('😀😂🎉👨‍👩‍👧‍👦', 2)  // '😀😂'(正确截取2个用户感知字符)

陷阱6:String.prototype.normalize 在比较中的重要性

// 实际的国际化搜索实现
function searchText(text, query, locale = 'zh') {
  // 1. 规范化(统一 NFC/NFD)
  const normalizedText = text.normalize('NFC');
  const normalizedQuery = query.normalize('NFC');

  // 2. 不区分大小写的搜索
  const lowerText = normalizedText.toLocaleLowerCase(locale);
  const lowerQuery = normalizedQuery.toLocaleLowerCase(locale);

  return lowerText.includes(lowerQuery);
}

searchText('Résumé', 'RÉSUMÉ')  // true(规范化 + 不区分大小写)
searchText('cafe\u0301', 'café') // true(NFD 和 NFC 的同一内容)

// 更完整的解决方案:使用 Intl.Collator
const collator = new Intl.Collator('zh', {
  sensitivity: 'base',  // 忽略大小写和变音符
  usage: 'search'
});

function collatorSearch(text, query) {
  // Intl.Collator 比较两个字符串(但不直接支持 includes)
  // 需要逐位置尝试
  for (let i = 0; i <= text.length - query.length; i++) {
    const result = collator.compare(
      text.slice(i, i + query.length),
      query
    );
    if (result === 0) return true;
  }
  return false;
}

本章小结

  1. JavaScript 字符串是 UTF-16 码元(Code Unit)序列,不是 Unicode 码点序列:length 返回码元数(16位整数个数),str[i] 访问第 i 个码元。补充平面字符(U+10000以上,如 emoji)占用两个码元,因此 '😀'.length === 2。

  2. 代理对是 UTF-16 表示补充平面字符的机制:高代理(U+D800-DBFF)和低代理(U+DC00-DFFF)配对表示一个码点。直接访问 str[0] 可能得到孤立的高代理,这是无效的 Unicode 字符,会在 JSON 序列化、URI 编码等场景引发问题。

  3. 处理 emoji 需要使用码点级 API:str.codePointAt()、String.fromCodePoint()、[...str](扩展运算符使用迭代器,正确处理代理对)、/u 标志正则(. 匹配完整码点)。字符计数用 [...str].length,而非 str.length。

  4. Unicode 规范化(NFC/NFD)会影响字符串相等性:'é'(U+00E9)和 'e\u0301'(e + 组合变音符)在视觉上相同但 === 为 false,长度也不同(1 vs 2)。跨系统比较字符串时,必须先调用 .normalize('NFC') 统一格式。

  5. Intl.Segmenter 是正确计算用户感知字符数的唯一可靠方法:家庭 emoji 👨‍👩‍👧‍👦 由7个码点(14个码元)组成,但视觉上是1个"字符"(字形簇)。[...str].length 返回7,只有 Intl.Segmenter 能返回1。在限制用户输入长度、截断字符串展示等场景必须使用 Intl.Segmenter。

本章评分
4.5  / 5  (36 评分)

💬 留言讨论