时间函数这个题目,看起来是前端基本功,但真正能一次写对的人不多。问题主要集中在几个地方:Date对象的底层设计、字符串解析的不一致性、时区偏移到底由谁负责、以及格式化函数为什么总在边界条件“翻车”。
这次我们围绕 JavaScript 时间函数做一次系统梳理。不聊空洞概念,直接落到可运行代码、可复制的工具函数、业务场景中的真实用法,把常见坑位一次性踩清楚。
如果你最近正好在写倒计时、做报表统计、处理接口时间戳,或者被“日期差 8 小时”这类问题困扰,这篇文章建议收藏备用。
1. 时间函数核心能力速览
JavaScript 的时间处理并不是“一套 API 走天下”,而是由三部分组成:
| 能力层 | 代表工具 | 解决什么问题 |
|---|---|---|
| 原生基础 | Date对象、Date.now()、Date.parse() | 获取时间、时间戳转换、基础比较 |
| 原生格式化 | toISOString()、toLocaleString()、Intl.DateTimeFormat | 转字符串、本地化显示 |
| 第三方封装 | dayjs、date-fns、moment.js | 链式操作、复杂格式化、相对时间 |
| 自定义工具函数 | formatDate、parseDate、dateAdd | 项目内统一格式,避免重复踩坑 |
什么场景该用哪一层,这是重点:
- 一个时间戳存库、两个时间戳比大小:直接原生
Date,不需要引库。 - 做
YYYY-MM-DD HH:mm:ss这种固定格式展示:写一个工具函数,或者用 dayjs。 - 国际化、多语言、跨时区展示:优先
Intl.DateTimeFormat。 - 老项目里到处都有
moment,不一定要迁移,但新项目更推荐 dayjs 或 date-fns。 - 定时、轮询、倒计时:
setInterval、setTimeout,注意清理和误差累积。
2. 适用场景与使用边界
时间函数适合这些场景:
- 前端页面:活动倒计时、列表时间显示、日历组件、消息“x分钟前”。
- 后端 Node.js:日志时间、定时任务、接口返回时间戳、UTC 标准时间存储。
- 数据分析:按天/按周/按月分组统计,时间区间筛选。
- 数据迁移:不同格式的日期字符串统一解析为标准时间。
边界条件也要说清楚:
- 不要期望原生
Date自动处理所有格式,new Date("2023-10-05 14:30:00")在旧版浏览器和不同 Node.js 版本上表现不完全一致。 Date对象在 JavaScript 内部只存一个数字:自 1970-01-01 00:00:00 UTC 以来的毫秒数。年和月从 0 开始计数,getMonth()返回值 0-11,这是新手最容易错的地方。- 时间字符串解析在不同引擎上可能有差异,业务上如果传入的是外部数据,建议先人工解析再构造时间。
- 夏令时地区的时间运算,不能假设每天都固定 24 小时。
- 涉及用户隐私的时间数据,例如访问记录、聊天记录,存储和展示时要遵守产品合规要求,避免在日志里暴露多余的个人信息。
3. 环境准备与前置条件
时间函数是基础能力,运行环境要求不高:
- 浏览器:F12 打开控制台就能直接跑。
- Node.js:建议 18 以上版本,
Intl支持更完整。 - 包管理器:npm、pnpm、yarn 任选。
- 日常调试可以在
.js文件里写,也可以直接node test.js执行。 - 如果想测试第三方库,先初始化一个 npm 项目。
# 初始化项目 npm init -y # 安装常见时间处理库,按需选择 npm install dayjs date-fns不需要额外依赖,原生写法足够跑通这套验证流程。下面给出的代码都可以直接保存为.js文件运行。
4. 基础时间函数:创建、获取、转换
这一节只讲原生能力,目标是建立可靠的时间函数基线:创建时间、读取时间、转换时间戳、比较早晚。
4.1 创建时间对象
推荐四种写法,分别对应不同输入:
// 写法1:当前时间 const now = new Date(); console.log(now); // 写法2:时间戳,单位毫秒 const fromTs = new Date(1696416000000); console.log(fromTs.toISOString()); // 写法3:日期字符串 const fromStr = new Date("2023-10-05T14:30:00Z"); console.log(fromStr.toISOString()); // 写法4:数值参数,注意 month 从 0 开始 const fromParts = new Date(2023, 9, 5, 14, 30, 0); // 2023-10-05 14:30:00 console.log(fromParts.toString());关键点:写法 4 的月份是9,代表 10 月。如果你传10,得到的是 11 月。
4.2 获取时间字段
const d = new Date("2023-10-05T14:30:00"); console.log(d.getFullYear()); // 2023 console.log(d.getMonth()); // 9,注意从0开始 console.log(d.getDate()); // 5,几号 console.log(d.getDay()); // 4,星期几,0表示周日 console.log(d.getHours()); // 14,本地时区 console.log(d.getMinutes()); // 30 console.log(d.getSeconds()); // 0 console.log(d.getMilliseconds());// 04.3 时间戳转换
// 当前时间戳 const ts = Date.now(); // Date 对象转时间戳 const d = new Date("2023-10-05T14:30:00"); const tsFromDate = d.getTime(); // 时间戳转 Date const dateFromTs = new Date(tsFromDate); // 秒级时间戳处理 const secondsTs = Math.floor(ts / 1000); const dateFromSeconds = new Date(secondsTs * 1000);接口开发里经常遇到秒级时间戳,记得先乘 1000 再传给new Date()。
4.4 时间比较与差值
const start = new Date("2023-10-05T00:00:00"); const end = new Date("2023-10-06T12:00:00"); // 直接用 getTime 比较 console.log(start.getTime() < end.getTime()); // 计算差值,单位毫秒 const diffMs = end.getTime() - start.getTime(); console.log(diffMs); // 129600000 console.log(diffMs / 1000); // 秒 console.log(diffMs / (1000 * 60)); // 分钟 console.log(diffMs / (1000 * 60 * 60)); // 小时 console.log(diffMs / (1000 * 60 * 60 * 24)); // 天直接用start < end也能比较,JavaScript 会自动调用valueOf(),但显式用getTime()更清楚。
5. 日期格式化与解析:自己写还是用库
原生Date没有一个好用的format方法,这是时间函数在实际开发中最常遇到的痛点。
5.1 手写一个 formatDate 函数
自己封装的好处是无依赖、体积小、完全可控:
function formatDate(date, fmt = "YYYY-MM-DD HH:mm:ss") { const pad = (num) => String(num).padStart(2, "0"); const map = { YYYY: date.getFullYear(), MM: pad(date.getMonth() + 1), DD: pad(date.getDate()), HH: pad(date.getHours()), mm: pad(date.getMinutes()), ss: pad(date.getSeconds()), }; return fmt.replace(/YYYY|MM|DD|HH|mm|ss/g, (match) => String(map[match])); } // 使用示例 const d = new Date("2023-10-05T14:30:00"); console.log(formatDate(d)); // 2023-10-05 14:30:00 console.log(formatDate(d, "YYYY年MM月DD日")); // 2023年10月05日5.2 解析字符串为 Date
外部传入的字符串不一定规范,建议先拆解再构造:
function parseDateString(str) { // 支持 "2023-10-05 14:30:00" 这类格式 const parts = str.split(/[-: ]/).map(Number); return new Date(parts[0], parts[1] - 1, parts[2], parts[3] || 0, parts[4] || 0, parts[5] || 0); } console.log(parseDateString("2023-10-05 14:30:00").toString());这里把“字符串解析”和“内部时间函数”完全分开,不依赖Date.parse()对格式的模糊处理。
5.3 使用 dayjs 简化
如果项目里已经用了 dayjs,格式化只写一行:
import dayjs from "dayjs"; console.log(dayjs().format("YYYY-MM-DD HH:mm:ss")); console.log(dayjs("2023-10-05 14:30:00").format("YYYY-MM-DD"));5.4 相对时间:几分钟前、几天前
不用额外库也可以实现:
function timeAgo(date) { const diff = Date.now() - date.getTime(); const minute = 60 * 1000; const hour = 60 * minute; const day = 24 * hour; if (diff < minute) return "刚刚"; if (diff < hour) return `${Math.floor(diff / minute)}分钟前`; if (diff < day) return `${Math.floor(diff / hour)}小时前`; return `${Math.floor(diff / day)}天前`; } console.log(timeAgo(new Date(Date.now() - 5 * 60 * 1000))); // 5分钟前 console.log(timeAgo(new Date(Date.now() - 2 * 60 * 60 * 1000))); // 2小时前6. 时区与国际化:8 小时差从哪里来
“日期差 8 小时”是时间函数中出现频率最高的问题,本质是“时间字符串没有时区标记”时,不同环境解析结果不同。
6.1 分清 UTC 与本地时区
const now = new Date("2023-10-05T14:30:00"); // 获取 UTC 时区的字段 console.log(now.getUTCFullYear()); console.log(now.getUTCHours()); // 获取本地时区的字段 console.log(now.getHours()); // 当前环境相对于 UTC 的偏移分钟数 console.log(now.getTimezoneOffset()); // 中国东八区为 -4806.2 toISOString 一定返回 UTC
const d = new Date("2023-10-05T14:30:00+08:00"); console.log(d.toISOString()); // 2023-10-05T06:30:00.000Z console.log(d.toLocaleString()); // 本地时区展示Z表示 UTC 时间,+08:00表示东八区偏移。如果你存储的是toISOString(),读取后想要显示为北京时间,需要做本地格式化。
6.3 推荐的时间存储规则
- 后端存储统一用 UTC:例如
2023-10-05T06:30:00.000Z。 - 接口传输统一用时间戳:
1696382400000,避免字符串解析歧义。 - 前端展示才转换本地时区:用
toLocaleString()或Intl.DateTimeFormat。 - 不要在前端和后端之间直接传
"2023-10-05"这种无时区字符串,除非双方约定它代表本地日期,且不涉及跨时区用户。
6.4 使用 Intl.DateTimeFormat 做本地化
const d = new Date("2023-10-05T14:30:00"); const formatter = new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, timeZone: "Asia/Shanghai", }); console.log(formatter.format(d));timeZone字段解决了“按特定时区展示”的问题,这对多时区产品非常关键。
7. 日期时间运算:加天、加月、加小时
时间运算在业务里非常常见,比如订单 30 分钟未支付自动关闭、活动到月底结束、订阅到期日推送。
7.1 原生方式:set 和 get 配合
function addDays(date, days) { const result = new Date(date); result.setDate(result.getDate() + days); return result; } function addHours(date, hours) { const result = new Date(date); result.setTime(result.getTime() + hours * 60 * 60 * 1000); return result; } function addMonths(date, months) { const result = new Date(date); const d = result.getDate(); result.setDate(1); result.setMonth(result.getMonth() + months); result.setDate(Math.min(d, result.daysInMonth ? 31 : 31)); return result; }注意addMonths有边界问题:1 月 31 日加 1 个月,不应变成 3 月 3 日。上面示例先用setDate(1)跳过目标月份的天数检查,再用setDate(Math.min(原日, 31))做兜底。实际生产环境建议直接用 dayjs:
import dayjs from "dayjs"; const d1 = dayjs("2023-01-31").add(1, "month"); console.log(d1.format("YYYY-MM-DD")); // 2023-02-287.2 计算某月的第一天和最后一天
function getMonthRange(year, month) { const firstDay = new Date(year, month - 1, 1); const lastDay = new Date(year, month, 0); // 下个月的第0天就是本月最后一天 return { firstDay: formatDate(firstDay, "YYYY-MM-DD"), lastDay: formatDate(lastDay, "YYYY-MM-DD"), }; } console.log(getMonthRange(2023, 10));7.3 计算本周一和本周日
function getWeekRange(date) { const day = date.getDay() || 7; // 周日改为7,周一为1 const monday = new Date(date); monday.setDate(date.getDate() - day + 1); monday.setHours(0, 0, 0, 0); const sunday = new Date(monday); sunday.setDate(monday.getDate() + 6); sunday.setHours(23, 59, 59, 999); return { monday, sunday }; } const range = getWeekRange(new Date()); console.log(range.monday.toString()); console.log(range.sunday.toString());8. 定时任务与倒计时:setInterval 的误差问题
“时间函数”不只是 Date API,还包括浏览器和 Node.js 提供的定时器函数。
8.1 常见倒计时实现
function startCountdown(targetTime, onTick, onFinish) { const timer = setInterval(() => { const remain = targetTime.getTime() - Date.now(); if (remain <= 0) { clearInterval(timer); onFinish && onFinish(); return; } const days = Math.floor(remain / (1000 * 60 * 60 * 24)); const hours = Math.floor((remain % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); const minutes = Math.floor((remain % (1000 * 60 * 60)) / (1000 * 60)); const seconds = Math.floor((remain % (1000 * 60)) / 1000); onTick && onTick({ days, hours, minutes, seconds }); }, 1000); return timer; } const timer = startCountdown( new Date(Date.now() + 1000 * 60 * 60 * 25), (val) => console.log(val) );8.2 为什么定时器会漂移
浏览器和 Node.js 的setInterval受事件循环影响,当主线程被长任务阻塞时,回调可能延迟执行;定时器回调本身执行也需要时间,所以标称 1000ms 的间隔并不精确。
更稳重的做法:不要依赖setInterval每秒累加,而是每次都用targetTime.getTime() - Date.now()重新计算剩余时间。上面倒计时示例已经采用这种思路,能避免累积误差。
8.3 关于清理定时器
页面组件销毁、Node.js 进程退出、任务取消时,必须调用clearInterval(timer)。不带清理的定时器会让回调一直执行,造成内存泄漏和无效请求。
9. 接口时间处理与批量任务设计
后端接口和批量任务里,时间函数通常不只服务于单个日期,而是贯穿于数据分组、调度、重试策略等环节。
9.1 接口入参统一使用时间戳
// 请求示例:传入时间戳范围 const payload = { startTime: new Date("2023-10-01T00:00:00+08:00").getTime(), endTime: new Date("2023-10-31T23:59:59+08:00").getTime(), }; // 前端解析返回数据 const start = new Date(payload.startTime).toISOString();9.2 按天分组统计
function groupByDay(list) { const map = new Map(); list.forEach((item) => { const day = formatDate(new Date(item.createdAt), "YYYY-MM-DD"); map.set(day, (map.get(day) || 0) + 1); }); return Object.fromEntries(map); } const result = groupByDay([ { createdAt: new Date().getTime() }, { createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000).getTime() }, ]); console.log(result);9.3 批量重试要注意时间窗口
批量任务中常见的错误是“失败后立刻重试”导致时间集中打崩目标服务。通用的做法是增加退避间隔:
function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function batchWithRetry(tasks, maxRetry = 3) { for (let i = 0; i < tasks.length; i++) { let success = false; for (let retry = 0; retry <= maxRetry; retry++) { try { await tasks[i](); success = true; break; } catch (err) { await delay(1000 * Math.pow(2, retry)); // 1s, 2s, 4s } } if (!success) { console.error(`任务 ${i} 失败`); } } }这套思路把时间函数和工程容错结合起来了,比单纯会写new Date()更贴近生产。
10. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
日期显示为NaN | 字符串格式不规范,new Date()无法解析 | 用console.log(new Date(str).toString())单独验证 | 先拆分字符串再构造 Date |
| 时间差 8 小时 | 服务端返回 UTC 时间,前端未做本地时区转换 | 检查字符串结尾是否有Z或+08:00 | 统一传时间戳;展示用toLocaleString()或Intl.DateTimeFormat |
| 月份少了 1 个月 | getMonth()返回 0-11 | 检查代码里是否直接输出getMonth() | 输出时getMonth() + 1 |
| 星期几不对 | getDay()返回 0-6,0 代表周日 | 打印getDay()排查 | 按业务定义映射数组 |
| 定时器重复执行 | 未清理setInterval | 检查组件卸载或任务完成逻辑 | clearInterval统一出口 |
toISOString()后时间变了 | 这是正常行为,它总是 UTC 时间 | 确认业务是否要 UTC 标准 | 存储用 ISO 字符串,展示时再转本地化 |
| 日期运算出现 2 月 31 日 | 直接在 31 日上setMonth | 用 dayjs 或先setDate(1) | 使用add(1, "month")或回退日期函数 |
11. 最佳实践与使用建议
写时间函数,比“能跑”更重要的是“可维护”。这些建议来自大量业务项目中的通用经验:
- 内部存储统一使用时间戳或 UTC ISO 字符串,展示时再格式化。
- 封装自己的
formatDate和parseDateString工具函数放在公共模块,不要到处重复getFullYear()拼接字符串。 - 项目引入时间库时确认体积:moment.js 官方已宣布进入维护模式,新项目更推荐 dayjs 或 date-fns。
- 所有时间格式化必须通过
padStart补零,避免2023-1-5 9:30这种不规整结果。 - 涉及“按周统计”“按月统计”的代码,一定要把周起始日、月起始日写成明确的函数,留给别人看懂。
- 倒计时和定时任务要关注主线程阻塞,不要在
setInterval回调里执行重计算任务。 - 接口返回值中的时间,统一以时间戳或明确时区的 ISO 字符为准,禁止传无时区的
"2023-10-05"。 - 测试时重点覆盖:跨月、跨年、2 月 29 日、夏令时地区、时间戳秒和毫秒混乱。
12. 总结与下一步
时间函数的核心不是背 API,而是建立一套可靠的处理链路:创建Date时明确输入格式,运算时用时间戳或 set 方法,存储时保留时区信息,展示时格式化和本地化。把这四步理清楚,8 小时差、月份偏移、定时器漂移这些经典问题都容易定位。
下一步建议这样验证:
- 在 Node.js 环境跑一遍第 4 节到第 7 节的示例代码;
- 把
formatDate单独抽到一个date-utils.js文件,准备几个测试用例覆盖跨年和月份补零; - 在项目里搜索所有直接拼接日期字符串的地方,换成统一工具函数;
- 如果项目使用 dayjs,顺手把
dayjs().add(1, "month")的边界行为在本地测试一遍。
时间函数的坑大多来自“隐式假设”:假设字符串被正确解析、假设本地时区一样、假设月份 1 就是 1。把这些假设全部显式化,你就能少写很多 bug。