Skip to content
返回博客
教程

URL 编码与解码:百分号编码开发实战指南

拆解 URL 编码原理:RFC 3986 规则、encodeURI 与 encodeURIComponent 的区别、UTF-8 字节映射,附 JS/Python/Go/Java 代码示例与在线工具。

12 分钟

URL 编码与解码:百分号编码开发实战指南

查服务器日志时看到查询字符串里有 %E4%BD%A0%E5%A5%BD,第一反应是数据坏了。其实没有——这只是”你好”两个字各自拆成三个 UTF-8 字节后做了百分号编码。看着像乱码,URL 本身完全正常。

URL 编码,正式叫百分号编码(percent encoding),负责把特殊字符转成 URL 能安全承载的格式。这篇文章从字节层面拆解编码原理,对比 encodeURIencodeURIComponent 的适用场景,给出 JS/Python/Go/Java 四种语言的写法,再整理几个老手也会中招的坑。

将任意 URL 粘贴到我们的 URL 解码器与编码器,边阅读边实时查看编码和解码效果。

什么是 URL 编码(百分号编码)?

URL 里能直接放的 ASCII 字符很有限——字母、数字加一小撮符号。空格、&、中文、emoji 这些统统不行,必须先转换。

百分号编码的做法很简单:把每个不安全字节替换成 % 加两位十六进制数。空格变 %20&%26,名字就来自那个 %

这套规则写在 RFC 3986 里,2005 年发布,现在还是现行标准。它替代了 RFC 2396,把安全字符、保留字符和非 ASCII 文本的处理规则都收紧了。

快速示例:

输入编码结果原因
hello worldhello%20world空格在 URL 中不允许出现
price=10&tax=2price%3D10%26tax%3D2=& 有结构性含义
%E4%B8%AD非 ASCII → UTF-8 字节 → 百分号编码
🚀%F0%9F%9A%80Emoji → 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 字节编码结果大小倍增
AU+004141A(无需编码)
空格U+002020%20
éU+00E9C3 A9%C3%A9
U+4E2DE4 B8 AD%E4%B8%AD
🚀U+1F680F0 9F 9A 80%F0%9F%9A%8012×

用 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 个字符。

encodeURIencodeURIComponent——选择正确的函数

这两个函数名字像,行为差很远,混用是最常见的 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 编码器工具中并排对比两种模式 →

各语言的 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=abcstate=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 语法)%20URL 中的任何位置
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 字节
IIS16,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 放请求体。