编码不是加密:Base64、URL 转义和 Unicode 转义各自遵守什么规则
「编码」分类下有三个页面:Base64 编解码、URL 编解码、Unicode 转换。它们做的都是同一件事——把文本换一种写法,而且都不设密钥、都能一键还原,所以谁都不是「藏起来」的手段。三个页面各自的规则不一样,而恰恰是这些规则决定你复制出去的东西对不对。
先把三件事分开
- Base64:把字节改成 64 个安全字符,让二进制能走纯文本通道。结果比原文大约长三分之一。
- 百分号转义:把在 URL 里有结构作用的字符改成
%HH,让它失去那个作用。只有当前位置不安全的字符才需要转。 \uXXXX转义:把字符写成它的编号,好让一个装不下非 ASCII 字节的文件仍然能表达这些字符。
三种都能还原。如果你的目的是「不希望第三方读懂」,这三页都不合适,那是AES 加密页的活。
Base64:它编码的是字节,而这页先固定按 UTF-8
页面的做法是先把文本编成 UTF-8 字节,再对字节做 Base64。中文 🐱 是 11 个 UTF-8 字节,结果是 5Lit5paHIPCfkLE=——这一点我拿 Python 的 base64 模块对过,不是看形状猜的。
两个必须内化的推论:
- 结果取决于字节形态,不取决于字符形态。 同样几个字,经过不同的 Unicode 规范化写法,或者结尾多一个你看不见的换行,输出就不同。在文本末尾按一下回车,结果就变了:
中文得到5Lit5paH,中文加一个换行得到5Lit5paHCg==。 - 纯 ASCII 也不例外。
Hello得到SGVsbG8=。Base64 不要求输入是二进制。
解码端容忍什么,以及它在哪件事上会骗你
解码比多数人以为的宽容,这对「粘贴」是好消息:
- 结尾的
=可以省。5Lit5paHIPCfkLE不带填充照样能解出来。 - 中间的空白会被忽略。 一行 76 字符折行的 MIME 风格 Base64 粘进来照样能解。
- 首尾空白在解码前被裁掉,但编码方向不裁——所以源文本末尾那个换行是会被编进去的。
坏消息在报错文案上。页面是「先解 Base64,再把字节按 UTF-8 读」,于是一段合法的 Base64、但字节不是合法 UTF-8 的输入,会得到「输入的字符串不是有效的 Base64 编码」。把 /w==(单个 0xFF 字节,标准得不能再标准的 Base64)喂进去,得到的就是这句话。也就是说,报错没有告诉你是两步里的哪一步失败了:如果你确定这段文本出自某个 Base64 编码器,那真正的问题是它的字节本来就不是文字——图片、压缩包这类东西必须当文件处理,贴回文本框里再解码是走不通的。
还有两处界面行为,看结果之前值得先知道:
- 失败的那一次不会清空输出框。 报错出现了,但旁边那串字符还是上一次成功时的结果,页面没有任何「结果已失效」的提示。复制前先看一眼报错区。
- 输出框就是普通可编辑的文本域。 你能直接在里面打字,改了也不会触发重新计算。
拖文件进去,答案落在输入框
上传区写着「上传编码为 Base64」,实际流程是:把文件读成 data URL,切掉逗号及其之前的部分,把剩下的写进输入框,同时清空输出框。所以拖完文件之后,Base64 待在你平时打字的那个框里,而不是结果框里——要复制就得从输入框复制。
这个设计带来三个副作用:
- FAQ 里提到的
data:image/png;base64,前缀,正是被切掉的那部分。要拼 data URI,得自己把data:<你的 MIME 类型>;base64,加回去。 - 一次拖多个文件时只取第一个,且不作任何提示。
- 体积标签恒定按 KB 打印(
size / 1024保留一位小数),大文件会显示成2097152.0 KB这种样子。此外整个文件会在内存里存两份——一份字节、一份 textarea 里的 Base64 字符串——几百兆的文件在任何浏览器标签页里都不好受,这里也不例外。
URL 转义:保留哪几个字符,以及加号那个坑
这一页直接调 encodeURIComponent / decodeURIComponent。输入 a b&c=d#e?f/g%h+i!~*_'()中🐱,输出是:
a%20b%26c%3Dd%23e%3Ff%2Fg%25h%2Bi!~*_'()%E4%B8%AD%F0%9F%90%B1
这行输出就是这个工具的边界说明书:
- 原样保留:大小写字母、数字,以及
- _ . ! ~ * ' ( )。其余全部转义。所以&、=、?、#、/、+、%都会被转义——这正是「参数值」该有的行为。 - 空格转成
%20,不是+。 - 非 ASCII 走 UTF-8 字节,
中是三段转义(%E4%B8%AD),emoji 是四段(%F0%9F%90%B1)。
方向不对称的地方最容易出事
decodeURIComponent 不会把 + 当空格。给它 a+b%20c,回来的是 a+b c——百分号那段变回了空格,加号还是加号。这事之所以要盯,是因为 HTML 表单和多数服务端的查询串解析用的恰恰是 + 表示空格(application/x-www-form-urlencoded 那套约定)。所以从浏览器地址栏或服务器日志里复制出来的查询串,在这一页解码会把本该是空格的位置留成字面加号。要么先手工把 + 换成 %20,要么用真正的表单解码器。
反过来编码时也有对称的差异:这页把空格转成 %20,在查询串里合法,但浏览器提交表单字段时不是这么发的。
这页会安静失败的两种情况
- 输入为空时,它清空输出并且什么都不说。 没有提示、没有报错态,空进空出。(Base64 页遇到空输入会打印一句提示,所以两页在同一个动作上表现不同。)
- 报错文本直接写进输出框,不是放在独立的报错区。于是错误文案可能正占着你上次结果的位置,而「复制结果」按钮会照原样复制它。
失败本身只来自少数几种输入,都值得记:
- 落单的
%后头不是合法转义;或者百分号序列拼不出合法 UTF-8(比如单独的%FF)→ 输出框里出现「解码失败」。 - 半截代理对(字符串在错误位置被截断时留下的那半个 emoji 编码单元)会让编码直接失败,而不是给出残缺输出。
一个实用的判断技巧:因为 %25 是百分号自己的转义,所以解一次之后结果里还带 %,说明这段东西被编码了两次。%2520 解一次得 %20,再解一次才是空格。如果你的 URL 里出现 %252C、%253A 这类片段,正确做法是解一次,而不是一路上继续打补丁。
Unicode 转义:每个字符六个位
这个页面在文本与 \uXXXX 记法之间双向转换,而它的正向规则比多数人以为的宽:每个字符都转,并且按 UTF-16 单元逐个输出。空格变 \u0020,A 变 \u0041,双引号变 \u0022,换行变 \u000a。十六进制一律小写、补零到四位,所以输出长度恰好是输入 UTF-16 单元数的 6 倍。
「按 UTF-16 单元」这一条正是它对 emoji 有效的原因:🐱 输出成 \ud83d\udc31 一对,而不是 \u{1f431} 这种码点写法。
反向只认「\u + 正好四位十六进制」这一种形状,并且逐段独立替换。因为 JS 字符串本身就是 UTF-16,两个半码会挨在一起重新合成代理对——所以往返实际是无损的,我在混合输入(中文、emoji、引号、反斜杠、换行)上验过,逐字符回到原样。
但「只认这一种形状」也意味着几种邻近写法会原样穿过:
\u{1f431}(ES6 的码点转义)不被识别,因为{不是十六进制位。\x41、两字符形式的\n、\t都不动。- HTML 实体
中也不动。
所以这一页是「\uXXXX 转换器」,不是通用反转义器。你的 JSON 里带 \n、\/ 时,它不是合适的工具。
也别把它当成 JSON 会做的事。 JSON.stringify("中文") 原样返回 "中文"——真正的 JSON 就是 UTF-8,不需要转义。「转 Unicode」的输出又长又难看,但两种写法解析回来是同一个字符串。只有当目标文件必须保持纯 ASCII 时,全量转义才有意义:老式 Java .properties、某些配置格式、以非 UTF-8 编码分发的源码文件。
一次挑对
- 数据要穿过纯文本通道,而且可能是二进制 → Base64。记住体积 +33%,也记住它完全不涉及保密。
- 值要塞进查询串 → 只对值做百分号转义。别把整条 URL 丢进
encodeURIComponent,那会把你依赖的?和&一起转掉。encodeURI保留 URL 结构、encodeURIComponent不保留——页面 FAQ 这句说得是对的。 - 文件必须能在只认 ASCII 的编辑器里读 →
\uXXXX。 - 想让第三方读不懂 → 三个都不是,那需要真正的加密算法和一个你没有公开在网页上的密钥。
三页共同的边界
三个页面都是浏览器的原生调用——btoa/atob、encodeURIComponent/decodeURIComponent,以及一个逐字符循环——所以没有服务器往返,也没有分块、进度反馈和文件流式处理。它们都不提供人们紧接着会需要的替代品:没有 URL 安全的 Base64 变体(用 -/_ 取代 +//)、没有十六进制或 Base32 输出、不能选 UTF-8 之外的字节编码、不支持 \xNN、也不能把带加号的查询串一次解成表单语义。这些需求存在时,命令行工具才是老实的答案;这三页的价值在于快速核对一个小而别扭的输入,看清浏览器会怎么处理它。