经常做前后端联调就知道,回调 URL 里有个参数经常会有中文。测试环境一切正常,上线后部分用户支付成功但回调识别失败。同样的代码,不同用户结果不一样。
排查下来发现是浏览器差异导致的。有些浏览器对 URL 中的中文字符做了编码,有些没做。服务端解码时用的策略也不统一。同一个回调地址,从 Chrome 过来是百分号编码后的格式,从某些低版本浏览器过来是原始 UTF-8 字节流。服务端只处理了其中一种情况。
这就是 URL 编码最烦人的地方——规则本身不复杂,但各方的实现在细节上总有偏差。
为什么叫百分号编码
你在浏览器地址栏输入中文,回车后复制一下链接,看到的是一串 %E4%B8%AD%E6%96%87 这样的东西。百分号后面跟两位十六进制数,所以叫百分号编码,官方名称叫 URL 编码或者百分比编码(percent-encoding)。
它的目标很直接:让 URL 可以安全传输任意字符。URL 最初只支持 ASCII 字母、数字和少数标点符号,但现实中 URL 里会出现中文、日文、空格甚至 emoji。不处理的话请求会失败,或者服务端拿到乱码。
百分号编码就是对不安全字符做替换。每个字符先转成字节序列,每个字节用 %xx 表示。% 后面跟两位十六进制数字,恰好能表示一个字节 (0x00 到 0xFF) 的所有可能值。
举个例子,空格在 ASCII 里是 0x20,URL 编码后是 %20。# 号是 0x23,编码后是 %23。这种映射关系是一一对应的,解码的时候看到 % 就知道后面两位是十六进制数,直接转回字节就行。
三类字符的划分
RFC 3986 把 URL 中的字符分成三类。
第一类叫非保留字符(unreserved)。包括大小写字母、数字和 - . _ ~ 四个符号。这些可以直接出现在 URL 里,不需要编码。你打开一个普通的网址,看到的路径和域名部分基本都是这些字符。
第二类叫保留字符(reserved)。包括 : / ? # [ ] @ ! $ & ' ( ) * + , ; = 。它们在 URL 里有特定的语法含义,不能随意使用。: 分割协议和主机名,? 标记 query string 开始,& 分隔多个参数,# 是锚点标识。这些字符如果作为普通数据出现,必须编码。
保留字符的规定有一个模糊地带:一个字符在某个位置可以按原意使用,在其他位置必须编码。比如 & 在 query string 里是参数分隔符,但在 path 部分没有特殊含义。/ 在 path 里是路径分隔符,但在 query string 的值里出现时不需要编码也可以正常工作。很多服务器会宽容处理这些场景,但严谨的做法是对所有非字母数字字符都编码。
第三类是其他所有字符,包括中文、日文、空格、特殊符号和 emoji。它们必须先转成 UTF-8 字节,再逐字节编码。一个 emoji 可能占 4 个字节,编码后就是 % 加八位十六进制数,看起来很长一串。
我自己的做法是,拿不准的字符一律编码。宁可 URL 长一些,也不要冒解析错误的风险。尤其是处理用户输入的时候,你永远不知道用户会输入什么。
编码分两步走
非 ASCII 字符的编码不是一步到位的。分两步。
第一步,把字符按 UTF-8 编码成字节序列。拿"测"字举例,Unicode 码点是 U+6D4B,UTF-8 编码后是三个字节:E6 B5 8B。如果按 GBK 编码,同一个字是 B2 E2,只有两个字节。
第二步,每个字节转成十六进制,前面加 %。E6 变成 %E6,B5 变成 %B5,8B 变成 %8B。
所以"测"在 URL 里就是 %E6%B5%8B。如果按 GBK 编码那就是 %B2%E2。两种结果完全不同。
这个流程有一个关键点:第一步用的字符编码必须是 UTF-8。如果是 GBK 编码,"测"字只有 2 个字节,编码结果是 %B2%E2。同样的字符,两套编码结果完全不同。
现代浏览器默认用 UTF-8 编码 URL。但老系统的页面编码如果是 GBK,浏览器可能会以 GBK 编码 URL 中的非 ASCII 字符。这就是乱码最常见的来源。同一段中文,一套系统编成 %E6%B5%8B,另一套编成 %B2%E2,后端如果按 UTF-8 解码,后者就会出乱码。
帮朋友排查表单问题时,用户在页面输入中文后提交,后端收到的是乱码。检查页面发现 <meta charset="gbk">,浏览器以 GBK 编码 URL 中的中文。后端的 Nginx 默认按 UTF-8 解码,两边对不上。页面编码改成 UTF-8 后问题解决。这种问题说起来简单,但如果不知道 URL 编码有"先确定字符集再编码"这个前置步骤,排查方向很容易走偏。
两个 API 不要用混
JavaScript 提供了两个编码方法:
encodeURIComponent(str); // 编码整个 URI 组件
encodeURI(str); // 编码 URI 但保留结构字符
encodeURI 会放过 &、=、?、# 不编码,因为它假设你在编码一个完整的 URI。encodeURIComponent 则会把所有这些保留字符也编码掉,因为它假设你编码的是 URI 中某个组件的值。
如果你在拼接 query 参数,永远用 encodeURIComponent。我看到过太多这样的低级错误:
// 错 — searchTerm 里如果有 & 号,URL 结构就坏了
const url = "https://api.example.com/search?q=" + encodeURI(searchTerm);
// 对
const url =
"https://api.example.com/search?q=" + encodeURIComponent(searchTerm);
假设 searchTerm 的值是 a&b,用 encodeURI 编码后是 a&b(保留字符原样不变),生成的 URL 会变成 https://api.example.com/search?q=a&b。服务端解析时会认为有两个参数:q=a 和 b=。用 encodeURIComponent 编码后得到 a%26b,服务端能正确解析出 q=a&b。
encodeURIComponent 做了三件事:把字符串转成 UTF-8 字节序列,每个字节转成 %xx,对保留字符也做同样的处理。自己实现一遍很容易漏掉边缘情况,尤其是遇到 emoji 或零宽空格的时候。不要自己写 URL 编码函数,直接用浏览器提供的 API。
还有个方法叫 URLSearchParams,用来处理 query string 的拼接和解析。这个 API 能自动处理编码问题:
const params = new URLSearchParams();
params.set("q", "hello world & more");
params.set("lang", "zh-CN");
console.log(params.toString()); // q=hello+world+%26+more&lang=zh-CN
注意这里空格被编码成了 + 而不是 %20,这是符合 application/x-www-form-urlencoded 规范的。
空格的历史遗留问题
URL 中的空格可以编码成 %20。但 HTML 表单提交时用的 application/x-www-form-urlencoded 格式里,空格被编码为 +。
原因很简单:早期 URL 规范把空格直接映射为 + 字符,后来改成了 %20。但表单格式保留了 + 的传统以保证向后兼容。
这就造成了一个麻烦:decodeURIComponent('hello+world') 返回的是 hello+world(加号原样保留)。大多数后端框架的 URL 解码函数却会把 + 转成空格。如果你在浏览器端解码从后端返回的 URL 数据,需要手动处理:
function decodeQueryParam(value) {
return decodeURIComponent(value.replace(/\+/g, " "));
}
之前写 Node.js 接口时,前端传的搜索关键词包含空格。encodeURIComponent 把空格编成了 +,我用 url.parse 解析后拿到的仍然是 +,直接拿去数据库做 LIKE 查询,匹配不到任何数据。排查了半个小时才发现是这个差异导致的。
这个问题的根源在于 JavaScript 的 decodeURIComponent 不做 + 到空格的转换,而 Node.js 的某些 URL 解析函数(比如 url.parse)也不做。但 Express 之类的框架在解析 query 参数时又会把 + 转成空格。行为不一致导致在不同层级的代码里表现不同,调试起来特别绕。
浏览器和服务器谁说了算
不同浏览器对非 ASCII 字符的处理方式有细微差异。
Chrome 和 Firefox 在 AJAX 请求中会自动编码非 ASCII 字符,行为基本一致。早期版本的 Safari 在某些情况下会发送未经编码的原始 UTF-8 字节。如果服务器端没有做容错处理,解析就会出错。
服务器端的差异更大。Nginx 对 URL 非常严格,遇到非法字节序列直接返回 400 Bad Request。Apache 相对宽容,但解码行为取决于 AddDefaultCharset 配置。Tomcat 有个著名的陷阱——URIEncoding 默认值是 ISO-8859-1,不是 UTF-8。Java 应用部署在 Tomcat 上,query 参数中的中文几乎必乱。必须手动设置 URIEncoding="UTF-8"。你如果发现老项目在 Tomcat 上跑得好好的但新项目的中文参数出了问题,首先检查的就是这个配置项。
之前排查问题就花了好长时间,Node.js 后端收到的 query 参数中,中文显示为 %C3%A4%C2%B8%C2%AD 的序列。这不是正常的 UTF-8 编码结果。正常的 UTF-8 编码的"中"字应该是 %E4%B8%AD,三字节。而我看到的是九字节,明显有问题。
仔细排查后发现是双重编码:前端 encodeURIComponent 编码了一次("中" → %E4%B8%AD),中间的 Nginx 反向代理又把 % 号本身当作百分比字符处理了——它把百分号编码再次编码成了 %25,于是 %E4%B8%AD 变成了 %25E4%25B8%25AD。后端解一次码只解掉了一层,拿到的是 %E4%B8%AD 而不是原始中文。这也让我学到,加中间件时要搞清楚它会不会动你的 URL。
编码流程
把整个过程画出来就是这样:
从原始字符串到最终的百分号编码,中间经过字符编码选择和逐字节转换两个环节。任何一个环节配置不对,结果都是错的。流程图里可以看到,同样的输入"中文",用 UTF-8 编码和 GBK 编码走下来的路径不同,最终输出的百分号序列也不同。服务端如果按 UTF-8 解码却收到了 GBK 编码的结果,就会出乱码。
各语言的处理实践
不同编程语言处理 URL 编码的方式略有不同,但原理相通。
Python 3 的 urllib.parse.quote() 默认用 UTF-8 编码,空字符串编码成 %20,保留字符默认不编码。如果你要安全编码 query 参数,需要设置 safe='':
from urllib.parse import quote, urlencode
print(quote("测试", safe='')) # %E6%B5%8B%E8%AF%95
print(urlencode({'q': '测试'})) # q=%E6%B5%8B%E8%AF%95
PHP 的 urlencode() 会把空格编码成 +,而 rawurlencode() 编码成 %20。前者是表单格式,后者更接近 RFC 3986 规范。PHP 的 $_GET 在解析 query 参数时会自动把 + 转成空格。如果直接在代码里调用 urldecode() 处理单个参数要注意这一点,因为 urldecode 和 rawurldecode 的行为不同。
Java 的 URLEncoder.encode() 也把空格编码成 +。这个方法是按照 application/x-www-form-urlencoded 规范来的,不是严格意义上的百分号编码。如果要严格的百分号编码,得用其他库。
不同语言之间的行为差异,导致同一个 URL 在不同技术栈中解析结果可能不同。跨语言调试时先确认双方用的编码函数是否正确。
用工具验证最靠谱
调试 URL 编码问题时,在线验证是最快的方法。输入原始字符串看编码结果,或者在编码和解码之间来回切换,确认有没有双重编码。支付回调那段时间我在一个在线工具上验证了不下几十次,反复对比不同浏览器发出的请求内容,最终才定位到 Nginx 的双重编码问题。
之前就经常用 URL 编解码工具 来解决这类问题,支持百分号编码和 + 号编码的互相转换。把一个可疑的字符串粘贴进去,点一下解码看结果是不是你期望的,不行就换个编码方式再试。反复做几次对比,很快能看出是哪一步出了问题。
写在最后
URL 编码不是天天要写的东西,但出了问题特别难受。表面上看到的是乱码,背后可能是字符集配置、浏览器版本、服务器类型、中间件行为的组合问题。上次花了一天排查双重编码,下次可能遇到的是 + 号没转成空格,再下次可能是 Tomcat 的 URIEncoding 没设对。每个坑都不一样,但根源都是同一个——字符流经过的每个环节都要确认编码方式一致。
这一点需要注意,非 ASCII 字符先转 UTF-8 再编码,拼接 query 参数用 encodeURIComponent,注意 + 和 %20 不是一回事,找个靠谱的工具随时验证。