前端字符编码小结

导语

本文源于微信游戏春节王者摇心愿活动英雄语音祝福自定义输入模块开发过程,对踩过的前端字符编码的坑进行记录总结。

Unicode 字符

Unicode(中文:万国码、国际码、统一码、单一码)是计算机科学领域里的一项业界标准。它对世界上大部分的文字系统进行了整理、编码,使得电脑可以用更为简单的方式来呈现和处理文字。

简单地来说,Unicode 是一种字符编码,它规定用一个码点表示一个字符,其范围为 U+0000~ U+10FFFF , 可以表示超过100万个符号。Unicode 分成17个平面,其中第1个平面称谓基本平面(也称 BMP),其范围为 U+0000~ U+FFFF,另外16个平面称之为辅助平面,每个辅助平面拥有65536(即 2^16)个字符。

Unicode 只规定了字符编码,而并没有规定具体的编码方式。因此就产生了不同的编码方式,包括 UTF-8、UTF-16、UTF-32 等等。

UTF-16

本文主要介绍 UTF-16 编码,不涉及 UTF-8、UTF-32 等其他编码方式,需要扩展阅读请自行查阅。
UTF-16 是一种变长的编码方式,可以用2个字节或者4个字节来编码 Unicode 字符。UTF-16 使用两个字节编码 Unicode 字符中的基本平面的字符,使用 4 个字节编码 Unicode 字符中的辅助平面的字符。
前端字符编码小结

UTF-16 使用变长字节的编码方式,那么如何判断一个字符是基本平面字符还是辅助平面字符?
UTF-16 规定了BMP中,从 U+D800U+DFFF 之间BMP的区段是永久保留不映射到字符,可以利用这段区间来编码辅助平面的字符。
简单来说,从左到右扫描,发现前两个字节不在 U+D800U+DFFF 之UTF间,则可认定这两个字节组成了一个基本平面的字符,发现前两个字节处于 U+D800U+DFFF 之间,则需要读取下两个字节,拼凑成四个字节组成一个辅助平面的字符。

前面提到辅助平面有16(即 2^4)个,每个辅助平面拥有65536(即 2^16)个字符,因此辅助平面共有 2^20个字符,也就是说需要 20 位二进制位来对应这些字符。

16 * 65536 = 2^4 * 2^16 = 2^20

U+D800U+DFFF 之间刚好有 2^11 个码元,因此 UTF-16 使用 U+D800U+DBFF 之间(共有2^10个)码元作为高位, U+DC00U+DFFF 之间(共有 2^10 个)作为低位,这样子高低位 4 个字节组成的编码方式(代理对)就可以表示一个辅助平面的字符了。

其中,辅助平面字符 Unicode 到 UTF-16 代理对的转换规则如下( c 表示 Unicode 的码元,H 表示代理对的高位字节,L 表示代理对的低位字节):

H = Math.floor((c - 0x10000) / 0x400) + 0xD800 
L = (c - 0x10000) % 0x400 + 0xDC00

以上面的音乐字符为例,其 Unicode 字符的码元为 U+1F3B6,可以通过 https://codepoints.net/ 查询到对应字符信息

> H = Math.floor((0x1F3B6 - 0x10000) / 0x400) + 0xD800 
0xd83c

> L = (0x1F3B6 - 0x10000) % 0x400 + 0xDC00
0xdfb6

通过上面的转换规则可以算出其代理对为 \ud83c\udfb6

UCS-2

UCS-2 是 UTF-16 未出世之前的一种编码方式,可以简单理解为 UTF-16的子集。它采用定长2字节编码,因此只能表示基本平面的字符,对于辅助平面字符,它只能理解为这是 “两个基本平面字符” ,无法正常表示。

javascript的编码方式

好了,进入正题了。前面讲了 UTF-16 和 UCS-2,那么 javascript 到底是采用什么编码的呢?
这个要分情况来讲,javascript 引擎采用 UTF-16 编码,而 javascript 语言本身的设计是采用 UCS-2 编码方式
因此,当我们使用 UCS-2 编码方式设计的 javascript 接口来处理 UTF-16 编码的字符,就会出现很多问题。
比如:
前端字符编码小结

那么如何解决这两者编码方式不一致造成的问题呢,有两种方式:

ES6

新版本的ECMA Script提供了新的API来正确处理字符
前端字符编码小结

正则

利用正则表达式对其修正(项目也是采用这种方式)

var regexAstralSymbols = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g
// 获取字符的长度
function countSymbols(string) {
    return string
        // 把代理对改为一个BMP的字符.
        .replace(regexAstralSymbols, '_')
        // …这时候取长度就妥妥的啦.
        .length;
}

// 获取前6个字符
function sliceSymbols(str, limit) {
    var output = [];
    var index = 0;
    var oldStr = str;
    str = str.replace(regexAstralSymbols, function(input, offset, match) {
        if( offset > index ) {
            output = output.concat(match.slice(index, offset).split(""));
        }
        index = offset + input.length;
        output.push(input)
        return "";
    });

    if( index < oldStr.length  ) {
        output = output.concat(oldStr.slice(index, oldStr.length).split(""));
    }
    return output.slice(0, limit).join("");
}

实现效果如下:
前端字符编码小结

上面的解决方法基本可以解决大部分的字符问题,但是在遇到某些emoji表情依然会有些问题。

emoji

emoji表情符号是一种象形文字(图片符号),通常以丰富多彩的形式呈现并在文本中以内联形式使用,起源于日本。Unicode 对 emoji 表情做了划分范围,大部分属于辅助平面字符,目前 Unicode 中收录的 emoji 表情达到了 2700多个。因此,在大部分情况下,使用UTF-16的代理对来处理emoji 表情是没有问题。但在 emoji 表情中,还存在着一些字符(Emoji Sequences),它们没有显示的样式,主要起着连接、控制等作用。目前有下面几种:

控制符 <U+FE0E> 和 <U+FE0F>

<U+FE0E>, 作用是让基础Emoji 变成更接近文本样式( text-style )。
<U+FE0F>, 作用则是让基础Emoji 变成更接近Emoji样式( emoji-style )。
前端字符编码小结

零宽连接符 <U+200D>

emoji 除了单个 emoji 符号,还可以通过零宽连接符将多个 emoji 连接成一个 emoji。比如 \ud83d\udc68是表示一个 man,\ud83c\udf93表示一个学士帽,这两个通过零宽连接符连接起来 \ud83d\udc68\u200d\ud83c\udf93就表示一个男学生了。
前端字符编码小结

因此,为了解决emoji这些Emoji Sequences,将正则进行扩展:

var regexAstralSymbols = /[\uD800-\uDBFF][\uDC00-\uDFFF][\u200D|\uFE0F|\uFE0E]|[\uD800-\uDBFF][\uDC00-\uDFFF]/g;

除了以上两种比较常见的 Emoji Sequences,其实还有 Keycap Sequence, Flag Sequence, Tag Sequence, Modifier Sequence等字符,可以参考这里

参考链接

https://mathiasbynens.be/note...
https://mathiasbynens.be/note...
https://codepoints.net/
http://www.alloyteam.com/2016...
http://unicode.org/emoji/
https://unicode.org/emoji/cha...
http://unicode.org/emoji/char...

相关推荐