还在用三方包 Base64 编码?btoa() 和 atob() 浏览器早给你了!

内容分享3周前发布
1 0 0

家好,很高兴又见面了,我是”高级前端‬进阶‬”,由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发,您的支持是我不断创作的动力。

还在用三方包 Base64 编码?btoa() 和 atob() 浏览器早给你了!

Base64 编解码是将二进制内容转换为网络安全文本的常见方法,可用于 Data URL,例如:内联图片。而在 JavaScript 中对字符串应用 Base64 编解码可能引发一系列连锁反应,本文将带着大家一起探讨其中的细微差别以及潜在的陷阱。

1.btoa() 和 atob() 只能处理单字节字符串

许多开发者可能觉得 btoa() 和 atob() 两个方法名特别奇怪,这一点在 StackOverflow 也有讨论。根据 JavaScript 创建者 Brendan Eich 的回复,大致意思是说,该方法是从 Unix 中引入的:

Old Unix names, hard to find man pages rn but see https://www.unix.com/man-page/minix/1/btoa/ …. The names carried over from Unix into the Netscape codebase. I reflected them into JS in a big hurry in 1995 (after the ten days in May but soon).

btoa() 函数的核心作用是用于将字符串转换为 base64 编码,而 atob() 则用于解码回字符串,例如下面的示例:

// 仅包含低于 128 的代码点 (code points) 的字符串
const asciiString = "hello";
const asciiStringEncoded = btoa(asciiString);
console.log(`Encoded string: [${asciiStringEncoded}]`);
// 输出: [aGVsbG8=]
const asciiStringDecoded = atob(asciiStringEncoded);
console.log(`Decoded string: [${asciiStringDecoded}]`);
// 解码后输出 [hello]

遗憾的是,这两个方法仅适用于包含 ASCII 字符或 可以用单个字节表明的字符的字符串,而不适用于 Unicode。

const validUTF16String = "hello⛳❤️";
// DOMException: 无法在 Window 上执行 btoa,由于要编码的字符串包含 Latin1 范围之外的字符
try {
  const validUTF16StringEncoded = btoa(validUTF16String);
  console.log(`Encoded string: [${validUTF16StringEncoded}]`);
} catch (error) {
  console.log(error);
}

在上面的示例中,虽然字符串都是有效的 UTF-16,但是有以下注意点:

  • 'hello' 的每个代码点都低于 128
  • '⛳' 是一个 16 位代码单元
  • '❤️' 是两个 16 位代码单元,U+2764 和 U+FE0F,即一个心形和一个变体
  • '' 是一个 32 位代码点 (U+1F9C0),也可以表明为两个 16 位代码单元的代理对 'ud83euddc0'

总之,字符串中的任何一个表情符号都会导致以上 DOMException 错误。

2. JS 字符串用 UTF-16 编码对 btoa() 造成破坏

Unicode 是当前字符编码的全球标准,即为特定字符分配数字,以便字符能在计算机系统中使用。以下是一些 Unicode 字符及其对应数字的示例:

  • h :104
  • ñ :241
  • ❤ :2764
  • ❤️ :2764 带有一个隐藏修饰符,编号为 65039
  • ⛳ :9971
  • :129472

代表每个字符的数字称为代码点 (code points),开发者可以将代码点视为每个字符的地址。红心表情符号实际上有两个代码点:

  • 一个代表心形
  • 一个用于改变颜色,使其始终为红色。

Unicode 有两种常用方法来获取这些代码点并将其转换为计算机可以一致解释的字节序列:UTF-8 和 UTF-16。

  • UTF-8:一个代码点可以使用 1 到 4 个字节,每个字节 8 位
  • UTF-16:一个代码点始终占用两个字节,即 16 位

值得注意的是,JavaScript 会将字符串处理为 UTF-16 格式,从而会破坏像 btoa() 这类函数,此类函数实际上是基于每个字符都映射到单个字节的假设来运行的。MDN 上对此也进行了明确说明:

btoa() 方法会将二进制字符串,即将字符串中的每个字符都视为一个二进制字节创建为 Base64 编码的 ASCII 字符串。

实则除了 btoa 外,unescape() 和 escape() 也会受到 UTF-16 格式的影响:

console.log(escape("你好"));
// escape() 函数计算一个新字符串,其中某些字符已被十六进制转义序列替换
console.log(unescape("%u4F60%u597D"));
// unescape() 函数会生成一个新的字符串,其中十六进制转义序列会被替换为其所代表的字符

3.btoa() 和 atob() 与 Unicode 融合的方法

读到这里大家都知道了,以上示例抛出该错误的缘由是字符串包含超出单个字节的字符,开发者可以修改上面的代码:

function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}
const validUTF16String = "hello⛳❤️";
const validUTF16StringEncoded = bytesToBase64(
  new TextEncoder().encode(validUTF16String)
);
console.log(`Encoded string: [${validUTF16StringEncoded}]`);
// 输出编码后的字符串 [aGVsbG/im7PinaTvuI/wn6eA]
const validUTF16StringDecoded = new TextDecoder().decode(
  base64ToBytes(validUTF16StringEncoded)
);
console.log(`Decoded string: [${validUTF16StringDecoded}]`);
// 输出解码后的字符串: [hello⛳❤️]

以上代码的核心改动在于:

  • TextEncoder 获取 UTF-16 编码的 JavaScript 字符串,并使用 TextEncoder.encode() 将其转换为 UTF-8 编码的字节流 ,而 UTF-8 向后兼容 ASCII,可以表明任何标准 Unicode 字符。
  • 将 TextEncoder.encode() 返回的表明 8 位无符号整数的 Uint8Array(范围 0 ~ 255,单个字节) 类型数组传递给 bytesToBase64() 函数,该函数使用 String.fromCodePoint() 将 Uint8Array 中的每个字节视为一个代码点,并以此创建一个字符串,最终生成一个由所有代码点组成的字符串,这些代码点都可以表明为一个字节
String.fromCodePoint(42);
// "*"
String.fromCodePoint(65, 90);
// "AZ"
String.fromCodePoint(0x404);
// "u0404" === "Є"
String.fromCodePoint(0x2f804);
// "uD87EuDC04"
String.fromCodePoint(194564);
// "uD87EuDC04"
String.fromCodePoint(0x1d306, 0x61, 0x1d307);
// "uD834uDF06auD834uDF07"
console.log(String.fromCodePoint(9731, 9733, 9842, 0x2f804));
// Expected output: "☃★♲你"
  • 获取该字符串并使用 btoa() 对其进行 base64 编码

4. 如何处理 btoa() 和 atob() 的静默失败

接下来使用同样的方法来处理不一样的输入,其中输入字符串修改为'hello⛳❤️uDE75':

function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}
const partiallyInvalidUTF16String = "hello⛳❤️uDE75";
const partiallyInvalidUTF16StringEncoded = bytesToBase64(
  new TextEncoder().encode(partiallyInvalidUTF16String)
);
console.log(`Encoded string: [${partiallyInvalidUTF16StringEncoded}]`);
// 输出编码后的字符串: [aGVsbG/im7PinaTvuI/wn6eA77+9]
const partiallyInvalidUTF16StringDecoded = new TextDecoder().decode(
  base64ToBytes(partiallyInvalidUTF16StringEncoded)
);
console.log(`Decoded string: [${partiallyInvalidUTF16StringDecoded}]`);
// 输出解码后的字符串 [hello⛳❤️�]

获取解码后的最后一个字符 (�) 的十六进制值,开发者会惊奇的发现其是 uFFFD 而非原来的 uDE75。但是,btoa() 和 atob() 函数并没有失败或抛出错误。

那这是为什么呢?

如前所述,JavaScript 将字符串处理为 UTF-16,但 UTF-16 字符串具有一个独特的属性。以 表情符号为例,该符号的 Unicode 码位为 129472。但遗憾的是,16 位数字的最大值是 65535!那么 UTF-16 如何表明这个更大的数字呢?

UTF-16 有一个称为 代理对 的概念:

  • 代理对中的第一个数字指定要在哪本 “书” 中搜索,即 “代理”
  • 代理对中的第二个数字是该 “书” 中的条目

开发者可以想象,有时只有代表书籍的数字,而没有该书籍中的实际条目可能会出现问题,而该情况在 UTF-16 中被称为单独代理。

这在 JavaScript 中会常常造成怪癖,由于有些 API 即使有单独代理也能正常工作,而另一些 API 则会失败。在上面示例中,在从 Base64 解码回文本时使用了 TextDecoder,而 TextDecoder 第二个参数 fatal 的默认值为 false,这意味着解码器会用默认字符替换格式错误的数据。

const textDecoder3 = new TextDecoder("csiso2022kr", { fatal: true});

而之前在输出中看到的 � 字符就是默认的替换字符。在 UTF-16 编码中,其是包含唯一代理项的字符串且被视为 “格式错误” 或 “格式不正确”。

有各种 Web 标准明确规定了格式错误的字符串何时会影响 API 行为,而 TextDecoder 就是其中之一。因此,在进行文本处理之前确保字符串格式正确是有必要的。

6. 使用 isWellFormed() 和 encodeURIComponent() 检查字符串格式是否正确

目前,最新的浏览器都提供了一个用于此目的的函数,即 isWellFormed()。当然,开发者还可以使用 encodeURIComponent() 实现类似的结果,如果字符串包含单个代理项则会抛出 URIError 错误。

以下函数在 isWellFormed() 可用时使用,否则使用 encodeURIComponent() :

// 由于旧版浏览器不支持 isWellFormed(),因此需要快速 polyfill
// encodeURIComponent() 对于单独代理会抛出错误,本质上是一样的
function isWellFormed(str) {
  if (typeof str.isWellFormed != "undefined") {
    return str.isWellFormed();
  } else {
    try {
      encodeURIComponent(str);
      return true;
    } catch (error) {
      return false;
    }
  }
}

7. 在 Node.js 中使用 Buffer 进行编码

如果要在 Node.js 中进行 Base64 编码会更加简单。

Node.js 中的 Buffer 类可用于将字符串转换为一系列字节,其提供的 Buffer.from() 方法接受待转换的字符串及其当前的编码,编码可以指定为 “utf8”。然后,可以使用 toString() 方法将转换后的字节以 base64 格式返回。

下面示例中,“base64” 被指定为要使用的编码。因此,此方法可以将任何字符串转换为 base64 格式。

// The original utf8 string
let originalString = "GeeksforGeeks";
// Create buffer object, specifying utf8 as encoding
let bufferObj = Buffer.from(originalString, "utf8");
// Encode the Buffer as a base64 string
let base64String = bufferObj.toString("base64");
console.log("The encoded base64 string is:", base64String);

此时输出如下内容:

The encoded base64 string is: R2Vla3Nmb3JHZWVrcw==

参考资料

声明:原文来自于 Matt Joseph 发表的文章《The nuances of base64 encoding strings in JavaScript》,但是对部分内容做了修改。

https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder/encode

https://web.dev/articles/base64-encoding

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array

https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem

https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem

https://dev.to/codesensei/bufferfrom-vs-atob-vs-btoa-the-differences-and-when-to-use-them-1f79

© 版权声明

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
none
暂无评论...