1. 需要先了解下什么是ASCII编码

了解base64之前先了解一下ASCII编码,所有的数据在存储和运算时都使用二进制数表示,二进制数有0和1两种状态,ASCII码就是用7位或8位二进制数组合来表示128 或 256种字符,从0000 0000 到 0111 1111 是基础ASCII码用后7位表示了128个字符,其中0~31是控制字符不可显示字符,在256个字符中后128个是扩展ASCII 字符,用来表示特殊符号字符、外来语字母和图形符号。不同的国家有不同的字母,例如130在法语编码中代表了é,在希伯来语编码中却代表了字母Gimel (ג),在俄语编码中又会代表另一个符号,并且其中也有不可显示字符。

2. 为什么使用 base64 原因

ASCII编码中有很多是不可见的字符,不可见字符指的是这些字符在屏幕上显示不出来,在网络交换数据的时候,要经过多个路由设备,由于不同的设备处理字符的方式不同,这样不可见的字符就有可能会被处理错误,不利于传输,或者一些特定的设备或者网络中,不可见字符往往被作为非法字符而不允许传递,例如传统的邮件只支持可见字符传输,或者图片的二进制流,每个字符不可能都是可见字符,所以会先做一个base64编码,把不可见字符转换成可见字符,这样出错的概率就大大降低了。

3. base64 编码原理解析

Base64编码使用64个字符来对任意数据进行编码,同理还有Base32、Base16编码,标准的base64编码使用的下面的64个字符,这64个字符是各种字符编码例如ASCII编码的子集,并且可以打印。

3.1. base64编码的几个步骤?

1、将待编码的字符串转换成二进制形式表示出来

2、3个字节为一组,即24个二进制数为一组

3、将24个二进制数,每6个一组,分成4组,每组前面添加两个00将6个二进制数补全为8个二进制数,从原来的3个字节变成4个字节

4、计算这四个字节对应的十进制,然后根据上面的表格得出对应的字符串,拼接字符串形成最后的base64编码

举个例子

假设我们要对 Hello! 进行Base64编码,按照ASCII表,其转换过程如下图所示:

如果原始字符字节长度不能被3整除,则使用0来补充,以 Hello!! 为例,其转换过程为:

Hello!! 这个字符串的字节长度不能被3整除,缺少两个字节,所以用0补充,补充后的0转换为base64的字符为AA,在原始字符中并没有对应的字符,所以需要特殊处理,以免解码错误。标准Base64编码通常用 = 字符来替换最后的 A,即编码结果为 SGVsbG8hIQ==。因为 = 字符并不在Base64编码索引表中,其意义在于结束符号,在Base64解码时遇到 = 时即可知道一个Base64编码字符串结束。

3.2. 按位操作符

了解源码之前我们先要学习一下按位操作符,按位操作符是将操作数当作32位的比特序列(0和1组成),不是十进制、十六进制、八进制,是操作数字的二进制形式,返回的是标准的js数值(10进制)。所有操作数都会被转换成有符号的32位整数(0和1组成),有符号指当一个正数的时候左边第一位是0,负数的时候左边第一位是1。

3.2.1. & (按位与)

对每一对比特位执行与(AND)操作。只有 两个都 1 时是 1,否则为 0。

3.2.2. | (按位或)

对每一对比特位执行或(OR)操作,一个为 1 则为 1

<< (左移)

该操作符会将第一个操作数向左移动指定的位数。向左被移出的位被丢弃,右侧用 0 补充

在数字 x 上左移 y 比特得到 x * 2y,9<<2  = 9乘以2的2次方 = 36,1001左移两位,右侧用00填充,结果为100100

3.2.3. >> (有符号右移)

该操作符会将第一个操作数向右移动指定的位数。向右被移出的位被丢弃,拷贝最左侧的位以填充左侧。

9>>2,1001 右移两位,被移出的将被丢弃,并且拷贝左侧的0填充,0010

3.2.4. base64 源码

_.base64Encode = function(data) {
  if (typeof btoa === 'function') {
    return btoa(encodeURIComponent(data).replace(/%([0-9A-F]{2})/gfunction (match, p1) {
      return String.fromCharCode('0x' + p1);
    }));
  }
  data = String(data);
  var b64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
  var o1, o2, o3, h1, h2, h3, h4, bits, i = 0, ac = 0, enc = '', tmp_arr = [];
  if (!data) {
    return data;
  }
  data = _.utf8Encode(data);
  do {
    o1 = data.charCodeAt(i++);
    o2 = data.charCodeAt(i++);
    o3 = data.charCodeAt(i++);    bits = o1 << 16 | o2 << 8 | o3;
    
    // 4个字符串的索引值
    h1 = bits >> 18 & 0x3f;
    h2 = bits >> 12 & 0x3f;
    h3 = bits >> 6 & 0x3f;
    h4 = bits & 0x3f;
    
    tmp_arr[ac++] = b64.charAt(h1) + b64.charAt(h2) + b64.charAt(h3) + b64.charAt(h4);
  while (i < data.length);  enc = tmp_arr.join('');  switch (data.length % 3) {
    case 1:
      enc = enc.slice(0, -2) + '==';
      break;
    case 2:
      enc = enc.slice(0, -1) + '=';
      break;
  }

  return enc;
};

以“hello”为例,调用sensors._.base64Encode(“hello”)

1、do循环中会先调用charCodeAt,获取三个字符对应的十进制,当我们做位运算操作的时候,会把三个字符的二进制拼接为一个32位的比特序列


o1 : 104 (base 10)   =   0110  1000 (base 2)
o2 : 101 (base 10)   =   0110  0101(base 2)
o3 : 108 (base 10)   =   0110  1100(base 2)

              o1<<160000 0000 0110 1000 0000 0000 0000 0000 (base 2)
               o2<<80000 0000 0000 0000 0110 0101 0000 0000 (base 2)
                  o3 =  0000 0000 0000 0000 0000 0000 0110 1100 (base 2)
o1<<16 | o2<<8 | o3  =  0000 0000 0110 1000 0110 0101 0110 1100  (base 2

2、0x3f 对应的十进制是63,转换为二进制是 6个1 : 111 111,&操作两个二进制都为1的情况下是1,所以当一个数&0x3f,返回最大的值是63

h1: ( bits >> 18 )      = 0000 0000 0000 0000 0000 0000 0001  1010
        0x3f            = 0000 0000 0000 0000 0000 0000 0011  1111
                        = 0000 0000 0000 0000 0000 0000 0001  1010 
                        bits >> 18 = o1前六位= 0110 10  
                        h1 =  011010 & 111111 = 011010 =  parseInt(11010,2) = 26
| ------------- | ------------- |

h2: ( bits >> 12 )      = 0000 0000 0000 0000 0000 0110 1000  0110  
        0x3f            = 0000 0000 0000 0000 0000 0000 0011  1111
                        = 0000 0000 0000 0000 0000 0000 0000  0110  
                        bits >> 12 = o1后两位(00)+o2前四位(0110) = 000110
                        h2 = 000110 & 111111 = 000110 =parseInt(110,2) = 6
| ------------- | ------------- |

h3: ( bits >> 6 )       = 0000 0000 0000 0001 1010 0001 1001  0101  
        0x3f            = 0000 0000 0000 0000 0000 0000 0011  1111
                        = 0000 0000 0000 0000 0000 0000 0001  0101
                        bits >> 12 = o2后四位(0101)+o3前两位(01) = 010101
                        h3 = 010101 & 111111 = 010101 =parseInt(10101,2) = 21
| ------------- | ------------- |

h4: ( bits  )           = 0000 0000 0110 1000 0110 1000 0110 1100  
        0x3f            = 0000 0000 0000 0000 0000 0000 0011 1111
                        = 0000 0000 0000 0000 0000 0000 0010 1100
                        o3 的后六位 = 101100
                        h4 = 101100 & 111111 = 101100 = parseInt(101100,2) = 44
| ------------- | ------------- |

上面可以看到最终h1,h2,h3,h4或操作后,返回的是每个二进制对应的十进制的值,然后根据这个十进制的值在找到对应的字符,然后在拼接到一起就是对应的base64的值了,charAt() 方法可返回指定位置的字符,b64是64个字符拼接在一起的字符串,b64.charAt(26),即返回b64的26索引的字符串。最终hel的base64的编码是 “aGVs”,把这个字符串存储到tep_arr数组中。

3、判断i=3<5,执行下一次循环,o1是获取字符”l”的unicode 值,o2是获取字符 “o”的unicode 值,因为hello没有第六个字符,所以o3是NaN

   o1 = 108 (base10)= 0110 1100(base2)
   o2 = 111 (base10)= 0110 1111(base2)
   o3 = NaN
   
   位运算之后,第三个字符用0补充
   bits = o1 << 16 | o2 << 8 | o3  = 0000 0000 0110 1100 0110 1111 0000 0000

   h1 =  bits >> 18 & 0x3f  = 011011 & 111111 = 27
   h2 =  bits >> 12 & 0x3f  = 000110 & 111111 = 6
   h3 =  bits >> 6  & 0x3f  = 111100 & 111111 = 60
   h4 =  bits       & 0x3f  = 000000 & 111111 = 0

根据h1到h4的值,去找到对应的字符,最终”lo”的base64是bG8A,此时数组tmp_arr=[“aGVs”,“bG8A”],然后判断i=6大于data.length=5,跳出循环,拼接tmp_arr,判断data能否被3整除,例如hello长度为5,5%3=2,则表示转换后的base64最后有一个A,将最后一个A替换为“=”。

3.3. 为什么 base64 前要转换 utf-8

3.3.1.  Unicode

因为扩展ASCII在不同的国家有不同的编码方式,同一个二进制的数字可以解释成不同的符号,因此出现了Unicode,Unicode是将所有的符号都纳入其中,每一个符号定义一个编码,是一个大的符号集,但是只定义了符号的二进制,并没有规定这个二进制的代码应该如何存储。比如,汉字“严”的 Unicode 是十六进制数4E25,转换成二进制数有15位(100111000100101),也就是说,这个符号的表示至少需要2个字节。表示其他更大的符号,可能需要3个字节或者4个字节,甚至更多。,这就出现一个问题,例如怎样区分Unicode 和 ASCII ,计算机怎么知道三个字节是一个符号,还是一个字节是一个符号,如果统一用三个字节,那英文字母用一个字节就可以表示了,这样就是一个空间浪费。

3.4. UTF-8

UTF-8是unicode的一种存储方式,其他实现方式还包括 UTF-16(字符用两个字节或四个字节表示)和 UTF-32(字符用四个字节表示),现在使用最广的是UTF-8,UTF-8包含全世界所有国家需要用到的字符,是国际编码,通用性强,可以在各国支持UTF8字符集的浏览器上显示。UTF-8支持简体中文字、繁体中文字、英文、日文、韩文等语言(支持文字更广)

UTF-8 它是一种变长的编码方式。它可以使用1~4个字节表示一个符号

  1. 128个US-ASCII字符只需一个字节编码(Unicode范围由U+0000至U+007F)。
  2. 带有附加符号的拉丁文、希腊文、西里尔字母、亚美尼亚语、希伯来文、阿拉伯文、叙利亚文及它拿字母则需要两个字节编码(Unicode范围由U+0080至U+07FF)。
  3. 其他基本多文种平面(BMP)中的字符(这包含了大部分常用字,如大部分的汉字)使用三个字节编码(Unicode范围由U+0800至U+FFFF)。
  4. 其他极少使用的Unicode 辅助平面的字符使用四字节编码

如果一个字节的第一位是0,则这个字节单独就是一个字符;如果第一位是1,则连续有多少个1,就表示当前字符占用多少个字节,并且后面的字节前两位设为10。

Javascript内部的字符串,是以UTF-16的形式进行保存,为了在加密解密中文字符不出现乱码,所以需要在将中文字符编码成base64之前,先将UTF-16 转换成 UTF-8 (这里只考虑中文字符是UTF-8的情况),然后再应用base64编码规则进行编码得到最终结果。

在解码的时候,同样将解码出来的UTF-8在转换成对应的UTF-16格式。

参考地址:

  1. 阮一峰-base64笔记
  2. 按位操作符
  3. base64的编码和解码

 

每日一问的答案中可能无法全完解读这个问题,如果您是相关技术专家或者是对本问题有自己的见解,欢迎带着「批判性」的态度阅读,指正其中的不足。