Better

Ethan的博客,欢迎访问交流

字符集与字符编码

在日常开发中,UTF-8可以说是最常见的字符集了,那么为啥都首选它呢?他和其他字符集又有什么区别呢,今天简单了解一下。

比特、字节、字符

首先我们需要了解一下计算存储的几个概念

  • 比特位:比特位即 Bit ,是计算机最小的存储单位。以 0 或 1 来表示比特位的值。也是网络信息传输的基本单位。
  • 字节:8个比特位表示一个字节
  • 字符:字符是可使用多种不同字符方案或代码页来表示的抽象实体。

在计算机内部,所有的信息最终都表示为一个二进制的序列。每一个二进制位(bit)有 0 和 1 两种状态,因此八个二进制位就可以组合出 256 种状态,这被称为一个字节( byte )。也就是说,一个字节一共可以用来表示 256 种不同的状态或者符号。如果我们制作一张对应表格,对于每一个 8 位二进制序列,都对应唯一的一个符号。每一个状态对应一个符号,就是 256 个符号,从 0000000 到 11111111 。

文化差异

上个世纪 60 年代,美国制定了一套字符编码,对英语字符与二进制位之间的关系,做了统一规定。这被称为 ASCII 码,一直沿用至今。

ASCII(American Standard Code for Information Interchange) 是最早的字符编码之一,它使用 7 位二进制数表示 128 个字符,包括字母、数字、标点符号和控制字符。

但是 ASCII 码对于汉字无法表示,因此我们融合 ASCII 中的数字、标点、字母,还把数字符号、罗马希腊字母与 7000 多个简体汉字整合进去,产生了 GB2312 , GB2312 是对 ASCII 的中文拓展。

后来又发现中华文化实在是博大精深,汉字太多,原来的编码不太够用,因此再做拓展,此拓展方案叫做 GBK, GBK 中融合了 GB2312 的所有内容,同时新增 20000 个新的汉字(包括繁体字)和符号。再后来少数民族也用上了电脑,还得接着拓展。 GB18030 便由此诞生。从此中华民族灿烂的汉字文化,便在计算机中得以传承啦。

统一路程

如果每个国家都有自己的文字编码,每个国家的编码还都不一样,为何不建立一个全世界统一的字符集呢

在20世纪80、90年代就有两个组织在做这个事情:

  • 国际标准化组织( ISO )
  • 统一码联盟(Unicode Consortium)

两个机构的参与者意识到,世界上并不需要两个不兼容的字符集,因此双方开始合作。从 Unicode 2.0 开始,Unicode 采用了与 ISO 10646-1 相同的字库和字码;ISO 也承诺,ISO 10646 将不会替超出 U+10FFFF( Unicode 编码以 U+ 开头) 的 UCS-4 编码赋值,以使得两者保持一致。后来两个项目仍都独立存在,并独立地公布各自的标准。不过由于 Unicode- 这一名字比较好记,因而它使用更为广泛。

字符集从 0 开始为每个符号制定一个编码,叫码点。最新的 Unicode 版本一共有 109449 个符号。这些符号分区定义,每个区称作一个面,一共有 17(2^5)个面,每个面可以存放 65536(2^16) 个字符,也就是说一共可以存 2^21 个字符. 17 个面中有一个基本平面( BMP ),16个辅助平面( SMP )。

总而言之 Unicode 指得是字符的集合,而每个字符如何表示,那就需要编码方法,我们知道的编码方法有 UTF-32 、UTF-16 、UTF-8、UCS-2 等等,他们到底是什么?又有什么关系呢?

字符集

定义了一种语言或符号系统中可能使用的所有字符。比如,ASCII(美国信息交换标准代码)字符集定义了在英语中常用的字符,而 Unicode 字符集则包含了世界上几乎所有的语言所使用的字符。

字符编码

将字符集中的字符映射到二进制数据的方法。它指定了如何将字符表示为计算机能够理解的数字形式。例如,ASCII字符集使用 7 位二进制数来表示 128 个字符,而 Unicode 字符集则使用不同的编码方案来支持更多字符。

Code Points

首先想到的是,它可能使用 32 位(4字节)来表示它的每个字符,这可能是它能够支持这么多字符的原因。但事实并非如此,它不使用字节来表示字符,而是引入了码点(Code Points)概念,Code Points 是表示数字到字符的映射。因此 Unicode 本身只是定义了一个字符集,不是一个编码方案。

因此我们还需要一种方式将 Code Points 编码为 bits 的过程,这就是 UTF encodings 干的事情。

Unicode is a large table mapping characters to numbers and the different UTF encodings specify how these numbers are encoded as bits.

UTF-32 与 UCS-4

在 Unicode 与 UCS 合并之前已经产生了 UCS-4 编码方式。 UCS-4 还是使用 32 位( 4 字节)来表示每个 Unicode 编码,整个代码值表示所述代码空间范围介于 0 和 十六进制 7FFFFFFF 之间。但实际使用范围不超过 0x10FFFF ,为了兼容 Unicode 标准产生了 UTF-32 .他的码值与 UCS-4 一致,不过编码空间被限定在 0~0x10FFFF 之间,因此可以说 UTF-32 是 UCS-4 的子集。

UCS-4 存有明显的问题:由于 UTF-32 使用 4个字节来描绘每个字符,对于同样的英文文本来说,它是 ASCII 码使用空间的四倍。因此在HTML5网页标准中就明确规定,不得使用 UTF-32 进行编码。

UTF-16 与 UCS-2

UTF-16 的编码方式介于 UTF-32 和 UTF-8 之间,同时结合了定长和变长两种编码的特点。上文中提到过 Unicode 编码点有 17 个平面,一个基本平面,16 个辅助平面,UTF-16 的编码规则很简单:基本平面的字符占用两个字节,辅助平面的字符占用四个字节。就是说 UTF-16 编码要么是两个字节(U+0000到U+FFFF),要么是4个字节(U+010000到U+10FFFF).

UCS-2 又是什么,跟 UTF-16 又有什么关系呢?

JavaScript 采用了 Unicode 字符集。但是只支持一种编码方式。JS 最先采用的编码既不是 UTF-16 也不是 UTF-32 或 UTF-8 ,而是 UCS-2 。UTF-16 明确宣布是 UCS-2 的超集。

UTF-16 中基本平面字符延用 UCS-2 编码。辅助平面字符定义了 4 个字节的表示方法。UCS-2 被整合进了 UTF-16 。并且由于 JavaScript 诞生的时候还没有 UTF-16 编码( UCS-2 于 1990 年公布。而 UTF-16 于 1996 年公布),因此 JS 最先采用了已经被淘汰的 UCS-2。

JS 早期使用 UCS-2 编码,后来使用 UTF-16。UTF-16 是 UCS-2 的超集。

那么问题就显而易见了。JS 只能处理 UCS-2 编码,造成所有字符在这门语言中都是两个字节,如果是四个字节的字符。会被当做两个双字节的字符处理。

UTF-8

重要的 UTF-8 来了,对于英语国家来说,一个字符完全可以单字节编码。使用以上的两种编码方式是对带宽的极大浪费。UTF-8 便由此而生!

UTF-8 的编码规则:

  • 对于 ASCII 码中的符号,使用单字节编码,其编码值与 ASCII 值相同。其中 ASCII 值的范围为 0~0x7F ,所有编码的二进制值中第一位为 0(这个正好可以用来区分单字节编码和多字节编码)。
  • 其它字符用多个字节来编码(假设用 N 个字节),多字节编码需满足:第一个字节的前 N 位都为 1,第 N+1 位为 0,后面 N-1 个字节的前两位都为 10,这 N 个字节中其余位全部用来存储 Unicode 中的码位值。

空间大小

1280X1280.PNG

UTF-8 Scheme

由于 ASCII 字符的第一个位被设置为零,所以第一个位被设置为 1 的字节是不使用的,可以特别使用。 举例一下 UTF-8 读取规则,假设某个字节的格式为 110xxxxx。

  • 当字节以 1 开头时,统计它和下一个 0 之间有多少个 1,个数记为 N
  • 此时 N 标识其后有 N 个字节加上当前自己共同组成一个 Unicode 字符
  • 110xxxxx 形式的字节表示 Unicode 字符的前五位存储在这个字节的末尾,其余的位存储在下一个字节中(字节以 10xxxxxx 的形式存储)

多字节序列为如下形式中的一种

110xxxxx 10xxxxxx
1110xxxx 10xxxxxx 10xxxxxx
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

由此可见 UTF-8 最多可以标识 2^21 个字符,但其实 UTF-8 编码模式可以扩展为允许n = 4、5或6,但这是不必要的。 UTF-8 允许您将普通的 ASCII 文件视为使用 UTF-8 编码的 Unicode 文件。所以在空间方面,UTF-8 和 ASCII 一样有效。但不是在时间方面。如果软件知道一个文件实际上是 ASCII,它就可以按表面值取每个字节,而不必检查它是否是多字节序列的第一个字节。

JavaScript 字符相关

查看 MDM 大概可以看到如下函数

length

该属性返回字符串中的码元数量。JavaScript 使用 UTF-16 编码,其中每个 Unicode 字符可以编码为一个或两个码元,因此 length 返回的值可能与字符串中 Unicode 字符的实际数量不匹配。对于拉丁文、西里尔文、众所周知的 CJK 字符等常见脚本,这应该不是问题,但如果你正在处理某些脚本,例如表情符号、数学符号或生僻字,你可能需要考虑码元和字符之间的差异。

'123我'.length // 4
'👨👩👧👦'.length // 8
'😁'.length // 2

String.prototype.at

at() 方法接受一个整数值,并返回一个新的 String,该字符串由位于指定偏移量处的单个 UTF-16 码元组成。该方法允许正整数和负整数。负整数从字符串中的最后一个字符开始倒数。

'123我'.at(0) // 1
'😁'.at(0) // \uD83D

String.prototype.charAt

String 的 charAt() 方法返回一个由给定索引处的单个 UTF-16 码元构成的新字符串。 和 at() 类似,但不支持负整数。和使用中括号访问相似,主要区别在于

  • charAt() 尝试将 index 转换为整数,而方括号表示法不会,直接使用 index 作为属性名。
  • 如果 index 超出范围,charAt() 返回一个空字符串,而方括号表示法返回 undefined。

charAt() 可能会返回孤项代理,这些代理项不是有效的 Unicode 字符。

const str = "𠮷𠮾";
console.log(str.charAt(0)); // "\ud842",这不是有效的 Unicode 字符
console.log(str.charAt(1)); // "\udfb7",这不是有效的 Unicode 字符

要获取给定索引处的完整 Unicode 码位,请使用按 Unicode 码位拆分的索引方法,例如 String.prototype.codePointAt() 和将字符串展开为 Unicode 码位数组。

const str = "𠮷𠮾";
console.log(String.fromCodePoint(str.codePointAt(0))); // "𠮷"
console.log([...str][0]); // "𠮷"

String.prototype.charCodeAt

String 的 charCodeAt() 方法返回一个整数,表示给定索引处的 UTF-16 码元,其值介于 0 和 65535 之间。

Unicode 码位的范围是 0 到 1114111(0x10FFFF)。charCodeAt() 方法始终返回一个小于 65536 的值,因为更高的码位由一对 16 位代理伪字符(surrogate pseudo-character)来表示。因此,为了获取值大于 65535 的完整字符,不仅需要检索 charCodeAt(i),而且还要使用 charCodeAt(i + 1)(就像操作具有两个字符的字符串一样),或者使用 codePointAt(i) 方法。

"ABC".charCodeAt(0); // 65
const str = "𠮷𠮾";
console.log(str.charCodeAt(0)); // 55362 或 d842

String.prototype.codePointAt

String 的 codePointAt() 方法返回一个非负整数,该整数是从给定索引开始的字符的 Unicode 码位值。请注意,索引仍然基于 UTF-16 码元,而不是 Unicode 码位

字符串中的字符从左到右进行索引。第一个字符的索引为 0,而字符串 str 中最后一个字符的索引为 str.length - 1。

Unicode 码位范围从 0 到 1114111(0x10FFFF)。在 UTF-16 中,每个字符串索引是一个取值范围为 0 – 65535 的码元。较高的码位由一个由一对 16 位代理伪字符表示。因此,codePointAt() 返回的码位可能跨越两个字符串索引。

返回值:一个非负整数,表示给定 index 处字符的码位值。

  • 如果 index 超出了 0 – str.length - 1 的范围,codePointAt() 返回 undefined。
  • 如果 index 处的元素是一个 UTF-16 前导代理(leading surrogate),则返回代理对的码位。
  • 如果 index 处的元素是一个 UTF-16 后尾代理(trailing surrogate),则只返回后尾代理的码元。
"ABC".codePointAt(0); // 65
"ABC".codePointAt(0).toString(16); // 41

"😍".codePointAt(0); // 128525
"\ud83d\ude0d".codePointAt(0); // 128525
"\ud83d\ude0d".codePointAt(0).toString(16); // 1f60d

"😍".codePointAt(1); // 56845
"\ud83d\ude0d".codePointAt(1); // 56845
"\ud83d\ude0d".codePointAt(1).toString(16); // de0d

因为使用字符串索引进行循环会导致同一码位被访问两次(一次是前导代理,一次是后尾代理),而第二次调用 codePointAt() 时只返回后尾代理项,所以最好避免使用索引进行循环。

相反,可以使用 for...of 语句或字符串展开语法,这两种方法都会调用字符串的 @@iterator,从而按照码位进行迭代。然后,可以使用 codePointAt(0) 获取每个元素的码位值。

for (const codePoint of str) {
  console.log(codePoint.codePointAt(0).toString(16));
}
// '1f40e'、'1f471'、'2764'

[...str].map((cp) => cp.codePointAt(0).toString(16));
// ['1f40e', '1f471', '2764']

String.prototype.normalize

返回该字符串的 Unicode 标准化形式。

参数 form 可选:是 "NFC"、"NFD"、"NFKC" 或 "NFKD" 其中之一,用于指定 Unicode 标准化形式。如果省略或为 undefined,则使用 "NFC"。

Unicode 为每个字符分配一个唯一的数值,称为码位。例如,字母 "A" 的码位被表示为 U+0041。然而,有时候一个抽象字符可以由一个或多个码位或码位序列来表示,比如字母 "ñ" 可以被以下任意一种方式表示:

  • 单个码位 U+00F1。
  • 字母 "n" 的码位(U+006E)后跟组合波浪符的码位(U+0303)。 由于码位不同,字符串比较不会将它们视为相等。而且由于每个版本中的码位数量不同,它们甚至具有不同的长度
const string1 = "\u00F1"; // ñ
const string2 = "\u006E\u0303"; // ñ

console.log(string1 === string2); // false
console.log(string1.length); // 1
console.log(string2.length); // 2

normalize() 方法将字符串转换为一种标准化形式,这有助于解决这个问题,该标准化形式适用于表示相同字符的所有码位序列。有两种主要的标准化形式,一种基于规范等价性,另一种基于兼容性

在 Unicode 中,如果两个码位序列表示相同的抽象字符,并且它们应该始终具有相同的视觉外观和行为(例如,它们应该始终以相同的方式进行排序),则这两个序列具有规范等价性。

你可以使用 normalize() 方法并使用 "NFD" 或 "NFC" 参数来生成一个字符串的形式,该形式对于所有规范等价的字符串都是相同的。

let string1 = "\u00F1"; // ñ
let string2 = "\u006E\u0303"; // ñ

string1 = string1.normalize("NFD");
string2 = string2.normalize("NFD");

console.log(string1 === string2); // true
console.log(string1.length); // 2
console.log(string2.length); // 2

请注意,在 "NFD" 下,标准化形式的长度为 2。这是因为 "NFD" 给出了规范形式的分解版本,其中单个码位被拆分为多个组合码位。对于 "ñ",其分解的规范形式是 "\u006E\u0303"。

你可以指定 "NFC" 来获取组合的规范形式,其中多个码位在可行的情况下被替换为单个码位。对于 "ñ",其组合的规范形式是 "\u00F1":

let string1 = "\u00F1"; // ñ
let string2 = "\u006E\u0303"; // ñ

string1 = string1.normalize("NFC");
string2 = string2.normalize("NFC");

console.log(string1 === string2); // true
console.log(string1.length); // 1
console.log(string2.length); // 1
console.log(string2.codePointAt(0).toString(16)); // f1

关于 normalize 方法,MDN 上还有更详细的解释,推荐去看看,就不复制粘贴了。

String.fromCharCode

String.fromCharCode() 静态方法返回由指定的 UTF-16 码元序列创建的字符串。

由于 fromCharCode() 仅适用于 16 位的值(与 \u 转义序列相同),因此需要使用代理对来返回补充字符。例如,String.fromCharCode(0xd83c, 0xdf03) 和 "\ud83c\udf03" 都返回码位 U+1F303 "Night with Stars"。虽然补充码位值(例如 0x1f303)与表示它的两个代理值(例如 0xd83c 和 0xdf03)之间存在数学关系,但每次使用补充码位时都需要额外的步骤来计算或查找代理对值。出于这个原因,使用 String.fromCodePoint() 更方便,它可以根据实际的码位值返回补充字符。

// 在 UTF-16 中,BMP 字符使用单个码元
String.fromCharCode(65, 66, 67); // 返回 "ABC"
String.fromCharCode(0x2014); // 返回 "—"
String.fromCharCode(0x12014); // 也返回 "—";数字 1 被截断并忽略
String.fromCharCode(8212); // 也返回 "—";8212 是 0x2014 的十进制表示

// 在 UTF-16 中,补充字符需要两个码元(即一个代理对)
String.fromCharCode(0xd83c, 0xdf03); // 码位 U+1F303 "Night with
String.fromCharCode(55356, 57091); // Stars" == "\uD83C\uDF03"

String.fromCharCode(0xd834, 0xdf06, 0x61, 0xd834, 0xdf07); // "\uD834\uDF06a\uD834\uDF07"

String.fromCodePoint

将根据指定的码位序列返回一个字符串。

String.fromCodePoint(42); // "*"
String.fromCodePoint(65, 90); // "AZ"
String.fromCodePoint(0x404); // "\u0404" === "Є"

与 fromCharCode() 的比较:String.fromCharCode() 方法无法通过指定其码位来返回补充字符(即码位 0x010000 至 0x10FFFF)。相反,它需要使用 UTF-16 代理对来返回补充字符。

参考来源



留言