[译]JavaScript中Base64编码字符串的细节

742次阅读  |  发布于1年以前

本文作者为 360 奇舞团前端开发工程师

原文标题:The nuances of base64 encoding strings in JavaScript

原文作者:Matt Joseph

原文链接:https://web.dev/articles/base64-encoding

Base64编码和解码是一种常见的将二进制内容转换为适合Web的文本的形式。它通常用于data URLs,比如内嵌图片。

当你在JavaScript中对字符串应用base64编码和解码时会发生什么?这篇文章探讨了这些细节和需要避免的常见陷阱。

btoa() 和 atob() 函数

JavaScript中进行base64编码和解码的核心函数是btoa()atob()。btoa()用于将字符串转换为base64编码的字符串,而atob()则用于解码。

下面是一个快速示例:

// 一个非常简单的字符串,仅包含低于128的代码点。
const asciiString = 'hello';

// 这将会成功,它将打印:
// 编码后的字符串: [aGVsbG8=]
const asciiStringEncoded = btoa(asciiString);
console.log(`Encoded string: [${asciiStringEncoded}]`);

// 这也将会成功,它将打印:
// 解码后的字符串: [hello]
const asciiStringDecoded = atob(asciiStringEncoded);
console.log(`Decoded string: [${asciiStringDecoded}]`);

不幸的是,正如MDN文档所指出的,这只适用于包含ASCII字符的字符串,即可以用单个字节表示的字符。换句话说,这对于Unicode来说不起作用。

要理解发生了什么,请尝试以下代码:

// 示例字符串表示了小、中、大代码点的组合。
// 这个示例字符串是有效的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中的字符串。

Unicode和JavaScript中的字符串

Unicode是当前的全球字符编码标准,它是将数字分配给特定字符的实践,以便在计算机系统中使用。有关Unicode的更深入了解,请访问W3C的文章。

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

深入了解变体选择器的概念。

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

一个过于简化的视角是:

重要的是,JavaScript处理字符串时使用的是UTF-16。这破坏了像btoa()这样的函数,这些函数实际上是基于这样一个假设:字符串中的每个字符映射到一个单字节。MDN上明确说明了这一点:

The btoa() method creates a Base64-encoded ASCII string from a binary string (i.e., a string in which each character in the string is treated as a byte of binary data).

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

btoa()和atob()与Unicode

正如您现在所知,抛出的错误是由于我们的字符串包含位于单个字节之外的UTF-16字符。

幸运的是,MDN关于base64的文章包含了一些有用的示例代码来解决这个“Unicode问题”。您可以修改这些代码以适应前面的示例:

// 来自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(`Encoded string: [${validUTF16StringEncoded}]`);

// 这将会成功。它将打印:
// 解码后的字符串: [hello⛳❤️]
const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
console.log(`Decoded string: [${validUTF16StringDecoded}]`);The following steps explain what this code does to encode the string:
  1. 使用TextEncoder接口将UTF-16编码的JavaScript字符串转换为UTF-8编码的字节流,可通过TextEncoder.encode()实现。
  2. 这将返回一个Uint8Array,这是JavaScript中较少使用的数据类型,是TypedArray的子类。
  3. 将这个Uint8Array提供给bytesToBase64()函数,该函数使用String.fromCodePoint()将Uint8Array中的每个字节作为代码点处理,并从中创建一个字符串,其结果为一个可以全部用单个字节表示的代码点的字符串。
  4. 使用btoa()对该字符串进行base64编码。

解码过程与此相同,但顺序相反。

这有效的原因是,Uint8Array和字符串之间的步骤保证了虽然JavaScript中的字符串是以UTF-16的两字节编码表示的,但每两个字节代表的代码点始终小于128。

这段代码在大多数情况下都工作良好,但在其他情况下会悄悄地失败。

静默失败的案例

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

// 来自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(`Encoded string: [${partiallyInvalidUTF16StringEncoded}]`);

// 这也将会成功。它将打印:
// 解码后的字符串: [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中,这被称为 lone surrogate

这在JavaScript中尤其具有挑战性,因为一些API尽管存在单独代理也能工作,而其他API则会失败。

在前面的例子中,您在从base64解码回来时使用了TextDecoder。特别是,TextDecoder的默认设置指定了以下内容:

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

您之前观察到的那个�字符,用十六进制表示为\uFFFD,就是那个替代字符。在UTF-16中,带有单独代理的字符串被视为“格式错误的”或“不规范的”。

有各种Web标准(示例1, 2, 3, 4)准确指定了格式错误的字符串何时影响API行为,但值得注意的是TextDecoder是这些API之一。在进行文本处理之前确保字符串格式规范是一个好习惯

检查格式良好的字符串

最近版本的浏览器现在具有用于此目的的函数:isWellFormed().

浏览器支持: isWellFormed().

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

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

// 由于旧版浏览器不支持isWellFormed(),可以快速创建polyfill。
// 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和单独代理,您可以将所有内容整合在一起,创建能够处理所有情况并且不会进行静默文本替换的代码。

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

// 由于旧版浏览器不支持isWellFormed(),可以快速创建polyfill。
// encodeURIComponent()对于单独代理会抛出错误,这本质上是相同的。
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)) {
  // 这将会成功。它将打印:
  // 编码后的字符串: [aGVsbG/im7PinaTvuI/wn6eA]
const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
console.log(`Encoded string: [${validUTF16StringEncoded}]`);

  // 这将会成功。它将打印:
  // 解码后的字符串: [hello⛳❤️]
  const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
  console.log(`Decoded string: [${validUTF16StringDecoded}]`);
} else {
  // 忽略
}

if (isWellFormed(partiallyInvalidUTF16String)) {
  // 忽略
} else {
  // 这不是一个格式良好的字符串,因此我们要处理这种情况。
  console.log(`Cannot process a string with lone surrogates: [${partiallyInvalidUTF16String}]`);
}

这段代码可以进行许多优化,比如将其泛化为一个polyfill,将TextDecoder的参数更改为在单独代理处抛出而不是默默替换,以及其他。有了这些知识和代码,您还可以明确决定如何处理格式不正确的字符串,比如拒绝数据或明确启用数据替换,或者为以后分析而抛出错误。

除了作为base64编码和解码的一个有价值的例子外,本文还提供了一个例子,说明仔细处理文本数据尤其重要,特别是当文本数据来自用户生成或外部来源时。

Copyright© 2013-2019

京ICP备2023019179号-2