JsonMind
编码与文本8 分钟阅读更新于 2026年11月9日

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

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

JM作者:JsonMind Engineering最后复核: 2026年11月9日依据权威规范与引用来源复核。
快速答案

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。

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 能接受的二进制字符串:

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=");

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

文本 -> 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 可以直接完成转换:

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 字符串用代理对表示,这也是逐字符循环容易出错的原因:

"🚀".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. 如果字节已经被有损转换破坏,原文可能无法恢复。
// 错误:假设一个字符一个字节
const broken = atob(encoded);

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

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

单层误解码造成的乱码可以机械修复:把乱码字符串按 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 用 - 和 _ 替代,并经常去掉 = 填充。

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 解析工具 会自动处理这一点。

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 工具 可以用来测试加密流程,但生产密钥管理属于基础设施,不应该放在浏览器标签页里。

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

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

常见错误对照

错误现象修复
直接对 Unicode 调用 btoaInvalidCharacterError先编码 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 编码:

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

如果你的函数输出 5L2g5aW9,说明 UTF-8 链路正确。如果输出别的值,检查中间字节。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 字符、省略了填充,或者插入了换行。排查时应该比较解码后的字节,而不是直接比较编码后的字符串。

参考资料

把指南用起来

浏览全部工具

打开浏览器工具。输入内容保留在浏览器中,不会上传到业务服务器。

相关指南

全部指南