← 返回博客
语言: English 中文
Encoding 2026-06-08 7 分钟

避免二次编码:URI 百分号编码与表单规则的区别

1991 年 Tim Berners-Lee 给 URL 加上百分号编码,是因为当时唯一可参考的“在文本里转义”先例就是 shell 引号,那一团乱。三十多年过去,规范修订了六轮,URL 编码依然是最常见的小型 bug 来源——因为现在有两套互不兼容的“标准”在并行:一套用于 URL 本身(RFC 3986),一套用于 HTML 表单提交(application/x-www-form-urlencoded)。

URL 编码百分号编码RFC 3986encodeURIComponent表单编码

它到底是什么

百分号编码是一种把任意字节塞进 URL 的方式:% 加两个十六进制位。%20 是字节 0x20,也就是 ASCII 的空格。%E4%BD%A0 是三个字节(0xE4 0xBD 0xA0),合在一起恰好是 UTF-8 的"你"。编码本身不知道也不关心,它只看字节。

也就是说,百分号编码本身不规定文本怎么变成字节。RFC 3986(2005 年发布、目前的 URI 规范)说默认用 UTF-8,除非具体的 scheme 另有规定。现代 URL 工具基本都遵守,老一些的有时不遵守——这就是为什么一段 Latin-1 的查询参数在 IE6 里看着没问题,到了 Chrome 就乱码:字节是用一种字符集编出来的、却用另一种字符集解的。百分号编码本身完美往返了,乱码出在字节如何被解读这一层。

保留字符与非保留字符

RFC 3986 把字符分成两阵营:

非保留字符(unreserved):字母、数字,加上 -._~ 这四个。它们永远不需要编码。如果一个编码器把 A 编成 %41,技术上是错的——RFC 3986 §2.4 明确说生产端不应该编码非保留字符。

保留字符(reserved):在 URL 结构里有特殊含义的字符。又分两个子类:

  • gen-delims:/?#[]@。划分 URL 大组件的分隔符。
  • sub-delims!$&'()*+,;=。在特定组件内部有意义。

保留字符要不要编码,取决于它在 URL 的哪一段里。在路径段里出现 ? 必须编码,否则解析器会以为查询串从这里开始。在查询串里出现 ?...也是查询串的开头,但后续的 ? 通常被当作数据接受。规则是作用域相关的——这部分没有人能凭直觉一次性记对。

JavaScript 也因此提供了两个函数:

  • encodeURI()——假设你在编码整个 URL,所以保留字符不动。
  • encodeURIComponent()——假设你在编码单个组件(一个路径段、一个查询值),所以几乎所有保留字符都编码。

绝大多数情况你想要的是 encodeURIComponentencodeURI 唯一合法的用法是:你已经手工拼好了一个 URL、只想把里面的空格转义。这种场景很少,而且通常意味着你应该改用一个 URL builder 库。

表单变体

HTML 表单提交时,浏览器不用 RFC 3986 百分号编码。它们用 WHATWG HTML 规范定义的 application/x-www-form-urlencoded,差异如下:

  • 空格变成 + 而不是 %20
  • 文本到字节的转换用表单的 accept-charset,通常是 UTF-8 但不保证。
  • 更多字符被激进地编码。

这就是为什么从 HTML 表单出来的查询串长这样 ?q=hello+world,而 JS 用 encodeURIComponent 拼的查询串长这样 ?q=hello%20world。两个都合法、都能被解析成"hello world"——但前提是几乎所有服务端解析器都同时认这两种。

也是因为这条,URL 里的 + 是有歧义的。在查询串里它通常表示空格;在路径里通常不是。如果你的查询值里有一个真正的 + 想保留下来,必须把它编码成 %2B这就是查询串里电话号码字段的经典 bug+1-555-0100 经过表单解码后变成 1-555-0100,那个 + 被当成空格再 trim 掉。

双重编码

这一路上最常见的 bug。

你拿到 ?q=hello world,编码成 ?q=hello%20world,然后下游某个地方又把整个 URL 编了一次——%20 变成 %2520,因为 % 自己被百分号编码成了 %25。服务端最终拿到的查询值是字面量的 hello%20world(含百分号),不是 hello world

这种情况发生在:有人手工编码了一次再交给一个会自动编码的库;前端为了显示编码一次、后端为了重定向又编码一次;某个中间件的"安全过滤"重复跑了。特征是字符串里出现不该出现的 %25。修法是搞清楚哪一层应该负责编码,把其他层的编码全删掉——通常最靠近通信线缆的那一层赢,上游全部传明文。

IDN 域名是另一回事

https://例.jp/ 这种 URL 在主机名部分不是百分号编码。主机名用 Punycode(RFC 3492),把 Unicode 编成 xn-- 开头的 ASCII:例.jp 变成 xn--fsq.jp。这是和百分号编码完全不同的机制,只作用于主机名;路径和查询仍然用百分号编码。

如果你想"URL 编码"一个域名而效果不对,原因就在这——域名要做 IDNA 处理,不是百分号编码。把这两件事混在一起,URL 在你的测试浏览器里能解析、在别人那儿就 404。

常见踩坑

  • 该用 encodeURIComponent 的地方用了 encodeURI。结果你数据里& 被解析器当成 URL 结构了。
  • 用严格 RFC 3986 解码器去解表单编码的 payload。+ 还原后还是 +、不会变成空格。
  • 编了两次。看到字符串里出现 %25 时第一反应应该是查这个。
  • 用字符串拼接而不是用 URL 构造器或查询串库。库知道规则,你那个 + '&q=' + value 不知道。
  • 在查询值里编了一个 #,但忘了 # 是 fragment 分隔符,优先级高于查询分隔符。很多解析器在拆查询串之前就先把 # 之后的全砍掉了,所以查询值里的字面量 # 必须写成 %23

实用规则

  • 查询参数和路径段:用 encodeURIComponent,从来不用 encodeURI,几乎从来不字符串拼接。
  • 出现意料之外的 +,说明下游有什么东西把你的 URL 当表单编码处理了。
  • 一次到位地编码。最靠近通信层的那一层负责。
  • 域名走 Punycode,不走百分号编码。两套机制。
  • 路径里的空格用 %20+ 只在查询串里能用,而且是因为 HTML 表单的历史遗留。
  • 不要拿百分号编码去当 JSON 或 HTML 内部的转义机制——它们各有各的转义规则,错用百分号编码会把字符集假设悄悄烤进数据。

主要参考资料

用于核对本文技术细节的标准与官方文档。

两种 flavor 同时试

本站 URL 编码工具支持 RFC 3986 和表单编码两种模式并排显示。当你在排查“为什么我的 + 被读成空格”或者反过来时特别有用。

打开 URL 编码工具

相关文章

继续阅读同一主题领域的实践指南。

查看全部文章

Cookie 同意

我们使用 Cookie 来增强您的体验并展示相关广告。您可以自定义您的偏好。