声明:此文为译文,原文见The nuances of base64 encoding strings in JavaScript,翻译不准处的后面加上了英文原文词汇,翻译不对的地方请不吝赐教,留言更正。
Base64编码和解码是将二进制内容转换为 Web 安全文本的一种常见形式。它通常用于数据 URL,例如内联图像。在 JavaScript 中对字符串应用 base64编码和解码时会发生什么?这篇文章探讨了需要避免的细微差别和常见陷阱。
btoa() and atob()
在 JavaScript 中用于 base64编码和解码的核心函数是 btoa ()和 atob ()。Btoa ()从字符串转换为 base64编码的字符串,atob ()则负责解码回来。
如下为示例:
javascript
// 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。要查看发生了什么,请尝试以下代码:
javascript
// 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 中的字符及其相关数字的一些示例:
代表每个字符的数字称为"码点"(或作"码元")。你可以把"码点"想象成每个字符的地址。在红心表情符号中,实际上有两个代码点: 一个代表心型,另一个代表"改变"颜色,使其始终是红色。Unicode 有两种常见的方法来获取这些代码点,并将它们转换成计算机可以一致解释的字节序列: UTF-8和 UTF-16。一种简单的理解如下:
- 在 UTF-8中,一个编码点可以使用一到四个字节(每字节8位)。
- 在 UTF-16中,一个编码点总是两个字节(16位)。
重要的是,JavaScript 以UTF-16的方式处理字符串。这会破坏 btoa ()等函数,这些函数在假设字符串中的每个字符映射到单个字节的情况下才能有效地进行操作。这在MDN 上有明确的说明:
scss
Btoa ()方法从二进制字符串创建 Base64编码的 ASCII 字符串
(字符串中的每个字符都被视为二进制数据的一个字节)
现在您知道 JavaScript 中的字符通常需要不止一个字节,下一节将演示这种情况下如何如何处理 base64编码和解码的情况。
使用 Unicode 的 btoa ()和 atob ()
正如您现在所知道的,引发的错误是由于我们的字符串包含 UTF-16中位于单个字节之外的字符。幸运的是,base64上的MDN 文章包含了一些有用的示例代码来解决这个" Unicode 问题"。您可以修改此代码以使用前面的示例:
javascript
// 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。这段代码在大多数情况下都能很好地工作,但是在其他情况下却会悄无声息地失败(Silent failure )。
Silent failure的案例
使用相同的代码,但使用不同的字符串:
javascript
// 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。它不会失败或抛出错误,但是输入和输出数据已经悄悄地改变了。为什么?
因 JavaScriptAPI 导致的字符串差异
如前所述,JavaScript 将字符串处理为 UTF-16,但是 UTF-16字符串具有唯一的属性。以奶酪(🧀)表情为例,其 Unicode字符是129472。不幸的是,16位数字的最大值是65535。那么 UTF-16是如何表示这么高的数字的呢?
UTF-16有一个叫做代理对的概念,你可以这样想:
- 代理对中的第一个数字指定要搜索哪本"书",这就是所谓的"代理"
- 两者中的第二个数字是"书"中的某个条目
容易想到,有时只有代表书的数字,却没有该书中的实际条目,可能会出现问题。在 UTF-16中,这被称为孤独代理( lone surrogate)。这在 JavaScript 中尤其具有挑战性,如果只有一个代理,那么有些 API 可以工作,而其他 API 则会失败。在本例中,从 base64解码时使用的是 TextDecoder,TextDecoder 指定以下内容:
arduino
默认值为false, 意味着decoder使用替换字符代替格式错误的数据。
我们前面观察到的 �字符,在十六进制中表示为 \uFFFD, 就是一个替换字符。在 UTF-16中,具有孤独代理( lone surrogate)的字符串被认为是"格式错误"(malformed)或"格式不良"(not well formed)。
有各种各样的 web 标准精确地指定了格式不正确的字符串会影响 API 行为, 值得注意的是,TextDecoder 就是这些 API 之一。在进行文本处理之前确保字符串格式良好是一种很好的做法。
检查是否为格式良好的字符串
一些现代浏览器拥有实现检查是否为良好格式字符串的方法: isWellFormed()。兼容的浏览器如下:
可以通过使用 encodeURIComponent ()实现类似的效果,它将抛出一个URIError如果字符串包含一个孤独代理对。下面代码种,如果isWellFormed()可用,则使用 isWellFormed () ;如果isWellFormed()不可用,则使用 encodeURIComponent ()。可以使用类似的代码为 isWellFormed ()创建 polyfill。
javascript
// 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 和孤独代理,那么就可以将所有内容放在一起来创建可以处理所有情况的代码,而不需要使用静默文本替换:
javascript
// 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,更改 TextDecder 参数以抛出错误,而不是默默地替换孤独代理,等等。
有了这些知识和代码,我们可以明确地决定如何处理格式不正确的字符串,例如拒绝数据或明确地启用数据替换,或者抛出一个错误以供以后分析。
本文除了提供了一个 base64编码和解码的一个有价值的例子之外,还提供了一个例子,说明为什么仔细地文本处理特别重要,特别是当文本数据来自用户生成或外部来源时。