跳到主内容
d.devtul.fun
EN
HTTP · 2026-08-28

URL 编码实战:encodeURIComponent、encodeURI 到底该用哪个

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 还是 +,再决定信不信它。

继续阅读