前言
提出一个小小的问题。大家按照自己的开发语言的特性,想想结果是啥?
"🤦🏼♂️"这个Emoji的长度是多少?
如果,现在你用电脑阅读本文,你可以轻松的打开xx PlayGround(xx可以为Js/Java/Rust等)。然后会得到属于自己语言的结果。
如果,你现在手头没电脑,无法亲自验证,我来直接告诉你答案。上述Emoji在每种语言环境下的结果都不统一。(当然,有些语言内核使用的机制一样,结果可能也一样)。
也就是说,在编程层面,这不是一种 「所见即所得」的表现形式。大家这里可能会纳闷了,我要知道这个有啥?现在举一个例子,在前端页面中,我们总是会有统计用户字数的输入框,但是由于用户输入了Emoji,从用户的角度来看,这就是一个字符,但是在编程层面,如果不做一次解析的话,我们会得到千奇百怪的答案。
然后,我们再来一个让人匪夷所思的例子。在浏览器中,尝试复制如下代码,然后进行观察答案。结果是不是又再一次颠覆你的所学。
"Å" === "Å";
平时,我们时不时的会提到UTF-8/UTF-16/UTF-32它们到底是个啥?又有啥关系和区别呢?
还有其他的例子就不一一列举了。之所以会出现这么多让人匪夷所思的结果。一切的根源都是Unicode的闹的。
所以,今天我们就来谈谈这是何方神圣。
在2000多年前,我们那迷人的老祖宗,秦始皇,就实现了「车同轨,书同文」,划破「地域障碍」,从而给不同地方的人在交流上开辟了新的空间。虽然,有些地方还存在「十里不同音,百里不通俗」的情况(我老家山西就是这种情况)。但是,在官方层面或者书面层面上,大家可以沟通无阻。
好了,天不早了,干点正事哇。
我们能所学到的知识点
1. 前置知识点
「前置知识点」,只是做一个概念的介绍,不会做深度解释。因为,这些概念在下面文章中会有出现,为了让行文更加的顺畅,所以将本该在文内的概念解释放到前面来。「如果大家对这些概念熟悉,可以直接忽略」同时,由于阅读我文章的群体有很多,所以有些知识点可能「我视之若珍宝,尔视只如草芥,弃之如敝履」。以下知识点,请「酌情使用」。
ASCll
ASCII[1](American Standard Code for Information Interchange)的缩写,发音为ask-key。ASCII是一种用于表示字符的7位标准编码,其中包括字母、数字和标点符号。
图片
7 位编码允许计算机编码总共128个字符,包括数字 0-9、大写和小写字母 A-Z 以及一些标点符号。然而,这 128 位编码仅适用于英语用户。
ASCII 的功能
ASCII 在计算机系统中的工作原理
当我们按下键盘上的键,例如字母D时,电子信号被发送到计算机的CPU进行处理和存储在内存中。「每个字符都被转换为其对应的二进制形式」。计算机将字母处理为一个字节,实际上是一系列电子状态的开和关。当计算机完成处理字节后,系统中安装的软件将字节转换回,并在屏幕上显示。字母 D 被转换为01000100。
TextEncoder 和 TextDecoder
TextEncoder 和 TextDecoder 是 JavaScript 中用于处理字符编码的「内置对象」。它们通常用于在不同字符编码之间进行文本的编码和解码。
TextEncoder
- TextEncoder 是用于「将字符串文本编码为字节数组」(通常是 UTF-8 编码)的对象。
- 它提供了一个 encode() 方法,接受一个字符串作为参数,并返回一个包含字节的 Uint8Array 对象。
- TextEncoder 用于将文本数据转换为字节数据,以便在网络传输、文件读写或其他需要字节数据的情况下使用。
示例:
const encoder = new TextEncoder();
const text = "前端柒八九!";
const bytes = encoder.encode(text); // 将文本编码为字节数组
TextDecoder
- TextDecoder 是用于将字节数组解码为字符串文本的对象。
- 它提供了一个 decode() 方法,接受一个包含字节的 Uint8Array 对象,并返回相应的字符串。
- TextDecoder 用于将字节数据还原为文本,通常用于处理来自网络请求或文件的字节数据。
示例:
const decoder = new TextDecoder("UTF-8");
const bytes = new Uint8Array([
72, 101, 108, 108, 111, 44, 32, 87, 111, 114, 108, 100, 33,
]);
const text = decoder.decode(bytes); // 将字节数组解码为字符串
这些对象在处理「多语言文本」、「字符编码转换」和处理「国际化内容」时非常有用,使 JavaScript 能够处理不同字符编码之间的数据转换。
Emoji
Emoji 是可以插入文字的图形符号。
图片
它是一个日语词,e表示"絵",moji表示"文字"。连在一起,就是"絵文字"。
2010 年,Unicode 开始为 Emoji 分配码点。也就是说,「现在的 Emoji 符号就是一个文字」,它会被渲染为图形。
图片
想了解更多,可以翻阅Emoji 简介[2]
2. Unicode 是个啥?
Unicode是一个旨在统一所有人类语言(包括过去和现在的语言)并使它们与计算机兼容的标准。
Unicode 是一个将「不同字符分配给唯一编号的表格」。
例如:
- 拉丁字母 A 被分配编号 65。
- 阿拉伯字母 Seen س是 1587。
- 片假名字母 Tu ツ 是 12484
- 音乐符号 G 调号 𝄞 是 119070。
- 💩 是 128169。
Unicode 将这些编号称为「码位」(code points)。
由于这套准则是全球都认准的,所以我们采用这套规则,就可以达到「书同文」的情况,来自不同语言环境下的人,可以阅读彼此的文本。
有如下的关系链子。 一个Unicode对应着一个字符,并且该字符拥有几乎唯一的码位。
Unicode === 字符 ⟷ 码位.
Unicode 有多大?
目前,「最大的已定义码位」是0x10FFFF。(0x10FFFF 是一个十六进制数,将其转换为十进制,其值为 1,114,111。)这给我们提供了大约 110 万个码位的空间。
目前已定义了约 15%(约 170,000 个),另外 11%(为私人使用)已被保留。其余约 800,000 个码位目前尚未分配,它们可能在未来成为字符。
大致如下图所示:
图片
- 大正方形 包含 65,536 个字符。
- 小正方形 包含 256 个字符。
- 整个 ASCII 字符集仅占位于左上角的小红色正方形的一半。
私人使用区(Private Use)
私人使用区是为应用程序开发人员保留的码位,不会由 Unicode 本身定义。
例如,Unicode 中没有为苹果标志保留位置,因此苹果将它放在了 U+F8FF,这位于私人使用区。在任何其他字体中,它将呈现为缺失的字符 ,但在与 macOS 一起提供的字体中,我们将看到苹果图标。
私人使用区主要用于「图标字体」:
上面的图标都是文本格式
U+1F4A9 是什么意思?
这是一种写码位值的约定。前缀 U+表示 Unicode,而 1F4A9 是一个「十六进制的码位编号」。
U+1F4A9 具体表示的是 💩。(是不是我们多了一种很委婉的"表扬别人"方式)
3. UTF-8 又是什么?
UTF-8 是一种「编码方式」。
编码是我们将码位存储在内存中的方法。在互联网和许多操作系统中,UTF-8是「默认的文本编码」。
最简单的 Unicode 编码是 UTF-32。它将码位简单地「存储为 32 位整数」。因此,U+1F4A9 变成了 00 01 F4 A9,占用了「四个字节」。UTF-32 中的「任何其他码位也将占用四个字节」。由于最高定义的码位是 U+10FFFF,因此任何码位都能够容纳。
- UTF-8通常用于存储和传输文本
- UTF-16用于某些操作系统和编程语言
- UTF-16被许多系统采用。其中包括 Microsoft Windows、Objective-C、Java、JavaScript、.NET、Python 2等
- UTF-32适用于需要直接操作Unicode代码点的情况
UTF-8 有多少字节?
UTF-8 是一种「可变长度」的编码方式。
一个码位可能被编码为「一个到四个字节」的序列。
以下是 UTF-8 编码的表示形式,「根据不同的码位范围使用不同数量的字节」
码位范围 |
Byte 1 |
Byte 2 |
Byte 3 |
Byte 4 |
U+0000..007F |
0xxxxxxx |
|||
U+0080..07FF |
110xxxxx |
10xxxxxx |
||
U+0800..FFFF |
1110xxxx |
10xxxxxx |
10xxxxxx |
|
U+10000..10FFFF |
11110xxx |
10xxxxxx |
10xxxxxx |
10xxxxxx |
这些规则描述了如何将不同码位范围内的 Unicode 字符编码为 UTF-8 字节序列。
如果将这些内容与 Unicode 表结合起来,我们将看到
- 英语使用 1 个字节进行编码,
- 西里尔字母、拉丁欧洲语言、希伯来语和阿拉伯语需要 2 个字节,
- 中文、日语、韩语、其他亚洲语言和表情符号需要 3 或 4 个字节。
以下是一些重要的要点:
任何纯 ASCII 文本也是有效的 UTF-8 文本,而且「只使用码位 0..127 的 UTF-8 文本可以直接读取为 ASCII」。
-
对于像 HTML 标签或 JSON 这样的技术字符串来说,这是有意义的。
第三,UTF-8 内置了「错误检测」和「恢复功能」。
-
第一个字节的前缀总是与第 2 到第 4 个字节不同。这样,我们始终可以确定是否正在查看完整和有效的 UTF-8 字节序列,或者是否有遗漏。
-
然后,我们可以通过向前或向后移动,直到找到正确序列的开头来进行纠正。
还有一些重要的结论:
- 我们「无法通过计算字节来确定字符串的长度」。
- 我们「无法随机跳到字符串的中间并开始阅读」。
- 我们无法通过在任意字节偏移处进行「切割来获取子字符串」,可能会切断字符的一部分。
如果硬要这么做的话,系统会给你一个�。
“�”是什么?
U+FFFD,即「替换字符」(Replacement Character),只是 Unicode 表中的另一个码位。应用程序和库可以在检测到 Unicode 错误时使用它。
如果将码位的一半切掉,那么另一半也就没什么用了,除了显示错误。这时就会使用�。
JS 版本
const text = "前端柒八九";
const encoder = new TextEncoder();
const bytes = encoder.encode(text);
const partial = bytes.slice(0, 11);
const decoder = new TextDecoder("UTF-8");
const result = decoder.decode(partial);
console.log(result); // 输出 "前端柒�"
Rust 版本
fn main() {
let text = "前端柒八九";
let bytes = text.as_bytes();
let partial = &bytes[0..11];
let result = String::from_utf8_lossy(partial);
println!("{}", result); // 输出 "前端柒�"
}
在 JavaScript 中使用 TextEncoder 和 TextDecoder 来处理编码,而在 Rust 中使用 String::from_utf8_lossy 来处理字节。它们的目标是在 UTF-8 编码中处理文本并「截取部分字节」。
4. UTF-32 问题
UTF-32 非常适用于处理码位。它的编码方式中,「每个码位始终是 4 个字节」,那么strlen(s) == sizeof(s) / 4,substring(0, 3) == bytes[0, 12](上面代码为伪代码)等等。
问题在于,我们不想处理码位。一个码位即「不是一个书写单位」,又并「不总是代表一个字符」。我们应该处理的是扩展形素簇(extended grapheme clusters),或简称为形素(graphemes)。
形素是在特定书写系统的上下文中的「最小可区分」的书写单位。
例如,ö 是一个形素,é也是一个形素。还有像각这样的形素。基本上,「形素是用户认为是一个字符的单元」。
问题是,在 Unicode 中,一些形素是由「多个码位编码」的!
图片
例如,é(一个单一的形素)在 Unicode 中编码为 e(U+0065 拉丁小写字母 E)+ ´(U+0301 连接重音符)。两个码位!
它也可能不止两个:
- ☹️ 是 U+2639 + U+FE0F
- 👨🏭 是 U+1F468 + U+200D + U+1F3ED
- 🚵🏻♀️ 是 U+1F6B5 + U+1F3FB + U+200D + U+2640 + U+FE0F
- y̖̠͍̘͇͗̏̽̎͞ 是 U+0079 + U+0316 + U+0320 + U+034D + U+0318 + U+0347 + U+0357 + U+030F + U+033D + U+030E + U+035E
即使在最宽的编码 UTF-32 中,👨🏭 仍需要「三个 4 字节单元」来进行编码。它仍然需要被「视为一个单独的字符」。
我们可以将 Unicode 本身(没有任何编码)视为「可变长度」的。
扩展形素簇(Extended Grapheme Cluster)是「一个或多个 Unicode 码位的序列」,必须将其视为「一个单独的、不可分割的字符。
因此,在「码位级别」上:「不能只取序列的一部分,它总是应该作为一个整体选择、复制、编辑或删除」。
不正确使用形素簇会导致像这样的错误:
无论是否选择UTF-32还是UTF-8在处理形素上遇到相似的问题。所以如何使用形素才是我们应该关心的。
5. Unicode 病症
上面的例子中大部分都是涉及到表情符号,这会给人一种错觉。Unicode只有在表示表情符号时,会遇到问题。--其实不是。
扩展形素簇也用于常见的语言。
例如:
- ö(德语)是一个单一字符,但包含多个码位(U+006F U+0308)。
- ą́(立陶宛语)是 U+00E1 U+0328。
- 각(韩语)是 U+1100 U+1161 U+11A8。
所以,问题不仅仅是表情符号。
"🤦🏼♂️".length 是多少?
不同的编程语言给出了不同的结果。
Python 3:
>>> len("🤦🏼♂️")
5
JavaScript / Java / C#:
>> "🤦🏼♂️".length
7
Rust:
println!("{}", "🤦🏼♂️".len());
// => 17
不同的语言使用不同的「内部字符串」表示(UTF-32、UTF-16、UTF-8),并以存储字符的单位(整数、短整数、字节)来报告长度。
但是!如果你问任何不懂编程理论的人,他们会给你一个明确的答案:🤦🏼♂️ 字符串的长度是 1。
这就是扩展形素簇的意义:「人们视为单一字符的内容」。在这种情况下,🤦🏼♂️ 显然是一个单一字符。
🤦🏼♂️ 由 5 个码位组成(U+1F926 U+1F3FB U+200D U+2642 U+FE0F)仅仅是「实现细节」。它不应该被分开,「不应该被计为多个字符」,文本光标不应该定位在其中,不应该被部分选择,等等。
这是「文本的一个不可分割的单位」。在内部,它可以被编码为任何形式,但对于面向用户的 API,应该将其视为一个整体。
唯一正确处理此问题的现代语言是 Swift:
print("🤦🏼♂️".count)
// => 1
而对于我们比较熟悉的JS和Rust,我们可以使用一些方式做一下封装。
function visibleLength(str) {
return [...new Intl.Segmenter().segment(str)].length;
}
visibleLength("🤦🏼♂️"); // 输出结果为1
当然,我们还可以校验其他的形素。
visibleLength("ö"); // => 1
visibleLength("👩💻"); // => 1
visibleLength("👩💻👩❤️💋👩"); // => 2
visibleLength("と日本語の文章"); // => 7
但是呢,Intl.Segmenter的兼容性不是很好。
如果,我们要实现多浏览器适配,我们可以找一些第三方的库。
- graphemer[3]
- text-segmentation[4]
如果想了解更多细节,可以参考JS 如何正确处理 Unicode[5]
对于Rust我们可以使用unicode_segmentation[6]crate。
extern crate unicode_segmentation; // "1.9.0"
use std::collections::HashSet;
use unicode_segmentation::UnicodeSegmentation;
fn count_unique_grapheme_clusters(s: &str) -> usize {
let is_extended = true;
s.graphemes(is_extended).collect::().len()
}
fn main() {
assert_eq!(count_unique_grapheme_clusters(""), 0);
assert_eq!(count_unique_grapheme_clusters("🤦🏼♂️"), 1);
assert_eq!(count_unique_grapheme_clusters("🇺🇸"), 1);
}
6. 如何检测扩展形素簇
大多数编程语言选择了简单的方式,允许我们迭代字符串时使用 1-2-4 字节的块,但「不支持直接处理扩展形素簇」。
由于它是默认方式,结果我们看到了损坏的字符串:
图片
如果遇到这种问题,我们首先的就是应该想到使用Unicode 库。
使用库
即使是像 strlen、indexOf 或 substring 这样的基本操作也应该使用 Unicode 库!
例如:
- C/C++/Java:使用 ICU[7]。这是 Unicode 自身发布的库,包含了关于文本分割的所有规则。
- Swift:只需使用标准库。Swift 默认情况下会正确处理。
- Javascript的话,我们上面提到过,可以使用浏览器内置功能Intl.Segmenter或者graphemer/text-segmentation
- Rust而言,我们可以使用unicode_segmentation
不管选择哪种方式,确保它使用的是「新版本」的 Unicode,因为形素的定义会随版本而变化。
Unicode 规则更新
从大约 2014 年开始,Unicode 每年都会发布其标准的重大修订版本。
每年更新
图片
随之而来的不良反映就是,定义形素簇的规则每年也会发生变化。今天被认为是由两个或三个独立码位组成的序列,明天可能会成为一个形素簇!这种朝令夕改的做法,很是让人深恶痛绝。
更糟糕的是,我们自己的应用程序的不同版本可能运行在不同的 Unicode 标准上,并报告不同的字符串长度!
7. "Å" !== "Å" !== "Å"
将其中任何一个复制到你的 JavaScript 控制台:
"Å" === "Å";
"Å" === "Å";
"Å" === "Å";
你会得到让你匪夷所思的答案。没错,它们的打印结果都是false。
还记得之前的,ö 是由两个码位组成,U+006F U+0308 。基本上,Unicode 提供了「多种」编写字符如 ö 或 Å 的方式。
因为,它们「看起来是相同」的(Å 与 Å),所以从用户的角度,我们就「认为它们应该是相同」的,但结果却和我们的想法大相径庭。
这就是为什么我们需要规范化。有四种形式:
这里先从NFD和NFC介绍。
它消除任何规范化差异,并生成一个「分解的结果」
-
它消除任何规范化差异,通常生成一个「合成的结果」
不同的形式用于不同的用例,以确保文本在不同的方式下都保持一致。所以,尽管"Å" !== "Å" !== "Å",但通过适当的规范化,我们可以使它们等同。
图片
对于某些字符,Unicode 中还存在多个版本。例如,有 U+00C5 带有上面环圈的拉丁大写字母 A,但还有外观相同的 U+212B Ångström 符号。
这些字符在规范化过程中也会被替换,以确保它们的一致性。
图片
NFD 和 NFC 被称为“规范化规范”(canonical normalization)。另外两种形式是“兼容规范化”(compatibility normalization):
它消除规范化和兼容性差异,并生成一个分解的结果
-
它消除规范化和兼容性差异,并通常生成一个合成的结果
图片
视觉变体是表示相同字符的独立 Unicode 码位,但它们应该呈现不同的方式。比如,①、⁹ 或 𝕏。
图片
所有这些字符都有自己的码位,但它们也都是Xs。
在比较字符串或搜索子字符串之前,进行规范化!
`Unicode`规范化[8]传送 🚪
在JavaScript 中,我们可以使用 normalize() 方法来实现 NFC(Normalization Form C)和 NFD(Normalization Form D)。
const str1 = "Å";
const str2 = "Å";
const normalizedStr1 = str1.normalize("NFC"); // NFC 形式
const normalizedStr2 = str2.normalize("NFC"); // NFC 形式
console.log(normalizedStr1 === normalizedStr2); // true
上述代码首先使用 normalize('NFC') 方法将两个字符串都转换为 NFC 形式,然后比较它们是否相等。这将使 "Å" 和 "Å" 的比较结果为 true。
如果使用 NFD 形式,只需将 normalize('NFC') 更改为 normalize('NFD') 即可。
8. Unicode 取决于区域设置
俄罗斯名字「尼古拉」
图片
在Unicode 中编码为 U+041D 0438 043A 043E 043B 0430 0439。
保加利亚名字「尼古拉」
图片
也写成 U+041D 0438 043A 043E 043B 0430 0439。
它们的Unicode值完全一样,但是所显示的字体信息却不尽相同。是不是有种小脑萎缩的感觉。
然后心中有一个 🤔,计算机如何知道何时呈现保加利亚风格的字形,何时使用俄罗斯的字形?
其实,计算机也不知。Unicode 并不是一个完美的系统,它有很多不足之处。其中一个问题是「将本应呈现不同外观的字形分配给相同的码位」,比如西里尔字母的小写字母 K 和保加利亚的小写字母 K(都是 U+043A)。
针对一些表音语言这块还能好点,但是到了我们大亚洲,很多国家的文字都是「表意」的。许多汉字、日语和韩语表意字形的写法都截然不同,但被分配了相同的码位。
图片
Unicode 的动机是为了「节省码位空间」。渲染信息应该在字符串外部以区域设置/语言元数据的方式传递。
在实践中,依赖于区域设置带来了许多问题:
- 作为元数据,区域设置通常会丢失。
- 人们不限于使用「单一区域设置」。例如,我们可以阅读和写作中文,美国英语、英国英语、德语和俄语。
- 难以混合和匹配。比如在保加利亚文本中使用俄罗斯名字,反之亦然。
- 没有地方可以指定区域设置。即使制作上面的两个屏幕截图也不容易,因为在大多数软件中,没有下拉菜单或文本输入来更改区域设置。
9. 处理特殊语言
另一个不幸的例子是土耳其语中无点 i 的 Unicode 处理。
与英语不同,土耳其语有两种 I 变体:有点和无点。
Unicode 决定重用 ASCII 中的 I 和 i,并只添加了两个新的码位:İ 和 ı。
这导致了在相同输入上 toLowerCase/toUpperCase 表现不同:
var en_US = Locale.of("en", "US");
var tr = Locale.of("tr");
System.out.println("I".toLowerCase(en_US)); // => "i"
System.out.println("I".toLowerCase(tr)); // => "ı"
System.out.println("i".toUpperCase(en_US)); // => "I"
System.out.println("i".toUpperCase(tr)); // => "İ"
所以,我们在不知道字符串是用哪种语言编写的情况下将字符串转换为小写,会出现问题。
如果我们项目中涉及到土耳其语的字符转换,在 JS 中toLowerCase是达不到上面的要求的。因为,在JavaScript中,toLowerCase方法默认使用Unicode规范进行转换,根据Unicode的规范,大写 I 被转换为小写 i,而不是 ı。这是因为JavaScript的toLowerCase方法按照Unicode的标准工作。
要想使用JS正确处理上面的问题,我们就需要额外的 API.
"I".toLocaleLowerCase("tr-TR"); // => "ı"
"i".toLocaleUpperCase("tr-TR"); // => "İ"
我们也可以通过对String.prototype上做一层封装。
String.prototype.turkishToUpper = function () {
var string = this;
var letters = { i: "İ", ş: "Ş", ğ: "Ğ", ü: "Ü", ö: "Ö", ç: "Ç", ı: "I" };
string = string.replace(/(([iışğüçö]))+/g, function (letter) {
return letters[letter];
});
return string.toUpperCase();
};
String.prototype.turkishToLower = function () {
var string = this;
var letters = { İ: "i", I: "ı", Ş: "ş", Ğ: "ğ", Ü: "ü", Ö: "ö", Ç: "ç" };
string = string.replace(/(([İIŞĞÜÇÖ]))+/g, function (letter) {
return letters[letter];
});
return string.toLowerCase();
};
// 代码演示
"DİNÇ".turkishToLower(); // => dinç
"DINÇ".turkishToLower(); // => dınç
这样就可以正确规避JS针对土耳其语言中的准换问题。
在Rust中,我们可以使用如下代码:
fn turkish_to_upper(input: &str) -> String {
let letters = [
('i', "İ"),
('ş', "Ş"),
('ğ', "Ğ"),
('ü', "Ü"),
('ö', "Ö"),
('ç', "Ç"),
('ı', "I"),
];
let mut result = String::new();
for c in input.chars() {
let mut found = false;
for &(source, target) in &letters {
if c == source {
result.push_str(target);
found = true;
break;
}
}
if !found {
result.push(c);
}
}
result.to_uppercase()
}
fn turkish_to_lower(input: &str) -> String {
let letters = [
('İ', "i"),
('I', "ı"),
('Ş', "ş"),
('Ğ', "ğ"),
('Ü', "ü"),
('Ö', "ö"),
('Ç', "ç"),
];
let mut result = String::new();
for c in input.chars() {
let mut found = false;
for &(source, target) in &letters {
if c == source {
result.push_str(target);
found = true;
break;
}
}
if !found {
result.push(c);
}
}
result.to_lowercase()
}
fn main() {
let input = "İşğüöçı";
let upper_result = turkish_to_upper(input);
let lower_result = turkish_to_lower(input);
println!("Upper: {}", upper_result); //Upper: İŞĞÜÖÇI
println!("Lower: {}", lower_result); // Lower: işğüöçı
}
Reference
[1]ASCII:https://cikgucandoit.wordpress.com/what-is-ascll/
[2]Emoji 简介:https://www.ruanyifeng.com/blog/2017/04/emoji.html
[3]graphemer:https://github.com/flmnt/graphemer
[4]text-segmentation:https://github.com/niklasvh/text-segmentation
[5]JS 如何正确处理 Unicode:https://flaviocopes.com/javascript-unicode/
[6]unicode_segmentation:https://docs.rs/unicode-segmentation/latest/unicode_segmentation/
[7]ICU:https://github.com/unicode-org/icu
[8]Unicode规范化:https://www.unicode.org/glossary/