一、结论先给
URL 乱码不是编码函数选错了,而是两端用的字符集或编码次数不一致。定位方法只有一句:看乱码的原始字节是 UTF-8 的还是 GBK 的。
| 现象 | 根因 | 修法 |
|---|---|---|
%E4%B8%AD 解码出"中" |
正常,UTF-8 百分号编码 | 无需处理 |
%D6%D0 解码出"中" |
GBK 编码的中文 | 统一成 UTF-8 |
收到一堆 ?? 或 ä¸ |
字节被按错误字符集解释 | 全链路统一 UTF-8 |
空格变成 + 或被吃掉 |
form 表单编码规则 | query 里用 %20,body 里 + 才代表空格 |
%25E4%25B8%25AD |
双重编码(% 被编成 %25) | 只编码一次 |
判断字符集最快的方法:一个汉字 UTF-8 是 3 字节(%XX%XX%XX),GBK 是 2 字节(%XX%XX)。
👉 在线验证编码结果:URL 编码/解码工具,输入中文立刻看到 UTF-8 与 GBK 的差异。
二、URL 为什么要编码
URL 是一套有语法的结构,? # & = / : 这些字符在里面有特殊含义,不能直接当数据用。同时 URL 只能安全传输 ASCII 可见字符,中文、空格、emoji 都得先转成字节再转义。
RFC 3986 把字符分成三类:
| 类别 | 字符 | 是否编码 |
|---|---|---|
| 未保留字符 | A-Z a-z 0-9 - _ . ~ |
不编码 |
| 保留字符 | : / ? # [ ] @ ! $ & ' ( ) * + , ; = |
有语法含义,作数据时必须编码 |
| 其他 | 中文、空格、{} | \ ^ ` < > " 等 |
必须编码 |
编码规则:取字符的 UTF-8 字节,每个字节写成 % + 两位十六进制大写。
中 → UTF-8 字节 E4 B8 AD → %E4%B8%AD
文 → UTF-8 字节 E6 96 87 → %E6%96%87
空格 → 20 → %20
三、encodeURI 与 encodeURIComponent(最容易搞混的点)
const url = 'https://it997.com/search?q=中文&page=1#top'
encodeURI(url)
// https://it997.com/search?q=%E4%B8%AD%E6%96%87&page=1#top
encodeURIComponent(url)
// https%3A%2F%2Fit997.com%2Fsearch%3Fq%3D%E4%B8%AD%E6%96%87%26page%3D1%23top
| 函数 | 编码范围 | 用途 |
|---|---|---|
encodeURI |
不编码保留字符(/ ? : & = # 等),只编中文和非法字符 |
编整条 URL |
encodeURIComponent |
连 / ? & = # 都编码 |
编参数值(拼进 query 之前) |
escape |
非标准,已废弃 | 不要用 |
正确用法:
// ✅ 对每个参数值单独用 encodeURIComponent,再拼接
const q = 'Java & 云原生'
const url = `https://it997.com/search?q=${encodeURIComponent(q)}&page=1`
// https://it997.com/search?q=Java%20%26%20%E4%BA%91%E5%8E%9F%E7%94%9F&page=1
// ❌ 错误:先拼再编整条,& 被吃掉,参数结构全乱
const bad = encodeURI(`https://it997.com/search?q=${q}&page=1`)
服务端解码是自动的(Servlet 容器、Spring、Express 都会解),你只需要保证编码端做对一次。
四、加号 + 变空格:表单编码的历史包袱
这是最经典的一个坑。HTML 表单 application/x-www-form-urlencoded 规定:空格编码成 +(因为早期 URL 里 + 少见)。而 RFC 3986 的百分号编码规定空格是 %20。
结果就是:
| 场景 | 空格应写成 | 收到 + 时解成 |
|---|---|---|
| Query String(RFC 标准) | %20 |
+ 就是加号本身 |
| 表单 Body(urlencoded) | + 或 %20 |
空格 |
Java/Go/PHP 的坑:不少框架对 query 和 body 用同一套解码逻辑,把 query 里的 + 也解成空格。于是"搜索 C++"变成了"搜索 C "。
规避办法(推荐):在 query 里统一用 %20 而不是 +。
// 把 + 强制换成 %20,规避服务端差异
const safe = encodeURIComponent(q).replace(/%20/g, '%20') // encodeURIComponent 本来就输出 %20
// 真正要注意的是别用 URLSearchParams 的 form 语义
new URLSearchParams({ q: 'C++' }).toString() // q=C%2B%2B ✅ 正确
URLSearchParams 会正确把 + 编成 %2B,可以放心用:
const p = new URLSearchParams({ q: 'C++', tag: '云原生' })
fetch('/api/search?' + p.toString())
五、双重编码:% 被编成了 %25
现象:日志里看到 %25E4%25B8%25AD,解一次得到 %E4%B8%AD(还是字面量),要解两次才是"中"。
成因通常是:前端编码一次,网关/中间件又编码一次,或者把已经编码的 URL 当参数值再拼进另一个 URL。
| 场景 | 例子 | 处理 |
|---|---|---|
回调地址 redirect_uri |
?redirect=https%3A%2F%2Fa.com%2Fcb%3Fq%3D%E4%B8%AD |
正确,这是参数里的 URL,本来就该整体编码一次 |
| 网关二次编码 | 传递时又过了一层 encodeURIComponent |
去掉一层 |
| 前端框架自动编码 + 手动编码 | axios + 自己再 encode 一次 | 只留一处 |
判断是否该保留:参数值本身是一个完整 URL 时,整体编码一次是正确设计(如 OAuth 的 redirect_uri、SSO 的回调),服务端解一次拿到 URL 再用,这时不算 bug。除此之外出现 %25 基本都是多编了一次。
六、服务端解码配置(乱码的真正重灾区)
编码端做对了还是乱码,那问题在服务端用错了字符集解码。
6.1 Tomcat(Spring Boot 内嵌)
URI 的解码字符集由 URIEncoding 决定,Tomcat 8+ 默认是 UTF-8,Tomcat 7 及更早默认是 ISO-8859-1,这是老项目乱码的经典来源。
server:
tomcat:
uri-encoding: UTF-8 # URI 部分(? 之前和 query)的解码字符集
servlet:
encoding:
charset: UTF-8
force: true # 请求体强制 UTF-8
独立 Tomcat 改 server.xml:
<Connector port="8080" protocol="HTTP/1.1"
URIEncoding="UTF-8"
useBodyEncodingForURI="true"
... />
6.2 Nginx 转发
nginx 转发时默认不改动 query,但如果做了 rewrite 或者 $args 重组,一定要确认没有重复编码:
location /api/ {
proxy_pass http://backend$request_uri; # 原样带 query,最安全
# 避免用 proxy_pass http://backend/; 这种会丢 query 的写法
}
如果确实用了 rewrite,加 break 防止二次编码:
rewrite ^/old/(.*)$ /new/$1 break;
6.3 Node / Express
// query 由 Express 自动解码(UTF-8),无需手动
app.get('/search', (req, res) => {
const q = req.query.q // 已是解码后的中文
res.json({ q })
})
// 手动解码时用 decodeURIComponent,且务必 try/catch
function safeDecode(s) {
try { return decodeURIComponent(s) } catch { return s } // 非法 % 序列会抛 URIError
}
decodeURIComponent('%E4%B8%A') 会直接抛 URIError: URI malformed,线上没 catch 就是 500。凡是解码用户输入,一律包 try/catch。
6.4 Python
from urllib.parse import quote, unquote, urlencode
quote('中文') # '%E4%B8%AD%E6%96%87'
quote('中文', encoding='gbk') # '%D6%D0%CE%C4'
unquote('%E4%B8%AD%E6%96%87') # '中文'
urlencode({'q': '中文'}) # 'q=%E4%B8%AD%E6%96%87'
quote 默认 safe='/',也就是斜杠不编码。做签名时如果要求全编码,要显式 quote(s, safe=''),否则签名字符串和对方对不上——这是支付/网关对接里最常见的签名失败原因。
七、Base64 与 URL:为什么 JWT 用的是 Base64URL
标准 Base64 用了 + / = 三个字符,在 URL 里都有风险(+ 变空格、/ 是路径分隔符、= 是参数分隔符)。所以有了 Base64URL 变体:
| 标准 Base64 | Base64URL |
|---|---|
+ |
- |
/ |
_ |
=(padding) |
去掉 |
JWT 的三段(header.payload.signature)全部是 Base64URL,所以你不会在 JWT 里看到 + 或 /。
// 前端构造 Base64URL
const b64url = (s) => btoa(unescape(encodeURIComponent(s)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
👉 想看 JWT 里到底存了什么,直接粘到 JWT 在线解析;Base64 与 URL 编码互转用 Base64 工具 和 URL 编解码。
八、HTML 实体编码是另一回事
常有人把 & 这类 HTML 实体和 URL 编码混为一谈,它们解决的是不同问题:
| 编码 | 防的是什么 | 典型字符 |
|---|---|---|
| URL 编码 | URL 语法冲突、非 ASCII 传输 | 中 → %E4%B8%AD |
| HTML 实体 | XSS、HTML 标签冲突 | < → <,& → & |
| Base64 | 二进制转文本 | Hello → SGVsbG8= |
顺序很重要:把一段文本拼进 HTML 里的链接时,原则是"先 URL 编码,再 HTML 编码":
<!-- 正确:href 里 URL 编码,& 再转成 & 避免 HTML 解析歧义 -->
<a href="/search?q=a%26b">搜索 a&b</a>
👉 HTML 实体编码工具 可以快速处理这块。
九、常见误区
- 用
encodeURI编参数值:&=没被编码,参数结构被破坏。 - 手动拼字符串而不是用
URLSearchParams:漏编码的概率极高。 - 双重编码:框架已经编过一次,自己又编一次,日志里出现
%25。 - 服务端字符集不统一:Tomcat 7 默认 ISO-8859-1,MySQL 连接串没加
useUnicode=true&characterEncoding=utf8。 decodeURIComponent不 try/catch:非法%序列直接 500。- 做签名时用
quote默认值:safe='/'导致斜杠没编码,双方签名串不一致。
十、几条纪律
- 拼 URL 只用
URLSearchParams或encodeURIComponent,不手写字符串拼接。 - 全链路字符集统一 UTF-8:数据库、连接串、服务端配置、前端页面
<meta charset="utf-8">。 - 凡是解码外部输入,一律 try/catch。
- 日志里看到
%25立刻警觉,八成是双重编码。 - 对接第三方签名时,先拿官方示例串验证自己的编码函数,再接业务。
十一、延伸阅读
👉 相关在线工具:URL 编码/解码 · HTML 实体编码 · Base64 · JWT 解析,全部免登录、纯浏览器运行。