[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 code unit 的序列。字符 日 是 U+65E5,只占一个 16 位 code unit。而 emoji 👋(U+1F44B)超出了 Basic Multilingual Plane,表示形式是两个 16 位 code unit 组成的代理对。
btoa() 要的是字节,而不是 code unit。而且不是随便的字节——它要的是 0–255 范围内的 Latin-1 字节。UTF-16 的 code unit 轻易就会超过 255(比如 日 = 0x65E5 = 26085),于是 btoa() 抛错。
UTF-8 是一种对整个 Unicode 字符集的 8-bit 干净编码。每个 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 翻车
-
>
解码乱码(mojibake)——你直接用
atob()解码,接收方看到的是乱码。修复:用TextDecoder包一层。 -
>
代理对错误——你的代码按字符数切字符串,结果切到了 👋 代理对的中间。修复:对整段字符串使用
TextEncoder,不要逐字符处理。 -
>
BOM 问题——你的源文本以 UTF-8 BOM(
EF BB BF)开头。它会连同其他内容一起被编码,接收方会看到一个多余的\uFEFF。编码前请先剥掉 BOM。 -
>
字符集元信息不匹配——你把 UTF-8 字节的 Base64 丢进一个标注了
charset=iso-8859-1的字段。解码器会按元信息来解释字节。一定要让元信息与实际编码保持一致。 -
>
URL-safe Base64 混淆——你用 base64url 存储,却在解码前忘了还原成标准字母表。修复:用
replace(/-/g, '+').replace(/_/g, '/')归一化,并补齐到 4 的倍数。 -
>
依赖 locale 的编码——在 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 vs 对文本做 BASE64
这里有一个微妙但关键的区分。当你对一个 PNG 文件做 Base64 时,输入本来就是字节——没有 Unicode 的问题要回答。把这些字节直接喂给编码器就好。
当你对一个字符串做 Base64 时,输入是一串 Unicode 码位,而不是字节。你必须先决定使用哪种字节编码(2026 年基本永远是 UTF-8),编码器才能看到东西。产出的 Base64 字符串只有在接收方用同样的字节编码反解时才是正确的。
这正是"为什么我的 Base64 不对"这类工单的最大来源:两个系统对同一个逻辑字符串做 Base64,却用了不同的底层字节编码(一个 UTF-8、一个 UTF-16,或者一个 Latin-1),而接收方的解码器老老实实地把错误字节还原了出来。
// 是否应该永远用 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 年间每一条关于 Base64 与 Unicode 的 StackOverflow 回答几乎都用的是这套模式。
// 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 里:调用
.encode()/.getBytes()时务必显式传'utf-8'。 - > 上线前一定要用真实的 Unicode(emoji、CJK、阿拉伯文、组合记号)跑一轮往返测试。
// 相关
• Base64 是什么?它是如何工作的?——本文背后的 6-bit 分组机制。
• JavaScript(浏览器 + Node)中的 Base64——完整的跨运行时辅助函数。
• URL-safe 与标准 Base64——一项彼此正交、你也许还得做的选择。
• 在线试一下 Base64 编码器——同一条 Unicode 安全流水线,在你的浏览器里。