概述
有时我们需要将二进制数据转换为字符串,这样就可以在只支持字符的环境中安全使用二进制数据。
Base64是使用64个可打印的ASCII字符来表示二进制数据的一种编码机制。其所使用的字符范围,使用正则表达式的模式来表述如下:
[A-Za-z0-9+/]
即大写的26个英文字母,小写的26个英文字母,[0, 9]共10个表示数字的字符,再加上+
及/
这两个字符,共计64个字符。
JavaScript对Base64有直接的内置支持。
btoa与atob函数
函数功能
btoa函数(意为,binary to ASCII)将二进制数据编码为Base64的ASCII字符串。
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);
btoa及atob的函数名称虽然带有转换二进制
的字眼,并且在函数内部确实进行了二进制字节数据的转换,但它们的参数及返回值却是普通的字符串,因此很容易让使用者犯迷糊。这是因为在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);
解码顺序正好相反,先调用atob将Base64字符串解码为普通的字符串,取出各个字符的码位,将所有码位值用于创建Uint8Array的一个实例,最后调用TextDecoder的decode方法,将这些UTF-8编码解码为一个UTF-8字符串。
以上代码演示了Uint8Array与Base64之间的相互转换,如果经常需要用到,可自行将上述步骤打包为相应的函数。
上面代码使用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目前无此特权。
鉴此,Uint8Array与Base64字符串之间的相互转换,无比便捷。
let ui8Arr = new Uint8Array([1, 2, 3, 4, 5]);
let base64Str = ui8Arr.toBase64();
pc.log(base64Str);
let newArr = Uint8Array.fromBase64(base64Str);
pc.log(newArr);
因此,若在Uint8Array与Base64字符串之间相互转换,则可绕过古老的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标签使用(如设置img的src属性值)。
使用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的步骤抽象为一个函数,经常改变的mimeType及base64Str作为参数传入。这样做的好处是,每次编写代码时不会因为偶尔不小心而出错,并且关注点仅保留在是何媒介类型,以及如何获取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];
}
}
Uint8Array与Base64字符串之间可以直接相互转换,可惜并非所有主流浏览器都支持。
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编码结果。
这种方式有众多优点:
- 所有主流浏览器均支持这种方式。
- Blob作为中转站,其构造函数的参数支持多种类型,如ArrayBuffer, TypedArray, DataView, Blob,甚至可以是字符串。
- 我们不再需要手工构建dataURL。
- 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使用枚举变量来表示加载的状态:
常量名称 | 数值 | 含义 |
EMPTY | 0 | 未有数据 |
LOADING | 1 | 正在加载数据 |
DONE | 2 | 数据加载完毕 |
在readAs...系列方法中,readAsBinaryString已被废弃,改用readAsArrayBuffer来替代。
readAsDataURL方法的参数类型可为Blob或File,其中,File的prototype是Blob。
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",
];
}
}