第 24 章。Unicode 与 JavaScript
目录
购买本书
(广告,请不要屏蔽。)

第 24 章。Unicode 与 JavaScript

本章简要介绍了 Unicode 以及 JavaScript 如何处理 Unicode。

Unicode 历史

Unicode 由 Joe Becker(施乐)、Lee Collins(苹果)和 Mark Davis(苹果)于 1987 年创立。其理念是创建一个通用的字符集,因为当时有许多不兼容的纯文本编码标准:8 位 ASCII 的众多变体、五大码(繁体中文)、GB 2312(简体中文)等等。在 Unicode 出现之前,不存在多语言纯文本标准,但存在富文本系统(例如苹果的 WorldScript),允许您组合多种编码。

Unicode 的第一个草案于 1988 年发布。之后工作继续进行,工作组也随之扩大。Unicode 联盟 于 1991 年 1 月 3 日成立。

Unicode 联盟是一个非营利性组织,致力于开发、维护和推广软件国际化标准和数据,特别是 Unicode 标准 [...]

Unicode 1.0 标准的第一卷于 1991 年 10 月发布,第二卷于 1992 年 6 月发布。

重要的 Unicode 概念

字符的概念看似简单,但它有很多方面。这就是 Unicode 成为如此复杂的标准的原因。以下是一些重要的基本概念:

字符和字素
这两个术语的含义非常相似。字符是数字实体,而字素是书面语言的原子单位(字母、印刷连字、汉字、标点符号等)。程序员以字符思考,而用户以字素思考。有时,多个字符用于表示单个字素。例如,我们可以通过组合字符 o 和字符 ^(抑扬符)来生成单个字素 ô。
字形
这是显示字素的具体方式。有时,相同的字素会根据其上下文或其他因素以不同的方式显示。例如,字素 fi 可以表示为字形 f 和字形 i,通过连字字形连接,或者不使用连字。
代码点
Unicode 通过称为代码点 的数字来表示它支持的字符。代码点的十六进制范围是 0x0 到 0x10FFFF(17 乘以 16 位)。
代码单元
为了存储或传输代码点,我们将它们编码为代码单元,即具有固定长度的数据片段。长度以位为单位,由编码方案决定,Unicode 有几种编码方案,例如 UTF-8 和 UTF-16。名称中的数字表示代码单元的长度,以位为单位。如果代码点太大而无法放入单个代码单元中,则必须将其分解为多个单元;也就是说,表示单个代码点所需的代码单元数量可能会有所不同。
BOM(字节顺序标记)

如果代码单元大于单个字节,则字节顺序很重要。BOM 是文本开头的一个伪字符(可能编码为多个代码单元),用于指示代码单元是大端序(最高有效字节优先)还是小端序(最低有效字节优先)。没有 BOM 的文本默认为大端序。BOM 还指示使用的编码;UTF-8、UTF-16 等的 BOM 不同。此外,如果 Web 浏览器没有关于文本编码的其他信息,它还可以作为 Unicode 的标记。但是,BOM 的使用频率不高,原因如下:

规范化
有时,相同的字素可以用多种方式表示。例如,字素 ö 可以表示为单个代码点,也可以表示为 o 后跟组合字符 ¨(分音符、双点)。规范化是关于将文本转换为规范表示;等效的代码点和代码点序列都转换为相同的代码点(或代码点序列)。这对于文本处理(例如,搜索文本)很有用。Unicode 指定了几种规范化。
字符属性

规范为每个 Unicode 字符分配了几个属性,其中一些属性列举如下:

  • 名称。英文名称,由大写字母 A-Z、数字 0-9、连字符 (-) 和 <空格> 组成。两个例子

    • “λ”的名称是“GREEK SMALL LETTER LAMBDA”。
    • “!”的名称是“EXCLAMATION MARK”。
  • 常规类别。将字符划分为字母、大写字母、数字和标点符号等类别。
  • 版本。该字符是在哪个版本的 Unicode 中引入的(1.0、1.1、2.0 等)?
  • 已弃用。是否不鼓励使用该字符?
  • 以及更多.

代码点

代码点的范围最初是 16 位。在 Unicode 2.0 版(1996 年 7 月)中,它被扩展了:现在它被划分为 17 个平面,编号从 0 到 16。每个平面包含 16 位(十六进制表示法:0x0000-0xFFFF)。因此,在下面的十六进制范围内,超过四个最低有效位的数字表示平面的编号。

  • 平面 0,基本多文种平面 (BMP):0x0000-0xFFFF
  • 平面 1,辅助多文种平面 (SMP):0x10000-0x1FFFF
  • 平面 2,辅助表意文字平面 (SIP):0x20000-0x2FFFF
  • 平面 3-13,未分配
  • 平面 14,辅助专用平面 (SSP):0xE0000-0xEFFFF
  • 平面 15-16,辅助专用区域 (SPUA A/B):0x0F0000-0x10FFFF

平面 1-16 称为辅助平面星体平面

Unicode 编码

UTF-32(Unicode 转换格式 32)是一种具有 32 位代码单元的格式。任何代码点都可以由单个代码单元编码,这使其成为唯一的固定长度编码;对于其他编码,编码一个点所需的单元数会有所不同。

UTF-16 是一种具有 16 位代码单元的格式,需要一到两个单元来表示一个代码点。BMP 代码点可以用单个代码单元表示。较高的代码点是 20 位(16 乘以 16 位),在减去 0x10000(BMP 的范围)之后。这些位被编码为两个代码单元(所谓的代理对):

前导代理
最高有效 10 位:存储在 0xD800-0xDBFF 范围内。也称为高代理代码单元
后导代理
最低有效 10 位:存储在 0xDC00-0xDFFF 范围内。也称为低代理代码单元

下表(改编自 Unicode 标准 6.2.0,表 3-5)直观地显示了位的分布方式

代码点UTF-16 代码单元

xxxxxxxxxxxxxxxx(16 位)

xxxxxxxxxxxxxxxx

pppppxxxxxxyyyyyyyyyy(21 位 = 5+6+10 位)

110110qqqqxxxxxx 110111yyyyyyyyyy(qqqq = ppppp - 1)

为了启用这种编码方案,BMP 有一个空洞,其中未使用的代码点的范围是 0xD800-0xDFFF。因此,前导代理、后导代理和 BMP 代码点的范围是不相交的,这使得解码在面对错误时更加稳健。以下函数将代码点编码为 UTF-16(稍后我们将看到一个使用它的例子)

function toUTF16(codePoint) {
    var TEN_BITS = parseInt('1111111111', 2);
    function u(codeUnit) {
        return '\\u'+codeUnit.toString(16).toUpperCase();
    }

    if (codePoint <= 0xFFFF) {
        return u(codePoint);
    }
    codePoint -= 0x10000;

    // Shift right to get to most significant 10 bits
    var leadingSurrogate = 0xD800 | (codePoint >> 10);

    // Mask to get least significant 10 bits
    var trailingSurrogate = 0xDC00 | (codePoint & TEN_BITS);

    return u(leadingSurrogate) + u(trailingSurrogate);
}

UCS-2 是一种已弃用的格式,它使用 16 位代码单元来表示(仅限!)BMP 的代码点。当 Unicode 代码点的范围扩展到 16 位以上时,UTF-16 取代了 UCS-2。

UTF-8 具有 8 位代码单元。它在传统的 ASCII 编码和 Unicode 之间架起了一座桥梁。ASCII 只有 128 个字符,其编号与前 128 个 Unicode 代码点相同。UTF-8 向后兼容,因为所有 ASCII 代码都是有效的代码单元。换句话说,0-127 范围内的单个代码单元编码相同范围内的单个代码点。此类代码单元的最高位为零。另一方面,如果最高位为 1,则后面将跟更多单元,为更高的代码点提供额外的位。这导致了以下编码方案:

  • 0000-007F:0xxxxxxx(7 位,存储在 1 个字节中)
  • 0080-07FF:110xxxxx,10xxxxxx(5+6 位 = 11 位,存储在 2 个字节中)
  • 0800-FFFF:1110xxxx,10xxxxxx,10xxxxxx(4+6+6 位 = 16 位,存储在 3 个字节中)
  • 10000-1FFFFF:11110xxx,10xxxxxx,10xxxxxx,10xxxxxx(3+6+6+6 位 = 21 位,存储在 4 个字节中)。最高代码点是 10FFFF,因此 UTF-8 还有一些额外的空间。

如果最高位不是 0,则零之前的 1 的数量表示序列中有多少个代码单元。初始代码单元之后的所有代码单元都具有位前缀 10。因此,初始代码单元和后续代码单元的范围是不相交的,这有助于从编码错误中恢复。

UTF-8 已成为最流行的 Unicode 格式。最初,它的流行是由于它与 ASCII 向后兼容。后来,由于它在操作系统、编程环境和应用程序中的广泛而一致的支持,它获得了更大的吸引力。

JavaScript 源代码和 Unicode

JavaScript 处理 Unicode 源代码的方式有两种:内部(解析期间)和外部(加载文件时)。

内部源代码

在内部,JavaScript 源代码被视为 UTF-16 代码单元序列。根据 ECMAScript 规范第 6 节

ECMAScript 源文本表示为 Unicode 字符编码(版本 3.0 或更高版本)中的字符序列。[...] 出于本规范的目的,ECMAScript 源文本假定为 16 位代码单元序列。[...] 如果实际源文本以 16 位代码单元以外的形式编码,则必须将其视为首先转换为 UTF-16。

在标识符、字符串字面量和正则表达式字面量中,任何代码单元也可以通过 Unicode 转义序列 \uHHHH 表示,其中 HHHH 是四个十六进制数字。例如:

> var f\u006F\u006F = 'abc';
> foo
'abc'

> var λ = 123;
> \u03BB
123

这意味着您可以在字面量和变量名中使用 Unicode 字符,而无需离开源代码中的 ASCII 范围。

在字符串字面量中,还可以使用另一种转义:十六进制转义序列,它使用两位十六进制数字表示 0x00-0xFF 范围内的代码单元。例如:

> '\xF6' === 'ö'
true
> '\xF6' === '\u00F6'
true

外部源代码

虽然内部使用 UTF-16,但 JavaScript 源代码通常不以该格式存储。当 Web 浏览器通过 <script> 标签加载源文件时,它会按如下方式确定编码

我的建议可以总结如下

  • 对于您自己的应用程序,您可以使用 Unicode。但是您必须将应用程序的 HTML 页面的编码指定为 UTF-8。
  • 对于库,最安全的方法是发布 ASCII(7 位)代码。

一些代码压缩工具可以 将包含 7 位以上 Unicode 代码点的源代码转换为“7 位干净”的源代码。它们通过用 Unicode 转义序列替换非 ASCII 字符来做到这一点。例如,以下调用 UglifyJS 会转换文件 test.js

uglifyjs -b beautify=false,ascii-only=true test.js

文件 test.js 如下所示

var σ = 'Köln';

UglifyJS 的输出如下所示

var \u03c3="K\xf6ln";

考虑以下负面示例。有一段时间,库 D3.js 以 UTF-8 发布。当从编码不是 UTF-8 的页面加载它时,这会导致 错误,因为代码包含如下语句

var π = Math.PI, ε = 1e-6;

标识符 π 和 ε 未正确解码,并且未被识别为有效的变量名。此外,一些包含 7 位以上代码点的字符串文字也没有被正确解码。作为解决方法,您可以通过向 <script> 标签添加适当的 charset 属性来加载代码

<script charset="utf-8" src="d3.js"></script>

JavaScript 字符串和 Unicode

JavaScript 字符串是 UTF-16 代码单元序列。根据 ECMAScript 规范,第 8.4 节

当字符串包含实际文本数据时,每个元素都被视为单个 UTF-16 代码单元。

转义序列

如前所述,您可以在 字符串文字中使用 Unicode 转义序列和十六进制转义序列。例如,您可以通过将 o 与分音符(代码点 0x0308)组合来生成字符 ö:

> console.log('o\u0308')
ö

这适用于 JavaScript 命令行,例如 Web 浏览器控制台和 Node.js REPL。您还可以将这种字符串插入网页的 DOM 中。

通过转义序列引用星形平面字符

网络上有 许多不错的 Unicode 符号表。看看 Tim Whitlock 的 “Emoji Unicode 表”,并对现代 Unicode 字体中有多少符号感到惊讶。表中的符号都不是图像;它们都是字体字形。假设您想通过 JavaScript 显示一个位于星形平面中的 Unicode 字符(显然,这样做存在风险:并非所有字体都支持所有此类字符)。例如,考虑一头牛,代码点 0x1F404:

您可以复制该字符并将其直接粘贴到您的 Unicode 编码的 JavaScript 源代码中

JavaScript 引擎将解码源代码(通常为 UTF-8),并创建一个包含两个 UTF-16 代码单元的字符串。 或者,您可以自己计算两个代码单元并使用 Unicode 转义序列。有一些 Web 应用程序可以执行此计算,例如:

先前定义的函数 toUTF16 也执行此操作

> toUTF16(0x1F404)
'\\uD83D\\uDC04'

UTF-16 代理对 (0xD83D, 0xDC04) 确实编码了牛

计算字符数

如果字符串 包含代理对(编码单个代码点的两个代码单元),则 length 属性不再计算字素。它计算代码单元:

这可以通过库来解决,例如 Mathias Bynens 的 Punycode.js,它与 Node.js 捆绑在一起

> var puny = require('punycode');
> puny.ucs2.decode(str).length
1

Unicode 规范化

如果您想在 字符串中搜索或比较字符串,则需要进行规范化——例如,通过库 unorm(由 Bjarke Walling 开发)。

JavaScript 正则表达式和 Unicode

JavaScript 正则表达式(请参阅 第 19 章)中对 Unicode 的支持非常有限。例如,无法匹配 Unicode 类别,例如“大写字母”。

行终止符会影响 匹配。行终止符是以下四个字符之一,在下表中指定:

代码单元名称字符转义序列

\u000A

换行符

\n

\u000D

回车符

\r

\u2028

行分隔符

\u2029

段落分隔符

以下正则表达式结构基于 Unicode

  • \s \S(空白、非空白)具有基于 Unicode 的定义

    > /^\s$/.test('\uFEFF')
    true
  • .(点)匹配除行终止符以外的所有代码单元(不是代码点!)。请参阅下一节,了解如何匹配任何代码点。
  • 多行模式 /m:在多行模式下,断言 ^ 匹配输入的开头和行终止符之后。断言 $ 匹配行终止符之前和输入的结尾。在非多行模式下,它们分别仅匹配输入的开头或结尾。

其他重要的字符类具有基于 ASCII 而不是 Unicode 的定义

  • \d \D(数字、非数字):数字等效于 [0-9]
  • \w \W(单词字符、非单词字符):单词字符等效于 [A-Za-z0-9_]
  • \b \B(在单词边界处、单词内部):单词是单词字符的序列 ([A-Za-z0-9_])。例如,在字符串 'über' 中,字符类转义序列 \b 将字符 b 视为单词的开头

    > /\bb/.test('über')
    true

匹配任何代码单元和任何代码点

要匹配任何 代码单元,您可以使用 [\s\S];请参阅 原子:常规

要匹配任何代码点,您需要使用:[20]

([\0-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF])

前面的模式的工作原理如下

([BMP code point]|[leading surrogate][trailing surrogate])

由于所有这些范围都不相交,因此该模式将在格式良好的 UTF-16 字符串中正确匹配代码点。

一些库 有助于 在 JavaScript 中处理 Unicode:

  • Regenerate 有助于生成类似于前面的范围,用于匹配任何代码单元。它旨在用作构建工具的一部分,但也适用于动态地尝试事物。
  • XRegExp 是一个正则表达式库,它有一个 官方插件,用于通过以下三种结构之一匹配 Unicode 类别、脚本、块和属性

    \p{...} \p{^...} \P{...}

    例如,\p{Letter} 匹配各种字母表中的字母,而 \p{^Letter}\P{Letter} 都匹配所有其他代码点。 第 30 章 简要概述了 XRegExp。

  • ECMAScript 国际化 API(请参阅 ECMAScript 国际化 API)提供了 Unicode 感知的排序规则(字符串的排序和搜索)等等。

有关 Unicode 的更多信息,请参阅 以下内容:

有关 JavaScript 中 Unicode 支持的信息,请参阅

致谢

以下人员为本章做出了贡献:Mathias Bynens (@mathias)、Anne van Kesteren ‏(@annevk) 和 Calvin Metcalf ‏(@CWMma)。



[20] 严格来说,是任何 Unicode 标量值

下一页:25. ECMAScript 5 中的新增功能