工具
指南
本页内容

2026年8月9日

URL 编码详解:percent-encoding、RFC 3986 保留字与组件编码的取舍

你到处都见过它们:本该是空格的位置写着 %20,本该是汉字的位置写着 %E4%B8%AD,某个本身是个 URL 的查询值开头是一长串 %3A%2F%2F。百分号转义看起来像乱码,但每一个都是两个事实之间的小小折中:URL 必须是一行纯 ASCII 文本,而我们要塞进去的数据什么鬼样子都有。

这篇指南讲清楚:URL 编码到底是什么、哪些字符是 URL 语法自留的保留字、“编码一个组件”和”编码整条 URL”的区别(一大家子 bug 的根源都在这),以及 base64url 和这些是什么关系。文中每个示例都能在站内的 URL 编码解码工具里复现——它完全在你的浏览器里运行。

百分号编码为什么存在#

URL 在字符串格式里算得上身世特殊:它必须能穿过一群压根没为它设计过的信道。URL 会被复制进邮件、口头念出来、敲进终端、塞进 HTTP 头、印在招牌上。一路上它会经过各种对某些字节特别敏感的系统——控制字符、空格、非 ASCII 字节、#、?、乃至 % 自己。

所以 URL 语法(RFC 3986 标准化,统一了更早的 RFC 1738 与 RFC 2732 的规则)提出了一条硬性要求:URL 是一串 ASCII 字符,而且其中只有一部分允许原样出现。安全集合之外的字符必须以一个万能的转义形式出行:

% XX

一个百分号,后面跟恰好两位十六进制数字,指名一个 0 到 255 的字节。%20 是字节 0x20(空格),%3F 是字节 0x3F,也就是 ?。有两个字符特殊到”想按字面出现必须转义”:% 本身要写成 %25(否则每个 % 都会被当成转义的开头);空格要写成 %20——太多环境把空格当特殊字符处理(它在 HTTP 头里终结一个 token,在编辑器里被顺手裁掉),裸奔必死。

非 ASCII 文本的规则自 RFC 3986 起是 UTF-8:先把字符序列化成 UTF-8 字节序列,再对每个字节独立做百分号编码。中 的 UTF-8 是三个字节 E4 B8 AD,于是变成 %E4%B8%AD;中文 变成 %E4%B8%AD%E6%96%87。这就是为什么一个字符在 URL 里可能膨胀成九个 ASCII 字符——对长度上限来说,这个事实很要命。

保留字:URL 语法的自留地#

RFC 3986 把字符分成两个阵营,这个划分几乎能解释你将来要做的每一个编码决定。

非保留字符可以出现在任何组件的任何位置,不用转义、不改变含义:ASCII 字母 A-Z a-z、数字 0-9,外加四个标点 - . _ ~。把非保留字符转义掉(把 A 写成 %41)技术上合法但毫无意义——解码器会把它归一化回去。

保留字符是 URL 语法用来搭建结构的分隔符集合:: / ? # [ ] @ ! $ & ' ( ) * + , ; =。这些字符属于 URL 的语法。它们当然可以原样出现——但只能以语法角色的身份。一旦其中某个字符作为数据出现——作为你要运送的值的一部分——就必须转义,否则读 URL 的人无法分辨哪个是分隔符、哪个是负载。

最经典的演示:把一条 URL 装进另一条 URL 的查询串里,也就是每个登录跳转背后的 next= 模式。拿这条 URL:

https://example.com/redirect?next=/settings

next 的值是一个路径 /settings。把它原样插进外层 URL 的查询值里,里面的 /、?、= 会和外层 URL 自己的分隔符撞车。按组件方式编码后,每个保留字符都被缴械:

https%3A%2F%2Fexample.com%2Fredirect%3Fnext%3D%2Fsettings

再解码回来,逐字节还原成原 URL。“按数据编码、按数据解码”的往返性质,就是整个技巧的全部。

组件编码 vs 完整 URL:真正要紧的区别#

每种语言的标准库里都住着两个函数,选错一个是应用代码里最常见的 URL bug。JavaScript 把它们摆得很明白:encodeURIComponent 和 encodeURI。站内的 URL 工具通过 组件/完整 选择器提供的正是这一对。

组件编码把一切非保留字符全部转义。JavaScript 里精确的保留集合是 A-Z a-z 0-9 - _ . ! ~ * ' ( )——注意它比 RFC 3986 的非保留集略宽,是它所依据的旧规范留下的历史痕迹。其余的一切——/、?、&、=、#、空格、所有非 ASCII——统统变成百分号转义。用在你要放进 URL 的值上:一个查询参数、一个路径段、一个 fragment。

把 path/to file?name=值 按组件编码:

path%2Fto%20file%3Fname%3D%E5%80%BC

/ 变成 %2F,空格变 %20,? 变 %3F,= 变 %3D,值 的三个 UTF-8 字节变成 %E5%80%BC。作为数据,它现在彻底惰性化了:任何 URL 解析器都不会在里面读出结构。

完整 URL 编码转义同样不安全的字节,但故意放过保留分隔符。喂给它:

https://example.com/搜索?q=查询 词

得到:

https://example.com/%E6%90%9C%E7%B4%A2?q=%E6%9F%A5%E8%AF%A2%20%E8%AF%8D

协议冒号、斜杠、?、= 全部幸存;汉字和那个多余的空格被转义。用在你已经拿到手、形状正确、只需要清理其中字面字符的完整 URL 上。

判断法则一句话说清:**字符是 URL 结构的分隔符,就保留(完整模式);字符是被运送数据的一部分,就转义(组件模式)。**拿不准时,一律按组件编码、用零件拼装 URL——这是安全的默认做法,因为对每个值做组件编码永远不可能破坏骨架。

配套一条实操规矩:先编码后拼装,绝不反过来。写成 ?q= + encodeURIComponent(value) + &lang= + encodeURIComponent(other)。对拼好的整串再编码,就是 & 变 %26、你的服务端默默收到一个巨型参数而不是三个的来由。

用真实数据演算#

**例一——带混合内容的搜索词。**用户搜了 咖啡 & 茶 <list>。按组件编码:

%E5%92%96%E5%95%A1%20%26%20%E8%8C%B6%20%3Clist%3E

每个汉字贡献三个转义,& 变成 %26 所以不会凭空多出第二个参数,< > 变成 %3C %3E,这个值被放进 HTML 属性时也不致被搅乱。

例二——邮箱做标识符。[email protected] 编码为 user%2Btag%40example.com。那个 + 比看起来要紧:在 application/x-www-form-urlencoded 数据(传统 HTML 表单 POST,以及 q=a+b 风格的查询串)里,+ 被定义为空格。如果你裸发 +,有的服务器会把地址读成 user [email protected]。编码成 %2B 是让加号完好到达的唯一办法——而解码 %2B 可靠地得到 a+b 而不是 a b。

例三——字面百分号。50% off 编码后必须是 50%25 off:%25 是 % 的转义。双重编码的 bug 正是从这里孵化。某一层多编了一次,50% 先变 50%25,然后 %25 里的 % 又被转义,得到 50%2525——解码一次看到 50%25,解码两次看到 50%。当地址栏里的 URL 带着 %25 而页面渲染出一个字面 %,你看到的是多了一层编码;当页面把 %20 当文本显示出来,说明某个环节少解码了一次。

**例四——非法转义。**解码必须拒绝垃圾。% 后面必须跟两位十六进制数字,所以 a%GGb 不是合法编码,100%(结尾孤零零一个百分号)也不是——两者都会让 JavaScript 抛出 URI malformed,站内 URL 工具会报出错位而不是瞎猜。这种严格是故意的:对非法转义”尽力而为”的解码器产出的字符串永远无法再编码回原样,直接击碎百分号编码赖以存在的往返保证。

base64url 在这幅图景里的位置#

base64 常出现在 URL 场景里,也常和百分号编码混淆,值得把它的位置说准。两者解决的是不同的问题:

  • 百分号编码管的是 URL 语法:让任意字节在 URL 里安全出行,代价是长度膨胀三分之一到两倍(一个字节最多变三个字符)。
  • base64 管的是二进制转文本:用 64 个可打印字符表示任意字节,固定代价是每 3 字节变 4 字符(约 133%)。它生来与 URL 结构无关。

标准 base64 用 A-Z a-z 0-9 + / 加 = 填充——而这里面偏偏有两个字符是 URL 保留字。把标准 base64 串放进 URL,+ 可能被读成空格,/ 可能切断你的路径,= 可能终结你的查询值。修补方案就是专为这件事定义的 base64url 变体:+ 换成 -,/ 换成 _,结尾的 = 填充去掉。单字节 A 在标准 base64 里是 QQ==,在 base64url 里就是 QQ。

只要你解码过 JWT,今天就已经用过 base64url 了:它那三段以点分隔的内容就是 base64url 编码的。头部 {"alg":"HS256","typ":"JWT"} 变成 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9——没有 +、没有 /、没有 =,这个格式选择该变体的原因一目了然。可以用 JWT 解码器验证,也可以用 base64 工具(含 URL 安全模式)对任意文本做往返。

所以两者的关系是分层的:base64url 选了一套 URL 安全的字母表,从而通常根本不需要百分号编码。而百分号编码始终是兜底机制,负责一切仍含保留字或非 ASCII 字符的东西。

一份能挡掉大多数 bug 的清单#

  • 每个查询值都用组件编码,然后再用 & 和 = 拼接。绝不编码拼好的整串。
  • 完整编码只用于修复:你手里是一条完整 URL,里面混进了空格或非 ASCII 字符。
  • 数据里的 + 必须以 %2B 出行;空格用 %20 永远安全,用 + 只在表单编码的请求体里安全。
  • 数据里的 % 一律以 %25 出行。
  • 非 ASCII 文本先转 UTF-8 再逐字节百分号编码;一个三字节的汉字变九个字符——预算长度上限时要算上。
  • 收到的 URL 已部分编码时,先完整解码再重新编码,否则会把本来就对的部分双重编码。
  • 调试方法是逐层解码一次、读中间结果——多出来的 %25 或丢失的 %2B 是从哪一层进来的,一读便知。

还有一条边界值得守住:百分号编码让文本在 URL 里安全。把同一段文本放进 HTML 需要另一套转义——& 写成 &amp;、< 写成 &lt;——那是 HTML 实体工具的职责。URL 编码在 HTML 语境里保护不了你,HTML 转义在 URL 语境里也保护不了你;把两者搞混,链接坏掉和 XSS 钻空子都是这么来的。

常见问题#

为什么空格有时是 + 有时是 %20?#

两份规范撞了车。管 URL 总体的 RFC 3986 只定义了一种空格转义:%20。更老的 application/x-www-form-urlencoded 格式(HTML 表单提交 POST 体时用)把 + 定义为空格的简写。实践中两种写法都会在查询串里出现。%20 在哪里都正确;+ 只在表单编码约定生效的地方安全。

非保留字符保持编码状态(比如把 ~ 写成 %7E)合法吗?#

技术上说合法——解码器必须接受——但它暴露出编码方还在用 RFC 3986 之前的老古董(旧 escape 函数会转义 ~,encodeURIComponent 不会)。把 %7E 归一化回 ~ 是安全的;把两种写法当成两个不同的字符串则不安全。

为什么我的非 ASCII 文本编码后长了一大截?#

每个非 ASCII 字符先序列化成 UTF-8(1 到 4 字节),每个字节再变三个字符。一个汉字通常 3 字节,所以在 URL 里是 9 个字符。这属于正常现象,但正因为如此,内嵌中文、阿拉伯文或 emoji 的 URL 会轻松冲破那些看起来很宽松的长度限制。

组件还是完整——我的 URL 里既有查询串、值本身又是个 URL,怎么办?#

先把内层 URL 用组件模式编码,再把它作为一个值放进外层 URL 的查询里。如果外层 URL 自身的字面字符都是 ASCII 且非保留,就不需要再编码。用 URL 工具两步粘贴即可:编码内层值,把结果放进外层查询,再用完整模式确认拼好的 URL 没有残留裸空格或非 ASCII 字符。

% 后面跟的不是十六进制字符,还能解码吗?#

不能,而且要提防那些硬要解的工具。%GG 和结尾孤零零的 100% 都是非法的,正确行为是带着位置信息失败,正如 JavaScript 的 decodeURIComponent 抛出 URI malformed。宽松解码产出的字符串无法往返,还会掩盖上游的 bug。

base64 和百分号编码怎么互相配合?#

方向是单向独立的:百分号编码可以编码任何东西,包括含 + 或 / 的 base64 串。base64url 的存在则是让标准 base64 负载通常根本不需要百分号编码。两端都由你掌控时,URL 里的二进制数据优先用 base64url——几乎所有场景下它都比裸字节的百分号编码更短。

延伸阅读#

← 全部指南