为什么 URL 需要编码
URL 由字符组成,但并非所有字符都可以"安全地"直接使用。URL 在互联网上传输时,会经过各种系统,这些系统可能将某些字符解释为具有特殊含义——例如 ? 用于查询字符串,& 用于分隔参数,# 用于片段标识符。此外,一些字符是非 ASCII 字符(如中文或阿拉伯字母),还有一些字符可能会被老旧的 HTTP 基础设施损坏。
URL 编码(官方名称为百分号编码)通过将不安全字符替换为 % 加上该字符字节值的两位十六进制表示来解决这个问题。
空格 (0x20) → %20
@ 符号 (0x40) → %40
正斜杠 (0x2F) → %2F
RFC 标准
URL 编码定义在 RFC 3986(统一资源标识符)中。根据该标准:
未保留字符 — 在 URL 中任何位置都可以安全使用,无需编码:
- A–Z、a–z、0–9
-、_、.、~
保留字符 — 在 URI 中具有特殊含义,当作为数据使用时必须进行百分号编码:
: / ? # [ ] @ ! $ & ' ( ) * + , ; =
其他所有字符——包括空格、非 ASCII 字符以及 "、<、>、{、} 等——都必须进行百分号编码。
百分号编码实践
空格的编码
空格字符(U+0020)编码为 %20。在查询字符串中,你也可能看到 + 代替 %20——这来自 HTML 表单 URL 编码(application/x-www-form-urlencoded)标准,其中空格被编码为 +。两者是不同的:
%20— RFC 3986 百分号编码(路径、请求头中的空格)+— HTML 表单编码(查询字符串中的空格)
解码时,务必了解适用的是哪种约定,以避免 bug。
非 ASCII 字符
非 ASCII 字符首先以 UTF-8 编码,然后对每个字节进行百分号编码:
汉字 中 (U+4E2D)
UTF-8 字节:0xE4 0xB8 0xAD
百分号编码:%E4%B8%AD
因此,中文词语 你好 变为 %E4%BD%A0%E5%A5%BD。
JavaScript 编码函数
JavaScript 提供了几个内置函数用于 URL 编码:
encodeURI()
编码完整的 URI——设计用于在保留 URL 结构的同时编码完整的 URL。它不会编码属于 URI 语法的字符:; , / ? : @ & = + $ #。
encodeURI("https://example.com/search?q=hello world&lang=中文")
// "https://example.com/search?q=hello%20world&lang=%E4%B8%AD%E6%96%87"
encodeURIComponent()
编码 URI 组件——设计用于编码单个值,如查询参数值。它会编码除 A–Z a–z 0–9 - _ . ! ~ * ' ( ) 之外的所有内容。
encodeURIComponent("hello world & more")
// "hello%20world%20%26%20more"
encodeURIComponent("https://example.com")
// "https%3A%2F%2Fexample.com"
如何选择
| 场景 | 函数 |
|---|---|
| 编码整个 URL | encodeURI() |
| 编码查询参数值 | encodeURIComponent() |
| 编码路径段 | encodeURIComponent() |
| 编码嵌入另一个 URL 中的 URL | encodeURIComponent() |
对应的解码函数
decodeURI(encodedURI)
decodeURIComponent(encodedComponent)
永远不要使用已废弃的 escape() 和 unescape() 函数——它们处理非 ASCII 字符的方式不同,会产生错误结果。
常见陷阱和坑
重复编码
一个常见的 bug 是对已经编码的字符串再次编码:
encodeURIComponent(encodeURIComponent("hello world"))
// "hello%2520world"
// %25 是 % 的编码,所以 %20 变成了 %2520
在编码之前,务必检查该值是否已经被编码。
+ 与 %20 的陷阱
如果你用 decodeURIComponent 解码一个使用 + 表示空格的查询字符串,+ 不会被解码为空格——你必须先将 + 替换为 %20,或者使用 URLSearchParams API:
new URLSearchParams("q=hello+world").get("q")
// "hello world" ✓
decodeURIComponent("hello+world")
// "hello+world" ✗ — 仍然是字面加号
片段标识符
URL 中的 # 字符标志着片段标识符(用于页内锚点)的开始。如果数据中包含 #,它必须被编码为 %23,否则浏览器会将其后的所有内容视为片段。
国际化域名(IDN)
包含非 ASCII 字符的域名(如 bücher.de)使用 Punycode 编码,而非百分号编码。浏览器会在内部将 IDN 转换为 Punycode:bücher.de → xn--bcher-kva.de。
URL 结构参考
根据 RFC 3986,URL 包含以下组成部分:
scheme://userinfo@host:port/path?query#fragment
| 组成部分 | 示例 | 编码规则 |
|---|---|---|
| Scheme | https | 字母、数字、+、-、. |
| Host | example.com | 域名标签 + 点 |
| Port | 8080 | 仅数字 |
| Path | /search/results | 用 %XX 编码,除未保留字符 + :@!$&'()*+,;= |
| Query | q=hello+world | 表单数据中用 + 表示空格,通常用 %20 |
| Fragment | #section-2 | 不发送到服务器;仅浏览器使用 |
服务端注意事项
URL 规范化
服务器在处理之前应对 URL 进行规范化——例如,将 %41(解码为 A)和 A 视为相同。然而,某些字符在编码和未编码状态下含义不同:路径中的 / 与 %2F——出于安全原因(路径遍历保护),许多 Web 服务器会对此区别对待。
通过 URL 参数的 SQL 注入
在将 URL 参数用于数据库查询之前,即使经过 URL 解码后,也务必对其进行清理和验证。URL 编码并不是安全边界。
工具和 API
浏览器的 URL API
现代浏览器和 Node.js 提供了 URL API,以结构化方式处理 URL:
const url = new URL("https://example.com/search?q=hello world&page=1");
console.log(url.searchParams.get("q")); // "hello world"(自动解码)
url.searchParams.set("q", "new value & special");
console.log(url.href);
// https://example.com/search?q=new+value+%26+special&page=1
URL API 透明地处理编码/解码,这通常是比手动调用 encodeURIComponent 更推荐的方式。
总结
URL 编码是每位 Web 开发者每天都会遇到的基础概念,通常在不知不觉中就用到了。需要记住的关键点:
- 百分号编码(
%XX)是在 URI 中编码不安全字符的标准机制。 - 对单个值使用
encodeURIComponent();对完整 URL 使用encodeURI()。 - 注意查询字符串中
+与%20的区别。 - 避免重复编码——在编码前检查字符串是否已经被编码。
- 优先使用现代的
URL和URLSearchParamsAPI 以编程方式处理 URL。