这个正则到底匹配什么
最标准的 slug 正则很短,也很直白:
^[a-z0-9]+(?:-[a-z0-9]+)*$
逐段拆开看:
^和$把匹配锚定到整串字符。没有它们,正则会在更长的非法字符串里"捡出"一段合法 slug 就误判通过。[a-z0-9]+匹配一个或多个小写字母或数字。这是 slug 的"第一个词",必须存在,所以 slug 不能以连字符开头。(?:-[a-z0-9]+)*是一个可重复零次或多次的非捕获组。每次重复都是"一个连字符 + 一个或多个字母数字"。因为连字符后面永远跟着字母或数字,所以绝不可能出现两个连字符相连、结尾连字符,也不会有空段。
最终得到的正是网址片段应有的样子:my-first-post、product-123、a-b-c。凡是开头连字符、结尾连字符、双连字符、大写、空格或符号,一律被拒。
slug 为什么存在
slug 是网址里用来标识某条内容的可读部分:/blog/my-first-post 或 /shop/product-123。博客文章、商品页、分类页、文档,只要需要稳定又易懂的地址来做 SEO,就少不了它。
slug 的约束不是哪本风格手册定的,而是 URL 本身的工作方式逼出来的:
- 网址路径区分大小写。在大多数服务器上
/Post和/post是两个页面。全部小写能消掉一整类"为什么我的链接 404"的怪事。 - 可读性。slug 是要被人读、被人手敲、被人转发的。空格和标点会让它变难看,并被百分号编码成一堆
%20、%2C这样的乱码。 - 避开百分号编码。安全字符集之外的每个字符都会变成
%XX。干净的 slug 让网址又短又像正经链接。
这套安全字符,说白了就是小写字母、数字和连字符。正则就是这么来的。
真正该做的是生成,不是校验
这里有个不太好听但很重要的真相:slug 正则擅长"拒绝"坏输入,却没法"造出"好 slug。如果你只做校验,就是把"自己想出 my-first-post"这件事丢给用户,而他们多半会想出 My First Post!、post (2) 或者 café-au-lait。
正确的设计是:从源字符串(通常就是标题)生成 slug,再把它展示给用户确认。校验退居二线,只当最后一道防线。这也解释了为什么每个 CMS、每个静态站点生成器、每个电商平台都自带一个 slugify 函数,而不是一个"slug 校验"对话框。
一个 slug 生成算法
一个靠谱的 slugifier 按下面顺序做事:
- 整体转小写。
- 规范化 Unicode(NFKD),让带调音符号的字符拆成"基础字母 + 组合标记",再剥掉标记:
café变成cafe。 - 删掉目标字符集(字母、数字)之外的所有字符。
- 空白转连字符,连续的空白也并成一个连字符。
- 合并相邻连字符为一个。
- 去掉首尾连字符。
一个最小的 Python 版本:
import re, unicodedata
def slugify(text):
text = text.lower()
text = unicodedata.normalize('NFKD', text)
text = ''.join(c for c in text if not unicodedata.combining(c))
text = re.sub(r'[^a-z0-9]+', '-', text)
return text.strip('-')
它把 "My First Post!" 生成 my-first-post,把 "Café au lait" 生成 cafe-au-lait。
非拉丁文字怎么办
上面的算法默认是拉丁字母。遇到中文、日文、阿拉伯文、西里尔文标题,你有两条诚实的路,而且应该 deliberate 地选:
- 音译成拉丁字母再做 slug(中文用拼音,其他语言用罗马化)。这样网址短、全球可读,但丢了原文措辞。
- 保留原文字并做百分号编码。现代浏览器和服务器处理 UTF-8 路径没问题,但网址里会是一长串
%E6%...。合法,只是丑,而且个别老旧代理会处理出错。
很多站点两条都做:网址用音译 slug,页面上显示原文标题。别闷声选一条然后吓用户一跳。
长度上限与重复处理
slug 正则管不了长度。两条务实规则:
- 限长。50 到 80 个字符足够。太长的 slug 在搜索结果里会被截断,看着也像垃圾。可能的话在词边界上截断。
- 查重。两篇文章都生成出
my-post时,追加计数器:my-post-2、my-post-3。这是数据库的唯一性约束,正则做不了。
这个正则判断不了的事
就算正则再完美,它也只校验形状,不校验含义:
- 它不知道
product-123是不是已经被占用。 - 它分不清保留字(
admin、api、www)和普通 slug。 - 它照样愉快地接收
aaaaaa,跟接收useful-title没两样。 - 它对网址其余部分不闻不问;
/blog//my-post是路由问题,不是 slug 问题。
把正则当作边界上的廉价过滤器,真正的工作(唯一性、路由、保留字)交给应用代码。
常见错误写法
| 错误写法 | 为什么错 | 正确写法 |
|---|---|---|
^[a-z0-9-]+$ | 允许开头、结尾连字符,以及 -- 双连字符。 | ^[a-z0-9]+(?:-[a-z0-9]+)*$ |
^[\w-]+$ | \w 包含大写和下划线,连字符位置照样失控。 | 显式写出 [a-z0-9]。 |
| 只校验、不生成 | 用户乱输,你一拒了之,对方卡住。 | 从标题生成,允许用户改。 |
| 忘了 Unicode | café 残留组合标记成一团。 | 先做 NFKD 规范化再去标记。 |
变体与更严格的版本
按技术栈不同,你可能想收紧正则:
- 禁止纯数字 slug,避免
123和数字 ID 撞车:加一个负向预查^(?![0-9]+$)[a-z0-9]+(?:-[a-z0-9]+)*$。 - 要求至少两段的分层 slug;这该由路由层管,不该塞进 slug 本身。
- 允许点号做类文件名 slug,仅当路由真的需要,并且要写进文档。
基础正则保持不变。只有当某个具体需求逼你时,才去加更严的规则。