第 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 需要使用码点级 APIstr.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 评分)

💬 留言讨论