JavaScript 中 base64 编码字符串的细微差别

Matt Joseph
Matt Joseph

base64 编码和解码是将二进制内容转换为网络安全文本的常见形式。它通常用于数据网址,如内嵌图片。

在 JavaScript 中对字符串应用 base64 编码和解码后会发生什么情况?本文将探讨相关细微之处以及要避免的常见误区。

btoa() 和 atob()

在 JavaScript 中进行 Base64 编码和解码的核心函数是 btoa()atob()btoa() 会从字符串转换为 base64 编码的字符串,而 atob() 会进行反解码。

下面是一个简短的示例:

// A really plain string that is just code points below 128.
const asciiString = 'hello';

// This will work. It will print:
// Encoded string: [aGVsbG8=]
const asciiStringEncoded = btoa(asciiString);
console.log(`Encoded string: [${asciiStringEncoded}]`);

// This will work. It will print:
// Decoded string: [hello]
const asciiStringDecoded = atob(asciiStringEncoded);
console.log(`Decoded string: [${asciiStringDecoded}]`);

遗憾的是,如 MDN 文档中所述,这仅适用于包含 ASCII 字符的字符串,或可以由单个字节表示的字符。也就是说,Unicode 不支持 Unicode。

如需了解具体情况,请尝试以下代码:

// Sample string that represents a combination of small, medium, and large code points.
// This sample string is valid UTF-16.
// 'hello' has code points that are each below 128.
// '⛳' is a single 16-bit code units.
// '❤️' is a two 16-bit code units, U+2764 and U+FE0F (a heart and a variant).
// '🧀' is a 32-bit code point (U+1F9C0), which can also be represented as the surrogate pair of two 16-bit code units '\ud83e\uddc0'.
const validUTF16String = 'hello⛳❤️🧀';

// This will not work. It will print:
// 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 中的字符串。

Unicode 和 JavaScript 中的字符串

Unicode 是当前字符编码的全局标准,或者是为特定字符分配数字以便在计算机系统中使用的做法。如需深入了解 Unicode,请参阅 这篇 W3C 文章

Unicode 中的字符及其关联编号的一些示例:

  • h - 104
  • ? - 241
  • ❤ - 2764
  • ❤️ - 2764(带有隐藏的辅助键编号为 65039)
  • ⛳ - 9971
  • 🧀 - 129472

代表每个字符的数字称为“码位”。您可以将“代码点”视为每个字符的地址。红色心形表情符号实际上包含两个代码点:一个表示心形,另一个用于“改变”颜色,使其始终为红色。

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

一个过于简单的视图如下:

  • 在 UTF-8 中,一个代码点可以使用 1 到 4 个字节(每个字节 8 位)。
  • 在 UTF-16 中,一个码点始终是两个字节(16 位)。

重要的是,JavaScript 会将字符串处理为 UTF-16。这会破坏 btoa() 等函数,这些函数假定字符串中的每个字符都映射到单个字节,从而有效地进行操作。MDN 上明确说明了这一点:

btoa() 方法用于根据二进制字符串(即字符串中的每个字符都被视为一个字节的二进制数据)创建 Base64 编码的 ASCII 字符串。

现在,您已经知道 JavaScript 中的字符通常需要多个字节,下一部分将演示如何处理这种情况下的 base64 编码和解码。

使用 Unicode 的 btoa() 和 atob()

现在,您已经知道抛出错误的原因是我们的字符串包含 UTF-16 中单个字节之外的字符。

幸运的是,有关 base64 的 MDN 文章中提供了一些有用的示例代码,可解决此“Unicode 问题”。您可以修改此代码以使用上述示例:

// From 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));
}

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

// Sample string that represents a combination of small, medium, and large code points.
// This sample string is valid UTF-16.
// 'hello' has code points that are each below 128.
// '⛳' is a single 16-bit code units.
// '❤️' is a two 16-bit code units, U+2764 and U+FE0F (a heart and a variant).
// '🧀' is a 32-bit code point (U+1F9C0), which can also be represented as the surrogate pair of two 16-bit code units '\ud83e\uddc0'.
const validUTF16String = 'hello⛳❤️🧀';

// This will work. It will print:
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA]
const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
console.log(`Encoded string: [${validUTF16StringEncoded}]`);

// This will work. It will print:
// Decoded string: [hello⛳❤️🧀]
const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
console.log(`Decoded string: [${validUTF16StringDecoded}]`);

以下步骤介绍了此代码对字符串进行编码的操作:

  1. 使用 TextEncoder 接口获取 UTF-16 编码的 JavaScript 字符串,并使用 TextEncoder.encode() 将其转换为 UTF-8 编码的字节流。
  2. 这会返回 Uint8Array,这是 JavaScript 中不太常用的数据类型,是 TypedArray 的子类。
  3. 获取该 Uint8Array 并将其提供给 bytesToBase64() 函数,该函数使用 String.fromCodePoint()Uint8Array 中的每个字节视为一个码位,并据此创建一个字符串,从而生成一个可全部表示为单个字节的码位字符串。
  4. 获取该字符串,然后使用 btoa() 对其进行 base64 编码。

解码过程与之相同,只是方向相反。

这样做的原因是,Uint8Array 与字符串之间的步骤可确保 JavaScript 中的字符串表示为 UTF-16 双字节编码,但每个两个字节表示的代码点始终小于 128。

此代码在大多数情况下都能正常运行,但在其他情况下会静默失败。

静默失败情况

使用相同的代码,但使用不同的字符串:

// From 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));
}

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

// Sample string that represents a combination of small, medium, and large code points.
// This sample string is invalid UTF-16.
// 'hello' has code points that are each below 128.
// '⛳' is a single 16-bit code units.
// '❤️' is a two 16-bit code units, U+2764 and U+FE0F (a heart and a variant).
// '🧀' is a 32-bit code point (U+1F9C0), which can also be represented as the surrogate pair of two 16-bit code units '\ud83e\uddc0'.
// '\uDE75' is code unit that is one half of a surrogate pair.
const partiallyInvalidUTF16String = 'hello⛳❤️🧀\uDE75';

// This will work. It will print:
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA77+9]
const partiallyInvalidUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(partiallyInvalidUTF16String));
console.log(`Encoded string: [${partiallyInvalidUTF16StringEncoded}]`);

// This will work. It will print:
// Decoded string: [hello⛳❤️🧀�]
const partiallyInvalidUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(partiallyInvalidUTF16StringEncoded));
console.log(`Decoded string: [${partiallyInvalidUTF16StringDecoded}]`);

如果您在解码后取出最后一个字符 (�) 并检查其十六进制值,您会发现它是 \uFFFD,而不是原来的 \uDE75。它不会失败或抛出错误,但输入和输出数据会静默更改。为什么?

字符串因 JavaScript API 而异

如前所述,JavaScript 会以 UTF-16 编码处理字符串。但 UTF-16 字符串具有唯一属性。

以奶酪表情符号为例。表情符号 (🧀) 的 Unicode 码点为 129472。很遗憾,16 位数的最大值为 65535!那么,UTF-16 如何表示这个更大的数字?

UTF-16 有一个称为“代理对”的概念。您可以这样理解:

  • 键值对中的第一个数字指定要搜索的“书”。这称为“代理”。
  • 该对中的第二个数字是“图书”中的条目。

正如您可能想象的那样,如果只有代表图书的编号,而没有图书中的实际条目,有时可能会出现问题。在 UTF-16 中,这称为单独的代理代码单元

在 JavaScript 中,这尤其具有挑战性,因为有些 API 即使只有单个代理也会正常运行,而有些 API 则会失败。

在这种情况下,您在从 base64 解码时使用的是 TextDecoder。具体而言,TextDecoder 的默认值指定了以下内容:

默认值为 false,表示解码器将格式错误的数据替换为替换字符。

您之前观察到的“�”字符(以十六进制表示为 \uFFFD)就是替换字符。在 UTF-16 中,包含孤立代理字符的字符串会被视为“格式错误”或“格式不正确”。

有各种 Web 标准(示例:1234),它们会明确指定何时格式错误的字符串会影响 API 行为,但值得注意的是,TextDecoder 就是其中一个 API。最好先确保字符串的格式正确,然后再进行文本处理。

检查字符串是否格式正确

最新的浏览器现在提供了一个用于此目的的函数:isWellFormed()

浏览器支持

  • Chrome:111.
  • Edge:111。
  • Firefox:119.
  • Safari:16.4.

来源

您可以使用 encodeURIComponent() 来实现类似的结果,如果字符串包含单个代理字符,则会抛出 URIError 错误

以下函数会在 isWellFormed() 可用时使用 isWellFormed(),在 isWellFormed() 不可用时使用 encodeURIComponent()。类似的代码可用于为 isWellFormed() 创建 polyfill。

// Quick polyfill since older browsers do not support isWellFormed().
// encodeURIComponent() throws an error for lone surrogates, which is essentially the same.
function isWellFormed(str) {
  if (typeof(str.isWellFormed)!="undefined") {
    // Use the newer isWellFormed() feature.
    return str.isWellFormed();
  } else {
    // Use the older encodeURIComponent().
    try {
      encodeURIComponent(str);
      return true;
    } catch (error) {
      return false;
    }
  }
}

总结

现在,您已经知道如何处理 Unicode 和孤立的代理字符,接下来可以将所有内容整合起来,创建能够处理所有情况且无需静默文本替换的代码。

// From 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));
}

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

// Quick polyfill since Firefox and Opera do not yet support isWellFormed().
// encodeURIComponent() throws an error for lone surrogates, which is essentially the same.
function isWellFormed(str) {
  if (typeof(str.isWellFormed)!="undefined") {
    // Use the newer isWellFormed() feature.
    return str.isWellFormed();
  } else {
    // Use the older encodeURIComponent().
    try {
      encodeURIComponent(str);
      return true;
    } catch (error) {
      return false;
    }
  }
}

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

if (isWellFormed(validUTF16String)) {
  // This will work. It will print:
  // Encoded string: [aGVsbG/im7PinaTvuI/wn6eA]
  const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
  console.log(`Encoded string: [${validUTF16StringEncoded}]`);

  // This will work. It will print:
  // Decoded string: [hello⛳❤️🧀]
  const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
  console.log(`Decoded string: [${validUTF16StringDecoded}]`);
} else {
  // Not reached in this example.
}

if (isWellFormed(partiallyInvalidUTF16String)) {
  // Not reached in this example.
} else {
  // This is not a well-formed string, so we handle that case.
  console.log(`Cannot process a string with lone surrogates: [${partiallyInvalidUTF16String}]`);
}

您可以对此代码进行许多优化,例如泛化为 polyfill、更改要抛出的 TextDecoder 参数(而不是静默替换单独的代理),等等。

有了这些知识和代码,您还可以明确决定如何处理格式错误的字符串,例如拒绝数据或明确启用数据替换,或者抛出错误以供日后分析。

这篇博文不仅是一个很有价值的 base64 编码和解码示例,还提供了一个示例来说明为什么谨慎处理文本尤为重要,尤其是当文本数据来自用户生成或外部来源时。