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}]`);
下列步驟說明此程式碼會對字串進行編碼:
- 使用
TextEncoder
介面擷取 UTF-16 編碼的 JavaScript 字串,並使用TextEncoder.encode()
將其轉換為採用 UTF-8 編碼的位元組串流。 - 這會傳回
Uint8Array
,這是 JavaScript 中較不常用的資料類型,且是TypedArray
的子類別。 - 把
Uint8Array
提供給bytesToBase64()
函式,讓該函式使用String.fromCodePoint()
將Uint8Array
中的每個位元組視為程式碼點,並建立其字串,進而產生可以全部以單一位元組表示的碼點字串。 - 使用該字串,並使用
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 中,系統會將具有孤立代理值的字串視為「格式錯誤」或「格式不正確」。
市面上有各種不同的網路標準 (例如 1、2、3、4),可明確指定格式字串會影響 API 行為的時機,特別是 TextDecoder
是其中一個 API。建議您在處理文字之前,先確保字串的格式正確。
檢查字串格式是否正確
最新版的瀏覽器現在有提供函式的功能:isWellFormed()
。
您可以使用 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 編碼和解碼的實用範例外,這篇文章還舉例說明為何謹慎文字處理的重要性 (尤其是來自使用者產生或外部來源的文字資料)。