跳到主内容
d.devtul.fun
EN
正则 · Slug

URL Slug 正则

一个正确又无聊的 slug 正则,以及更有用的那句话:slug 该生成,而不是校验。

正则模式

^[a-z0-9]+(?:-[a-z0-9]+)*$

匹配纯小写字母、数字、连字符组成、且不以连字符开头或结尾、也没有连续连字符的标识符。其余一律拒绝。

在线测试这个正则

在下面直接改文本 —— 匹配在本机完成,不会上传任何内容。

其它写法

同时允许大写
^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$

放宽了大小写限制,但在大小写不敏感的服务器上,仅靠大小写区分的两个网址会撞车。

用下划线代替连字符
^[a-z0-9]+(?:_[a-z0-9]+)*$

有些系统偏好下划线。选定一种并统一,同一站点混用两种是迟早要出的 bug。

这个正则到底匹配什么

最标准的 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 按下面顺序做事:

  1. 整体转小写。
  2. 规范化 Unicode(NFKD),让带调音符号的字符拆成"基础字母 + 组合标记",再剥掉标记:café 变成 cafe。
  3. 删掉目标字符集(字母、数字)之外的所有字符。
  4. 空白转连字符,连续的空白也并成一个连字符。
  5. 合并相邻连字符为一个。
  6. 去掉首尾连字符。

一个最小的 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]。
只校验、不生成用户乱输,你一拒了之,对方卡住。从标题生成,允许用户改。
忘了 Unicodecafé 残留组合标记成一团。先做 NFKD 规范化再去标记。

变体与更严格的版本

按技术栈不同,你可能想收紧正则:

  • 禁止纯数字 slug,避免 123 和数字 ID 撞车:加一个负向预查 ^(?![0-9]+$)[a-z0-9]+(?:-[a-z0-9]+)*$。
  • 要求至少两段的分层 slug;这该由路由层管,不该塞进 slug 本身。
  • 允许点号做类文件名 slug,仅当路由真的需要,并且要写进文档。

基础正则保持不变。只有当某个具体需求逼你时,才去加更严的规则。

常见问题

正则够不够校验 slug?

不够。正则只查形状,唯一性、保留字、路由都得靠应用代码来管。

slug 该生成还是该校验?

从标题生成,再让用户改。校验只是第二道防线,不是主路径。

中文怎么处理 slug?

要么音译成拉丁字母,要么保留原文做百分号编码。两条路 deliberate 地选一条并保持一致。

为什么不让 slug 带大写?

网址路径区分大小写,混用大小写容易撞车、断链。全小写直接消掉这个风险。

其它正则速查

打开完整的正则测试器