功能说明
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");
}