# Base64 中文乱码与 btoa 报错：原因和正确写法

> 解释 btoa 为什么处理不了中文、日文和 Emoji，演示 TextEncoder 与 TextDecoder 的正确 UTF-8 编解码流程，并排查乱码。

**快速答案:** Base64 编码的是字节，不是文本。浏览器的 btoa 只接受每个字符一个字节的字符串，遇到中文、日文、韩文或 Emoji 会抛出 InvalidCharacterError。正确做法是先用 TextEncoder 把文本转成 UTF-8 字节，再把字节转成二进制字符串后交给 btoa。

## 核心要点

- Base64 是字节到文本的编码，不定义字符集。
- btoa 失败是因为中文字符超出了单字节范围。
- TextEncoder 生成 UTF-8 字节，TextDecoder 把字节还原成文本。
- Base64 会让体积增加约 33%，而且不是加密。
- URL-safe Base64 用 - 和 _ 替代 + 和 /，并可能省略填充。

---

Base64 的问题，几乎都来自一个概念混淆：把 Base64 当成文本编码。Base64 只是用 64 个可打印字符表示任意字节，它不关心这些字节是 UTF-8、UTF-16、图片还是压缩包。

把“字节”和“文本”分开之后，报错和乱码都会变得可预测。

## 为什么 btoa 处理不了中文

浏览器的 `btoa` 接受的是“二进制字符串”：每个字符的码点必须在 0 到 255 之间。它把这些字符转成字节，再对字节做 Base64。

```js
btoa("hello");        // "aGVsbG8="
btoa("你好");          // InvalidCharacterError
btoa("こんにちは");     // InvalidCharacterError
btoa("🚀");           // InvalidCharacterError
```

中文、日文、韩文和 Emoji 的码点都大于 255，所以 `btoa` 直接拒绝。这不是 `btoa` 的 bug，而是输入超出了它的约定范围。

浏览器里的完整报错是 `Failed to execute 'btoa' on 'Window': The string to be encoded contains characters outside of the Latin1 range.`，Node 的全局 `btoa` 报 `InvalidCharacterError: Invalid character`。报错不会指出第几个字符越界，长字符串只能扫描第一个码点大于 255 的字符，或者二分定位。

更危险的是不报错的情况。`é` 的码点是 U+00E9，落在 `btoa` 接受的 0 到 255 范围内，`btoa("é")` 返回 `6Q==`，也就是单字节 `0xE9` 的 Base64。UTF-8 消费方期望的是两个字节 `0xC3 0xA9`，对应 `w6k=`。这里不会抛异常，问题要到服务端按 UTF-8 解码时才暴露。所以检查标准不是“btoa 有没有报错”，而是“输入字节是按哪种字符集生成的”。

## 正确的 UTF-8 流程

先把文本编码成 UTF-8 字节，再把字节转成 `btoa` 能接受的二进制字符串：

```js
function base64EncodeUtf8(text) {
  const bytes = new TextEncoder().encode(text);
  let binary = "";
  for (const byte of bytes) binary += String.fromCharCode(byte);
  return btoa(binary);
}

function base64DecodeUtf8(encoded) {
  const binary = atob(encoded);
  const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
  return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
}

base64EncodeUtf8("你好, 世界");
base64DecodeUtf8("5L2g5aW9LCDkuJbnlYw=");
```

这条链路每一步类型都很清楚：

```text
文本 -> UTF-8 字节 -> 二进制字符串 -> Base64 文本
Base64 文本 -> 二进制字符串 -> UTF-8 字节 -> 文本
```

跳过字节这一步，就会出现乱码。比如把 UTF-8 字节当成 Latin-1 解码，一个汉字会被拆成三个字符，显示成 `ä½ ` 之类的结果。

`TextDecoder` 的 `fatal: true` 值得在信任边界上使用：遇到非法 UTF-8 会抛 `TypeError: The encoded data was not valid for encoding utf-8`，而不是替换成 U+FFFD。代价是一个坏字节会让整个解码失败；不开 `fatal` 则永远“成功”，但悄悄丢信息。展示用途可以容忍替换字符，凭据和签名材料不能。

`TextEncoder` 只产出 UTF-8，没有字符集参数；孤立代理项会被替换成 U+FFFD 而不是抛错，所以 `"\uD800"` 在进入 Base64 之前就已经被改掉。需要逐字节保留原始码元时，先校验再编码。

逐字节拼二进制字符串适合表单字段和请求头，不适合大文件。10 MB 文件会变成约 13.3 MB 的 Base64，中间还会同时存在原始字节、二进制字符串和编码结果。大数据应该按 3 字节的倍数分块编码：3 字节正好对应 4 个 Base64 字符，分块结果可以直接拼接。较新的运行时提供 `Uint8Array.prototype.toBase64` 和 `Uint8Array.fromBase64`，直接处理字节，使用前要先探测运行时是否支持。

## Node.js 写法

Node.js 的 `Buffer` 可以直接完成转换：

```js
const encoded = Buffer.from("你好", "utf8").toString("base64");
const decoded = Buffer.from(encoded, "base64").toString("utf8");
```

本质是同一条链路，只是 API 更短。注意 `Buffer.from(text, "base64")` 的第二个参数描述的是输入格式；如果输入是普通文本，不要传 `"base64"`。

Node 还支持 `buf.toString("base64url")`，直接输出无填充的 `-`/`_` 字母表，不需要手工替换。

Node 的 Base64 解码是宽松的：字母表之外的字符会被忽略，遇到结束填充的 `=` 就停止。宽松带来方便，也意味着损坏的输入经常能解出“一些字节”而不报错。`buf.toString("utf8")` 同样宽松，非法序列会被替换成 U+FFFD，因此它无法证明字节有效。验证办法是把解码结果重新编码，和标准化后的输入比较，或者比对生产端给出的 SHA-256。

## Emoji 和代理对

Emoji 往往位于基本多文种平面之外，JavaScript 字符串用代理对表示，这也是逐字符循环容易出错的原因：

```js
"🚀".length;        // 2 个 UTF-16 码元
[... "🚀"].length;  // 1 个码点
```

`TextEncoder` 能正确处理码点。如果自己写编码器，应该用 `for...of` 遍历码点，或者使用经过验证的库。不要假设一个 JavaScript 字符就是一个字节。

家庭 Emoji 更能说明问题：`"👨‍👩‍👧‍👦"` 是 7 个码点（4 个 Emoji 加 3 个零宽连接符），在 UTF-16 里占 11 个码元，`[...value].length` 返回 7。Base64 看不到字符和字形簇，`TextEncoder` 只负责输出 UTF-8 字节。这也是别再手写逐字符转换的理由。如果非写不可，用 `for...of` 遍历码点再交给 `TextEncoder`，不要把 UTF-16 码元数当成字节数。

## 乱码：解码端的问题

假设一段 UTF-8 文本被当成 Latin-1 解码，`你` 的三个字节会变成三个独立字符。再按 UTF-8 显示时，就成了乱码。

修复步骤：

1. 确认原始编码，通常是 UTF-8。
2. 把 Base64 解码成字节。
3. 用正确的字符集解码字节。
4. 如果字节已经被有损转换破坏，原文可能无法恢复。

```js
// 错误：假设一个字符一个字节
const broken = atob(encoded);

// 正确：按 UTF-8 解码字节
const fixed = base64DecodeUtf8(encoded);
```

一次性排查可以把内容粘贴到 [Base64 编码解码工具](/tools/base64)，再和 [UTF-8 编码工具](/tools/utf8) 的字节输出对比。如果字节一致但文本不同，问题在解码端。

单层误解码造成的乱码可以机械修复：把乱码字符串按 Latin-1 重新解释成字节，再按 UTF-8 解码，也就是 `Buffer.from(mojibake, "latin1").toString("utf8")`。`ä½ ` 能还原成 `你`，前提是中间没有任何字节被替换成 `?`。一旦出现问号、U+FFFD，或者某个字符集转换丢过字节，信息已经丢失，后面任何步骤都救不回来。

还要数清楚套了几层：UTF-8 被当成 Latin-1 解码一次得到 `ä½ `，两次得到 `Ã¤Â½Â`。层数越多，中间工具做过的规范化越多，可恢复性越差。与其堆叠修复函数，不如找到声明错字符集的生产端，否则下一批数据还会坏。

同样的字节按 GBK、Shift_JIS 或 Windows-1252 解码，乱码长得完全不同。凭外观猜源字符集并不可靠，先查 `Content-Type` 的 charset、数据库连接字符集和文件声明编码。

## URL-safe Base64

标准 Base64 使用 `+` 和 `/`，它们在 URL 中有特殊含义。URL-safe Base64 用 `-` 和 `_` 替代，并经常去掉 `=` 填充。

```js
function toUrlSafe(base64) {
  return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

function fromUrlSafe(value) {
  const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
  return base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
}
```

JWT 的 header 和 payload 使用无填充的 URL-safe Base64，所以严格的标准 Base64 解码器可能会拒绝 JWT 片段。[JWT 解析工具](/tools/jwt-decoder) 会自动处理这一点。

URL-safe 不代表可以随便放进查询串。表单解析会把 `+` 变成空格，而 URL-safe Base64 本身没有 `+`；真正的风险在填充 `=`，有些路由或链接识别器会把它去掉。稳妥做法是把整段当成不透明字符串：原样读取，不要经 `URLSearchParams` 往返，比较时看字节。

长度也有一个不可能状态：Base64 字符串长度除以 4 余 1 时，不存在能生成它的字节序列。`padEnd` 会补三个 `=`，但严格的解码器仍然应该拒绝。生成 ID 时先确认编码结果不是 `4n+1` 长度，再去掉填充。

JWT 是最常见的 URL-safe Base64 场景，但不是唯一：JWK 的 `n`、`e`、`d` 字段和 OAuth PKCE 的 `code_challenge` 都用同一套字母表。为其中一个写的辅助函数，只要容忍缺失填充，通常也能处理另外两个。

## Base64 不是加密

Base64 任何人都能解码，既不提供机密性，也不提供完整性。需要保护数据时：

- 使用 AES-GCM 做认证加密。
- 使用 HTTPS 保护传输。
- 把密钥放进密钥管理系统，而不是编码后的字符串。

[AES-GCM 工具](/tools/aes-gcm) 可以用来测试加密流程，但生产密钥管理属于基础设施，不应该放在浏览器标签页里。

Base64 的体积成本是实打实的：输出是输入字节的 4/3，生产端插入换行还会更大。10 MB 上传会变成 13.3 MB 传输量，转换期间原始缓冲区和编码缓冲区同时存在。大文件应该走 multipart 或对象存储预签名 URL；短 JSON 字段则无所谓。

Base64 还会泄露。放进 URL 的值会进入浏览器历史、代理日志和 `Referer`，编码并没有让它变得不可读。把 Token 或邮箱放进查询参数，是一个隐私决策，不是编码决策。

## 常见错误对照

| 错误 | 现象 | 修复 |
| --- | --- | --- |
| 直接对 Unicode 调用 btoa | InvalidCharacterError | 先编码 UTF-8 字节 |
| 对 Latin-1 文本调用 btoa（如 `é`） | 不报错，但字节不是 UTF-8（`6Q==` 而非 `w6k=`） | 用 TextEncoder 按 UTF-8 编码 |
| 把 atob 结果直接当文本 | 非 ASCII 内容变成乱码 | 当成字节交给 TextDecoder 解码 |
| 给 `4n+1` 长度补填充 | 解码器仍然拒绝 | 补填充前先校验长度 |
| 在查询串里直接传 Base64 | `+` 被解析成空格或填充丢失 | 原样读取并做 URL 编码 |
| 按 Latin-1 解码 | 出现 `ä½ ` 等乱码 | 按 UTF-8 解码字节 |
| 混用标准和 URL-safe 字母表 | 解码失败或字节错误 | 把 `-`/`_` 还原成 `+`/`/` |
| 忽略缺失的填充 | 解码器拒绝输入 | 补 `=` 到 4 的倍数 |
| 把 Base64 当加密 | 虚假的安全感 | 使用真正的加密 |
| 直接比较编码字符串 | 结果不同但内容等价 | 比较解码后的字节 |

## 用已知向量验证

一个有用的测试向量是 `你好` 的 UTF-8 编码：

```text
文本:   你好
UTF-8:  E4 BD A0 E5 A5 BD
Base64: 5L2g5aW9
```

如果你的函数输出 `5L2g5aW9`，说明 UTF-8 链路正确。如果输出别的值，检查中间字节。[Hex 编码工具](/tools/hex) 可以直接显示字节，方便定位差异。

只要把层次分清——文本到字节、字节到 Base64、再反向还原——Base64 就很简单。大多数“Base64 问题”其实是文本编码问题换了个名字。

五个往返用例基本能覆盖所有回归：`hello` 得到 `aGVsbG8=`；`é` 得到 `w6k=`（出现 `6Q==` 说明漏了 UTF-8 步骤）；`你好` 得到 `5L2g5aW9`；`🚀` 得到 `8J+agA==`（结果不同通常是代理对被单独编码了）；包含 `+`、`/` 的字节序列经过 `toUrlSafe`、`fromUrlSafe` 后解码结果不变。任何一条失败，都比较中间字节而不是 Base64 字符串，前文的 Hex 编码工具能直接显示分歧发生在哪一步。

该用 Base64 的场景是“文本通道必须携带字节”：JSON 字段、邮件附件、小体积 data URL。不该用它做压缩、保密或大文件传输。它只是字节和文本之间的适配层。

## 常见问题

### 为什么 btoa 处理中文会抛出 InvalidCharacterError？

btoa 会把每个 JavaScript 字符码当作一个字节，并拒绝大于 255 的值。中文字符的码点远大于 255，所以直接报错。需要先把文本编码为 UTF-8 字节，再对字节做 Base64。

### atob 是 btoa 的逆操作吗？

atob 返回的是“二进制字符串”，每个字符代表一个字节，不是原始 Unicode 文本。要还原中文，需要把二进制字符串转回字节，再用 TextDecoder 按 UTF-8 解码。

### 什么是 URL-safe Base64？

URL-safe Base64 用 - 和 _ 替代标准 Base64 中的 + 和 /，避免在 URL 和文件名中需要转义。它通常还会去掉末尾的 = 填充，解码时要容忍缺失的填充。

### Base64 能加密数据吗？

不能。Base64 是可逆编码，任何人都能解码。需要保密时应使用 AES-GCM 等认证加密方案，而不是 Base64。

### 为什么不同工具算出的 Base64 不一样？

可能是输入字节不同、使用了 URL-safe 字符、省略了填充，或者插入了换行。排查时应该比较解码后的字节，而不是直接比较编码后的字符串。

## 参考资料

- [RFC 4648 - Base16、Base32 和 Base64 数据编码](https://www.rfc-editor.org/rfc/rfc4648)
- [MDN - btoa()](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/btoa)
- [MDN - TextEncoder](https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder)
- [MDN - TextDecoder](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder)

---

- Canonical: https://www.jsonmind.com/zh-CN/guides/fix-base64-unicode-error
- Locale: zh-CN
- Published: 2026-11-09
- Updated: 2026-11-09
- Tools: base64, utf8, unicode, hex