小编典典

使用 Javascript 的 atob 解码 base64 不能正确解码 utf-8 字符串

all

我正在使用 Javascriptwindow.atob()函数来解码 base64 编码的字符串(特别是来自 GitHub API 的 base64
编码的内容)。问题是我得到了 ASCII 编码的字符(比如â¢代替)。如何正确处理传入的 base64 编码流,以便将其解码为 utf-8?


阅读 73

收藏
2022-07-17

共1个答案

小编典典

Unicode 问题

尽管 JavaScript (ECMAScript) 已经成熟,但 Base64、ASCII 和 Unicode
编码的脆弱性已经引起了很多头痛(大部分都在这个问题的历史中)。

考虑以下示例:

const ok = "a";
console.log(ok.codePointAt(0).toString(16)); //   61: occupies < 1 byte

const notOK = "✓"
console.log(notOK.codePointAt(0).toString(16)); // 2713: occupies > 1 byte

console.log(btoa(ok));    // YQ==
console.log(btoa(notOK)); // error

为什么我们会遇到这种情况?

根据设计,Base64 期望二进制数据作为其输入。就 JavaScript
字符串而言,这意味着每个字符仅占用一个字节的字符串。因此,如果您将一个字符串传递给
btoa(),其中包含占用超过一个字节的字符,您将收到错误,因为这不被视为二进制数据。

资料来源:MDN (2021)

最初的 MDN 文章还涵盖了 和 的损坏特性window.btoa.atob这些特性已在现代 ECMAScript 中得到修复。原始的,现已死亡的
MDN 文章解释说:

“Unicode 问题” 由于DOMStrings 是 16 位编码的字符串,因此在大多数浏览器中调用window.btoaUnicode
字符串时,Character Out Of Range exception如果字符超出 8 位字节 (0x00~0xFF) 的范围,则会导致 a。


具有二进制互操作性的解决方案

(继续滚动查看 ASCII base64 解决方案)

资料来源:MDN (2021)

MDN 推荐的解决方案是对二进制字符串表示进行实际编码:

编码 UTF8 - 二进制

// convert a Unicode string to a string in which
// each 16-bit unit occupies only one byte
function toBinary(string) {
  const codeUnits = new Uint16Array(string.length);
  for (let i = 0; i < codeUnits.length; i++) {
    codeUnits[i] = string.charCodeAt(i);
  }
  return btoa(String.fromCharCode(...new Uint8Array(codeUnits.buffer)));
}

// a string that contains characters occupying > 1 byte
let encoded = toBinary("✓à  la mode") // "EycgAOAAIABsAGEAIABtAG8AZABlAA=="

解码二进制——UTF-8

function fromBinary(encoded) {
  const binary = atob(encoded);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < bytes.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return String.fromCharCode(...new Uint16Array(bytes.buffer));
}

// our previous Base64-encoded string
let decoded = fromBinary(encoded) // "✓ à la mode"

这有点失败的地方是,您会注意到编码的字符串EycgAOAAIABsAGEAIABtAG8AZABlAA==不再与以前的解决方案的字符串匹配4pyTIMOgIGxhIG1vZGU=。这是因为它是二进制编码的字符串,而不是
UTF-8 编码的字符串。如果这对您来说无关紧要(即,您没有从另一个系统转换以 UTF-8 表示的字符串),那么您就可以开始了。但是,如果您想保留
UTF-8 功能,最好使用下面描述的解决方案。


具有 ASCII base64 互操作性的解决方案

这个问题的整个历史显示了多年来我们不得不用多少不同的方法来解决损坏的编码系统。 尽管最初的 MDN
文章不再存在,但这个解决方案仍然可以说是一个更好的解决方案,并且在解决“Unicode
问题”方面做得很好,同时保留了可以在base64decode.org上解码的纯文本
base64 字符串。

有两种可能的方法来解决这个问题:

  • 第一个是转义整个字符串(使用
    UTF-8,请参阅encodeURIComponent),然后对其进行编码;
  • 第二种是将 UTF-16 转换为DOMStringUTF-8 字符数组,然后对其进行编码。

关于以前解决方案的说明:MDN 文章最初建议使用unescapeandescape来解决Character Out Of Range异常问题,但后来它们已被弃用。这里的一些其他答案建议使用decodeURIComponentand来解决这个问题encodeURIComponent,这已被证明是不可靠且不可预测的。此答案的最新更新使用现代
JavaScript 函数来提高速度和现代化代码。

如果您想节省一些时间,您还可以考虑使用库:

编码 UTF8 - base64

    function b64EncodeUnicode(str) {
        // first we use encodeURIComponent to get percent-encoded UTF-8,
        // then we convert the percent encodings into raw bytes which
        // can be fed into btoa.
        return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
            function toSolidBytes(match, p1) {
                return String.fromCharCode('0x' + p1);
        }));
    }

    b64EncodeUnicode('✓ à la mode'); // "4pyTIMOgIGxhIG1vZGU="
    b64EncodeUnicode('\n'); // "Cg=="

解码base64——UTF8

    function b64DecodeUnicode(str) {
        // Going backwards: from bytestream, to percent-encoding, to original string.
        return decodeURIComponent(atob(str).split('').map(function(c) {
            return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
        }).join(''));
    }

    b64DecodeUnicode('4pyTIMOgIGxhIG1vZGU='); // "✓ à la mode"
    b64DecodeUnicode('Cg=='); // "\n"

(为什么我们需要这样做?('00' + c.charCodeAt(0).toString(16)).slice(-2)在单个字符串前面加上一个 0,例如
when c == \nc.charCodeAt(0).toString(16)returns a,强制a表示为0a)。


打字稿支持

这是具有一些额外 TypeScript 兼容性的相同解决方案(通过@MA-Maddin):

// Encoding UTF8 ⇢base64

function b64EncodeUnicode(str) {
    return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function(match, p1) {
        return String.fromCharCode(parseInt(p1, 16))
    }))
}

// Decoding base64 ⇢UTF8

function b64DecodeUnicode(str) {
    return decodeURIComponent(Array.prototype.map.call(atob(str), function(c) {
        return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
    }).join(''))
}

第一个解决方案(已弃用)

这使用了escapeunescape(现在已弃用,尽管这仍然适用于所有现代浏览器):

function utf8_to_b64( str ) {
    return window.btoa(unescape(encodeURIComponent( str )));
}

function b64_to_utf8( str ) {
    return decodeURIComponent(escape(window.atob( str )));
}

// Usage:
utf8_to_b64("✓ à la mode"); // "4pyTIMOgIGxhIG1vZGU="
b64_to_utf8('4pyTIMOgIGxhIG1vZGU='); // "✓ à la mode"

最后一件事:我在调用 GitHub API 时第一次遇到这个问题。为了让它在 (Mobile) Safari 上正常工作,我实际上必须先从 base64
源中去除所有空白, 然后 才能解码源。这在 2021 年是否仍然有用,我不知道:

function b64_to_utf8( str ) {
    str = str.replace(/\s/g, '');    
    return decodeURIComponent(escape(window.atob( str )));
}
2022-07-17