URL 解析的隐藏规则:path 和 query 编码不一样、IDN 中文域名、fragment 不发服务器

· 约 6 分钟 🧩 URL 参数解析

URL 看着简单,但里面藏着一堆规则——编码两套、协议默认端口、fragment 不上服务器、IDN 中文域名、Hash 路由原理。理解这些既能解决”中文参数乱码""SPA 路由失效""端口冲突”等常见问题,也能避免被同形字钓鱼网站骗。

URL 的标准结构

完整 URL 的各部分(RFC 3986):

https://user:pass@example.com:8080/path/to/page?key=value&k2=v2#section
└──┬─┘   └───┬───┘ └────┬────┘ └┬─┘└─────┬─────┘ └────────┬────────┘ └──┬──┘
scheme   userinfo    host    port    path           query           fragment

各部分的设计:

部分字符限制必需编码规则
scheme字母数字 + . - +不编码
userinfo一般字符编码
hostDNS 字符(ASCII)/ IDNIDN 走 punycode
port0-65535不编码
path一般字符 + /编码(保留 /)
query一般字符 + & =编码(保留 & =)
fragment一般字符 + 多编码

两套编码的本质

JS 里有 encodeURIencodeURIComponent,名字像是”哪个更彻底”,实际是用途不同

encodeURI —— 编码”整个 URL”:

encodeURI('https://example.com/path?key=value with space');
// → 'https://example.com/path?key=value%20with%20space'
//                                    ↑ 只编码了空格,:、/、?、= 都保留

保留 URL 结构字符(: / ? & # = + $ , ; @)。

encodeURIComponent —— 编码”URL 的某一部分”:

encodeURIComponent('value with space & special?');
// → 'value%20with%20space%20%26%20special%3F'
//                          ↑ & 和 ? 都被编码

把所有非字母数字 / ! ' ( ) * - . _ ~ 的字符全部编码。

实战用法

// 拼参数值 → 必须用 encodeURIComponent
const search = '小米 + 苹果';
const url = `https://search.com?q=${encodeURIComponent(search)}`;
// → 'https://search.com?q=%E5%B0%8F%E7%B1%B3%20%2B%20%E8%8B%B9%E6%9E%9C'

// 错误示范:用 encodeURI
const wrong = `https://search.com?q=${encodeURI('a&b=c')}`;
// → 'https://search.com?q=a&b=c'   ← & 没编码,被解析成两个参数

口诀

  • 拼整个 URL → encodeURI(基本不需要)
  • 拼参数值 / 路径段 → encodeURIComponent(99% 场景)

URLSearchParams 是更好的选择

手动 encodeURIComponent 拼参数容易出错。现代 API:

const url = new URL('https://example.com/search');
url.searchParams.set('q', '小米 + 苹果');
url.searchParams.set('page', '1');
url.searchParams.append('tag', 'phone');
url.searchParams.append('tag', 'cn');

url.toString();
// → 'https://example.com/search?q=%E5%B0%8F%E7%B1%B3+%2B+%E8%8B%B9%E6%9E%9C&page=1&tag=phone&tag=cn'

注意 URLSearchParams 把空格编成 +(form 风格),encodeURIComponent 编成 %20。两者都合法,服务端会正确解码——但混用会让 URL 不规范。

IDN 国际化域名

DNS 协议(1983 年)只支持 ASCII。中文 / 日文 / 阿拉伯文域名要走 IDNA 标准转码。

punycode 转换

"中国.cn" → DNS 实际查询 "xn--fiqs8s.cn"
"清华大学.cn" → "xn--xkrt7iy0g.cn"
"münchen.de" → "xn--mnchen-3ya.de"

xn-- 是 punycode 域名前缀。后面是把 Unicode 编码成 ASCII 的特殊算法。

浏览器自动透明转换:用户在地址栏输入”中国.cn”,浏览器显示中文但 DNS 查询用 punycode;服务器响应后浏览器仍显示中文。

安全问题:同形字攻击

不同字符集的字母在视觉上一样:

拉丁字母 a (U+0061)
西里尔字母 а (U+0430)
两者视觉完全一样

钓鱼攻击:注册 аpple.com(用西里尔的 а),看起来跟 apple.com 一样,但 punycode 完全不同。

浏览器防御:Chrome / Firefox 检测到混合字符集 / 同形字时强制显示 punycode 形式(xn--pple-43d.com)让用户警觉。

fragment 永远不发服务器

URL 的 # 后面叫 fragment(也叫 hash 或 anchor)。它只在浏览器本地处理,永远不发到服务器

https://example.com/page?key=value#section-2
                                  └────┬────┘
                                  fragment

浏览器实际请求:GET https://example.com/page?key=value
                                              ↑ 服务端收到的 URL,没有 #section-2

收到响应后,浏览器查找 id="section-2" 的元素,滚动过去

实务影响

  1. 服务端日志拿不到 fragment——想统计”用户在哪个页内位置”必须前端 JS 上报
  2. 复制 URL 给同事:fragment 会带过去,对方打开能跳到锚点
  3. fragment 修改不刷新页面location.hash = 'section-3' 只触发滚动,不重新请求

SPA 用 hash 做路由

早期单页应用(SPA)的路由:

https://app.com/#/users/123

       浏览器实际请求 https://app.com/
       服务端返回固定的 index.html
       前端 JS 读取 location.hash = "#/users/123"
       根据 hash 路由到 users 页面,渲染用户 123

优点

  • 切换路由不刷新页面
  • 服务端只提供一个入口文件
  • 不需要服务端配合(任何静态服务器都行)

缺点

  • URL 里有 # 不优雅
  • 服务端无法预渲染(SEO 差)
  • fragment 不能重复(#a#b 不合法)

现代做法:HTML5 history API

history.pushState({}, '', '/users/123');
// URL 变成 /users/123 不带 #
// 但页面不刷新

代价:服务端必须配合——所有路由(/users/123/posts/456)都返回同一个 index.html,让前端 JS 接管路由。Nginx 配置:

location / {
  try_files $uri $uri/ /index.html;
}

端口的默认与省略

每个协议有默认端口:

协议默认端口
http80
https443
ftp21
ssh22
smtp25
dns53
pop3110
imap143
https + IMAPS993

协议匹配默认端口时省略

https://example.com    ≡    https://example.com:443
http://example.com     ≡    http://example.com:80

何时必须写端口

  • 开发服务器:http://localhost:3000http://localhost:8080
  • 反向代理:https://example.com:8443(外部 8443 → 内部 443)
  • 同主机多实例::80 是站点 A,:81 是站点 B

陷阱

  1. HTTP 写 :443 不会自动升级 HTTPS——http://example.com:443 是 HTTP 协议在 443 端口,几乎肯定失败
  2. HTTPS 写 :80 同理会失败
  3. URL 规范化https://a.comhttps://a.com:443 应视为同一资源,但字符串比较不等

各语言的 URL 解析对比

语言 / APIURL 解析标准遵循
JS new URL()内置WHATWG URL Standard
Node.js url.parse()(旧)内置RFC 3986(已 deprecated)
Node.js new URL()内置WHATWG(推荐)
Python urllib.parse内置RFC 3986
Java java.net.URL内置RFC 3986(不更新)
Go net/url内置RFC 3986

WHATWG URLRFC 3986 在边缘情况上有微妙差异:

// WHATWG(浏览器 / Node 新 API)
new URL('https://example.com//double-slash').pathname; // '//double-slash'

// 旧 API
url.parse('https://example.com//double-slash').pathname; // '/double-slash' (吞掉了一个斜杠)

跨语言的 URL 处理建议只用一种实现,避免 corner case 不一致。

几个常见的 URL 处理坑

1. 拼接相对 URL

// 错误
const base = 'https://example.com/path/';
const url = base + '../other';   // → 'https://example.com/path/../other'
                                  //   字符串拼接,没解析

// 正确
const url = new URL('../other', 'https://example.com/path/').href;
// → 'https://example.com/other'

2. URL 长度限制

  • 浏览器:Chrome ≈32k、Firefox ≈65k、Safari ≈80k
  • 服务端:Nginx 默认 8k、Apache 默认 8k、IIS 默认 16k
  • CDN / WAF:常 4-8k 限制

实务:GET URL 控制在 2k 以内,超过用 POST。

3. URL 中的 + 符号

URL 里 + 在 query 里是空格的简写(form-urlencoded 习惯)。

const url = new URL('https://example.com/?q=a+b');
url.searchParams.get('q'); // 'a b'  ← 自动解码 + 为空格

要传真正的 + 必须编码为 %2B

const url = new URL('https://example.com/');
url.searchParams.set('q', 'a+b');
// → 'https://example.com/?q=a%2Bb'

4. URL 大小写

  • scheme、host:不区分大小写Example.COMexample.com
  • path、query、fragment:区分大小写/Page/page

服务器响应 404 时检查路径大小写——常见低级错误。

一句话总结

参数值用 encodeURIComponent / 整个 URL 用 URL 类、中文域名走 punycode 注意同形字钓鱼、fragment 永远不发服务器、协议默认端口可省略——五条记住基本不会被 URL 坑。

❓ 常见问题

encodeURI 和 encodeURIComponent 哪个更"安全"?

不是安全级别问题,是用途不同encodeURI 用于编码"整个 URL"——保留 URL 结构字符(: / ? & # = 等)不编码;encodeURIComponent 用于编码"URL 的某一部分"(参数值、路径段)——把所有非字母数字字符全部编码,包括 / : ? &。实务:(1) 拼 URL 参数 → 必须用 encodeURIComponent,否则参数里含 & 会破坏 query 结构;(2) 编码完整 URL → 用 encodeURI,但很少需要(一般 URL 不会有未编码字符)。陷阱:encodeURIComponent 不编码 ! ' ( ) * - . _ ~(RFC 3986 unreserved),少数老服务端把这些当特殊字符仍会出问题。

中文域名(如 中国.cn)真的能用吗?

能用,但要做 punycode 转换。DNS 协议只支持 ASCII,中文域名通过 IDNA(Internationalized Domain Names in Applications)标准转码:中文 → punycode → ASCII。比如"中国.cn"实际 DNS 查询的是"xn--fiqs8s.cn"。浏览器自动转换:地址栏输入"中国.cn",浏览器透明地转换为 punycode 发起 DNS 查询,地址栏仍显示中文。主要用途:商业品牌("清华大学.cn"、"故宫.cn")、本地化、营销噱头。主要问题:(1) 邮件协议不全支持,邮件地址里的中文域名兼容性差;(2) 同形字攻击——比如 а(西里尔字母)vs a(拉丁字母)肉眼一样但 punycode 不同,钓鱼网站用同形字伪造域名(apple.com vs аpple.com);(3) 部分老软件不识别。

URL 里的

不会。fragment(# 后面的部分)是浏览器本地的——服务器永远收不到。设计本意是定位文档内的锚点(#section-2),URL https://example.com/page#section-2 浏览器只发 https://example.com/page 给服务器,收到响应后本地滚动到 id="section-2" 的元素。单页应用 SPA 用 # 做路由就是利用这点——/#/users/123 不会触发整页刷新,路由切换完全在前端。:(1) 服务端日志拿不到 fragment——分析"用户在哪个页内位置"要前端 JS 上报;(2) 复制 URL 给同事时 fragment 会带过去(浏览器能保留),但服务端收不到;(3) HTML5 history API 用 pushState 实现"无 # 路由"是更现代的做法(但需要服务端配合返回相同入口)。

为什么有些 URL 端口写 :443 有些不写?

端口与协议匹配时省略,否则必须写。HTTP 默认端口 80、HTTPS 默认端口 443、FTP 21、SSH 22、MongoDB 27017……协议默认端口是约定俗成。https://example.comhttps://example.com:443,等价。何时必须写端口:(1) 服务监听非默认端口(如开发服务器 :3000、Spring Boot :8080、Vite :5173);(2) 反向代理把外部 :8443 转发到内部 :443;(3) 同主机多实例(:80 是站点 A、:81 是站点 B)。陷阱:(1) HTTP 写 :443 不会自动升级 HTTPS,会失败;(2) HTTPS 写 :80 同理;(3) URL 比较时要规范化——https://a.comhttps://a.com:443 应该视为同一资源;(4) Cookie 不限端口(同主机不同端口共享 cookie)。

🧩 打开 URL 参数解析 整条 URL 拆 protocol/host/path/query · 可视化改参数并拼回