前端也要学的“编码”知识

90 阅读10分钟

字符集

一组字符的集合,每个字符都有一个唯一的编号,称为 码点 。字符集的作用是将字符和码点一一对应起来,形成一个对照表。

字符集

ASCII

早期计算机起源于美国,ASCII字符集 是为英语设计的。共收录了 128 个字符,其中包括:可见字符(字母、数字、标点符号)和 控制字符(Tab、回车)。

Latin1

它是一种单字节编码,能够表示 256 个不同的字符。能够兼容 ASCII字符集 ,并在其基础上扩展了额外的字符,以支持西欧语言中的特殊字符(如法语、德语、西班牙语等)。

GBK

ASCII 字符集 无法兼容中文字符,为解决中文编码问题,设计出了 GBK字符集 ,也被称为国标码,兼容 ASCII 字符集GBK字符集 共收录了 21886 个字符,包括汉字(部首、构件)和图形符号。

Unicode

为实现各国语言字符的互通,国际上设计出了 Unicode字符集 ,也被称为万国码,兼容 ASCII字符集 。它包含了3万多个字符。

Base64

由于 ASCII字符集 中包含控制字符,导致并非所有字符都是可见的。为了将二进制数据编码为可见的文本数据,Base64字符集 被推出。它包含 64 个字符,由大小写字母(A-Z、a-z)、数字(0-9)以及两个特殊字符(通常是“+”和“/”)组成,再加上一个用于填充的“=”字符。

编码

编码是指将原始数据(如文本、图片)按照不同的 字符集 转换为二进制数据。反过来,将二进制数据转换回原始数据的过程被称为解码

UTF-8

最常见的编码方式,使用了 Unicode字符集 进行转换字符,编码输出的二进制长度是1字节~4字节,也就是动态长度。

兼容 ASCII字符集 ,英文占用1个字节。中文字符占用3个字节。

留个悬念:中文在 Unicode字符集 的码点在19968~40959,看起来 2**16 完全够了,而 UTF-8编码 还是规定需要三个字节储存???

字符码点二进制
2510501100010 00010001
i10501101001
2032001001111 01100000

如果我们按照上面的二进制储存到文件中:

我i你 ==> 0110001000010001011010010100111101100000

这就遇到一个问题,如何去解析文件中的二进制数据喃?因为UTF-8编码 是动态字节长度的。

当前字符需要解析多少个字节是未知的,比如:

前8位解析Unicode 字符是 b

前16位解析Unicode 字符是

前24位解析Unicode 字符是 错误!!

动态字节长度 是 UTF-8编码的特点,也是他必须要解决的问题,所以 UTF-8编码 设置了一套二进制储存规范。

UTF-8编码「二进制」
0xxxxxxxx1字节
110xxxxx 10xxxxxx2字节
1110xxxx 10xxxxxx 10xxxxxx3字节
11110xxx 10xxxxxx 10xxxxxx4字节
字符码点二进制
2510511100110 10001001 10000001
i10501101001
2032011100100 10111101 10100000

我i你 ==> 11100110100010011000000101101001111001001011110110100000

// 方案1
new TextEncoder().encode("我i你") //  Uint8Array(7) [230, 136, 145, 105, 228, 189, 160]

// 方案2
const render = new FileReader()
render.readAsArrayBuffer(new Blob(["我i你"]))
render.onload = function () {

    console.log(new Uint8Array(this.result)) // Uint8Array(7) [230, 136, 145, 105, 228, 189, 160]
}

虽然比原始的二进制数据长,但是可以去按照 开头的标识 去解析二进制数据了。

但是也带来一个问题:不是所有的二进制数据都有所对应的 Unicode字符 。但是当二进制数据找不到所对应的 Unicode字符 时,就会发生我们熟知的乱码问题。

比如:10000000

UTF-32

前面聊到 UTF-8编码 的时候,不得不提到 UTF-32编码。不过,UTF-32编码 出现得比 UTF-8编码 早,但后来却逐渐被淘汰了。

UTF-32编码 是一种固定字节的编码方式,不论字符在 Unicode字符集 中处于什么码点,都用 4 字节 来记录。这样做的好处是字符表示简单直接,能涵盖所有 Unicode字符,还避免了像 UTF-8编码 那样需要识别字节前缀的复杂性。

但它的缺点也很明显,那就是太占空间了。每个字符都用 4 字节,即使是简单的 ASCII 字符也不例外,这在存储和传输大量文本时,会带来很大的资源浪费。

所以,尽管 UTF-32编码 在某些特定场景下仍有其价值,但整体上,它还是因为效率和资源占用的问题,逐渐被 UTF-32编码 等方式取代了。

Base64

Base64编码 是一种将数据转换为可打印字符的编码方式,常用于文件、字符串等数据的可视化传递

  • 使用 64个字符(包括大小写字母、数字和特殊符号)作为字符集,确保所有字符都是可视的。

  • Base64编码 的每个字符需要 6位 二进制数据。如果在编码过程中,二进制数据的长度不是 6 的倍数,会在二进制数据末尾添加 0 以补足至 6 的倍数。

    • 示例A 二进制数据是 01000001,长度是 8,不是 6 的倍数,会在末尾添加 0,变成 010000010000,使其长度成为 6 的倍数。
  • Base64编码 的规则要求编码后的字符串长度必须是 4 的倍数。如果编码后的字符串长度不是 4 的倍数,会在末尾添加 = 作为填充字符,以补足至 4 的倍数。

    • 示例A 经过 Base64 编码后得到的字符串是 QQ,其长度为 2,不是 4 的倍数。为了符合 Base64 编码的规则,会在末尾添加 =,使其变为 QQ==,长度变为 4,是 4 的倍数。
字符串编码转为 Base64 格式
window.btoa
console.log(window.btoa("hello world!"));   // aGVsbG8gd29ybGQh

btoa只支持 Latin1字符集 的字符,对于 非 Latin1字符集 的字符会抛出错误

window.btoa("你好!")

TextEncoderBlob
let str = "你好!"

const blob = new Blob([new TextEncoder("utf-8").encode(str)], {
type: "text/plain"
})

let render = new FileReader()
render.readAsDataURL(blob)
render.onload = function () {
    console.log(render.result)
}
Base64 格式转为 字符串编码
window.atob

只能处理属于 Latin-1字符集 的字符。

window.atob("aGVsbG8gd29ybGQh")
代码实现
Base64 编码

  • 在编码时,以 6位 二进制为一组进行分割。如果最后的二进制数量不足 6位,则进行补 0
  • 在编码成功时,判断 Base64 长度是否能被 4整除,如果不足 4 个字符,就进行补 =
// 定义Base64字符集
const base64CharSet = {};

// 初始化Base64字符集,将索引与字符对应
`ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/`.split("").forEach((char, index) => base64CharSet[index] = char);

/**
 * 将字符串转换为Base64编码
 * @param {string} inputStr - 需要转换的字符串
 * @returns {string} Base64编码后的字符串
 */
function base64Encode(inputStr) {
    // 用于存储最终的Base64编码结果
    let base64String = "";

    // 将输入字符串的每个字符转换为对应的ASCII码的二进制表示,并拼接成一个长的二进制字符串
    // 注意:每个字符的二进制表示需要补足8位
    let binaryString = inputStr.split("").map(char => {
        return char.charCodeAt().toString(2).padStart(8, '0');
    }).join("");

    // 如果二进制字符串的长度不是6的倍数,则在末尾补0,使其长度成为6的倍数
    if (binaryString.length % 6 !== 0) {
        binaryString += "0".repeat(6 - binaryString.length % 6);
    }

    // 遍历二进制字符串,每6位一组进行处理
    for (let index = 0; index < binaryString.length; index += 6) {
        // 提取每6位二进制字符
        let sixBitSegment = binaryString.substring(index, index + 6);

        // 将6位二进制字符转换为十进制索引
        let indexInBase64Set = parseInt(sixBitSegment, 2);

        // 根据索引从Base64字符集中获取对应的字符,并添加到结果中
        base64String += base64CharSet[indexInBase64Set];
    }

    // 如果Base64编码结果的长度不是4的倍数,则在末尾补“=”字符
    if (base64String.length % 4 !== 0) {
        base64String += "=".repeat(4 - base64String.length % 4);
    }

    // 返回最终的Base64编码字符串
    return base64String;
}


console.log(base64Encode("hello world!"));
Base64 解码
/**
 * 将Base64编码的字符串转换为原始字符串
 * @param {string} base64Str - 需要转换的Base64编码字符串
 * @returns {string} 解码后的字符串
 */
function base64Decode(base64Str) {
    let decodedString = ''; // 用于存储解码后的字符串

    // 去除Base64字符串末尾的填充字符("=")
    base64Str = base64Str.replace(/=+$/, "");

    // 将Base64字符串转换为二进制字符串
    let binaryString = base64Str
        // 将字符串拆分为字符数组
        .split("")
        .map(char => {
            // 找到字符在Base64字符集中的索引
            let index = Object.entries(base64CharSet).find(entry => entry[1] === char)[0];
            // 将索引转换为6位二进制字符串
            return Number(index).toString(2).padStart(6, '0');
        })
        // 将所有二进制字符串拼接起来
        .join("");

    // 去除二进制字符串末尾多余的0(由于去除填充字符可能导致的多余部分)
    binaryString = binaryString.slice(0, binaryString.length - binaryString.length % 8);

    // 按每8位一组进行处理
    for (let i = 0; i < binaryString.length; i += 8) {
        // 提取8位二进制字符串
        let byteSegment = binaryString.substring(i, i + 8);

        // 将8位二进制字符串转换为字符代码
        const charCode = parseInt(byteSegment, 2);

        // 将字符代码转换为字符,并追加到解码后的字符串中
        decodedString += String.fromCharCode(charCode);
    }

    // 返回解码后的字符串
    return decodedString;
}

console.log(base64Decode("aGVsbG8gd29ybGQh"));
疑问
Base64编码 长度不是 4 的倍数时的处理
  • 解码时的处理= 不属于 Base64 字符集, 在解码过程中,解码器会自动识别并去除末尾的 = 填充字符。

    例如ABase64码QQ==,在解析时,会去除末尾的 = ,变成 QQ

    结论= 不属于 Base64 字符集,仅用于补足长度。在解码时会被去除,因此不会影响解码结果。

Base64编码 转二进制数据长度不是 6 的倍数时,会进行的补位处理,会不会影响到 Base64解码
  • 解码时的处理: 在解码时,解码器会根据实际的 Base64编码 长度来确定二进制数据的有效长度,并去除末尾多余的 0

    例如ABase64 解析时, 二进制数据为 010000010000,得到的二进制数长度为 12 位,而实际需要的长度为 8 位,那么就会去除末尾的 4 位 0

    结论:在 Base64 编码转二进制数据时,末尾添加的 0 仅用于补足长度。在解码时,这些补位的 0 会被去除,因此不会影响解码结果。

后记