URL 编码与中文参数乱码:为什么 %E4%B8%AD 变成了一堆问号

一、结论先给

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 实体编码是另一回事

常有人把 &amp; 这类 HTML 实体和 URL 编码混为一谈,它们解决的是不同问题:

编码 防的是什么 典型字符
URL 编码 URL 语法冲突、非 ASCII 传输 中 → %E4%B8%AD
HTML 实体 XSS、HTML 标签冲突 < → &lt;,& → &amp;
Base64 二进制转文本 Hello → SGVsbG8=

顺序很重要:把一段文本拼进 HTML 里的链接时,原则是"先 URL 编码,再 HTML 编码":

<!-- 正确:href 里 URL 编码,& 再转成 &amp; 避免 HTML 解析歧义 -->
<a href="/search?q=a%26b">搜索 a&b</a>

👉 HTML 实体编码工具 可以快速处理这块。

九、常见误区

  1. 用 encodeURI 编参数值:& = 没被编码,参数结构被破坏。
  2. 手动拼字符串而不是用 URLSearchParams:漏编码的概率极高。
  3. 双重编码:框架已经编过一次,自己又编一次,日志里出现 %25。
  4. 服务端字符集不统一:Tomcat 7 默认 ISO-8859-1,MySQL 连接串没加 useUnicode=true&characterEncoding=utf8。
  5. decodeURIComponent 不 try/catch:非法 % 序列直接 500。
  6. 做签名时用 quote 默认值:safe='/' 导致斜杠没编码,双方签名串不一致。

十、几条纪律

  1. 拼 URL 只用 URLSearchParams 或 encodeURIComponent,不手写字符串拼接。
  2. 全链路字符集统一 UTF-8:数据库、连接串、服务端配置、前端页面 <meta charset="utf-8">。
  3. 凡是解码外部输入,一律 try/catch。
  4. 日志里看到 %25 立刻警觉,八成是双重编码。
  5. 对接第三方签名时,先拿官方示例串验证自己的编码函数,再接业务。

十一、延伸阅读

👉 相关在线工具:URL 编码/解码 · HTML 实体编码 · Base64 · JWT 解析,全部免登录、纯浏览器运行。

还有 65 个免费在线工具

纯前端实现,不用注册,数据不上传服务器。

浏览全部工具