Base64 编解码:中文、emoji 都不出错的那种写法
两个方向都做:文字 → Base64,Base64 → 文字,输入即出结果。文字会先经过 UTF-8 字节再编码 ——
这一点才是关键,因为浏览器自带的 btoa 根本表示不了汉字,而对带重音的拉丁字母它会静默地编错。
解码这一侧收下真实世界里的各种写法:缺失的 = 填充、换行与空格、URL-safe 表;
而当一段合法的 Base64 解出来根本不是文字时,这一页会直接说清,而不是用替换字符糊成一串乱码给你。
文字 → Base64
————Base64 → 文字
—————这是合法的 Base64 —— 但这些字节不是 UTF-8 文本
———你在上面输入的内容不会发给我们:两个方向都在这个页面里跑。详见 隐私说明。
怎么用
- 先选方向。这里是两个框,不是一个"聪明"的框。没有工具能可靠地判断
Zm9v这种字符串 到底是"你想编码的文字"还是"你想解码的 Base64" —— 它恰好两者都成立,而两种读法给出的答案是两回事。 所以方向由你定,工具一个都不猜。 - 边打边看结果。每敲一个字符结果就跟着变,所以编码出来的样子是不是你预期的,立刻能看出来。 「往返自检」那一行会把结果真的再解一遍、逐字和你输入的比较 —— 那是核对,不是感觉。
- 先读黄色的提示,再读结果。"我替你做过了什么"都写在提示里:去掉了多少空白、补了几个
=、 识别到的是不是 URL-safe 表;以及唯一那个真正需要警惕的警告 —— 落单的代理项字符,编码器会静默替换它, 于是往返之后就再也回不到你输入的样子。 - 方向搞不清就用那两个「拿去…」按钮。手工把结果复制到另一个框恰恰是最容易出错的步骤: 多带一个换行,它就已经不是原来那段 Base64 了。
为什么不能直接用浏览器自带的 btoa
你在别处用过的 Base64 工具,底层几乎都是浏览器那两个函数 btoa 和 atob。
它们处理的是"当成字符写出来的字节":输入的每个字符必须落在 0–255 之间,一个字符对应一个字节。
这是 Latin-1 时代的文本假设,而 Web 早就不在这个假设之下了。由此产生两种失败,第二种才是真正危险的。
一种是直接抛异常
汉字「中」是 U+4E2D,一个字节装不下,于是 btoa('中') 抛 InvalidCharacterError,
什么都编不出来。麻烦,但至少它诚实。
另一种是"成功"地编出一串错字节
带重音的拉丁字母在 0xFF 以下,于是被当成一个字节收下。café 编成 Y2Fm6Q== ——
这串东西只有在对方也按 Latin-1 理解时才能还原成 café。而现在的 JSON 接口、数据库、
浏览器 fetch 默认都是 UTF-8,在 UTF-8 里 é 是 2 个字节,
café 的正确结果是 Y2Fmw6k=。两串值不一样,两串看着都很正常,
而它们本身都没说明自己用的是哪套约定。这正是这一页要避免的缺陷:不是崩溃,而是一个自信的错答案。
解法只有一步,这一页两个方向都这么做:编码前用 TextEncoder 把文字转成 UTF-8 字节,
对字节做 Base64;解码时用 TextDecoder 把字节读回文字。下面这张表就是这一步带来的长度变化,
也是最多人感到意外的地方。
| 文字 | 码点 | UTF-8 字节 | Base64 |
|---|---|---|---|
f | 1 | 1 | Zg== |
Hello | 5 | 5 | SGVsbG8= |
café | 4 | 5 | Y2Fmw6k= |
中文 | 2 | 6 | 5Lit5paH |
😀 | 1 | 4 | 8J+YgA== |
Hello 世界 😀 café | 15 | 23 | SGVsbG8g5LiW55WMIPCfmIAgY2Fmw6k= |
表里每一行都是这一页当场算出来的,你可以把任一行的原文粘进上面的框里逐字核对。 最后一行最值得记住:15 个字符变成 23 个字节,因为两个汉字各占 3 字节、emoji 占 4 字节。 Base64 编的是字节,所以结果一定比原文长;如果你在算 URL 或请求头的长度预算, 该用的是字节数而不是字符数 —— 这两个数字这一页都会列出来。
标准表与 URL-safe 表:同一串字节,两张字母表
Base64 需要 64 个字符,其中 62 个是白送的:A–Z、a–z、0–9。
最后两个必须另外挑,于是有了两种通行选择:标准表(RFC 4648 §4)用 + 和 /;
URL-safe 表改用 - 和 _,因为在表单编码里 + 代表空格,
而在 URL 路径和 query 里 + 与 / 都得转义。
三个在实际工作中会碰到的结论:
- 前 62 个字符完全相同,所以一段值里只要没有
+和/, 两张表编出来的结果就是同一个字符串。5Lit5paH既不"属于标准表"也不"属于 URL-safe 表" —— 它两边都是。 - 混用不是第三张表。同时含
+和-的字符串在两张表下都不合法; 谁要是默默替你挑一张,解出来的字节就和挑另一张的工具不一样。这一页直接拒绝,并说明原因。 - JWT 用的是 URL-safe 表。开发者最常见到
-和_的地方就是它, 而它同时也是"缺失=填充"的来源 —— 见下一节。
所以从 URL 或令牌里复制一段值粘进来时,这一页会告诉你它识别到的是哪张表; 如果这段值里两个特殊字符都没有,它也会如实说"判断不了,而且不需要判断",而不会假装检测到了什么。
填充的 =:为什么它经常不见
Base64 每次读入 3 个字节、写出 4 个字符。输入长度不是 3 的倍数时,会剩下 1 个或 2 个字节,
编码就用 2 个或 1 个 = 收尾 —— 它不是数据,而是一句"这一组是短的"的声明。
这句声明是多余的:从填充之前的字符数就能算出余数。于是有的系统保留 =
(配置文件的 PEM 块、多数命令行工具),有的直接省略(JWT、很多 query string 和接口)。
两种写法这一页都读得对;缺填充时它会补上并告诉你补了几个,这样你不会再怀疑"我的输入是少了一个字符,
还是本来就该没有"。
反过来那种情况更值得知道,因为它看起来很无辜:SGVsbG8== 并不是 SGVsbG8=
的另一种写法。7 个数据字符恰好只需要 1 个填充字符,出现 2 个就是自相矛盾 —— 通常说明这段值被人手工改过,
或者两段值被拼在了一起。这一页会把两个数字都报出来,而不是"解出能解的部分、把剩下的忽略掉"。
为什么我的 Base64 不一样?
这就是把人带到一个 Base64 页面上来的那个问题:同一个值在一处合法、在另一处被拒; 或者两个工具对"同一段内容"给出了两串不同的 Base64。几乎总是下面这几种原因之一, 而且每一种只要去看字节而不是看字符,都是看得见的 —— 这一页的十六进制视图就是为此准备的。
| 你看到的现象 | 实际发生了什么 | 在这里怎么核对 |
|---|---|---|
你的值末尾有 Cg 或 Cg==,另一个没有 |
其中一个是末尾多了一个换行。编辑器保存文件时都会在末尾加一个,所以"Hello"和"装着 Hello 的文件"是两份不同的输入:SGVsbG8= 是 Hello,SGVsbG8K 是 Hello 再加一个换行符。 |
两段都粘进来对比字节数与十六进制,多出来的那个字节是 0a |
| 一段很长的值每 76 个字符换一行,别的工具却报错 | 那是 MIME 的写法(邮件正文、PEM 文件都这样)。换行是传输格式,不属于数据本身。 | 原样粘进来即可:解码前会去掉换行,去掉的个数写在提示里 |
你的值末尾有 =,另一个没有(或者反过来) |
一个系统保留填充、另一个省略了。谁都没错,填充本来就能从长度推出来。 | 两种都能解,「填充」那一行会告诉你你手上是哪种 |
你的值里有 + 或 /,另一个是 - 或 _ |
一个是标准表、一个是 URL-safe 表。字节一样,只是那两个字符的写法不同。 | 两种都收,「识别到的字母表」会写出它认出的是哪张 |
| 同一段文字,长度却完全不同 | 两边编的是这段文字的不同编码。同一个「中」字,UTF-8 是 e4b8ad,
UTF-16LE 是 2d4e,而 GBK/GB18030 又是另一套字节。国内的老系统
(旧版 Windows 工具、Excel 导出的 CSV、部分 Java 程序的默认编码)踩这一条的概率很高。 |
两段都解开,对比十六进制:字母之间夹 00 的是 UTF-16 |
你的值以 H4sI 开头 |
它根本不是文字,而是 gzip 压缩后的数据(头两个字节是 1f 8b)。是某个环节先把内容压缩、再做的 Base64,而这串 Base64 忠实地复制了那段压缩流。 |
本页会明确报"不是 UTF-8 文本"并给出十六进制,1f 8b 在里面能直接看到 |
解出来本该是 =、+、/ 的位置上是 %3D、%2B、%2F |
这段值在做完 Base64 之后又被 URL 编码了一次,于是那三个在 URL 里有含义的字符被转义了。先解 Base64 是解不出来的,因为百分号本身就是你粘进来的内容的一部分。 | 先把百分号编码还原,再粘进来;报错信息会指出 % 的位置 |
| 你的值看着没问题,对方却说不合法 | 传输过程中有字符被吃掉了。最常见的三种:聊天软件把 + 当空格处理、表格软件重排了单元格内容、复制时少复制了最后一个字符(于是长度变成任何一种 Base64 都不可能有的长度)。 |
报错会指出坏字符的位置,或者报出"这个长度不可能是 Base64"并给出字符数 |
从网页表单或接口参数里拿到的值,+ 变成了空格 |
application/x-www-form-urlencoded 里 + 就是空格的转义形式,所以服务端收到时那个 + 已经没了。这是"标准表 Base64 放进 URL/表单"最经典的翻车方式。 |
把空格还原成 + 再解码;或者干脆改用 URL-safe 表 |
这里面有两条值得单独说死,因为它们制造的困惑最多。第一条是末尾的换行:从文件里粘出来的值通常带一个、 手打的不带,而末尾差一个字节,最后 4 个 Base64 字符就全变了。第二条是编码:同一段字符在 UTF-8、UTF-16 和各个历史代码页里编出来都不一样,这就是"同一个字符串"能产生两串各自都能正确还原出原文的 Base64 的原因 —— 两边说的都对,只是各自在自己那套约定里对。
合法的 Base64 也可能不是文本
Base64 是字节编码,不是文本编码。它装图片、装 PDF、装压缩流、装密文、装证书, 和装一句话一样顺理成章,而它没有任何办法告诉你自己装的是哪一种。所以解码器必须对一种情况给出明确答案: 输入是正确的 Base64,确实解出了字节,而这些字节不是合法的 UTF-8。
这时只有两种做法,其中一种比什么都不说更糟:
- 用替换字符糊过去。任何字节序列都能这样"解码" —— 非法字节变成
U+FFFD(那个黑色问号)。 结果是一串字符串,看起来像输出,其实毫无价值:用户把它复制走、粘到别处,损坏就从一处变成了两处。 - 直接说这些字节不是文本,并把它们真实的样子给出来。这一页就是这么做的:字节数、 第一个破坏规则的字节位置、坏在哪一类,以及十六进制视图。
其中真正有用的是"位置 + 类别",因为它指向原因。断在数据最末尾、而且断在一个多字节字符中间,
说明这段值被截断了 —— 这就是那个经典的"接口把响应截短了,最后那个汉字的字节没传完"。
断在最开头、字节值大于 0xf4,说明它从来就不是文本。字母与 00 交替出现,
说明是 UTF-16 —— 请注意那在 UTF-8 规则下是合法的(NUL 是合法码点),所以这一页会正常解出来,
而十六进制视图才是那个规律暴露出来的地方。另外,几种众所周知的开头字节可以直接告诉你格式:
1f 8b 是 gzip,89 50 4e 47 是 PNG,25 50 44 46 是 PDF。
也要说清这一页不做什么:它不替你猜编码。光凭字节去判断 UTF-16、Latin-1 还是某个历史代码页并不可靠, 而猜错的解码器会给出错误的文字,却一声不响 —— 那正是这一整页在防的那种失败。
哪些输入会被拒绝,为什么
下面每一条,一个"宽容"的解码器都会接受,因为总能找到让程序跑下去的字节。但没有一条能说明你原本要的是什么, 所以这一页宁可拒绝并说出问题,也不给你一个悄悄变了意思的值。
| 输入 | 为什么拒绝 | 可以怎么写 |
|---|---|---|
A,或任何去掉填充后余下 1 个字符的值 | 4 个 Base64 字符装 3 个字节,所以单独剩下的 1 个字符一个字节都装不了。这个长度不可能来自任何字节序列。 | 检查是不是复制断了 —— 截断的粘贴是最常见的原因 |
SGVs=bG8= | = 出现在中间。填充只对末尾有意义,中间的 = 说明这段值被拼接或改过,而"哪一段才是你要的"没有任何依据可判断。 | 拆成两段分别解 |
++-- | 标准表和 URL-safe 表混用,两张表下都不合法。默默挑一张,解出来的字节你就没法核对了。 | 先确定来源用的是哪张表,再换算那两个字符 |
SGVsbG8== | 7 个数据字符只对应 1 个 =,不是 2 个。填充和数据长度自相矛盾,说明其中一个被改过。 | 写成 SGVsbG8=,或者干脆去掉填充 |
== | 只有填充、没有数据,没有字符可解。 | 核对到底复制到了什么 |
Zm9v😀,以及任何含全角字符的值 | 不在字符表里。emoji 和全角字符看起来就像"哪里错了",通常是粘错了输入框。 | 报错会指出是哪个字符、在第几个位置 |
| 超过 200 万个字符 | 这个量级的单线程转换会把标签页卡住很久,久到浏览器主动问你要不要结束这个页面 —— 看起来就像工具崩了。 | 把内容切开分别转换 |
这个上限是有意选的,不是技术天花板:换算就是纯算术,只要允许它一直占着线程,浏览器处理几十 MB 也做得下来; 但一个卡住、什么都没有的标签页不是"慢结果",而是坏结果。200 万字符解码后大约是 1.5 MB —— 远远超过这一页真正要服务的请求头、令牌和配置值,同时又小到结果几乎立刻出来。 超过 30 万字符时,这一页会先画出状态行再开始算,所以即使是最慢的那种情况,页面上也总有动静。
有什么东西离开了这个页面
没有。这一页是一个静态文件,引用一个纯函数模块;换算、UTF-8 处理、合法性判定和十六进制视图全都在你打开的这个标签页里跑。 不向我们的服务器发请求、不给你的内容挂统计事件、不写 cookie、不做任何存储 —— 刷新页面,你粘的东西就没了, 因为它从来没有到过别的地方。
但这一类比其它工具更需要小心。Base64 正是 Authorization 头、大多数会话 cookie
和 API key 的写法:粘给一个会把它上传的工具,等于把凭据交出去。粘在这里它留在本地,
但剪贴板、浏览器对这个输入框的记忆、以及你在会议里共享的屏幕,都不在这一页能管的范围内。
把一段令牌解开来读它写了什么,是再正常不过的操作;把一个还在生效的 key 粘进任何地方,都不是 ——
不管那个工具承诺了什么。
常见问题
- Base64 是加密吗?能把密码藏起来吗?
- 不是加密,藏不住。Base64 只是"用 64 个可打印字符把一串字节写出来",任何拿到它的人一步就能还原 —— 包括这一页,不需要密钥,也不需要猜。它存在的原因是有些通道只能传文本:JSON 字符串、邮件正文、URL、HTTP 头。要保密就用加密,要校验有没有被改动就用哈希。一段看着像乱码的密码并不因为"看不懂"就安全,而粘进公开地方的令牌,在那里依然是有效的。
- 为什么中文编出来的 Base64 比原文长这么多?
- 因为 Base64 编的是「字节」而不是字符,而字节来自 UTF-8 编码。一个 ASCII 字母是 1 个字节,一个汉字在 UTF-8 里是 3 个字节,一个 emoji 是 4 个。Base64 每 3 个字节写成 4 个字符,所以一个汉字大约要占 4 个多字符,而一个英文字母只占 1 个多。这一页会把"码点"和"UTF-8 字节"两个数字都列出来,比例是算出来的,不用猜。
- 标准表和 URL-safe 表有什么区别?我该用哪个?
- 只差最后两个字符。标准表(RFC 4648 §4)用 + 和 /,但这两个字符在 URL 和表单里都有特殊含义;URL-safe 表改用 - 和 _,于是整串可以原样放进 URL 而不用转义。前 62 个字符完全相同,所以只要一段值里没有 + 和 /,两张表编出来的结果一模一样。JSON Web Token(JWT)是 URL-safe 表最有名的使用者 —— 前端的登录令牌基本都用它。
- 为什么我的 Base64 末尾没有 = ?
- 因为 = 不携带任何信息。Base64 每 4 个字符一组装 3 个字节,输入长度不是 3 的倍数时,用 1 个或 2 个 = 把这一组补齐。既然 = 的个数可以从长度算出来,那些在意长度的系统 —— JWT、query string、部分配置格式 —— 就直接省略它。两种写法这一页都收;如果它替你补了,会在提示里说明补了几个,这样你能看出自己的输入原本长什么样。
- 工具说「合法 Base64,但不是 UTF-8 文本」,是什么意思?
- 意思是:你的输入完全正确,它确实解出了一串字节;但这串字节不是 UTF-8 文本。常见原因有四种:它本来就是图片、PDF、gzip 或加密数据;文字是按 UTF-16 存的,于是每隔一个字节就是 00;数据在传输中被截断了,某个多字节字符没传完;或者它是 GBK/GB18030 这类中文老编码。这一页会给出字节数、第一个出问题的字节位置和十六进制视图 —— 因为用替换字符糊一个"成功",只会让你复制走一串没用的乱码。
- 我粘在这里的内容会被上传或保存吗?
- 不会。这一页只是一个静态文件,引用的模块全部在你的浏览器里跑:不向我们的服务器发请求、不给你的内容挂统计事件、不写 cookie、不做任何存储。刷新页面,你粘的东西就没了 —— 因为它从来没有到过别的地方。不过这一类比其它工具更需要小心:Base64 正是 Authorization 头、会话 cookie 和 API key 的写法,粘进来的往往是一个还在生效的凭据。这一页不会上传,但剪贴板、浏览器对这个输入框的记忆、以及你在会议里共享的屏幕,都不在这一页能管的范围内。
不含任何数据。这一页没有内置字符编码表、没有示例数据、也没有任何字典 —— 它显示的每一个字节都是用浏览器自带的 UTF-8 编解码器和 RFC 4648 的算术,从你输入的内容当场算出来的。