Unicode 与前端字符编码全揭秘

前端瓶子君,关注公众号

回复算法,加入前端编程面试算法每日一题群

〇、狗非狗

首先,我们来看一段简单的JS代码。

console.log('狗'.length);   // output: 1
console.log('🐶'.length);   // output: 2
console.log('🐕‍🦺'.length);   // output: 5
复制代码

“大家都是狗,为什么长度差这么多?”

如果你对这个结果感到一脸懵逼,那么你一定要看完本文,因为本文会深入解释背后不为人知的奥秘。如果你已然知晓其中缘由,我也希望你能看完本文,因为本文内容远远不仅限于此。

本文大概可分成两个部分:

  • 第一部分介绍Unicode字符集,及其常用编码格式,如:UTF-8、UTF-16、UTF-32。

  • 第二部分介绍Unicode在前端语言(JS、HTML、CSS)中的应用,例如:

    • JS中编码格式的历史变迁

    • 如何利用Unicode字符属性转义序列精确匹配字符串中的汉字

    • 如何以字符簇为单位分割字符串

    • JSX如何处理Unicode字符实体

TLDR; 全文较长,请小伙伴们自行控制阅读节奏。

一、Unicode字符集

计算机的底层是一个由0和1组成的数字世界,因此我们并不能把现实中的字符(如:汉字、英语、标点符号等)直接写入内存,而必须先把它们转换成数字,然后把这些数字写入内存。等到读取的时候,再把数字转换成对应的字符。在这样一个字符和数字相互转换的过程中,就需要用到字符集(Character Set)。简单来讲,字符集保存着字符和数字之间一对一的映射关系,而每个字符所对应的那个数字称为码点(Code Point)。例如,ASCII就是被大家所熟知的一个字符集,其中字母A对应的码点是0x41(十六进制)。

ASCII所包含的字符相当有限,大部分是英文字母、英文标点和阿拉伯数字。所以,像本文这样中英文混杂的情况,ASCII就无能为力了,更别说全世界还有其他那么多种的文字。于是,为了便于各种文字在全世界交换分享,Unicode被创造了出来。

Unicode是一个超大字符集,其中几乎包含了全世界所有的字符,当然现在聊天常备的Emoji也在其中。在编写本文时,Unicode最新版本为14.0,故本文相关内容也基于此版本。

在本文中,与Unicode相关的内容将统一使用以下两种格式:

  1. 使用U+十六进制码点值的格式来表示一个Unicode码点,例如:U+10FF。注意,此格式仅用于描述,在实际定义中,每个码点都是数字,并没有前缀U+

  2. 使用起始码点..终止码点的格式来表示一个连续的Unicode码点范围。例如,U+0000..U+0003表示U+0000、U+0001、U+0002、U+0003这四个码点。

Unicode的码点范围,或者说编码空间,为U+0000..U+10FFFF,这些码点被平均分成17个平面(Plane)。其中,

  • 第1个平面(U+0000..U+FFFF)称为基本平面(Basic Multilingual Plane)

  • 其余16个平面(U+10000..U+10FFFF)称为16个补充平面(Supplementary Plane)

在一个平面中,又会根据字符类别划定大小不一的区块(Block)。例如,几乎所有的常用汉字都在基本平面中的CJK Unified Ideographs(U+4E00..U+9FFF)这个区块。

以本文开头的🐶为例,让我们来看下这两个字符在Unicode中的一些基本信息。

字符码点区块平面
U+72D7CJK Unified Ideographs(U+4E00..U+9FFF)基本平面(U+0000..U+FFFF)
🐶U+1F436Miscellaneous Symbols and Pictographs(U+1F300..U+1F5FF)1号补充平面(U+10000..U+1FFFF)

二、Unicode编码格式

上一节说到,字符必须先被转换成数字才能写入内存。那么,现在有了Unicode字符集,是不是只要将码点直接写入内存就可以了呢?事实并非如此。例如,对于字符串狗🐶,根据上文,可将其转换成十六进制码点序列72D71F436,进一步转换成二进制码点序列就是11100101101011111111010000110110

如果现在要求你将上面这个二进制序列还原成字符串,在事先并不知道原始字符串的前提下,你还能准确判断从第一位到第几位表示的是第一个码点,而之后表示的是第二个码点吗?你可以将其分成1110010110101111,1111010000110110,也可以分成11100101101011111,111010000110110,而最后还原出来的结果将截然不同。

在不同位置分割二进制码点序列十六进制码点序列原始字符串
111001011010111,11111010000110110U+72D7 U+1F436狗🐶
1110010110101111,1111010000110110U+E5AF U+F436
11100101101011111,111010000110110U+1CB5F U+7436𜭟琶

所以,在传输和存储字符串时,并不是简单转换成码点序列就可以的,还需要能够准确界定各个码点的边界,否则就无法被准确解码。有的小伙伴可能会想到,那是不是可以在码点之间添加一个特殊符号作为标记呢,比如上面所用的逗号?实际上并不能,别忘了,这是一个只有0和1的世界,并没有什么逗号。

不过,还是有一个简单的方法可以解决码点边界的问题,就是在把码点转换成二进制时,都把结果补齐到相同的位数(在最高位前加0)。然后在还原时,始终以这个位数为单位进行读取。例如,Unicode中最大的码点是U+10FFFF,其二进制为1 0000 1111 1111 1111 1111,共21位。所以,我们可以将Unicode中其他所有码点的二进制形式也都补齐到21位。这样,对于一个二进制码点序列,我们每读取21位就能准确得到一个码点。还是以狗🐶为例,整个转换过程如下:

狗 -> U+72D7 -> 111001011010111 -> 000000011101011010111
🐶 -> U+1F436 -> 11111010000110110 -> 000011111010000110110

000000011101011010111000011111010000110110
|-------------------||-------------------|
         21位                 21位
复制代码

至此,我们已经掌握了第一种Unicode编码格式(缩写为UTF)。由于是统一补齐到21位,所以就把这个编码格式命名为UTF-21吧!

2.1 UTF-32

虽然UTF-21是我们自己杜撰的,但实际上我们也已经掌握了一种真实的Unicode编码格式——UTF-32!因为UTF-32就是把码点统一补齐到32位(4个字节)。

0000000000000000001110101101011100000000000000011111010000110110  --> 狗🐶
|------------------------------||------------------------------|
              32位                             32位           
复制代码

但是,UTF-32有个很大的缺陷就是太浪费空间了,因为上面我们提到,用21位就足以表示Unicode中所有的码点。

2.2 UTF-8

相比UTF-32,UTF-8就大大提高了空间利用率。UTF-8针对不同范围的码点采用不同长度的字节数(1–4个字节)进行编码。整个编码过程相对复杂一些,受篇幅限制,本文不会做详细描述。但是俗话说一图胜千言,所以希望下面这张表格能让我们对UTF-8有个直观的认识。在表格中,

  • 黑色的0和1表示固定标记位,用于确定码点边界。例如,如果一个字节的最高位是0,那么这一定就是个单字节码点;如果一个字节最高3位是110,那么这一定是一个双字节码点,而后面紧跟的一个字节的最高两位一定是10。以此类推。

  • 其他颜色的0和1则表示如何将码点的各个比特位映射到对应的字节中。

eb294396b0b3869ccb4b47569975d3e1.png
image.png

UTF-8除了节省空间,还具有兼容ASCII、容错率高、没有字节序问题等诸多优点。所以在日常开发中,像HTML、CSS和JS这些文件几乎都以UTF-8格式保存。在整个互联网中,大约98%的网页基于UTF-8格式(摘自维基百科[1])。

上文提到,大部分常用汉字都在U+4E00..U+9FFF这个范围,所以在UTF-8中都需要用3个字节来表示。所以我有个疑问,如果某个文本中大部分都是汉字,在某些场景下,UTF-8还是最优的编码格式吗?

2.3 UTF-16

虽然在日常开发中,源文件多以UTF-8格式保存,但是在JS内部却是采用另外一种Unicode编码格式——UTF-16。下面我们就来详细了解UTF-16的编码规则:

  • 对于基本平面(U+0000..U+FFFF)中的码点,统一使用2个字节来表示,且与码点完全相同。例如,就被编码为0x72D7(0111 0010 1101 0111)。

  • 对于所有补充平面(U+10000..U+10FFFF)中的码点,先减去0x10000,于是数值范围变成0x00000–0xFFFFF,然后补齐到20位,最后把这20位一分为二。

    • 将较高的10位与0xD800(1101 1000 0000 0000)相加得到一个值,称为高位代理项(High Surrogate)。通过简单计算可得,高位代理项的数值范围为0xD800–0xDBFF。

    • 将较低的10位与0xDC00(1101 1100 0000 0000)相加得到一个值,称为低位代理项(Low Surrogate)。通过简单计算可得,低位代理项的数值范围为0xDC00–0xDFFF。

    • 高位代理项(2个字节)和低位代理项(2个字节)在一起组成一个代理对(Surrogate Pair)。一个代理对(4个字节)编码一个补充平面的码点。

例如,🐶在补充平面内,所以被编码为0xD83D 0xDC36,整个编码过程如下表。

401b25914c4bd4dc34e8beb81903f632.png
image.png

你可能会有疑问,🐶的高位代理项——0xD83D,不是也可以当做是码点U+D83D在UTF-16中的编码结果吗,这样不会有冲突吗?实际上在Unicode中,直接将U+D800..U+DBFF和U+DC00..U+DFFF这两个区块定义为High Surrogates和Low Surrogates,专供UTF-16使用,其中所有码点都没有单独对应的字符。

综上,我们来看一下在UTF-16中是如何确定码点边界的。首先,每次读取2个字节:

  1. 如果值在0xD800–0xDBFF之间,则表示是高位代理项,需要判断其后2个字节是否为低位代理项。

  • 如果是,则表示这4个字节是一个代理对。

  • 如果不是,则表示前面2个字节是一个单独的高位代理项,而后面2个字节从头开始判断。

如果值在0xDC00–0xDFFF之间,则表示这2个字节是单独的低位代理项。

如果值不在以上两个范围之内,则表示这2个字节是基本平面中的码点。

在UTF-16中,理论上不应该出现单独的高位代理项或者低位代理项。不过由于实际输入不可控,所以大部分编解码器对此做了兼容。例如在JS中,定义const str = '\uD83D'并不会报错,只是当作一个普通的码点处理。如果看不懂'\uD83D'的意思,也没关系,下文会涉及。

三、JS和Unicode的历史变迁

虽然JS内部使用UTF-16编码,但是许多API却无法处理补充平面中的码点。比如String.length,就是以2个字节为单位计算字符串长度。而在UTF-16中,补充平面的码点需要占据4个字节,所以在本文开头的例子中,'狗'.length等于1,而'🐶'.length等于2。之所以如此String.length的行为如此诡异,还要从JS和Unicode的历史说起。

在Unicode早期版本中,只存在一个基本平面,因为标准制定者认为基本平面已经足以覆盖全世界所有的字符。但是事实证明,他们还是低估了全世界人民的创造能力,所以后来不得不想办法扩展字符集,并设计一个能够支持扩展后的字符集的编码格式。

于是,UTF-16应运而生。

当时U+D800..U+DFFF之间的码点仍处于未分配的状态,所以被UTF-16征用为代理对。同时,由于一个代理对共有20个有效位(高位代理项10位,低位代理项10位),所以能表示的数值范围是0x00000–0xFFFFF,加上偏移量0x10000之后,就变成了现在Unicode中所有补充平面的码点范围U+10000..U+10FFFF。所以实际上,现在的Unicode的编码空间是由UTF-16决定的。

UTF-32和UTF-8的设计时间要早于UTF-16,也并非为扩展后的Unicode所专门设计。所以你才会看到UTF-32有那么多冗余位,而现在的UTF-8也是阉割之后的版本,原始的UTF-8实际可以支持的编码空间远大于现在的Unicode。

JS恰好诞生在Unicode还只有一个基本平面,而UTF-16并不存在的年代,所以作者选择了另外一种编码格式——UCS-2。UCS-2的编码规则很简单,即对基本平面的码点统一用2个字节表示,且与码点值完全相同。换句话说,UCS-2和UTF-16在基本平面内的编码规则是完全一致的。于是后来,随着Unicode扩展出补充平面,以及UTF-16的出现,UCS-2就被逐步淘汰了。但是为了向后兼容,JS中字符串相关的API依然保留了UCS-2的影子。所以,String.length仍以每个码点占据2个字节的方式来计算字符串长度。

不过在ES6之后,JS逐步引入了一些新的API和语言特性,以提高对Unicode的支持度。下一节我们就来详细聊一聊。

四、Unicode in JS

为了方便描述,在本节开始之前,需要介绍一个新名词——码元(Code Unit)。在UTF-16中,1个码元等于2个字节。所以,我们可以说String.length是以UTF-16码元为单位计算字符串长度。另外,由于一个码点在UTF-16中占据2个字节或4个字节,所以,我们也可以说一个码点由1个或者2个码元组成。

4.1 Unicode字符转义序列

在JS字符串中,我们一般都直接输入字符本身,不过在少数不方便输入字符的情况下,也可以通过\u码元\u{码点}的方式来表示一个Unicode字符。这两种写法的最大区别就在于,\u码元必须通过代理对来表示补充平面的码点,例如:

console.log('狗' === '\u72D7');        // output: true
console.log('狗' === '\u{72D7}');      // output: true
console.log('🐶' === '\uD83D\uDC36');  // output: true
console.log('🐶' === '\u{1F436}');     // output: true
复制代码

4.2 以码点为单位分割字符串

在JS中,和String.length行为相同的API还有很多,例如:String.prototype.slice()String.prototype.charAt()和通过下标获取字符串中某个字符等,这些API都是以码元为单位分割字符串。所以如果字符串中包含补充平面的字符,就会出现问题。例如:

const str = '🐶狗';
console.log(str.slice(1));  // output: "\udc36狗"
console.log(str.charAt(1)); // output: "\udc36"
console.log(str[1]);        // output: "\udc36"
复制代码

ES6之后,JS实现了以码点为单位分割字符串的迭代器——String.prototype[@@iterator]()。另外,Array.fromfor..of也可以实现这个功能,因为它们在底层也都调用了这个迭代器。示例如下:

const str = '🐶狗';

// 1. iterator
const strIter = str[Symbol.iterator]();
console.log(strIter.next().value); // "🐶"
console.log(strIter.next().value); // "狗"

// 2. Array.from
console.log(Array.from(str));  // output: [ "🐶", "狗" ]

// 3. for..of
for (let v of str) {
  console.log(v);
}
// output: "🐶" "狗"
复制代码

4.3 以字素簇为单位分割字符串

虽然字符串自身的迭代器是以码点为单位分割字符串,但是也还是会遇到一些出乎意料的结果。例如:

console.log(Array.from('🐕‍🦺'));  // output: [ "🐕", "‍", "🦺" ]
// 泰语
console.log(Array.from('สุ'));  // output: [ "ส", "ุ" ]
复制代码

之所以会这样,是因为许多我们看起来是一个字符的图形或文字,实际是由多个码点组合而成。在上面的例子中,🐕‍🦺由3个码点组成,用转义序列可以表示为"\u{1F415}\u{200D}\u{1F9BA}",而สุ可以表示为"\u{0E2A}\u{0E38}"

这些从人类视觉角度被认为是单个字符的图形或文字,在Unicode中被称为字素簇(Grapheme Cluster)

如果要以字素簇为单位分割字符串,目前来说有两个方案。第一个方案是原生API——Intl.Segmenter[2]目前这个提案处于Stage 4,并且Chrome和Safari已经支持[3]。示例如下:

const segmenter = new Intl.Segmenter();
const segments = segmenter.segment('🐕‍🦺สุ');
// segments是可迭代的,所以可以用Array.from或for..of来调用其内部的迭代器
console.log(Array.from(segments));
// output: [
//   {segment: '🐕‍🦺', index: 0, input: '🐕‍🦺สุ'},
//   {segment: 'สุ', index: 5, input: '🐕‍🦺สุ'}
// ]
复制代码

另外一个方案就是调用第三方开源库,比如:graphemer[4]、text-segmentation[5]。这些库的实现原理是根据Unicode本身规定的GraphemeBreakProperty[6]对文本进行切分,这里就不多做介绍了,有兴趣的小伙伴可以尝试阅读Unicode官方文档。

在少数情况下,如:印度语系(测试文本अनुच्छेद),Intl.Segmenter和第三方库的分割结果可能会不同,这是因为Unicode允许各个语言对分割规则进行扩充和自定义。Intl.Segmenter可以通过参数指定locale,而第三方库往往只实现了默认的分割规则。

4.4 Unicode字符属性转义序列

在JS中,正则表达式默认也是以码元为单位进行匹配,不过可以通过添加u标记,将其转换成以码点为单位进行匹配。示例如下:

// 以码元为单位匹配
/^.$/.test('狗');    // return: true
/^.$/.test('🐶');    // return: false
// 以码点为单位匹配
/^.$/u.test('🐶');   // return: true
复制代码

加上u标记之后,还为正则表达式提供了一个强大的功能——Unicode字符属性转义序列(Unicode Property Escapes)[7],它可以大大简化编写Unicode字符相关的正则表达式的复杂度。

首先,让我们来了解下什么是Unicode字符属性。Unicode中的每一个字符都有许多属性,比如:Age(首次被收录的Unicode版本)、Block(所属区块)和General_Category(所属类别)等。以为例,下表列出了这个字符的部分属性(完整列表见此处[8])。

Property NameTypeValue
AgeCatalog1.1
BlockCatalogCJK_Unified_Ideographs
General_CategoryEnumerationOther_Letter
Unified_IdeographBinaryYes

Unicode字符属性转义序列正是利用这些属性,让我们可以在正则表达式中很方便地筛选出符合条件的字符集合。语法如下:

// 非Binary类型的属性
\p{PropertyName=PropertyValue}
// Binary类型的属性
\p{PropertyName}
复制代码

以一个常见场景为例,如果你在网上搜索“如何用正则表达式匹配汉字”,八成会看到一个答案是/[\u4E00-\u9FA5]/。这是因为上文提到,大部分常用汉字都在CJK Unified Ideographs(U+4E00..U+9FFF)这个区块,并且在几十年前,这个区块中最后一个汉字的码点就是U+9FA5。

但是随着时间的推移,越来越多的生僻字被包括进Unicode中,而且大部分在其他区块中,比如:CJK Unified Ideographs Extension A(U+3400..U+4DBF)、CJK Unified Ideographs Extension B(U+20000..U+2A6DF)。另外,这些新的区块中的码点也不是一次性分配完毕的,而是随着Unicode的版本逐步分配。所以从当下来看,/[\u4E00-\u9FA5]/这个正则表达式在匹配常用汉字时依然够用,但是如果需要匹配所有汉字,那么就不准确了。当然,为了精准匹配所有汉字,我们也可以选择跟随Unicode版本变化,手动更新表达式,但是这种解决方案的可维护性和可读性比较差。

在这种情况下,用Unicode字符属性转义序列就能很方便地解决这个问题,因为Unified_Ideograph(中日韩统一表意文字)这个属性只有在汉字字符中才是Yes,其他都为No。所以正则表达式可以写成:

// 匹配一个汉字
/\p{Unified_Ideograph}/u

// 实例:去除字符串中的所有汉字
'abc汉字'.replace(/\p{Unified_Ideograph}/ug, '');  // return: 'abc'
// 相反,如果想匹配字符串中的所有非汉字字符,则可以使用\P{...}
'abc汉字'.replace(/\P{Unified_Ideograph}/ug, '');  // return: '汉字'
复制代码

这样,正则表达式就再也不需要改动,而且更简洁、更准确、可读性更强。

再举个例子,在General_Category这个属性中有个值为Punctuation,于是我们就可以很方便地匹配字符串中的所有标点符号,无论是中文标点,还是英文标点。示例如下:

// 去掉字符串中所有的标点符号
'中,。;:‘“”’「」英,.?;:\'"!'.replace(/\p{General_Category=Punctuation}/ug, ''); // return: 中英
// General_Category还支持下面这种省略属性名的简写方式
'中,。;:‘“”’「」英,.?;:\'"!'.replace(/\p{Punctuation}/ug, ''); // return: '中英'
复制代码

General\_Category的完整列表[9]

不过,目前在字符属性转义序列中只支持部分Unicode字符属性,比如Age就不支持,但是ES规范中指定了必须支持的属性列表[10]。如果不了解每个属性的用途,可以参考此文档[11]。另外,Unicode官方也提供了工具[12],方便查看具有某个属性的所有字符。

4.5 Normalization

由于一些历史原因和扩展的需要,许多字符在Unicode中有多种表现形式,既可以使用单一码点,也可以使用字素簇。例如,拼音中的ǒ,既可以用"\u{01D2}"来表示,也可以用"\u{006F}\u{030C}"。在第二种形式中,"\u{006F}"表示普通英文字母o"\u{030C}"则表示拼音声调符号 ̌。这两种形式所表达的字符语义完全相同,但是在字符串比较时却并不相同。

console.log("\u{01D2}" === "\u{006F}\u{030C}"); // output: false
复制代码

对于这种情况,JS原生提供了normalize[13]函数,方便在两种形式之间相互转换。

// NFC = Normalization Form Composition
// NFD = Normalization Form Decomposition
'\u{006F}\u{030C}'.normalize('NFC');  // return: '\u{01D2}'
'\u{01D2}'.normalize('NFD');          // return: '\u{006F}\u{030C}'

// 参数为空时,默认转换成NFC格式
'\u{006F}\u{030C}'.normalize();     // return: '\u{01D2}'
'\u{01D2}'.normalize();             // return: '\u{01D2}'
复制代码

还有另外一些字符,看起来并不一样,用处也不同,比如:(U+2075)和5(U+0035),一般用来表示数学中的指数(如:2⁵)。但是,在一些上下文中可能表达一样的含义,比如:⁵只狗5只狗。我们可以认为这两个5其实表达同样的意思,或者说是相互兼容的。在这种情况下,我们也可以使用normalize函数处理,只需设置参数为NFKC。不过,由于这种兼容性十分受到上下文的影响,所以要慎用。

'⁵只狗'.normalize('NFKC');  // return: "5只狗"
复制代码

normalize函数之所以可以实现这些转换,是因为在Unicode规范中,详细定义了每个字符的等价形式和兼容形式,参见:www.unicode.org/charts/norm…[14]。

五、Unicode in HTML

在HTML中,除了直接输入字符本身之外,所有的Unicode字符还可以用字符实体(Entity) 来表示,格式为&#十进制码点值;&#x十六进制码点值;,对于部分字符,还可以用&实体名称;表示。例如:

Unicode字符码点十进制十六进制实体名称
<0x003C&#60;&#x3C;&lt;
🐶0x1F436&#128054;&#x1F436;

如果在浏览器中渲染以下html片段,你将会看到3只一模一样的狗子。

<body>🐶 &#x1F436; &#128054;</body>
复制代码

不过,当我们通过JS将上面的内容插入到body中时,不同的方法将得到不同的结果,例如:

document.body.innerHTML = '🐶 &#x1F436; &#128054;';   // result: 🐶 🐶 🐶
document.body.textContent = '🐶 &#x1F436; &#128054;'; // result: 🐶 &#x1F436; &#128054;
复制代码

这是因为,innerHTML将内容识别成HTML片段,所以会解析其中的字符实体,而textContent则将内容当做普通文本插入。

下面这段代码会显示5只狗子。
document.body.innerHTML = '🐶 &#x1F436; &#128054; \u{1F436} \uD83D\uDC36';

5.1 JSX的字符实体变形记

对于日常开发使用React的同学来说,应该对JSX已经十分熟悉了。JSX看起来与HTML十分相似,但是在项目构建阶段会被Babel或者TypeScript编译器转成JS,然后再渲染到页面上。而在这个过程中,存在着一个与Unicode字符实体相关的隐秘细节。

先让我们来看一段react代码:

const LessThanExample = () => {
  return <div>1 &lt; 2</div>  
}
复制代码

这是一个特别简单的React组件,最后渲染出来的结果就是简单的1 < 2。不过,因为在JSX中<号也是保留字符,所以和在HTML中一样,需要使用对应的字符实体&lt;来表示。

这个组件还有另外这种写法可以避免保留字的冲突问题:<div>1 {'<'} 2</div>

另一方面,React有一个特性就是能够有效防御XSS漏洞,这是因为React在渲染JSX的时候,都是通过textContent[15]来输出到HTML中(除去dangerouslySetInnerHTML[16]的部分),而不是innerHTML。所以乍看之下,上面的组件最后被渲染时,应该类似下面这段代码:

const div = document.createElement('div');
div.textContent = '1 &lt; 2';
复制代码

那么,在浏览器上看到的结果就应该是1 &lt; 2,而不是1 < 2。但是事实并非如此,这是因为Babel和TS编译器在编译JSX时,会把所有Unicode字符实体转换成字符本身。以TS编译器为例,相关代码如下:

Babel的代码与之大同小异,感兴趣的小伙伴可自行品读[17]。

// https://github.com/microsoft/TypeScript/blob/1ade73df2bf3e83085ca4647d0a3339abe3f869b/src/compiler/transformers/jsx.ts#L484
/**
 * Replace entities like "&nbsp;", "&#123;", 
 * and "&#xDEADBEEF;" with the characters they encode.
 * See https://en.wikipedia.org/wiki/List_of_XML_and_HTML_character_entity_references
 */
function decodeEntities(text: string): string {
  return text.replace(/&((#((\d+)|x([\da-fA-F]+)))|(\w+));/g, (match, _all, _number, _digits, decimal, hex, word) => {
    if (decimal) {
      return utf16EncodeAsString(parseInt(decimal, 10));
    }
    else if (hex) {
      return utf16EncodeAsString(parseInt(hex, 16));
    }
    else {
      const ch = entities.get(word);
      return ch ? utf16EncodeAsString(ch) : match;
    }
  });
}

// https://github.com/microsoft/TypeScript/blob/1ade73df2bf3e83085ca4647d0a3339abe3f869b/src/compiler/transformers/jsx.ts#L543
const entities = new Map(getEntries({
  quot: 0x0022,
  amp: 0x0026,
  apos: 0x0027,
  lt: 0x003C,
  gt: 0x003E,
  nbsp: 0x00A0,
  //..other entities
}));

// https://github.com/microsoft/BuildXL/blob/d1d0b430961e56a5bd006783ab8bb5c3d563bb60/Public/Src/FrontEnd/TypeScript.Net/TypeScript.Net/TypeScriptImpl/Scanner.ts#L1037
// 这个函数其实就是String.fromCodePoint()的一个polyfill,可以将码点转换成字符。
function utf16EncodeAsString(codePoint: number): string {
  if (codePoint <= 65535) {
    return String.fromCharCode(codePoint);
  }

  const codeUnit1 = Math.floor((codePoint - 65536) / 1024) + 0xD800;
  const codeUnit2 = ((codePoint - 65536) % 1024) + 0xDC00;

  return String.fromCharCode(codeUnit1, codeUnit2);
}

// decode
const str = decodeEntities('1 &lt; 2');  // str = '1 < 2'
复制代码

也就是说,经过Babel和TS编译之后,这个组件变成了类似下面这样:

const LessThanExample = () => {
  // 编译后已经变成了1 < 2
  return React.createElement("div", null, "1 < 2")  
}
复制代码

问题到这里其实并未完全结束,因为React还支持服务端渲染。对于1 < 2这个字符串,如果在服务端渲染时直接被插入HTML中,就会造成浏览器解析HTML错误。所以在将字符串插入到HTML之前,ReactPartialRenderer[18]会调用函数escapeTextForBrowser[19]把<再转换回&lt;

六、Unicode in CSS

在CSS的字符串中,也可以用转义序列来表示一个Unicode字符,格式为\十六进制码点值。例如,é的码点为U+00e9,所以可以直接表示成\e9,前导0可以省略。

.foo::after {
    content: '\e9'; /* é */
}
复制代码

但是有些情况下,省略前导0可能会导致字符串解析错误。例如,如果把content的值改成\e9cho,你会发现最后浏览器中显示的结果是ຜho,而不是écho。这是因为解析器会把转义字符\后紧跟的连续的最多6个十六进制数字都用来组成码点。所以在\e9cho中,\e9c被解析成一个码点,而该码点对应的字符是。要想解决这个问题也很简单,只需要补全前导0即可,也就是\0000e9cho

七、如何保证Unicode字符正确显示

上文中,我们提到如何在HTML、CSS和JS中使用转义序列或字符实体等形式来表示一个Unicode字符。但是,在允许的情况下始终应该优先选择输入Unicode字符本身,因为这样可维护性和可读性更好。但是,要想保证Unicode字符在浏览器中正确显示,我们还需要同时保证以下三点:

  1. 保存文件时,使用UTF-8编码。

  2. 对于HTML和CSS文件,需要在文件内声明编码格式,HTML为<meta charset='utf-8'>,CSS为@charset 'utf-8'。注意,虽然在HTML5中规定默认编码是UTF-8,但是有些浏览器并非如此(如:Safari),所以建议还是显式设置编码格式。

  3. 在服务器端,在请求资源的HTTP响应头中设置文件类型和编码格式,例如:content-type: text/html; charset=utf-8

八、总结

到此,本文的内容也就基本结束了。编写本文时,为了能在保证易懂的情况下,较为全面和准确地介绍Unicode及其在前端的应用,我前前后后花了两个多月时间阅读资料、设计案例、整理文章思路、修改文章内容。不过,我始终也是个半吊子水平,所以不免有错漏,希望小伙伴们积极指出。同时,我也略去了一些更加深入的内容,有兴趣的小伙伴可以根据文中的链接或下方的参考资料进一步学习。最后,如果硬要给本文下个结论,我觉得可能有以下两点:

  1. 在允许的情况下,始终优先选择输入Unicode字符本身,而不是转义序列或字符实体。

  2. 如果你将要处理的字符串中可能包含Emoji或多种语言,那么一定要小心了,有必要时请记得回来再翻翻这篇文章。

关于本文

来自:weiyimin01

https://juejin.cn/post/7070079762429034526

最后

欢迎关注【前端瓶子君】✿✿ヽ(°▽°)ノ✿

回复「算法」,加入前端编程源码算法群,每日一道面试题(工作日),第二天瓶子君都会很认真的解答哟!

回复「交流」,吹吹水、聊聊技术、吐吐槽!

回复「阅读」,每日刷刷高质量好文!

如果这篇文章对你有帮助,「在看」是最大的支持

 》》面试官也在看的算法资料《《

“在看和转发”就是最大的支持

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: Java中可以使用Unicode编码来表示字符,也可以将Unicode编码转换为字符。 将Unicode编码转换为字符可以使用Java中的char类型,例如: char c = '\u0041'; // 将Unicode编码为十六进制表示的字符'A' 将字符转换为Unicode编码可以使用Java中的String类型的getBytes()方法,例如: String str = "A"; byte[] bytes = str.getBytes("Unicode"); // 将字符'A'转换为Unicode编码的字节数组 String unicodeStr = new String(bytes, "Unicode"); // 将Unicode编码的字节数组转换为Unicode编码字符串 需要注意的是,Java中的char类型占用两个字节,可以表示Unicode编码范围内的所有字符。而String类型的getBytes()方法默认使用UTF-8编码,如果需要转换为Unicode编码需要指定编码方式为"Unicode"。 ### 回答2: Java 语言中,Unicode 编码用于表示各种语言的字符,包括中文、英文、德文等等。当我们需要在 Java 代码中处理 Unicode 编码字符时,需要进行编码转换。 编码转换的过程可以分为两步:Unicode 编码转换为字节数组,再将字节数组转换为字符串。 第一步,我们可以使用 Java 标准类库中的 String 类的 getBytes() 方法将 Unicode 编码转换为字节数组。具体地,我们可以按照以下方式进行编码转换: ``` String str = "Hello World! 你好,世界!"; // 将此字符串转换为 Unicode 编码 byte[] bytes = str.getBytes("Unicode"); // 将 Unicode 编码转换为字节数组 ``` 在这里,我们通过指定 getBytes() 方法的参数为“Unicode”,从而实现了 Unicode 编码转换为字节数组的功能。 第二步,我们可以使用 Java 标准类库中的 String 类的构造函数将字节数组转换为字符串。具体地,我们可以按照以下方式进行编码转换: ``` String str = new String(bytes, "Unicode"); // 将字节数组转换为字符串 ``` 在这里,我们通过指定 String 类构造函数的第二个参数为“Unicode”,从而实现了字节数组转换为字符串的功能。 除了使用 String 类的 getBytes() 方法和构造函数进行编码转换,我们也可以使用 Java 标准类库中的 Charset 类和 CharsetEncoder、CharsetDecoder 类来进行编码转换。不过相比于使用 String 类的方法,使用 Charset 类进行编码转换的代码会稍微复杂一些。 总之,在 Java 语言中,我们可以通过多种方法实现 Unicode 编码转换为字符或者字符转换为 Unicode 编码的功能。在实际工作中,我们应该根据具体的需求和场景,在多种方法中选择最适合自己的方法来进行编码转换。 ### 回答3: Java中使用Unicode编码可以支持多种语言和字符,但有时候需要把Unicode编码转换成字符,或将字符转换成Unicode编码。在Java中,可以使用各种API来完成这些转换。 Unicode编码是一种用来表示字符的标准编码方式,其中每个字符对应一个唯一的Unicode码点。在Java中,我们可以使用char类型来表示一个Unicode码点,用"\u"后面跟上4个十六进制数字表示。例如,'\u0041'表示字符"A"的Unicode编码。 如果我们需要将Unicode编码转换为字符,我们可以使用Java中的Character类的静态方法chr(),这个方法接受一个Unicode码点作为参数,并返回与之对应的字符。例如,如果我们要把Unicode码点为\u0041转换为字符,我们可以使用如下代码: char a = Character.chr(0x0041); 如果我们需要将字符转换为Unicode编码,则可以使用Java中的Character类的静态方法codePointAt(),这个方法接受一个字符串和一个索引作为参数,并返回索引处的字符Unicode码点。例如,如果我们想要获取字符"A"的Unicode编码,我们可以使用如下代码: int unicode = Character.codePointAt("A",0); 除了Character类的API之外,还有其他的工具类和API可用于Unicode编码字符转换。例如,使用Java中的UnicodeEscape类可以将字符串中的非ASCII字符转换为Unicode编码,使用String类的getBytes()方法将字符转换为字节数组时,可以指定使用某种字符编码,也可以使用Java中的Charset类指定字符集。这些工具和API可以根据需要使用。 总之,在Java中使用Unicode编码进行字符表示是很常见的。但如果需要在不同的编码之间进行转换,我们必须使用合适的API和技术,确保数据的正确性和一致性。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值