相关文章推荐
爱喝酒的楼房  ·  Android OpenSL ES ...·  3 月前    · 
寂寞的哑铃  ·  如何使用Open ...·  5 月前    · 

当字符串遇上Emoji

引子

先来看看上面的几个例子:

  • 一个笑脸的长度是2
  • " ".slice(1),结果出现了一个unicode码
  • 一个家庭的Emoji获取长度结果得到了11

我们这篇文章将要解答以下问题:

  1. 字符串的长度真的是字符的个数吗?
  2. 字符串截取操作为什么可能出现了unicode?
  3. 一个Emoji的长度到底可能有多长?

关于编码的一些基础知识

在解决上述疑问时,先补充一些基础知识。
unicode
Unicode是一套字符编码方案,目标是把全世界的字符用数字编号编起来。( 划重点,unicode给每个字符分配了一个独一无二的编号。 )在7.0版中,一共收入了109449个符号。

Code point 码点
每个符号在unicode中的编号,就叫做码点。这个网站能看到字符和码点的对应关系:
unicode-table.com/

编号为U+0041的字符为大写字母A;编号为U+004F的字符为大写字母O

Plane 平面
unicode的符号码位不是连续的,而是分区定义。每个分区大小为16^4。编号u+0000~u+FFFF所在区域称为 基本平面 (缩写BMP),所有最常见的字符都放在这个平面,这是Unicode最先定义和公布的一个平面。
剩下的字符都放在辅助平面(缩写SMP),码点范围从U+010000一直到U+10FFFF。


几种编码格式

我们经常会听到"UTF-8", "UTF-16"这些编码格式。其中UTF 的全称为 Unicode/UCS Transformation Format,即Unicode字符转换为某种格式之意。(划重点: unicode只是给字符分配了一个编号,UTF-16、UTF-8解决了如何在计算机中存储、表示这些字符或者说编号的问题
UTF-32
由上文可以看出,一个字符最大的码点可能是`U+10FFFF`, 最多使用三个字节。而UTF-32编码使用4个字节表示每个字符,例如A的表示为: 0x00000047.这种编码会带来存储空间的极大浪费。所以极少使用。

UTF-16
该编码把两个字节长的比特位为一组,称作 码元 。那么每个码元有16个比特位,总共可以表示2^16种字符。或者说,我们可以把基本平面的每个字符用一个码元表示起来。
那么对于编号大于u+FFFF的字符怎么办?这时utf-8会用两个码元来表示。表示一个字符的两个码元叫做 代理对 (Surrogate Pair)。所有不常用的、不在基础平面的字符都需要两个码元,即4个字节长度的位置来存储。
那么我在解析的时候,怎么知道某个码元自己代表一个字符,还是跟相邻的另一个码元一起来表示一个字符呢?
这里另外一个小知识是,unicode编码的 u+{D800}~u+DBFF, 是不映射任何字符的。所以当解析到一个码元位于 D800-DBFF之间时,就知道需要和后边的一个码元一起解析。


例如,对于UTF-16编码出来的以下数据:
'\u0041\ud842\udfb7'
1、0x0041小于0xD800,所以它单独表示一个字符,查找unicode字符对照表可以查出表示大写字母: A
2、\ud842介于 D800-DBFF,需要和后边的码元一起解析。'\ud842\udfb7'一起解析出' '字,这是一个古汉语,跟'吉'是同一个意思。



UTF-8
而UTF-8使用的是一种变长的编码方法,一个字符可能使用1,2,4个字节。越是常用的字符,字节越短,最前面的128个字符,只使用1个字节表示,与ASCII码完全相同,这里不细说。



JavaScript使用的字符编码规范

JavaScript 使用UCS-2编码
JavaScript 使用了 UCS-2 编码,UCS-2编码固定使用2个字节存储每个字符,所以它只能正确表示基本面的字符,即编码小于0xFFFF的字符。后来utf-16发布时兼容了UCS-2编码,可以理解为USC-2编码是UTF-16的一个子集。
这也解释了为什么码点超出 0xFFFF的字符都不能被JavaScript正常处理。如以下case:



ES6 对unicode 增强了支持

  1. 可以将码点放入大括号,来表示字符,包括非基础平面的字符:
"\u{20BB7}" 
// " " 
"\u{41}\u{42}\u{43}" 
// "ABC" 

2. 为字符串添加了遍历器接口,该遍历器能识别大于0xFFFF的码点。

所以对于前端开发者而言,以下几种case需要考虑:
字符串的长度真的是字符的个数吗?
我们先查一查string的长度是怎么定义的:

The length property of a String object contains the length of the string, in UTF-16 code units. length is a read-only data property of string instances. MDN

所以实际length表示的是字符串有几个码元( code unit )。
这时候我们就能解释为什么 ' '.length === 2了, 在unicode中的码位为:u+1F600 。在用UTF-16编码时,编码结果为:'\ud83d\u{de00}'。所以它的长度是2。
理论上所有码点超过0xFFFF的字符都会出现长度判断不正确的问题。


为什么字符串截取操作出现了unicode?



其实这也是因为截取的开始和结束位置是根据码元的长度计算的,所以出现了将一个字符的两个码元一分为二了。\ud83d是 的后一个码元。

为什么一个Emoji长度可能能达到11
从上文中我们可以确定的是,一个unicode字符用UTF-16编码表示一般只会占用一个码元(基16比特位)。对于不常见的字符,如Emoji或不常见的汉字,也可能会占用2个码元(即32比特位)。
那么怎么解释下面的行为呢?

' ‍ ‍ '.length  // 8 

因为还有一套Emoji规范,简单地说,该规范定义了Emoji几种合成的行为:

Emoji的合成行为

  1. 可以额外在人物的Emoji上修饰肤色 。语法格式是 <emoji>\ud83c[\udffb-\udfff] ,即 U+D83C 后面跟不同的几个值表示不同的肤色控制。肤色总共有5中,用\udffb-\udfff表示。
let colors = ['\udffb', '\udffc', '\udffd', '\udffe', '\udfff'] 
let defaultBoy = ' ' 
colors.map(color => defaultBoy + '\ud83c' + color) 
// ["  ", "  ", "  ", "  ", "  "] 
  1. 可以将多个Emoji合成为一个Emoji , 如 ‍ ‍ 实际上是由一个 ,一个 ,一个 合成的。具体语法为`<Emoji> U+200D <Emoji> U+200D <Emoji> ` 。U+200D表示将前后的Emoji合成的意思。
const man = ' ',women = ' ', daughter=' ', son = ' ' 
[man, women, son].join('\u200d') === " ‍ ‍ " 
[man, women, daughter, son].join('\u200d') 
" ‍ ‍ ‍ " 
' ‍ ‍ '.length // 8, 每个基础Emoji长度为2,每个连接符长度为1,所以总长为 8 
[...' ‍ ‍ '] // [" ", "‍", " ", "‍", " "] // 中间的空字符串其实就是 U+200D 
" \u200D \u200D " // " ‍ ‍ " 
  1. 键帽Emoji : 0-9*# , 是由0-9的unicode加上一个Emoji修饰符,加一个键帽修饰符表示的。
[0,1,2,3,4,5].map(num => num + '\ufe0f\u20e3') 
// ["0️⃣", "1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"] 


前端开发者需要注意的场景


如何正确地统计用户输入长度?
我们常常会在表单项上添加统计字数功能、或限制用户输入长度。
❌错误1: 对于字数统计的场景,直接使用了value.length: 那么可能就会出现出来的字数跟用户预期不符的情况。比如说用户刚刚输入一个Emoji,结果实时统计已输入长度已经到了2。

const userInput = ' '; 
console.log(`you have inputed ${userInput.length} characters`); 
// "you have inputed 2 characters" 

错误2:使用Textarea的maxLength限制用户输入: Textarea的maxLength实际上也是基于对码元的计数,而不是对字符的计数。

如上例子,限制了长度为10的textatea,结果输入五个笑脸表情之后,就再也无法输入更多字符了。

✅方案1:使用字符串的Iterator统计长度。
字符串的迭代器能正确识别代理对的情况,如下例子:

const testStr = '123 ' 
for(let c of testStr) { 
  console.log(c)