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 | 一般字符 | ✗ | 编码 |
| host | DNS 字符(ASCII)/ IDN | ✓ | IDN 走 punycode |
| port | 0-65535 | ✗ | 不编码 |
| path | 一般字符 + / | ✗ | 编码(保留 /) |
| query | 一般字符 + & = | ✗ | 编码(保留 & =) |
| fragment | 一般字符 + 多 | ✗ | 编码 |
两套编码的本质
JS 里有 encodeURI 和 encodeURIComponent,名字像是”哪个更彻底”,实际是用途不同:
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" 的元素,滚动过去
实务影响:
- 服务端日志拿不到 fragment——想统计”用户在哪个页内位置”必须前端 JS 上报
- 复制 URL 给同事:fragment 会带过去,对方打开能跳到锚点
- 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;
}
端口的默认与省略
每个协议有默认端口:
| 协议 | 默认端口 |
|---|---|
| http | 80 |
| https | 443 |
| ftp | 21 |
| ssh | 22 |
| smtp | 25 |
| dns | 53 |
| pop3 | 110 |
| imap | 143 |
| https + IMAPS | 993 |
协议匹配默认端口时省略:
https://example.com ≡ https://example.com:443
http://example.com ≡ http://example.com:80
何时必须写端口:
- 开发服务器:
http://localhost:3000、http://localhost:8080 - 反向代理:
https://example.com:8443(外部 8443 → 内部 443) - 同主机多实例:
:80是站点 A,:81是站点 B
陷阱:
- HTTP 写 :443 不会自动升级 HTTPS——
http://example.com:443是 HTTP 协议在 443 端口,几乎肯定失败 - HTTPS 写 :80 同理会失败
- URL 规范化:
https://a.com和https://a.com:443应视为同一资源,但字符串比较不等
各语言的 URL 解析对比
| 语言 / API | URL 解析 | 标准遵循 |
|---|---|---|
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 URL 和 RFC 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.COM≡example.com) - path、query、fragment:区分大小写(
/Page≠/page)
服务器响应 404 时检查路径大小写——常见低级错误。
一句话总结
参数值用 encodeURIComponent / 整个 URL 用 URL 类、中文域名走 punycode 注意同形字钓鱼、fragment 永远不发服务器、协议默认端口可省略——五条记住基本不会被 URL 坑。