JavaScript 里有两个长得几乎一样、于是大家谁先被自动补全选中就用谁的函数:encodeURIComponent 和 encodeURI。它们不能互换,选错是"本地好好的链接上线就崩"这种 bug 的经典来源。这篇指南把两者各自编码什么、分别该用在哪、其他语言会踩的坑,以及如何解码才不会崩,一次说清。
精确差异在哪
两者都把不安全字符变成 %XX 三元组,唯一的区别是留下多少字符不动。encodeURIComponent 把所有不在非保留集合里的字符都编码掉——包括那些构成 URL 本身的字符。encodeURI 则有意放过了让一个 URL 成为 URL 的那些字符:/ ? : # & = + @。
const full = 'https://example.com/p?q=a b&x=1';
encodeURI(full);
// "https://example.com/p?q=a%20b&x=1" (保留 : / ? & = )
encodeURIComponent(full);
// "https%3A%2F%2Fexample.com%2Fp%3Fq%3Da%20b%26x%3D1" (全编码)
如果你拿一个完整的 URL 去调 encodeURIComponent,等于亲手毁了它::// 和斜杠变成了 %3A%2F%2F,浏览器再也认不出这是个能跳转的地址。能拦住大多数编码 bug 的,只有一条铁律:编码的是"值",不是"URL"。
各自该用在哪
- 用
encodeURIComponent处理单个值:一个查询参数、一个路径段、你亲手拼的片段。 - 仅在你已经有一个完整且合法的 URL、只是想顺手清理掉里面的不安全字符(又不希望动它的结构)时,才用
encodeURI——比如一个来自配置文件、可能混进了空格或非 ASCII 的 URL。 - 永远不要用这两个函数去编码一个你准备直接 fetch 的完整 URL——编码片段、再组装。
速查小抄
拿不准的时候,整个决策就这张表:
| 你要编码的是 | 用哪个 | 结果里的空格 |
|---|---|---|
| 单个查询值 / 路径段 | encodeURIComponent | %20 |
| 整条已经合法的 URL(仅清理) | encodeURI | %20 |
| 整条查询串(多参数) | URLSearchParams | + |
| 表单请求体 | 表单编码(见下) | + |
拼查询串的正确姿势
手写 '?q=' + encodeURIComponent(v) 能跑,但一旦参数多起来就又啰嗦又容易漏。浏览器自带 URLSearchParams,它会替你把每个值编码对:
const p = new URLSearchParams();
p.set('q', 'a b');
p.set('page', '2');
const url = 'https://example.com/search?' + p.toString();
// "https://example.com/search?q=a+b&page=2"
注意空格变成了 +。这又是表单编码的约定,服务端用标准表单解码时会把它还原成空格。如果你非要严格的 %20,那就自己用 encodeURIComponent 拼字符串。
用 fetch 发送表单体
发表单数据时也是同一套约定。把 content type 设成 application/x-www-form-urlencoded,让 URLSearchParams 去编码:
fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ q: 'a b', page: '2' }),
});
// 发出的 body:"q=a+b&page=2"
别拿 encodeURIComponent 手写这个 body、然后再把 content type 设成表单——那样你会在服务端表单解码器期望 + 的地方给出 %20,在严格的服务端上,值里的空格就会以字面 %20 抵达。
decodeURIComponent 和它的兄弟 decodeURI
解码也有两个和编码器对应的函数。decodeURIComponent 解一个值;decodeURI 解整条 URL,并且会拒绝解码那些代表结构性字符的 % 序列。实际中你几乎总是对从 searchParams 取出的值用 decodeURIComponent。
const u = new URL('https://ex.com/s?q=a%20b');
u.searchParams.get('q'); // "a b" (URL 解析器已经解过码了)
decodeURIComponent('a%20b'); // "a b"
decodeURI('https://ex.com/a%20b'); // "https://ex.com/a b"
为什么 decodeURIComponent 会抛 URIError
解码既不对称也不绝对安全。如果输入里包含一个残缺的百分号序列——一个后面没跟两个十六进制数字的 %,或者只跟了一个——decodeURIComponent 就会抛出 URIError。数据损坏、复制粘贴被截断、或者根本没被正确编码的字符串,都会触发它。
decodeURIComponent('%E4%BD'); // URIError: malformed URI sequence
decodeURIComponent('%'); // URIError
防御性写法是包一层,解码失败时降级而不是让整个请求炸掉:
function safeDecode(s) {
try {
return decodeURIComponent(s);
} catch (e) {
return s; // 返回原始字符串,而不是抛错
}
}
返回原始字符串通常是对的:一个解码失败的值至少还看得见,而未捕获的异常可能直接拖垮整个处理函数。
其他语言:各自的陷阱
- Python——
urllib.parse.quote把空格编成%20;quote_plus编成+,还会把斜杠也变成%2F。本想拼路径却随手用了quote_plus,斜杠就悄悄被毁了。 - Go——
url.QueryEscape名字就写着给查询用,空格产出+,跟表单编码一个路数。别把它塞进路径里。 - Java——
URLEncoder.encode是给application/x-www-form-urlencoded请求体设计的,空格变+,而且比你预期的激进得多,连斜杠都编码。拿它去拼路径或重定向目标,是常犯的错误。 - PHP——
urlencode把空格产出成+(表单风格);rawurlencode产出%20。很多 PHP 应用按表单规则解码$_GET,所以+会解码成空格——但如果你在 JS 端用encodeURIComponent拼 URL,发出去的是%20,PHP 同样会把它解码成空格,所以这两者通常兼容;只有在字面加号上才会暴露出问题。
双重编码事故
框架替你编码了参数,你的代码又编码了一遍。结果:hello%20world 变成 hello%2520world。服务端解码一次,看到的是 hello%20world 这个字面量,你搜"hello world"自然什么也搜不到。修复办法是在恰好一层做编码——要么信框架的自动编码,要么信你手写的,别两层都做。
// Express/Next 会自动解码 req.query,所以不要在客户端再预编码:
// 客户端:fetch('/api?q=' + encodeURIComponent(term)) // 正确,单层编码
// 服务端:req.query.q 已经是解码后的真实字符串
实战场景:带用户值的 REST 路径
路径段也需要编码,坑在于值里的斜杠会变成路径分隔符。每个段都用 encodeURIComponent 编码:
function apiUrl(user) {
// 错误:含 "/" 的用户名会把路径切开
// return '/api/users/' + user;
// 正确:
return '/api/users/' + encodeURIComponent(user);
}
apiUrl('a/b'); // "/api/users/a%2Fb" —— 是一个段,不是两个
一个能防住大多数 bug 的习惯:往返检查
当你拿不准编码对不对时,做个往返检查:先编码,再用对应的解码器解回来,和原值比较。如果解出来和原值相等,说明这一层的编码是对的;如果不等,说明你编多了或编少了。这一个测试能同时抓住"双重编码"和"该编没编"两类问题,在控制台里十秒钟就能跑完。
const original = 'Tom & Jerry / 你好';
const enc = encodeURIComponent(original);
const dec = decodeURIComponent(enc);
console.log(dec === original); // true —— 往返是干净的
还有一个坑:把整个 key=value 一次性编码
一个常见错误是拼出 'q=' + value 之后,不去只编码值,而是把整个 'q=' + value 塞进 encodeURIComponent。= 于是变成 %3D,服务端看到的是一个名叫 q%3D 的参数,而不是"键为 q、值为你的内容"。永远先编码值、再拼接那个本来就是安全的键:'q=' + encodeURIComponent(value),绝不要 encodeURIComponent('q=' + value)。
服务端该怎么选 + 还是 %20
前端用 encodeURIComponent 发出去的是 %20,而 URLSearchParams 和表单发出去的是 +。服务端解码时一定要知道自己在解哪一种,否则就会出前面那些"空格变加号"的事故。下面这个 Python 例子演示了两种解码的差异:
# 前端用 URLSearchParams 发的(表单风格,空格是 +)
from urllib.parse import parse_qs
parse_qs('q=a+b') # {'q': ['a b']} 表单解码:+ 变空格
# 前端用 encodeURIComponent 发的(空格是 %20)
from urllib.parse import unquote
unquote('a%20b') # 'a b'
# 但如果你拿表单解码器去解 %20 风格的串,结果也正确:
parse_qs('q=a%20b') # {'q': ['a b']} 标准实现两种都认
坑在于:有些手写的解码器只认 +、不认 %20,或者反过来。最稳的做法是永远用语言自带的、符合标准的查询解析器(URLSearchParams、parse_qs、框架的 req.query),不要自己写 split('&') 再 replace('+',' ')。一旦你手写,就一定会漏掉 %20 和 %2B 的区别,于是"该是加号的值"在某个输入下变成空格。
顺带一提,%2B 和 + 的混淆在令牌和签名场景里尤其危险。如果一个 API 要求你把一个 base64 字符串当作查询参数传过去,base64 本身含有 + 和 /;若服务端用表单解码器,+ 会被读成空格、/ 会被当成路径分隔的一部分,签名立刻对不上。正确做法是:要么用 encodeURIComponent 让 + 变成 %2B,要么直接用 URL 安全的 base64 字母表(- 和 _),从源头消灭这两个字符。
小结
编码的是"值",不是"URL"。片段用 encodeURIComponent,整条查询串和表单体用 URLSearchParams,解码时用 try/catch 包一层做防御。换语言时,先确认那个语言的"encode"产出的是 %20 还是 +,再决定信不信它。