日期格式化
为什么需要日期格式化
• 后台系统中日期字段随处可见:创建时间、更新时间、操作日志等 • 原生 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-空格式串");