每个 JavaScript 开发者都应该了解的 Unicode

803

本文为翻译

原文标题:What every JavaScript developer should know about Unicode

原文作者:Dmitri Pavlutin

原文地址:dmitripavlutin.com/what-every-…

本文绝大多数专业名词中文翻译均参考自对应的中文维基百科

在开始之前,我要坦白一点:有很长一段时间,我都很怕 Unicode。每当我遇到些需要应用 Unicode 知识去解决的编程任务,我就会去搜索一个 hack 方案,但其实我并不理解到底在做什么。

我一直在逃避,直到我遇到了一个需要深入理解 Unicode 才能解决的问题。但已经搜索不到适合我当前场景的解决方案了。

我努力读了一大堆文章 — 让我惊讶的是,其实 Unicode 不难理解。虽说 … 有些文章我读了至少三遍。

事实证明,Unicode 是一个通用且优雅的标准。但由于那一大堆的抽象术语,要想坚持学习下去其实还蛮难的。

如果你感觉自己对 Unicode 的理解还不够,那么现在是时候来直面它了!没那么难。给自己泡一杯可口的 茶 或 咖啡 ☕。让我们深入 抽象、字符、星光 和 代理 的美妙世界吧。

本文首先会解释 Unicode 的基本概念,帮你打牢基础。之后会阐述 Unicode 在 JavaScript 是如何工作的,以及在这个过程中你可能会遇到的坑。你还将学会如何应用新的 ECMAScript 2015 特性来解决部分问题。

准备好了吗?燥起来!

1. Unicode 背后的思想

我们先从一个简单的问题开始。你为何能够阅读理解这篇文章呢?很简单,因为你知道每一个 字 和 词(由字组成的)的含义。

而为何你能理解每一个字的含义呢?简单来说,是因为你(读者)和我(作者)都认同 这些图形符号(在屏幕上看到的东西)和 汉语中的 字(含义)之间存在着联系。

同样的事也发生在计算机之间。不同之处在于计算机无法理解字的含义。对于计算机,这些 字 仅仅是 二进制位序列。

想象这样一个场景, 用户1 通过网络向 用户2 发送了一条消息 hello

用户1 的计算机不知道其中每个字母的含义。所以它会将 hello 转换成数字序列 0x68 0x65 0x6C 0x6C 0x6F ,其中每一个字母唯一对应一个数字: h 对应 0x68e 对应 0x65 ,诸如此类。这些数字会被发送到 用户2 的计算机上。

用户2 的计算机接收到数字序列 0x68 0x65 0x6C 0x6C 0x6F 后,它会使用同样一套 对应关系(字母和数字之间的)来还原消息。然后展示出正确的消息: hello

这两台计算机 在 字母和数字的对应关系 上达成了一致,而就是 Unicode 制定了标准。

按照 Unicode 标准, h 是一个叫做 LATIN SMALL LETTER H 的 抽象字符(Abstract Character)。这个字符对应一个数字 0x68 ,这就是一个 码位(Code Point),标准形式记为 U+0068

Unicode 会提供一个 抽象字符列表(字符集 Character Set),并为每个字符分配一个唯一识别符 — 码位(编码字符集 Coded Character Set)。

2. Unicode 基础术语

网站 www.unicode.org 提到:

Unicode 为每个字符提供了一个独一无二的数字,无论是什么平台,无论是什么程序,无论是什么语言。”

Unicode 是一个通用字符集,它为世界上大部分的 文字系统 定义了 字符形式(Character Form)列表,并为每个字符关联了一个唯一的数字(码位)。

Unicode 包含了当今大多数语言中的 字符、标点符号、变音符号、数学符号、技术性符号、箭头、emoji 等等。

最初的 Unicode 版本 1.0 在 1991-10 发布,包含 7,161 个字符。最近的一个版本是 14.0 (2021-9 发布)包含 144,697 个字符。

在 Unicode 出现之前,厂商们 实现了很多 难用的 字符集 和 编码,Unicode 以 普遍且包容的方式 解决了那个时期的存在的大部分问题。

创建一个支持所有 字符集 和 编码 的应用程序是非常复杂的。

如果你觉得 Unicode 已经很难了,那我要告诉你,没了 Unicode,编程只会更难。

我还记得以前在读取文件的时候,会胡乱地挑选字符集和编码。简直是在抽奖!

2.1 字符 和 码位

抽象字符 (或者说 字符)是用于组织、控制 或 表示 文本数据 的 信息单元”

Unicode 将 字符 作为抽象术语。每个 抽象字符 都会关联一个名称,例如 LATIN SMALL LETTER A 。这个字符的 渲染形式(字形 Glyph)是 a

码位 是给单个字符指定的一个数字”

码位 是从 U+0000U+10FFFF 的数字。

U+<hex> 是码位的格式,其中 U+ 是代表 Unicode 的前缀,而 <hex> 表示十六进制数字。例如, U+0041U+2603 都是码位。

记住,码位 就是一个简简单单的数字,别考虑太多。码位 就是一种 数组中元素的索引。

Unicode 将 码位 和 字符 关联起来,一切就变得奇妙起来了。例如, U+0041 对应的 字符 名为 LATIN CAPITAL LETTER A (渲染为 A ),U+2603 对应的 字符 名为 SNOWMAN (渲染为 )。

并非所有 码位 都会关联字符。总共有 1,114,112 个码位可用(范围从 U+0000U+10FFFF ),但是只有 144,697 个(截止 2021.9)被赋予了字符。

2.2 Unicode 平面(Unicode Planes)

平面(Planes) 是从 U+n0000U+nFFFF 的 65,536(或 100001610000_{16}) 个连续的码位,n 的取值范围从 0160_{16}101610_{16}

平面 将全部的 Unicode 码位分成了 17 个均等的组:

  • 平面0 包含从 U+0000 到 U+FFFF 的 码位。
  • 平面1 包含从 U+10000 到 U+1FFFF 的 码位。
  • 平面16 包含从 U+100000 到 U+10FFFF 的 码位。

基本多文种平面(Basic Multilingual Plane)

平面 0 是其中最特殊的一个平面,名为 基本多文种平面(Basic Multilingual Plane),简称 BMP 。它包含了绝大多数现代语言中的字符 (Basic Latin 基础拉丁字母,Cyrillic 西里尔字母,Greek 希腊字母,等等)和 大量的符号(Unicode符号,英语:Unicode symbol,是一个并非用以表达一种书写文字,但可用于文本上的Unicode字符)。

综上所述,基本多文种平面 范围是从 U+0000U+FFFF ,最多 4 位十六进制数字。

开发者通常只会处理 BMP 中的字符。BMP 包含了绝大部分必要的字符。

BMP 中的一些字符:

  • eU+0065 ,命名为 LATIN SMALL LETTER E
  • |U+007C ,命名为 VERTICAL BAR
  • U+25A0 ,命名为 BLACK SQUARE
  • U+2602 ,命名为 UMBRELLA

星光平面(Astral Planes)

译者注: Astral Plane,也称为星界,是古典、中世纪、东方、神秘哲学和神秘宗教所假设的一个存在位面。它是天球的领域,是灵魂诞生和死亡之后的穿梭的地方,通常认为,天使、精灵 或 其他非物质生命在这里居住。"Astral Plane" 是 辅助平面(Supplementary Planes) 的非正式名称,因为(特别是90年代后期)对它们的使用太少了,以至于和神秘学中的 “彼岸”(The Great Beyond)一样虚无缥缈。很多人对这种幽默的称呼持反对意见,而且随着 平面1 和 平面2 的广泛使用,越来越少的人觉得这些平面真的是 “星光界”。但是这种诙谐的引申是无害的,它提醒我们现在还远远达不到那种程度。 详见:www.opoudjis.net/unicode/uni…

在 BMP 后的 16 个平面(平面 1平面 2,…,平面 16)被称为 星光平面(Astral Planes)辅助平面(Supplementary Planes)

星光平面 中的 码位 被称为 星光码位。码位范围从 U+10000U+10FFFF

一个 星光码位 有 5 或 6 位 十六进制数字:U+dddddU+dddddd

让我们看看 星光平面 中的一些字符:

  • 𝄞U+1D11E ,命名为 MUSICAL SYMBOL G CLEF
  • 𝐁U+1D401 ,命名为 MATHEMATICAL BOLD CAPITAL B
  • 🀵U+1F035 ,命名为 DOMINO TITLE HORIZONTAL-00-04
  • 😀U+1F600 ,命名为 GRINNING FACE

2.3 码元(Code Units)

OK,我们刚刚说的 Unicode 的 “字符”,“码位” 和 “平面” 都是些抽象概念。

现在我们该谈谈 Unicode 在 物理层面、硬件层面 是如何实现的了。

计算机在内存级别 用不上 码位 或 抽象字符 这些概念。它需要一种物理方式来表现 Unicode 码位,那就是 码元。

码元 是一串 二进制位序列,用于以给定编码格式 对每个字符编码 ”

字符编码 将 抽象概念 码位 转换为 物理上的 二进制位:码元。换句话说,字符编码 会将 Unicode 码位 转换为 唯一的 码元序列。

常用的 字符编码 有 UTF-8UTF-16UTF-32

大多数 JavaScript 引擎 会使用 UTF-16 编码,所以现在我们聚焦到 UTF-16。

UTF-16 (全称为:16-bit Unicode Transformation Format)是一种可变长度编码:

  • BMP 的 码位 会编码为 1 个 16 位长的码元
  • 星光平面 的 码位 会编码为 2 个 16 位长的码元

OK,枯燥理论谈的有点多了。我们来看些例子吧。

假设你想将 LATIN SMALL LETTER A 字符 a 保存到硬盘驱动器。Unicode 会告诉你 抽象字符 LATIN SMALL LETTER A 是映射到 码位 U+0061 的。

现在我们来想想 UTF-16 编码 是如何对 U+0061 做的转换。按照 编码规范,对于 BMP 码位,会提取其中的十六进制数字 0061 并将其存储在 1个16位长的码元0x0061

如你所见,BMP 码位 很适合塞进 1 个 16 位长的码元。

2.4 代理对(Surrogate Pairs)

我们再研究一个复杂的例子。假设你想对 GRINNING FACE 字符 😀 编码。这个字符映射为码位 U+1F600 ,是星光平面中的。

由于 星光码位 需要 21个二进制位 来存储信息,按照 UTF-16,需要 2个16位长的码元。码位 U+1F600 会被分割为所谓的 “代理对” :0xD83D (高位代理码元)和 0xDE00 (低位代理码元)。

代理对 是一种表示形式,用于表示 由 两个16位码元序列 构成的 单个抽象字符,代理对 中的首个值为 高位代理码元 而第二个值为 低位代理码元。”

星光码位 需要的 两个码元 — 可以称为 代理对。例如,以 UTF-16 编码 U+1F600😀),会得到代理对: 0xD83D 0xDE00

console.log('\uD83D\uDE00'); // => '😀'

高位代理码元 从 0xD8000xDBFF 取值。低位代理码元 从 0xDC000xDFFF 取值。

代理对 和 星光码位 之间相互转换的算法如下:

function getSurrogatePair(astralCodePoint) {
  let highSurrogate = 
     Math.floor((astralCodePoint - 0x10000) / 0x400) + 0xD800;
  let lowSurrogate = (astralCodePoint - 0x10000) % 0x400 + 0xDC00;
  return [highSurrogate, lowSurrogate];
}
getSurrogatePair(0x1F600); // => [0xD83D, 0xDE00]
function getAstralCodePoint(highSurrogate, lowSurrogate) {
  return (highSurrogate - 0xD800) * 0x400 
      + lowSurrogate - 0xDC00 + 0x10000;
}
getAstralCodePoint(0xD83D, 0xDE00); // => 0x1F600

代理对 用起来可不舒服。在 JavaScript 中处理字符串时,你必须将它们当作特殊情况处理,我会后面的文章中谈谈这部分。

然而,UTF-16 是 内存高效(Memory Efficient) 的。我们平时需要处理字符中有 99% 都来自 BMP,只需要一个码元,节省了大量内存。

2.5 组合字符(Combining Marks)

字位(Grapheme,又称 形素、字素),或 符号(Symbol),是最小的有意义书写符号单位。”

字位 表示了我们如何看待一个字符。我们将 字位 在显示器上渲染出的具体图形 称为 字形(Glyph)。

译者注:这里的 “字形” 指的是 “一个可以辨认的抽象的图形符号,它不依赖于任何特定的设计”,又称字符、字图、书形,是指字的形体。详见 中文维基-字形 ,并非 letterform

在绝大多数情况下,一个 Unicode 字符 对应 一个字位。例如 U+0066 LATIN SMALL LETTER F 就写作 f

但也存在 一个字位 由一系列字符组成 的情况。

例如,å 在丹麦语的文字系统中 是一个原子性的 字位。需要使用 U+0061 LATIN SMALL LETTER A (渲染为 a) 组合一个特殊字符 U+030A COMBINING RING ABOVE (渲染为 ◌̊)来显示。

U+030A 对前面的字符进行了修改,它被称作 组合字符

console.log('\u0061\u030A'); // => 'å'
console.log('\u0061');       // => 'a'

组合字符 是一种 在先前的 基本字符 上创建 字位 的 字符”

组合字符 包含 重音符号、变音符号、希伯来语点、阿拉伯元音符号 和 印度matras。

组合符号 通常不会独立使用(即没有基本字符的情况下)。应当避免独立显示它们。

就和 代理对 一样,组合符号 在 JavaScript 中也很难处理。

组合字符序列 (基本字符 + 组合字符)会被用户认知为 单个符号(例如 '\u0061\u030A' 就是 'å')。但开发者必须使用 2 个 码位 U+0061U+030A 来构造 å

3. JavaScript 中的 Unicode

ES2015 规范 提到 ,源码文本使用 Unicode (版本 5.1 以上)。码位范围从 U+0000U+10FFFF 。源码 存储 和 数据交换 的格式在 ECMAScript 规范中没有提到,不过通常会使用 UTF-8 编码(Web 的首选编码)。

我建议保留源码中 Unicode 基本拉丁字母块(或者 ASCII)中的字符。超出 ASCII 的字符应该被转义。这可以减少编码问题出现的概率。

深入下去,在语言层面,ECMAScript 2015 提供了 JavaScript 中 String 的明确定义

“String 类型是由零个或多个 16 位无符号整数值(“元素”)组成的所有有序序列的集合,最大长度为 25312^{53}-1 个元素。 String 类型通常用于表示运行中 ECMAScript 程序的文本数据,这时,String 中的每个元素都被视为一个 UTF-16 码元 值。”

字符串中的每个元素都会被引擎解析为一个码元。字符串的渲染方式不会提供一种确定的方法来决定其包含的码元(所表示的码位)。看下面这个例子:

console.log('cafe\u0301'); // => 'café'
console.log('café');       // => 'café'

'cafe\u0301''café' 从字面上看,码元略有不同,但两者都会渲染出相同的符号序列 café

在前文的 代理对 和 组合符号 这两章,我们讲过,一些符号需要 2个 或 更多 码元来表示。因此在计算字符数量 或 通过索引访问字符时,你要小心仔细,做好预防工作:

const smile = '\uD83D\uDE00';
console.log(smile);        // => '😀'
console.log(smile.length); // => 2
const letter = 'e\u0301';
console.log(letter);        // => 'é'
console.log(letter.length); // => 2

smile 字符串包含2个码元:\uD83D(高位代理) 和 \uDE00 (低位代理)。由于字符串就是一个码元序列,所以 smile.length 就会得出 2。即使 字符串 smile 只渲染出一个符号 😀

我建议,始终将 JavaScript 中的字符串理解为码元序列。渲染字符串的方式无法清晰地说明其包含的码元。

星光平面的符号 和 组合符号序列 需要 2个 或 更多码元 进行编码。但只会被当作单一的字位。

如果 字符串 中包含 代理对 和 组合符号,开发者在不知情的情况下可能会在 字符串长度的计算 和 用索引访问字符 时感到困惑。

大多数 JavaScript 字符串方法都不是 “Unicode 感知”(Unicode-aware) 的。如果你的字符串中包含 Unicode 复合字符,在调用 myString.slice()myString.substring() 这些方法时,请做好防范。

3.1 转义序列(Escape Sequences)

JavaScript 字符串 的 转义序列 是基于 码位数字 来表示 码元 的。JavaScript 提供了 3 种转义类型,其中一种是在 ECMAScript 2015 中引入。

我们来看更多细节。

十六进制转义序列

转义序列最短的形式叫做 十六进制转义序列\x<hex> ,其中 \x 时前缀,后面跟着的是长度固定为2位的十六进制数字 <hex> 。例如,'\x30'(符号 '0'),'\x5B'(符号 '[')。

十六进制转义序列 的 字符串字面量 和 正则表达式 写法是这样的:

const str = '\x4A\x61vaScript';
console.log(str);                    // => 'JavaScript'
const reg = /\x4A\x61va.*/;
console.log(reg.test('JavaScript')); // => true

因为只能使用 2 位,所以十六进制转义序列 只能转义有限范围内的码位:U+00U+FF 。但它的优点在于短。

Unicode 转义序列

如果你想转义整个 BMP 的码位,那你就应该使用 Unicode 转义序列。其转义格式为 \u<hex>,其中 \u 为前缀,后面跟着一个长度固定为4位的十六进制数字 <hex> 。例如,'\u0051'(符号 'Q'),'\u222B'(积分符号 '∫')。

我们来用用看 Unicode 转义序列:

const str = 'I\u0020learn \u0055nicode';
console.log(str);                 // => 'I learn Unicode'
const reg = /\u0055ni.*/;
console.log(reg.test('Unicode')); // => true

因为只能使用 4 位, Unicode 转义序列 可以转义有限范围内的码位:U+0000U+FFFF (BMP的全部码位),大多数情况下,这已经足够表示那些常用的符号了。

想要再 JavaScript 字面量 中表示一个 星光平面中的符号,需要使用两个连接在一起的 Unicode 转义序列(高位代理 和 低位代理),这就创建了一个 代理对:

const str = 'My face \uD83D\uDE00';
console.log(str); // => 'My face 😀'

码位转义序列

ECMAScript 2015 提供了能够表示整个 Unicode 空间(U+0000U+10FFFF) 码位 的 转义序列:即 BMP 和 星光平面。

新的更是被称作 码位转义序列\u{<hex>} ,其中 <hex> 是一个长度为 1 到 6 位 的十六进制数字 。例如,'\u{7A}'(符号 'z'),'\u{1F639}'(滑稽猫符号 '😹')。

看看如何再字面量中使用它:

const str = 'Funny cat \u{1F639}';
console.log(str);                      // => 'Funny cat 😹'
const reg = /\u{1F639}/u;
console.log(reg.test('Funny cat 😹')); // => true

注意,正则表达式 /\u{1F639}/u 中有一个特殊的标志 u ,这是用来开启附加的 Unicode 特性的(详见 3.5 正则表达式匹配)。

我喜欢通过 码位转义 来避免使用 代理对 来表示 星光平面的符号。我们来转义 U+1F607 SMILING FACE WITH HALO 码位:

const niceEmoticon = '\u{1F607}';
console.log(niceEmoticon);   // => '😇'
const spNiceEmoticon = '\uD83D\uDE07'
console.log(spNiceEmoticon); // => '😇'
console.log(niceEmoticon === spNiceEmoticon); // => true

赋给变量 niceEmoticon 的字符串字面量是 转义码位 \u{1F607} ,表示的是星光平面的码位 U+1F607 。不过,其实在底层 码位转义 还是创建了一个 代理对(2个码位)。正如你看到的,spNiceEmoticon 使用 Unicode 转义 '\uD83D\uDE07' 创建了一个代理对,是等同于 niceEmoticon

如果正则表达式是通过 RegExp 构造函数创建的,必须在字符串字面量中将 \ 替换为 \\ 来做 Unicode 转义。下面这些正则表达式对象是等价的:

const reg1 = /\x4A \u0020 \u{1F639}/;
const reg2 = new RegExp('\\x4A \\u0020 \\u{1F639}');
console.log(reg1.source === reg2.source); // => true

3.2 字符串比较

JavaScript 中的字符串 是 码元序列。那么,我们有理由认为 字符串比较 会涉及对码元的计算,这样的话,比较字符串 就是在比较 两个字符串中包含的码元是否一致。

这种方式快速高效。可以很好的处理 “简单的” 字符串:

const firstStr = 'hello';
const secondStr = '\u0068ell\u006F';
console.log(firstStr === secondStr); // => true

firstStrsecondStr 字符串是相同的码元序列。

假设你想比较两个渲染出来相同,但包含不同码元序列的字符串。那么你也许会得到一个意想不到的结果,字符串 看起来相同 但 比较结果是不相等:

const str1 = 'ça va bien';
const str2 = 'c\u0327a va bien';
console.log(str1);          // => 'ça va bien'
console.log(str2);          // => 'ça va bien'
console.log(str1 === str2); // => false

str1str2 渲染看起来相同,但码元不同。之所以会发生这种情况,字位 ç 是通过两种不同的方式构造的:

  • 使用 U+00E7 LATIN SMALL LETTER C WITH CEDILLA
  • 另一种使用 组合字符序列:U+0063 LATIN SMALL LETTER C 加 组合符号 U+0327 COMBINING CEDILLA

如何处理这种情况并正确比较字符串?答案是字符串 正规化(Normalization)。

正规化(Normalization)

正规化 就是将字符串转换为规范的表示形式,以确保 标准等价(canonical-equivalent)和/或 兼容等价(compatibility-equivalent)的 字符串 有标准的表示形式。”

换句话说,当字符串结构复杂(包含组合字符序列 或 其他复杂结构),可以 将它 正规化 得到规范格式。正规化的字符串可以无痛 比较 或 执行文字搜索等字符串操作,以此类推。

Unicode 附加标准 #15 提供了 正规化 过程中有趣的细节。

在 JavaScript 正规化 字符串 可以调用myString.normalize([normForm]) 方法,该方法在 ES2015 中可用。normForm 是一个可选参数(默认为 'NFC'),其值也可以是一下 正规化 格式:

  • 'NFC' 为 Normalization Form Canonical Composition
  • 'NFD' 为 Normalization Form Canonical Decomposition
  • 'NFKC' 为 Normalization Form Compatibility Composition
  • 'NFKD' 为 Normalization Form Compatibility Decomposition

我们通过应用字符串正规化来改进之前的例子,这能帮助我们正确的比较字符串:

const str1 = 'ça va bien';
const str2 = 'c\u0327a va bien';
console.log(str1 === str2.normalize()); // => true
console.log(str1 === str2);             // => false

'ç''c\u0327' 是 标准等效 的。当调用 str2.normalize() 时,会返回标准版本的 str2'c\u0327' 被替换为 'ç')。因此比较 str1 === str2.normalize() 就会和预期一样返回 truestr1 不受 正规化 影响,因为它已经是规范形式了。

将两个比较的字符串都 正规化,得到两个操作数的规范表示是非常合理的。

3.3 字符串长度

当然,确定字符串长度最常用的方式,就是访问 myString.length 属性。这个属性可以等到一个字符串所拥有的码元数量。

只包含 BMP 中 码位 的字符串确实可以使用这种计算方式 来得到预期结果。

const color = 'Green';
console.log(color.length); // => 5

color 中每一个 码元 对应一个单独的 字位。字符串预期长度为 5

长度 与 代理对

当字符串包含 代理对 (为了表示 星光平面的码位)时,情况就变得棘手了。因为每个 代理对 包含 2 个 码元(一个高位代理,一个低位代理),因此 length 属性会比预期大。

看下面这个例子:

const str = 'cat\u{1F639}';
console.log(str);        // => 'cat😹'
console.log(str.length); // => 5

字符串 str 渲染后,包含 4 个符号 cat😹。然而 smile.length 得出结果是 5,这是因为 U+1F639 是一个星光平面的码位,被编码成了2个码元(一个代理对)。

很不幸,目前还没有一种原生且高效的方式来解决这个问题。

不过至少 ECMAScript 2015 引入了感知 星光平面符号 的算法。星光平面的符号 即使被编码为 2 个码元,也会被计算为单个字符。

字符串迭代器 String.prototype[@@iterator]() 是 Unicode 感知 的。你可以通过 展开运算符 [...str]Array.from(str) 函数(两者底层都会调用字符串迭代器)来。然后计算返回数组的符号数量。

注意,这种解决如果广泛使用可能会导致性能问题。

我们来通过 展开运算符 来改进之前的例子:

const str = 'cat\u{1F639}';
console.log(str);             // => 'cat😹'
console.log([...str]);        // => ['c', 'a', 't', '😹']
console.log([...str].length); // => 4

[...str] 会创建一个包含 4 个符号的数组。 表示 U+1F639 CAT FACE WITH TEARS OF JOY 😹 的 代理对会保持完整,因为字符串迭代器是 Unicode 感知的。

长度 和 组合符号

那组合符号序列该怎么处理呢?因为每个组合符号是一个码元,所以会遇到相同的问题。

利用 字符串正规化 就能解决这个问题。如果足够幸运,组合字符序列 会被 正规化 为单个字符。我们来试试:

const drink = 'cafe\u0301';
console.log(drink);                    // => 'café'
console.log(drink.length);             // => 5
console.log(drink.normalize())         // => 'café'
console.log(drink.normalize().length); // => 4

drink 字符串包含 5 个码元(因此 drink.length5),即使它被渲染为了 4 个符号。

drink 正规化 时,很幸运,组合字符序列 'e\u0301' 存在一个标准形式 'é'。所以 drink.normalize().length 会得到预期的 4

但不幸的是,正规化 不是一个通用的解决方案。较长的 组合字符序列 并不总是有单个字符的标准等价形式。我们来看个例子:

const drink = 'cafe\u0327\u0301';
console.log(drink);                    // => 'cafȩ́'
console.log(drink.length);             // => 6
console.log(drink.normalize());        // => 'cafȩ́'
console.log(drink.normalize().length); // => 5

drink 有 6 个码元,drink.length6 。然而 drink 有 4 个符号。

正规化 drink.normalize() 将 组合序列 'e\u0327\u0301' 转换成了 两个字符的标准形式 'ȩ\u0301' (只消去了一个组合符号)。很可悲,drink.normalize().length 得到了 5,仍然不能表示出正确的符号个数。

3.4 字符定位

由于字符串就是一系列码元,所以通过字符串索引来访问字符也是存在一些难题的。

如果字符串只包含 BMP 字符(不包含 U+D800U+DBFF 高位代理 和 U+DC00U+DFFF 的 低位代理),字符定位可以正常运行。

const str = 'hello';
console.log(str[0]); // => 'h'
console.log(str[4]); // => 'o'

每个符号都会被编码为单个码元,因此通过索引访问是 OK 的。

字符定位 与 代理对

一旦字符串包含 星光平面的符号 时,情况就不一样了。

星光平面的符号 使用 2 个码元进行编码(代理对)。所以通过索引访问字符串中的字符,可能会得到分离的 高位代理 或者 低位代理,这些东西都不是有效的符号。

const omega = '\u{1D6C0} is omega';
console.log(omega);        // => '𝛀 is omega'
console.log(omega[0]);     // => '' (无法打印的字符)
console.log(omega[1]);     // => '' (无法打印的字符)

因为 U+1D6C0 MATHEMATICAL BOLD CAPITAL OMEGA 是星光平面的字符,它会被编码为包含两个码元的代理对。omega[0] 会访问到 高位代理码元,omega[1] 会访问到 低位代理码元,代理对 被破坏分解了。

存在 2 种在字符串中正常访问 星光平面符号 的方法:

  • 使用 Unicode 感知 的 字符串迭代器 生成符号数组 [...str][index]
  • 通过调用 number = myString.codePointAt(index) 拿到 码位数字,然后通过 String.fromCodePoint(number) 将数字转换为符号。(推荐选项)

我们来试试看这两种方法:

const omega = '\u{1D6C0} is omega';
console.log(omega);                        // => '𝛀 is omega'
// Option 1
console.log([...omega][0]);                // => '𝛀'
// Option 2
const number = omega.codePointAt(0);
console.log(number.toString(16));          // => '1d6c0'
console.log(String.fromCodePoint(number)); // => '𝛀'

[...omega] 返回 omega 包含的符号数组。代理对被正确计算了,因此能够如预期一般访问第一个字符。[...smile][0]'𝛀'

omega.codePointAt(0) 方法调用 是 Unicode 感知的,所以会返回 omega 中第一个字符的 星光码位数字 0x1D6C0。函数 String.fromCodePoint(number) 会基于码位数字返回符号:'𝛀'

字符定位 与 组合符号

包含 组合符号 的字符串 进行 字符定位 时 也会有相同的问题。

通过字符串索引访问字符 就相当于 访问码元。然而,组合符号序列 应当被当作一个整体访问,而不应该被分离多个码元。

下面这个例子说明了这个问题:

const drink = 'cafe\u0301';  
console.log(drink);        // => 'café'
console.log(drink.length); // => 5
console.log(drink[3]);     // => 'e'
console.log(drink[4]);     // => ◌́

drink[3] 只会访问到 基本字符 e,而不会带上 组合字符 U+0301 COMBINING ACUTE ACCENT (渲染为 ◌́)。drink[4] 会访问到独立的 组合字符 ◌́

这种情况可以将字符串 正规化。U+0065 LATIN SMALL LETTER E 加上 U+0301 COMBINING ACUTE ACCENT 的 组合字符序列 存在 标准等价形式 U+00E9 LATIN SMALL LETTER E WITH ACUTE é 。我们来改进前面的代码示例:

const drink = 'cafe\u0301';
console.log(drink.normalize());        // => 'café'  
console.log(drink.normalize().length); // => 4  
console.log(drink.normalize()[3]);     // => 'é'

请注意,并非所有 组合字符序列 都存在 单个字符的标准等价。因此 正规化 并不是一个通用的解决方案。

但幸运的是,这种方式对大部分欧美语言是有效的。

3.5 正则表达式匹配

正则表达式 和 字符串 一样,是基于码元工作的。和之前我提到那些场景类似,代理对 和 组合字符序列 会给正则表达式的使用带来麻烦。

BMP 字符匹配符合预期,因为单一码位表示单一符号:

const greetings = 'Hi!';
const regex = /.{3}/;
console.log(regex.test(greetings)); // => true

greetings 里的 3 个符号被编码为了 3 个码元。正则表达式 /.{3}/ 想要匹配 3 个码元,用于匹配 greetings

当匹配 星光平面中符号(会被编码为包含 2 个码元的代理对),你可能就会遇到一些问题。

const smile = '😀';
const regex = /^.$/;
console.log(regex.test(smile)); // => false

smile 包含 星光平面中的符号 U+1F600 GRINNING FACEU+1F600 会被编码为代理对 0xD83D 0xDE00。然而,正则表达式 /^.$/ 期望匹配一个码元,所以匹配失败了:regexp.test(smile) 就得 false 了。

当使用 星光平面中的符号 来定义 字符类(Character Classes) 的时候情况会更糟糕,JavaScript 会直接抛出错误:

const regex = /[😀-😎]/;
// => SyntaxError: Invalid regular expression: /[😀-😎]/: 
// Range out of order in character class

星光平面中的符号 会被编码为代理对。所以 JavaScript 在正则表达式中会使用码元 /[\uD83D\uDE00-\uD83D\uDE0E]/。每个码元在 pattern 中都被视为分离的元素,因此正则表达式会忽略 代理对 这个概念。因为 \uDE00 大于 \uD83D,所以 字符类 中 \uDE00-\uD83D 这部分就是非法的。因此,错误就产生了。

正则表达式 标志 u

幸运的是,ECMAScript 2015 引入了一个非常有用的 u 标志,它为正则表达式带来了 Unicode 感知 的能力。这个标志开启后,就能够正确处理 星光平面的字符。

你可以在正则表达式中使用 Unicode 转义序列 /u{1F600}/u。这种转义方式的写法是要 短于 高位代理 + 低位代理 的 代理对 /\uD83D\uDE00/ 的。

我们应用 u 标志 来看看 . 运算符()是如何匹配 星光平面的符号的:

const smile = '😀';
const regex = /^.$/u;
console.log(regex.test(smile)); // => true

/^.$/u 正则表达式,因为 u 标志的原因 变得 Unicode 感知 了,现在可以匹配 😀

开启 u 标志后,也能正常处理 字符类 中的 星光平面符号了:

const smile = '😀';
const regex = /[😀-😎]/u;
const regexEscape = /[\u{1F600}-\u{1F60E}]/u;
const regexSpEscape = /[\uD83D\uDE00-\uD83D\uDE0E]/u;
console.log(regex.test(smile));         // => true
console.log(regexEscape.test(smile));   // => true
console.log(regexSpEscape.test(smile)); // => true

[😀-😎] 现在可以得到 星光平面符号的范围了。/[😀-😎]/u 匹配到了 😀

正则表达式 与 组合符号

不幸的是,无论在正则表达式中是否使用 u 标志,正则表达式都会将其视为分离的码元。

如果你需要匹配一个 组合字符序列,你必须分别匹配 基本字符 和 组合符号。

看下面的例子:

const drink = 'cafe\u0301';
const regex1 = /^.{4}$/;
const regex2 = /^.{5}$/;
console.log(drink);              // => 'café'  
console.log(regex1.test(drink)); // => false
console.log(regex2.test(drink)); // => true

渲染出的字符串包含 4 个符号 café

不过,正则表达式 /^.{5}$/ 匹配到了 'cafe\u0301' ,认为其有 5 个元素。

4. 总结

可能在 JavaScript 中,Unicode 最重要的概念就是:将 字符串 视为 码元序列,那是字符串真正的模样。

一旦开发者认为 是 字位(或符号) 组成了 字符串 ,而忽略了 码元序列 的概念,就会产生很多疑惑。

在处理字符串时,如果包含 代理对 或 组合字符序列,就容易出现一些坑:

  • 获取字符串长度
  • 字符定位
  • 正则表达式匹配

注意,JavaScript 中大部分方法不是 Unicode 感知的: myString.indexOf()myString.slice() 等等。

ECMAScript 2015 引入了很棒的特性,比如 字符串 和 正则表达式 中的 码位转义序列 \u{1F600}

新的正则表达式标志 u 可以开启 Unicode 感知 的 字符串匹配。这简化了 星光平面中符号 的匹配。

字符串迭代器 String.prototype[@@iterator]() 是 Unicode 感知的。你可以使用 展开运算符 [...str]Array.from(str) 来创建 符号数组,然后就可以在不破坏代理对的条件下,计算字符串长度 或 通过索引访问其中的字符。注意,这些操作会影响性能。

如果你需要更好的方式来处理 Unicode 字符,你可以使用 工具库 punycodegenerate 来生成专业的正则表达式。

我希望这篇文章能帮助你掌握 Unicode!

你还知道 JavaScript 中 Unicode 其他有趣的点吗?欢迎在下方评论!