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

马特·约瑟夫
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。

如需了解会发生的情况,请尝试使用以下代码:

// 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 有一个称为“代理对”的概念。您可以这样理解:

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

正如您能想象的那样,如果仅用代表图书的数字而非图书中的实际条目,有时可能会出现问题。在 UTF-16 中,这称为“单独代理”。

这在 JavaScript 中尤其具有挑战性,因为有些 API 即使有单独的代理,而其他 API 却会失败,仍可正常工作。

在本例中,您在从 base64 解码时使用的是 TextDecoder。具体而言,TextDecoder 的默认值会指定以下内容:

默认为 false,这意味着解码器会将格式错误的数据替换为替换字符。

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

有各种 Web 标准(示例 1234)可明确指明格式不正确的字符串何时影响 API 行为,但值得注意的是,TextDecoder 是这些 API 之一。在进行文本处理之前,最好确保字符串的格式正确无误。

检查字符串的格式是否正确

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

浏览器支持

  • 111
  • 111
  • 119
  • 16.4

来源

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

以下函数使用 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 编码和解码的重要示例之外,这篇博文还提供了一个示例,用于说明为什么谨慎的文本处理尤为重要,尤其是在文本数据来自用户生成的来源或外部来源时。