跳到主内容
d.devtul.fun
EN
HTTP · 2026-08-26

什么是 URL 编码?写给开发者的百分号编码指南

你每天在地址栏敲下、点击、或者代码里 fetch 的每一个 URL,其实都不是"一串随便的字符"。它是一个有语法的地址,规定了哪些字符可以直接出现、哪些必须先伪装一下。百分号编码(也就是你常见的 %20、%E4 这类写法)就是用来保住这些规则不被破坏的。这篇文章讲清楚规则的来源、字节层面到底发生了什么,以及那些在生产环境里悄悄把 URL 弄坏的坑。

保留字符和不安全字符是从哪来的

一个 URL 是有语法的。看 https://user@host:443/path?query#fragment:冒号分隔协议,@ 分隔用户名和主机,斜杠切分路径,问号引出查询串,井号标记片段。每一个标点都是带着任务的分隔符。麻烦在于,你想塞进 URL 的数据——搜索词、用户名、文件名——自己也可能包含这些字符。如果路径段里本来就有个 /,解析器怎么知道它是数据而不是分隔符?

这个矛盾就是编码存在的全部理由。规范(RFC 3986)必须划一条线:一部分字符是保留字符,因为它们可能充当分隔符;其余的要么非保留,要么干脆不安全、不能直接发送。保留字符是语法关心的;不安全字符是那些会被老旧的传输层、代理或者粗糙的字符串处理弄坏的。

百分号编码的字节级原理

百分号编码针对的不是字符,而是字节。规则很简单:把要发送的字节写成十六进制,前面加一个百分号。一个字节精确变成三个字符:% 加两个十六进制数字。

非 ASCII 文本在这里就变有意思了。字符并不会被"直接编码",而是先用一种字符编码(几乎总是 UTF-8)变成字节,然后再对每一个字节独立地做百分号编码。

const s = '你好';
const bytes = new TextEncoder().encode(s);
// bytes = [0xE4, 0xBD, 0xA0, 0xE5, 0xA5, 0xBD]
console.log(encodeURIComponent(s));
// "%E4%BD%A0%E5%A5%BD"

你好 是两个汉字。在 UTF-8 里每个汉字占 3 个字节,所以一共 6 个字节,每个字节变成一个 %XX 三元组。这就是为什么一个汉字——对你来说只是一个"字"——在 URL 里会变成 9 个字符。这不是浪费,而是把任意文本塞进一个为 ASCII 设计的通道里唯一可行的办法。

保留字符与非保留字符的区别

RFC 3986 精确地定义了非保留集合:大小写字母、数字,再加上四个符号——连字符 -、点号 .、下划线 _ 和波浪号 ~。这些永远不需要编码,它们既不会被误认成分隔符,也能在传输中完好无损。

其余的要么是保留字符,要么干脆不允许裸奔:

  • 保留字符:: / ? # @ ! $ & ' ( ) * + , ; =。在它们"自己的那一段"里是合法的;但如果你希望某个字符被当作字面数据而不是它作为分隔符的含义,就必须编码。
  • 不安全 / 绝不能裸奔:空格、控制字符,以及所有超出 ASCII 可打印范围的字符。这些在任何位置都必须做百分号编码。

关键区别在于:保留字符在 URL 里完全合法——只要它在扮演自己的语法角色。路径段之间的 / 没问题。但一旦你想让 / 成为某个值的一部分,它就得变成 %2F。

为什么空格是 %20 而不是 +

这是 URL 编码里最让人迷糊的角落,根源在历史。其实有两套约定:

  • 在通用的 URI 语法里,空格属于不安全字符,编码成 %20。
  • 在更老的 application/x-www-form-urlencoded 媒体类型里——也就是浏览器提交 HTML 表单时用的格式——空格被编码成字面意义的加号 +,只有少数字符走百分号编码。

所以加号是表单编码的历史包袱,不是 URL 编码的规则。你在 JavaScript 里调 encodeURIComponent('a b') 得到的是 a%20b,不是 a+b。如果你再把它丢进一个把 + 当成空格的表单解码器,那没问题;但如果你手工拼查询串时写了 q=a+b 而本意是"a 加 b",表单解析器会把它读成"a 空格 b"。这种错配酿成了不少 bug 报告。

// URL 规范的写法(fetch 和大多数 API 期望的):
const q = 'hello world';
'https://example.com/search?q=' + encodeURIComponent(q);
// -> https://example.com/search?q=hello%20world

// 表单解码器的写法(某些老服务器仍然这样假设):
'hello world'.replace(/ /g, '+');
// -> "hello+world"  —— 只有当服务端用表单解码时才正确

哪些必须编码,哪些可以不管

按严格程度给点实操建议:

  • 必须编码:在你自己拼装的组件(查询值、亲手拼的路径段)里,凡是落在非保留集合之外的字符,先编码再放进去。
  • 不要编码:一个你没亲手拼、本来就是合法的 URL 里的结构性分隔符。如果你已经有了一个合法 URL,别再把它过一遍编码器。
  • 当心 % 本身。一个后面没有跟着两个十六进制数字的孤零零的 % 是非法的,会让解码直接失败。

两个会悄悄弄坏 URL 的误区

第一个是双重编码。你把值编码一次放进 URL,下游某一层又编码了一次,于是 %20 变成 %2520。服务端解码一次,看到的是 %20,把它当作字面数据——结果空格变成了字符串 "%20",而不是真正的空格。这在"框架自动编码参数、应用代码又编码了一遍"的场景里极其常见。

第二个是把整个 URL 当成一个整体去编码。不要拿一个完整的 URL 字符串去喂 encodeURIComponent,那样你会把 ://、斜杠和查询分隔符全编码掉,得到一串根本不再是 URL 的东西。要编码的是片段——也就是那些值——然后再用这些片段拼出 URL。

// 错误:这破坏了 URL 的结构
fetch(encodeURIComponent('https://example.com/a?b=c'));
// -> 实际去请求 "https%3A%2F%2Fexample.com%2Fa%3Fb%3Dc"

// 正确:只编码值,保留结构
const url = 'https://example.com/search?q=' + encodeURIComponent(userInput);

小结

百分号编码是一个字节级的转义机制,不是某种文本变换。搞清楚哪些字符在哪里是保留的,记住 + 表示空格只是表单的怪癖,并且始终编码"值"而不是"整个 URL"。这三件事做对了,一大半"我的 URL 怎么坏了"的工单就会消失。

继续阅读