1. 日期格式校验:需求拆解与整体思路
日期格式的正则表达式,这个需求在开发中出现的频率高得惊人。表单里的生日、合同里的签署日期、日志里的时间戳、数据库里的入库时间,只要是做过后端接口或者写过前端校验的人,几乎都碰到过“帮我校验一下这个日期格式”的需求。我见过很多新人上来就写一个\d{4}-\d{2}-\d{2},然后发现这个正则能匹配2024-01-01,也能匹配9999-99-99,更别说2024-02-30这种根本不存在的“日期”了。
在动手写正则之前,必须先把需求拆清楚。日期正则其实分三个层次,很多人没想明白自己要的是哪一个,导致要么写得过于宽松,要么写得过于复杂:
- 第一层,只匹配“看起来像日期”的字符串。比如从一段文本里把
2024-06-01这种形状的内容提取出来,不关心2月有没有30号。这适合日志提取、爬虫清洗这类对准确性要求不高的场景。 - 第二层,校验格式并且检查月日范围。比如月份必须是01到12,日期必须是01到31。这适合绝大多数表单校验场景,能挡住大多数无意义的输入。
- 第三层,校验日期是否真实存在。比如2023-02-29不合法因为2023年不是闰年,2024-02-30不合法因为2月没有30号。这个级别写起来最费劲,但确实在一些数据严苛的场景下有用。
明确了这三个层次,再写正则就不会盲目了。这篇文章我会把三层都写出来,从简单到复杂逐步拆解每一段的含义,再带入JavaScript、Python、Java、C#这些常见语言里说到说到实践经验和坑。
2. 基础版:年月日匹配的写法与原理
2.1 最简单的写法为什么不够用
先看大多数人第一反应写出来的版本:
\d{4}-\d{2}-\d{2}这个正则的含义是:4位数字、短横线、2位数字、短横线、2位数字。在匹配2024-06-01的时候没问题,但问题也很明显:
- 它会匹配
9999-99-99,因为99作为月份和日期,在正则眼里只是“两位数字”,它不知道月份最大是12。 - 它会匹配
2024-1-1吗?不会,因为\d{2}要求正好两位数字。如果你的业务允许用户输入不补零的日期,比如2024-6-1,那这个正则就直接误判了。 - 如果目标字符串是“今天是2024-06-01,明天是2024-06-02”,不加边界符的正则会匹配出两段日期,这在某些场景下可能是你要的,但如果你想校验“整个字符串必须是一个合法的日期”,就需要用
^和$把字符串两头锁住。
所以基础版至少要解决两件事:限制月份和日期的取值范围,加上边界限定。
2.2 限制月份和日期的取值范围
把月份限制在01到12,正则写法是这样的:
(0[1-9]|1[0-2])这里用了两个分支:0[1-9]匹配01到09,1[0-2]匹配10到12。如果业务允许不补零,也就是1到12,那写成(0?[1-9]|1[0-2]),多了一个0?表示前面可以有零也可以没有。很多团队会统一要求日期必须补零,这样数据看起来整齐,正则也简单些。
日期限制在01到31,写法是:
(0[1-9]|[12][0-9]|3[01])三个分支分别对应01到09、10到29、30到31。注意[12][0-9]不能写成[12]\d,因为\d在部分语言的正则引擎里会匹配到全角数字,比如12这种字符,而[0-9]只匹配ASCII的半角数字。安全起见,日期类正则我统一建议用[0-9]而不是\d。
合起来,一个基础的日期校验正则就是:
^(19|20)[0-9]{2}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$开头(19|20)[0-9]{2}是把年份限制在1900到2099之间,如果你要放宽,可以直接用[0-9]{4},但要注意0000这种年份在大多数业务场景里是没有意义的。这里就引出一个问题:年份的范围到底是业务决定的,还是正则决定的?我的建议是正则只做格式层面的粗筛,真正的业务范围(比如出生年份不能超过当前年份)交给代码逻辑判断,不要硬塞进正则是。
2.3 处理多种分隔符与捕获组引用
很多项目不止用2024-06-01这一种格式,还会遇到2024/06/01、2024.06.01。处理思路很简单,把分隔符写成字符组:
^[0-9]{4}([-/.])(0[1-9]|1[0-2])\1(0[1-9]|[12][0-9]|3[01])$这里有个小知识点:([-/.] )外面的括号是捕获组,把匹配到的分隔符存起来了,后面的\1是反向引用,要求第二个分隔符和第一个完全一样。这样才能保证2024-06/01这种“混搭风格”不会被误判为合法日期。
要特别留意的是,一旦你加了别的捕获组,\1的编号就会变。比如改成^([0-9]{4})([-/.])(0[1-9]|1[0-2])\2(0[1-9]|[12][0-9]|3[01])$,此时第1组是年份,第2组才是分隔符,所以反向引用要写\2而不是\1。这是初学者最容易踩的坑,后面在实战环节我会再提一次。
如果你不想数括号编号,也可以用非捕获组(?:...)把不需要引用的分组包起来,这样保持后面的编号不变。正则的可读性本来就一般,能给后人留一点善意就留一点。
3. 进阶版:月份天数与闰年规则的正则表达
3.1 为什么2月30号这种“假日期”会漏过去
上面写的基础版正则,能挡住13月、32号这种明显错误,但挡不住2月30号、4月31号、6月31号这种“格式合法但现实不存在”的日期。原因是(0[1-9]|[12][0-9]|3[01])里的3[01]允许了31号,而正则并不知道当前月份是哪个月、这个月到底有几天。
这其实是正则表达式的天然短板:它只做模式匹配,不具备“查日历”的能力。但如果我们非得用正则做,也不是完全做不到,思路就是把每个月的情况单独写成分支,让正则“记住”哪个月对应多少天。这样的正则虽然长,但每个分支的逻辑是清晰可读的。
3.2 大月、小月、二月的分支拆解
我们先不碰闰年,把平年的情况梳理一下:
- 大月,31天:1月、3月、5月、7月、8月、10月、12月。正则写法为
(?:0[13578]|1[02])-(?:0[1-9]|[12][0-9]|3[01])。 - 小月,30天:4月、6月、9月、11月。正则写法为
(?:0[469]|11)-(?:0[1-9]|[12][0-9]|30)。 - 2月平年,28天:正则写法为
02-(?:0[1-9]|1[0-9]|2[0-8])。
把这三段用|合起来,再套上年份前缀,就是一个能区分大小月的平年日期正则:
^[0-9]{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12][0-9]|3[01])|(?:0[469]|11)-(?:0[1-9]|[12][0-9]|30)|02-(?:0[1-9]|1[0-9]|2[0-8]))$你可以验证一下:2024-04-31在这个正则下是不匹配的,因为月份04走的是小月分支,日期只能是01到30。2024-02-28能匹配,但2024-02-29不匹配,因为我们还没处理闰年。
3.3 闰年判断的本质与正则模拟
闰年的规则说起来很简单:能被4整除但不能被100整除的年份是闰年;能被400整除的年份也是闰年。用代码写就是:
(year % 4 === 0 && year % 100 !== 0) || year % 400 === 0但正则没有取模运算,它只能通过数字的规律来模拟这个逻辑。这里有个小学数学知识:一个数能不能被4整除,只看最后两位组成的数字能不能被4整除。比如2024能不能被4整除,看24就行,24能被4整除,所以2024就能被4整除。同理,一个数能不能被100整除,看后两位是不是00。
于是闰年判断在正则里可以拆成两种情况:
- 非世纪闰年:年份后两位能被4整除且后两位不是00。这个条件等价于后两位匹配
(?:0[48]|[2468][048]|[13579][26]),前面再补上任意两位数字。 - 世纪闰年:年份后两位是00,且前两位能被4整除。也就是匹配
(?:0[48]|[2468][048]|[13579][26])00。
整个闰年2月29日的正则分支可以写成:
(?:(?:[0-9]{2}(?:0[48]|[2468][048]|[13579][26])|(?:0[48]|[2468][048]|[13579][26])00)-02-29)这里面的[2468][048]和[13579][26]是什么意思?逐位拆开看:能被4整除的两位数,个位只能是0、4、8或者2、6;十位是奇数时,个位只能是2或6;十位是偶数时,个位只能是0、4、8。所以[2468][048]覆盖了十位为2/4/6/8的情况,[13579][26]覆盖了十位为1/3/5/7/9的情况。再加上0[48]覆盖04、08,三类合起来就是所有能被4整除的两位数字。
这种写法说实话不好读,但它确实是正则模拟“能被4整除”的经典写法。我自己更推荐把闰年判断放在正则之外,因为正则里塞这种逻辑,后续维护的人大概率要花半小时才能反应过来。
4. 完整版:具备日期合法性校验的正则封装
4.1 一个相对完整的日期正则
把前面几节的内容拼起来,再加上闰年分支,就可以得到一个能判断“日期真实存在”的正则。这里给一版比较常用的写法:
^(?:(?!0000)[0-9]{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12][0-9]|3[01])|(?:0[469]|11)-(?:0[1-9]|[12][0-9]|30)|02-(?:0[1-9]|1[0-9]|2[0-8]))|(?:[0-9]{2}(?:0[48]|[2468][048]|[13579][26])|(?:0[48]|[2468][048]|[13579][26])00)-02-29)$这段正则看起来很长,但其实结构是清晰的:最外层是一个大分组,左边是平年分支,右边是闰年2月29日的分支。
我先用几个例子验证一下:
| 输入 | 是否匹配 | 原因 |
|---|---|---|
| 2024-06-01 | 匹配 | 正常的闰年日期 |
| 2024-02-29 | 匹配 | 2024是闰年,2月有29日 |
| 2023-02-29 | 不匹配 | 2023不是闰年 |
| 2024-04-31 | 不匹配 | 4月只有30天 |
| 2024-13-01 | 不匹配 | 13月不存在 |
| 1900-02-29 | 不匹配 | 1900能被4整除但不能被100整除,不是闰年 |
| 2000-02-29 | 匹配 | 2000能被400整除,是闰年 |
测试下来这版逻辑是通的。但我也要说清楚,这段正则有一个缺陷:年份范围没有限制,(?!0000)只能排除0000年,其他任何四位数都能走平年分支或闰年分支。如果业务上只允许1900到2099,可以把[0-9]{4}替换成(?:19|20)[0-9]{2},其他部分保持不变。
4.2 逐段拆解完整正则的构成
我习惯把这种长正则拆成三块来看:
第一块是年份部分:(?!0000)[0-9]{4}。(?!0000)是负向前瞻,表示接下来的四位不能是0000。这里用前瞻而不是直接写[1-9][0-9]{3},是因为后者会排除掉0123这种以0开头的年份,而用前瞻只排除“全零”这一个特殊情况,更通用。
第二块是平年的月日部分:(?:0[13578]|1[02])-(?:0[1-9]|[12][0-9]|3[01])对应大月,(?:0[469]|11)-(?:0[1-9]|[12][0-9]|30)对应小月,02-(?:0[1-9]|1[0-9]|2[0-8])对应平年2月。三个分支用|连接,外加一个非捕获组(?:...)包起来。
第三块是闰年2月29日的部分:(?:[0-9]{2}(?:0[48]|[2468][048]|[13579][26])|(?:0[48]|[2468][048]|[13579][26])00)-02-29。前半段是普通闰年,后半段是世纪闰年。
每次要排查这种正则的时候,我建议在在线正则工具里打开忽略大小写的开关,把每段用注释分隔来测试。比如regex101这个工具,它支持在正则里写注释,遇到长正则会比肉眼看舒服得多。
4.3 扩展:日期时间格式与带补零要求
实际项目中我们更常遇到的是日期时间格式,比如2024-06-01 12:30:00或者ISO格式的2024-06-01T12:30:00.123Z。日期时间格式的正则,可以在日期正则后面追加时间部分:
^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])[ T]([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$这里时间部分同样要注意:
([01][0-9]|2[0-3])是24小时制的小时,01到09、10到19、20到23。[0-5][0-9]是分钟和秒,因为分钟和秒最大就是59。[ T]表示中间的空格或字母T,ISO 8601标准里常用T来连接日期和时间。
如果需要毫秒,可以在秒后面加(\.[0-9]{1,3})?。时区部分Z或者+08:00这种,正则写起来比较啰嗦,建议遇到带时区的时间直接用专门的日期解析库去处理,不要硬刚正则。日期正则的核心价值是“快速筛选出形状正确的字符串”,而不是替代一个完整的时间解析器。
5. 实战环节:不同语言里的写法与踩坑
5.1 JavaScript:test、match与new Date的坑
JavaScript里的正则字面量方式写起来最自然:
const datePattern = /^(?:(?!0000)[0-9]{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12][0-9]|3[01])|(?:0[469]|11)-(?:0[1-9]|[12][0-9]|30)|02-(?:0[1-9]|1[0-9]|2[0-8]))|(?:[0-9]{2}(?:0[48]|[2468][048]|[13579][26])|(?:0[48]|[2468][048]|[13579][26])00)-02-29)$/; datePattern.test("2024-02-29"); // true datePattern.test("2023-02-29"); // false在JavaScript里有一个非常经典的坑:new Date("2024-02-30")并不会返回“无效日期”,而是会静默地把2月30日解析成3月1日。所以如果你用正则校验通过之后,又用new Date()做了二次校验,很容易出现“正则认为不合法,new Date认为合法”的错觉。正确的做法是校验完再对比一下日期字符串是否保持不变:
function isValidDate(str) { const pattern = /^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/; if (!pattern.test(str)) return false; const date = new Date(str); return date.toISOString().slice(0, 10) === str; }这里toISOString()会把日期转成UTC时区的字符串,所以本地时区是东八区也不会影响结果,因为new Date("2024-06-01")看到的是UTC时间的零点。这个方法在只需要验证年月日的时候特别管用。
5.2 Python:re.match与\d的坑
Python里用re模块就能处理:
import re pattern = r"^(?:(?!0000)[0-9]{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12][0-9]|3[01])|(?:0[469]|11)-(?:0[1-9]|[12][0-9]|30)|02-(?:0[1-9]|1[0-9]|2[0-8]))|(?:[0-9]{2}(?:0[48]|[2468][048]|[13579][26])|(?:0[48]|[2468][048]|[13579][26])00)-02-29)$" re.match(pattern, "2024-02-29") # 返回匹配对象 re.match(pattern, "2023-02-29") # 返回 None这里要提醒一个和\d相关的坑。Python的re模块默认情况下\d能匹配Unicode中的数字字符,比如阿拉伯文数字、全角数字“123”,如果你写\d{4},用户输入“2024-06-01”也可能被匹配上。所以在日期这种对字符要求严格的场景里,我建议一律用[0-9]。
另外Python里re.match和re.search有区别:match从字符串开头匹配,所以配合^前缀作用类似;search是扫描整个字符串,如果你用search并且正则是^\d{4}$,那其实match更合适。我习惯的做法是正则里明确写^和$,这样用search也能达到整串匹配的效果。
5.3 Java与C#:字符串转义与RegexOptions
Java里写正则需要特别注意字符串转义,反斜杠在Java字符串里必须写成\\:
import java.util.regex.Pattern; String regex = "^(?:(?!0000)[0-9]{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12][0-9]|3[01])|(?:0[469]|11)-(?:0[1-9]|[12][0-9]|30)|02-(?:0[1-9]|1[0-9]|2[0-8]))|(?:[0-9]{2}(?:0[48]|[2468][048]|[13579][26])|(?:0[48]|[2468][048]|[13579][26])00)-02-29)$"; Pattern pattern = Pattern.compile(regex); System.out.println(pattern.matcher("2024-02-29").matches()); // trueJava的Matcher.matches()方法要求整个字符串完全匹配,所以写不写^和$效果一样,但为了统一风格我还是会在正则里写上。
C#的写法跟Java很接近,区别是C#的正则更容易踩到性能坑。如果一段日期正则在循环里被反复调用,建议使用RegexOptions.Compiled来编译正则:
using System.Text.RegularExpressions; var pattern = new Regex(@"^(?:(?!0000)[0-9]{4}-...$)", RegexOptions.Compiled); pattern.IsMatch("2024-02-29"); // trueCompiled选项首次匹配会花更多时间做编译,但后续匹配会快很多。如果只是零星几次校验,就别加这个选项了,否则反而更慢。
5.4 从文本中提取日期:先粗筛再精洗
很多场景不需要“整个字符串必须是一个日期”,而是要把一段文本里的日期抠出来。比如从爬虫抓取的网页正文里提取发布时间,从客服聊天记录里提取日期。这种提取需求,我建议用宽松正则会更快更好维护:
import re text = "活动时间:2024-06-01至2024/06/05,请提前一天报名。" dates = re.findall(r"[0-9]{4}[-/.](?:0?[1-9]|1[0-2])[-/.](?:0?[1-9]|[12][0-9]|3[01])", text) print(dates) # ['2024-06-01', '2024/06/05']注意这里没有加^和$,日期是从文本中间找到的。而且我故意把月份和日期的正则写成了0?[1-9]这种允许不补零的版本,因为真实场景中用户可能写“2024-6-1”而不是标准的“2024-06-01”。提取出来之后,再统一做一轮标准化转换,用日期库或者手动split成三段,转成规范的ISO格式。正则在这个环节只需要做“找出来”的粗活,不要让它连“判断是否真实存在”也一起做了。
热搜词里有个“提取出中间的数字及#符号后的字符串”,本质上就是这种“先粗提取再清洗”的思路。先用一个宽松的正则把命中的文本段切出来,然后再用replace或者第二个正则去掉干扰符号,比企图写一个一步到位的超大正则要可靠得多。
6. 常见问题与排查技巧实录
6.1 正则只匹配了日期的一部分而不是完整日期
这是很多人第一次调试日期正则时的困惑:明明写的是2024-06-01,但正则从一段较长的数字串中间就把2024-06或者06-01匹配出来了。原因就是缺少边界符,正则引擎只要在字符串里找到一个位置能从头开始匹配到“某个子串”就算成功。
解决办法:
- 希望整个字符串就是日期,那就必须在开头加
^、结尾加$。 - 希望从文本里提取所有日期,不要加
^$,但可以考虑加“前后不能是数字”这种约束,用负向前瞻和负向后顾,比如(?<!\d)和(?!\d),防止匹配到一段连续数字中间的一部分。 - 正则的
$在JavaScript里如果字符串末尾有换行符,可能匹配到换行前的位置,严谨起见可以用\z(PCRE)或者对字符串先做trim()再校验。
6.2 用\d匹配到了全角数字或者意外字符
不同语言里\d的含义不一样。JavaScript的\d是[0-9]的简写,行为还算规矩;Python的re模块\d默认匹配Unicode数字,范围包括阿拉伯文数字、全角数字等;Java的\d默认是\p{Digit},也包含一些Unicode范围。结果就是,你以为只匹配半角数字,实际把各种“看起来像数字”的字符都放进来了。
排查技巧很简单:把正则里的\d全部替换成[0-9],再重新跑测试用例。日期正则的字符集越明确越安全,不要偷懒写\d。这个习惯养成了,以后写其他数字类正则也能少踩很多坑。
6.3 存在性能风险的长正则
有些日期正则为了追求“一步到位”,会把月份、日期、闰年判断全都堆在一起,嵌套很多分组。这种正则在短字符串上一般没问题,但如果被用在大量数据的扫描场景,并且正则里出现了(a+)+这类嵌套量词,就有可能触发灾难性回溯,导致程序卡死。
日期正则里最常见的性能隐患是把[0-9]{4}写成[0-9]+,然后在后面又加了-和更多字符。遇到匹配失败的长文本时,正则会不断尝试各种拆分方式,消耗指数级的时间。我的建议是:
- 能用
{n}精确表示次数就不用+。 - 尽量使用非捕获组
(?:...),避免不必要的回溯记录。 - 如果在日志清洗这类高性能要求的场景使用,先做一次简单的
indexOf('-')之类的预筛,再跑正则,性能会好很多。
6.4 正则校验通过了,但日期解析函数却报错
正则毕竟只能做模式匹配,它无法理解“2024年2月29日是存在的,但2023年2月29日不存在”这种语义。有时候正则写得太宽松,比如只校验到月日范围这层,2024-02-30就会通过校验。如果后端恰好用了Date.parse或者datetime.strptime这类解析函数,轻则返回一个“被修正”的日期,重则直接抛异常。
我的处理习惯是:
- 表单提交这种用户入口,用中间层级的日期正则做格式校验,然后用语言自带的日期解析做合法性校验。比如Python里用
datetime.strptime(str, "%Y-%m-%d"),它天然会拒绝不存在的日期。 - 如果坚持要用正则做完整合法性校验,那就要付出维护成本,并且把文章第4节那种长正则写清楚注释,否则三个月后的自己看到这段正则也会一头雾水。
6.5 常见问题速查表
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 2023-02-29被匹配 | 正则没有处理闰年分支 | 加入闰年判断分组,或在代码里用日期函数二次校验 |
| 2024-04-31被匹配 | 正则没有区分大月小月 | 将月份拆成31天、30天、2月三组分别匹配 |
| 2024/06-01被匹配 | 分隔符没有用反向引用保持约束 | 用([-/.] )捕获分隔符并通过\1或\2引用 |
| 12:60:00被匹配 | 分秒范围写错成了[0-9]{2} | 改成[0-5][0-9] |
| 2024-1-1不被匹配 | 正则要求必须补零 | 在月份和日期前加0?支持不补零输入 |
| 文本中间一段被误匹配 | 缺少边界条件 | 加^$,或用(?<!\d)和(?!\d)限定前后边界 |
这份表格里的每一条,我基本都在真实项目里遇到过。尤其是第2和第3条,几乎每个写过日期正则的同事都会中招一次。排查的核心思路其实很简单:不要急着改正则,先用一组已知合法的日期和一组已知非法的日期,把当前正则跑一遍,看它到底卡在哪一层。能定位到具体是月份分支还是日期分支的问题,剩下的就是照着规则改。
写在最后
日期正则写过几轮之后,我个人的习惯是分场景选择正则的复杂程度:对用户交互场景,用“格式校验+日期函数二次校验”的组合,正则不会难维护,数据也不会出错;对日志或爬虫提取场景,用宽松正则会省很多事,提完之后再统一清洗;只有极少数不允许引入依赖、又必须严格校验的场景,才会搬出那种带闰年分支的长正则。
如果你第一次接触完整版闰年正则,被[2468][048]|0[48]这种写法搞晕了,我的建议是先别急着背,拿几个具体年份在在线工具里跑一下,看到2024能匹配、1900不能匹配,就自然理解它到底在干什么了。想真正把正则学扎实,最好的方式就是拆解自己遇到的每一个长正则,弄清楚每一段缩写的意图,下次再遇到类似需求时,手比脑子快。