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 )
- Let codePoints be a new empty List.
- Let size be the length of string.
- Let position be 0.
- Repeat, while position < size, a. Let cp be CodePointAt(string, position). b. Append cp.[[CodePoint]] to codePoints. c. Set position to position + cp.[[CodePointCount]].
- Return codePoints.
CodePointAt(string, position):
- Let size be the length of string.
- Let first be the numeric value of the code unit at index position within string.
- If first is not a leading surrogate or trailing surrogate, then a. Return the Record { [[CodePoint]]: first, [[CodeUnitCount]]: 1, [[IsUnpairedSurrogate]]: false }.
- If first is a trailing surrogate or position + 1 = size, then a. Return the Record { [[CodePoint]]: first, [[CodeUnitCount]]: 1, [[IsUnpairedSurrogate]]: true }.
- Let second be the numeric value of the code unit at index position + 1 within string.
- If second is not a trailing surrogate, then a. Return the Record { [[CodePoint]]: first, [[CodeUnitCount]]: 1, [[IsUnpairedSurrogate]]: true }.
- Let cp be UTF16SurrogatePairToCodePoint(first, second).
- 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;
}
本章小结
-
JavaScript 字符串是 UTF-16 码元(Code Unit)序列,不是 Unicode 码点序列:
length返回码元数(16位整数个数),str[i]访问第 i 个码元。补充平面字符(U+10000以上,如 emoji)占用两个码元,因此'😀'.length === 2。 -
代理对是 UTF-16 表示补充平面字符的机制:高代理(U+D800-DBFF)和低代理(U+DC00-DFFF)配对表示一个码点。直接访问
str[0]可能得到孤立的高代理,这是无效的 Unicode 字符,会在 JSON 序列化、URI 编码等场景引发问题。 -
处理 emoji 需要使用码点级 API:
str.codePointAt()、String.fromCodePoint()、[...str](扩展运算符使用迭代器,正确处理代理对)、/u标志正则(.匹配完整码点)。字符计数用[...str].length,而非str.length。 -
Unicode 规范化(NFC/NFD)会影响字符串相等性:
'é'(U+00E9)和'e\u0301'(e + 组合变音符)在视觉上相同但===为 false,长度也不同(1 vs 2)。跨系统比较字符串时,必须先调用.normalize('NFC')统一格式。 -
Intl.Segmenter是正确计算用户感知字符数的唯一可靠方法:家庭 emoji👨👩👧👦由7个码点(14个码元)组成,但视觉上是1个"字符"(字形簇)。[...str].length返回7,只有Intl.Segmenter能返回1。在限制用户输入长度、截断字符串展示等场景必须使用Intl.Segmenter。