从字符串到时间线:实现一个 Cron 表达式解析器

3 阅读5分钟

最近在做一个定时任务管理后台,需要让用户配置执行时间。一开始想直接让用户输入 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 字段语法:*51-5*/51-10/21,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
}

几个细节:

  1. L 的处理:计算当前月份的最后一天,比较日期是否匹配
  2. 周日的特殊性:有些系统用 0 表示周日,有些用 7,都要支持
  3. 月份从 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
}

性能优化

直接逐秒检查太慢了。几个优化思路:

  1. 步长优化:如果秒字段是固定值(如 0),可以按分钟递增,而不是按秒
  2. 跳跃优化:如果当前分钟不匹配,直接跳到下一个可能匹配的分钟
  3. 缓存优化:对于固定的 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 编解码