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


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

当你在JavaScript中应用 base64 编码和解码到字符串时会发生什么?本文探讨了其中的细微差别和常见陷阱。

btoa()atob()

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

以下是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
// 一个非常简单的只包含 128 个以下的代码点的字符串。
const asciiString = 'hello';

// 这会成功。它将打印:
// Encoded string: [aGVsbG8=]
const asciiStringEncoded = btoa(asciiString);
console.log(`Encoded string: [${asciiStringEncoded}]`);

// 这也会成功。它将打印:
// Decoded string: [hello]
const asciiStringDecoded = atob(asciiStringEncoded);
console.log(`Decoded string: [${asciiStringDecoded}]`);

不幸的是,正如`` MDN 文档中所指出的,这只适用于包含ASCII字符或可由单个字节表示的字符的字符串。换句话说,它不适用于 Unicode

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 一个代表小、中、大代码点组合的样本字符串。
// 这个样本字符串是有效的 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 中一些字符及其相关数字的示例:

1
2
3
4
5
6
h - 104
ñ - 241
❤ - 2764
❤️ - 有一个隐藏修饰符编号为 65039 的 2764
⛳ - 9971
🧀 - 129472

表示每个字符的数字称为 “代码点”。你可以把 “代码点” 看作是每个字符的地址。在红心表情符号中,实际上有两个代码点:一个是心形,另一个是 “变化” 颜色并始终是红色。

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

一个过于简单化的观点是这样的:

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

重要的是,JavaScript 处理字符串时将其视为 UTF-16。这破坏了像 btoa() 这样的函数,因为它们实际上假设字符串中的每个字符都映射到一个字节。这在 MDN 上有明确说明:

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

现在你知道 JavaScript 中的字符通常需要多个字节,下一节将演示如何处理此情况以进行 base64 编码和解码。

带有 Unicode 的 btoa() 和 atob()

如今你已经知道,抛出的错误是由于我们的字符串包含位于 UTF-16 单个字节之外的字符。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 来自 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⛳❤️🧀';

// 这会成功。它将打印:
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA]
const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
console.log(`Encoded string: [${validUTF16StringEncoded}]`);

// 这也会成功。它将打印:
// Decoded string: [hello⛳❤️🧀]
const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
console.log(`Decoded string: [${validUTF16StringDecoded}]`);

以下步骤解释了这段代码对字符串进行编码的过程:

  • 使用 TextEncoder 接口将 UTF-16 编码的JavaScript字符串转换为 UTF-8 编码的字节流,使用 TextEncoder.encode()
  • 这将返回一个 Uint8Array,这是 JavaScript 中不常用的数据类型,是 TypedArray 的子类。
  • 取出该 Uint8Array 并将其提供给 bytesToBase64() 函数,该函数使用 String.fromCodePoint() Uint8Array 中的每个字节视为一个代码点,并从中创建一个字符串,结果是一个由可以全部表示为单个字节的代码点组成的字符串。
  • 取出该字符串并使用btoa()进行 base64 编码。

解码过程是相同的,但是是反向进行的。

这能够正常工作是因为 Uint8Array 和字符串之间的转换确保了在JavaScript中字符串被表示为 UTF-16,两个字节的编码时,每两个字节代表的代码点始终小于 128。

这段代码在大多数情况下运行良好,但在其他情况下会默默失败。

默默失败的情况

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 来自 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';

// 这会成功。它将打印:
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA77+9]
const partiallyInvalidUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(partiallyInvalidUTF16String));
console.log(`Encoded string: [${partiallyInvalidUTF16StringEncoded}]`);

// 这也会成功。它将打印:
// 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”。这称为 “surrogate”。
  • 对中的第二个数字是 “book” 中的条目。

你可以想象,仅有代表book的数字而没有实际条目可能有时会有问题。在 UTF-16 中,这被称为单独代理。

这在 JavaScript 中特别具有挑战性,因为一些API尽管存在单独代理,但仍能正常工作,而另一些则失败。

在这种情况下,你在从 base64 解码时使用 TextDecoder。特别是,TextDecoder 的默认设置如下:

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

你之前观察到的那个 � 字符,在十六进制中表示为 \uFFFD,就是那个替换字符。在UTF-16中,具有单独代理的字符串被认为是 “畸形” 或 “不完整” 的。

有各种网络标准(例如 1、2、3、4)确切地指定了何时畸形字符串会影响 API 行为,但值得注意的是 TextDecoder 是其中之一。在进行文本处理之前,确保字符串是完整的是一个好的做法。

检查格式良好的字符串

最近的浏览器现在有一个用于此目的的函数:isWellFormed()

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 快速 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 和单独代理,您可以将所有内容整合在一起,创建处理所有情况且不进行悄悄文本替换的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 来自 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,因为 Firefox 和 Opera 尚不支持 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)) {
// 这会成功。它将打印:
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA]
const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
console.log(`Encoded string: [${validUTF16StringEncoded}]`);

// 这也会成功。它将打印:
// Decoded string: [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编码和解码的宝贵示例之外,本文还提供了一个示例,说明仔细处理文本数据尤其重要,特别是当文本数据来自用户生成或外部来源时。