DevKitLab Logo DevKitLab
URL 编码 / 编码 / 调试

URL 编码为什么乱了?百分号编码与 + vs %20

你把 café 放进 URL,出来变成 café。空格在一处是 +、在另一处是 %20。值里一个 & 把查询串劈成两半,后半截悄悄没了。有东西被编码了两次,你盯着一串 %2520。这些看着像各不相同的 bug,其实是同一个想法加两层拧劲:百分号编码把 URL 不能直接写的字节转义掉,规则随所在部位而变,而空格有两种合法写法。

你把 café 放进一个 URL,从另一头出来却成了 café。你编码一个空格,一处得到 +、另一处得到 %20,你也说不清哪个才对。一个带着 & 的值,把查询串劈成两半,后半截悄无声息地消失了。有东西被编码了两次,你盯着一串 %2520。每一个看着都像各自独立的 bug,于是你给每一个打一个各自的补丁——这里解一次码、那里 .replace() 一下、再多编码一次——然后下游某一层又坏了。

这些底下其实是同一个事实:百分号编码,就是让 URL 去搬运那些它不被允许直接写出来的字节的办法。URL 的序列化形式只能使用一套受限的 ASCII 字符;任何在这套字符之外的东西——非 ASCII 文本,或者像 &/ 那样带结构职责、却出现在一个值内部的字符——都得被转义成一个 %,后面跟上每个字节的两位十六进制数。整套机制就这么多。困惑来自叠在它上面的两层拧劲:「哪些字符必须被转义」这条规则,会随你处在 URL 的哪个部位而改变;而一个空格有两种合法编码——%20+——它们只在恰好一种语境里表示同一个东西,在别处则各说各话。

这篇文章先花一分钟讲清百分号编码到底是什么,然后走一遍它出错的那几种方式:+/%20 之分、本想编码一小块却编码了整条 URL、把 café 变成 %C3%A9(处理不当时则变成 café)的那一步 UTF-8,以及把 %20 变成 %2520 的双重编码。读到最后,你会有一份清单,对付任何一条出来就乱掉的 URL。

唯一的核心:把一个字节转义成 % 加它的两位十六进制

URL 的序列化形式只能使用有限的 ASCII 字符集合——所以非 ASCII 文本得先(用 UTF-8)转成字节、再转义,才能上路。百分号编码——又叫 URL 编码——就是那个逃生口:把任何在这里不被允许的字节,写成一个 %,后面跟上这个字节值的两位十六进制。空格是字节 0x20,于是变成 %20/ 是字节 0x2F,于是当它是数据而不是路径分隔符时就变成 %2F。有两类字符定下了规则:

  • 非保留字符——A–Z a–z 0–9 - . _ ~——永远安全,从不需要编码。
  • 保留字符——: / ? # [ ] @ ! $ & ' ( ) * + , ; =——它们带着结构含义:把查询和路径分开、把一个参数和下一个分开、把键和值分开。作为分隔符它们是合法的,但当其中某个出现在一个值内部时,就必须被编码——否则它会被当成结构读掉。

第二点,一句话就是那个「& 劈开了我的查询」的 bug:一个值里没被编码的 &,跟两个参数之间的那个 & 长得一模一样,所以它后面的一切都被当成一个新参数解析了。

+%20:一个空格的两副面孔

URL 编码里最让人困惑的一点,是两个写法都可能正确,只是适用场景不同。空格有两种编码,哪个对取决于所在的部位:

  • 路径里、以及 URL 的大部分地方,空格是 %20。那里一个字面的 + 就只是个加号。
  • 表单编码数据application/x-www-form-urlencoded)里——最常见的就是查询串——空格是 +,而一个字面的加号必须写成 %2B

所以在一般的 URL 组件里,%20 是表示空格的明确写法;而「+ 代表空格」是 application/x-www-form-urlencoded 的约定——表单编码器所遵循的规则,最常见于查询串,也会出现在表单请求体里。问题通常就出在这道边界上:一个在路径里把 + 当空格的解码器,会毁掉一个真正的加号;而一个在表单编码数据里+ 当空格的解码器,会让你在本该是空格的地方留下一串字面的 +。同一个输入,能看出这两种约定的分歧:

encodeURIComponent("a b")                      // "a%20b"
new URLSearchParams({ q: "a b" }).toString()   // "q=a+b"

encodeURIComponent 面向一般 URL 组件,所以输出 %20URLSearchParams 按表单来序列化,所以输出 +。这也会延伸到 Base64 的问题——一段搭着表单编码数据传输的 Base64,它的 + 可能被表单解码悄悄变成空格,这其实是一个「我的 Base64 为什么解不出来」、本质上就是这条 +/空格规则的 bug。拿不准时,就把空格编码成 %20、把加号编码成 %2B,这份歧义就消失了。

编码那一小块,别编码整条 URL

「编码坏了」里有很大一部分,是在错误的粒度上编码。这里有两件活、两个不同的工具:

  • 编码整条 URL 会放过那些结构字符(: / ? # & =),因为它们正干着自己的活。在 JavaScript 里这是 encodeURI
  • 编码单独一块——一个查询值、一个路径段——则必须把所有保留字符都转义掉,包括 / ? # & =,因为在这里它们是数据,不是结构。这是 encodeURIComponent

拿整条 URL 的编码器去处理一个,它的 &= 会原封不动地溜过去,把你的查询炸开。拿单块编码器去处理整条 URL,它的 ://? 会被转义成没法再路由的乱码。规则是:用编码过的各块拼出 URL;永远不要把拼好的整条 URL 当成一个字符串再编码一次。用单块编码器编码每个值,再用那些你希望保持结构性的字面 ?&= 把它们连起来。

非 ASCII:先 UTF-8,再百分号编码

café 不会变成 %café。百分号编码作用在字节上,而像 é 这样的字符并不是一个字节——所以中间藏着一步:文本先用 UTF-8 编码成字节,然后每个字节再做百分号编码。é 是 UTF-8 的两个字节 0xC3 0xA9,于是变成 %C3%A9café 就变成 caf%C3%A9。一个 CJK 字符通常是三个字节,于是对应三组 %XX。这正是 café 变成 café 的原因:字节是按 UTF-8 编码的,但下游某处却按 Latin-1 去解,在 Latin-1 里 0xC3 0xA9 读作 é。URL 里的乱码,几乎总是一次 UTF-8 与别的什么之间的错配,而不是百分号编码本身出了错。另外要注意,那些 %XX 对不过就是十六进制——%C3 就是字节值 0xC3——这也正是把十六进制读顺了、一条编码过的 URL 就忽然可读起来的原因

还有一点值得分开说:URL 的主机部分,对非 ASCII 并不用百分号编码。café.com 会通过 Punycode / IDN 变成 xn--caf-dma.com,那是一套完全不同的机制——所以一个非 ASCII 的域名和一个非 ASCII 的路径,是被两套不同的系统转义的,把它们搞混本身就是一类困惑的来源。

双重编码:%20 是怎么变成 %2520

典型的后续问题。百分号编码不是幂等的:对一个已经编码过的字符串再编码一次,那些 %本身会被编码,因为 % 是字节 0x25%25。于是 %20(一个编码过的空格)再过一遍编码器,就变成 %2520,读的人现在看到的是文本里一个字面的 %20,而不是一个空格。特征就是 %25 出现在你没放它的地方——%2520%253A%2526。它发生在:一个值被你的代码编码过,然后又被一个框架、一个 HTTP 客户端、或一次重定向编码了一遍,而后者以为这个值还是原始的。修法是恰好只编码一次:找出那个在双重包裹的层,只让其中一个去干这活。想看清有几层,把这串丢进 URL 编解码工具反复解码——每解一遍剥掉一层,等那些 %25 变回 %,你就数清了它被包了几次。不过反复解码只是诊断手段,不是修复逻辑:正式代码里别盲目循环解码,因为一个正确编码的值本身也可能合法地包含字面的 %25%20 文本。

还会咬人的那些角落

几个藏在规范里的小坑:

  • encodeURIComponent 不编码 ! ' ( ) *。有些服务器严格按 RFC 3986 来,也想让这几个被转义——所以要是一个挑剔的端点拒收它们,就自己手动编码。
  • 路径里的 + 是字面的加号。只有 application/x-www-form-urlencoded 数据才把 + 读成空格,所以别靠把 + 变成空格去「修」一条路径。
  • # 会悄悄截断。一个值里没编码的 # 会开启 URL 的片段(fragment),它后面的一切永远到不了服务器——数据里的 # 必须是 %23

URL 编码乱掉时的排查清单

当一条 URL 出来就不对,别上来就乱替字符。按顺序问这几句:

  1. 是哪一块? 整条 URL 要让 : / ? # & = 保持结构性;单独一个值则必须把它们转义掉。值用单块编码器(encodeURIComponent),整条 URL 用 URL 编码器(encodeURI)。
  2. 空格显示成 + 还是 %20application/x-www-form-urlencoded 数据里,+ 表示空格;其他 URL 组件用 %20。保险起见,空格编成 %20、加号编成 %2B
  3. 重音或 CJK 变乱码了? 那是 UTF-8 与字符集的错配,不是百分号编码——让两头都统一到 UTF-8。主机名用 Punycode,不用 %XX
  4. 看到你没加的 %25 它被双重编码了——一路解码到那些 %25 变回 %,然后恰好只编码一次。
  5. 一个值被截断,或者查询劈开了? 一个值里没编码的 #&= 正被当成结构读掉——把它编码掉。

这五条底下,还是那个唯一的核心:百分号编码把一个字节转义成 % 加两位十六进制,而真正难的是两件事:分清数据与结构,以及那个空格等于 + 的独一语境。点名你到底站错在哪一边,那条看似乱掉的 URL 就会干干净净地解开。