时间函数在 JavaScript 里是入门级话题,但真正能一次写对的人并不多。日常开发中,日期格式化、时间戳换算、倒计时、定时器、时区处理,几乎每个项目都会碰到;一旦出了问题,很多人第一反应是“语法写错了”,实际真正错的多半是概念:UTC 与本地时间差在哪、月份为什么从 0 开始、时间戳单位到底是秒还是毫秒、两个日期相减得到的是什么东西。这篇文章把时间函数的常用场景拆开讲,覆盖 Date 对象、格式化、时间计算、定时器和排查思路,适合前端开发、Node 后端开发,也适合所有被日期计算折磨过的人。
1. 先分清时间函数要解决的 4 类问题
学时间函数最忌讳的是“遇到一个方法背一个方法”。Date 对象上有几十个方法,名称接近、行为各异,硬背很容易混。我建议先把时间函数处理的问题分成四类,再按类别去记 API。
1.1 获取时间:当前时刻、时间戳、UTC 与本地时间
第一类是“获取时间”。这里要区分三个概念:
- 当前时刻,用
new Date()拿到一个 Date 对象; - 绝对时间,通常用时间戳表示,是自 1970 年 1 月 1 日 00:00:00 UTC 以来的毫秒数;
- 表达时间,指的是同一个时刻在不同时区、不同地区显示出来的样子。
很多人分不清“当前时刻”和“本地时间”。实际上 Date 对象内部保存的是一个绝对时刻,与运行环境所在时区无关;只有调用toString、getHours这类方法时,才会按本地时区去换算。理解这一点,很多显示异常的问题就能想通。
1.2 展示与格式化:同一个时刻,不同的人看到不同样子
第二类是“展示”。同样是 2025 年 1 月 15 日 10:30,可以显示成2025/01/15、2025-01-15、January 15, 2025、10:30:00等形式。展示不改变时刻本身,只是换个格式。
这类操作看似简单,实际上最容易遇到补零、时区、国际格式差异等问题。后面我会单独用一节来说格式化。
1.3 时间计算与比较:差值、进位、边界
第三类是“计算与比较”。典型场景包括:
- 两个时间点相隔多久;
- 某个时间点之后 7 天是哪一天;
- 判断某天是否在同一天、同一月、同一年;
- 计算月末最后一天、季度末、年龄等。
时间计算最怕边界条件:2 月 29 日、跨月、跨年、夏令时,任何一个不注意都会算错。后面的章节会逐个拆。
1.4 定时执行:setTimeout 与 setInterval
第四类是“定时执行”。严格说,setTimeout和setInterval不是 Date 的方法,而是浏览器和 Node 提供的定时器 API。但它们和时间强相关,项目里做倒计时、轮询、延迟任务时都会用到,所以必须一起讲清楚。
这四类问题对应的使用思路完全不同。你可以先把当前项目里的时间需求归类,再看该用哪一类 API,比死记方法列表有效得多。
2. Date 对象:创建和读取,先把最容易踩的 3 个坑记住
Date 是 JavaScript 里最常用的时间对象。它本身不难,难在几个非常反直觉的规则。这些规则只要错一次,后面所有基于它的计算都会跟着错。
2.1 创建 Date 的几种方式
// 当前时刻 const now = new Date(); // 传时间戳(毫秒) const d1 = new Date(1736908200000); // 传日期字符串 const d2 = new Date('2025-01-15T10:30:00'); // 传年月日时分秒,注意月份从 0 开始 const d3 = new Date(2025, 0, 15, 10, 30, 0);第四种写法最容易踩坑,下面单独说。
2.2 月份从 0 开始:新手第一坑
JavaScript 的 Date 构造函数中,月份从 0 开始计数,0 表示一月,11 表示十二月:
new Date(2025, 0, 15); // 2025-01-15 new Date(2025, 11, 15); // 2025-12-15很多人在传 1 表示一月,结果日期整体往后推了一个月,而且不同月份天数不同,很难一眼看出来。getMonth()也是同理,返回值是 0 到 11,需要加 1 才是习惯上的月份。
const d = new Date(2025, 0, 15); d.getMonth(); // 0,即一月 d.getMonth() + 1; // 1这个规则不是 bug,是历史设计,但你必须记住。写工具函数时,建议把“月份 +1 / -1”的逻辑封装在同一个函数里,避免业务代码里到处手动处理。
2.3 时间戳单位是毫秒,不是秒
另一个高频坑是时间戳单位。JavaScript 的Date.now()、getTime()返回的都是毫秒,而很多后端接口、数据库、其他语言里的时间戳用的是秒。
Date.now(); // 1736908200000,毫秒 Math.floor(Date.now() / 1000); // 转换成秒如果拿秒当毫秒传给new Date(),时间会直接回到 1970 年附近;反过来拿毫秒当秒,又会得到几十年后的时间。和接口对接时,我一般会先确认字段注释里写的是秒还是毫秒,再决定要不要除以 1000。
2.4 读取年月日时分秒的方法
除了创建,还要会读:
const d = new Date(); d.getFullYear(); // 四位年份 d.getMonth(); // 0-11,需要 +1 d.getDate(); // 1-31 d.getDay(); // 0-6,0 是星期日 d.getHours(); // 0-23 d.getMinutes(); // 0-59 d.getSeconds(); // 0-59 d.getMilliseconds(); // 0-999注意getDay()返回的是星期几,不是“当月第几天”;拿它当日期用,属于常见误用。对应还有setFullYear、setMonth、setDate等 set 方法,名称规律一致。
记住口诀:
getMonth()要加 1,getDay()是星期,getDate()才是日期。这三个最容易混。
3. 日期格式化:别急着引入库,先搞懂原生方法
格式化是时间函数里最常写、也最常出 bug 的部分。很多人一遇到格式化就想起 dayjs、moment,但在引入库之前,原生能力其实已经覆盖了大半需求。
3.1 toISOString、toLocaleString、toString 的区别
先看三个容易混淆的方法:
const d = new Date('2025-01-15T10:30:00'); d.toString(); // "Wed Jan 15 2025 10:30:00 GMT+0800 (中国标准时间)" d.toISOString(); // "2025-01-15T02:30:00.000Z" d.toLocaleString(); // "2025/1/15 10:30:00"toString()返回的是当前运行时本地时区的完整字符串;toISOString()返回的是 UTC 时间,结尾带 Z,按 ISO 8601 格式;toLocaleString()返回的是本地化字符串,具体格式由运行环境和语言区域决定。
这里最容易迷惑的是toISOString():它显示的是 UTC 时间,不是你的本地时间。我见过有人直接用toISOString()展示日期,结果在中国时区下看到的时间比本地时间慢了 8 小时。
3.2 手写一个简单的格式化函数
如果项目只用到几种固定格式,手写格式化函数完全够用:
function pad(n) { return n < 10 ? '0' + n : '' + n; } function formatDate(date, pattern = 'YYYY-MM-DD HH:mm:ss') { 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 pattern.replace(/YYYY|MM|DD|HH|mm|ss/g, (key) => map[key]); } formatDate(new Date()); // 例如 "2025-01-15 10:30:00"这个函数把年月日时分秒先取出来,再按模板替换。优点是逻辑完全可控,不受原生方法在浏览器间的差异影响;缺点是只处理了固定时区,如果需要跨时区显示,就要手动换算,或者用更成熟的库。
3.3 补零问题
格式化里最不起眼、最容易出错的是补零。月份、日期、小时、分钟、秒,只有个位数时要补成两位数。上面的pad函数就是干这个的。
新手常犯的错误是:
// 错误示范 '2025-' + (d.getMonth() + 1) + '-' + d.getDate(); // 结果是 "2025-1-5",和预期 "2025-01-05" 不一致这种格式看起来没大问题,但如果后端接口对格式有严格要求,或者要按字典序排序日期字符串,不补零会直接导致排序错误。比如"2025-02-01"和"2025-1-15"按字符串比较时,后者会排在前面,因为字符'1'比'2'小。
3.4 时区对格式化结果的影响
格式化时必须想清楚一个问题:这个时间是给谁看的?
- 给本机用户看,用本地格式,例如
getHours()、toLocaleString(); - 给服务端存数据、给跨时区用户看,优先用 UTC,例如
toISOString()或手动拼接 UTC 字符串; - 给日志记录,建议统一用 ISO 8601 并带时区偏移,方便排查。
很多线上事故出在“前端按照本地时间格式化成字符串,传给后端,后端再按别的时区解析”,结果时间偏差了几个小时。比较稳妥的做法是:接口传输尽量传时间戳或 ISO 字符串,展示时再转成本地格式。
传输数据时优先传时间戳或 ISO 字符串,展示时再转本地格式。这条约定能省掉大量时区 bug。
4. 时间计算与比较:差值、进位和边界条件
有了创建和读取的基础,接下来是真正考验逻辑的部分:时间计算。这里建议所有计算都统一思路——先把日期转成时间戳,做数值运算,再转回 Date 对象。
4.1 两个时间戳相减
计算两个时间点间隔,最稳定的方式是时间戳相减:
const start = new Date('2025-01-15T10:00:00'); const end = new Date('2025-01-15T12:30:00'); const diff = end.getTime() - start.getTime(); // diff 是毫秒数:2 小时 30 分 = 9000000 毫秒转成习惯的单位:
const seconds = Math.floor(diff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24);这里注意:diff可能是负数。如果开始时间晚于结束时间,先取绝对值再做转换,避免负数的天数和小时混在一起。
4.2 setMonth 和 setDate 的自动进位
直接用 Date 对象的 set 方法做加减,有一个很实用的特性:超出范围会自动进位。例如:
const d = new Date(2025, 0, 31); d.setMonth(d.getMonth() + 1); // 2 月没有 31 日,结果自动变成 2025-03-03这个行为既是便利也是坑。如果你要的是“每月最后一天”,用setMonth就不安全,因为 1 月 31 日加一个月会跳到 3 月 3 日,而不是 2 月 28 日或 29 日。
处理这类问题,我一般会先设定目标月份,再用new Date(year, month + 1, 0)的方式取当月最后一天,这个技巧下一节会讲。
4.3 判断两个日期是否同一天
直接比较两个 Date 对象用==是不行的,因为比较的是对象引用,两个不同对象永远不相等。
判断同一天,正确思路是分别取年、月、日再比较:
function isSameDay(a, b) { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); }如果要判断同一小时、同一分钟,逐级加条件就行。这种写法比转字符串再比较更稳,因为不受格式化和补零影响。
4.4 本月第一天、最后一天、某月有多少天
一个常用技巧是利用 Date 构造函数的自动进位特性:
// 本月第一天 const firstDay = new Date(2025, 0, 1); // 本月最后一天:下个月第 0 天就是本月最后一天 const lastDay = new Date(2025, 1, 0); // 2025-01-31 // 判断某月有多少天 function daysInMonth(year, month) { // month 传习惯上的 1-12 return new Date(year, month, 0).getDate(); } daysInMonth(2025, 2); // 28,2025 年 2 月 daysInMonth(2024, 2); // 29,2024 年是闰年new Date(year, month, 0)的 day 参数传 0,意思是上一个月的最后一天,因此能很方便地拿到某月天数,也能顺便判断闰年。这个技巧在写日历组件、统计报表时非常常用。
注意:
new Date(year, month, 0)里的 month 仍然是“下一个月”的索引,结果取的是上一个月的最后一天。这个技巧写之前可以先在控制台验证一次,避免方向搞反。
5. 定时器:setTimeout 和 setInterval 的真实行为
时间函数不只是读取和计算日期,还涉及定时执行。虽然setTimeout、setInterval不是 Date 的方法,但很多人会把它们和时间函数混在一起学。这里把关键行为说清楚。
5.1 基本用法
// 延迟 1 秒执行一次 setTimeout(() => { console.log('执行'); }, 1000); // 每隔 1 秒执行一次 setInterval(() => { console.log('每隔 1 秒执行'); }, 1000);单位同样是毫秒。新手写代码时经常把 1 秒写成 1,结果是 1 毫秒执行一次,运行时 CPU 占用直接飙升。先明确单位,再写数字。
5.2 清除定时器
定时器要保存返回值才能清除:
const timer = setInterval(() => { // 业务逻辑 }, 1000); // 条件满足时清除 clearInterval(timer);setTimeout对应clearTimeout。组件销毁、页面离开、任务结束时,要记得清理定时器,否则会一直运行,造成资源占用和多余请求。在 React 里通常在useEffect的清理函数里清除。
5.3 为什么 setInterval 不适合做倒计时
setInterval的间隔时间不是精确的。它只保证“每隔大约 N 毫秒把回调加入队列”,如果回调执行时间超过 N 毫秒,或者浏览器标签页被切到后台,计时就会堆积或暂停,导致倒计时不准确。
一个典型场景:用setInterval每秒减 1 实现倒计时,用户切到别的标签页 10 分钟再回来,发现倒计时还停留在刚才的位置,或者突然跳变。
更稳的做法是:每次执行时用Date.now()计算真实剩余时间,而不是简单地计数减一。这样即使定时器被暂停,恢复后也能根据当前时刻重新计算。
5.4 用 Date.now() 校准定时器
const deadline = Date.now() + 10 * 1000; const timer = setInterval(() => { const remain = deadline - Date.now(); if (remain <= 0) { clearInterval(timer); console.log('倒计时结束'); return; } console.log('剩余', Math.ceil(remain / 1000), '秒'); }, 200);这里每隔 200 毫秒检查一次真实剩余时间,而不是依赖“每执行一次就减 1”。即便浏览器定时器被延迟,剩余时间也是通过当前时刻计算出来的,最终结束时间不会偏移。定时器本身不准没关系,用绝对时间校准即可。
6. 实战:倒计时、相对时间和日期工具函数
前面讲的是知识点,这一节把常见需求写成可以直接参考的工具函数。这些函数都比较小,可以直接放进项目的 utils 文件里。
6.1 一个真正能用的倒计时
结合第 5 节思路,封装一个微小的倒计时函数:
function countdown(endTime, onTick, onEnd) { const timer = setInterval(() => { const remain = endTime.getTime() - Date.now(); if (remain <= 0) { clearInterval(timer); if (onEnd) onEnd(); return; } const d = Math.floor(remain / 86400000); const h = Math.floor(remain / 3600000) % 24; const m = Math.floor(remain / 60000) % 60; const s = Math.floor(remain / 1000) % 60; if (onTick) onTick({ d, h, m, s }); }, 200); return timer; }使用时传入结束时间、每次更新的回调和结束回调即可。注意这里d、h、m、s都是从总剩余毫秒数推导出来的,不会出现累计误差。
6.2 相对时间:刚刚、几分钟前
很多内容平台展示“3 分钟前”,实现思路是当前时间减去目标时间,再按阈值分段:
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) + ' 小时前'; if (diff < 30 * day) return Math.floor(diff / day) + ' 天前'; return date.toLocaleDateString(); }这个函数的核心仍然是时间戳相减。阈值可以根据产品需求调整,比如“1 小时内显示分钟,24 小时内显示小时,7 天内显示天,更早显示完整日期”。
6.3 计算年龄
计算年龄要处理生日和当前日期的大小关系,不能只减年份:
function getAge(birthday) { const now = new Date(); let age = now.getFullYear() - birthday.getFullYear(); const isBeforeBirthday = now.getMonth() < birthday.getMonth() || (now.getMonth() === birthday.getMonth() && now.getDate() < birthday.getDate()); if (isBeforeBirthday) { age--; } return age; }先按年份差算一个初始值,再判断今年生日是否已经过了,没过则减 1。这样能正确处理边界情况。2 月 29 日出生的人在非闰年怎么算,需要按业务约定,通常取 2 月 28 日或 3 月 1 日作为判断基准。
6.4 跨时区的处理思路
跨时区场景下,最容易犯的错是“直接把本地时间字符串传给接口”。更稳妥的方案:
- 接口统一传时间戳,例如
Date.now(),单位毫秒要与后端约定好; - 展示时用
new Date(timestamp)转成本地时间; - 需要日期字符串时,优先用
toISOString()保证是 UTC,或明确带上时区偏移。
如果项目需要复杂的时区转换(例如“给纽约用户展示纽约时间”),原生 Date 不够用,建议引入专门的时间库,不要在业务代码里手动维护时区表。
7. 时间相关 bug 的排查链路
最后是我自己的排查经验。时间相关的 bug 很特殊,它往往不是“崩溃”型报错,而是“结果不对”型问题,所以很容易被误判成业务逻辑错误。遇到这类问题,我按下面顺序排查。
7.1 先看现象:是显示错、算错,还是定时不准
- 如果日期显示成 1970 年附近,优先怀疑时间戳单位,多半是秒被当成了毫秒。
- 如果月份比预期大 1,优先怀疑
getMonth()没有 +1。 - 如果倒计时忽快忽慢、切回页面后不准,优先检查定时器是否依赖计数累减,而不是绝对时间。
- 如果某个日期字符串解析后和预期不同,下一步检查输入格式。
先定位现象类别,能少走一半弯路。
7.2 再看时区和输入格式
时区问题会在“看起来没错,但对不上”时暴露。比如后端返回2025-01-15T02:30:00.000Z,前端直接toLocaleString()显示,和数据库里记录的时间差 8 小时,这是正常的,因为 Z 结尾表示 UTC。如果需要与本地时间一致,必须显式转换,而不是怀疑数据错了。
字符串解析也要注意:new Date('2025-01-15')和new Date('2025/01/15')在不同浏览器下表现可能不同。固定格式时,尽量用 ISO 8601 字符串,或者干脆传年月日参数给构造函数。
7.3 再检查运行环境差异
同一段代码,Windows 和 macOS 下的toLocaleString()输出可能不一样;Safari 对某些日期字符串的解析也比 Chrome 严格。如果只在某个浏览器或某台机器上出问题,优先怀疑运行环境差异。
7.4 最后看工具边界
最后才是确认是不是工具本身不支持。比如某些浏览器对new Date('2025-01-15 10:30:00')这种带空格的格式支持不统一,就属于浏览器对非 ISO 格式的解析差异。遇到这种情况,统一输入格式,或者手动拆分字符串再构造 Date,会稳定很多。
时间函数的坑很少是“原理不懂”造成的,更多是“边界没想清楚”。把单位、时区、补零、进位这四个点记牢,能避开绝大多数问题。我建议你在自己项目里把这些工具函数单独抽成一个文件,统一封装格式化、差值、相对时间等逻辑,长期看比到处散落new Date()好维护得多。