〇、狗非狗
首先,我们来看一段简单的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相关的内容将统一使用以下两种格式:
- 使用
U+十六进制码点值
的格式来表示一个Unicode码点,例如:U+10FF。注意,此格式仅用于描述,在实际定义中,每个码点都是数字,并没有前缀U+
。- 使用
起始码点..终止码点
的格式来表示一个连续的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+72D7 | CJK Unified Ideographs(U+4E00..U+9FFF) | 基本平面(U+0000..U+FFFF) |
🐶 | U+1F436 | Miscellaneous Symbols and Pictographs(U+1F300..U+1F5FF) | 1号补充平面(U+10000..U+1FFFF) |
二、Unicode编码格式
上一节说到,字符必须先被转换成数字才能写入内存。那么,现在有了Unicode字符集,是不是只要将码点直接写入内存就可以了呢?事实并非如此。例如,对于字符串狗🐶
,根据上文,可将其转换成十六进制码点序列72D71F436
,进一步转换成二进制码点序列就是11100101101011111111010000110110
。
如果现在要求你将上面这个二进制序列还原成字符串,在事先并不知道原始字符串的前提下,你还能准确判断从第一位到第几位表示的是第一个码点,而之后表示的是第二个码点吗?你可以将其分成1110010110101111,1111010000110110
,也可以分成11100101101011111,111010000110110
,而最后还原出来的结果将截然不同。
在不同位置分割二进制码点序列 | 十六进制码点序列 | 原始字符串 |
---|---|---|
111001011010111,11111010000110110 | U+72D7 U+1F436 | 狗🐶 |
1110010110101111,1111010000110110 | U+E5AF U+F436 | |
11100101101011111,111010000110110 | U+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则表示如何将码点的各个比特位映射到对应的字节中。
UTF-8除了节省空间,还具有兼容ASCII、容错率高、没有字节序问题等诸多优点。所以在日常开发中,像HTML、CSS和JS这些文件几乎都以UTF-8格式保存。在整个互联网中,大约98%的网页基于UTF-8格式(摘自维基百科)。
上文提到,大部分常用汉字都在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个字节)编码一个补充平面的码点。
- 将较高的10位与0xD800(
例如,🐶
在补充平面内,所以被编码为0xD83D 0xDC36
,整个编码过程如下表。
你可能会有疑问,🐶
的高位代理项——0xD83D,不是也可以当做是码点U+D83D在UTF-16中的编码结果吗,这样不会有冲突吗?实际上在Unicode中,直接将U+D800..U+DBFF和U+DC00..U+DFFF这两个区块定义为High Surrogates和Low Surrogates,专供UTF-16使用,其中所有码点都没有单独对应的字符。
综上,我们来看一下在UTF-16中是如何确定码点边界的。首先,每次读取2个字节:
- 如果值在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.from
和for..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目前这个提案处于Stage 4,并且Chrome和Safari已经支持。示例如下:
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、text-segmentation。这些库的实现原理是根据Unicode本身规定的GraphemeBreakProperty对文本进行切分,这里就不多做介绍了,有兴趣的小伙伴可以尝试阅读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),它可以大大简化编写Unicode字符相关的正则表达式的复杂度。
首先,让我们来了解下什么是Unicode字符属性。Unicode中的每一个字符都有许多属性,比如:Age
(首次被收录的Unicode版本)、Block
(所属区块)和General_Category
(所属类别)等。以狗
为例,下表列出了这个字符的部分属性(完整列表见此处)。
Property Name | Type | Value |
---|---|---|
Age | Catalog | 1.1 |
Block | Catalog | CJK_Unified_Ideographs |
General_Category | Enumeration | Other_Letter |
Unified_Ideograph | Binary | Yes |
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: '中英'
不过,目前在字符属性转义序列中只支持部分Unicode字符属性,比如Age
就不支持,但是ES规范中指定了必须支持的属性列表。如果不了解每个属性的用途,可以参考此文档。另外,Unicode官方也提供了工具,方便查看具有某个属性的所有字符。
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函数,方便在两种形式之间相互转换。
// 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…。
五、Unicode in HTML
在HTML中,除了直接输入字符本身之外,所有的Unicode字符还可以用字符实体(Entity) 来表示,格式为&#十进制码点值;
或&#x十六进制码点值;
,对于部分字符,还可以用&实体名称;
表示。例如:
Unicode字符 | 码点 | 十进制 | 十六进制 | 实体名称 |
---|---|---|---|---|
< | 0x003C | < | < | < |
🐶 | 0x1F436 | 🐶 | 🐶 |
如果在浏览器中渲染以下html片段,你将会看到3只一模一样的狗子。
<body>🐶 🐶 🐶</body>
不过,当我们通过JS将上面的内容插入到body中时,不同的方法将得到不同的结果,例如:
document.body.innerHTML = '🐶 🐶 🐶'; // result: 🐶 🐶 🐶
document.body.textContent = '🐶 🐶 🐶'; // result: 🐶 🐶 🐶
这是因为,innerHTML
将内容识别成HTML片段,所以会解析其中的字符实体,而textContent
则将内容当做普通文本插入。
下面这段代码会显示5只狗子。
document.body.innerHTML = '🐶 🐶 🐶 \u{1F436} \uD83D\uDC36';
5.1 JSX的字符实体变形记
对于日常开发使用React的同学来说,应该对JSX已经十分熟悉了。JSX看起来与HTML十分相似,但是在项目构建阶段会被Babel或者TypeScript编译器转成JS,然后再渲染到页面上。而在这个过程中,存在着一个与Unicode字符实体相关的隐秘细节。
先让我们来看一段react代码:
const LessThanExample = () => {
return <div>1 < 2</div>
}
这是一个特别简单的React组件,最后渲染出来的结果就是简单的1 < 2
。不过,因为在JSX中<
号也是保留字符,所以和在HTML中一样,需要使用对应的字符实体<
来表示。
这个组件还有另外这种写法可以避免保留字的冲突问题:
<div>1 {'<'} 2</div>
。
另一方面,React有一个特性就是能够有效防御XSS漏洞,这是因为React在渲染JSX的时候,都是通过textContent来输出到HTML中(除去dangerouslySetInnerHTML的部分),而不是innerHTML
。所以乍看之下,上面的组件最后被渲染时,应该类似下面这段代码:
const div = document.createElement('div');
div.textContent = '1 < 2';
那么,在浏览器上看到的结果就应该是1 < 2
,而不是1 < 2
。但是事实并非如此,这是因为Babel和TS编译器在编译JSX时,会把所有Unicode字符实体转换成字符本身。以TS编译器为例,相关代码如下:
Babel的代码与之大同小异,感兴趣的小伙伴可自行品读。
// https://github.com/microsoft/TypeScript/blob/1ade73df2bf3e83085ca4647d0a3339abe3f869b/src/compiler/transformers/jsx.ts#L484
/**
* Replace entities like " ", "{",
* and "�" 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 < 2'); // str = '1 < 2'
也就是说,经过Babel和TS编译之后,这个组件变成了类似下面这样:
const LessThanExample = () => {
// 编译后已经变成了1 < 2
return React.createElement("div", null, "1 < 2")
}
问题到这里其实并未完全结束,因为React还支持服务端渲染。对于1 < 2
这个字符串,如果在服务端渲染时直接被插入HTML中,就会造成浏览器解析HTML错误。所以在将字符串插入到HTML之前,ReactPartialRenderer会调用函数escapeTextForBrowser把<
再转换回<
。
六、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字符在浏览器中正确显示,我们还需要同时保证以下三点:
- 保存文件时,使用UTF-8编码。
- 对于HTML和CSS文件,需要在文件内声明编码格式,HTML为
<meta charset='utf-8'>
,CSS为@charset 'utf-8'
。注意,虽然在HTML5中规定默认编码是UTF-8,但是有些浏览器并非如此(如:Safari),所以建议还是显式设置编码格式。 - 在服务器端,在请求资源的HTTP响应头中设置文件类型和编码格式,例如:
content-type: text/html; charset=utf-8
。
八、总结
到此,本文的内容也就基本结束了。编写本文时,为了能在保证易懂的情况下,较为全面和准确地介绍Unicode及其在前端的应用,我前前后后花了两个多月时间阅读资料、设计案例、整理文章思路、修改文章内容。不过,我始终也是个半吊子水平,所以不免有错漏,希望小伙伴们积极指出。同时,我也略去了一些更加深入的内容,有兴趣的小伙伴可以根据文中的链接或下方的参考资料进一步学习。最后,如果硬要给本文下个结论,我觉得可能有以下两点:
- 在允许的情况下,始终优先选择输入Unicode字符本身,而不是转义序列或字符实体。
- 如果你将要处理的字符串中可能包含Emoji或多种语言,那么一定要小心了,有必要时请记得回来再翻翻这篇文章。
九、参考资料
- Unicode® 14.0.0: www.unicode.org/versions/Un…
- JavaScript's internal character encoding: mathiasbynens.be/notes/javas…
- JavaScript character escape sequences: mathiasbynens.be/notes/javas…
- JavaScript has a Unicode problem: mathiasbynens.be/notes/javas…
- Declaring character encodings in CSS: www.w3.org/Internation…
- Declaring character encodings in HTML: www.w3.org/Internation…
- ECMAScript® 2015 Language Specification: 262.ecma-international.org/6.0/
- Universal Coded Character Set: en.wikipedia.org/wiki/Univer…
- How to Add a Unicode Character to CSS "content" Property: www.designcise.com/web/tutoria…