拆解一条 URL:从 URI 到 slug
一条「信息最全」的 URL 长这样:https://user:pa%24%24@shop.example.co.uk:8443/cart/items?id=42&ref=ad#summary,可拆成 scheme、userinfo、host、port、path、query、fragment 七大块。本页把它逐段拆开,并且全程不手写正则——解析依靠浏览器内置的 URL 与 URLSearchParams;host
里「注册域名与公共后缀」这件 URL 接口不负责的事,交给带 Public Suffix List 的 tldjs。
1 · URI 之下的两支
动手拆 URL 之前先理清一组常被混用的词。URI(Uniform Resource Identifier,统一资源标识符)是总称,任何用来指明一个资源的字符串都是 URI。它下面分两支:URL(Locator,定位符)说明资源在哪、怎么取;URN(Name,名称)只给资源一个持久的名字,不管它存在哪。因此 URL 与 URN 都是 URI 的子集,而日常说的「网址」几乎都是 URL。
一句话区分:URL 能让人去拿到资源——scheme 指明访问机制(https / ftp / mailto),authority 与 path 指明位置;URN 只叫得出资源——以 urn: 开头,在命名空间里唯一,资源换了存放位置名字也不变,但光凭名字不一定能直接取到,需另接一套解析服务。
1.1 · 标识符的归类
建议 · 记住命名取的是上位词,就不会再纠结「为什么 JS 的接口叫 encodeURI 而不是 encodeURL」:percent-encoding 编的是 RFC 3986 定义的 URI 通用语法,对 URL 和 URN 一视同仁。那份 RFC 的标题正是「Uniform
Resource Identifier」。
警示 · WHATWG 的口径不一样。当代的 URL Standard 认为 URI、URN、IRI 这套术语区分「令人困惑且无实益」,干脆只用「URL」一个词统称所有这些字符串——连 urn:isbn:… 也照样能被 new URL() 解析。所以 URL 与 URI 的严格区分主要是 RFC
3986 的历史视角;写浏览器代码时,凡 new URL() 收得下的,它一概叫 URL。
2 · 一条 URL 的七段
按 WHATWG URL Standard,一条 URL 可以拆成 scheme、userinfo、host、port、path、query、fragment 七大块。浏览器内置的 URL 接口已经把这些切好了:new URL(str) 之后,每一块都是对象上的一个属性,完全不必自己写正则去切。
建议 · origin 等于 scheme 加 host 加 port,是浏览器同源策略 (same-origin policy) 的判定单位——https://a.com、https://a.com:8443、http://a.com 三者 origin 各不相同,互相算跨域。注意 origin 不含 userinfo、path、query 与 hash。
没有浏览器时由谁提供 new URL(),答案是 whatwg-url 包。本页所有拆解都直接调宿主(浏览器或现代 Node)内置的 URL,但像 jsdom 这种在纯 JS 环境模拟 DOM 的场景没有原生实现,于是把整份
URL Standard——连 URL parser、host parser、URL record、serializer 这些规范内部算法——用 JavaScript 完整实现了一遍。它既能当独立的 URL 解析器与 polyfill,又把底层算法暴露出来,供 jsdom 实现 <a>.href 这类要求「持有一个可变 URL record、改
.hash 就原地更新」的规范接口。内置 URL 之所以不够用,是因为它只给了组装好的成品外壳,parser、record、serializer 这些零件都封死了。普通代码里解析网址,直接用内置 URL 即可。
2.1 · 相对 URL 的解析
页面里写的 <a href="../about"> 不是完整 URL,浏览器要拿当前页地址当 base 拼出绝对地址。这套规则就是 new URL(relative, base) 的第二个参数。
警示 · 前缀决定一切,四种写法结果差别很大:以单个 / 开头是从 host 根算起;以 // 开头是更换 host、保留 scheme(protocol-relative URL);只写 ?q=1 或 #frag 则沿用 base 的 path;而一个带 scheme 的完整 URL 会直接忽略 base。
3 · host 的三段拆解
上一节里 URL.hostname 给出 shop.example.co.uk 这一整串,但它不回答:哪部分是可以去注册的域名、哪部分是注册局管的公共后缀?这件事 URL 接口刻意不处理,因为答案藏在一份外部清单 Public Suffix List (PSL) 里。本节用
tldjs(内置一份 PSL 快照)把 host 拆成 subdomain、domain、public suffix 三段。
警示 · 不能简单按 . 切。「取最后两段当域名」对 shop.example.com 没错(得 example.com),但对 shop.example.co.uk 就切错了——co.uk 整体才是一个后缀,真正的注册域名是三段的
example.co.uk。com.cn、github.io、s3.amazonaws.com 同理。后缀有几段、是哪些,只有 PSL 知道。
浏览器依赖 PSL 是为了守住一条安全边界。https://shop.example.co.uk 想设一个对整站生效的 cookie,浏览器必须知道「能向上设到 example.co.uk 为止,但不允许设到 co.uk」——否则一个站点就能给同后缀下其他所有网站设置 cookie。这条防线正是由 PSL 守住的。tldjs
把这份清单打进了包里,所以前端不联网也能算出 eTLD+1。
4 · query 串的解析
URL.search 给出一整串 ?id=42&ref=ad,但几乎从不该自己去 split('&') 再 split('=')——因为值里可能藏着被编码的 & 或 =,手动切分必然出错。浏览器内置的 URLSearchParams(也是 url.searchParams)把
query 当成一组有序、可重复的 key=value 来读写,编码解码全部代劳。
query 里历史上有两种空格编码:%20(标准 percent-encoding)与 +(来自 HTML 表单的 application/x-www-form-urlencoded)。URLSearchParams 两种都解码成空格,但序列化时统一吐 +。所以 q=a+b 与 q=a%20b 解析结果相同。
建议 · 给当前页 URL 添加一个参数,最稳妥的写法是 const u = new URL(location.href); u.searchParams.set('page', '2'); history.pushState(null, '', u);。全程不做字符串拼接,编码、已有参数、重复 key 都已处理妥当。
5 · percent-encoding 的各家分歧
前面几节里那些 %24 与 %E4%BD%A0 是如何得来的?这就是 percent-encoding(百分号编码):把字符按 UTF-8 取字节,每字节转两位十六进制,前面加 %。规则简单,问题却在别处——「哪些字符需要编码」规范并未规定死,各语言实现各不相同。encodeURIComponent 不处理
!'()*,而 PHP 的 rawurlencode 与 Python 的 quote 全部编码。一旦拿编码结果去计算签名、做鉴权,两端编出的串不一致,验签必然失败——这是开放 API 最常见的隐形陷阱。
5.1 · 字符到 UTF-8 字节再到 %XX
percent-encoding 编的是字节而不是字符。现代浏览器与主流语言统一先把字符按 UTF-8 编成字节流再逐字节转 %XX,所以汉字「你」对应字节 E4 BD A0,写作 %E4%BD%A0。历史上有过 GBK 等其他编码,那才是「中文 URL 乱码」的根源。
5.2 · 六种编码器的分歧
RFC 3986 只把字符明确分成两类。规范只把 unreserved 钉死为「永不编码」,reserved 这些分隔符要不要编码取决于它出现在 URL 哪一段——这份「留给实现自己定」的模糊地带,就是各家编码器分歧的来源。
警示 · 规范唯一明确的指引是 unreserved 等于字母、数字与 - . _ ~ 四个符号。业界实践于是收敛成一句话:除 unreserved 外,其余字符一律 percent-encode。PHP 的 rawurlencode 和 Python 的 quote(safe='') 正是这么做的,而 JS 的
encodeURIComponent 偏偏遗漏了 sub-delims 里的 !'()* 这五个。
图 5-2 里 JS 那三行用的是真实的 encodeURI 与 encodeURIComponent 当场跑;PHP 与 Python 三行按各自规范的安全字符集合算出来(rawurlencode 留 -_.~、urlencode 留 -_. 且空格变 +、quote(safe='') 留
-_.~),与真机行为一致。注意 encodeURI 是给整条 URL 用的,它故意保留 : / ? # & = + 等分隔符,不能拿来编单个参数值。
三处经典分歧可直接从表里读出:其一是 ! ' ( ) *,encodeURIComponent 不编、其余全编;其二是空格,多数给 %20,唯独 PHP 的 urlencode 给 +;其三是 ~,现代实现都保留,唯独 PHP 的 urlencode 仍将其编成 %7E。
建议 · 凡是要把编码结果参与签名、哈希或对账的场景,不要依赖语言默认的 urlencode——各端统一约定「除 A-Za-z0-9-_.~ 外一律 percent-encode」(即 fixedEncodeURIComponent、rawurlencode、quote(safe='')),差异自然消失。补全后的
fixedEncodeURIComponent 这一行在图 5-2 的对照表里会与 PHP 的 rawurlencode、Python 的 quote(safe='') 完全重合。
6 · slug:path 里那段给人读的 id
前面把 path 那段一带而过。但 path 里经常会出现一种专门为「给人看」而生的片段——slug,一个人类可读、唯一的标识符,用来代替数据库里那种不可读的 id(自增整数或 UUID)去引用一个资源,让人直接看出引用的是什么。
本节脱胎自 Dave Sag 的《What's a slug?》。那篇文章自己的 URL 就是个好例子:/@davesag/whats-a-slug-f7e74b6c23e0 里有两个 slug——用户名 @davesag,以及文章 slug whats-a-slug-f7e74b6c23e0。后半截那串短 uuid 是 Medium
加的,用于在同标题文章冲突时仍能保持唯一。
流水线里 normalize('NFD') 加删除组合符这一步是去重音的关键。é 在 Unicode 里可以是一个预组合字符,NFD 把它拆成基本拉丁字母加一个组合用的重音符 (U+0301);删掉组合符那一段,就只剩干净的字母本身。这是 deaccent 最简便的纯 JS 做法,不用查表。
警示 · CJK 与非拉丁标题会在这条流水线上失效。像「深入理解网址」这种纯汉字标题,NFD 不会把汉字拆成 ASCII,于是 [^a-z0-9] 全部过滤后什么都不剩,slug 变成空串。实际项目里此时要么转写(拼音或罗马化,如 shen-ru-li-jie-wang-zhi),要么退回用 id。本节的纯 ASCII
流水线会在这种输入下给出红字提示。
建议 · 原文给出的 Lean API 三原则值得照搬:其一,不返回数据库内部 id,而返回 slug;其二,只返回够用的最少数据;其三,返回足够数据以减少重复请求。即便某处 slug 看着冗余,为了一致性也总是把它带上——消费方往往能因此省去额外的复杂度。
一个好 slug 应当短、有描述性、全小写、不带重音、没有有歧义或难以辨认的字符,并且唯一。它既是给人看的(URL 与日志里可直接辨认),又因为唯一,同样能充当机器使用的 id——写日志时尤其推荐写 slug 而非裸 id。
当资源没有适合派生 slug 的标题、或需要一个不透出内部信息的随机 id 时,改用 nanoid。