之前做一个搜索功能时,前端传关键词到后端 API。用户在搜索框输入中文,比如"北京天气",我在前端拼 URL 的时候直接这么干了:
const url = `/api/search?q=${
inputValue}`;
fetch(url).then(...)
测试的时候发现,英文关键词正常,但中文关键词后端收到的全是乱码,或者干脆报 400 Bad Request。我当时的第一反应是"后端没做 UTF-8 解码",跑去问后端的同事,人家说接口一切正常,是前端 URL 没编码。
当时还觉得一定是后端问题,浏览器地址栏直接输入中文也能访问啊,怎么到了 fetch 就不行了?
后来搞清楚才知道,浏览器地址栏和 JavaScript fetch 是两套逻辑。地址栏有浏览器帮你做编码处理,但 JavaScript 不会帮你自动编码。你需要手动调用 encodeURIComponent,后来尝试直接用 encodeURI:
const url = `/api/search?q=${
encodeURI(inputValue)}`;
结果中文是不乱了,调试了半天才发现 encodeURI 和 encodeURIComponent 确实有些不一样的地方。
这是个很典型的"我以为我知道"的坑。URL 编码看起来简单,其实有些细节还是很值得注意,所以准备将URL 编码从头拆一遍,从字符分类到编码规则,再到实际开发中的各种陷阱。
ASCII 的限制,URL 为什么排斥中文
URL 的设计可以追溯到 1990 年代初期。那时候的互联网基本是英文世界,URL 规范(RFC 1738)明确规定 URL 只能包含 ASCII 字符集的子集。ASCII 只有 128 个字符,其中可打印字符就更少了。
中文、日文、阿拉伯文、表情符号——统统不在 ASCII 范围内。如果你把未经编码的中文字符直接放进 URL,不同浏览器和服务器会有不同的处理方式。有些直接报错,有些用本地编码(比如 GB2312)去解码,有些默默丢弃。
统一资源定位符(URL)本质上是一个定位系统,它的职责是指向资源,而不是传输任意数据。但现实是用户需要搜索中文、需要传带特殊符号的参数、需要 URL 里包含非英文字符。于是就有了百分号编码(Percent-Encoding),也叫 URL 编码。
核心思想很直接:把不安全的字符替换成 % 后面跟两位十六进制数。比如空格变成 %20,中文"你"在 UTF-8 编码下变成 %E4%BD%A0。
但问题来了——哪些字符算"不安全"?这个定义在不同的规范版本里有过变化,而且在浏览器实现中也不完全一致。这事一直没有一个"一刀切"的答案,因为 URL 的不同部分对字符的要求不一样。
字符分类,RFC 3986 怎么说
目前最权威的定义来自 RFC 3986(URI 通用语法)。它把字符分成几类。
非保留字符(Unreserved Characters):
这些字符在 URL 中可以直接使用,不需要编码:
A-Z a-z 0-9 - . _ ~
字母、数字以及短横线、点、下划线、波浪线。看到编码结果里出现 %7E 代替 ~,基本可以确定是编码器实现有问题—— ~ 不应该被编码。
保留字符(Reserved Characters):
这些字符在 URL 语法中有特殊含义,如果在数据中出现了它们,就需要编码。
通用分隔符(Gen-Delims):
: / ? # [ ] @
子分隔符(Sub-Delims):
! $ & ' ( ) * + , ; =
举个例子,= 在 URL 中用来分隔查询参数的键和值(q=keyword),如果你要传的数据本身包含 =,就得编码成 %3D,否则解析 URL 的时候会产生歧义。
& 也是同理。你传的参数值里如果包含 & 字符,必须编码成 %26,否则 URL 解析器会把它当成参数分隔符。
这其实涉及一个基本原则:当数据字符和语法字符冲突时,数据字符必须让路。 百分号编码就是把数据字符"伪装"成语法安全的格式。
不安全字符(Unsafe Characters):
除了上面两类,剩下的 ASCII 字符和非 ASCII 字符都属于不安全字符,必须编码:
空格
控制字符(0x00-0x1F, 0x7F)
< > " % { } | \ ^ `
以及所有非 ASCII 字符(中文、日文、特殊符号等)
非 ASCII 字符的处理方式比较特殊:先用 UTF-8 编码成字节序列,再对每个字节做百分号编码。这是目前浏览器和服务器的通行做法。RFC 3986 并没有强制要求 UTF-8——理论上页面可以用其他编码来解码 URL 中的百分号序列。实际上大部分现代实现都统一用 UTF-8 了,但我在一个遗留系统里遇到过用 GBK 编码 URL 参数的情况,后面会详细说。

百分号编码的机械操作
百分号编码本身的规则很简单。
假设要对"你"字做编码:
第一步:找到"你"在 UTF-8 下的字节序列。U+4F60 在 UTF-8 中对应三个字节:E4 BD A0。
这个转换过程是:U+4F60 属于 U+0800 到 U+FFFF 范围,UTF-8 编码为 1110xxxx 10xxxxxx 10xxxxxx 格式。把 0x4F60 的二进制填入就得到 11100100 10111101 10100000,也就是 E4 BD A0。
第二步:每个字节前面加百分号:%E4%BD%A0。
把完整例子再拆一下。假设对字符串 q=你好 做 encodeURIComponent:
q → 0x71 → q(非保留字符,不编码)
= → 0x3D → %3D(保留字符,编码)
你 → UTF-8:E4 BD A0 → %E4%BD%A0(非 ASCII,编码)
好 → UTF-8:E5 A5 BD → %E5%A5%BD(非 ASCII,编码)
结果:q%3D%E4%BD%A0%E5%A5%BD
解码反过来:连续遇到 % 开头的三位字符,就提取十六进制数,拼成字节序列,再用 UTF-8 解码成原始字符。
需要注意的是,百分号编码不要求把所有字符都转为 %xx。非保留字符原样保留,只有不安全字符才编码。这也是为什么编码后的 URL 还保留了一部分可读性。
encodeURI 与 encodeURIComponent 的差异
这是开发中最高频的踩坑点。
encodeURI("https://example.com/search?q=hello world");
// "https://example.com/search?q=hello%20world"
encodeURIComponent("https://example.com/search?q=hello world");
// "https%3A%2F%2Fexample.com%2Fsearch%3Fq%3Dhello%20world"
差别非常明显:
encodeURI 的意图是编码整个 URI,它会保留 URI 语法结构需要的字符。: / ? # @ & = + $ , ; 这些在 URI 中有语法作用的字符不会被编码。
encodeURIComponent 的意图是编码 URI 的某个组成部分,它假定你传入的字符串纯粹是数据而非 URI 结构,所以连 : / ? 这些结构字符也一起编码。
具体的编码范围差异:
| 字符类别 | encodeURI | encodeURIComponent |
|---|---|---|
| 字母、数字、- _ . ! ~ * ' ( ) | 不编码 | 不编码 |
| : / ? # [ ] @ | 不编码 | 编码 |
| & = + $ , ; | 不编码 | 编码 |
| 空格、中文等非 ASCII | 编码 | 编码 |
什么时候该用哪个?
如果你在拼一个完整 URL,用 encodeURI。如果你在拼查询参数的值、路径段或者片段标识,用 encodeURIComponent。
一个常见的场景——拼查询字符串:
const params = {
q: "hello world & more",
lang: "zh-CN",
};
// 正确做法:参数值需要 encodeURIComponent
const query = Object.entries(params)
.map(([k, v]) => `${
k}=${
encodeURIComponent(v)}`)
.join("&");
const url = `/search?${
query}`;
// /search?q=hello%20world%20%26%20more&lang=zh-CN
更简洁的方式是用 URLSearchParams:
const sp = new URLSearchParams();
sp.set("q", "hello world & more");
sp.set("lang", "zh-CN");
sp.toString(); // q=hello+world+%26+more&lang=zh-CN
有一个细节:URLSearchParams 对空格的处理是 + 而不是 %20。这是 HTML 表单编码(application/x-www-form-urlencoded)的遗留习惯。在表单编码规范里空格用 + 表示,但纯 URL 百分号编码(RFC 3986)里空格应该是 %20。好在大多数服务端两种都接受。
不同浏览器的地址栏行为
这个问题的排查花了我不少时间。浏览器地址栏的行为和 JavaScript 的 encodeURI 不完全一致。
Chrome 地址栏输入中文搜索,Chrome 会把中文字符编码成 UTF-8 百分号编码。但复制地址栏 URL 时,Chrome 有时显示"解码后"的版本(用中文字符显示),有时又显示编码后的版本。实际上 Chrome 地址栏显示的 URL 和真正通过网络请求发出的 URL 不完全一致——地址栏做了"统一编辑"展示,把百分号编码解码回原始字符方便阅读,但请求头里仍然是编码后的版本。
Firefox 行为类似,但在处理某些保留字符时编码策略和 Chrome 略有不同。具体差在哪我不太确定,因为没做过系统性的对比测试。
Safari 对非 ASCII 字符的处理相对激进。某些版本会把 URL 路径中的中文直接保留不编码就发送出去,尽管这不符合 RFC 规范。
不同浏览器对这个问题态度不一致,导致开发时容易困惑——你在地址栏输入可以访问,但换成 fetch 或者 curl 可能就不行。一个经验是:不要依赖浏览器地址栏来验证 URL 编码是否正确。 用开发者工具的网络面板看实际发出的请求,那里的 URL 才是真正在网络上传输的版本。
双重编码,一个鲜活的 Debug 过程
继续说开头那个故事。
我发现直接用字符串拼接中文到 URL 不行,就加上了 encodeURIComponent。但当时的代码里,后端框架已经做了一次 URL 解码,前端又在传参前自己编码了一次,结果后端收到的数据看起来是编码后的编码。
流程是这样的:
前端传: "北京天气"
↓ encodeURIComponent
编码后: "%E5%8C%97%E4%BA%AC%E5%A4%A9%E6%B0%94"
↓ 再次误编码 encodeURIComponent
双重: "%25E5%258C%2597%25E4%25BA%25AC%25E5%25A4%25A9%25E6%25B0%2594"
↓ 服务器解码一次
得到: "%E5%8C%97%E4%BA%AC%E5%A4%A9%E6%B0%94"(看起来像编码结果,不是"北京天气")
这种问题排查起来特别恶心。你在浏览器控制台打印日志,看到的是正常的中文,因为浏览器帮你做了一次解码。但在网络层面,数据流里确实是双重编码的。
我是这么排查的,把前端最终请求的 URL 复制出来,用 Node.js 直接发送请求,逐层打印解码过程,才发现多了一层编码。
双重编码的本质是编码和解码的层数不匹配。前端传了一次编码,后端自动解了一次码,数据正好还原。但如果前端编码了两次,后端只解码了一次,数据就是编码到一半的状态。
解决方式就是确认每层只做一次编码/解码,不重复处理。在我的例子中,后端框架已经自动对 URL 参数做了解码,前端就不需要在传参前再自己编码。或者确认框架不做自动解码,前端统一编码。
解码时的其他陷阱
除了双重编码,解码环节也有几个坑要注意。
不完整的百分号序列。 URL 里的 % 后面应该跟两位十六进制数。如果遇到 % 后面不是有效的十六进制,比如 %XY 或者单独一个 % 结尾,不同的解析器处理方式不同。有些直接抛异常,有些把 % 当作文本原样保留。浏览器通常比较宽容,会尽量恢复原样。但如果你在 Node.js 里手动写 URL 解析逻辑,需要考虑这个边界情况。
UTF-8 和 Latin-1 的混淆。 早期的 URL 规范没有规定非 ASCII 字符用什么编码,有些旧系统用 Latin-1 或系统默认编码。很早之前维护一个旧项目时遇到过这种情况,某个 URL 是十年前的,解码后得到乱码,查了好久才发现那个 URL 是用 GB2312 编码中文的。用 GB2312 而不是 UTF-8 去解码百分号序列后恢复正常。这种问题在现代 Web 开发中很少见了,但如果接触遗留系统,需要留个心眼。
+ 号 vs %20。 application/x-www-form-urlencoded 格式里空格用 + 表示,但纯 URL 百分号编码里空格用 %20。如果服务器端的解析器把 + 当成字面量而不是空格,就会出现问题。反过来也一样——期望 + 表示空格,但收到的是 %20 也能正常解码。大多数现代框架两种都处理,但如果你自己手写解析逻辑,需要注意这个差异。Node.js 的 querystring 模块默认把 + 解码为空格,而 URLSearchParams 也是同样的行为。
decodeURI 和 decodeURIComponent 不能混用。 encodeURI 编码的字符串用 decodeURI 解码,encodeURIComponent 编码的字符串用 decodeURIComponent 解码。如果用 decodeURI 去解码 encodeURIComponent 的结果,遇到 %23(#)、%3F(?)等字符时,decodeURI 不会解码。因为 decodeURI 在 URI 语境下认为这些字符是结构性的,不应该被解码。这会导致解码结果仍然包含百分号,显示成乱码。
曾经的遗留系统
有一个老系统的搜索功能传中文参数到后端。URL 看起来像这样:
/search?q=%B1%B1%BE%A9%CC%EC%C6%F8
拿着 %B1%B1%BE%A9%CC%EC%C6%F8 用 UTF-8 解码,得到的是乱码。试了 Latin-1 也是乱码。最后想到可能是 GB2312 编码,试了一下,解码出来是"北京天气"。
这个系统的前端好像是十年前的 ASP.NET 项目,默认编码不是 UTF-8。后端也假设 URL 参数用 GB2312 编码。这在现代 Web 开发里已经很少见了,大部分系统和浏览器都用 UTF-8。但如果你遇到一个古老系统的 URL 解码出来是乱码,可以试试用 GBK 或 Latin-1 解码百分号序列。
所以URL 百分号编码只负责把字节转为 %xx 格式,至于这些字节对应什么字符,编码和解码双方需要约定好字符集。如果不一致,数据传输就会出问题。
对于接口或页面URL的编码字符串可以通过URL 编解码工具查看,如果会编码也可通过 Chrome DevTools 解码也可以的。把疑似编码后的字符串贴进去,看解码结果是不是期望的值。会对比 encodeURI 和 encodeURIComponent 的实际输出差异,不过有图形界面比在浏览器控制台逐行输入方便一些,尤其是需要反复验证多组数据的时候。
总结
URL 编码不是一个复杂的算法,但它的细节分布在规范定义、浏览器实现和后端框架的不同解读中。理解了字符分类、百分号编码规则、encodeURI 和 encodeURIComponent 的差异、以及常见陷阱之后,大部分 URL 相关的编码问题都可以快速定位。
其实写这篇文章之前,个人也查了不少资料,确认了几个之前模棱两可的点。比如 encodeURI 到底会不会编码 @ 符号——查了 MDN 的规范表才确定 @ 属于 encodeURI 不编码的字符。还有一些不太确定的问题也顺便搞清楚了,比如浏览器地址栏和 JavaScript API 在编码策略上的差异。
这些细微之处如果不较真,遇到问题就只能靠试错。弄清楚原理之后,至少排查问题的方向是对的,不会在错误的地方浪费时间。