Base64 和 Blob

问题

调用公司某 APP 客户端拍照 API,成功后返回给前端的结果是一段 Base64 编码后的字符串,但是后端接口要求我们使用 FormData 上传 Blob 这样的二进制文件。下面来一步步分析、解决这个问题,同时复习相关知识点。

Base64

Base64 是网络上最常见的用于传输 8 位单字节的编码方式之一,基于 64 个可打印字符来表示二进制数据。

既然是用于表示 8 位字节,对编码前的字符编码范围便限制在 8 位以内,即 [0, 255](十进制表示)或 [0x00, 0xFF](十六进制表示)。这个编码范围对应 ISO-8859-1 字符集,也就是熟知的 Latin1。Base64 的编码范围意味着不能对汉字直接进行 Base64 编码。

标准 Base64 编码只有 64 个字符(26*2 + 10 + 2),即大小写英文字母、数字和 +/,加上用于填充空位的 =。Base64 编码后的数据,包括 = 在内,全部在 7 位 ASCII 字符集范围内。所以 Base64 的作用可以简单理解为使用 ASCII 表示 Latin1。

编码后的数据尺寸要大于编码前(约 4/3),因此在上传文件时,为了减少传输的数据量,应该尽可能使用 Blob 代替 Base64 字符串。

JavaScript 的常见宿主环境,如浏览器和 Node 一般都包含以下 Base64 相关的函数:

  • btoa(): Base64 编码 (Binary to ASCII)
  • atob(): Base64 解码 (ASCII to Binary)

示例:

const encoded = btoa('Hello, world!'); // "SGVsbG8sIHdvcmxkIQ=="
const decoded = atob('SGVsbG8sIHdvcmxkIQ=='); // "Hello, world!"

如果试图传入超出 Latin1 字符集的字符,如 btoa('永'),则会抛出异常 The string to be encoded contains characters outside of the Latin1 range

从 Base64 到 Blob

对于 Base64 图片字符串,调用 atob 后可以得到原始的二进制数据:

const base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==';
const binary = atob(base64);
‰PNG

���
IHDR���0���0���¥,ä´���gAMA��±üa���$PLTE���;;;666333444444333333444333333333!kÛÆ���tRNS�
9<cqxÖìóúFªX���NIDAT8Ëc`²€Q�‡DÔìâl»w`—ÈÞ½‡†Ý“phØi€CÃäa«!‡ÆÕ84à”ÀinËq:w¸i™DZŝ¨qfÜg��'Ê>Þe[����IEND®B`‚

处理后的结果是一段包含乱码的字符串,这是因为原始数据是二进制图片,不应该作为文本展示。

我们可以通过 String.prototype.charCodeAt() 将字符串转换为由字符编码组成的无符号整型数组,成为真正的二进制数据。String.prototype.charCodeAt() 本身返回的整数范围是 [0, 65535][0x0000, 0xFFFF],理论上应该使用 Uint16Array 来存储数据(因为 JavaScript 中 String 类型使用的是 16 位码元,UTF-16 编码)。实际上呢,前面提到过 Base64 解码后的数据编码范围对应单字节的 Latin1,因此只需要使用同样是单字节的 8 位无符号整型数组 Uint8Array 进行存储。

function latin1ToUint8Array(str) {
  const bytes = new Uint8Array(str.length);
  for (let i = 0; i < bytes.length; i++) {
    bytes[i] = str.charCodeAt(i);
  }
  return bytes;
}

const bytes = latin1ToUint8Array(binary);
// [137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 5,
//  0, 0, 0, 5, 8, 6, 0, 0, 0, 141, 111, 38, 229, 0, 0, 0, 28, 73, 68, 65, 84,
//  8, 215, 99, 248, 255, 255, 63, 195, 127, 6, 32, 5, 195, 32, 18, 132, 208,
//  49, 241, 130, 88, 205, 4, 0, 14, 245, 53, 203, 209, 142, 14, 31, 0, 0, 0,
//  0, 73, 69, 78, 68, 174, 66, 96, 130]

获取到 Uint8Array 后,可以很方便地创建 Blob 对象:

function base64ImageToBlob(base64) {
  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < bytes.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return new Blob([bytes], { type: 'image/png' });
}

这里 Blob 的媒体类型硬编码成 image/png,只针对上文 Base64 图片,实际需要根据二进制内容进行识别。例如 PNG 图片的开头数据,即“魔法数字(magic number)”应该是 [137, 80, 78, 71, 13, 10, 26, 10][0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],上文 Base64 图片符合这一特征。其中 [80, 78, 71][0x50, 0x4e, 0x47] 对应的字符恰好为字符串 PNG

String.fromCharCode(80, 78, 71); // "PNG"