JavaScript 中对 Unicode 进行 base64 编码时需要注意的问题

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

btoa() 和 atob()

在 JavaScript 中,base64 编码和解码的核心函数是 btoa() 和 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 with a hidden modifier numbered 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 字符串。

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

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

正如你现在所知,出现错误的原因是我们的字符串包含了 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. 该函数使用 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有一个叫做代理对 surrogate pairs 的概念。你可以这样理解

  • 数字对中的第一个数字指定在哪本 “书 “中搜索。这就是所谓的 “surrogate“。
  • 数字对中的第二个数字是 “书 “中的条目。

可以想象,如果只有代表图书的编号,而没有该图书的实际条目,有时会很麻烦。在 UTF-16 中,这种情况被称为 ” lone surrogate“。

这在 JavaScript 中尤其具有挑战性,因为有些应用程序接口即使有 lone surrogate 也能正常工作,而另一些应用程序接口则无法正常工作。

在这种情况下,你需要使用 TextDecoder 从 base64 解码。具体来说,TextDecoder 的默认设置如下:

默认值为 false,这意味着解码器会用替换字符替换畸形数据。

你之前观察到的 � 字符,在十六进制中表示为 \uFFFD,就是这种替换字符。在 UTF-16 中,带有 lone surrogate 字符的字符串会被视为 “畸形 “或 “不完整 “字符串。

有多种网络标准(示例 1、2、3、4)明确规定了畸形字符串何时会影响 API 行为,但值得注意的是,TextDecoder 就是其中之一。在进行文本处理之前,确保字符串格式正确是一种良好的做法。

检查字符串格式十分正确

现在,最新的浏览器都为此提供了一个函数:isWellFormed()

使用 encodeURIComponent() 可以获得类似的结果,如果字符串中包含一个 lone surrogate,则会抛出 URIError 错误。

如果 isWellFormed() 可用,则使用下面的函数;如果不可用,则使用 encodeURIComponent()。类似的代码可用于创建 isWellFormed() 的多重弥补。

// 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 和 lone surrogates,您可以将所有内容整合在一起,创建可处理所有情况的代码,并且无需进行静默替换。

// 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 的参数改为抛出,而不是默默替换lone surrogate等等。

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

除了作为 base64 编码和解码的宝贵示例外,这篇文章还提供了一个例子,说明为什么谨慎的文本处理尤为重要,尤其是当文本数据来自用户生成或外部来源时。

阅读余下内容
 

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注


京ICP备12002735号