JavaScript 中的 Base64 編碼字串細微差異

麥特約瑟夫
Matt Joseph

Base64 編碼和解碼是二元內容轉換,以網路安全文字表示的常見形式。通常用於資料網址,例如內嵌圖片。

如果對 JavaScript 中的字串套用 base64 編碼和解碼,會有什麼影響?本文將探討一些細微差異和應避免的常見錯誤。

btoa() 和 atob()

在 JavaScript 中採用 base64 編碼和解碼的核心函式為 btoa()atob()btoa() 從字串轉換為採用 Base64 編碼的字串,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 字元及其相關數字範例:

  • 高至 104
  • ñ - 241
  • ❤ - 2764
  • ❤️ - 2764 (含有隱藏修飾符編號 65039)
  • ★ - 9971
  • 🧀? - 129472

代表每個字元的數字稱為「碼點」。您可以將「碼點」視為每個字元的地址。紅色心形表情符號實際上有兩個代碼點:一個代表心形,另一個代表「改變」顏色,並一律設為紅色。

萬國碼 (Unicode) 使用 UTF-8 和 UTF-16 將這些碼點轉換為位元組序列,這些碼點經常可以解讀。

過度簡化的檢視畫面如下:

  • 在 UTF-8 中,碼點可以使用一到四個位元組 (每位元組 8 位元)。
  • 使用 UTF-16 時,碼點一律為兩個位元組 (16 位元)。

重要的是,JavaScript 會以 UTF-16 的形式處理字串。這會破壞 btoa() 等函式,並假設字串中的每個字元都對應至單位元組,因而能有效運作。MDN 明確說明如下:

btoa() 方法會從二進位字串建立 Base64 編碼的 ASCII 字串 (即字串中的每個字元,都會被視為二進位資料的位元組)。

現在,您已瞭解 JavaScript 中的字元通常需要多個位元組,下一節將說明如何處理這個 Base64 編碼與解碼案例。

使用 Unicode 的 btoa() 和 atob()

如您所知,系統擲回錯誤的原因,是字串包含以 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. Uint8Array 提供給 bytesToBase64() 函式,讓該函式使用 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 具有稱為「代理值組」的概念。您可以這樣想:

  • 組合中的第一個數字會指定要搜尋的「book」。這就是所謂的「代理值」。
  • 配對中的第二個數字是「book」中的項目。

如您所想,有時只有代表書籍的數字數量,而不是該書的實際項目,可能會發生問題。在 UTF-16 中,這種做法稱為「孤獨代理值」

這在 JavaScript 中尤其困難,因為有些 API 儘管有孤立代理,有些則失敗。

在此情況下,使用的是 TextDecoder 來進行從 base64 解碼。具體來說,TextDecoder 的預設值可指定下列項目:

預設為 false,表示解碼器會將格式錯誤的資料替換成替換字元。

您之前觀察到的該替換字元,以十六進位表示的 \uFFFD。在 UTF-16 中,系統會將具有孤立代理值的字串視為「格式錯誤」或「格式不正確」。

市面上有各種不同的網路標準 (例如 1234),可明確指定格式字串會影響 API 行為的時機,特別是 TextDecoder 是其中一個 API。建議您在處理文字之前,先確保字串的格式正確。

檢查字串格式是否正確

最新版的瀏覽器現在有提供函式的功能:isWellFormed()

瀏覽器支援

  • 111
  • 111
  • 119
  • 16.4

資料來源

您可以使用 encodeURIComponent() 達到類似的結果,如果字串包含孤立代理值,就會擲回 URIError 錯誤

以下函式會使用 isWellFormed() (如果有的話) 和 encodeURIComponent() (不可用)。可用來為 isWellFormed() 建立 polyfill。

// 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) 和孤立代理 (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);
}

// 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 參數以進行擲回,而非無意取代孤立代理值等等。

憑藉這些知識和程式碼,您也可以明確決定如何處理格式錯誤的字串,例如拒絕資料或明確啟用資料替換,或擲回錯誤以供日後分析。

除了運用 Base64 編碼和解碼的實用範例外,這篇文章還舉例說明為何謹慎文字處理的重要性 (尤其是來自使用者產生或外部來源的文字資料)。