最近在做一个定时任务管理后台,需要让用户配置执行时间。一开始想直接让用户输入 Cron 表达式,结果被产品经理怼回来了:“你让运营同事填 0 0 9 * * 1-5?他们只会填’工作日早上 9 点’。”
确实,Cron 表达式对开发者来说很熟悉,但对普通用户简直是天书。干脆做一个双向转换工具:用户选时间,自动生成 Cron;输入 Cron,可视化展示执行时间线。
Cron 表达式的本质
Cron 就是一个时间匹配规则。标准格式是 5 个字段:
分 时 日 月 周
有些系统支持 6 字段,开头加秒:
秒 分 时 日 月 周
每个字段都有特定的取值范围:
| 字段 | 范围 | 说明 |
|---|---|---|
| 秒 | 0-59 | 第几秒执行 |
| 分 | 0-59 | 第几分执行 |
| 时 | 0-23 | 几点执行 |
| 日 | 1-31 | 几号执行 |
| 月 | 1-12 | 几月执行 |
| 周 | 0-6 | 周几执行(0=周日) |
每个字段还支持特殊语法:
*- 任意值5- 固定值1-5- 范围*/5- 步长(每 5 个单位)1,3,5- 列表L- 最后一天(仅日期字段)
核心算法:时间匹配
最核心的是判断某个时间点是否匹配 Cron 表达式。
单字段匹配
先实现单个字段的匹配:
function matchesCronField(value: number, field: string, min: number, max: number): boolean {
// * 匹配任意值
if (field === '*') return true
// 支持逗号分隔的列表:1,3,5
const parts = field.split(',')
for (const part of parts) {
const p = part.trim()
// 处理步长:*/5 或 1-10/2
if (p.includes('/')) {
const slashIdx = p.indexOf('/')
const range = p.substring(0, slashIdx)
const step = parseInt(p.substring(slashIdx + 1))
if (isNaN(step) || step <= 0) continue
let start = min
let end = max
if (range !== '*') {
if (range.includes('-')) {
const dashIdx = range.indexOf('-')
start = parseInt(range.substring(0, dashIdx))
end = parseInt(range.substring(dashIdx + 1))
} else {
start = parseInt(range)
}
}
// 检查是否在范围内且符合步长
if (value >= start && value <= end && (value - start) % step === 0) return true
continue
}
// 处理范围:1-5
if (p.includes('-')) {
const dashIdx = p.indexOf('-')
const start = parseInt(p.substring(0, dashIdx))
const end = parseInt(p.substring(dashIdx + 1))
if (!isNaN(start) && !isNaN(end) && value >= start && value <= end) return true
continue
}
// 固定值
const num = parseInt(p)
if (!isNaN(num) && value === num) return true
}
return false
}
这个函数处理了所有 Cron 字段语法:*、5、1-5、*/5、1-10/2、1,3,5。
完整时间匹配
有了单字段匹配,完整时间匹配就简单了:
function matchesCron(date: Date, second: string, minute: string, hour: string, day: string, month: string, weekday: string): boolean {
// 检查秒
if (!matchesCronField(date.getSeconds(), second, 0, 59)) return false
// 检查分
if (!matchesCronField(date.getMinutes(), minute, 0, 59)) return false
// 检查时
if (!matchesCronField(date.getHours(), hour, 0, 23)) return false
// 处理 L(最后一天)
if (day === 'L') {
const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
if (date.getDate() !== lastDay) return false
} else if (!matchesCronField(date.getDate(), day, 1, 31)) {
return false
}
// 检查月
if (!matchesCronField(date.getMonth() + 1, month, 1, 12)) return false
// 检查周几(注意:0 和 7 都表示周日)
const dow = date.getDay()
if (!matchesCronField(dow, weekday, 0, 6)) {
if (dow !== 0 || !matchesCronField(7, weekday, 0, 7)) {
return false
}
}
return true
}
几个细节:
- L 的处理:计算当前月份的最后一天,比较日期是否匹配
- 周日的特殊性:有些系统用 0 表示周日,有些用 7,都要支持
- 月份从 1 开始:JavaScript 的
getMonth()返回 0-11,要 +1
计算下次执行时间
有了匹配函数,计算下次执行时间就是逐个时间点检查:
function getNextExecutions(cron: string, count: number = 10): Date[] {
const parts = cron.trim().split(/\s+/)
if (parts.length < 5 || parts.length > 6) return []
// 解析各字段
const isSixField = parts.length === 6
const second = isSixField ? parts[0] : '0'
const minute = isSixField ? parts[1] : parts[0]
const hour = isSixField ? parts[2] : parts[1]
const day = isSixField ? parts[3] : parts[2]
const month = isSixField ? parts[4] : parts[3]
const weekday = isSixField ? parts[5] : parts[4]
const results: Date[] = []
let current = new Date()
current.setMilliseconds(0)
current.setSeconds(current.getSeconds() + 1) // 从下一秒开始
// 优化:根据秒字段决定步长
const stepMs = (second === '*' || second.includes('/')) ? 1000 : 60000
while (results.length < count) {
if (matchesCron(current, second, minute, hour, day, month, weekday)) {
results.push(new Date(current))
}
// 按步长递增
if (stepMs === 60000) {
current.setSeconds(0)
current.setMinutes(current.getMinutes() + 1)
} else {
current = new Date(current.getTime() + 1000)
}
// 防止无限循环:超过一年就停止
if (current.getTime() - startTime > 365 * 24 * 60 * 60 * 1000) {
break
}
}
return results
}
性能优化
直接逐秒检查太慢了。几个优化思路:
- 步长优化:如果秒字段是固定值(如
0),可以按分钟递增,而不是按秒 - 跳跃优化:如果当前分钟不匹配,直接跳到下一个可能匹配的分钟
- 缓存优化:对于固定的 Cron 表达式,可以缓存匹配模式
// 步长优化示例
const stepMs = (second === '*' || second.includes('/')) ? 1000 : 60000
if (stepMs === 60000) {
// 按分钟递增
current.setSeconds(0)
current.setMinutes(current.getMinutes() + 1)
} else {
// 按秒递增
current = new Date(current.getTime() + 1000)
}
这个优化对于 0 0 9 * * 1-5(工作日早上 9 点)这样的表达式,性能提升 60 倍。
一些边界情况
实现过程中踩过的坑:
1. 日期和周几的冲突
Cron 标准中,日期和周几是"或"关系,不是"且"关系:
0 0 0 15 * 1 = 每月 15 号 或 每周一,不是"每月 15 号且是周一"
但有些实现是"且"关系。我们在工具里做了明确说明,避免歧义。
2. 2 月 30 号
0 0 0 30 2 * 这个表达式在大多数年份永远不会执行。工具需要在时间线计算中检测这种情况,给出提示。
3. 时区问题
Cron 表达式本身不带时区信息。如果服务器在 UTC+8,用户在 UTC+5,需要明确说明表达式是基于哪个时区的。
// 建议在工具中显示时区
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
console.log(`当前时区: ${timezone}`) // Asia/Shanghai
4. L 的计算
计算月份最后一天:
// 下个月的第 0 天 = 这个月的最后一天
const lastDay = new Date(year, month + 1, 0).getDate()
这个技巧很实用,避免了判断闰年的复杂逻辑。
可视化时间线
有了 getNextExecutions 函数,可视化就简单了:
const executions = getNextExecutions('0 0 9 * * 1-5', 10)
executions.forEach((date, i) => {
console.log(`${i + 1}. ${date.toLocaleString('zh-CN')}`)
})
// 输出:
// 1. 2026/4/27 09:00:00(周一)
// 2. 2026/4/28 09:00:00(周二)
// 3. 2026/4/29 09:00:00(周三)
// ...
在 UI 中,用列表展示这些时间点,用户一眼就能确认配置是否正确。
最终效果
基于以上思路,做了一个在线工具:Cron 表达式生成器
主要功能:
- 可视化配置时间,自动生成 Cron 表达式
- 输入 Cron 表达式,显示接下来 50 次执行时间
- 26 个常用预设模板(每分钟、每小时、工作日早上 9 点等)
- 支持 6 字段格式(带秒)
核心算法不复杂,但把边界情况处理好需要花些心思。希望这篇对你有帮助。
相关工具:时间戳转换 | Base64 编解码