前端日期格式化别再拼字符串:toISOString 的时区坑、new Date 解析兼容性与手写 formatDate

toISOString 输出的是 UTC 时间,直接 slice(0,10) 取日期,在北京时间每天早上 8 点前都会退一天。本文讲清 new Date 的解析兼容性坑、秒/毫秒时间戳互转,并给一个支持非法输入兜底的手写 formatDate。

约 7 分钟读完 · 更新于 2026-10-21 · 文中每条都能在下面的配套工具里动手验证

最大的坑:toISOString 输出的是 UTC

toISOString 永远输出带 Z 的 UTC 时间。拿它 slice(0,10) 当「今天的日期」,在东八区每天 0 点到 8 点之间都会退一天——线上「凌晨订单日期错一天」多半是它。

现场复现:日期退一天

两行代码就能在本地复现这个线上事故。

UTC 陷阱演示
const d = new Date('2026-10-16T02:30:00'); // 本地凌晨 2:30
console.log(d.toISOString()); // '2026-10-15T18:30:00.000Z'
console.log(d.toISOString().slice(0, 10)); // '2026-10-15' ← 日期少了一天

// 展示用本地方法,存储才用 ISO
console.log(d.getDate()); // 16

new Date 解析兼容性:三种写法三种命运

同一个日期,写法不同,各浏览器结果可能不一样,Safari 尤其挑剔。

  1. '2026-10-16'(纯日期 ISO):按 UTC 0 点解析,东八区实际是 16 号 8 点;做日期比较时会带入 8 小时偏差。
  2. '2026/10/16':按本地时区 0 点解析,行为最直观,但不是标准写法。
  3. '2026-10-16 10:00'(空格分隔):非标准 ISO,Chrome 能解析,老 Safari 返回 Invalid Date。
  4. 结论:跨端可靠的只有两种输入——完整 ISO 字符串(含时间与时区)或时间戳数字;后端给的其他字符串先转换再进 new Date。

手写 formatDate:20 行够用

支持非法输入兜底
function formatDate(input, fmt = 'YYYY-MM-DD HH:mm:ss') {
  const d = input instanceof Date ? input : new Date(input);
  if (Number.isNaN(d.getTime())) return ''; // 非法输入返回空串,而不是 'NaN-NaN-NaN'
  const pad = (n) => String(n).padStart(2, '0');
  const map = {
    YYYY: d.getFullYear(),
    MM: pad(d.getMonth() + 1), // 月份从 0 开始
    DD: pad(d.getDate()),
    HH: pad(d.getHours()),
    mm: pad(d.getMinutes()),
    ss: pad(d.getSeconds()),
  };
  return fmt.replace(/YYYY|MM|DD|HH|mm|ss/g, (t) => map[t]);
}

formatDate('2026-10-16T22:30:00', 'YYYY年MM月DD日 HH:mm'); // '2026年10月16日 22:30'
formatDate(Date.now(), 'MM/DD HH:mm'); // 当天日期时间(按本地时区)

常见需求的正确产出方式

先分清「给机器看」还是「给人看」,再选方法。

需求推荐写法坑
传给接口 / 存库d.toISOString()输出 UTC,展示端要自己转本地
页面展示手写 formatDategetFullYear/getHours 系列才是本地时区
日期大小比较比时间戳或 Date 对象别比较 '2026-1-1' 这类非定长字符串
倒计时Date.now() 与目标值做差目标时间解析失败会得到 NaN,先 isNaN 检查
「今天是几号」d.getDate()别从 ISO 字符串 slice,凌晨会错一天

时间戳:先分清秒和毫秒

秒级时间戳喂给 new Date 会得到 1970 年;毫秒级又乘 1000 会跑到五万年后。先确认单位再动手。

互转模板
// 秒级时间戳(10 位)转 Date:要乘 1000
const sec = 1790000000;
new Date(sec * 1000);  // 正确:正常日期
new Date(sec);         // 错误:1970 年附近

// Date.now() 与 getTime() 都是毫秒(13 位)
const ms = Date.now();
Math.floor(ms / 1000); // 毫秒转秒

// 判断后端给的是哪种:数值 < 1e12 就当秒
const ts = 1790000000;
const date = new Date(ts < 1e12 ? ts * 1000 : ts);

什么时候上 dayjs

手写不是目的,按需求复杂度选工具。

  • 只需要展示层格式化:上面 20 行够了,不必引依赖。
  • 需要「3 天前」「下周一」这类相对或自然语言时间:手写成本高,用 dayjs 的 relativeTime 插件。
  • 多时区业务(跨时区排期、日历):原生 API 极易出错,上 dayjs + timezone 插件或 Temporal(配 polyfill)。
  • 排查日期 bug 时先确认三件事:数据源字符串长什么样、new Date 解析结果、目标时区——比盲改代码快得多。

常见问题

toISOString 为什么和本地时间差 8 小时?

toISOString 永远输出 UTC(结尾带 Z)。东八区用户展示时要用 getHours 等本地方法或手写 formatDate,别直接 slice ISO 字符串。

new Date('2026-10-16') 在 Safari 返回 Invalid Date?

老 Safari 只认完整 ISO 带时间的格式,纯日期或空格分隔的字符串可能解析失败。统一换成 'YYYY/MM/DD' 或先转时间戳最稳。

怎么判断后端给的时间戳是秒还是毫秒?

看位数:10 位是秒,13 位是毫秒;也可以按数值判断,小于 1e12 当秒处理并乘 1000。Date.now() 返回的永远是毫秒。

前端日期处理该用 dayjs 还是手写?

只是展示层格式化,20 行手写 formatDate 足够;涉及时区转换、相对时间(3 天前)、多语言,直接上 dayjs,别重复造轮子。

📂

松开以自动格式化

支持 .json / .sql / .csv / .html / .css / .xml / .yaml 文件