URL 编码:+ 还是 %20、; 算不算分隔符、% 为什么会报错
URL 编码的坑不在于规则难,而在于同一个字符在不同段里有不同含义。空格有三种合法写法,+ 在查询串里是空格、在路径里是加号,% 出现在数据里就直接把解码器打挂。所以有用的问题不是"这个字符编对了没有",而是"它正处在 URL 的哪一段"。
一、percent 编码只做一件事:把字节写成 %XX
规则是机械的:把字符按 UTF-8 展开成字节,每个字节写成 % 加两个十六进制位(习惯用大写)。于是:
- 一个汉字变 9 个字符。它在 UTF-8 里是 3 字节 →
%E4%B8%AD。 - 一个 emoji 变 12 个字符。它是 4 字节 →
%F0%9F%98%80,而且在 JavaScript 里长度是 2 个字符位(UTF-16 代理对)。按charCodeAt循环拼字节的写法会把代理对劈成两半分两次编码,那结果是坏字节序列,解的时候报错。 - 不编码的字符是
A-Za-z0-9-._~(RFC 3986 的 unreserved 集)。这一集之外的字节都必须编,否则它会被当成结构字符。
encodeURIComponent 还额外保留了 !'()* 五个——这是 RFC 2396 的历史遗留,标准里它们早该编码了。所以你的输出和某个"严格 RFC 3986"的在线工具逐字对不上时,先看是不是这五个字符。两种写法服务端都能正确解回来,但做签名比对时必须固定一个口径。
二、encodeURI 和 encodeURIComponent 差在会不会改动结构
encodeURI("a?b=c&d") // "a?b=c&d" —— 什么都不编
encodeURIComponent("a?b=c&d") // "a%3Fb%3Dc%26d"
encodeURI 的定位是"整条链接里只想把空格和中文转掉",它保留 :/?#[]@!$&'()*+,;= 这 14 个结构字符。所以它永远不能用来编参数值:值里的 &、=、# 不编掉,链接结构就被改写了——?next=/a&b=1 里那个 & 会让服务端多看到一个参数 b,而 # 会把后半截变成片段,服务器根本收不到。
一句话:拼整条链接用 encodeURI(或者更稳的是用 URL 对象),填参数值一律 encodeURIComponent。
三、空格:%20、+,以及"原样"是错的
同一个值 a b,三种地方三种写法:
| 出处 | 结果 | 谁会这样产出 |
|---|---|---|
encodeURIComponent("a b") |
a%20b |
现代前端手写拼接 |
new URLSearchParams({q:"a b"}).toString() |
q=a+b |
表单提交、URLSearchParams |
| 直接把空格塞进 URL | 被规范化成 %20 |
人手写的链接 |
+ 代表空格只在查询串里成立:
new URL("https://a.com/?q=a+b").searchParams.get("q") // "a b" —— 空格
new URL("https://a.com/a+b").pathname // "/a+b" —— 加号
这就是最常见的一个线上事故来源:密码、token 或文件名里有 +,拼进 query 之后服务端解出空格。如果你的接口签名对不上、只有部分用户复现,先查他的值里有没有 +——尤其是那个值本来就是 Base64(见 Base64 里的中文和 emoji 第六节)。反过来说,字面加号在查询串里该写成 %2B,想手动确认就用 URL 编解码 或 Base64 文本编码 / 解码 对一遍。
四、; 不是分隔符,重复键要显式取全部
[...new URL("https://a.com/?a=1;b=2").searchParams] // [["a", "1;b=2"]]
浏览器把整段当一个参数的值——; 只是普通字符。老代码里用 ; 分隔参数的写法(某些框架、某些 PHP 配置)在新解析口径下会得到一个怪值。想传两个参数就写两个 &。
重复键则相反,它是合法且常见的:
const p = new URL("https://a.com/?t=1&t=2").searchParams;
p.get("t"); // "1" —— 只要第一个,静默丢掉第二个
p.getAll("t"); // ["1", "2"]
get 只回第一个且不告诉你后面还有,所以参数丢了值的时候,先确认接收端用的是 get 还是 getAll。
五、% 是唯一的"数据里不能出现"的字符
decodeURIComponent("100%") 抛 URIError,decodeURIComponent("100%纯牛奶") 同样抛——因为 % 后面必须跟两个十六进制位。真实数据里带百分号、带 %PDF 头、带 %s 占位符,都会撞上这一条。
更隐蔽的是坏 UTF-8 序列:%C3%28 语法上完全合法(两个 %XX),但拼起来不是任何合法编码的字符,浏览器解的时候抛 URIError。所以"能解码"不等于"输入正确",反过来也一样。
双重编码靠肉眼判断:%25E4%25B8%25AD 解一次得到 %E4%B8%AD——还剩完整的 %XX 形态,说明该再解一次。%25 就是编码后的 %。工具解一次就是一次,不会猜你要几次;encodeURIComponent(encodeURIComponent(" ")) 会得到 %2520,这类串在服务端解一次就变成一个字面的 %20 字符串,看起来"差不多对了",其实差一层。
六、粘进来的链接已经被浏览器改过了
用浏览器自带的 URL 构造器(WHATWG URL 标准)解析一条链接,结果和输入往往不完全一样。下面每一行都在 Chrome 里实测过,它们都是规范的一部分,不是解析出错:
| 你粘进去的 | 浏览器解析出来的 | 原因 |
|---|---|---|
https://a.com/../../../../etc/passwd |
https://a.com/etc/passwd |
. 和 .. 段被折叠 |
https:\a.com/x(一个反斜杠) |
https://a.com/x |
http/https 里 \ 当 / 处理 |
https://a.com:443/p |
port 是空 | 443 本就是 https 的默认端口,被抹掉 |
ht⇥tps://a.com/(中间夹制表符) |
https://a.com/ |
制表符、换行、首尾空白被删掉后继续解析 |
https://a.com/a b |
https://a.com/a%20b |
空格自动补成 %20 |
https://例例.中国/a |
hostname 是 xn--fsqa.xn--fiqs8s |
IDNA 转换,中文域名进 URL 就是 punycode |
https://a.com:99999/ |
直接解析失败 | 端口越界,new URL() 抛 TypeError |
要判断"浏览器最终请求哪个地址",看规范化后的 href,不要看输入框里那串。另外 # 后面的片段不会发给服务器——把敏感值写在片段里,服务端日志看不到,但它会留在浏览器历史、Referer 相关的场合以及任何前端脚本里,所以它不是保密手段。
七、什么时候用页面工具对一遍
URL 编解码 的 A 段把链接拆成协议、主机、端口、路径、查询串、片段,并把查询摊成参数表(同时给出 get 和 getAll 的差别);B 段对任意文本做 percent 编码/解码,并且明确写出有多少处被转义、有几处是非法转义——落单的 % 它按字面保留并计数,坏 UTF-8 序列换成替换字符并计数,所以"解码完成"的提示后面如果计数不为 0,就该回原串查。勾选项里保留了 + 的表单口径、encodeURI 口径和 !'()* 这几个歧义点,就是为了让你和代码里的输出逐字对上。
相关问题
为什么我编出来的和别的网站不一样? 三个可能:空格用了 + 而不是 %20;!'()* 有没有编;十六进制位大小写(%e4 与 %E4 等价,但做字符串比对时不等价)。固定一个口径,签名才对得上。
路径里的中文要不要编? 现代浏览器会自动把 new URL() 里的非 ASCII 编成 percent 形态,但不同段该编的集合不一样(路径里 / 是分隔符,查询里 & 是分隔符)。手写拼接最容易在这里出错,用 URL + searchParams.set() 组装最稳。
表单编码和 percent 编码是一回事吗? 不是。application/x-www-form-urlencoded 用 + 表示空格、按 UTF-8 编其余非安全字符;percent 编码(RFC 3986)用 %20。两套都在用,区别只在服务端拿哪种解码器——所以 + 是一个勾选框,而不是自动判断。
粘进来的链接会上传吗? 不会。解析用的是浏览器内的 URL 对象,编解码都是脚本里的循环,链接不会被送到别处,你粘的链接不会离开这台设备。