百分号编码

一种在 URL 中使用 %XX 十六进制格式表示特殊字符的编码方式,也称为 URL 编码。

百分号编码 (Percent-Encoding) 是一种将 URL 中不能直接使用的字符替换为 % 加两位十六进制数字的编码方式,也称为 URL 编码。该方式由 RFC 3986 标准化,作为 Web 的基础技术,是浏览器与服务器之间安全传输非 ASCII 字符和保留字符不可或缺的机制。

编码机制非常简单。将目标字符转换为 UTF-8 字节序列,然后将每个字节表示为 % 加两位十六进制数字。例如,空格变为 %20 (0x20),日语字符"あ"在 UTF-8 中为 3 个字节 (0xE3, 0x81, 0x82),因此变为 %E3%81%82。RFC 3986 要求对非保留字符 (A-Z、a-z、0-9、-、_、.、~) 不做百分号编码。编码之后的形式并不改变含义,但为了让写法保持一致,规定要用原字符来写。另一方面,/、?、#、&、= 这类保留字符则要分开用:当作分隔符使用时就照原样写,作为值的一部分使用时才编码。如果记成"除非保留字符以外全都需要编码",就会连分隔符一起压掉,把 URL 的结构弄坏。

在实际开发中,浏览器在发送搜索查询和表单数据时会自动应用百分号编码。JavaScript 提供 encodeURIComponent() 进行编码,decodeURIComponent() 进行解码。而 encodeURI() 针对整个 URL,不会编码 / 或 ? 等保留字符。混淆这两个函数可能导致 URL 损坏或产生安全隐患。再说细一点,encodeURIComponent() 不做转换而原样留下的 ASCII 字符,与 RFC 3986 的非保留字符集合并不完全一致,!、'、(、)、* 这 5 个字符会保持原样。想严格对齐成只留下 RFC 3986 非保留字符的形式时,就在编码之后把这 5 个字符自己替换成 %21、%27、%28、%29、%2A。

一个常见的误解是将百分号编码与 HTML 实体 (如 &) 混淆。百分号编码专用于 URL,而 HTML 实体转义的目的和语法完全不同。此外,处理国际化 URL 时需注意,浏览器地址栏显示的是解码后的 URL,但实际的 HTTP 请求发送的是编码后的 URL。

与之相关的概念是 Base64 编码,但两者用途不同。百分号编码用于在 URL 中安全地表示单个字符,而 Base64 用于将二进制数据转换为文本格式。此外,在 application/x-www-form-urlencoded 格式中,空格用 + 而非 %20 表示,这体现了不同上下文中的差异。这个差异在解码一侧很容易出事故,decodeURIComponent('a+b') 不会把 + 还原成空格,而是原样返回。手工去解码来自表单的查询串,空格就会以 + 的形态混进正文。查询字符串交给理解表单格式的 API 更安全,new URLSearchParams({ q: 'a b' }).toString() 会返回 q=a+b,读取时也会把 + 还原成空格。

从字符计数的角度来看,百分号编码会导致原始字符数与 URL 上的长度之间产生差异。假名和汉字在 UTF-8 中占 3 个字节,因此经百分号编码后 1 个字符会膨胀成 9 个字符 (%XX%XX%XX)。占 4 个字节的表情符号还要更长,会变成 12 个字符。关于 URL 的长度,浏览器、Web 服务器、以及中间的转发设备各自都有自己的上限,其中还有并未公开的,因此没法把某个特定数值当作安全范围来依靠。事先估算含非 ASCII 字符的查询参数编码后会变成多少个字符,如果会变长,就换成不放在 URL 上、而用 POST 的正文发送的设计,这样才可靠。

分享这篇文章