资讯动态

日期格式校验正则表达式:从基础匹配到闰年判断的完整指南

发布时间:2026/9/23 12:53:56 来源:尧图企业网站定制
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到091[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在部分语言的正则引擎里会匹配到全角数字比如这种字符而[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]表示中间的空格或字母TISO 8601标准里常用T来连接日期和时间。如果需要毫秒可以在秒后面加(\.[0-9]{1,3})?。时区部分Z或者08:00这种正则写起来比较啰嗦建议遇到带时区的时间直接用专门的日期解析库去处理不要硬刚正则。日期正则的核心价值是“快速筛选出形状正确的字符串”而不是替代一个完整的时间解析器。5. 实战环节不同语言里的写法与踩坑5.1 JavaScripttest、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 Pythonre.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中的数字字符比如阿拉伯文数字、全角数字“”如果你写\d{4}用户输入“--”也可能被匹配上。所以在日期这种对字符要求严格的场景里我建议一律用[0-9]。另外Python里re.match和re.search有区别match从字符串开头匹配所以配合^前缀作用类似search是扫描整个字符串如果你用search并且正则是^\d{4}$那其实match更合适。我习惯的做法是正则里明确写^和$这样用search也能达到整串匹配的效果。5.3 Java与C#字符串转义与RegexOptionsJava里写正则需要特别注意字符串转义反斜杠在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里如果字符串末尾有换行符可能匹配到换行前的位置严谨起见可以用\zPCRE或者对字符串先做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不能匹配就自然理解它到底在干什么了。想真正把正则学扎实最好的方式就是拆解自己遇到的每一个长正则弄清楚每一段缩写的意图下次再遇到类似需求时手比脑子快。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价