[UNICODE] Base64 處理 UTF-8 與 Unicode
btoa('héllo') 會丟例外。btoa('日本語') 也會丟。這不是 bug——這是 1990 年代的 API 撞上 2020 年代的文字。以下逐位元組說明如何正確對 Unicode 做 Base64 編碼。
// 陷阱
瀏覽器裡的 btoa() 看起來很無辜。你給它一個字串,它回你一個 Base64。直到某天使用者送出自己的名字——Renée——你的程式碼就崩潰在 InvalidCharacterError: The string to be encoded contains characters outside of the Latin1 range。
這不是古怪行為,也不能單獨怪 JavaScript。btoa() 誕生於「字串」與「位元組」在 U+00FF 以下被視為同一回事的年代。Unicode 席捲了全世界,但 btoa() 的合約還凍結在 Latin-1。要對西歐字元以外的任何東西做 Base64,你必須先繞道 UTF-8。
// 30 秒修正法
// Browser — encode any Unicode string to Base64
function encodeUtf8Base64(str) {
const bytes = new TextEncoder().encode(str); // UTF-8 bytes
let bin = '';
for (const b of bytes) bin += String.fromCharCode(b); // Latin-1 wrapper
return btoa(bin); // now btoa can handle it
}
// Browser — decode Base64 back to a Unicode string
function decodeUtf8Base64(b64) {
const bin = atob(b64);
const bytes = Uint8Array.from(bin, c => c.charCodeAt(0));
return new TextDecoder().decode(bytes); // UTF-8 → string
}
// Usage
encodeUtf8Base64('Renée'); // 'UmVuw6ll'
encodeUtf8Base64('日本語'); // '5pel5pys6Kqe'
encodeUtf8Base64('👋 hello'); // '8J+RiyBoZWxsbw=='
decodeUtf8Base64('5pel5pys6Kqe'); // '日本語'
// 為什麼這樣行得通 —— UTF-8 的簡短繞道
JavaScript 字串是 UTF-16 程式碼單元的序列。字元 日 是 U+65E5,只佔一個 16 位元的程式碼單元。emoji 👋(U+1F44B)超出了基本多文種平面(BMP),以一對代理對(surrogate pair)的兩個 16 位元程式碼單元來表示。
btoa() 要的是位元組,不是程式碼單元。而且不是任何位元組——它要的是範圍 0–255 的 Latin-1 位元組。UTF-16 程式碼單元經常超過 255(例如 日 = 0x65E5 = 26085),所以 btoa() 就丟例外。
UTF-8 是一種 8-bit clean、能編碼整個 Unicode 字元集的編碼。每個 Unicode 字元變成 1–4 個位元組,每個位元組保證落在 0–255 範圍內。如果我們先把字串轉成 UTF-8 位元組,再騙 btoa() 把這些位元組當成 Latin-1 字串(一字元對一位元組),一切就對上了:btoa() 看到它能處理的位元組,接收端再把同一批位元組解回 UTF-8。
// TEXTENCODER 底層在做什麼
TextEncoder 是把 JavaScript 字串轉成 UTF-8 位元組的標準做法。它從約 2017 年起就在每個瀏覽器中,並從 Node.js 11+ 開始成為全域物件。
快速看一眼它產生的位元組序列:
new TextEncoder().encode('A');
// Uint8Array [ 0x41 ] (1 byte)
new TextEncoder().encode('é');
// Uint8Array [ 0xC3, 0xA9 ] (2 bytes)
new TextEncoder().encode('日');
// Uint8Array [ 0xE6, 0x97, 0xA5 ] (3 bytes)
new TextEncoder().encode('👋');
// Uint8Array [ 0xF0, 0x9F, 0x91, 0x8B ] (4 bytes)
new TextEncoder().encode('Renée');
// Uint8Array [ 0x52, 0x65, 0x6E, 0xC3, 0xA9, 0x65 ] (6 bytes for 5 chars)
// NODE.JS:BUFFER 預設就理解 UTF-8
Node 的 Buffer 類別在內部完成 UTF-8 轉換。你永遠不需要 TextEncoder;把字串傳給 Buffer.from() 時,除非你明確指定其他編碼,否則會以 UTF-8 編碼。這就是為什麼 Node 伺服器端的 Base64 一行就能搞定:
// Encode
Buffer.from('日本語').toString('base64');
// '5pel5pys6Kqe'
// Decode
Buffer.from('5pel5pys6Kqe', 'base64').toString();
// '日本語'
// Explicit UTF-8 (the default, but clearer)
Buffer.from('Renée', 'utf8').toString('base64');
// 'UmVuw6ll'
// 各語言中的 UNICODE BASE64
// Python
import base64
base64.b64encode('日本語'.encode('utf-8'))
# b'5pel5pys6Kqe'
base64.b64decode('5pel5pys6Kqe').decode('utf-8')
# '日本語'
// PHP
base64_encode('日本語'); // '5pel5pys6Kqe'
base64_decode('5pel5pys6Kqe'); // '日本語'
// (PHP strings are byte strings, so UTF-8 is handled implicitly
// if your source file is saved as UTF-8.)
// Java
import java.util.Base64;
import java.nio.charset.StandardCharsets;
Base64.getEncoder().encodeToString("日本語".getBytes(StandardCharsets.UTF_8));
// '5pel5pys6Kqe'
new String(Base64.getDecoder().decode("5pel5pys6Kqe"), StandardCharsets.UTF_8);
// '日本語'
// Go
import "encoding/base64"
base64.StdEncoding.EncodeToString([]byte("日本語"))
// '5pel5pys6Kqe'
b, _ := base64.StdEncoding.DecodeString("5pel5pys6Kqe")
string(b)
// '日本語'
// Ruby (source file must be UTF-8)
require 'base64'
Base64.strict_encode64('日本語') # '5pel5pys6Kqe'
Base64.decode64('5pel5pys6Kqe') # '日本語'
// Rust
use base64::{Engine, engine::general_purpose::STANDARD};
STANDARD.encode("日本語"); // "5pel5pys6Kqe"
STANDARD.decode("5pel5pys6Kqe")
.map(|b| String::from_utf8(b).unwrap())?; // "日本語"
// Shell (bash/zsh with GNU coreutils)
echo -n '日本語' | base64 # '5pel5pys6Kqe'
echo -n '5pel5pys6Kqe' | base64 -d # '日本語'
// UNICODE 常見失誤
-
>
解碼後亂碼 —— 你直接用
atob()解碼,接收端看到亂碼。修正:用TextDecoder包起來。 -
>
代理對錯誤 —— 你的程式碼按字元數切割字串,切到 👋 代理對的中間。修正:對整個字串使用
TextEncoder,而不是逐字元處理。 -
>
BOM 問題 —— 你的來源文字以 UTF-8 BOM(
EF BB BF)開頭。它會跟其他內容一起被編碼,接收端就看到一個多出來的\uFEFF。編碼前先去除 BOM。 -
>
錯誤的字集中繼資料 —— 你把 UTF-8 位元組的 Base64 放進一個標示為
charset=iso-8859-1的欄位。解碼器會依標示來解讀位元組。中繼資料一定要與實際編碼相符。 -
>
URL 安全 Base64 混淆 —— 你用 base64url 儲存,卻忘了在解碼前還原成標準字母表。修正:以
replace(/-/g, '+').replace(/_/g, '/')正規化,並補齊到 4 的倍數。 -
>
依地區設定而異的編碼 —— 在 Python 2 中,
'日本語'.encode()預設為 ASCII 會當掉。在任何現代執行環境中,永遠明確指定utf-8。
// 往返測試 —— 最決定性的證明
驗證 Base64 流程能否正確處理 Unicode 的最佳方式就是往返測試。把一個已知正確的字串編碼,再解碼,然後逐位元組比對結果是否與原始相同。
// Vitest / Jest / any test runner
const samples = [
'hello',
'héllo',
'日本語',
'한국어',
'👋 Renée — 日本語 — مرحبا',
'\n\t\0 edge cases',
'𐀀 ancient greek (U+10000)',
];
for (const s of samples) {
const encoded = encodeUtf8Base64(s);
const decoded = decodeUtf8Base64(encoded);
expect(decoded).toBe(s);
expect([...new TextEncoder().encode(s)])
.toEqual([...new TextEncoder().encode(decoded)]);
}
// 二進位的 BASE64 對文字的 BASE64
一個細微但重要的區別。當你對一個 PNG 檔做 Base64,輸入已經是位元組——沒有 Unicode 問題需要回答。把那些位元組直接丟給編碼器就好。
當你對字串做 Base64,輸入是 Unicode 程式碼點的序列,而不是位元組。你必須先決定一種位元組編碼(在 2026 年實際上永遠是 UTF-8),編碼器才看得到任何東西。你產生的 Base64 字串只有在接收端反轉相同的位元組編碼時才有效。
這是大多數「為什麼我的 Base64 是錯的」工單的根源:兩個系統用不同的底層位元組編碼(一個 UTF-8、一個 UTF-16,或一個 Latin-1)對同樣的邏輯字串做 Base64,而接收端的解碼器會很開心地重建出錯誤的位元組。
// 應該永遠都用 UTF-8 嗎?
是的,除非你在一個明確指定其他編碼的傳統系統中工作。UTF-8 是:
• 網路上的主流編碼(2026 年超過 97% 的網站)
• 每個現代程式語言標準函式庫的預設值
• 前 128 個程式碼點與 ASCII 相容,因此永遠不會破壞英文
• 對每個 Unicode 字元都有明確定義且可往返
• IETF 推薦用於新協定的字符集(RFC 2277)
老的替代品(UTF-16、UTF-32、Latin-1、GBK、Shift-JIS)仍會出現在傳統資料庫、Windows API,以及部分電信協定中。如果你的資料來自那些系統,請在邊界處轉成 UTF-8,對 UTF-8 做 Base64,並在酬載格式中紀錄此編碼決策。
// 不依賴 TEXTENCODER 的後備方案(舊瀏覽器)
如果你必須支援沒有 TextEncoder 的非常舊瀏覽器(IE11 及更舊),經典的技巧是把 encodeURIComponent 當成 UTF-8 編碼器,再用 unescape 轉回位元組字串。這個模式從 2008 到 2020 在每個 StackOverflow 關於 Base64 與 Unicode 的回答裡都會看到。
// Legacy browser fallback (pre-2017)
function encodeUtf8Base64Legacy(str) {
return btoa(unescape(encodeURIComponent(str)));
}
function decodeUtf8Base64Legacy(b64) {
return decodeURIComponent(escape(atob(b64)));
}
encodeUtf8Base64Legacy('日本語'); // '5pel5pys6Kqe'
decodeUtf8Base64Legacy('5pel5pys6Kqe'); // '日本語'
// Note: unescape() and escape() are deprecated.
// Only use this pattern if TextEncoder is genuinely unavailable.
// It also mishandles some edge-case surrogates.
// 要記住的事
- > Base64 編碼的是位元組,不是字元。 先選定一種位元組編碼——幾乎永遠是 UTF-8。
-
>
在瀏覽器中:
TextEncoder→String.fromCharCode→btoa→ 編碼方向。 -
>
在瀏覽器中:
atob→Uint8Array.from→TextDecoder→ 解碼方向。 -
>
在 Node.js 中:
Buffer.from(str).toString('base64')與Buffer.from(b64, 'base64').toString()—— 兩者預設皆為 UTF-8。 -
>
在 Python / Go / Java 中:永遠明確傳遞
'utf-8'給.encode()/.getBytes()。 - > 上線前永遠用真實的 Unicode(emoji、CJK、阿拉伯文、組合符號)做往返測試。
// 相關
• 什麼是 Base64?它是如何運作的? —— 支撐這整篇內容的 6-bit 分組。
• JavaScript(瀏覽器 + Node)中的 Base64 —— 完整的跨執行環境輔助函式。
• URL 安全版與標準版 Base64 —— 你可能也需要做的另一個正交選擇。
• 試用即時 Base64 編碼器 —— 同樣的 Unicode 安全流程,在你瀏覽器中直接執行。