← URL Anatomy · 一条网址是怎么拆开的 / 拆解一条 URL:从 URI 到 slug 待审核 1 / 2
URL 接口 · tldjs

拆解一条 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 里「注册域名 vs 公共后缀」这件 URL 接口不负责的事,交给带 Public Suffix List 的 tldjs。每节都可修改输入、查看真实运行结果。

1 · URL、URI、URN 到底什么关系

动手拆 URL 之前先理清一组常被混用的词。URI(Uniform Resource Identifier,统一资源标识符)是总称——任何用来「指明一个资源」的字符串都是 URI。它下面分两支:URL(Uniform Resource Locator,定位符)告诉你资源在哪、怎么取;URN(Uniform Resource Name,名称)只给资源一个持久的名字,不管它存在哪。因此 URL ⊂ URIURN ⊂ URI,而日常说的「网址」几乎都是 URL。

一句话区分:URL 能让你「去拿到」资源——scheme 指明访问机制(https / ftp / mailto),authority / path 指明位置;URN 只「叫得出」资源——urn: 开头,在命名空间里唯一,资源换了存放位置名字也不变,但光凭名字不一定能直接取到(需另接一套解析服务)。

1.1 · 输入一个标识符,看它属于哪一类

输入任意标识符(或点预设),判定它是 URL 还是 URN,以及它带不带「定位信息」(authority):

为什么 JS 的接口叫 encodeURI 而不是 encodeURL? 因为 percent-encoding 编的是 RFC 3986 定义的 URI 通用语法,对 URL 和 URN 一视同仁——命名取了更上位的 URI。那份 RFC 的标题正是「Uniform Resource Identifier」。具体编码规则见下面 percent-encoding 那节。

WHATWG 的口径不一样: 当代的 URL Standard 认为 URI / URN / IRI 这套术语区分「令人困惑且无实益」,干脆只用「URL」一个词统称所有这些字符串——连 urn:isbn:urn:isbn:\dots 也照样能被 new URL() 解析。所以「URL vs URI」的严格区分主要是 RFC 3986 的历史视角;写浏览器代码时,凡 new URL() 收得下的,它一概叫 URL。

概念理清,下一节就把一条具体的 URL 拆成七段。

2 · 一条 URL 拆成几段

WHATWG URL Standard,一条 URL 可以拆成 scheme · userinfo · host · port · path · query · fragment 七大块。好消息是:浏览器内置的 URL 接口已经帮你把这些切好了——new URL(str) 之后,每一块都是对象上的一个属性,完全不用自己写正则去切。点任意一段(或下方属性行)看含义;修改输入看 URL 对象上各属性如何变化。注意几处容易出错的细节:protocol 带尾随的 :search 带头部的 ?hash 带头部的 #;默认端口(https 的 443)会被抹除

origin = scheme + host + port,是浏览器同源策略 (same-origin policy) 的判定单位——https://a.comhttps://a.com:8443http://a.com 三者 origin 各不相同,互相算「跨域」。注意 origin 不含 userinfo / path / query / hash。

没有浏览器时,谁来提供 new URL()? 本页所有拆解都直接调宿主(浏览器 / 现代 Node)内置的 URL。但像 jsdom 这种在纯 JS 环境模拟 DOM 的场景没有原生实现,于是把整份 URL Standard——连 URL parser、host parser、URL record、serializer 这些规范内部算法——用 JavaScript 完整实现了一遍,即 whatwg-url 包。它既能当独立的 URL 解析器 / polyfill,又把底层算法暴露出来,供 jsdom 实现 <a>.href 这类要求「持有一个可变 URL record、改 .hash 就原地更新」的规范接口。为什么不直接用内置 URL? 因为内置的只给了组装好的成品外壳,parser / record / serializer 这些零件都封死了,jsdom 拿不到——而它要实现的正是规范本身,需要的就是这些底层零件。若你只是普通代码里解析网址,直接用内置 URL 即可,无需它。

2.1 · 相对 URL:浏览器怎么「拼绝对地址」

页面里写的 <a href="../about"> 不是完整 URL,浏览器要拿当前页地址当 base 拼出绝对地址。这套规则就是 new URL(relative, base) 的第二个参数。点不同的相对写法,看它落到哪:

易错点:/ 开头是从 host 根算起(/xco.uk/x\dots co.uk/x);以 // 开头是更换 host、保留 scheme(protocol-relative URL);只写 ?q=1#frag沿用 base 的 path。一个带 scheme 的完整 URL 会直接忽略 base

host 还能继续往下拆成 subdomain / domain / public suffix,见下节。

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。这条「超级 cookie」防线,正是由 PSL 守住的。tldjs 把这份清单打进了包里,所以前端不联网也能算出 eTLD+1。

host 拆解之后,接着看 query 串的解析。

4 · query 串不是一个字符串

URL.search 给你一整串 ?id=42&ref=ad,但你几乎从不该自己去 split('&')split('=')——因为值里可能藏着被编码的 & / =,手动切分必然出错。浏览器内置的 URLSearchParams(也是 url.searchParams)把 query 当成一组有序、可重复的 key=value 来读写,编码解码全帮你做了。下面依次演示:解析一段 query(重复的 key 会标橙,get() 只返回第一个、getAll() 返回全部)、把一个值序列化时如何转义、以及在活动对象上 set / append / delete / sort。

+ 还是 %20? query 里历史上有两种空格编码:%20(标准 percent-encoding)与 +(来自 HTML 表单的 application/x-www-form-urlencoded)。URLSearchParams 两种都解码成空格,但序列化时统一吐 +。所以 q=a+bq=a%20b 解析结果相同。

实践: 给当前页 URL 添加一个参数最稳妥的写法——const u = new URL(location.href); u.searchParams.set('page', '2'); history.pushState(null, '', u);。全程不做字符串拼接,编码、已有参数、重复 key 都已为你处理妥当。

percent-encoding 的细节(那些 %24 / %E4%BD%A0 如何得来),见下节。

5 · 同一个字符,各家编码不一样

前面几节里那些 %24 / %E4%BD%A0 是如何得来的?这就是 percent-encoding(百分号编码 / urlencode):把字符按 UTF-8 取字节 → 每字节转两位 16 进制 → 前面加 %。规则简单,问题却在别处——「哪些字符需要编码」规范并未规定死,各语言实现各不相同encodeURIComponent 不处理 !'()*,而 PHP rawurlencode / Python quote 全部编码。一旦你拿编码结果去计算签名、做鉴权,两端编出的串不一致,验签必然失败——这是开放 API 最常见的隐形陷阱。

5.1 · 编码规则:字符 → UTF-8 字节 → %XX

任意输入几个字符,看每个字符被拆成几个 UTF-8 字节、再变成 percent-encoding。ASCII 一个字节,中文 / emoji 多个字节:

为什么是 UTF-8? percent-encoding 编的是字节不是字符。现代浏览器 / 主流语言统一先把字符按 UTF-8 编成字节流再逐字节 %XX——所以 = 字节 E4 BD A0 = %E4%BD%A0。历史上有过 GBK 等其他编码,那才是「中文 URL 乱码」的根源。

5.2 · 哪些字符算「安全」:reserved vs unreserved,以及六种编码器

RFC 3986 只把字符明确分成两类。规范只把 unreserved 钉死为「永不编码」,reserved 这些分隔符要不要编码取决于它出现在 URL 哪一段——这份「留给实现自己定」的模糊地带,就是各家编码器分歧的来源。下面先看字符分类,再输入任意内容,看 JS 三种、PHP 两种、Python 一种编码器各自输出什么(与 encodeURIComponent 不同的片段标黄),最后逐字符摊开分歧到底在哪:

规范唯一明确的指引是 unreserved = 字母 / 数字 / - . _ ~。业界实践于是收敛成一句话:「除 unreserved 外,其余字符一律 percent-encode」——PHP rawurlencode 和 Python quote(safe='') 正是这么做的。而 JS 的 encodeURIComponent 偏偏遗漏了 sub-delims 里的 !'()* 这 5 个。

JS 三个用的是真实 encodeURI / encodeURIComponent 当场跑;PHP / Python 三个按各自规范的「安全字符集合」算出来(rawurlencode-_.~urlencode-_. 且空格变 +quote(safe='')-_.~),与真机行为一致。注意 encodeURI 是给「整条 URL」用的,它故意保留 : / ? # & = + 等分隔符,不能拿来编单个参数值。

可直接读出三处经典分歧:其一 ! ' ( ) *——encodeURIComponent 不编,其余全编;其二 空格——多数给 %20,唯独 PHP urlencode+(表单 x-www-form-urlencoded 的历史);其三 ~——现代实现都保留,唯独 PHP urlencode 仍将其编成 %7E

结论: 凡是要把编码结果参与签名 / 哈希 / 对账的场景,不要依赖语言默认的 urlencode——各端统一约定 「除 A-Za-z0-9-_.~ 外,一律 percent-encode」(即 fixedEncodeURIComponent / rawurlencode / quote(safe='')),差异自然消失。补全后的 fixedEncodeURIComponent 这一行在上面对照表里会与 PHP rawurlencode、Python quote(safe='') 完全重合

path 里那段「人能读的 id」(slug)如何从标题派生,见下节。

6 · slug——path 里那段「人能读的 id」

前面把 path 那段一带而过(/cart/items)。但 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 加的,用于在「同标题文章冲突」时仍能保持唯一。slug 通常从资源的 name / title 派生而来,下面逐步演示这条标准流水线——全程不写复杂正则,只靠 String.prototypetrim / toLowerCase / normalize('NFD') 加两条朴素 replace:

为什么是 normalize('NFD') 再删 combining marks? é 在 Unicode 里可以是「一个预组合字符」,NFD 把它拆成 e + 一个「组合用的重音符」(U+0301)。删掉 ̀–ͯ 这段组合符,就只剩干净的 e——这是去重音 (deaccent) 最简便的纯 JS 做法,不用查表。

CJK / 非拉丁标题的注意点: 像「深入理解网址」这种纯汉字标题,NFD 不会把汉字拆成 ASCII,于是 [^a-z0-9] 全部过滤后什么都不剩,slug 变成空串。实际项目里此时要么转写(拼音 / 罗马化,如 shen-ru-li-jie-wang-zhi),要么退回用 id。本节的纯 ASCII 流水线会在这种输入下给出红字提示。

Lean API 设计三原则(摘自原文): 一个 API 应当 其一 不返回数据库内部 id,而返回 slug;其二 只返回够用的最少数据;其三 返回足够数据以减少重复请求。即便某处 slug 看着冗余,为了一致性也总是把它带上——消费方往往能因此省去额外的复杂度。

一个好 slug:有描述性全小写不带重音没有有歧义或难以辨认的字符、并且唯一。它既是给人看的(URL / 日志里可直接辨认),又因为唯一,同样能充当机器使用的 id——写日志时尤其推荐写 slug 而非裸 id。

当资源没有适合派生 slug 的标题、或需要一个不透出内部信息的随机 id 时,改用 nanoid——一个天生 URL 安全的随机 id 生成器。