头图

这个故事以一个自白开始:我在很长一段时间都害怕Unicode。每当一个编程任务需要Unicode知识时,我正在寻找一个可破解的解决方案,而没有详细了解我在做什么。

我的回避一直持续到我遇到了一个需要详细Unicode知识的问题,再也没法回避。

经过一些努力,读了一堆文章——令人惊讶的是,理解它并不难。嗯……有些文章至少需要读3遍。

事实证明,Unicode是一种通用而优雅的标准。这可能很难,因为有一大堆难以坚持的抽象术语。

如果您在理解Unicode方面有差距,那么现在正是面对它的时候!没那么难。给自己沏杯美味的茶或咖啡☕. 让我们深入到抽象奇妙的世界。

这篇文章解释了Unicode的基本概念。这就创造了必要的基础。

然后阐明JavaScript如何与Unicode协同工作,以及可能遇到的陷阱。

您还将学习如何应用新的ECMAScript 2015特性来解决部分困难。

准备好了吗?让我们嗨起来!

1. Unicode背后的理念

让我们从一个基本问题开始。你如何阅读和理解当前的文章?简单地说:因为你知道字母和单词作为一组字母的含义。

为什么你能理解字母的意思?简单地说:因为你(读者)和我(作者)在图形符号(屏幕上看到的东西)和英语字母(意思)之间的关联上达成了一致。

计算机也是如此。不同之处在于计算机不理解字母的含义:它们认为这些只是一些字节。

设想一个场景,当用户1通过网络向用户2发送消息“hello”。

用户1的计算机不知道字母的含义。因此,它将“hello”转换为一个数字序列0x68 0x65 0x6C 0x6C 0x6F,其中每个字母唯一地对应一个数字:h是0x68,e是0x65,等等。这些数字被发送到用户2的计算机。

当用户2的计算机接收到数字序列0x68 0x65 0x6C 0x6C 0x6F时,它将使用数字对应的字母并还原消息。然后它会显示正确的消息:“hello”。

这两台计算机之间关于字母和数字之间对应关系的协议是Unicode标准化的。

依据Unicode,h是一个名为拉丁小写字母h的抽象字符。该字符具有相应的数字0x68,代码点表示为U+0068

Unicode的作用是提供一个抽象字符列表(字符集),并为每个字符分配一个唯一的标识符代码点(编码字符集)。

2.Unicode的基本术语

www.unicode.org提到:“Unicode为每个字符提供唯一的数字,”,与平台、编程和语言无关。

Unicode是一种通用字符集,用于定义大多数书写系统中的字符列表,并为每个字符关联一个唯一的数字(代码点)。

image.png

Unicode包括来自当今大多数语言的字符、标点符号、变音符号、数学符号、技术符号、箭头、表情符号等等。

第一个Unicode版本1.0于1991年10月发布,共有7161个字符。最新版本9.0(于2016年6月发布)提供了128172个字符的代码。

Unicode的通用性和包容性解决了以前存在的一个主要问题,当时供应商实现了许多难以处理的字符集和编码。

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

如果您认为Unicode很难,那么没有Unicode的编程将更加困难。

我仍然记得我读着文件内容中的乱码,就跟买彩票一样!

2.1字符和代码点

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

Unicode将字符作为抽象术语处理。每个抽象字符都有一个相关的名称,例如拉丁字母(LATIN SMALL LETTERA。该字符的呈现形式(字形)为a

“代码点是一个分配给单个字符的数字。”

代码点的范围是U+0000U+10FFFF

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

请记住,代码点就是一个简单的数字。你应该这样想,代码点是元素在数组中的一个索引。

因为Unicode将一个代码点与一个字符相关联,所以产生了神奇的效果。例如,U+0041对应于名为拉丁大写字母(LATIN CAPITAL LETTERA的字符(渲染为A),或者U+2603对应于名为雪人(SNOWMAN)的字符(渲染为).

并非所有代码点都具有相应的字符。1114112个代码点可用(范围从U+0000到U+10FFFF),但只有137929个代码点分配了字符(截至2019年5月)。

2.2 Unicode平面

“平面(Plane)是一个从U+n0000U+nFFFF,总共有65536个持续的Unicode代码点的范围,其中n取值范围是0x0~0x10

平面将Unicode代码点分成17个相等的组:

  • 平面0包含从U+0000U+FFFF的代码点,
  • 平面1包含从U+10000U+1FFFF的代码点
  • ...
  • 平面16包含从U+100000U+10FFFF的代码点

Unicode planes

基本多文种平面

平面0是一个特殊的平面,称为基本多文种平面( Basic Multilingual Plane)或简称BMP。它包含来自大多数现代语言(基本拉丁语)、西里尔语)、希腊语等)的字符和大量符号。

如上所述,基本多文种平面的代码点在U+0000U+FFFF之间,最多可以有4个十六进制数字。

开发人员通常处理BMP中的字符。它包含大多数必需的字符。

BMP中的某些字符:

  • eU+0065,命名为拉丁文小写字母e
  • |U+007C,命名为竖线
  • U+25A0,命名为黑色正方形
  • U+2602,命名为伞

星形平面

其他16个超过BMP的平面(平面1、平面2、...平面16)被称为星形平面(astral planes)或者辅助平面(supplementary planes)。

星形平面里的代码点被称为星形代码点,它的范围从U+10000U+10FFFF

星形代码点可以有5到6个十六进制数字,如 U+dddddU+dddddd

例子如下:

  • 𝄞U+1D11E,命名为音乐符号G谱号
  • 𝐁U+1D401,命名为数学黑体大写字母B
  • 🀵U+1F035,命名为多米诺水平标题-00-04
  • 😀U+1F600,命名为笑脸

2.3 代码单元

计算机在内存中不使用代码点或抽象字符。它需要一种物理方式来表示Unicode代码点:代码单元(code units)。

“代码单元是一个位序列,用于对给定编码形式中的每个字符进行编码。”

字符编码将抽象代码点转换为物理位:代码单元。

换句话说,字符编码将Unicode代码点转换为唯一的代码单元序列。

流行的编码有UTF-8UTF-16UTF-32

大多数JavaScript引擎使用UTF-16编码。这会影响JavaScript使用Unicode的方式。从现在开始,让我们专注于UTF-16

UTF-16(长名称:16位Unicode转换格式)是一种可变长度编码

  • BMP中的代码点使用16位的单个代码单元进行编码
  • 星形代码点使用两个16位的编码单元进行编码。

我们来举几个例子。

假设你想将拉丁小写字母 a 保存到硬盘驱动器。 Unicode 告诉你 丁小写字母 a 映射到 U+0061 代码点。

现在让我们询问UTF-16编码U+0061应该如何转换。编码规范规定,对于BMP代码点,取其十六进制数U+0061,并将其存储到一个16位的代码单元中:0x0061

如你所见,BMP中的代码点适合于单个16位代码单元。

2.4 代理对

现在让我们研究一个复杂的案例。假设你要保存一个星形代码点(来自星形平面): 笑脸😀 。此字符映射到 U+1F600 代码点。

由于星形代码点需要 21 位来保存信息,因此 UTF-16 表示你需要两个 16 位的代码单元。代码点 U+1F600 被分成所谓的代理对:0xD83D(高代理代码单元,high-surrogate code unit)和 0xDE00(低代理代码单元,low-surrogate code unit)。

引用
代理对(Surrogate pair)是单个抽象字符的表示,它由两个 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 在内存中是高效的。 99%的字符来自BMP,这些字符只需要一个代码单元。

组合标记

在一个特定的书写系统的上下文中,一个字形(grapheme)或符号(symbol)是一个最小的独特的书写单位。

字形是从用户的角度看待字符。屏幕上显示的一个图形的具体图像称为字形(glyph)

在大多数情况下,单个Unicode字符表示单个图形。例如,U+0066 拉丁文小写字母表示英文字母 f

在某些情况下,字形包含一系列字符。

例如,å 是丹麦书写系统中的一个原子字形。它使用U+0061拉丁文小写字母A(呈现为A)和特殊字符U+030A(呈现为 ◌̊)COMBINING RING ABOVE).

U+030A 修饰前置字符,命名为组合标记(combining mark)。

console.log('\u0061\u030A'); // => 'å'
console.log('\u0061');       // => 'a'
组合标记是一个应用于前一个基本字符的字符,用于创建字形。”

组合标记包括重音符号、变音符号、希伯来文点、阿拉伯元音符号和印度语字母等字符。

组合标记通常在没有基本字符的情况下不单独使用。您应该避免单独显示它们。

与代理对一样,组合标记在 JavaScript 中也很难处理。

组合字符序列(基本字符 + 组合标记)被用户区分为单个符号(例如 '\u0061\u030A''å')。但是开发者必须确定使用U+0061U+030A 这2个代码点来构造 å

3. JavaScript 中的 Unicode

ES2015 规范提到源代码文本使用 Unicode(5.1 及更高版本)表示。源文本是从 U+0000 U+10FFFF 的代码点序列。源代码的存储或交换方式与 ECMAScript 规范无关,但通常以 UTF-8(web首选编码方式)编码。

我建议使用Basic Latin Unicode block) (或ASCII)中的字符保留源代码文本。ASCII以外的字符应转义。这将确保编码方面的问题更少。

在内部,在语言层面,ECMAScript 2015 提供了一个明确的定义,JavaScript 中的字符串是什么:

字符串类型是零或多个16位无符号整数值(“元素”)的所有有序序列的集合,最大长度为(2的53次方减1)个元素。字符串类型通常用于表示正在运行的ECMAScript程序中的文本数据,在这种情况下,字符串中的每个元素都被视为UTF-16代码单位值。

字符串的每个元素都被引擎解释为一个代码单元。字符串的呈现方式不能确定它包含哪些代码单元(代表代码点)。请参阅以下示例:

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

'cafe\u0301''café'文字的代码单元稍有不同,但它们都呈现为相同的符号序列café

字符串的长度是其中的元素(16位的代码单元)的数目。[...]当ECMAScript操作解释字符串值时,每个元素被解释为单个UTF-16代码单元。

正如你从上述章节的代理对组合标记中知道的那样,某些符号需要 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 仅有一个符号 '😀'

letter字符串也是相同的情况。组合标记 U+0301 应用在前一个字符e上,渲染结果为符号'é'。但是 letter 包含 2 个代码单元,因此 letter.length 为 2。

我的建议:始终将 JavaScript 中的字符串视为一系列代码单元。字符串的呈现方式无法清楚说明它包含哪些代码单元。

星形平面的符号和组合字符序列需要编码2个或更多的代码单元。但它们被视为一个单一的字形(grapheme)。

如果字符串具有代理对组合标记,开发人员在没有记住这一要点的情况下去计算字符串的长度或者按索引访问字符时会感觉到困惑。

大多数JavaScript字符串方法都不支持Unicode。如果字符串包含复合Unicode字符,请在调用myString.slice()myString.substring()等时采取预防措施。

3.1 转义序列

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

十六进制转义序列可以转义有限范围内的代码点:从 U+00 U+FF,因为只允许使用 2 位数字。但是十六进制转义很好,因为它很短。

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

Unicode 转义序列可以转义有限范围内的代码点:从 U+0000U+FFFF(所有 BMP 代码点),因为只允许使用 4 位数字。大多数情况下,这足以表示常用的符号。

要在 JavaScript 文字中指示星形平面的符号,请使用两个连接的 unicode 转义序列(高代理和低代理),这将创建代理对:

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

代码点转义序列

ECMAScript 2015 提供了表示整个Unicode空间的代码点的转义序列:U+0000 U+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代码点。

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

字符串比较

JavaScript中的字符串是代码单元序列。可以合理地预期,字符串比较涉及对匹配的代码单元进行求值。

这种方法快速有效。它可以很好地处理“简单”字符串:

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

firstStrsecondStr字符串具有相同的代码单元序列。它们是相等的。

假设您要比较呈现的两个字符串,它们看起来相同但包含不同的代码单元序列。那么你可能会得到一个意想不到的结果,因为在比较中看起来相同的字符串并不相等:

渲染时 str1 str2 看起来相同,但具有不同的代码单元。
发生这种情况是因为 ç 字形可以通过两种方式构建:

  • 使用U+00E7,带有变音符的拉丁小写字母c
  • 或者使用组合字符序列:U+0063拉丁小写字母c,加上组合标记 U+0327 组合变音符。

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

规范化

规范化(Normalization)是将字符串转换为规范表示,以确保规范等效(和/或兼容性等效)字符串具有唯一表示。

换句话说,当字符串具有组合字符序列或其他复合结构的复杂结构时,您可以将其规范化为规范形式。规范化的字符串可以轻松比较或执行文本搜索等字符串操作。

Unicode标准附录#15提供了有关规范化过程的有趣细节。

在JavaScript中,要规范化字符串,请调用myString.normalize([normForm])方法,该方法在ES2015中提供。normForm是一个可选参数(默认为“NFC”),可以采用以下规范化形式之一:

  • 'NFC' 作为规范化形式的标准组合
  • 'NFD' 作为规范化形式规范分解
  • 'NFKC'作为规范化形式兼容性组合
  • 'NFKD'作为规范化形式兼容性分解

让我们通过应用字符串规范化来改进前面的示例,这将允许正确比较字符串:

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() 按预期返回 true

str1 不受规范化的影响,因为它已经是规范形式了。

规范化两个比较的字符串,以获得两个操作数上的规范表示似乎是合理的。

3.3 字符串长度

确定字符串长度的常用方法当然是访myString.length属性。此属性表示字符串具有的代码单元数。

属于BMP的代码点的字符串长度的计算通常按预期得到:

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

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

长度和代理对

当字符串包含代理对来表示星形代码点时,情况变得棘手。由于每个代理对包含 2 个代码单元(高代理和低代理),因此长度属性大于预期。

看一个例子:

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

str 字符串被渲染时,它包含 4 个符号 cat😹。然而,str.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

长度和组合标记

那么组合字符序列呢?因为每个组合标记都是一个代码单元,所以您可能会遇到相同的困难。

该问题在规范化字符串时得到解决。如果幸运的话,组合字符序列将规范化为单个字符。让我们试试:

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.length 为 5),即使渲染它也显示 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.length 的计算结果为 6。但是,drink4个符号。

规范化 Drink.normalize() 将组合序列 'e\u0327\u0301' 转换为两个字符 'ȩ\u0301' 的规范形式(通过仅删除一个组合标记)。遗憾的是,drink.normalize().length 的计算结果为 5,但仍然没有表示正确的符号数。

字符定位

由于字符串是一系列代码单元,因此通过索引访问字符串中的字符也存在困难。

当字符串仅包含 BMP 字符时(不包括从 U+D800U+DBFF 的高代理和从 U+DC00U+DFFF 的低代理),字符定位没有什么问题。

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

每个符号都使用单个代码单元进行编码,因此通过索引访问字符串字符是正确的。

字符定位和代理对

当字符串包含星形符号时,情况会发生变化。

星形符号使用 2 个代码单元(代理对)进行编码。因此通过索引访问字符串字符可能会返回一个分隔的高代理或低代理,它们是无效符号。

以下示例访问星形符号中的字符:

const omega = '\u{1D6C0} is omega';
console.log(omega);        // => '𝛀 is omega'
console.log(omega[0]);     // => '' (unprintable symbol)
console.log(omega[1]);     // => '' (unprintable symbol)

因为U+1D6C0大写字母OMEGA(MATHEMATICAL BOLD CAPITAL OMEGA)是一个星形字符,所以它使用2个代码单元的代理对进行编码。omega[0]访问高代理项代码单元,omega[1]访问低代理项,从而分离代理对。

在一个字符串中存在2种正确访问星形符号的可能性:

  • 使用字符串迭代器并生成符号数组[…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]);     // => 'é'

请注意,并非所有组合字符序列都具有作为单个符号的标准等价物。所以规范化字符串方案并不通用。
幸运的是,它应该适用于欧洲/北美语言的大多数情况。

正则表达式匹配

正则表达式和字符串一样,都是按照代码单元运行的。与之前描述的场景类似,这在处理代理对和使用正则表达式组合字符序列时会产生困难。

BMP 字符按预期匹配,因为单个代码单元表示一个符号:

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

greetings有3个字符,即3个代码单元。正则表达式 /.{3}/表达式能成功匹配。

在匹配星形符号(用 2 个代码单元的代理对编码)时,您可能会遇到困难:

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

smile字符串包含星形符号 U+1F600 GRINNING FACEU+1F600 使用代理对 0xD83D 0xDE00 进行编码。
然而,正则表达式 /^.$/期望匹配一个代码单元,所以失败了。
用星形符号定义字符类时情况更糟。 JavaScript 抛出一个错误:

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

星形代码点被编码为代理对。因此 JavaScript 使用代码单元 /[\uD83D\uDE00-\uD83D\uDE0E]/ 来表示正则表达式。每个代码单元都被视为模式中的一个单独元素,因此正则表达式忽略了代理对的概念。

字符类的 \uDE00-\uD83D 部分无效,因为 \uDE00 大于 \uD83D。结果,正则表达式产生错误。

正则表达式 u 标志

幸运的是,ECMAScript 2015 引入了一个有用的 u 标志,使正则表达式能够识别 Unicode。该标志可以正确处理星形符号。

您可以在正则表达式 /u{1F600}/u 中使用 unicode 转义序列。此转义比指示高代理和低代理对 /\uD83D\uDE00/ 更短。

让我们应用 u 标志,看看.运算符(包括量词 ?, +, * {3}, {3,}, {2,3})如何匹配星形符号:

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 标志,正则表达式都会将组合标记视为单独的代码单元。

如果需要匹配组合字符序列,则必须分别匹配基字符和组合标记。

看看下面的例子:

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é
然而,正则表达式匹配 'cafe\u0301' 作为 5 个元素的序列 /^.{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 字符,您可以使用 punycode 库或generate库生成专门的正则表达式。

希望这篇文章对你掌握Unicode有帮助!


yannisLee
0 声望0 粉丝

偶尔翻译一些精品文章