这次彻底搞懂Unicode编码和UTF-8、UTF-16和UTF-32

3,235 阅读12分钟

这篇文章是自己想搞清楚Unicode编码和UTF-8、UTF-16和UTF-32之间的关系做的简单总结和记录,一是方便自己后期查看,二是希望能帮助同样想了解Unicode编码的人;最后一节引入了base64编码的原理和简单实现,主要是在网上看到太多文章在讲base64都在讲加密相关的,没有说明base64的本质,很容易误导新人,因为unicode也是编码,所以顺便记录一下base64的本质

unicode是什么

unicode官方中文名是统一码,是计算机科学领域的业界标准,它整理、编码了世界上大部分的文字。

它的目标:给世界上的所有字符提供唯一标识符。

它解决了什么问题?

传统字符编码方案存在一个局限,就是不同国家之间使用会存在兼容性问题(应该是字符集不够大,一些特殊情况照顾不到)。

unicode和UTF-8、UTF-16、UTF-32的关系

UTF:Unicode Transformation Format(unicode转化格式)

UTF-8、UTF-16和UTF-32只是unicode不同实现方式,就是怎么去存储字符。

码点(code points):我们知道unicode的目标是给字符提供唯一标识符,这个唯一标识符就是一个数字,也就是所谓的码点。

unicode目前使用17个平面(plane),每一个平面有2^16(65536)个代码点,可以表示65536个字符;17个平面就是0x10ffff个码点,目前unicode使用数字就是0-0x10ffff(不到三字节)。 unicode平面内容.png

UTF-32

UTF-32就是使用4个字节来存储字符,所有的unicode都能够直接表示,简单粗暴,但是存在一个问题,就是存储空间利用率太低。

具体实现:

码点编码
0x10ffff00000000 00010000 11111111 11111111

unicode最大码点空间利用率都不足2 / 3,这样会浪费很多存储空间,对于网络传输也是一种负担。

UTF-16

多语言基本平面:也有一些地方称为基本多文种平面(Basic Multilingual Plane,BMP),就是unicode的第一个平面,码点范围:0 - oxffff

扩展平面( supplementary planes):不在第一个平面的码点都属于扩展平面,扩展平面一共有16个平面

具体实现:

码点编码
0x0000 - 0xffffxxxxxxxx xxxxxxxx
0x010000 - 0x10ffff110110xx xxxxxxxx 110111xx xxxxxxxx
  1. 对于基本平面的码点使用两个字节进行编码

  2. 对于扩展平面的码点使用四个字节进行编码;但是这样的话就会有一个问题,计算机在解析的时候怎么知道一个扩展字符是表示一个字符还是表示两个字符?

    解决方案:引入代码对(surrogate pairs),基本平面保留了两段代码点,不表示任何字符,这两段字符就是代码对;两字节一组,一对代码对就是四字节,一个高位代理(迁到代理)和一个低位代理组成一个代理对

utf-16代理对.png

  1. 具体规则如下:

    1. 代码点减去0x010000,得到一个0x000000 - 0x0fffff(最多20位)的数字
    2. 对于不足20位的左边填0,补充为20位,然后均分为两份(yyyyyyyyyyxxxxxxxxxx),高位的(前面的)10位+D800(110110yyyyyyyyyy)得到第一个码元或者代理对,低位的(后面的)10位+DC00(110111xxxxxxxxxx)得到第二个码元或者代理对。
    3. 这样就得到两个码元或者代理对,在解析的时候按照相反的规则进行解析,代理对必须是成对出现,如果解析的时候不是成对出现说明编码有问题,解析失败。

UTF-8

UTF-8和UTF-16一样,也是一种可变长度字符编码,UTF-16有2字节和4字节两种长度,UTF-8则可以用1-4个字节表示unicode字符集中的任意一个字符,具体如下:

码点编码
0x0000 - 0x007f0xxxxxxx(7位最多表示:7f)
0x0080 - 0x07ff110xxxxx 10xxxxxx(11位最多表示:7ff)
0x00800 - 0x00ffff1110xxxx 10xxxxxx 10xxxxxx(16位最多表示:ffff)
0x010000 - 0x10ffff11110xxx 10xxxxxx 10xxxxxx 10xxxxxx(20位最多表示:fffff)

UTF-8兼容ASCII编码,UTF-8的前128个码点,采用和ASCII码完全相同的二进制值编码,同时字面也和ASCII字面保持一致。

具体编码规则如下:

UTF-8使用前缀来标记当前编码的字符使用几字节

  1. 如果当前字节是0开头,则当前字节独立表示一个字符
  2. 如果当前字节第一位是1,第二位是0的,则当前字节是多字节字符中的一个字节
  3. 如果当前字节第一位是1,第二位也是1,第三位是0,则当前字节是表示两个字节的字符中的第一个字节
  4. 如果当前字节第一位是1,第二位也是1,第三位也是1,第四位是0,则当前字节表示三个字节的字符中的第一个字节
  5. 如果当前字节第一位是1,第二位也是1,第三位也是1,第四位也是1,第五位是0,则当前字节表示四个字节的字符中的第一个字节

简单总结就是:分为两部分,字符的第一字节算第一部分,剩下的其他字节算第二部分;第一部分标志当前字符使用几个进行编码,0开头表示是单字节字符,其他情况下看是几个1开头,有几个1当前字符就采用几个字节编码;第二部分就是比较固定,10开头,后面补上未表示完的位。

字节序

字节序(Byte Order Mark):几乎在所有的机器上,多字节对象(unicode字符)被存储为连续的字节序列,字节需就是指多字节数据存储时字节的排列顺序。一个多字节对象的低位放在较小的地址处,高位放在较大的地址处,称为小端序(little-endian);一个多字节对象的低位放在较大的地址处,高位放在较小的地址处,称为大端序(big-endian)。

例子:如果有一个变量x,位于地址0x100处,它的值是0x01234567,它的地址范围为0x100 - 0x103,如果用小端序存储为:0x100: 0x67、0x101: 0x45、0x102: 0x23、0x103: 0x01,用大端序存储则为:0x100: 0x01、0x101: 0x23、0x102: 0x45、0x103: 0x67。

仔细查看上面示例有没有发现大端序的存储方式好像更适合硬件的存储方式,这里可以去看一下阮老师讲字节序一篇的文章:理解字节序;里面简洁的总结了字节序的一些内容。

编码方式BTM
UTF-16 LEFF FE
UTF-16 BEFE FF
UTF-32 LEFF FE 00 00
UTF-32 BE00 00 FE FF

和js的关联

  1. charCodeAt:返回给定索引处字符的码点,范围是0 - 65535之间(16位)
  2. String.fromCharCode:将码点(0-65535)转为对应的字符,和charCodeAt方法是一对
  3. codePointAt:ES6为支持钱UTF-16新增的方法,根据索引提取给定字符串的unicode码点
  4. String.fromCodePoint:将码点转位对应的字符,和codePointAt方法是一对
  5. 正则表达式u标志:js中的正则匹配假定单个字符使用一个16位的码元表示,对于大于65535的会被识别为两个字符,使用u标志将字符串当作unicode字符处理

示例如下:

const str = '𠮷'; // \uD842 \uDFB7
str.length; // 2
str.codePointAt(0); // 134071
str.codePointAt(1); // 57271str.charCodeAt(0); // 55362
str.charCodeAt(1); // 57271const reg = /^.$/;
reg.test(str); // false
const regU = /^.$/u;
regU.test(str); // true

额外话题:base64编码

首先声明: base64只是一种编码方式,不是一种加密方式,但是可以用来做简单的数据脱敏处理(网上很多文章都在讲用base64加密,这里指正一下,base64是没有加密能力的,只能做简单数据脱敏;加密是只要密钥不泄露,你加密的内容就是安全的,但是base64根本做不到;有点技术知识的人一眼就能看出来是base64编码)

base64编码方式:就是选出64(2 ^ 6)个字符(A-Z、a-z、0-9、\、+)作为base64的字符集,然后加上一个填充字符=,实际上是65个字符,然后使用这些字符表示二进制数据

base字符集.png

编码规则如下:

  1. 三个字节一组,一共24位,然后将这24位分为四组——每组6位(2 ^ 6)
  2. 在每组前面补充00扩展成32位,总共四字节
  3. 根据上表将对应的码点转为对应的字符
  4. 如果整个二进制数据长度是三的倍数,一直循环前三步就行了,但是对3取余会有三种情况,分别是0、1、2,0刚好是三的倍数,上面已经处理
  5. 最后还剩二个字符,按照上面的规则,将其分为三组(6、6、4),最后一组除了前面补充00,后面也补充00,然后再填充一个=组成四个字符
  6. 最后还剩一个字符,按照上面的规则,将其分为两组(6、2),最后一组除了前面补充00,后面补充0000,然后再填充一个==组成四个字符

示例如下:

m的ascii码点:109 - 01101101

a的ascii码点:97 - 01100001

n的ascii码点为:110 - 01101110

  • 对字符串man进行base64编码,过程如下:

    1. m的ascii码点:109,a的ascii码点:97,n的ascii码点为:110
    2. 三个字符刚好一组,一共24位:011011010110000101101110,分为四组:011011 - 010110 - 000101 - 101110
    3. 011011:27 - b 、010110: 22 - W、000101:5 - F、101110:46 - u,结果为:bWFu
  • 当字符串只有ma时,base64编码过程如下:

    1. 第一步基本一样,只是只有两个字符;m的ascii码点:109,a的ascii码点:97
    2. 只有16位,只能分分为三组:011011 - 010110 - 0001,因为最后一组只有4位,后面填充2个0变为6位:011011 - 010110 - 000100
    3. 011011:27、 010110:22、 000100:4,得到结果只有3个字符,最后面填充1个=,变成4个字符,结果为:bWE=
  • 当字符串只有m时,base64编码过程如下:

    1. 第一步和上面差不多,只有一个字符m:109
    2. 一个字符只有8位,只能分为两组:011011 - 01,最后一组只有2位,后面填充4个0变为6位:011011 - 010000
    3. 011011:27 、 010000:16,只得到2个字符,最后面填充2个=,编程4个字符,结果为:bQ==

js base64原生支持:atob、btoa(binary to ASCII),使用该方法需要注意以下两点:

  1. btoa:因为这个函数将每个字符视为一字节(255),而不管实际组成字符的字节数是多少,所以如果任何字符的码位超出 0x00 ~ 0xFF 这个范围,则会引发 InvalidCharacterError 异常。
  2. atob:因为atob和btoa是配套的方法,所以在解码的时候也默认每个字符是一个字节,如果对二进制流编码的数据使用该方法进行解码,得到的结果一定不是原来的内容(除非二进制流里面的所有数据都只占用一个字节)。

base64编码简单实现:

const b64ch = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
const b64chs = Array.prototype.slice.call(b64ch);
function base64Encode (bin) {
    let u32, c0, c1, c2, asc = '';
    const pad = bin.length % 3;
    for (let i = 0; i < bin.length;) {
        if ((c0 = bin.charCodeAt(i++)) > 255 ||
            (c1 = bin.charCodeAt(i++)) > 255 ||
            (c2 = bin.charCodeAt(i++)) > 255)
            throw new TypeError('The string to be encoded contains characters outside of the Latin1 range');
        u32 = (c0 << 16) | (c1 << 8) | c2;
        asc += b64chs[u32 >> 18 & 63]
            + b64chs[u32 >> 12 & 63]
            + b64chs[u32 >> 6 & 63]
            + b64chs[u32 & 63];
    }
    return pad ? asc.slice(0, pad - 3) + "===".substring(pad) : asc;
};

base64解码简单实现:

const b64ch = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
const b64tab = ((a) => {
    let tab = {};
    a.forEach((c, i) => tab[c] = i);
    return tab;
})(b64chs);
const b64re = /^(?:[A-Za-z\d+/]{4})*?(?:[A-Za-z\d+/]{2}(?:==)?|[A-Za-z\d+/]{3}=?)?$/;
const _fromCC = String.fromCharCode.bind(String);
function base64Decode (asc) {
    asc = asc.replace(/\s+/g, '');
    if (!b64re.test(asc))
        throw new TypeError('malformed base64.');
    asc += '=='.slice(2 - (asc.length & 3));
    let u24, bin = '', r1, r2;
    for (let i = 0; i < asc.length;) {
        u24 = b64tab[asc.charAt(i++)] << 18
            | b64tab[asc.charAt(i++)] << 12
            | (r1 = b64tab[asc.charAt(i++)]) << 6
            | (r2 = b64tab[asc.charAt(i++)]);
        bin += r1 === 64 ? _fromCC(u24 >> 16 & 255)
            : r2 === 64 ? _fromCC(u24 >> 16 & 255, u24 >> 8 & 255)
                : _fromCC(u24 >> 16 & 255, u24 >> 8 & 255, u24 & 255);
    }
    return bin;
};

上面base64编码和解码的实现,是摘抄自js-base64的base64的ployfill实现

通过了解unicode编码和base64编码,大致能够发现,编码似乎都是面向二进制数据的。

参考文章