URL 编码与解码:百分号编码开发实战指南
查服务器日志时看到查询字符串里有 %E4%BD%A0%E5%A5%BD,第一反应是数据坏了。其实没有——这只是”你好”两个字各自拆成三个 UTF-8 字节后做了百分号编码。看着像乱码,URL 本身完全正常。
URL 编码,正式叫百分号编码(percent encoding),负责把特殊字符转成 URL 能安全承载的格式。这篇文章从字节层面拆解编码原理,对比 encodeURI 和 encodeURIComponent 的适用场景,给出 JS/Python/Go/Java 四种语言的写法,再整理几个老手也会中招的坑。
将任意 URL 粘贴到我们的 URL 解码器与编码器,边阅读边实时查看编码和解码效果。
什么是 URL 编码(百分号编码)?
URL 里能直接放的 ASCII 字符很有限——字母、数字加一小撮符号。空格、&、中文、emoji 这些统统不行,必须先转换。
百分号编码的做法很简单:把每个不安全字节替换成 % 加两位十六进制数。空格变 %20,& 变 %26,名字就来自那个 %。
这套规则写在 RFC 3986 里,2005 年发布,现在还是现行标准。它替代了 RFC 2396,把安全字符、保留字符和非 ASCII 文本的处理规则都收紧了。
快速示例:
| 输入 | 编码结果 | 原因 |
|---|---|---|
hello world | hello%20world | 空格在 URL 中不允许出现 |
price=10&tax=2 | price%3D10%26tax%3D2 | = 和 & 有结构性含义 |
中 | %E4%B8%AD | 非 ASCII → UTF-8 字节 → 百分号编码 |
🚀 | %F0%9F%9A%80 | Emoji → 4 个 UTF-8 字节 → 百分号编码 |
哪些字符需要编码?
RFC 3986 把字符分成三组,搞清楚分组规则能少踩很多坑。
非保留字符(无需编码)
下面这 66 个字符可以直接出现在 URL 的任何位置:
A-Z a-z 0-9 - . _ ~
只有这些。字母、数字、连字符、点号、下划线、波浪号,其余一律要编码。
保留字符(视上下文而定)
这些字符在 URL 中充当结构分隔符:
| 字符 | 在 URL 结构中的角色 |
|---|---|
: | 分隔 scheme 和 authority(https:) |
/ | 分隔路径段 |
? | 查询字符串起始 |
# | 片段标识符起始 |
& | 分隔查询参数 |
= | 分隔参数的键和值 |
@ | 分隔用户信息和主机 |
+ ! $ ' ( ) * , ; [ ] | 各种保留用途 |
原则:保留字符在充当结构分隔符时原样保留;一旦它出现在数据里(比如参数值中),就必须编码。
其他所有字符(始终编码)
空格、尖括号、花括号、竖线、反斜杠,以及所有非 ASCII 字符(中文、阿拉伯语、emoji)——放进 URL 前必须百分号编码。
空格比较特殊:RFC 3986 编码为 %20,HTML 表单提交却用 +。这个矛盾后面会展开讲。
URL 编码的工作原理:UTF-8 管道
ASCII 字符编码很直白:拿到十六进制字节值,前面加 %。空格是字节 32(十六进制 20),编码后就是 %20。
非 ASCII 文本多走两步:
第 1 步——拿到 Unicode 码点。
é 对应 U+00E9,🚀 对应 U+1F680。
第 2 步——码点转 UTF-8 字节。
UTF-8 按码点大小用 1 到 4 个字节。é(U+00E9)拆成两个字节 0xC3 0xA9;火箭 emoji(U+1F680)拆成四个字节 0xF0 0x9F 0x9A 0x80。
第 3 步——每个字节套上 %XX。
上一步拆出来的字节,每个都变成一个百分号编码三元组。
完整编码管道示例:
| 字符 | 码点 | UTF-8 字节 | 编码结果 | 大小倍增 |
|---|---|---|---|---|
A | U+0041 | 41 | A(无需编码) | 1× |
| 空格 | U+0020 | 20 | %20 | 3× |
é | U+00E9 | C3 A9 | %C3%A9 | 6× |
中 | U+4E2D | E4 B8 AD | %E4%B8%AD | 9× |
🚀 | U+1F680 | F0 9F 9A 80 | %F0%9F%9A%80 | 12× |
用 JavaScript 验证一下:
const char = '中';
const encoded = encodeURIComponent(char);
console.log(encoded); // '%E4%B8%AD'
// Trace the bytes
const bytes = new TextEncoder().encode(char);
console.log([...bytes].map(b => '%' + b.toString(16).toUpperCase()).join(''));
// '%E4%B8%AD' — matches
膨胀直接影响 URL 长度限制。20 个中文字符编码后就要占掉 180 个字符。
encodeURI 与 encodeURIComponent——选择正确的函数
这两个函数名字像,行为差很远,混用是最常见的 URL 编码错误。
encodeURI() | encodeURIComponent() | |
|---|---|---|
| 用途 | 编码完整 URL | 编码单个组件(参数的键或值) |
| 保留不编码 | : / ? # & = @ + $ , | 以上全部都编码 |
| 编码范围 | 空格、非 ASCII、部分标点 | 除 A-Z a-z 0-9 - _ . ~ ! ' ( ) * 外全部编码 |
| 使用场景 | 完整 URL 中路径包含空格或 Unicode | 用用户输入构建查询参数 |
这个 bug 在线上代码里出现的频率很高:
// ❌ BUG: encodeURI does NOT encode &
const search = 'Tom & Jerry';
const bad = `https://api.example.com/search?q=${encodeURI(search)}`;
// Result: https://api.example.com/search?q=Tom%20&%20Jerry
// The & splits the query string — server sees q=Tom%20 and a separate param %20Jerry
// ✅ FIX: encodeURIComponent encodes & as %26
const good = `https://api.example.com/search?q=${encodeURIComponent(search)}`;
// Result: https://api.example.com/search?q=Tom%20%26%20Jerry
拿不准就用 encodeURIComponent(),95% 的 URL 拼接场景它都能覆盖。
各语言的 URL 编码实现
JavaScript(浏览器和 Node.js)
// Encode a parameter value
const value = encodeURIComponent('price >= 100 & currency = €');
// 'price%20%3E%3D%20100%20%26%20currency%20%3D%20%E2%82%AC'
// Decode
const original = decodeURIComponent(value);
// 'price >= 100 & currency = €'
// Modern approach: URLSearchParams handles encoding automatically
const params = new URLSearchParams({ q: 'hello world', lang: '中文' });
console.log(params.toString());
// 'q=hello+world&lang=%E4%B8%AD%E6%96%87'
// Note: URLSearchParams uses + for spaces (form encoding)
Python
from urllib.parse import quote, unquote, urlencode
# Encode a path segment
quote('hello world/file name.txt', safe='/')
# 'hello%20world/file%20name.txt'
# Encode query parameters
urlencode({'q': '你好', 'page': '1'})
# 'q=%E4%BD%A0%E5%A5%BD&page=1'
# quote_plus uses + for spaces (form encoding)
from urllib.parse import quote_plus
quote_plus('hello world') # 'hello+world'
quote('hello world') # 'hello%20world'
Go
import "net/url"
// Encode a query value (uses + for spaces)
url.QueryEscape("hello world & more")
// "hello+world+%26+more"
// Encode a path segment (uses %20 for spaces)
url.PathEscape("hello world & more")
// "hello%20world%20&%20more"
// Build a URL safely with url.Values
params := url.Values{}
params.Set("q", "你好世界")
params.Set("page", "1")
fmt.Println(params.Encode())
// "page=1&q=%E4%BD%A0%E5%A5%BD%E4%B8%96%E7%95%8C"
Java
import java.net.URLEncoder;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
// Encode (uses + for spaces — Java follows form encoding)
String encoded = URLEncoder.encode("hello world & more", StandardCharsets.UTF_8);
// "hello+world+%26+more"
// For RFC 3986 compliance, replace + with %20
String rfc3986 = encoded.replace("+", "%20");
// "hello%20world%20%26%20more"
// Decode
String decoded = URLDecoder.decode(encoded, StandardCharsets.UTF_8);
// "hello world & more"
Go 和 Java 默认走表单编码,空格输出为 +。要拿到 RFC 3986 格式,编码后再把 + 替换成 %20 就行。
五个会搞垮生产环境的 URL 编码 Bug
1. 双重编码(出现 %2520 而非 %20)
你编码了一次,框架又帮你编了一次。%20 里的 % 变成 %25,服务器收到的是文字 %20 而不是空格。
症状: URL 里出现 %2520、%253D 或其他 %25xx 模式。
诊断: 看到 %25 就该警觉——% 本身被编码了,几乎都是双重编码。
修法: 先解码一遍,再统一编码一次。编码前确认输入是否已经编码过。
// Detect double encoding
function isDoubleEncoded(str) {
return /%25[0-9A-Fa-f]{2}/.test(str);
}
// Safe encode: decode first, then encode
function safeEncode(str) {
try { str = decodeURIComponent(str); } catch (e) { /* not encoded, that's fine */ }
return encodeURIComponent(str);
}
2. 路径段中的 + 号
用了一个把空格编码成 + 的库来处理文件名。my report.pdf 变成 my+report.pdf,服务器把 + 当字面加号,直接 404。
记住: + 只在查询字符串(? 后面)里表示空格。路径段里 + 就是加号本身,空格必须用 %20。
3. OAuth 重定向 URI 被截断
授权 URL 写成了这样:
https://auth.provider.com/authorize?redirect_uri=https://myapp.com/callback?code=abc&state=xyz
OAuth 服务器把 redirect_uri 截断到 https://myapp.com/callback?code=abc,state=xyz 被当成顶层独立参数。认证直接挂掉。
修法: 把整个重定向 URI 值编码:
const redirectUri = 'https://myapp.com/callback?code=abc&state=xyz';
const authUrl = `https://auth.provider.com/authorize?redirect_uri=${encodeURIComponent(redirectUri)}`;
// redirect_uri=https%3A%2F%2Fmyapp.com%2Fcallback%3Fcode%3Dabc%26state%3Dxyz
4. 日志中非 ASCII 文本变成乱码
日志里全是 %E4%BD%A0%E5%A5%BD,看不到中文。这不是 bug,编码完全正确——只是日志查看器没做解码。
解决: 日志输出管道加一层解码,或者直接把 URL 丢进 URL 解码器 看原文。
5. API 签名验证失败
OAuth 1.0 和 AWS Signature V4 要求严格遵循 RFC 3986。但 encodeURIComponent() 不编码 !、'、(、) 和 *。这几个字符混进签名输入,签名就对不上。
修法: 编码后做一次替换:
function rfc3986Encode(str) {
return encodeURIComponent(str).replace(/[!'()*]/g, c =>
'%' + c.charCodeAt(0).toString(16).toUpperCase()
);
}
%20 与 +——空格编码之争
两套标准,一个字符,无尽的困惑。
| 标准 | 空格变成 | 适用范围 |
|---|---|---|
| RFC 3986(URI 语法) | %20 | URL 中的任何位置 |
application/x-www-form-urlencoded | + | HTML 表单提交的查询字符串 |
+ 表示空格这个约定从 Web 诞生之初就有了。浏览器用 method="GET" 提交 <form> 时,查询字符串里的空格就编码为 +。HTML 规范白纸黑字写着,改不了。
麻烦在于 + 只在查询字符串里代表空格。路径段里 + 就是加号。https://example.com/my+file.pdf 请求的是 my+file.pdf 这个文件,不是 my file.pdf。
怎么选:
- 手动拼 URL 或处理路径段,用
%20,在哪都不会错。 - 解析表单提交的查询字符串时接受
+,你用的框架多半已经处理好了。 - 别混用。一个组件里选定一种写法,保持一致。
URL 编码与安全
URL 编码不是加密
百分号编码是纯粹的确定性转换,完全可逆,不需要任何密钥。%48%65%6C%6C%6F 谁都能秒解出 Hello。
别指望用 URL 编码藏敏感数据。要加密,上 HTTPS。而且 URL 会留在服务器日志、浏览器历史和 Referer 头里,敏感信息应该放请求体,不要放 URL。
开放重定向攻击
攻击者用编码过的 URL 绕过校验。重定向参数里塞一个 %2F%2Fevil.com,解码后变成 //evil.com,浏览器按协议相对 URL 处理,直接跳到攻击者的域名。
防御: 校验解码后的 URL,不要拿编码形式做判断。重定向域名走白名单。
双重编码攻击
WAF 扫描 URL 里有没有 <script>。攻击者发 %253Cscript%253E,WAF 看到的是百分号编码文本,放行了。应用解码一次得到 %3Cscript%3E,再解码一次就是 <script>,过滤器被绕过。
防御: 安全检查前先把输入彻底解码(标准化)。只解码一次不够。
Web 安全相关的更多内容可以看 Web 安全要点。
URL 长度限制与编码的代价
HTTP 规范没有规定 URL 最大长度,但栈的每一层都有自己的限制。
| 层级 | 限制 |
|---|---|
| 通用建议 | 2,000 个字符 |
| Chrome、Firefox | 约 2 MB(但服务器远在此之前就会拒绝) |
| Apache(默认) | 8,190 字节 |
| Nginx(默认) | 8,192 字节 |
| IIS | 16,384 字节(查询字符串) |
| CDN、代理 | 各异,通常 4,096–8,192 字节 |
百分号编码让 URL 变长。一个中文字符从 1 个字符膨胀到 9 个(%E4%B8%AD),emoji 膨胀到 12 个。查询字符串里放 200 个中文字符,还没算基础 URL,光编码文本就 1,800 个字符了。
到上限了怎么办: 把数据从查询参数挪到请求体,改用 POST。搜索接口可以用 POST 端点接收 JSON 请求体。
常见问题
什么是 URL 编码?为什么开发者需要它?
URL 编码(百分号编码)把 URL 里不允许直接出现的字符转成 %XX 十六进制序列。URL 只认 66 个非保留 ASCII 字符,空格、&、Unicode 文本和大多数标点都必须编码,否则会破坏 URL 结构或让服务器误解。
encodeURI 和 encodeURIComponent 有什么区别?
encodeURI() 编码整个 URL,保留 ://、/、?、& 等结构字符不动。encodeURIComponent() 除了 A-Z a-z 0-9 - _ . ~ ! ' ( ) * 以外全编码。拼查询参数值用 encodeURIComponent();手里有完整 URL、只想修一下空格或非 ASCII 字符的时候才用 encodeURI()。
为什么 URL 中空格有时显示为 %20,有时显示为 +?
都是空格,但来源不同。%20 是 RFC 3986 的写法,URL 任何位置通用。+ 是 HTML 表单编码的写法,只在查询字符串里有效;路径段里 + 就是字面加号。用 %20 不会错;+ 是 HTML 表单时代留下来的历史约定。
如何在 Python、JavaScript、Go 和 Java 中进行 URL 编码?
JavaScript:encodeURIComponent('hello world') → hello%20world。Python:urllib.parse.quote('hello world') → hello%20world。Go:url.QueryEscape("hello world") → hello+world。Java:URLEncoder.encode("hello world", UTF_8) → hello+world。Go 和 Java 默认使用表单编码(空格为 +)——将 + 替换为 %20 即可得到 RFC 3986 输出。
URL 编码能用于安全防护或加密吗?
不能。URL 编码完全可逆,不需要密钥,谁都能秒解。它没有任何保密能力。敏感数据靠 HTTPS(TLS)加密,不是靠百分号编码。URL 会留在服务器日志、浏览器历史和 Referer 头里,敏感信息应该放请求体。
什么是双重编码?如何修复?
已编码的字符串又被编码了一次。%20 里的 % 变成 %25,产出 %2520,服务器收到的是文字 %20 而不是空格。修法:先解码,再统一编码一次。看到 %25 后面跟两位十六进制数字,基本就是双重编码。
URL 的最大长度是多少?
HTTP 规范没有官方上限。2,000 字符是各端广泛兼容的安全线。Apache 默认 8,190 字节,Nginx 8,192 字节。非 ASCII 字符编码后膨胀 3 到 12 倍,国际化 URL 很容易撞限制。数据量大的话,换 POST 放请求体。