← 中文资源

简体中文版本

Cron 表达式解析与下次执行时间

解析五段式 Cron 表达式,支持范围、步长、列表与月份星期名称,并计算下一个匹配的 UTC 分钟。

阅读英文原文

功能说明

parseCron 把五段式 Cron 表达式解析为分钟、小时、日、月、星期的集合,支持星号、列表、范围、步长以及 jan、mon 这类名称。matchesCron 判断某个时间是否命中,nextCronRun 从指定时刻开始逐分钟查找下一次执行时间。适合轻量任务调度、健康检查和本地开发工具,无需引入完整调度库。

关键代码

每个字段按逗号拆分,再按斜杠拆出步长;星号展开为完整区间,范围用连字符解析,单值在带步长时自动扩展到字段上限。月份和星期先查名称表,再回退到数字。日与星期同时受限时采用 OR 语义,符合传统 Cron 行为;只限制其中一个时按 AND 处理。nextCronRun 从下一分钟开始,最多扫描一年的分钟数。

使用注意

实现按 UTC 计算,若业务需要本地时区或夏令时,应在调用前转换或改用带时区字段的六段式表达式。日与星期的大小判断用集合大小推断是否受限,若显式列全所有值会被误判为未限制,应避免这种写法。逐分钟扫描最坏情况下开销较大,高频调用可缓存解析结果。不支持秒级字段、L、W、# 等扩展语法。

验证方式

测试星号、列表、范围、步长、月份名称与星期名称的解析结果。用固定时间验证 matchesCron 对分钟、小时、日期与星期的判断,覆盖日与星期同时受限的 OR 语义。测试 nextCronRun 跨小时、跨天和跨月的结果,以及非法字段数量、越界值、零步长和一年内无匹配时的错误。

代码示例

export interface CronSchedule {
  minutes: Set<number>;
  hours: Set<number>;
  daysOfMonth: Set<number>;
  months: Set<number>;
  daysOfWeek: Set<number>;
  source: string;
}

const MONTH_NAMES: Record<string, number> = {
  jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
  jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
};

const DAY_NAMES: Record<string, number> = {
  sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6,
};

function resolveValue(token: string, names?: Record<string, number>): number {
  const normalized = token.trim().toLowerCase();
  const named = names?.[normalized];
  const value = named ?? Number(normalized);
  if (!Number.isInteger(value)) throw new RangeError("invalid cron value: " + token);
  return value;
}

function parseField(field: string, min: number, max: number, names?: Record<string, number>): Set<number> {
  const values = new Set<number>();
  for (const part of field.split(",")) {
    const [rangePart, stepPart] = part.split("/");
    const step = stepPart === undefined ? 1 : Number(stepPart);
    if (!Number.isInteger(step) || step <= 0) throw new RangeError("invalid step: " + part);
    let start: number;
    let end: number;
    if (rangePart === "*") {
      start = min;
      end = max;
    } else if (rangePart.includes("-")) {
      const [rawStart, rawEnd] = rangePart.split("-");
      start = resolveValue(rawStart, names);
      end = resolveValue(rawEnd, names);
    } else {
      start = resolveValue(rangePart, names);
      end = stepPart === undefined ? start : max;
    }
    if (start < min || end > max || start > end) {
      throw new RangeError("cron field out of range: " + part);
    }
    for (let value = start; value <= end; value += step) values.add(value);
  }
  if (values.size === 0) throw new RangeError("empty cron field: " + field);
  return values;
}

export function parseCron(expression: string): CronSchedule {
  const fields = expression.trim().split(/\s+/);
  if (fields.length !== 5) throw new RangeError("cron expression must have five fields");
  const [minutes, hours, daysOfMonth, months, daysOfWeek] = fields as [string, string, string, string, string];
  return {
    source: expression,
    minutes: parseField(minutes, 0, 59),
    hours: parseField(hours, 0, 23),
    daysOfMonth: parseField(daysOfMonth, 1, 31),
    months: parseField(months, 1, 12, MONTH_NAMES),
    daysOfWeek: parseField(daysOfWeek, 0, 6, DAY_NAMES),
  };
}

export function matchesCron(schedule: CronSchedule, date: Date): boolean {
  const domRestricted = schedule.daysOfMonth.size !== 31;
  const dowRestricted = schedule.daysOfWeek.size !== 7;
  const domMatch = schedule.daysOfMonth.has(date.getUTCDate());
  const dowMatch = schedule.daysOfWeek.has(date.getUTCDay());
  const dayMatch = domRestricted && dowRestricted ? domMatch || dowMatch : domMatch && dowMatch;
  return schedule.minutes.has(date.getUTCMinutes())
    && schedule.hours.has(date.getUTCHours())
    && schedule.months.has(date.getUTCMonth() + 1)
    && dayMatch;
}

export function nextCronRun(expression: string, from: Date = new Date()): Date {
  const schedule = parseCron(expression);
  const cursor = new Date(Math.floor(from.getTime() / 60000) * 60000 + 60000);
  for (let attempt = 0; attempt < 527040; attempt += 1) {
    if (matchesCron(schedule, cursor)) return new Date(cursor);
    cursor.setUTCMinutes(cursor.getUTCMinutes() + 1);
  }
  throw new RangeError("no cron run found within one year");
}