← 返回博客首页

URL 编码解码完全指南:百分号编码、查询字符串与常见陷阱

什么是 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 的处理分两步:

  1. 字符 → UTF-8 字节序列
  2. 每个字节 → 百分号编码
字符 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)。例如 中文.comxn--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

最佳实践

  1. 始终使用标准库URLSearchParamsURLurl.Valuesurllib.parse
  2. 区分编码场景:路径用 encodeURI/PathEscape,参数值用 encodeURIComponent/QueryEscape
  3. 统一 UTF-8 编码:前后端都显式指定
  4. 不要编码后再编码:检查框架是否已自动编码
  5. 验证用户输入:防止保留字符注入与路径遍历
  6. 使用 HTTPS:编码后的 URL 在传输层同样需要加密保护
广告

常见问题

空格到底该编码成 %20 还是 +?

取决于它出现在 URL 的哪一段。**路径**里必须是 `%20`;**查询字符串**里两种都能被主流框架正确解析,但语义不同——`%20` 是 RFC 3986 的百分号编码,`+` 来自 `application/x-www-form-urlencoded`(HTML 表单提交的历史格式)。实践规则:用标准库构建查询串(`URLSearchParams`、`urlencode`)它会自动用 `+`;手工拼路径必须用 `%20`。**别在同一段里混用**,也别把 `+` 直接写进路径——服务端会把它当成字面加号。

encodeURIComponent 和 encodeURI 该怎么选?

按「你要编码的是整条 URL 还是一个组件」来选。`encodeURI` 假设输入是完整 URL,因此**不编码** `? # / & = :` 这些结构字符——它适合「把一条非法 URL 里的中文修成合法」。`encodeURIComponent` 假设输入是单个值,会把 `? # / & =` 全部编码——**拼参数值时必须用它**。90% 的编码 bug 来自该用 `encodeURIComponent` 的地方用了 `encodeURI`,导致参数值里的 `&` 被解析成了分隔符。

为什么我把 / 编码成 %2F 之后服务器返回 404?

因为很多 Web 服务器(Apache 默认、部分 Nginx 配置、多数网关)会**先把路径解码再做路由匹配**,解码出来的 `/` 被当成层级分隔符,路径段数就变了。RFC 3986 其实明确允许路径段里出现未编码的 `/` 之外的字符,但 `%2F` 的语义是「这个斜杠是数据不是分隔符」,而服务器未必遵守。三种解法:① 把这种值放进**查询参数**而不是路径;② 用不会撞分隔符的替代编码(Base64URL);③ 显式打开服务器开关(Apache 的 `AllowEncodedSlashes NoDecode`、Nginx 保持 `$request_uri` 不解码)。

重复编码怎么检测和避免?

症状很好认:编码结果里出现 `%25`(`%` 自己被编码了)。成因是「框架已经自动编码过,你又手动调用了一次」。避免方法是**分层约定清楚**:数据层只存原始值,构建层负责编码一次,传输层不再动它。写代码时用 `decodeURIComponent` 解码两次看是否还原成同一字符串,可以快速判断某段文本是否被多编了一层——[URL 编码工具](/url-encoder.html) 会同时显示编解码结果,粘进去一眼就能看出来。

URL 编码能当安全措施用吗?

**不能。** 编码解决的是「字符能不能安全传输」,不是「内容有没有恶意」。编码后的 `<script>` 照样是 `<script>`——服务端解码后原样执行。它能挡住的只有一类问题:用户输入里的分隔符破坏 URL 结构(比如 `?`、`#`、`&` 截断参数、拼出开放重定向)。真正的防护还得靠:输入校验、输出转义(HTML 场景用 [HTML 实体](/html-entities.html))、参数化查询、CSP。把编码当成 XSS 防御是常见误解。

← 返回博客首页