ASCII、Unicode、UTF-8、base64 怎么区分?

232 阅读3分钟

【摘要】本文介绍 web 中常见的字符编码,包括 ASCII、Unicode,Base64,URL 编码。

ASCII

ASCII 是基于拉丁字母的一套编码系统,它主要用于编码英语和其他西欧语言。

ASCII 用一个字节中的后 7 位,规定了 128 个字符的编码 (27=1282 ^ 7 = 128),最高位闲置。它们包括:

  • 33 个控制字符,比如换行、换页、删除等字符
  • 95 个可显示字符,比如数字、英文字母 A-Z、a-z 和其他标点符号等。

ASCII 对照表截取(控制字符):

二进制十进制十六进制缩写可以显示的表示法名称/意义
0000 1001909HT水平定位符号
0000 1010100ALF换行键
0000 1011110BVT垂直定位符号
0000 1100120CFF换页键

ASCII 对照表截取(可显示字符):

二进制十进制十六进制图形
0010 00113523#
0010 01003624$
0011 000048300
0011 000149311
0100 00016541A
0100 00106642B

完整的 ASCII 对照表

EASCII

128 个字符对于英语来说足够了,但是对于其他语言显然不够。于是,人们便将闲置的那个最高位也利用起来,提出了 EASCII 码 ( extended ASCII )。

EASCII 扩充了表格符号、计算符号、希腊字母和特殊的拉丁符号。

但是,这不能解决问题。

以汉语为例,常用汉字就有数千个,而 8 位最多只能编码 256 个字符。我们可以用两个字节来编码汉字,那么就能编码 216=655362^{16} = 65536 个字符。

这依然没完全解决问题:

  • 汉字总数不只 65536 个(据资料,汉字总数超 8 万)
  • 如果在一份文本中,既出现汉字,又出现其他国家的文字,那么编码就会出问题
  • 如果有多种编码方式,那么在解码时方式不对,文件会乱码

所以,人们需要一种可以将所有字符都纳入其中的统一编码方式,于是就有了 Unicode。

Unicode

Unicode 和 ASCII 是兼容的,比如 ASCII 中字符 A 的编码是 0x41,Unicode 中是U+0041。可通过这个网站进行 Unicode 查询。

注意,Unicode 只是一个符号集,它只规定了符号的二进制代码,并没有规定这个代码如何存储。

比如汉字「文」的 Unicode 是U+6587,用二进制表示是0110 0101 1000 0111,需要两个字节来存储,对于编码值更大的字符,就需要更多字节。

如果对于所有 Unicode 中的字符,都用两个字节存储,那么对于英文等字符来说,必然有大量的位没有利用,造成空间浪费。

为此,我们需要更加节省空间的存储方式,UTF-8 就是其中一种。

UTF-8

UTF-8 是在互联网上使用最广的一种 Unicode 的实现方式。

其他方式还包括 UTF-16 ( 字符用 2 bytes 或 4 bytes 表示 ) 和 UTF-32 ( 字符用 4 bytes 表示 ),不过在互联网上基本不用。

重复一遍,这里的关系是,UTF-8 是 Unicode 的实现方式之一。

UTF-8 最大的一个特点,就是它是一种变长编码方式。它可以使用 1~4 个 bytes 表示一个符号,根据不同的符号而变化字节长度。

UTF-8 的编码规则很简单,只有两条:

  1. 对于单字节的符号,字节的第一位设为0,后面 7 位为这个符号的 Unicode 码。因此对于英语字母,UTF-8 编码和 ASCII 码是相同的。
  2. 对于n字节的符号(n > 1),第一个字节的前n位都设为1,第n + 1位设为0,后面字节的前两位一律设为10。剩下的没有提及的二进制位,全部为这个符号的 Unicode 码。

下表总结了编码规则,字母x表示可用编码的位。

Unicode符号范围      |        UTF-8编码方式
--------------------+---------------------------------------------
0000 0000-0000 007F | 0xxxxxxx
0000 0080-0000 07FF | 110xxxxx 10xxxxxx
0000 0800-0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx
0001 0000-0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

跟据上表,解读 UTF-8 编码非常简单。

  1. 如果一个字节的第一位是0,则这个字节单独就是一个字符
  2. 如果第一位是1,则连续有多少个1,就表示当前字符占用多少个字节。

举个例子,

  1. 汉字「严」的 Unicode 是4E25100111000100101)。
  2. 根据上表,可以发现4E25处在第三行的范围内(0000 0800 - 0000 FFFF),因此「严」的 UTF-8 编码需要三个字节,即格式是1110xxxx 10xxxxxx 10xxxxxx
  3. 从「严」的最后一个二进制位开始,依次从后向前填入格式中的x,多出的位补0。这样就得到了,「严」的 UTF-8 编码是11100100 10111000 10100101,转换成十六进制就是E4B8A5

UTF-8 的代码实现

下面简单实现下上述编码过程。

编码函数主要有两步:

  1. 计算一个 Unicode 需要多少个 bytes 存储。
  2. 将二进制分别填充进各个 bytes。
function encode(char) {
  const code = char.charCodeAt(0) // 获取 Unicode 码点
  const binCode = code.toString(2) // 转成2进制字符串
  const bytes = countBytes(code) // 计算需要多少个字节存储

  return padEncodeStr(binCode, bytes) // 按规则填充
}

计算一个 Unicode 码点需要多少个 bytes 存储。

// 判断需要多少个字节存储
function countBytes(code) {
  const codeSeg = [0x007f, 0x07ff, 0xffff, 0x10ffff]
  if (code > codeSeg[codeSeg.length - 1]) {
    throw new Error('超出编码范围')
  }
  for (let i = 0; i < codeSeg.length; i++) {
    if (code <= codeSeg[i]) {
      return i + 1;
    }
  }
}

将二进制分别填充进各 bytes。

function padEncodeStr(binCode, n) {
  binCode = binCode.padStart(n * 8 - (n + 1 + (n - 1) * 2), '0') // 补齐空位
  let encodeStr = ''
  let binPtr = binCode.length
  let i = n
  while (i-- > 1) { // 首字节另外填充
    encodeStr = `10${binCode.substring(binPtr - 6, binPtr)}${encodeStr}` //截取六位填充一个字节
    binPtr -= 6
  }
  // 当只有一个字节时,以0开头
  let start = n > 1 ? `${''.padStart(n, 1)}0` : '0'
  encodeStr = `${start}${binCode.substring(0, binPtr)}${encodeStr}`
  return encodeStr
}

解码函数主要将分散在各 bytes 中的二进制字符提取出来,然后组合在一起。

function sliceCode(binCode, n) {
  let segs = []
  let p = 0
  while (n--) {
    segs.push(`${binCode.substr(p + 2, 6)}`)
    p += 8
  }
  return segs.join('')
}

function decode(binCode) {
  let unicode = ''
  let p = 0
  let n = 0
  while (binCode[p++] != 0) {
    n++
  }
  n = Math.max(1, n) // 至少需要1个字节
  unicode += binCode.substring(p, 8)
  unicode += sliceCode(binCode.substring(8, (p - 1) * 8), n - 1)

  return String.fromCharCode(`0b${unicode}`)
}

测试一下:

encode('文') // '111001101001011010000111'
encode('a') // '01100001'

decode('111001101001011010000111') // '文'
decode('01100001') // 'a'

decode(encode('文')) // '文'
decode(encode('0')) // '0'

base64 编码

base64 的意思是用 64 个基本字符来编码任意数据。同理有 base32、base16 编码。

64 个字符包括A-Za-z0-9+/

base64 编码本质上是一种将二进制数据转成文本数据的方案。

下表是 base64 的字符对照表:

索引字符
0A8I16Q24Y32g40o48w564
1B9J17R25Z33h41p49x575
2C10K18S26a34i42q50y586
3D11L19T27b35j43r51z597
4E12M20U28c36k44s520608
5F13N21V29d37l45t531619
6G14O22W30e38m46u54262+
7H15P23X31f39n47v55363/

编码过程

  1. 每 3 字节为一组,共 24 位。
  2. 将 24 位重新分为 4 组,每组有 6 位。
  3. 在每组前面加00,得到 32 位,共 4 bytes。
  4. 根据对照表,找到对应的编码字符。
  5. 如果不够3字节,则在后面补0直到能被 6 整除,然后填充=
    1. 如果剩余 1 个字节 ( 8 位 ),则补充 4 个0 ( 12 位 ),替换成 base 字符后再加上 2 个=
    2. 如果剩余 2 个字节 ( 16 位 ),则补充 2 个0 ( 18 位 ),替换成 base 字符后再加上 1 个=

以对字符串cat进行编码为例:

text     |       c         |        a        |          t      |
ASCII    |      99         |       97        |         116    |
binary   | 0 1 1 0 0 0 1 1 | 0 1 1 0 0 0 0 1 | 0 1 1 1 0 1 0 0|
index    |     24    |     54      |      5      |     52    |
base64   |      Y    |      2       |      F       |      0     |

cat 编码为 Y2F0

不够 3 个 bytes 的情况:

text     |       c         |        a        |
ASCII    |      99         |       97        |
binary   | 0 1 1 0 0 0 1 1 | 0 1 1 0 0 0 0 1 | 0 0 (补充2个0凑够6位)
index    |     24    |     54      |      4      |
base64   |     Y      |      2      |      E       |      =      |

ca 编码为 Y2E=
text     |       c         |
ASCII    |      99         |
binary   | 0 1 1 0 0 0 1 1 | 0 0 0 0 (补充4个0凑够6位)
inde     |     24    |     48      |
base64   |      Y     |      w      |      =       |      =      |

c 编码为 Yw==

base64 将 3 bytes 转化成 4 bytes,因此 base64 编码后的文本,会增大 33% 左右。

浏览器提供了相应的 base64 编解码函数。

window.btoa('cat') // "Y2F0"
window.atob('Y2F0') // "cat"

使用场景

首先,邮件系统使用的SMTP 简单邮件传输协议是基于纯 ASCII 文本的。

人们为了能发送图片、音频这样的二进制文件作为附件,新增了MIME,多用途互联网邮件扩展,在这个标准中,图片等文件会被编码成 base64 字符串。

此外,网页内嵌小图片将多个小图片编码成 base64 然后用img标签放入网页中,这样可以有效减少网络请求数。

// canvas.toDataURL
canvas.getContext("2d").drawImage(img, 0, 0, width, height);
canvas.toDataURL("image/jpeg"); // "data:image/png;base64,iVBORw0...CYII="

// blob & FileReader

最后,JSON 嵌入文件内容时,可以将文件编码成 base64 字符串,然后用 JSON 传递。

URL 编码

网络标准 RFC 1738 对 URL 中的字符做了规定:

只有字母和数字 [0-9a-zA-Z]、一些特殊符号 $-_.,+!*'()、以及某些保留字,才可以不经过编码直接用于URL。

现实中 URL 的编码情况比较复杂:

  • 网址路径用的是 utf-8 编码。
http://zh.wikipedia.org/wiki/春节

编码结果为:

http://zh.wikipedia.org/wiki/%E6%98%A5%E8%8A%82

其中"春"和"节"的 utf-8 编码分别是E6 98 A5E8 8A 82,用%拼接起来得到编码结果。

  • 查询字符串用的是操作系统的默认编码。
http://www.baidu.com/s?wd=春节

在一台使用 GB2312 作为默认编码的机器上,编码结果为:

http://www.baidu.com/s?wd=%B4%BA%BD%DA

我的机器的编码设置为:

$ locale
LANG="zh_CN.UTF-8"
LC_COLLATE="zh_CN.UTF-8"
...

编码结果为:

http://www.baidu.com/s?wd=%E6%98%A5%E8%8A%82
  • GET 和 POST 方法用的是网页的编码,即 html 文件中 meta 标签设置的编码。
<meta charset="utf-8">

form 表单可以通过 accept-charset 来设置编码。

<form action="/xx.php" accept-charset="utf-8">
  <input type="text" name="name" id="id" />
</form>

  • 在 Ajax 调用中,IE 总是采用 GB2312 编码,而 Firefox 总是采用 utf-8 编码。

从上面内容可以看出,网页内容的编码方式很不统一

为了让服务器获得统一的编码结果,需要使用 Javascript 先对 URL 编码,然后再向服务器提交,不要给浏览器插手的机会

  • encodeURI / decodeURI

一些符号在网址中是有特殊含义的,如;/?:@&=+$,#,encodeURI 不会对这些符号进行编码。

编码后,它输出符号的 utf-8 形式,并且在每个字节前加上 %

encodeURI("https://www.baidu.com?mail='mail@qq.com'&name=春节")
// "https://baidu.com?mail='mail@qq.com'&name=%E6%98%A5%E8%8A%82"
// 对于 mail 中的 单引号和 @ 符号 没有编码,编码了“春节”

需要注意的是,它不对单引号'编码。

  • encodeURIComponent / decodeURIComponent

这组 API 和前一组名称很相似,它们的区别在于:encodeURIComponent 会编码 ;/?:@&=+$,#

encodeURIComponent("https://www.baidu.com?mail='mail@qq.com'&name=春节")
// "https%3A%2F%2Fwww.baidu.com%3Fmail%3D'mail%40qq.com'%26name%3D%E6%98%A5%E8%8A%82"
// 对于URL中所有的特殊符号,全部编码
  • escape / unescape 不建议使用。

总结

现代字符通常用 Unicode 编码,Unicode 编码了所有字符,且兼容 ASCII,它用 UTF-8 变长方式存储,URL 中各部分最好也统一使用 utf-8。

最后,base64 是一种将任意数据转成字符串形式的方法。

参考

字符编码笔记:ASCII,Unicode 和 UTF-8

Unicode与JavaScript详解

关于URL编码

Base64笔记

一份简明的 Base64 原理解析