WebGL Tutorial
and more

Base64 编码机制

撰写时间:2025-02-25

修订时间:2025-03-01

概述

有时我们需要将二进制数据转换为字符串,这样就可以在只支持字符的环境中安全使用二进制数据。

Base64是使用64个可打印的ASCII字符来表示二进制数据的一种编码机制。其所使用的字符范围,使用正则表达式的模式来表述如下:

[A-Za-z0-9+/]

即大写的26个英文字母,小写的26个英文字母,[0, 9]10个表示数字的字符,再加上+/这两个字符,共计64个字符。

JavaScriptBase64有直接的内置支持。

btoa与atob函数

函数功能

btoa函数(意为,binary to ASCII)将二进制数据编码为Base64ASCII字符串。

let str = "Hello, World."; let base64Str = btoa(str); pc.log(str); pc.log(base64Str);

btoa的参数虽为字符串,但在其内部,却是通过将每个字符的码位值视为一个字节,然后再将这些字节的数值转换为Base64字符串。

编码后,可通过调用相对应的atob函数(意为,ASCII to binary)将Base64字符串解码为普通的字符串。

let base64Str = "SGVsbG8sIFdvcmxkLg=="; let str = atob(base64Str); pc.log(base64Str); pc.log(str);

btoaatob的函数名称虽然带有转换二进制的字眼,并且在函数内部确实进行了二进制字节数据的转换,但它们的参数及返回值却是普通的字符串,因此很容易让使用者犯迷糊。这是因为在JavaScript支持二进制数据类型之前这两个函数就已经存在了,既然当时不支持二进制数据类型,就只好临时使用普通字符串(通过转换为码位值)来表示二进制数据。

这同时导致了更让函数调用者犯迷糊的多字节的问题,详见下节。

多字节的问题

当字符串中每个字符的码位都落在在1个字节的范围内(对应于十六进制的0xFF),则可安全地进行编码。

let str = String.fromCodePoint(0xFF); let base64Str = btoa(str); pc.log(str); pc.log(base64Str);

但如果字符的码位超过1个字节的范围,则会抛出异常。

let str = String.fromCodePoint(0xFF + 1); pc.log(str); let base64Str = btoa(str); pc.log(base64Str);

字符串可以打印,但只是无法进行Base64编码。

Safari所抛出的异常信息也是个糊涂虫:

pc.error('%s', 'Exception thrown: The string contains invalid characters.');

错误信息为:字符中包含了无效字符。字符串本身所包含的字符是有效的,只不过不能进行Base64编码而已。因此可以说Safari不嫌更乱。

Chrome抛出的异常信息为:

pc.error('%s', `Exception thrown: Failed to execute 'btoa' on 'Window': The string to be encoded contains characters outside of the Latin1 range.`);

两相比较,Chrome的异常信息更为精准。

解决任意字符的多字节问题

UTF-8编码可对任意字符编码为1或多个字节。利用此特点,我们可将UTF-8编码系列作为一个中转站,从而解决任意字符的Base64编码问题。

编码:

let str = '海'; let bytes = new TextEncoder().encode(str); pc.log(bytes); let charArr = Array.from(bytes, (byte) => { let char = String.fromCodePoint(byte); return char; }); let utf8Str = charArr.join(""); pc.log(utf8Str); let base64Str = btoa(utf8Str); pc.log(base64Str);

先使用UTF-8将字符串编码为一系列的字节数组,将每个字节转换为一个字符,将所有字符组成一个字符串,再调用btoa函数安全地编码为Base64字符串。需注意的是,此字符串并非直接对应于Base64字符串。

因此,若要从该Base64字符串取回的字符串,须经过相应的解码步骤。

下面为具体的解码步骤:

let base64Str = '5rW3'; let bytesStr = atob(base64Str); pc.log(bytesStr); let codePointArr = []; for (const char of bytesStr) { let codePoint = char.codePointAt(0); codePointArr.push(codePoint); } pc.log(codePointArr); let tarr = new Uint8Array(codePointArr); let str = new TextDecoder().decode(tarr); pc.log(str);

解码顺序正好相反,先调用atobBase64字符串解码为普通的字符串,取出各个字符的码位,将所有码位值用于创建Uint8Array的一个实例,最后调用TextDecoderdecode方法,将这些UTF-8编码解码为一个UTF-8字符串。

以上代码演示了Uint8ArrayBase64之间的相互转换,如果经常需要用到,可自行将上述步骤打包为相应的函数。

上面代码使用for ... of语句来取出字符串中的每个字符,该语句可安全适用于编码值为多字节的字符。例如:

let str = '海'; for (const char of str) { pc.log(char); }

Uint8Array与Base64的亲密关系

数组只容纳单字节的元素

Uint8Array是无符号8位整数的数组,即每个数组元素的字长为1字节。

下面代码演示了若使用字长超出1字节的整数数组来创建Uint8Array实例情况。

const { getHexAddrStrFromTypedArray } = await import('/js/esm/BinUtils.js'); let tarr = new Uint8Array([0x1234, 0x5678]); pc.log('%s', getHexAddrStrFromTypedArray(tarr));

在构造函数的参数中,数组每个元素字长均为2字节,共4个字节。而创建实例后,tarr只取参数中数组每个元素的最低权重的字节,舍弃了其他的字节。

因此,对于已有的Uint8Array,它可以很好地与btoa函数联动;但如果我们需要手工创建Uint8Array的实例,则需考虑参数中数组元素是否存在多字节的情况。

Uint8Array的特权

pc.log(new Uint8Array()); pc.log(new Int8Array()); pc.log(new Uint16Array()); pc.log(new Float32Array()); pc.log(Uint8Array.hasOwnProperty('fromBase64'));

经对比,Uint8Array独有以下几个方法:

  • setFromBase64
  • toBase64
  • fromBase64(类的静态方法)

上述特权只在Safari 18.2以上的版本才有,Chrome目前无此特权。

鉴此,Uint8ArrayBase64字符串之间的相互转换,无比便捷。

let ui8Arr = new Uint8Array([1, 2, 3, 4, 5]); let base64Str = ui8Arr.toBase64(); pc.log(base64Str); let newArr = Uint8Array.fromBase64(base64Str); pc.log(newArr);

因此,若在Uint8ArrayBase64字符串之间相互转换,则可绕过古老的btoa, atob等函数,非常方便。

setFromBase64方法将Base64字符串解码后,将字节写进Uint8Array中,返回一个对象,以标明读取了多少个字符,写了多少个字节。这种方式在我们需要改写一个已经存在的Uint8Array实例的内容时较为实用。

let ui8Arr = new Uint8Array(10); let result = ui8Arr.setFromBase64('AQIDBAU='); pc.log(result); pc.log(ui8Arr);

由于Uint8Array又是ArrayBuffer的一个视图,这意味着我们可以通过调用fetch函数来加载任意二进制文件资源,然后再编码为Base64字符串。具体应用,下节详述。

data:URL

data:URL可将内容为Base64字符串的URL,提供给 Web APIs调用(如fetch函数),或给HTML标签使用(如设置imgsrc属性值)。

使用data:URL的例子

下面代码用于加载一张图片,图片的来源使用Base64字符串来存储二进制数据。

const SCHEMA = 'data:'; let mimeType = 'image/png'; const ENCODING = 'base64'; let asciiStr = 'iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFAQMAAABo7865AAAABlBMVEVHcEzMzMzyAv2sAAAAAXRSTlMAQObYZgAAABBJREFUeF5jOAMEEAIEEFwAn3kMwcB6I2AAAAAASUVORK5CYII='; let img = new Image(); img.src = `${SCHEMA}${mimeType};${ENCODING},${asciiStr}`; pc.log(img);

这种技术非常实用,由于二进制资源已变成以字符串的方式嵌入到网页中,它可以大幅降低访问者加载资源的时间,在诸如xterm.js, glTF, Emscripten, wabt.js, brython.js, dat.gui.js, graphviz.js, three.js的源码中均可看到它的身影。

data:URL的格式

正如上面所见,一个data:URL的格式由4部分组成,具体如下:

{"data:"}{mimeType};{"base64"},{content}

其中,第一部分为传输协议,使用字符串常量data:表示。

第二部分指定媒介类型,如上面的image/png,后面紧跟分号;

第三部分为编码机制,使用字符串常量base64表示,后面紧跟逗号,

第四部分为各种媒介的具体内容经Base64编码后的字符串。

由于存在两个常量字符串,可将生成data:URL的步骤抽象为一个函数,经常改变的mimeTypebase64Str作为参数传入。这样做的好处是,每次编写代码时不会因为偶尔不小心而出错,并且关注点仅保留在是何媒介类型,以及如何获取Base64编码字符串上面。

function makeDataURL(mimeType, base64Str) { const SCHEMA = 'data:'; const ENCODING = 'base64'; return `${SCHEMA}${mimeType};${ENCODING},${base64Str}`; } let mimeType = 'image/png'; let base64Str = 'iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFAQMAAABo7865AAAABlBMVEVHcEzMzMzyAv2sAAAAAXRSTlMAQObYZgAAABBJREFUeF5jOAMEEAIEEFwAn3kMwcB6I2AAAAAASUVORK5CYII='; let dataURL = makeDataURL(mimeType, base64Str); let img = new Image(); img.src = dataURL; pc.log(img);

fetch data:URL

既然data:URL是一种行内的URL,则可调用fetch函数来加载它。

const { Base64Utils } = await import('/js/esm/Base64Utils.js'); const { getHexAddrStrFromTypedArray } = await import('/js/esm/BinUtils.js'); let mimeType = 'image/png'; let base64Str = 'iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFAQMAAABo7865AAAABlBMVEVHcEzMzMzyAv2sAAAAAXRSTlMAQObYZgAAABBJREFUeF5jOAMEEAIEEFwAn3kMwcB6I2AAAAAASUVORK5CYII='; let dataURL = Base64Utils.MakeDataURL(mimeType, base64Str); fetch(dataURL) .then(response => response.bytes()) .then(bytes => pc.log('%s', getHexAddrStrFromTypedArray(bytes)))

有了fetch函数的加持,我们能玩的花样就多了去了。上面的代码,我们从一个原始的ASCII字符串,却可显示出它所表示的图像的二进制数据。

设想一下,您将一段乱七八糟的字符串通过微信发给女朋友,别人无意中看到了只会说无聊,但只有你们才会会心地一笑。你们的感情联络,受到了计算机加密技术的严密保护。

这种祖传秘诀,一般人我都不会告诉他。

FileReader

前面内容回顾

回顾上面各节的内容,涉及到众多的对象,在此作一集中汇总图表。

使用btoa函数及atob函数在由单字节字符构成的普通字符串及Base64字符串之间相互转换。

digraph { node [ shape=component ]; edge [ fontcolor="#999999" ]; subgraph A { edge [ color="tomato", ]; String1 [label="String"]; Base64Str1 [label="Base64 String"]; String1 -> Base64Str1 [label="btoa()"]; } subgraph B { edge [ color="dodgerblue", ]; String2 [label="String"]; Base64Str2 [label="Base64 String"]; String2 -> Base64Str2 [label="atob()", dir=back]; } }

当遇到字符串由多字节的字符组成时,借力于UTF-8编码,使用Uint8Array作为中转站。

digraph { node [ shape=component ]; edge [ fontcolor="#999999" ]; subgraph A { edge [ color="tomato", ]; String1 [label="String"]; Uint8Array1 [label="Uint8Array"]; UTF8Str1 [label="UTF-8 Byte String"]; Base64Str1 [label="Base64 String"]; String1 -> Uint8Array1 [label="TextEncoder.encode()"]; Uint8Array1 -> UTF8Str1 [label="Array.from()"]; UTF8Str1 -> Base64Str1 [label="btoa()"]; } subgraph B { edge [ color="dodgerblue", ]; String2 [label="String"]; Uint8Array2 [label="Uint8Array"]; UTF8Str2 [label="UTF-8 Byte String"]; Base64Str2 [label="Base64 String"]; String2 -> Uint8Array2 [label="TextDecoder.decode()", dir=back]; Uint8Array2 -> UTF8Str2 [label="Char.codePointAt()", dir=back]; UTF8Str2 -> Base64Str2 [label="atob()", dir=back]; } }

Uint8ArrayBase64字符串之间可以直接相互转换,可惜并非所有主流浏览器都支持。

digraph { node [ shape=component ]; edge [ fontcolor="#999999" ]; subgraph A { edge [ color="tomato", ]; Uint8Array1 [label="Uint8Array"]; Base64Str1 [label="Base64 String"]; Uint8Array1 -> Base64Str1 [label="Uint8Array.toBase64()"]; } subgraph B { edge [ color="dodgerblue", ]; Uint8Array2 [label="Uint8Array"]; Base64Str2 [label="Base64 String"]; Uint8Array2 -> Base64Str2 [label="Uint8Array.fromBase64()", dir=back]; } }

最后,根据Base64字符串来生成data:URL,并由fetch函数及img标签使用。

digraph { node [ shape=component ]; edge [ fontcolor="#999999" ]; subgraph A { edge [ color="tomato", ]; Base64Str [label="Base64 String"]; DataURL [label="dataURL"]; Base64Str -> DataURL [label="makeDataURL()"]; } subgraph B { node [ shape=cylinder ]; edge [ color="dodgerblue", arrowhead=diamond minlen=2 ]; fetch [label="fetch()"] img [label="img.src"] DataURL -> fetch DataURL -> img } }

上面这些流程,都需要我们手工生成Base64字符串,然后再手工生成dataURL

引入FileReader

FileReader可以将一个Blob对象作为输入源,直接生成dataURL。其基本用法如下:

let tarr = new Uint8Array([1, 2, 3, 4, 5]); let blob = new Blob([tarr], {type: "application/octet-stream"}); { let fileReader = new FileReader(); fileReader.readAsDataURL(blob); fileReader.onload = (evt) => { pc.log(evt.target.result); }; }

其流程图表为:

digraph { node [ shape=component ]; edge [ fontcolor="#999999" ]; subgraph A { edge [ color="dodgerblue", arrowtail=diamond ]; FileReader [label="FileReader"]; Uint8Array [label="Uint8Array"]; Blob [label="Blob"]; Uint8Array -> Blob [label="1. using" dir=back]; {rank=same; FileReader -> Blob [label="2. readAsDataURL()", arrowhead=diamond]}; FileReader -> dataURL [label="3. yields" color="tomato"] } }

先以Uint8Array来创建一个Blob对象,调用FileReader实例的readAsDataURL方法,将Blob对象的内容编码为dataURL。编码完毕后,FileReader实例的onload事件被触发,则可通过回调函数中的evt参数获取最终的Base64编码结果。

这种方式有众多优点:

  1. 所有主流浏览器均支持这种方式。
  2. Blob作为中转站,其构造函数的参数支持多种类型,如ArrayBuffer, TypedArray, DataView, Blob,甚至可以是字符串。
  3. 我们不再需要手工构建dataURL
  4. Blob的构造方法中允许传入不同的Mime Types

可见,JavaScript处理二进制数据时相关的类比较丰富,已构成一个小型生态环境。清晰地了解各个类及其相应方法的功能,有助于我们快速地解决相应范畴内的问题。

类图与对象协作

let tarr = new Uint8Array([1, 2, 3, 4, 5]); let blob = new Blob([tarr], {type: "application/octet-stream"}); { let fileReader = new FileReader(); fileReader.readAsDataURL(blob); fileReader.onload = (evt) => { pc.log(evt.target.result); pc.log(fileReader.readyState); }; pc.log(fileReader); pc.log(new File(["foo"], "foo.txt", {type: "text/plain"})); }

FileReader的类图如下:

digraph { node [shape=record, style=filled, fillcolor="#222", fontcolor="#ACB7C4", color="#4e6e9a"]; FileReader [label=" {FileReader | + readyState \l + result \l + error \l | + readAsArrayBuffer() \l + readAsBinaryString() \l + readAsText() \l + readAsDataURL() \l | + onabort \l + onload \l + onloadstart \l + onloadend \l + onprogress \l + onerror \l | + abort() \l } "]; }

readyState使用枚举变量来表示加载的状态:

常量名称数值含义
EMPTY0未有数据
LOADING1正在加载数据
DONE2数据加载完毕

readAs...系列方法中,readAsBinaryString已被废弃,改用readAsArrayBuffer来替代。

readAsDataURL方法的参数类型可为BlobFile,其中,FileprototypeBlob

digraph { rankdir=LR; edge [minlen=2]; { node [shape=plaintext, style="transparent"]; FileReader [label=<
FileReader
+ readAsDataURL(blob: [Blob, File])
>]; } { node [shape=component, fontcolor="#F2B659", style="transparent"]; FileReader:f1 -> Blob, File [ style="dashed", arrowhead="vee", fontcolor="#666666", ]; } }

参考资源

  1. MDN data:URLs
  2. MDN Base64
  3. MDN btoa() method