【译】在 JavaScript 中 base64 编码字符串的一些细节

base64 编码和解码经常被用于将二进制内容转换为 web-safe 文本, 它通常被用于 data URL 中, 比如内联的图片

JavaScript 中当你使用 base64 对字符串进行编码和解码时, 它们会发生些什么呢? 在本文中将探讨如何规避一些细节以及常见的误区

btoa() 和 atob()

btoa()atob()JavaScript 中的核心方法, 可用于对 base64 字符串进行编码和解码。btoa() 用于将一个字符串转码为 base64, 同时 atob() 可将编码后的 base64 解码回去

下面来看一段简短的例子:

js 复制代码
// 一个普通的字符串, 且码位低于 128
const asciiString = 'hello';

// 下面代码会正常执行, 并打印出: 
// 编码后的字符串: [aGVsbG8=]
const asciiStringEncoded = btoa(asciiString);
console.log(`编码后的字符串: [${asciiStringEncoded}]`);

// 下面代码会正常执行, 并打印出: 
// 解码后的字符串: [hello]
const asciiStringDecoded = atob(asciiStringEncoded);
console.log(`解码后的字符串: [${asciiStringDecoded}]`); 

不幸的是, 正如 MDN docs 所记录的, 上面代码只有在字符串中包含 ASCII 或者能够使用单字节进行描述的字符时才能够正常工作, 换句话说就是不能用于包含 Unicode 的字符串

尝试运行下面代码, 来看看都发生了啥:

js 复制代码
// 实例字符串包含了小、中、大的码位
// 实例字符串是一个有效的 UTF-16 字符串
// 'hello' 所有码位都在 128 以下
// '⛳' 是单个的 16 为字符单元
// '❤️' 是二个 16 为字符单元,  U+2764 和 U+FE0F (一颗心和一个变种)
// '🧀' 是一个 32 为的字符 (U+1F9C0), 它也可以使用两个 16 的代码单元进行表示 '\ud83e\uddc0'
const validUTF16String = 'hello⛳❤️🧀';

// 下面代码将不能正常工作, 它将会打印:
// DOMException: Failed to execute 'btoa' on 'Window': The string to be encoded contains characters outside of the Latin1 range.
try {
  const validUTF16StringEncoded = btoa(validUTF16String);
  console.log(`Encoded string: [${validUTF16StringEncoded}]`);
} catch (error) {
  console.log(error);
}

任何一个表情字符在字符串中都会导致这个错误。那么为什么 Unicode 会导致这个问题呢?

要理解这个问题, 先让我们退一步先理解下在计算机科学和 JavaScript中的字符串

JavaScript 中的 Unicode 字符串

Unicode 是目前全球标准的字符串编码, 使用不同的数字来表示特定的字符, 这样的话就能够在电脑系统中被使用。如果你想深入了解 Unicode 可以浏览 这篇 W3C 文章

下面是一个一些关于 Unicode 字符以及对应关联的数字:

这些描述每个字符的数值被称之为「码位」, 你可以将「码位」理解为是每个字符的一个地址。上面红心表情字符, 它其实包含了两个「码位」: 一个表示一个爱心, 一个则是用于设置颜色, 让字符一直展示为红色。

学习更多关于 [变异选择器](en.wikipedia.org/wiki/Variat...) 的概念。

Unicode 有两种常见的方法获取字符的「码位」并将其转换为对应的字节, 使得这些字符能够在电脑系统能够保持一致性: UTF-8 以及 UTF-16

下面是个人简单的观点:

  • UTF-8 中, 一个「码位」使用一到四个字节进行表示(每个字节 8 bit)
  • UTF-16 中, 一个「码位」是 2 个字节(16 bit)

重要的是, JavaScript 会将字符串处理为 UTF-16。但是呢这个会破坏类似 btoa() 这样的函数, 假设这些函数能够有效的将字符串中每个字符映射为对应的字节。MDN 里明确说明了这一点:

这个 btoa() 方法基于一个二进制字符串创建一个 Base64 编码的 ASCII 字符串(即, 在字符串中每一个字符都会被视为一个字节的二进制数据)

现在你知道, 在 JavaScript 中字符往往需要多个字节进行表示, 在下一个节我们将演示在 base64 编码和解码中如何去处理这个问题。

在 Unicode 中使用 btoa() 和 atob()

我们都知道, 上面所说的抛出的错误主要是因为在 UTF-16 字符串中包含了非单个字节的字符。

幸运的是, 在 DMN 的一篇关于 base64 的文章 中包含了一些解决这个「Unicode 问题」的有用的实例代码。你可以使用前面的例子并修改此代码然后运行它:

js 复制代码
// 来自 https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

// 来自 https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// 实例字符串包含了小、中、大的码位
// 实例字符串是一个有效的 UTF-16 字符串
// 'hello' 所有码位都在 128 以下
// '⛳' 是单个的 16 为字符单元
// '❤️' 是二个 16 为字符单元,  U+2764 和 U+FE0F (一颗心和一个变种)
// '🧀' 是一个 32 为的字符 (U+1F9C0), 它也可以使用两个 16 的代码单元进行表示 '\ud83e\uddc0'
const validUTF16String = 'hello⛳❤️🧀';

// 下面代码能够正常工作, 并打印:
// 编码后的字符串: [aGVsbG/im7PinaTvuI/wn6eA]
const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
console.log(`编码后的字符串: [${validUTF16StringEncoded}]`);

// 下面代码能够正常工作, 并打印:
// 解码后的字符串: [hello⛳❤️🧀]
const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
console.log(`解码后的字符串: [${validUTF16StringDecoded}]`);

下面步骤解释了, 这段代码如何对字符串进行编码的:

  1. 使用 TextEncoder 接口来获取 UTF-16 编码字符串并通过 TextEncoder.encode() 来将其转为 UTF-8 编码的字节流
  2. 这个将返回一个 Uint8Array, 它是一个在 JavaScript 中被使用得比较少的一种数据类型, 并且它是 TypedArray 的一个子类
  3. 获取到 Uint8Array 然后作为 bytesToBase64() 函数的参数被使用, 该函数内使用 String.fromCodePoint() 来处理数据, 它会将 Uint8Array 中的每个字节作为一个「码位」并基于它创建字符串
  4. 最后将获取到的字符串再使用 btoa() 转为 base64 编码

而这解码的过程基本和解码一致, 只是它是一个相反的流程。

上文代码之所以能够正常工作是因为, Uint8Array 和字符串之间的步骤保证了字符串在 JavaScript 中能够被表示为 UTF-16, 以及双字节编码, 并且每个字节的「码位」总是小于 128

上面这段代码在大部分情况下都能够很好的工作, 但是还是会在以下特殊情况下莫名的失效。

莫名失效的情况

如下, 实现相同的代码, 但是使用不同的字符串:

js 复制代码
// 来自 https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

// 来自 https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// 实例字符串包含了小、中、大的码位
// 实例字符串是一个有效的 UTF-16 字符串
// 'hello' 所有码位都在 128 以下
// '⛳' 是单个的 16 为字符单元
// '❤️' 是二个 16 为字符单元,  U+2764 和 U+FE0F (一颗心和一个变种)
// '🧀' 是一个 32 为的字符 (U+1F9C0), 它也可以使用两个 16 的代码单元进行表示 '\ud83e\uddc0'
// '\uDE75' 编码单元 是「代理对」的一半
const partiallyInvalidUTF16String = 'hello⛳❤️🧀\uDE75';

// 下面代码能够正常工作, 并打印:
// 编码后的字符串: [aGVsbG/im7PinaTvuI/wn6eA77+9]
const partiallyInvalidUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(partiallyInvalidUTF16String));
console.log(`编码后的字符串: [${partiallyInvalidUTF16StringEncoded}]`);

// 下面代码能够正常工作, 并打印:
// 解码后的字符串: [hello⛳❤️🧀�]
const partiallyInvalidUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(partiallyInvalidUTF16StringEncoded));
console.log(`解码后的字符串: [${partiallyInvalidUTF16StringDecoded}]`);

如果你拿到最后一个解码后的字符(�)并且检查它的一个十六进制的值, 你将发现它的值是 \uFFFD 而不是最初的值 \uDE75。上面的代码不会允许失败或者抛出错误, 但是呢编码解码后输入输出内容却悄悄发生改变, 这是为什么呢?

字符串在 JavaScript API 中的差异

如上文所述, JavaScript 会将字符串作为 UTF-16 进行处理, 但是呢 UTF-16 字符串却有一个独特的特性。

拿奶酪表情作为一个例子, 这个表情(🧀)它有一个对应的 Unicode 其「码位」是 129472。不幸的是, 对于 16 字节的数值其最大值为 65535!! 所以如何在 UTF-16 中去表示「码位」数值比较大的字符呢?

UTF-16 有一个「代理对」的概念, 你可以这么理解它:

  • 前部分数值, 它表示使用哪个 "book" 进行搜索, 这里我们可以称之为 代理项
  • 后部分数值, 则表示字符在 "book" 中的一个位置

因此也许你能够想象得到, 有时仅仅使用一个数值去表示这个 "book", 而不是具体的条目这将可能会成为一个问题。这在 UTF-16 被称为「单独代理」。

JavaScript 中, 上面这特性让一切变得特别麻烦起来, 因为对于有些 API 能够正常在「单独代理」的字符串中运行, 而有的就无法正常工作。

在这种情况下, 当你需要将 base64 解码回去的话可以使用 TextDecoder。需要注意的是, 在 TextDecoder 中其默认情况如下所诉:

TextDecoder 可以设置配置项, 其中有个配置项叫 fatal 表示在解码时如果发生错误是否抛出错误, 其默认值为 false; 该值作用是在解码时替换错误的数据时会使用默认的 替换符

我们看到的 字符, 在十六进制中它被表示为 \uFFFD, 而它实际上就是所谓的 替换符。在 UTF-16 中, 字符串具有「单独代理」的字符, 会被认为是 错误的不正确的

这里有多种的 WEB 标准(例子: 1, 2, 3, 4), 确切的指出了什么情况下错误的字符串将会影响 API 的一个行为, 并且 TextDecoder 就是其中的一个 API。在你处理文本之前确保字符串符合规范是一个很好的做法。、

检测字符串是否规范

最新的一些现代浏览器有一个方法就可以实现我们的目标: isWellFormed()。该方法可用于判断字符串中是否包含「单独代理项」。

当然你也可以使用 encodeURIComponent() 来达到一个相似的结果, 如果字符串中包含了「单独代理项」该方法会抛出 URIError 错误。

如下实例代码, 如果浏览器支持 isWellFormed() 则直接使用, 否则使用 encodeURIComponent()。类似下面代码可以用于实现一个 isWellFormed()polyfill

js 复制代码
// 简的的 polyfill 使得老版本的浏览器也能够支持 isWellFormed()
// encodeURIComponent() 在「单独代理」中会抛出错误, 这个本质上是一样的问题
function isWellFormed(str) {
  if (typeof(str.isWellFormed)!="undefined") {
    // 使用最新的 isWellFormed() 特性
    return str.isWellFormed();
  } else {
    // 使用老方法 encodeURIComponent()
    try {
      encodeURIComponent(str);
      return true;
    } catch (error) {
      return false;
    }
  }
}

将所有代码合并

现在你清楚如何去处理 Unicode 以及「单独代理」, 下面你可以将上面代码进行一个合并成一段代码去处理各种情况, 并且处理后的字符串是不带有替换字符的

js 复制代码
// 来自 https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

// 来自 https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// 简的的 polyfill 使得老版本的浏览器也能够支持 isWellFormed()
// encodeURIComponent() 在「单独代理」中会抛出错误, 这个本质上是一样的问题
function isWellFormed(str) {
  if (typeof(str.isWellFormed)!="undefined") {
    // 使用最新的 isWellFormed() 特性
    return str.isWellFormed();
  } else {
    // 使用老方法 encodeURIComponent()
    try {
      encodeURIComponent(str);
      return true;
    } catch (error) {
      return false;
    }
  }
}

const validUTF16String = 'hello⛳❤️🧀';
const partiallyInvalidUTF16String = 'hello⛳❤️🧀\uDE75';

if (isWellFormed(validUTF16String)) {
  // 下面代码能够正常工作, 并打印:
  // 编码后的字符串: [aGVsbG/im7PinaTvuI/wn6eA77+9]
  const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
  console.log(`编码后的字符串: [${validUTF16StringEncoded}]`);

  // 下面代码能够正常工作, 并打印:
  // 解码后的字符串: [hello⛳❤️🧀�]
  const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
  console.log(`解码后的字符串: [${validUTF16StringDecoded}]`);
} else {
  // 不会执行
}

if (isWellFormed(partiallyInvalidUTF16String)) {
  // 不会执行
} else {
  // 这不是一个规范的字符串, 我们需要处理下这种情况
  console.log(`不支持带有「单独代理」的字符串: [${partiallyInvalidUTF16String}]`);
}

当然, 这段代码还有很多可以优化的空间, 比如, 可以将上面代码整理到 polyfill, 修改 TextDecoder 的规范, 使其在遇到「单独代理」时及时抛出错误而不是直接悄摸摸的使用替换符进行替换, 当然能够优化的还有很多。

理解了这些知识和代码, 对于如何处理不规范的字符串你也能够做出正确的决策, 比如拒绝处理对应错误数据或者使用「替换符」来替换数据, 又或者抛出错误以便后续分析使用。

除了为 base64 的编码和解码提供了一个有价值的案例之外, 本文还提供了一个案例去说明为什么谨慎处理文本是一件重要的事件, 特别是当文本数据是由用户生成的或者是外部资源的情况下是尤为重要的。

相关推荐
hackeroink6 分钟前
【2024版】最新推荐好用的XSS漏洞扫描利用工具_xss扫描工具
前端·xss
迷雾漫步者2 小时前
Flutter组件————FloatingActionButton
前端·flutter·dart
向前看-2 小时前
验证码机制
前端·后端
燃先生._.3 小时前
Day-03 Vue(生命周期、生命周期钩子八个函数、工程化开发和脚手架、组件化开发、根组件、局部注册和全局注册的步骤)
前端·javascript·vue.js
高山我梦口香糖4 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
m0_748235244 小时前
前端实现获取后端返回的文件流并下载
前端·状态模式
m0_748240255 小时前
前端如何检测用户登录状态是否过期
前端
black^sugar5 小时前
纯前端实现更新检测
开发语言·前端·javascript
寻找沙漠的人6 小时前
前端知识补充—CSS
前端·css