什么是 URL 编码(以及 URL 为什么会坏)
URL 编码要解决的根本问题是:URL 在 1994 年设计时只考虑了 ASCII 文本。RFC 3986 定义了一小组"非保留"字符可以直接出现在 URL 中:A-Z、a-z、0-9,以及四个符号(- _ . ~)。其他所有东西——空格、中文字符、emoji,甚至常见标点如 & 和 =——都必须经过百分号编码才能安全地在 URL 中传输。
百分号编码把字符的每个字节转换为 %XX,其中 XX 是十六进制值。空格变成 %20,斜杠变成 %2F。日文字符"日"的 UTF-8 编码是三个字节(E6 97 A5),所以变成 %E6%97%A5。这就是为什么包含非 ASCII 文本的 URL 看起来像乱码——每个字符膨胀成 3-9 个字符的百分号编码字节。
让人困惑的地方在于:有些字符是"保留"的(: / ? # @ & = +),在 URL 中有特殊含义。是否编码它们取决于它们出现的位置。路径中的 / 是分隔符(不要编码)。查询参数值中的 / 是数据(要编码)。搞错这个区别是我过去十年调试过的 URL 相关 bug 中大约 80% 的根源。
真正重要的 URL 编码规则
RFC 3986 把 URL 拆分为组件:scheme(https)、authority(user:pass@host:port)、path(/a/b/c)、query(?key=value)和 fragment(#section)。每个组件有不同的编码规则。路径允许 / 作为分隔符但必须编码 ? 和 #。查询允许 ? 但必须编码 # 和 &。片段几乎可以包含任何东西,因为它永远不会发送到服务器。
实际工作中只需要记住三条规则。规则一:在把用户输入插入任何 URL 组件之前,永远先编码。规则二:编码值,不编码结构字符。如果你在构建 ?name=张三&age=30,把"张三"编码为 "%E5%BC%A0%E4%B8%89" 但保留 ? 和 & 不动。规则三:对不同组件使用正确的函数(下面详述)。
+ 号是个特别头疼的问题。在查询字符串(application/x-www-form-urlencoded)中,+ 表示空格。在路径段中,+ 就是字面量加号。所以 /search?q=C++ 的查询部分编码为 q=C%2B%2B,但 /path/C++ 不需要编码。HTML 表单提交时把空格编码为 +(1995 年的惯例),这就是为什么有些解码器把 + 当空格,有些不会。拿不准的时候用 %20 表示空格——它在哪里都能用。
另一个陷阱:# 字符。浏览器把 # 后面的所有内容当作片段标识符,永远不发送到服务器。如果你的 API 密钥包含 # 而你放进 URL 时没有编码,服务器收到的是被截断的密钥。我曾经花了两天调试一个 OAuth 集成失败的问题——大约 4% 的用户受影响——最后发现他们的 client secret 包含 # 字符,被浏览器静默截断了。
URL structure and what gets encoded where:
https://user:[email protected]:8080/path/to/page?key=value&q=hello world#section
├─────┤├────────┤├──────────────┤├───────────┤├──────────────────────┤├──────┤
scheme userinfo host:port path query fragment
Characters that MUST be encoded in each component:
Path: space ? # [ ] & = + (and all non-ASCII)
Query: space # & = + (and all non-ASCII)
Fragment: (almost nothing — browser-only, not sent to server)
Examples:
Space in path: /my documents/file → /my%20documents/file
Space in query: ?q=hello world → ?q=hello%20world (or ?q=hello+world)
& in value: ?company=AT&T → ?company=AT%26T
# in value: ?color=#ff0000 → ?color=%23ff0000encodeURIComponent 和 encodeURI 的区别(搞混了就出 bug)
JavaScript 给了你两个函数,大多数开发者选错了那个。encodeURI() 编码一个完整 URL——它保留保留字符(: / ? # @ & = +)不动,因为它们是结构性的。encodeURIComponent() 编码单个组件值——除了非保留字符外全部编码。对查询参数值使用 encodeURI() 会导致 & 和 = 不被编码,破坏你的 URL 结构。
规则很简单:对值用 encodeURIComponent(),对完整 URL 用 encodeURI()(你很少需要编码完整 URL)。实际工作中你几乎总是需要 encodeURIComponent()。如果你在从各部分拼接 URL,分别编码每个值然后用结构字符连接。永远不要在拼装完成后编码整个 URL——那会把 %20 双重编码成 %2520。
Python 有 urllib.parse.quote()(编码路径段)和 urllib.parse.quote_plus()(编码查询值,空格变 +)。Go 有 url.PathEscape() 和 url.QueryEscape()。每种语言都有这个分裂,因为路径和查询组件的编码规则确实不同。选错了就会出现只在特定输入字符时才暴露的隐蔽 bug。
双重编码是最常见的错误。它发生在你编码了一个值,然后传给一个框架又编码了一次。字符串 "hello world" 第一次编码后变成 "hello%20world",第二次编码后变成 "hello%2520world"(% 被编码成了 %25)。如果你在 URL 中看到 %25,说明有人编码了两次。我们的 url-encoder 工具能逐步展示编码过程,帮你定位双重编码发生在哪里。
// ❌ WRONG: encodeURI doesn't encode & and = in values
const query = "company=AT&T&city=New York";
encodeURI(query);
// "company=AT&T&city=New%20York" — AT&T is split into two params!
// ✅ RIGHT: encode each value separately
const params = new URLSearchParams({
company: "AT&T",
city: "New York",
});
params.toString();
// "company=AT%26T&city=New+York"
// ✅ Also right: manual encoding with encodeURIComponent
const url = `/search?q=${encodeURIComponent("C++ programming")}&lang=en`;
// "/search?q=C%2B%2B%20programming&lang=en"
// ❌ Double-encoding trap
const encoded = encodeURIComponent("hello world"); // "hello%20world"
encodeURIComponent(encoded); // "hello%2520world" — broken!不同场景下的 URL 编码
HTML 表单:用 method="GET" 提交表单时,浏览器使用 application/x-www-form-urlencoded 格式编码表单值。空格变成 +,特殊字符变成 %XX。这是 Web 上最古老的编码惯例(定义在 HTML 2.0,1995 年),也是为什么 + 在查询字符串中表示空格。用 method="POST" 加 enctype="multipart/form-data" 时不做 URL 编码——二进制数据放在 MIME 边界里。
REST API:大多数框架在你的处理函数看到参数之前就自动解码了。Express 给你的 req.query.name 已经是解码后的。但如果你在客户端为 fetch() 调用构建 URL,必须自己编码值。Fetch API 不会自动编码 URL 字符串。fetch("/api?name=张三") 发送的是原始中文字符,大多数服务器会返回 400 Bad Request。
重定向和 OAuth:OAuth 2.0 流程把 token 和回调 URL 作为查询参数传递。redirect_uri 参数本身就是一个 URL,所以它会被编码:redirect_uri=https%3A%2F%2Fexample.com%2Fcallback。如果 OAuth 提供方解码了它,然后你的应用又解码一次,就会出现双重解码问题。我见过这个问题在 redirect URI 本身包含查询参数时爆发——嵌套的 ? 和 & 被过早解码。
文件路径在 URL 中:Windows 路径的反斜杠(C:\Users\file.txt)需要特殊处理。反斜杠在 RFC 3986 中不是保留字符,但很多服务器会把它规范化为正斜杠。文件名中的空格是经典问题——"我的文档"在 URL 中变成 "%E6%88%91%E7%9A%84%E6%96%87%E6%A1%A3"。macOS 和 Linux 允许文件名包含几乎任何字符(包括换行),所以永远对来自用户输入的路径段做编码。
Unicode 与国际化域名
现代 URL 可以包含任何 Unicode 字符,这得益于 IRI(国际化资源标识符,RFC 3987)。浏览器在地址栏显示 Unicode,但在网络上发送的是百分号编码的 UTF-8 字节。URL https://example.com/日本語 实际发送的是 https://example.com/%E6%97%A5%E6%9C%AC%E8%AA%9E。浏览器只是为了人类可读而做了美化显示。
国际化域名(IDN)使用一种叫 Punycode(RFC 3492)的不同编码。域名 日本語.jp 在 DNS 层面编码为 xn--wgv71a309e.jp。这跟百分号编码是两回事——它是在纯 ASCII 的 DNS 系统中表示 Unicode 的方式。你不能对域名做百分号编码,必须用 Punycode。JavaScript 的 URL 构造函数自动处理这个:new URL("https://日本語.jp").hostname 返回 "xn--wgv71a309e.jp"。
Emoji 在 URL 中能用但编码后很丑。🎉 这个 emoji 是 4 个 UTF-8 字节,在 URL 中变成 %F0%9F%8E%89。有些短链接服务和社交平台能漂亮地显示 emoji URL,但邮件客户端和老旧软件经常会搞坏它们。我的建议:避免在你期望人们复制粘贴的 URL 中使用 emoji。如果非要用,放在片段标识符里(#🎉)——那些留在客户端,不会在服务器端遇到编码问题。
一个真实的坑:上传到云存储的中文文件名。AWS S3 的 key 是 UTF-8 字符串,但访问它们的 URL 必须经过百分号编码。如果你存了一个文件叫"报告.pdf"然后生成预签名 URL 时没有编码 key,URL 就会坏掉。AWS SDK 会处理这个,但如果你在手动构建 URL(用于 CloudFront 或自定义域名),必须自己编码路径。
常见 URL 编码 bug(以及怎么修)
Bug 1:路径中空格显示为 +。你的框架对路径段使用了查询字符串编码(application/x-www-form-urlencoded)。路径中空格必须是 %20,不是 +。修复:对路径段使用 encodeURIComponent(),不要用 URLSearchParams(它对空格产生 +)。或者使用 URL 构造函数,它能正确处理。
Bug 2:特殊字符导致分页失效。你有 ?page=2&filter=price>100,> 破坏了 URL。filter 的值需要编码:filter=price%3E100。这个问题通常在用户往搜索框输入过滤表达式、前端不编码就直接拼 URL 时出现。
Bug 3:OAuth 回调 URL 间歇性失败。redirect_uri 包含查询参数(?source=app),在 OAuth 流程中被解码,导致单个 redirect_uri 参数变成了多个参数。修复:永远把整个回调 URL 作为单个值编码,并验证你的 OAuth 库在比较注册的 redirect URI 之前没有提前解码。
Bug 4:ID 中包含斜杠的资源返回 404。如果你的资源 ID 是 "2024/Q1" 而你构建 URL 为 /api/reports/2024/Q1,服务器看到的是三个路径段而不是一个。修复:用 encodeURIComponent() 编码 ID,斜杠变成 %2F:/api/reports/2024%2FQ1。注意:有些 Web 服务器(Apache、nginx)默认会解码路径中的 %2F,可能又会搞坏。检查你的服务器配置。
URL 编码不是万能药
如果你要通过 URL 传递大型二进制数据(图片、文件),别用百分号编码。一个 100 KB 的文件会变成 300 KB 的 URL(每个字节作为 %XX 膨胀三倍)。用 multipart 表单上传,或者 Base64 编码后放在 POST body 里。URL 有实际长度限制——IE 限制在 2083 字符,虽然现代浏览器能处理更长的 URL,但很多服务器和代理在 8 KB 处截断。
对于 URL 中的结构化数据,考虑查询字符串之外的替代方案。如果你需要传递一个复杂的过滤对象,你可以百分号编码一个 JSON 字符串(?filter=%7B%22price%22%3A%7B%22gt%22%3A100%7D%7D),但那不可读且脆弱。更好的选择:简单层级用路径段(/products/electronics/phones),复杂查询用 POST 加 JSON body,或者用专门的查询语言参数(?filter=price:>100)。
不要对要存入数据库、文件或非 URL 上下文的数据做 URL 编码。我见过有人"为了安全"在存储前对用户输入做 URL 编码,然后在显示时解码。这导致数据后来被放入 URL 时出现双重编码 bug(又被编码了一次)。在边界处编码——插入 URL 之前编码,从 URL 提取之后解码。永远不要存储编码后的数据。
安全提示:URL 编码不是消毒处理。把 <script> 编码为 %3Cscript%3E 并不能防止 XSS,如果服务器在插入 HTML 之前解码了它的话。URL 编码保护的是 URL 结构,不是你的应用。防 XSS 要用 HTML 实体编码(<script>)或自动转义的模板引擎。这些是不同的编码方案,用于不同的目的。
各语言 URL 编码速查
JavaScript:encodeURIComponent(value) 用于查询/路径值。new URLSearchParams({key: value}).toString() 用于构建查询字符串(自动处理编码)。new URL(path, base) 用于安全地构造完整 URL。解码:decodeURIComponent(encoded)。永远不要用 escape()/unescape()——它们已废弃且 Unicode 处理有问题。
Python:urllib.parse.quote(string, safe="") 用于路径段。urllib.parse.urlencode(dict) 用于查询字符串。requests 库在你传 params={"key": "value"} 时自动处理编码。解码:urllib.parse.unquote(encoded)。safe 参数控制哪些字符不被编码——对保留斜杠的路径编码设为 "/"。
Go:url.QueryEscape(value) 用于查询值(空格变 +)。url.PathEscape(value) 用于路径段(空格变 %20)。url.URL 结构体配合 Query() 方法能正确处理 URL 的构建和解析。Go 对编码很严格——net/http 会拒绝路径中包含未编码特殊字符的请求。
PHP:urlencode(value) 用于查询值(空格变 +)。rawurlencode(value) 用于路径段(空格变 %20)。http_build_query(array) 从数组构建查询字符串。PHP 的 $_GET 超全局变量自动解码查询参数,所以你在应用代码中永远看不到百分号编码,除非你在读原始 URL。