URL 编码与 Base64:什么时候用哪个
2026-09-30 · 1505 词
百分号编码和 Base64 都把输入变成一组受限的 ASCII 字符,都可逆,都常被描述成"让字符串安全"。它们同时也是两个完全不同问题的答案 —— 所以靠感觉在两者之间选,产出的数据会在测试里活下来,然后在三周后的日志文件里瓦解。
区别只有一句话:百分号编码保护的是在 URL 内部具有语法含义的字符,Base64 重新编码的是根本不是文本的字节。 把它们搞反,不会有任何异常抛出 —— 值只是到达时变了样,或者只到了一半。
规则,只说一遍
- 如果你手上是文本,而它必须待在某个 URL 组件里(搜索词、文件名、跳转目标),就为那个组件做百分号编码。
- 如果你手上是字节(图片、哈希、密钥、protobuf),就做 Base64 —— 如果它还要走 URL,再把 Base64 结果做一次百分号编码。
第二条的附加部分是人们会跳过的,也正是下面那个经典 bug 的来源。这两个变换是组合关系,不是竞争关系。
各自到底保证了什么
百分号编码是按 URL 语法的每个组件定义的。它说的是:把这里不允许的字节替换成 % 加两个十六进制数字。它的输出仍然是有同样含义的文本 —— 人读一段编码后的路径还能看出它原本是什么。代价在最坏情况下没有上限:需要转义的 ASCII 字符每个花一组 %XX(三个字符),而非 ASCII 字符按每个 UTF-8 字节花一组,所以一个汉字是九个字符。
Base64 是定义在字节之上的,对 URL 一无所知。它的输出用 64 个字符 —— A–Z、a–z、0–9、+、/ —— 外加 = 填充,膨胀精确地是三分之一。这些字符作为字符在 URL 里全都合法,而这恰恰是 Base64 在那里翻车的原因:+ 另有含义,/ 另有含义,= 被 URL 语法的其他部分占用。合法和能承重不是同一件事。
每个人都会踩的失败:查询串里的 Base64
把一张图片编成标准 Base64,直接丢进查询参数:
https://example.test/img?data=iVBORw0KGgoAAAANSUhEUg...+/8AAAAASUVORK5CYII=
这里每个字符在 URL 里都合法,所以浏览器会发出去、服务器会收到。然后服务器用 application/x-www-form-urlencoded 规则解析查询串 —— 多数框架的默认行为 —— 而在那套规则里,+ 表示空格。到达的 Base64 已经不是发出的那个了。如果它还能解码,解出的字节也是错的;更常见的是在"长度不是 4 的倍数"处失败,而那个位置与被改动的字符位置完全无关。
有两个都正确的修法,它们不能互换:
- 把 Base64 做百分号编码再拼接,回来的路上严格反序解码。
+变成%2B,表单解析不会碰它。 - 改用 URL 安全字母表 —— 用
-和_替代+和/—— 从源头去掉歧义,而不是绕着它转义。那套字母表以及它的来历,见 URL 安全 Base64 与标准 Base64。
操作顺序值得写下来,因为错一步很容易、看出来很难:字节 → Base64 → 百分号编码 → 查询串,反过来是 查询串 → 百分号解码 → Base64 解码 → 字节。在 Base64 解码之后再百分号解码,是一个只在含 % 的输入上才会暴露的 bug。
encodeURI 与 encodeURIComponent
JavaScript 两个都提供了,差别正是"按组件"这条规则。
encodeURIComponent('a&b=c d'); // 'a%26b%3Dc%20d'
encodeURI('https://x.test/?a&b=c d'); // 'https://x.test/?a&b=c%20d'
encodeURI 的设计目标是处理一个你已经拼好的完整 URL。因此它放过 &、=、?、/、: —— 它们是 URL 的结构,编码它们会毁掉结构。如果拿它去处理一个随后被放进查询参数的值,它会放过 &,而 & 会把你的值切成第二个参数。这就是一次直白的注入:用户文本变成了 URL 语法。
encodeURIComponent 的设计目标是URL 的一段,所以它编码一切结构字符。它应当是你的默认选择,而这大概也是经验法则真正成立的一个地方:不确定就编得更狠 —— 一个被过度编码的保留字符无害,一个编码不足的 & 是一次参数注入。
也注意 encodeURIComponent 不编码什么:! ' ( ) * - . _ ~ 和字母数字都原样保留,因为 RFC 3986 把它们标记为非保留字符。这是正确的,也正是为什么两个不同的生产者可以输出看起来不同却等价的字符串 —— 这点在后面很重要,当签名或缓存键是拿其中之一算出来的时候。
加号与空格:民间说法背后的规则
+ 表示空格不是 URL 规则,而是 application/x-www-form-urlencoded 的规则 —— 即 HTML 表单使用的格式,以及很多服务器在查询串上沿用的惯例。由此有几点值得知道:
- 在查询串里,
+通常被读成空格;在路径里,它是字面的加号。所以在符合标准的解析器上,?q=C%2B%2B和?q=C++不同,而%2B是唯一可靠的"在值里发送字面加号"的方式。 encodeURIComponent对空格产出%20,不是+;而new URLSearchParams({ q: 'a b' }).toString()产出q=a+b—— 含义相同,拼写不同。在表单解析器下两者都能正确解码;在把+当字面量的严格 URL 解析器下,两者都不能。你得到哪一个由库决定,不由你决定 —— 这就是把一个值往返穿过两个库会可靠地多出一个空格的原因。- 当一个值已经被编码、你又编了一遍时,空格和
+不会在天真的双重变换下存活。那是 百分号编码:会破坏 URL 的保留字符 的主题,那篇讲的就是本节概括的字符级规则。
两者都不该出现在 URL 里的情况
两种变换编码都有成本,而它们都不是"URL 太长"的解法。百分号编码能把 ASCII 输入变成三倍,非 ASCII 更糟;Base64 固定加 33%,然后还要再做百分号编码 —— 所以 URL 里的二进制载荷,在各种计数开始之前,光是字符数就可能接近原体积的两倍,具体算术见 为什么 Base64 要有 33% 的开销。
实际限制才是这件事的要害:旧浏览器把 URL 限制在约 2,083 个字符,很多服务器和代理把请求行限制在 8 KB,而每个 URL 最终都会进日志、分析系统和 Referer 头 —— 没人想把一兆字节的编码数据留在那里。如果载荷超过一个 token 或一个短参数,它就属于请求体,不属于查询串。如果它是二进制而且必须待在 URL 里 —— 签名缩略图、内联 data URI —— 那是一个有成本的设计决定,而 Base64 加百分号编码就是那个成本。
决策清单
- 用户文本要进 URL 组件 →
encodeURIComponent,或用你语言里的等价函数。 - 一个拼好的 URL 需要转义非 ASCII 或空格 →
encodeURI,并且知道它会保留结构字符。 - 二进制要穿过文本通道 → Base64;如果目的地是 URL,用 URL 安全字母表,或把结果做百分号编码。
- 文本要进表单字段或请求体 → 只有在该字段被定义为 URL 编码时才做百分号编码,否则原样发送。
- 任何体积较大的东西 → 放进请求体,不要进 URL。
- JSON 字符串里的 Base64 再放进 URL 里 —— 这个场景会让人踩两次 —— 先编 Base64,再编 JSON,再做 URL 组件编码,然后严格反序解码。URL 编解码工具 会展示每一步逐字符的结果,这是看清"是哪一层吃掉了你的
+"最快的方式。