避免二次编码:URI 百分号编码与表单规则的区别
1991 年 Tim Berners-Lee 给 URL 加上百分号编码,是因为当时唯一可参考的“在文本里转义”先例就是 shell 引号,那一团乱。三十多年过去,规范修订了六轮,URL 编码依然是最常见的小型 bug 来源——因为现在有两套互不兼容的“标准”在并行:一套用于 URL 本身(RFC 3986),一套用于 HTML 表单提交(application/x-www-form-urlencoded)。
它到底是什么
百分号编码是一种把任意字节塞进 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()——假设你在编码单个组件(一个路径段、一个查询值),所以几乎所有保留字符都编码。
绝大多数情况你想要的是 encodeURIComponent。encodeURI 唯一合法的用法是:你已经手工拼好了一个 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 内部的转义机制——它们各有各的转义规则,错用百分号编码会把字符集假设悄悄烤进数据。
主要参考资料
用于核对本文技术细节的标准与官方文档。
相关文章
继续阅读同一主题领域的实践指南。
Node 生产 Dockerfile 里到底该有什么,不该有什么
网上大多数 Node Dockerfile 都把 node_modules 直接拷进镜像、用 root 运行、最后产出一个 900 MB 的层。本文只讲那几个真正影响构建时间、镜像体积和运行时安全的决定:基础镜像、多阶段构建、依赖层缓存、NODE_ENV 陷阱,以及为什么你的 docker-compose 不该照搬生产。
在用户之前发现缺失的翻译键和插值参数不匹配
缺失的翻译键会把原始键路径直接渲染给用户,插值参数不匹配会渲染出空串或崩溃。这两者在评审中都容易漏,因为开发者的语言包永远有全部键。本文讲如何结构化比较 locale JSON 文件、找出缺失键,并在发布前抓住参数不匹配。
能真正压测 UI 的 mock 数据(而不是只把页面填满)
大多数 mock 数据是同一行复制十遍、只换个 id。它填满页面,却什么都测不到。本文讲如何生成能压测布局边界、长名字、缺失字段、空状态,以及会破坏格式化代码的日期和数字格式的 mock 数据,并通过字段推断让一个 JSON 样本一步变成贴近真实的数据集。