扩展:nanoid——path 里那段「机器读的 id」
slug 那节给的是「给人看」的标识符;但很多资源没有适合派生 slug 的标题,或者干脆需要一个不透出任何内部信息的随机 id。这正是 nanoid 的位置——一个极小(压缩后约 130 字节)、用
密码学随机源、且天生 URL 安全的唯一 id 生成器。它和 slug 互为补充:slug 给人读,nanoid 给机器认,两者都能直接出现在 path 里。本页所有 id 都是当场用浏览器的 crypto.getRandomValues 跑 nanoid 算法得出的,不是预置字符串;熵与碰撞概率也按你选的参数实时计算。
1 · 现场生成:默认 21 个字符
默认 nanoid() 返回 21 个字符。拖动滑块改长度、点按钮重新生成,观察每一个结果都只由 A-Za-z0-9_- 组成——可以原样塞进 URL 任何一段,无需 percent-encoding:
这几行就是 nanoid 的全部核心。 crypto.getRandomValues 一次取 size 个密码学随机字节,每个字节用 & 63 取低 6 位(),正好索引到 64 字符字母表里的一个字符。没有 Math.random、没有时间戳、没有计数器——因此不可预测、不可枚举,适合做对外暴露的资源 id。
2 · 为什么「天生 URL 安全」:字母表就是 unreserved 集合
nanoid 默认字母表共 64 个字符。把它逐个摊开看,它的字符集合恰好等于 percent-encoding 那节讲的 RFC 3986 unreserved——即 A-Z a-z 0-9 再加 - 与 _(顺序被打乱过,但集合不变):
结论: unreserved 字符任何实现都不编码(见 percent-encoding 节)。所以 nanoid 出现在 path / query / fragment 任意位置都原样保留,既不会被 encodeURIComponent 改写、也不会在双重编码 /
签名对账里出错。这就是它名字里 URL-friendly 的含义。
3 · size · 字母表 → 熵 → 碰撞概率
id 够不够「唯一」,取决于它的熵:。选一种字母表、调一个长度,看 ID 空间有多大、以及要生成多少个才有 1% 的碰撞概率(按生日界估算):
和 UUID v4 比: UUID v4 有 122 bit 随机熵,但字符串长 36 位(含 4 个连字符、32 个 16 进制位)。nanoid 默认 21 位就有 126 bit——因为字母表更大(64 vs 16),同样的安全度下串更短,且不带 UUID 那种固定格式。
4 · 关键细节:为什么不用「取随机数再取模」
要把一个随机字节()映射到一个 K 字符的字母表,最直觉的写法是 byte % K——但这会引入 modulo bias:当 256 不能被 K 整除时,排在前面的
256 mod K 个字符会比其余字符多分到一个字节值,于是出现得更频繁,熵被悄悄削弱。下面用真实随机字节各打 6 万个样本,对比两种映射的分布(选不同 K 看效果):
nanoid 的做法: 先把 K 向上取到「最接近的 2 的幂减一」做掩码
,用 byte & mask 取值;落在 [0, K) 之外的就丢弃重抽(rejection sampling)。这样每个字符的概率严格相等,没有 bias。
默认字母表为何偏偏是 64? 因为
是 2 的整数次幂,256 能被它整除——取模根本不产生 bias,掩码 byte & 63 又永不触发 rejection。于是默认路径既均匀又零废弃、零循环,就是最上面那段 id += alphabet[byte & 63]。选 64 是把「URL 安全」「分布均匀」「实现最快」三件事一次性对齐的结果。
5 · 回到 url anatomy:四种 id 各管一段
URL 的 path 里那个标识符,可能是下面任意一种。它们不是互斥,而是分工:
实务里常见组合:对内用自增主键(数据库友好、可排序),对外用 nanoid 或 UUID(不泄露规模、不可枚举),再叠一层 slug 让 URL 可读。例如 /posts/v1StGXR8_Z5jdHi6B-myT/hello-world——前者保唯一与不可猜,后者保可读。
那个可读的后半段 hello-world 怎么从标题派生,见 slug 那节;为什么这些字符进 URL 不用转义,见 percent-encoding 那节。