Skip to content

日期格式化

为什么需要日期格式化

• 后台系统中日期字段随处可见:创建时间、更新时间、操作日志等 • 原生 Date 输出机器格式(如 2023/05/03)或时间戳,不友好 • 不同业务场景需要不同展示:yyyy-MM-dd HH:mm:ss、yyyy-MM-dd 等

新建文件

  • utils/dateformat.ts
ts
/**
 * 日期格式化工具
 * 提供两种日期格式化方法:基于原生 Date 本地化能力、基于格式字符串的手写解析器
 */

/** 默认格式字符串 */
const DEFAULT_PATTERN = "yyyy-MM-dd HH:mm:ss";

/**
 * 校验输入是否为有效的 Date 对象
 * @param date 输入日期
 * @throws {TypeError} 非 Date 类型或无效日期时抛出
 */
function assertValidDate(date: Date): void {
  if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
    throw new TypeError("参数错误:输入必须是有效的 Date 对象");
  }
}

/**
 * 数字补零为两位字符串
 */
function pad2(n: number): string {
  return String(n).padStart(2, "0");
}

/**
 * 方法一:基于浏览器原生 Date 本地化能力的格式化函数
 *
 * @param date - 必填,输入的 Date 对象
 * @param locale - 可选,本地化区域标识(如 'zh-CN'、'en-US'),默认 'zh-CN'
 * @returns 固定格式为 `yyyy-MM-dd HH:mm:ss` 的字符串,如 "2026-08-12 15:30:05"
 * @throws {TypeError} 输入不是有效 Date 对象时抛出
 * @throws {Error} 当前环境不支持 Intl/本地化 API 时抛出明确错误提示
 *
 * 使用场景:在浏览器或支持 ICU 的环境中,希望利用原生 Intl.DateTimeFormat
 * 完成字段提取(同时自动完成补零)的场景。
 */
export function formatDateByLocale(
  date: Date,
  locale: string = "zh-CN",
): string {
  assertValidDate(date);

  // 兼容性检查:Intl.DateTimeFormat 是本地化能力的基础(Chrome 24+/Firefox 29+/Safari 10+ 均支持)
  if (
    typeof Intl === "undefined" ||
    typeof Intl.DateTimeFormat !== "function"
  ) {
    throw new Error(
      "当前环境不支持 Intl.DateTimeFormat,请使用 formatDateByPattern 替代",
    );
  }

  let parts: Intl.DateTimeFormatPart[];
  try {
    const formatter = new Intl.DateTimeFormat(locale, {
      year: "numeric",
      month: "2-digit",
      day: "2-digit",
      hour: "2-digit",
      minute: "2-digit",
      second: "2-digit",
      hourCycle: "h23", // 强制 24 小时制,避免 24 点或 12AM 歧义
    });
    parts = formatter.formatToParts(date);
  } catch {
    throw new Error(
      `本地化格式化失败:不支持的区域标识 "${locale}" 或当前环境缺少对应语言数据`,
    );
  }

  // 从 parts 中提取各字段(Intl 已保证 2-digit 字段补零)
  const fieldMap: Partial<Record<Intl.DateTimeFormatPartTypes, string>> = {};
  for (const part of parts) {
    fieldMap[part.type] = part.value;
  }

  const { year, month, day, hour, minute, second } = fieldMap;
  if (!year || !month || !day || !hour || !minute || !second) {
    throw new Error(
      "本地化格式化失败:无法从 Intl.DateTimeFormat 结果中解析完整的时间字段",
    );
  }

  return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}

/**
 * 方法二:基于格式字符串的手写解析器(不依赖任何本地化 API)
 *
 * @param date - 必填,输入的 Date 对象
 * @param pattern - 可选,格式字符串,默认 'yyyy-MM-dd HH:mm:ss'
 * @returns 按格式字符串输出的格式化结果,如 "2026-08-12 15:30:05"
 * @throws {TypeError} 输入不是有效 Date 对象时抛出
 * @throws {TypeError} 格式字符串不是字符串或不包含任何有效占位符时抛出
 *
 * 支持的占位符:
 *   yyyy - 四位年份(2026)   yy - 两位年份(26)
 *   MM   - 两位月份(08)     M  - 不补零月份(8)
 *   dd   - 两位日期(05)     d  - 不补零日期(5)
 *   HH   - 24小时制两位小时(09) H - 不补零小时(9)
 *   mm   - 两位分钟(03)     m  - 不补零分钟(3)
 *   ss   - 两位秒数(07)     s  - 不补零秒数(7)
 *
 * 使用场景:需要在无 Intl 支持的环境(或需要完全自定义格式)下使用,
 * 手动解析格式字符串,逐字符替换占位符。
 */
export function formatDateByPattern(
  date: Date,
  pattern: string = DEFAULT_PATTERN,
): string {
  assertValidDate(date);

  if (typeof pattern !== "string" || pattern.length === 0) {
    throw new TypeError("参数错误:格式字符串必须是非空字符串");
  }

  const year = date.getFullYear();
  const month = date.getMonth() + 1; // getMonth() 返回 0-11,转为 1-12
  const day = date.getDate();
  const hour = date.getHours();
  const minute = date.getMinutes();
  const second = date.getSeconds();

  // 占位符映射表:按长度从长到短匹配,避免 "yyyy" 被 "yy" 抢先匹配
  const placeholderMap: Record<string, string> = {
    yyyy: String(year),
    yy: String(year).slice(-2),
    MM: pad2(month),
    M: String(month),
    dd: pad2(day),
    d: String(day),
    HH: pad2(hour),
    H: String(hour),
    mm: pad2(minute),
    m: String(minute),
    ss: pad2(second),
    s: String(second),
  };

  let result = "";
  let matchedAny = false;
  let i = 0;

  while (i < pattern.length) {
    let replaced = false;
    // 优先匹配长占位符(yyyy > yy,MM > M)
    for (const token of [
      "yyyy",
      "yy",
      "MM",
      "M",
      "dd",
      "d",
      "HH",
      "H",
      "mm",
      "m",
      "ss",
      "s",
    ]) {
      if (pattern.startsWith(token, i)) {
        result += placeholderMap[token];
        matchedAny = true;
        i += token.length;
        replaced = true;
        break;
      }
    }
    if (!replaced) {
      // 非占位符字符原样保留
      result += pattern[i];
      i += 1;
    }
  }

  if (!matchedAny) {
    throw new TypeError(
      `参数错误:格式字符串 "${pattern}" 不包含任何有效占位符`,
    );
  }

  return result;
}

/* ============================== 测试用例 ============================== */

/** 断言两种方法输出一致且等于期望值 */
function assertFormat(
  date: Date,
  expected: string,
  pattern: string = DEFAULT_PATTERN,
): void {
  const localeResult = formatDateByLocale(date);
  const patternResult = formatDateByPattern(date, pattern);
  // pattern 为默认格式时,两种方法输出应完全一致
  if (pattern === DEFAULT_PATTERN) {
    console.assert(
      localeResult === patternResult,
      `两方法输出不一致: locale="${localeResult}" pattern="${patternResult}"`,
    );
  }
  const pass =
    patternResult === expected &&
    (pattern !== DEFAULT_PATTERN || localeResult === expected);
  console.log(
    `${pass ? "PASS" : "FAIL"} | locale="${localeResult}" pattern="${patternResult}" 期望="${expected}"`,
  );
}

/** 断言抛出指定类型的错误 */
function assertThrows(fn: () => unknown, label: string): void {
  try {
    fn();
    console.log(`FAIL | ${label}: 未抛出错误`);
  } catch (e) {
    console.log(
      `PASS | ${label}: 抛出 ${(e as Error).name}: ${(e as Error).message}`,
    );
  }
}

// 1. 普通日期格式化(使用 ISO 字符串构造,可读性更好,避免月份索引 0-11 的歧义)
assertFormat(new Date("2026-08-12T15:30:45"), "2026-08-12 15:30:45");
// 2. 跨年日期(12月31日 23:59:59)
assertFormat(new Date("2025-12-31T23:59:59"), "2025-12-31 23:59:59");
// 3. 闰年 2 月 29 日(2024 是闰年)
assertFormat(new Date("2024-02-29T08:00:00"), "2024-02-29 08:00:00");
// 4. 非闰年 2 月 28 日(2023 非闰年)
assertFormat(new Date("2023-02-28T12:00:00"), "2023-02-28 12:00:00");
// 5. 单数字月份/日期/时分秒补零
assertFormat(new Date("2026-01-05T03:07:09"), "2026-01-05 03:07:09");
// 6. 闰年校验:校验 2 月 29 日日期计算正确
assertFormat(new Date("2024-02-29T00:00:00"), "2024-02-29 00:00:00");
// 7. 自定义格式字符串:中文格式 + 非补零占位符
assertFormat(new Date("2026-08-12T09:05:03"), "2026年8月12日", "yyyy年M月d日");
// 8. 自定义格式字符串:两位年份 + 斜杠分隔
assertFormat(new Date("2026-01-01T00:00:00"), "26/01/01", "yy/MM/dd");
// 9. 自定义格式字符串:混合分隔符
assertFormat(
  new Date("2026-08-12T15:30:45"),
  "2026.08.12-15:30",
  "yyyy.MM.dd-HH:mm",
);
// 10. 无效日期输入:两方法均应抛出 TypeError
assertThrows(() => formatDateByLocale(new Date("invalid")), "locale-无效Date");
assertThrows(
  () => formatDateByPattern(new Date("invalid")),
  "pattern-无效Date",
);
assertThrows(
  () => formatDateByPattern("not-a-date" as unknown as Date),
  "pattern-非Date类型",
);
// 11. 非法格式字符串:不含任何占位符(注意 'hello world' 含 'd' 属合法占位符)
assertThrows(
  () => formatDateByPattern(new Date(), "--- ###"),
  "pattern-无占位符",
);
assertThrows(() => formatDateByPattern(new Date(), ""), "pattern-空格式串");