【摘要】本文介绍 web 中常见的字符编码,包括 ASCII、Unicode,Base64,URL 编码。
ASCII
ASCII 是基于拉丁字母的一套编码系统,它主要用于编码英语和其他西欧语言。
ASCII 用一个字节中的后 7 位,规定了 128 个字符的编码 (),最高位闲置。它们包括:
- 33 个控制字符,比如换行、换页、删除等字符
- 95 个可显示字符,比如数字、英文字母 A-Z、a-z 和其他标点符号等。
ASCII 对照表截取(控制字符):
| 二进制 | 十进制 | 十六进制 | 缩写 | 可以显示的表示法 | 名称/意义 |
|---|---|---|---|---|---|
| 0000 1001 | 9 | 09 | HT | ␉ | 水平定位符号 |
| 0000 1010 | 10 | 0A | LF | ␊ | 换行键 |
| 0000 1011 | 11 | 0B | VT | ␋ | 垂直定位符号 |
| 0000 1100 | 12 | 0C | FF | ␌ | 换页键 |
ASCII 对照表截取(可显示字符):
| 二进制 | 十进制 | 十六进制 | 图形 |
|---|---|---|---|
| 0010 0011 | 35 | 23 | # |
| 0010 0100 | 36 | 24 | $ |
| 0011 0000 | 48 | 30 | 0 |
| 0011 0001 | 49 | 31 | 1 |
| 0100 0001 | 65 | 41 | A |
| 0100 0010 | 66 | 42 | B |
完整的 ASCII 对照表。
EASCII
128 个字符对于英语来说足够了,但是对于其他语言显然不够。于是,人们便将闲置的那个最高位也利用起来,提出了 EASCII 码 ( extended ASCII )。
EASCII 扩充了表格符号、计算符号、希腊字母和特殊的拉丁符号。
但是,这不能解决问题。
以汉语为例,常用汉字就有数千个,而 8 位最多只能编码 256 个字符。我们可以用两个字节来编码汉字,那么就能编码 个字符。
这依然没完全解决问题:
- 汉字总数不只 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 的编码规则很简单,只有两条:
- 对于单字节的符号,字节的第一位设为
0,后面 7 位为这个符号的 Unicode 码。因此对于英语字母,UTF-8 编码和 ASCII 码是相同的。 - 对于
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 编码非常简单。
- 如果一个字节的第一位是
0,则这个字节单独就是一个字符 - 如果第一位是
1,则连续有多少个1,就表示当前字符占用多少个字节。
举个例子,
- 汉字「严」的 Unicode 是
4E25(100111000100101)。 - 根据上表,可以发现
4E25处在第三行的范围内(0000 0800 - 0000 FFFF),因此「严」的 UTF-8 编码需要三个字节,即格式是1110xxxx 10xxxxxx 10xxxxxx。 - 从「严」的最后一个二进制位开始,依次从后向前填入格式中的
x,多出的位补0。这样就得到了,「严」的 UTF-8 编码是11100100 10111000 10100101,转换成十六进制就是E4B8A5。
UTF-8 的代码实现
下面简单实现下上述编码过程。
编码函数主要有两步:
- 计算一个 Unicode 需要多少个 bytes 存储。
- 将二进制分别填充进各个 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-Z、a-z、0-9、+/。
base64 编码本质上是一种将二进制数据转成文本数据的方案。
下表是 base64 的字符对照表:
| 索引 | 字符 | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | A | 8 | I | 16 | Q | 24 | Y | 32 | g | 40 | o | 48 | w | 56 | 4 |
| 1 | B | 9 | J | 17 | R | 25 | Z | 33 | h | 41 | p | 49 | x | 57 | 5 |
| 2 | C | 10 | K | 18 | S | 26 | a | 34 | i | 42 | q | 50 | y | 58 | 6 |
| 3 | D | 11 | L | 19 | T | 27 | b | 35 | j | 43 | r | 51 | z | 59 | 7 |
| 4 | E | 12 | M | 20 | U | 28 | c | 36 | k | 44 | s | 52 | 0 | 60 | 8 |
| 5 | F | 13 | N | 21 | V | 29 | d | 37 | l | 45 | t | 53 | 1 | 61 | 9 |
| 6 | G | 14 | O | 22 | W | 30 | e | 38 | m | 46 | u | 54 | 2 | 62 | + |
| 7 | H | 15 | P | 23 | X | 31 | f | 39 | n | 47 | v | 55 | 3 | 63 | / |
编码过程
- 每 3 字节为一组,共 24 位。
- 将 24 位重新分为 4 组,每组有 6 位。
- 在每组前面加
00,得到 32 位,共 4 bytes。 - 根据对照表,找到对应的编码字符。
- 如果不够3字节,则在后面补
0直到能被 6 整除,然后填充=- 如果剩余 1 个字节 ( 8 位 ),则补充 4 个
0( 12 位 ),替换成 base 字符后再加上 2 个= - 如果剩余 2 个字节 ( 16 位 ),则补充 2 个
0( 18 位 ),替换成 base 字符后再加上 1 个=
- 如果剩余 1 个字节 ( 8 位 ),则补充 4 个
以对字符串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 A5和E8 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 是一种将任意数据转成字符串形式的方法。