什么是 URL 编码?
URL 编码(又称百分号编码,Percent-Encoding)是将 URL 中不允许直接出现的字符转换为 % 后跟两位十六进制数的过程。它依据 RFC 3986 标准,确保 URL 在不同系统、不同网络协议间安全传输。
URL 只允许使用 ASCII 字符集的一个子集:
- 未保留字符:
A-Z a-z 0-9 - _ . ~ - 保留字符:
: / ? # [ ] @ ! $ & ' ( ) * + , ; = - 其他所有字符(包括中文、空格、特殊符号)都必须进行编码
先建立一张地图,后面所有规则都是在回答「某个字符在 URL 的这一段能不能原样出现」:
https://api.example.com:443/v1/search?q=caf%C3%A9&page=2#results
└─┬──┘ └──────┬──────┘└┬┘└────┬────┘ └────┬────┘ └───┬───┘
scheme host port path query fragment
保留字符与未保留字符
未保留字符(Unreserved)
| 字符 | 说明 |
|---|---|
A-Z a-z 0-9 |
字母和数字 |
- _ . ~ |
连字符、下划线、点号、波浪号 |
未保留字符在任何位置都可以原样出现,编码了也不算错(解码后等价),只是白白变长。
保留字符(Reserved)
通用分隔符(划分 URL 各大块):: / ? # [ ] @
子分隔符(在块内细分):! $ & ' ( ) * + , ; =
关键认知:保留字符不是「必须编码」,而是「在该出现结构的地方不能编码,在当数据的地方必须编码」。 同一个
/在路径里是层级分隔符,出现在查询值里就得变成%2F。
各段允许什么:一张对照表
这是全文最实用的一张表——同一个字符在不同段的规则是不同的:
| 字符 | 路径 path | 查询 query | 片段 fragment | 说明 |
|---|---|---|---|---|
字母数字 - _ . ~ |
✅ 原样 | ✅ 原样 | ✅ 原样 | 未保留 |
/ |
⚠️ 分隔符 | ✅ 允许(无特殊义) | ✅ 允许 | 当数据时编 %2F |
? |
⚠️ 起查询串 | ✅ 允许 | ✅ 允许 | 当数据时编 %3F |
# |
❌ 起片段 | ❌ 起片段 | ❌ 起片段 | 任何位置当数据都编 %23 |
& = |
✅ 允许 | ⚠️ 分隔符 | ✅ 允许 | 查询值里编 %26 %3D |
+ |
⚠️ 字面加号 | ⚠️ 表单里=空格 | ✅ 字面加号 | 见 FAQ |
% |
❌ 编码前缀 | ❌ 编码前缀 | ❌ 编码前缀 | 当字面量必须编 %25 |
| 空格 | %20 |
%20 或 + |
%20 |
路径里只能 %20 |
| 非 ASCII(中文、emoji) | UTF-8 后逐字节编码 | 同左 | 同左 | 见「国际化」 |
注意 # 的特殊性:它在整条 URL 里的第一个出现位置就切分出 fragment,之后全部属于 fragment 内容。所以查询值里如果有 #,不编码就会把后面的内容全丢给前端。
查询字符串编码
查询字符串是 URL 中 ? 之后的部分,由 & 分隔的键值对组成。
三种编码风格
| 场景 | 空格 | 标准 | 说明 |
|---|---|---|---|
| 百分号编码(RFC 3986) | %20 |
RFC 3986 | 通用、语义清晰 |
application/x-www-form-urlencoded |
+ |
HTML 表单 | 浏览器表单提交与大多数 HTTP 客户端默认 |
| JSON-in-query | %20 |
无标准 | 把 JSON 串整体编码后塞进一个参数,调试困难,不推荐 |
URLSearchParams、Python 的 urlencode、Go 的 url.Values.Encode() 走的是第二种(空格 → +);手工拼路径走第一种。两者在同一个 URL 里可以并存(路径用 %20、查询用 +),但同一段内不要混。
数组与嵌套参数的三种写法
| 写法 | 示例 | 支持框架 |
|---|---|---|
| 重复键 | tag=a&tag=b |
几乎所有(PHP 需 tag[]) |
| 方括号 | tag[]=a&tag[]=b |
PHP、Rails、Express(qs) |
| 点号路径 | user.name=Tom |
Java Spring、部分 Go 框架 |
没有标准,只有约定。跨语言对接时必须显式约定,否则后端会拿到字符串而不是数组。
各语言构建方式
JavaScript:
const params = new URLSearchParams({ name: '张三 & 李四' });
const url = `/api?${params.toString()}`;
// /api?name=%E5%BC%A0%E4%B8%89+%26+%E6%9D%8E%E5%9B%9B
Python:
from urllib.parse import urlencode, quote
query = urlencode({'name': '张三 & 李四'}) # 查询串:空格→+
path = quote('张三 & 李四', safe='') # 路径段:空格→%20
Go:
import "net/url"
v := url.Values{}
v.Set("name", "张三 & 李四")
query := v.Encode() // 查询串
seg := url.PathEscape("张三 & 李四") // 路径段
永远不要手动拼接查询字符串,始终使用标准库的编码函数。
国际化 URL
非 ASCII 编码
URL 标准要求仅使用 ASCII。对中文、日文、韩文、emoji 的处理分两步:
- 字符 → UTF-8 字节序列
- 每个字节 → 百分号编码
| 字符 | UTF-8 字节 | 编码结果 |
|---|---|---|
中 |
E4 B8 AD |
%E4%B8%AD |
é |
C3 A9 |
%C3%A9 |
€ |
E2 82 AC |
%E2%82%AC |
| 😀 | F0 9F 98 80 |
%F0%9F%98%80 |
注意 emoji 是 4 字节——一个 emoji 会变成 12 个字符,长度预算要留够(URL 总长的实际限制约 2000 字符,见下文)。
Punycode 与国际化域名(IDN)
域名部分用 Punycode(RFC 3492)。例如 中文.com → xn--fiq228c.com。浏览器地址栏显示原始字符,实际请求用 Punycode。
⚠️ 同形异义字攻击(Homograph Attack):西里尔字母的 а 与拉丁 a 肉眼难辨,攻击者可注册 аpple.com(真 Cyrillic)冒充 apple.com。防御:对用户展示域名时转成 Punycode,或做字符白名单校验。
路径中的 Unicode
路径段用百分号编码。现代浏览器和 HTTP 客户端自动处理,但服务端日志里看到的是编码形式——排查问题时别被日志误导。
Base64 与 URL 编码的关系
这是两个常被混淆的概念:
| 用途 | 字符集 | 典型场景 | |
|---|---|---|---|
| URL 编码 | 让任意字符安全出现在 URL 里 | % + 两位十六进制 |
查询参数、路径段 |
| Base64 | 让二进制安全出现在文本里 | A-Za-z0-9+/= |
邮件、内嵌图片 |
| Base64URL | 让 Base64 安全出现在 URL 里 | A-Za-z0-9-_ |
JWT、OAuth state、文件下载令牌 |
标准 Base64 的 + / = 在 URL 里都有歧义(+ 会被当空格),所以 RFC 4648 定义了 Base64URL:用 - 替代 +、_ 替代 /、去掉填充 =。JWT 的三段用的就是 Base64URL——这也是为什么 JWT 能直接放进 URL 而不用再编码一层。
做转换时可以用 URL 编码工具 与 Base64 转换器 对照验证;生成 URL 友好标识时用 slug 生成工具;要拆解一条完整 URL 看各段,用 URL 解析器。
常见编码 Bug
1. 重复编码
原始: 张三
一次编码:%E5%BC%A0%E4%B8%89
二次编码:%25E5%25BC%25A0%25E4%25B8%2589 ❌
原因:框架自动编码后,又手动调用了一次 encodeURIComponent。识别特征:出现 %25。
2. 编解码不一致
前端用 encodeURI,后端用非 UTF-8 字符集解码 → 乱码。解决:全链路约定 UTF-8,并在解码时显式指定。
3. 空格处理差异
路径用 %20、表单查询串用 +。混用会导致服务端把 + 当字面加号,或把 %20 当三个字符。
4. 保留字符未编码
把用户输入直接拼进 URL 路径。若输入含 ?、#、/,会破坏 URL 结构;拼进重定向地址时更可能形成开放重定向漏洞(?next=https://evil.com)。
5. Hash 片段不发送到服务端
# 之后的内容不会出现在 HTTP 请求里。把令牌、回调参数放在 hash 中并不安全——它只是不在服务端日志里,前端 JS 完全可读,任何 XSS 都能拿走。
6. 编码顺序错误(新增)
先拼后编 vs 先编后拼,结果完全不同:
// ❌ 先拼后编:分隔符一起被编码,服务端解析不出两个参数
encodeURIComponent('a=1&b=2') // a%3D1%26b%3D2
// ✅ 先编值,再用分隔符拼装
`?a=${encodeURIComponent('1')}&b=${encodeURIComponent('2')}`
7. 大小写与归一化(新增)
百分号编码的十六进制不区分大小写(%2F ≡ %2f),但路径的其余部分大小写敏感(/API ≠ /api)。做签名、缓存键、去重之前,必须先把 URL 归一化:解码一次 → 统一 scheme/host 小写 → 按字母序排查询参数 → 去掉默认端口。否则同样的资源会产生多个缓存副本或签名校验失败。
8. 长度超限(新增)
规范没有定义 URL 最大长度,但现实约束是:
| 环节 | 上限 |
|---|---|
| IE(历史) | 2,083 字符 |
| 多数 CDN / 网关 | 8,192 字符 |
Nginx 默认 large_client_header_buffers |
8 KB(超出返回 414) |
| 实用建议 | 查询串控制在 2,000 字符内 |
GET 请求带大量筛选条件时容易撞线,改用 POST + body。
encodeURI vs encodeURIComponent
| 函数 | 用途 | 不编码的字符 |
|---|---|---|
encodeURI |
编码完整 URL | A-Za-z0-9 ; , / ? : @ & = + $ - _ . ! ~ * ' ( ) # |
encodeURIComponent |
编码 URL 组件(参数值) | A-Za-z0-9 - _ . ! ~ * ' ( ) |
new URL() |
现代方案:解析 + 归一化 | 自动处理各段 |
// 编码完整 URL(只修非法字符,保留结构)
encodeURI('https://example.com/路径?name=张三')
// https://example.com/%E8%B7%AF%E5%BE%84?name=%E5%BC%A0%E4%B8%89
// 编码参数值(连分隔符一起编)
encodeURIComponent('name=张三&p=1')
// name%3D%E5%BC%A0%E4%B8%89%26p%3D1
⚠️ encodeURIComponent 会放过 ! ~ * ' ( )——这几个在 RFC 3986 里属于子分隔符。日常几乎无影响,但如果后端做严格签名校验(如 AWS SigV4、OAuth 1.0),需要按规范再补一层替换:
const rfc3986 = (s) => encodeURIComponent(s)
.replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
服务端解码时机
| 环节 | 是否解码 | 说明 |
|---|---|---|
| Nginx / Apache 路由匹配 | ⚠️ 通常先解码 | %2F 变回 / 会改变路径段数 → 404 |
| 框架路由(Express/Spring/Rails) | ✅ 解码 path 段 | 但不解码 %2F 是常见默认 |
| 查询参数解析 | ✅ 自动解码 | + 与 %20 都还原为空格 |
| 请求体(body) | 视 Content-Type | 表单自动解;JSON 不解 |
排查技巧:遇到「路由 404 但 URL 看起来对」,先把 %2F、%5C(反斜杠)、%2E%2E(..)这几个查一遍——它们正是路径遍历与路由劫持常用的编码值,很多 WAF 和网关会直接拦掉。相关状态码见 HTTP 状态码速查。
检查清单
- [ ] 参数值用
encodeURIComponent(组件级),整条 URL 才用encodeURI - [ ] 路径里的空格用
%20,查询串交给标准库 - [ ] 同一段内不混用
+与%20 - [ ] 用户输入拼进路径前先编码,且校验是否含
..、%2F - [ ] 签名/缓存前先归一化 URL(解码一次 + 小写 + 参数排序)
- [ ] 全链路 UTF-8,解码时显式指定字符集
- [ ] 查询串控制在 2,000 字符内,超了改 POST
- [ ] 不做重定向 URL 的直接拼接(开放重定向)
- [ ] 敏感参数不放在 fragment(前端完全可读)
- [ ] 确认没有重复编码(搜
%25)
最佳实践
- 始终使用标准库:
URLSearchParams、URL、url.Values、urllib.parse - 区分编码场景:路径用
encodeURI/PathEscape,参数值用encodeURIComponent/QueryEscape - 统一 UTF-8 编码:前后端都显式指定
- 不要编码后再编码:检查框架是否已自动编码
- 验证用户输入:防止保留字符注入与路径遍历
- 使用 HTTPS:编码后的 URL 在传输层同样需要加密保护