2022 年 10 月 18 日

Unicode,字符串内部

高级知识

本节深入探讨字符串内部。如果你计划处理表情符号、罕见的数学或象形文字或其他罕见符号,那么这些知识对你来说会很有用。

正如我们所知,JavaScript 字符串基于 Unicode:每个字符由 1-4 个字节的字节序列表示。

JavaScript 允许我们通过使用以下三种表示法之一指定其十六进制 Unicode 代码来将字符插入字符串

  • \xXX

    XX 必须是两个十六进制数字,其值介于 00FF 之间,然后 \xXX 是 Unicode 代码为 XX 的字符。

    由于 \xXX 表示法仅支持两个十六进制数字,因此它只能用于前 256 个 Unicode 字符。

    这前 256 个字符包括拉丁字母、大多数基本语法字符以及其他一些字符。例如,"\x7A""z"(Unicode U+007A)相同。

    alert( "\x7A" ); // z
    alert( "\xA9" ); // ©, the copyright symbol
  • \uXXXX XXXX 必须恰好是 4 个十六进制数字,其值介于 0000FFFF 之间,则 \uXXXX 是 Unicode 编码为 XXXX 的字符。

    Unicode 值大于 U+FFFF 的字符也可以用此表示法表示,但在此情况下,我们需要使用所谓的代理对(我们将在本章后面讨论代理对)。

    alert( "\u00A9" ); // ©, the same as \xA9, using the 4-digit hex notation
    alert( "\u044F" ); // я, the Cyrillic alphabet letter
    alert( "\u2191" ); // ↑, the arrow up symbol
  • \u{X…XXXXXX}

    X…XXXXXX 必须是介于 010FFFF(Unicode 定义的最高代码点)之间的 1 到 6 个字节的十六进制值。此表示法使我们能够轻松表示所有现有的 Unicode 字符。

    alert( "\u{20331}" ); // 佫, a rare Chinese character (long Unicode)
    alert( "\u{1F60D}" ); // 😍, a smiling face symbol (another long Unicode)

代理对

所有常用字符都有 2 字节代码(4 个十六进制数字)。大多数欧洲语言中的字母、数字以及基本的统一 CJK 表意文字集(CJK - 来自中文、日文和韩文书写系统)具有 2 字节表示法。

最初,JavaScript 基于 UTF-16 编码,每个字符仅允许 2 个字节。但 2 个字节仅允许 65536 种组合,这不足以表示 Unicode 的每个可能符号。

因此,需要超过 2 个字节的稀有符号使用一对称为“代理对”的 2 字节字符进行编码。

作为副作用,此类符号的长度为 2

alert( '𝒳'.length ); // 2, MATHEMATICAL SCRIPT CAPITAL X
alert( '😂'.length ); // 2, FACE WITH TEARS OF JOY
alert( '𩷶'.length ); // 2, a rare Chinese character

这是因为在创建 JavaScript 时不存在代理对,因此语言无法正确处理它们!

我们实际上在上面每个字符串中都有一个符号,但 length 属性显示的长度为 2

获取符号也可能很棘手,因为大多数语言特性将代理对视为两个字符。

例如,这里我们可以在输出中看到两个奇异字符

alert( '𝒳'[0] ); // shows strange symbols...
alert( '𝒳'[1] ); // ...pieces of the surrogate pair

代理对的各个部分彼此之间没有意义。因此,上面示例中的警报实际上显示的是垃圾。

从技术上讲,代理对也可以通过其代码检测:如果一个字符的代码在 0xd800..0xdbff 区间内,那么它就是代理对的第一部分。下一个字符(第二部分)的代码必须在 0xdc00..0xdfff 区间内。这些区间由标准专门为代理对保留。

因此,JavaScript 中添加了 String.fromCodePointstr.codePointAt 方法来处理代理对。

它们本质上与 String.fromCharCodestr.charCodeAt 相同,但它们正确地处理了代理对。

这里可以看到区别

// charCodeAt is not surrogate-pair aware, so it gives codes for the 1st part of 𝒳:

alert( '𝒳'.charCodeAt(0).toString(16) ); // d835

// codePointAt is surrogate-pair aware
alert( '𝒳'.codePointAt(0).toString(16) ); // 1d4b3, reads both parts of the surrogate pair

也就是说,如果我们从位置 1 开始(这里相当不正确),那么它们都只返回对的第 2 部分

alert( '𝒳'.charCodeAt(1).toString(16) ); // dcb3
alert( '𝒳'.codePointAt(1).toString(16) ); // dcb3
// meaningless 2nd half of the pair

您将在本章 可迭代对象 的后面找到更多处理代理对的方法。可能也有专门的库可以处理,但没有足够出名可以在这里建议。

外卖:在任意点拆分字符串很危险

我们不能只在任意位置拆分字符串,例如,获取 str.slice(0, 4) 并期望它是一个有效的字符串,例如

alert( 'hi 😂'.slice(0, 4) ); //  hi [?]

这里我们可以在输出中看到一个垃圾字符(笑脸代理对的前半部分)。

如果您打算可靠地使用代理对,请注意这一点。可能不是什么大问题,但至少您应该了解会发生什么。

变音符号和规范化

在许多语言中,有一些符号是由带有上面/下面的标记的基本字符组成的。

例如,字母 a 可以是这些字符的基本字符:àáâäãåā

大多数常见的“复合”字符在 Unicode 表中都有自己的代码。但并非所有字符都有,因为可能的组合太多了。

为了支持任意组合,Unicode 标准允许我们使用多个 Unicode 字符:基本字符后跟一个或多个“标记”字符来“装饰”它。

例如,如果我们有 S 后跟特殊“上点”字符(代码 \u0307),它显示为 Ṡ。

alert( 'S\u0307' ); // Ṡ

如果我们需要在字母上方(或下方)添加额外的标记——没问题,只需添加必要的标记字符即可。

例如,如果我们追加一个“点在下方”(代码 \u0323)的字符,那么我们就会得到“带上下点的 S”:Ṩ

例如

alert( 'S\u0307\u0323' ); // Ṩ

这提供了很大的灵活性,但也带来了一个有趣的问题:两个字符在视觉上可能看起来相同,但用不同的 Unicode 组合表示。

例如

let s1 = 'S\u0307\u0323'; // Ṩ, S + dot above + dot below
let s2 = 'S\u0323\u0307'; // Ṩ, S + dot below + dot above

alert( `s1: ${s1}, s2: ${s2}` );

alert( s1 == s2 ); // false though the characters look identical (?!)

为了解决这个问题,存在一个“Unicode 规范化”算法,它将每个字符串转换为单个“正常”形式。

它由 str.normalize() 实现。

alert( "S\u0307\u0323".normalize() == "S\u0323\u0307".normalize() ); // true

有趣的是,在我们的情况下,normalize() 实际上将一个 3 个字符的序列合并为一个:\u1e68(带两个点的 S)。

alert( "S\u0307\u0323".normalize().length ); // 1

alert( "S\u0307\u0323".normalize() == "\u1e68" ); // true

实际上,情况并非总是如此。原因是符号 “相当常见”,因此 Unicode 创建者将其包含在主表中并为其提供了代码。

如果你想了解有关规范化规则和变体的更多信息,它们在 Unicode 标准的附录中进行了描述:Unicode 规范化形式,但对于大多数实际目的,本节中的信息就足够了。

教程地图

评论

在评论之前阅读此内容…
  • 如果你有改进建议,请 提交 GitHub 问题 或提交拉取请求,而不是发表评论。
  • 如果你无法理解文章中的某些内容,请详细说明。
  • 要插入一些代码,请使用 <code> 标记,对于多行代码,请将其包装在 <pre> 标记中,对于 10 行以上的代码,请使用沙盒(plnkrjsbincodepen…)