URL 编码的三个陷阱:空格、加号与双重编码
查询串里的 + 是空格,路径里的 + 是加号;同一个字符在路径与查询里的编码规则不同;编码两次会得到看似正常却永远匹配不上的值。
URL 编码只有一条规则(百分号加两个十六进制位),但它在三个地方各有一套习惯,混用就是 bug。
陷阱一:+ 只在查询串里表示空格
?a=b+c → 值是 "b c" (表单编码,+ 是空格)
/a+b → 路径是 "/a+b" (路径里 + 就是加号)
这个不对称来自历史:查询串沿用了 application/x-www-form-urlencoded,路径没有。所以:
| 位置 | 空格 | 加号 |
|---|---|---|
| 查询串 | + 或 %20 |
必须写成 %2B |
| 路径 | 必须写成 %20 |
直接用 + |
在查询串里传一个真的加号,必须编码成 %2B,否则另一端会解成空格。这是最经典的编码 bug。
陷阱二:路径与查询的保留字不同
encodeURIComponent 会编码 / ? & = #,encodeURI 不会。用错方向的后果:
// 想编码一个路径片段(不是整条路径)
encodeURIComponent('a/b'); // 'a%2Fb' ← 路径里这是一段
encodeURI('a/b'); // 'a/b' ← 变成了两段
// 想编码一个查询值
encodeURIComponent('a&b'); // 'a%26b' ← 正确
encodeURI('a&b'); // 'a&b' ← 被当成两个参数
规则很简单:编码单个组件用 encodeURIComponent,整条 URL 用 encodeURI。 拼查询串时永远用前者。
标准还要求编码 ! ' ( ) *,而 encodeURIComponent 不编这四个:
function strictEncode(text) {
return encodeURIComponent(text)
.replace(/[!'()*]/g, (ch) => '%' + ch.charCodeAt(0).toString(16).toUpperCase());
}
多数场景不编也能工作,但签名计算(OAuth 之类)会因为这几个字符产生不一致。
陷阱三:双重编码
对已经编码过的串再编一次,% 变成 %25:
原文 a b
编码一次 a%20b ← 正确
编码两次 a%2520b ← 解码一次得到 "a%20b",不是 "a b"
双重编码不会报错,只会得到错的值。 常见来源:框架自动编码了一次,代码里又编了一次。
排查手段是看请求里 % 的密度:出现 %25 基本可以确定编了两次。解码时也别自作主张「解到不能再解」—— 如果值本身就该含 %,多解一次就毁掉了原值。
两个实用规则
一、编码要在拼串的最后一步做。
const q = params.map(([k, v]) =>
strictEncode(k) + '=' + strictEncode(v)
).join('&');
先拼后编会把分隔符也编掉。用 URLSearchParams 更省心,它已经处理了这些:
new URLSearchParams([['a', 'b c'], ['d', '1+2']]).toString();
// 'a=b+c&d=1%2B2'
二、不要信任解码结果做路径拼接。
%2E%2E%2F 解出来是 ../。服务端若先解码再拼路径,就得到了目录穿越。正确顺序是先按段拆路径、校验每一段,再解码,或者解码后拒绝任何含 / 与 .. 的结果。
URL 编码不是「把特殊字符换成百分号」这么简单。位置决定规则:路径与查询各一套,编码的时机决定成败。

评论
…