一份可以调试时一直开着的参考:同一个字符在三种编码器下分别长什么样。把这张表记熟,能省下十几回"我的参数为什么不对"的排查时间;而记住那几行在不同语言里结果不一样的字符,能帮你避开更隐蔽的一类 bug——同一段代码在服务端和浏览器里产出不同字符串。
三种语境
看表之前,先把这三种情况在脑子里分清楚。它们不是同一个工具,产出也不是同一个字符串:
encodeURIComponent——在 JavaScript 里编码单个值。它假设输入是数据,所以把所有保留字符都转义掉。encodeURI——编码一个已经完整的 URL。它放过了结构性字符,让 URL 仍然可以跳转。- 表单提交(
application/x-www-form-urlencoded,也就是URLSearchParams和 HTML 表单产出的格式)——三者里唯一会把空格变成字面+的。
前两个是 JavaScript 函数,第三个是线格式(wire format)。把它们混为一谈,就是下面大多数意外的根源。
完整对照表
这是值得收藏的表。按列读:同一个字符,仅仅因为被哪个编码器碰过,就可能变出不同的样子。
| 字符 | encodeURIComponent | encodeURI | 表单提交 | 说明 |
|---|---|---|---|---|
| 空格 | %20 | %20 | + | 表单用 +,其余用 %20 |
| ! | %21 | ! | %21 | encodeURI 保留感叹号 |
| " | %22 | %22 | %22 | 双引号 |
| # | %23 | # | %23 | 片段分隔符 |
| $ | %24 | $ | %24 | encodeURI 保留 |
| % | %25 | %25 | %25 | 百分号自身必须编码 |
| & | %26 | %26 | %26 | 查询分隔符 |
| ' | %27 | ' | %27 | 单引号 |
| ( ) | %28 %29 | ( ) | %28 %29 | encodeURI 保留括号 |
| * | %2A | * | %2A | encodeURI 保留星号 |
| + | %2B | + | %2B | 字面加号必须编码 |
| , | %2C | , | %2C | encodeURI 保留逗号 |
| / | %2F | / | %2F | 路径分隔符 |
| : | %3A | : | %3A | 协议分隔符 |
| ; | %3B | ; | %3B | encodeURI 保留分号 |
| = | %3D | %3D | %3D | 键值分隔符 |
| ? | %3F | ? | %3F | 查询起始符 |
| @ | %40 | @ | %40 | 用户分隔符 |
| [ ] | %5B %5D | %5B %5D | %5B %5D | IPv6 字面量用 |
| ~ | ~ | ~ | %7E | 非保留,但表单仍编码 |
| 你好 | %E4%BD%A0%E5%A5%BD | %E4%BD%A0%E5%A5%BD | %E4%BD%A0%E5%A5%BD | UTF-8 六字节 |
| 😀 | %F0%9F%98%80 | %F0%9F%98%80 | %F0%9F%98%80 | 四字节 emoji |
| 换行 | %0A | %0A | %0A | 控制字符,必须编码 |
| Tab | %09 | %09 | %09 | 控制字符 |
最让人意外的那几行是 ?、#、/、@、:,以及 ! $ ( ) * , ; 这些标点。encodeURI 放它们走,因为它们是结构性的;encodeURIComponent 不放,因为它默认输入是个值,而不是一个 URL。~ 那行是个安静的坑:它属于非保留集合,所以两个 JS 函数都原样放过,但表单编码仍然把它变成 %7E。
为什么空格那行不一样
空格在 URI 语法里属于不安全字符,所以两个 JS 函数都输出 %20。但表单媒体类型出现得比现代 URI 规范更早,它选了 + 来表示空格,好让查询串更可读。服务端的职责是解码表单格式,在那种格式里 + 就是空格。如果你用 encodeURIComponent 手写查询串,得到的是 %20,而符合标准的表单解码器仍然会把它读回空格——所以这两者通常能兼容。错配只发生在:一方期望表单的 + 约定,另一方不期望。
加号陷阱:a+b 与 a%2Bb
最常被报告的一个编码 bug 就是加号。在表单解码的查询串里,a+b 表示"a 空格 b"。只有 a%2Bb 表示"a 加 b"。如果你的数据里可能含有字面意义的加号,就必须把它编码掉,否则服务端会静默地把加号变成空格。
// 服务端收到 q="a b" (加号被当成空格)
?q=a+b
// 服务端收到 q="a+b" (加号被保留)
?q=a%2Bb
这个坑在 base64 和 URL 安全的令牌上咬人最狠:一个像 ab+cD/ef== 的 base64 值,过一遍服务端的表单解码,就变成了 ab cD/ef==,令牌就此校验失败。一定要用 encodeURIComponent 编码令牌(或者干脆用 - 和 _ 的 URL 安全 base64 字母表)。
不同语言里的编码差异
同一个逻辑操作,在不同语言里产出不同结果,而这正是跨系统 bug 藏身之处。下面几个值得背下来:
# Python:quote 保留斜杠,quote_plus 把斜杠变成 %2F 且用 +
from urllib.parse import quote, quote_plus
quote('a/b c') # 'a/b%20c'
quote_plus('a/b c') # 'a%2Fb+c'
// Go:QueryEscape 是表单风格,空格变成 +
import "net/url"
url.QueryEscape("a b") // "a+b"
// Java:URLEncoder 是表单风格,空格变 +,斜杠变 %2F
URLEncoder.encode("a/b c", "UTF-8") // "a%2Fb%2Bc"
# Ruby:URI.encode_www_form_component 是表单风格(空格 -> +)
require 'uri'
URI.encode_www_form_component('a b') # "a+b"
结论:在 Python、Go、Java、Ruby 里,"默认"编码器都是表单编码器,产出 + 并且会动斜杠。JavaScript 的 encodeURIComponent 是个异类,它产出 %20,而且只有当你拿它去编码整条路径时才会把斜杠保留成 %2F。当你的前端用 encodeURIComponent 编码、后端用表单解码器解码时,一个错配就已经在等你了。
双重编码事故
这是典型的"能跑但值不对" bug。一层编码完,另一层又把已经编码过的字符串再编码一次。一个被正确编成 %20 的空格,变成了 %2520。服务端解码一次,看到的是字面字符串 %20,把它当作数据——于是你想要的空格没了,用户屏幕上看到的是 "%20"。
// 前端已经编码过值
const sent = 'q=' + encodeURIComponent('a b'); // "q=a%20b"
// 错误:某个代理或框架在发送前又编码了一次
const double = encodeURIComponent(sent); // "q%3Da%2520b"
// 服务端解码一次:q=a%20b -> 值变成了字面的 "a%20b",而不是 "a b"
修复办法是纪律:只在恰好一层做编码。如果你的框架或 HTTP 客户端已经编码了参数,就别在应用代码里再编码一遍。如果必须预编码,就把客户端的自动编码关掉。
"该编码却没编码"的事故
镜像的另一面:一个带有保留字符的值,被人当成"不过就是个字符串"裸丢进了 URL,因为以为不用编码。解析器随后在错误的分隔符处把值切开了。
// 错误:值里的 & 被当成了新的查询参数
const name = 'Tom & Jerry';
const url = 'https://example.com/search?q=' + name;
// 结果:https://example.com/search?q=Tom & Jerry
// 服务端解析出两个参数:q="Tom " 和 Jerry=""(Jerry 现在成键了!)
// 正确:编码值
const url2 = 'https://example.com/search?q=' + encodeURIComponent(name);
// https://example.com/search?q=Tom%20%26%20Jerry
同样的隐患也适用于 #(它后面的内容被丢进片段)、?(开启第二段查询)、/(开启一段路径)。任何这些字符出现在未编码的用户输入里,都会重塑整个 URL。
编码非 ASCII 与 emoji
百分号编码针对的是字节,不是字符。非 ASCII 文本先被转成 UTF-8,再对每个字节做 %XX 三连码。这就是为什么 你好 是 %E4%BD%A0%E5%A5%BD(六个字节、九个可见字符),而那个咧嘴 emoji 😀 是 %F0%9F%98%80(四个字节)。同一个字符只有在源用了不同字符集时才会编出不同结果——所以你应该在所有地方钉死 UTF-8,绝对别让框架回退到 Latin-1。
百分号自身与残缺序列
一个后面没有跟着两个十六进制数字的孤零零 % 是非法的。如果你拼接的字符串里本来就含有百分号(日志、哈希、像 50% 这种格式化数字)却没编码,就会产出一个严格解码器会拒绝的序列。永远把 % 当成数据,在它进 URL 之前编码成 %25。
实战场景:拼一个安全的搜索 URL
把这些串起来。用户输入一段自由文本查询,里面可能有空格、&、中文和 emoji。正确的构造方式只编码"值"那几块:
function buildSearch(term, page) {
const p = new URLSearchParams();
p.set('q', term); // URLSearchParams 会编码每个值
p.set('page', String(page));
return 'https://example.com/search?' + p.toString();
}
// term = "C++ & 你好" -> "...?q=C%2B%2B+%26+%E4%BD%A0%E5%A5%BD&page=2"
// 注意:空格变成了 +(表单风格),+ 和 & 被编码,中文是 UTF-8 字节
如果服务端期望严格的 %20 而不是 +,就把 URLSearchParams 换成手写的 encodeURIComponent 拼接。
什么时候该用工具
如果你正盯着一长串编码后的文本、想搞清楚它到底说了什么,直接粘进 URL 编码工具 解码就行。除了很短的值,手写编码正是错误溜进来的地方;而解码器是确认一个字符串究竟包含什么的最快办法。
小结
一个字符的编码结果,不是关于这个字符的事实,而是关于"哪个编码器碰过它"的事实。把三种语境分开,把表收藏好,并记住 + 只在表单编码里才表示空格。每个值只编码恰好一次,而当服务端和客户端对不上时,上面那张表就是你想明白"为什么"的地方。