JavaScript 字符串的 Unicode 编码:UTF-16 编码与代理对(Surrogate Pairs)处理

欢迎大家来到今天的技术讲座,我是你们的讲师。今天我们将深入探讨 JavaScript 字符串中一个既基础又充满挑战的主题:Unicode 编码,特别是其核心——UTF-16 编码机制,以及如何处理那些看似神秘的“代理对”(Surrogate Pairs)。对于任何希望构建健壮、国际化应用程序的开发者来说,理解这些概念至关重要。

在当今全球化的数字世界里,文本处理远不止英文字母那么简单。从中文、日文、韩文的表意文字,到阿拉伯文、希伯来文的从右到左书写,再到各种表情符号(Emoji),我们的代码必须能够优雅地处理所有这些字符。JavaScript 作为 Web 开发的基石,其字符串处理机制直接影响到我们应用的正确性和用户体验。

我们将从字符编码的历史演进开始,逐步揭示 Unicode 的诞生背景,然后聚焦到 JavaScript 所采用的 UTF-16 编码。我会详细解释代理对的原理、计算方式,并结合大量的代码示例,展示它们在 JavaScript 字符串操作中带来的影响和相应的解决方案。

1. 字符编码的基础概念:从ASCII到Unicode

在深入 UTF-16 之前,我们首先需要建立对字符编码的基本理解。

1.1 ASCII:早期数字世界的基石

计算机最初是为处理英文设计的。ASCII (American Standard Code for Information Interchange) 是最早的字符编码标准之一。它使用 7 位二进制数来表示 128 个字符,包括英文字母(大小写)、数字、标点符号和一些控制字符。

例如,大写字母 ‘A’ 的 ASCII 码是 65,二进制表示是 01000001

代码示例 1.1:查看 ASCII 字符的编码

const charA = 'A';
console.log(`字符 '${charA}' 的 ASCII 码是: ${charA.charCodeAt(0)}`); // 输出: 65

const charExclamation = '!';
console.log(`字符 '${charExclamation}' 的 ASCII 码是: ${charExclamation.charCodeAt(0)}`); // 输出: 33

ASCII 简单高效,但它的局限性显而易见:它无法表示其他语言的字符。

1.2 扩展 ASCII 与代码页:区域性解决方案的混乱

为了解决 ASCII 的局限性,人们开始使用 8 位来表示字符,这样就可以表示 256 个不同的字符。然而,这256个位置很快就被不同的国家和地区用来表示自己的特定字符,形成了所谓的“扩展 ASCII”或“代码页”(Code Page)。

例如,在西欧,Windows-1252 代码页将一些位置用于表示带有变音符号的字符(如 é),而在中文环境,GBK 或 Big5 则将这些位置用于表示汉字。

这种方案导致了一个严重问题:当文本从一个代码页传输到另一个代码页时,如果没有正确指定编码,就会出现“乱码”。

1.3 Unicode:统一字符集的大一统

为了彻底解决乱码问题,Unicode 诞生了。Unicode 的目标是为世界上所有语言的每一个字符都分配一个唯一的数字,这个数字被称为“码点”(Code Point)。

码点(Code Point):一个抽象的数字,代表一个字符。它通常用 U+ 后跟十六进制数字表示,例如 U+0041 代表 ‘A’,U+4E2D 代表汉字 ‘中’,U+1F600 代表笑脸表情符 ‘😄’。

Unicode 将字符组织在不同的“平面”(Planes)中。最重要的平面是:

  • 基本多语言平面 (Basic Multilingual Plane, BMP):范围从 U+0000U+FFFF。它包含了世界上绝大多数常用字符,包括拉丁字母、希腊字母、西里尔字母、CJC(中日韩统一表意文字)字符等。
  • 辅助平面 (Supplementary Planes):范围从 U+10000U+10FFFF。这些平面包含了一些不常用的古文字、数学符号,以及现代生活中大量使用的表情符号(Emoji)。

Unicode 码点的总范围是 U+0000U+10FFFF,这意味着可以表示超过一百万个不同的字符。

字符与码点:抽象与具象
一个“字符”在 Unicode 层面是一个抽象概念,由一个码点唯一标识。但在屏幕上,一个“字符”的视觉呈现可能更复杂。

字素簇(Grapheme Cluster):用户感知的“字符”
用户通常认为的一个“字符”可能由一个或多个码点组成。例如,一个带有变音符号的字母(如 é)可能由一个基本字母码点和一个组合变音符号码点组成。更复杂的例子是表情符号序列,如家庭表情符号 👨‍👩‍👧‍👦,它实际上是由多个单独的表情符号码点通过零宽度连接符(Zero Width Joiner, ZWJ)连接而成的。这些由一个或多个码点组成的、被用户视为单个文本单元的序列,被称为“字素簇”(Grapheme Cluster)。

代码示例 1.2:字素簇的长度差异

const char_e_acute = 'é'; // 一个码点 U+00E9
const char_e_combining_acute = 'é'; // 两个码点: U+0065 (e) + U+0301 (́)
const emoji_family = '👨‍👩‍👧‍👦'; // 多个码点组成的字素簇

console.log(`'${char_e_acute}' 的长度 (码元数): ${char_e_acute.length}`);         // 输出: 1
console.log(`'${char_e_combining_acute}' 的长度 (码元数): ${char_e_combining_acute.length}`); // 输出: 2 (JavaScript 字符串的 length 属性计算的是码元数)
console.log(`'${emoji_family}' 的长度 (码元数): ${emoji_family.length}`);     // 输出: 11 (同样是码元数)

// 真正的码点数量或字素簇数量需要更复杂的处理,我们稍后会讲到

2. Unicode 的编码形式:UTF 家族

Unicode 只是定义了码点,但如何将这些码点存储在计算机内存或传输中,则需要具体的编码形式。最常见的三种编码形式是 UTF-8、UTF-16 和 UTF-32。

2.1 UTF-32:简单粗暴的固定宽度编码

UTF-32 是最直接的 Unicode 编码。它使用 4 个字节(32 位)来表示每一个 Unicode 码点。

  • 优点:每个码点占用固定长度,因此字符串索引、截取等操作非常简单高效。
  • 缺点:空间效率低下。即使是只需要 1 个字节就能表示的 ASCII 字符,在 UTF-32 中也需要 4 个字节。

由于其低空间效率,UTF-32 在实际应用中较少用于文本存储或网络传输,更多用于内部处理。

2.2 UTF-8:互联网的宠儿

UTF-8 是一种变长编码,它根据码点的值使用 1 到 4 个字节来表示。

  • ASCII 字符(U+0000 到 U+007F)使用 1 个字节表示,与 ASCII 完全兼容。

  • 其他 BMP 字符(U+0080 到 U+FFFF)使用 2 或 3 个字节表示。

  • 辅助平面字符(U+10000 到 U+10FFFF)使用 4 个字节表示。

  • 优点

    • 高度节省空间,特别是对于以 ASCII 字符为主的文本。
    • 与 ASCII 完全兼容,旧的 ASCII 处理程序通常可以无缝处理 UTF-8 文本中的 ASCII 部分。
    • 在 Web 上应用最广泛,是 HTML5 的默认编码。
  • 缺点:变长编码使得字符串的索引和截取变得复杂,不能直接按字节偏移量来操作字符。

2.3 UTF-16:JavaScript 的内部选择

UTF-16 也是一种变长编码,它根据码点的值使用 2 或 4 个字节(即 1 或 2 个 16 位“码元”)来表示。

  • 基本多语言平面 (BMP) 字符U+0000U+FFFF):使用一个 16 位码元表示。

  • 辅助平面字符U+10000U+10FFFF):使用两个 16 位码元表示,这就是我们今天要重点讨论的“代理对”(Surrogate Pair)。

  • 优点

    • 对于 BMP 字符,每个字符固定使用 2 字节,这在 Unicode 早期(UCS-2 阶段)被认为是空间效率和处理效率的良好折衷。
    • 在 JavaScript、Java 等语言中,字符串内部都采用 UTF-16 编码。
  • 缺点

    • 对于辅助平面字符,仍然是变长编码,这使得字符串操作复杂化。
    • 不兼容 ASCII。

JavaScript 字符串内部使用 UTF-16 编码,这意味着它的字符串实际上是 16 位无符号整数(称为“码元”或“代码单元”)的序列。这一点是理解 JavaScript 字符串行为的关键。

3. 深入理解 UTF-16 与代理对

现在,让我们聚焦到 UTF-16 编码的核心机制,特别是它如何处理超出 BMP 范围的字符。

3.1 BMP (基本多语言平面) 与 UCS-2 的历史遗留

在 Unicode 发展的早期,人们认为 65536 个字符(即 BMP 的范围)足以表示所有常用字符。因此,早期的 Unicode 实现(如 UCS-2)将每个字符都用一个 16 位的无符号整数来表示。这使得字符串处理非常简单:每个“字符”都占用固定空间,length 属性返回的就是字符数,charAt() 也能直接取出字符。

JavaScript 语言设计时,正是处于 UCS-2 盛行的时期,因此其字符串内部表示采用了类似的 16 位码元序列。这就是为什么 JavaScript 的 length 属性、charAt()charCodeAt() 等方法在处理 BMP 字符时表现正常,但在遇到辅助平面字符时就会出问题的原因。

代码示例 3.1:BMP 字符的 lengthcharCodeAt

const bmpChar = '中'; // 码点 U+4E2D
console.log(`字符 '${bmpChar}' 的码元数: ${bmpChar.length}`);     // 输出: 1
console.log(`字符 '${bmpChar}' 的码点值: ${bmpChar.charCodeAt(0).toString(16)}`); // 输出: 4e2d

3.2 超越 BMP:辅助平面与代理对的诞生

随着 Unicode 的发展,新的字符集(如不常用的古文字、大量的表情符号)被加入,超出了 BMP 的范围。Unicode 的码点范围扩展到了 U+10FFFF。为了在 UTF-16 中表示这些超出 16 位限制的码点,引入了“代理对”(Surrogate Pairs)机制。

代理对使用两个 16 位码元来表示一个辅助平面的码点。这两个码元被称为:

  • 高代理码元 (High Surrogate / Leading Surrogate):范围从 U+D800U+DBFF
  • 低代理码元 (Low Surrogate / Trailing Surrogate):范围从 U+DC00U+DFFF

这两个范围 (U+D800U+DFFF) 是 Unicode 专门为代理码元保留的,不会被分配给任何实际的字符。因此,当一个程序遇到一个码元在这个范围内时,它就知道这个码元是一个代理码元,需要与另一个代理码元组合起来才能形成一个完整的字符。

表格 3.1:代理码元的范围

类型 码点范围 备注
高代理码元 U+D800 ~ U+DBFF 512 个码点,用于代理对的第一个码元
低代理码元 U+DC00 ~ U+DFFF 512 个码点,用于代理对的第二个码元
总共保留范围 U+D800 ~ U+DFFF 1024 个码点,专门用于 UTF-16 代理对

3.3 代理对的计算原理

一个辅助平面的 Unicode 码点 C (范围 U+10000U+10FFFF) 如何被编码成一对 16 位码元 H (高代理) 和 L (低代理) 呢?反之,给定一对 HL,又如何还原出原始码点 C 呢?

从码点到代理对的计算:

  1. 将原始码点 C 减去 0x10000。这将把码点映射到 0x000000xFFFFF 的 20 位范围内。我们称之为 C'
    C' = C - 0x10000
  2. C' 的高 10 位用于计算高代理码元 H
    H = 0xD800 + (C' >> 10)
  3. C' 的低 10 位用于计算低代理码元 L
    L = 0xDC00 + (C' & 0x3FF)

其中:

  • >> 10 表示右移 10 位,取出高 10 位。
  • & 0x3FF (即 & 1023) 表示与 0000001111111111 进行位与操作,取出低 10 位。

代码示例 3.2:从码点计算代理对

/**
 * 将一个辅助平面码点转换为 UTF-16 代理对
 * @param {number} codePoint - 辅助平面的 Unicode 码点 (U+10000 到 U+10FFFF)
 * @returns {number[]} 包含高代理码元和低代理码元的数组 [high, low]
 */
function toSurrogatePair(codePoint) {
    if (codePoint < 0x10000 || codePoint > 0x10FFFF) {
        throw new Error("码点必须在辅助平面范围 U+10000 到 U+10FFFF 之间");
    }

    const cPrime = codePoint - 0x10000; // 映射到 0x00000 - 0xFFFFF (20位)
    const highSurrogate = 0xD800 + (cPrime >> 10); // 高10位
    const lowSurrogate = 0xDC00 + (cPrime & 0x3FF);  // 低10位

    return [highSurrogate, lowSurrogate];
}

const emojiCodePoint = 0x1F600; // 笑脸 😄 的码点 U+1F600
const [high, low] = toSurrogatePair(emojiCodePoint);

console.log(`码点 U+${emojiCodePoint.toString(16).toUpperCase()} (${String.fromCodePoint(emojiCodePoint)})`);
console.log(`高代理码元: U+${high.toString(16).toUpperCase()} (十进制: ${high})`);
console.log(`低代理码元: U+${low.toString(16).toUpperCase()} (十进制: ${low})`);
// 预期输出:
// 码点 U+1F600 (😄)
// 高代理码元: U+D83D (十进制: 55357)
// 低代理码元: U+DE00 (十进制: 56832)

从代理对到码点的计算:

反过来,给定高代理码元 H 和低代理码元 L,我们可以还原出原始码点 C

  1. H 中减去 0xD800,得到 H' (高 10 位)。
    H' = H - 0xD800
  2. L 中减去 0xDC00,得到 L' (低 10 位)。
    L' = L - 0xDC00
  3. H' 左移 10 位,然后与 L' 进行位或操作,得到 C'
    C' = (H' << 10) | L'
  4. 最后,将 C' 加上 0x10000,得到原始码点 C
    C = C' + 0x10000

代码示例 3.3:从代理对还原码点

/**
 * 将一对 UTF-16 代理对转换为其对应的 Unicode 码点
 * @param {number} highSurrogate - 高代理码元 (U+D800 到 U+DBFF)
 * @param {number} lowSurrogate - 低代理码元 (U+DC00 到 U+DFFF)
 * @returns {number} 对应的 Unicode 码点
 */
function fromSurrogatePair(highSurrogate, lowSurrogate) {
    if (highSurrogate < 0xD800 || highSurrogate > 0xDBFF) {
        throw new Error("高代理码元范围错误");
    }
    if (lowSurrogate < 0xDC00 || lowSurrogate > 0xDFFF) {
        throw new Error("低代理码元范围错误");
    }

    const hPrime = highSurrogate - 0xD800;
    const lPrime = lowSurrogate - 0xDC00;
    const cPrime = (hPrime << 10) | lPrime; // 高10位和低10位组合
    const codePoint = cPrime + 0x10000;

    return codePoint;
}

const highSurrogate = 0xD83D; // 55357
const lowSurrogate = 0xDE00;  // 56832
const restoredCodePoint = fromSurrogatePair(highSurrogate, lowSurrogate);

console.log(`代理对 U+${highSurrogate.toString(16).toUpperCase()} U+${lowSurrogate.toString(16).toUpperCase()}`);
console.log(`还原的码点: U+${restoredCodePoint.toString(16).toUpperCase()} (${String.fromCodePoint(restoredCodePoint)})`);
// 预期输出:
// 代理对 U+D83D U+DE00
// 还原的码点: U+1F600 (😄)

这些计算原理是 UTF-16 编码辅助平面字符的数学基础。了解它们有助于我们更深刻地理解 JavaScript 字符串在底层是如何工作的。

4. JavaScript 字符串与 UTF-16 码元:实际影响

现在,我们将这些理论知识应用到 JavaScript 中,看看 UTF-16 编码和代理对如何影响我们日常的字符串操作。

4.1 JavaScript 字符串的本质:16位码元序列

在 JavaScript 中,字符串被视为一个 16 位码元(code unit)的序列。这意味着:

  • String.prototype.length 属性返回的是字符串中 16 位码元的数量,而不是 Unicode 码点的数量,也不是用户感知的“字符”数量。
  • 对于 BMP 字符,一个码点对应一个码元,所以 length 属性是准确的。
  • 对于辅助平面字符,一个码点对应两个码元(一个代理对),所以 length 属性会返回 2。

代码示例 4.1:length 属性的误区

const bmpChar = 'A'; // U+0041
const chineseChar = '中'; // U+4E2D
const supplementaryChar = '😄'; // U+1F600,由代理对 U+D83D U+DE00 组成
const complexEmoji = '👨‍👩‍👧‍👦'; // 多个码点组成的字素簇

console.log(`'${bmpChar}' 的 length: ${bmpChar.length}`);             // 输出: 1
console.log(`'${chineseChar}' 的 length: ${chineseChar.length}`);     // 输出: 1
console.log(`'${supplementaryChar}' 的 length: ${supplementaryChar.length}`); // 输出: 2 (因为是代理对,两个码元)
console.log(`'${complexEmoji}' 的 length: ${complexEmoji.length}`); // 输出: 11 (多个码点,其中有代理对,还有零宽度连接符等)

const str = 'Hello 😄 World!';
console.log(`'${str}' 的 length: ${str.length}`); // 输出: 15 (5 + 1(空格) + 2(😄) + 1(空格) + 5 = 14 ? 12 + 2 = 14)
// 'H' 'e' 'l' 'l' 'o' ' ' (6个码元)
// '😄' (2个码元)
// ' ' 'W' 'o' 'r' 'l' 'd' '!' (7个码元)
// 总计 6 + 2 + 7 = 15 个码元

从上面的例子可以看出,length 属性在存在代理对或复杂字素簇时,不能直接反映“字符”的数量。

4.2 charCodeAt(index):获取16位码元

String.prototype.charCodeAt(index) 方法返回指定索引处码元的 16 位整数值。

  • 对于 BMP 字符,它返回的就是该字符的码点值。
  • 对于辅助平面字符,如果你访问代理对的第一个码元,它会返回高代理码元的值;如果你访问第二个码元,它会返回低代理码元的值。

代码示例 4.2:charCodeAt() 的行为

const strWithEmoji = 'A😄B';

console.log(`strWithEmoji[0]: ${strWithEmoji.charCodeAt(0).toString(16)}`); // 'A' -> U+0041
console.log(`strWithEmoji[1]: ${strWithEmoji.charCodeAt(1).toString(16)}`); // '😄' 的高代理码元 -> U+D83D
console.log(`strWithEmoji[2]: ${strWithEmoji.charCodeAt(2).toString(16)}`); // '😄' 的低代理码元 -> U+DE00
console.log(`strWithEmoji[3]: ${strWithEmoji.charCodeAt(3).toString(16)}`); // 'B' -> U+0042

可以看到,charCodeAt(1)charCodeAt(2) 分别返回了笑脸表情符号的两个代理码元,而不是完整的码点。

4.3 codePointAt(index):获取真正的Unicode码点 (ES6+)

为了解决 charCodeAt() 在处理代理对时的局限性,ECMAScript 2015 (ES6) 引入了 String.prototype.codePointAt(index) 方法。

  • 它返回指定索引处码元开始的 Unicode 码点。
  • 如果该码元是高代理码元,并且紧随其后的是一个低代理码元,codePointAt() 会自动组合它们,并返回完整的辅助平面码点。
  • 如果该码元不是高代理码元,或者它是一个高代理码元但没有合法的低代理码元跟随,它就返回该码元本身的码点。

代码示例 4.3:codePointAt() 的正确行为

const strWithEmoji = 'A😄B';

console.log(`strWithEmoji.codePointAt(0): U+${strWithEmoji.codePointAt(0).toString(16).toUpperCase()}`); // 'A' -> U+0041
console.log(`strWithEmoji.codePointAt(1): U+${strWithEmoji.codePointAt(1).toString(16).toUpperCase()}`); // '😄' -> U+1F600
console.log(`strWithEmoji.codePointAt(2): U+${strWithEmoji.codePointAt(2).toString(16).toUpperCase()}`); // '😄' 的低代理码元,它本身不是一个码点开始,但会返回其自身值 -> U+DE00
console.log(`strWithEmoji.codePointAt(3): U+${strWithEmoji.codePointAt(3).toString(16).toUpperCase()}`); // 'B' -> U+0042

需要注意的是,codePointAt(index) 如果 index 指向的是代理对的第二个码元(即低代理码元),它仍然会返回该低代理码元的码点,而不是跳过它。这是因为 codePointAt 是从给定索引处尝试解析一个码点,如果该索引处不是一个高代理码元的开始,它就简单地返回当前码元的值。

4.4 字符串迭代:陷阱与解决方案

代理对的存在使得字符串迭代变得复杂。

4.4.1 传统 for 循环与 charCodeAt():问题重重

使用传统的 for 循环结合 charCodeAt() 来遍历字符串,在遇到代理对时会将其拆散,导致逻辑错误。

代码示例 4.4:错误的字符串遍历

const text = 'Hello 😄 World!';

console.log("使用 for 循环和 charCodeAt 遍历:");
for (let i = 0; i < text.length; i++) {
    const codeUnit = text.charCodeAt(i);
    console.log(`索引 ${i}: 码元 U+${codeUnit.toString(16).toUpperCase()}`);
}
// 预期输出 (部分):
// ...
// 索引 6: 码元 U+D83D   (😄 的高代理码元)
// 索引 7: 码元 U+DE00   (😄 的低代理码元)
// ...

这种遍历方式无法正确识别辅助平面的字符。

4.4.2 for...of 循环:ES6 的正确姿势

ECMAScript 2015 (ES6) 引入的 for...of 循环是遍历字符串的推荐方式,因为它能够正确处理代理对,每次迭代都返回一个完整的 Unicode 码点(如果该码点由代理对组成,它会自动合并)。

代码示例 4.5:for...of 循环的正确性

const text = 'Hello 😄 World!';

console.log("n使用 for...of 循环遍历 (按码点):");
for (const char of text) {
    console.log(`字符: '${char}', 码点: U+${char.codePointAt(0).toString(16).toUpperCase()}`);
}
// 预期输出 (部分):
// ...
// 字符: '😄', 码点: U+1F600
// ...

for...of 循环在幕后使用了迭代器协议,使得它能够正确地按码点(而非码元)遍历字符串。

4.4.3 手动处理代理对的迭代

如果你需要更精细地控制迭代过程,或者需要兼容旧的 JavaScript 环境,你可以手动结合 codePointAt() 来迭代。

代码示例 4.6:手动按码点迭代

const text = 'Hello 😄 World!';
console.log("n使用 codePointAt 手动按码点遍历:");

let i = 0;
while (i < text.length) {
    const codePoint = text.codePointAt(i);
    const char = String.fromCodePoint(codePoint); // 将码点转换回字符
    console.log(`索引 ${i}: 字符 '${char}', 码点 U+${codePoint.toString(16).toUpperCase()}`);

    // 根据码点占用码元的数量来调整索引
    if (codePoint >= 0x10000) { // 辅助平面字符,占两个码元
        i += 2;
    } else { // BMP 字符,占一个码元
        i += 1;
    }
}
// 预期输出 (部分):
// ...
// 索引 6: 字符 '😄', 码点 U+1F600
// ...

这种手动迭代方式虽然更复杂,但它确保了每次迭代都处理一个完整的 Unicode 码点。

4.5 常用字符串方法的影响

JavaScript 的许多内置字符串方法都是基于 16 位码元进行操作的,这意味着它们可能无法正确处理代理对。

表格 4.1:受代理对影响的常见字符串方法

方法 行为概述 代理对影响 码元计数
:—

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注