1. 日期偏移这件事,远比你想的要容易翻车
new Date()大概是 JavaScript 里最容易被低估的一个 API。刚入行的同学看到它,第一反应往往是"不就是拿个当前时间吗",然后随手写一行new Date().toLocaleDateString()就交差了。可真到了业务里,需求往往不是"拿当前时间"这么简单,而是"拿当前时间的前 7 天""拿当前时间的后 30 天""判断某个日期是不是已经过期失效了"。这三个需求听起来都是小菜一碟,但每一个都埋着坑,而且坑还不浅。
我自己在项目里就吃过亏。有一次做优惠券过期判断,前端用new Date()算了个时间戳直接跟后端返回的过期时间比大小,本地测试一切正常,上线之后用户反馈"明明还没到期的券显示已过期"。排查了半天才发现,问题出在时区上——用户设备时区和服务器时区不一致,前端算出来的"当前时间"和后端理解的"当前时间"根本不是一回事。从那以后,我对日期处理这件事就再也不敢掉以轻心了。
这篇内容就是把我这些年踩过的日期坑、总结出来的实操方案,完整地梳理一遍。核心围绕三件事展开:怎么用new Date()拿到前几天的日期、怎么拿到后几天的日期、怎么判断一个日期是否已经失效。不管你是刚接触前端的新手,还是写了几年业务代码的老手,只要你的项目里涉及日期计算,这篇内容都能给你一些直接能抄的代码和避坑经验。我会尽量把每个参数、每个边界情况都讲透,让你看完之后不用再去翻 MDN 文档。
2. 先搞懂 Date 对象的底层逻辑,不然全是玄学
2.1 Date 到底存的是什么
很多人以为new Date()存的是"年月日时分秒",其实不是。Date 对象内部只存一个东西:从 1970 年 1 月 1 日 00:00:00 UTC 到现在的毫秒数,也就是我们常说的时间戳。你调用getFullYear()、getMonth()、getDate()这些方法时,它才根据当前设备的时区设置,把这个毫秒数"翻译"成你看到的年月日。
这个底层逻辑非常关键,因为它解释了很多看起来很奇怪的现象。比如为什么new Date(2024, 0, 1)和new Date('2024-01-01')得到的结果可能不一样——前者是按本地时区构造的,后者在部分浏览器里是按 UTC 解析的。再比如为什么跨时区传输日期时,直接传字符串容易出问题,而传时间戳就稳得多。
理解了这一点,你就能明白:所有日期偏移操作,本质上都是在对毫秒数做加减法。前 7 天就是减 7 天的毫秒数,后 30 天就是加 30 天的毫秒数。至于怎么减、减多少、减完之后怎么格式化,才是真正需要讲究的地方。
2.2 时区是绕不开的第一道坎
时区问题在日期处理里出现的频率高得离谱。我见过太多项目,本地开发环境跑得好好的,一到测试环境或者用户手里就出问题,十有八九是时区惹的祸。
举个具体的例子。假设服务器返回的过期时间是"2024-06-01 00:00:00",你在前端用new Date("2024-06-01 00:00:00")去解析。在 Chrome 里,这个字符串会被当作本地时间解析;但在 Safari 里,同样的字符串可能直接返回Invalid Date。这就是为什么我强烈建议:永远不要依赖字符串解析,要么用时间戳,要么用明确的年月日参数构造。
再比如判断日期是否失效。如果你拿new Date()和过期时间直接比大小,而这两个时间一个来自用户设备、一个来自服务器,时区不一致的话,判断结果就可能完全相反。稳妥的做法是统一用 UTC 时间戳比较,或者在后端就把时间处理成标准格式再传给前端。
提示:涉及跨端、跨时区的日期比较,优先用
getTime()拿到时间戳再比,不要直接比较 Date 对象或格式化后的字符串。
2.3 月份从 0 开始这个反直觉设计
getMonth()返回 0 到 11,0 代表一月,11 代表十二月。这个设计坑了无数人,我自己也写过new Date(2024, 12, 1)这种代码,结果得到的是 2025 年 1 月 1 日,而不是报错。JavaScript 的 Date 构造函数会自动进位,月份传 12 不会报错,而是顺延到下一年。
这个特性在日期偏移计算里其实有用。比如你要算"当前月份加 3 个月",直接new Date(year, month + 3, day)就行,不用手动处理跨年。但反过来,如果你在读取月份时忘了加 1,显示出来的日期就会差一个月,这种 bug 在表单回显、日历组件里特别常见。
3. 获取前几天的日期:三种方案对比与实操
3.1 方案一:时间戳加减法(推荐)
这是最直接、最不容易出错的方案。核心思路是拿到当前时间戳,减去 N 天的毫秒数,再用结果构造新的 Date 对象。
function getDaysBefore(days) { const now = new Date(); const timestamp = now.getTime(); const oneDayMs = 24 * 60 * 60 * 1000; const targetTimestamp = timestamp - days * oneDayMs; return new Date(targetTimestamp); } // 获取前 7 天 const sevenDaysAgo = getDaysBefore(7); console.log(sevenDaysAgo.toLocaleDateString());这里的24 * 60 * 60 * 1000就是一天的毫秒数:24 小时 × 60 分钟 × 60 秒 × 1000 毫秒 = 86400000。这个数字建议定义成常量,不要每次现算,既清晰又避免手误。
这个方案的优势在于逻辑纯粹,就是毫秒数的减法,不涉及月份、年份的边界判断。不管你是跨月、跨年还是跨闰年,时间戳减法都能正确处理。比如从 3 月 1 日往前推 1 天,它会自动得到 2 月 28 日或 29 日,你完全不用操心。
但它也有一个需要注意的地方:如果跨了夏令时切换的时间点,按固定 86400000 毫秒减出来的"日期"可能不是你期望的那一天。不过在国内大部分地区不涉及夏令时,这个问题影响不大。如果你的用户分布在全球各地,那就需要更精细的处理,后面我会讲到。
3.2 方案二:setDate 方法(直观但要注意细节)
setDate()方法允许你直接设置"几号",而且它支持传入超出范围的数值,会自动进位或退位。
function getDaysBeforeBySetDate(days) { const date = new Date(); date.setDate(date.getDate() - days); return date; } const sevenDaysAgo = getDaysBeforeBySetDate(7);这个方案读起来更符合人类直觉:"把日期设置为今天减 7 天"。它同样能正确处理跨月跨年,因为setDate(0)会自动变成上个月最后一天,setDate(-5)会继续往前推。
但这里有个大坑:setDate()会直接修改原对象。如果你写的是const date = new Date(); date.setDate(...),那date本身就被改了。如果你后面还要用原始的当前时间,就会拿到被修改后的值。所以稳妥的写法是先复制一份:
function getDaysBeforeSafe(days) { const date = new Date(); const copy = new Date(date.getTime()); copy.setDate(copy.getDate() - days); return copy; }注意:Date 对象是引用类型,任何 set 开头的方法都会修改原对象。在函数里操作日期时,养成先复制的习惯,能避免很多莫名其妙的 bug。
3.3 方案三:手动计算年月日(适合特殊需求)
有些业务场景下,你需要的是"日历上的前 N 天",而不是"精确的 24 小时 × N 之前"。比如统计报表里,你要的是"前 7 个自然日",这时候用时间戳减法可能会因为时分秒的差异导致结果偏差。
function getCalendarDaysBefore(days) { const now = new Date(); const year = now.getFullYear(); const month = now.getMonth(); const day = now.getDate(); // 构造当天零点,再减天数 const todayZero = new Date(year, month, day); todayZero.setDate(todayZero.getDate() - days); return todayZero; }这个方案先把时间归零到当天 00:00:00,再做偏移,得到的结果是"前 N 天的零点"。做日期区间查询、报表统计时,这种处理方式更符合业务预期。
三种方案怎么选?我整理了一个对比表:
| 方案 | 适用场景 | 优点 | 注意事项 |
|---|---|---|---|
| 时间戳加减 | 通用场景、精确到毫秒 | 逻辑纯粹、无边界问题 | 跨夏令时需额外处理 |
| setDate | 快速实现、代码简洁 | 直观易读、自动进位 | 会修改原对象,需先复制 |
| 手动计算 | 报表统计、自然日 | 结果符合业务直觉 | 代码稍长,需归零处理 |
4. 获取后几天的日期:加法里的隐藏陷阱
4.1 基础实现与参数说明
后几天的计算和前几天的逻辑完全对称,把减法换成加法就行。
function getDaysAfter(days) { const now = new Date(); const oneDayMs = 24 * 60 * 60 * 1000; return new Date(now.getTime() + days * oneDayMs); } // 获取后 30 天 const thirtyDaysLater = getDaysAfter(30);用setDate()也一样:
function getDaysAfterBySetDate(days) { const date = new Date(); date.setDate(date.getDate() + days); return date; }看起来很简单,但加法里有个比减法更隐蔽的坑:月末溢出。比如 1 月 31 日往后加 1 个月,你期望得到 2 月 28 日(或 29 日),但如果用setMonth()而不是setDate(),结果会变成 3 月 2 日或 3 月 3 日,因为 2 月没有 31 号,JavaScript 会自动顺延。
const date = new Date(2024, 0, 31); // 2024年1月31日 date.setMonth(date.getMonth() + 1); console.log(date.toLocaleDateString()); // 2024/3/2,不是期望的2月29日这个行为在计算"后 N 个月"时特别容易出问题。如果你要的是"后 N 天",用setDate()或时间戳加法都没问题;但如果需求是"后 N 个月",就必须手动处理月末边界,把日期钳制到目标月份的最大天数。
4.2 月末边界的手动处理
处理月末边界的标准做法是:先算出目标月份,再取目标月份的最后一天,如果原日期超过了这个天数,就钳制到最后一天。
function addMonthsSafe(date, months) { const result = new Date(date.getTime()); const targetMonth = result.getMonth() + months; result.setDate(1); // 先设为1号,避免溢出 result.setMonth(targetMonth); const lastDay = new Date(result.getFullYear(), result.getMonth() + 1, 0).getDate(); const originalDay = date.getDate(); result.setDate(Math.min(originalDay, lastDay)); return result; }这里用了一个小技巧:new Date(year, month + 1, 0)会返回目标月份的最后一天,因为"下个月的第 0 天"就是"这个月的最后一天"。这个写法比手动判断闰年、判断大小月要简洁得多。
4.3 跨年、跨闰年的验证
日期加法最容易让人不放心的就是跨年跨闰年。我建议在写完日期工具函数后,用几个边界用例跑一遍验证:
// 跨年验证 console.log(getDaysAfterBySetDate.call(null, 0)); // 需要调整调用方式 const dec31 = new Date(2024, 11, 31); dec31.setDate(dec31.getDate() + 1); console.log(dec31.toLocaleDateString()); // 2025/1/1,正确 // 闰年验证 const feb28 = new Date(2024, 1, 28); feb28.setDate(feb28.getDate() + 1); console.log(feb28.toLocaleDateString()); // 2024/2/29,正确 // 平年验证 const feb28Normal = new Date(2023, 1, 28); feb28Normal.setDate(feb28Normal.getDate() + 1); console.log(feb28Normal.toLocaleDateString()); // 2023/3/1,正确实测下来,setDate()和时间戳加法在跨年、跨闰年场景下都是可靠的,因为它们的底层都是毫秒数运算,不涉及"月份有多少天"的逻辑判断。真正需要小心的是setMonth()和setFullYear(),这两个方法在月末边界上会给你"惊喜"。
5. 日期失效判断:业务里最容易出错的环节
5.1 什么是"日期失效"
"日期失效"这个词在不同业务里含义不同,但归纳起来无非几种:过期时间已过(比如优惠券、会员卡)、有效期未开始(比如活动还没到开始时间)、日期格式非法(比如用户输入了 2024-13-45 这种不存在的日期)。前两种是业务逻辑判断,第三种是数据校验,处理方式完全不一样。
先说业务逻辑判断。最朴素的做法是拿当前时间和失效时间比大小:
function isExpired(expireTime) { const now = new Date(); const expire = new Date(expireTime); return now.getTime() > expire.getTime(); }这段代码能跑,但有几个隐患。第一,new Date(expireTime)如果传入的是字符串,解析行为在不同浏览器里不一致;第二,如果expireTime是null、undefined或者空字符串,new Date()会返回Invalid Date,而Invalid Date.getTime()返回的是NaN,任何和NaN的比较都返回false,也就是说失效时间非法时,这段代码会判定为"未失效",这在安全场景下是致命的。
5.2 健壮的失效判断函数
一个能上生产的失效判断函数,至少要处理三种情况:时间非法、已过期、未过期。我一般会写成这样:
function checkDateStatus(expireTime) { // 情况一:时间参数缺失或非法 if (expireTime === null || expireTime === undefined || expireTime === '') { return { valid: false, expired: true, reason: 'missing' }; } const expire = new Date(expireTime); // 情况二:解析出来是 Invalid Date if (isNaN(expire.getTime())) { return { valid: false, expired: true, reason: 'invalid' }; } const now = new Date(); // 情况三:正常比较 if (now.getTime() > expire.getTime()) { return { valid: true, expired: true, reason: 'expired' }; } return { valid: true, expired: false, reason: 'active' }; }这个函数返回的是一个对象,而不是简单的布尔值。为什么要这样设计?因为业务里经常需要区分"因为过期而失效"和"因为数据异常而失效",前者可能要提示用户"您的优惠券已过期",后者可能要提示"数据异常,请联系客服"。返回布尔值的话,调用方就没法区分这两种情况了。
提示:
isNaN(date.getTime())是判断 Invalid Date 的标准方法。不要用date.toString() === 'Invalid Date',那个依赖具体实现,不够可靠。
5.3 时区一致性:失效判断的隐形杀手
前面提到的时区问题,在失效判断里体现得最明显。假设服务器返回的过期时间是 UTC 时间"2024-06-01T00:00:00Z",用户在东八区,本地时间是 6 月 1 日早上 8 点。这时候:
- 如果前端用
new Date("2024-06-01T00:00:00Z")解析,得到的是 UTC 时间,转成本地时间是 6 月 1 日 8 点,判断结果是"刚好到期"。 - 如果服务器返回的是不带时区标识的
"2024-06-01 00:00:00",前端按本地时间解析,得到的是本地 6 月 1 日 0 点,判断结果是"已过期 8 小时"。
同样的字符串,差一个Z,判断结果差了 8 小时。这就是为什么我反复强调:跨端传输时间,优先用时间戳(毫秒数),或者用带时区标识的 ISO 8601 格式。如果实在要用字符串,前后端一定要约定好时区,并且在代码注释里写清楚。
5.4 日期格式合法性校验
除了业务失效判断,还有一类需求是校验用户输入的日期是否合法。比如用户在表单里填了2024-02-30,这个日期在日历上不存在,但new Date("2024-02-30")在部分浏览器里不会报错,而是自动变成2024-03-01。这种"静默修正"会让用户以为填对了,实际存进去的是另一个日期。
要严格校验,可以反向验证:把解析出来的年月日再拼回去,看是否和输入一致。
function isValidDateString(str) { const date = new Date(str); if (isNaN(date.getTime())) return false; const [y, m, d] = str.split('-').map(Number); return date.getFullYear() === y && date.getMonth() + 1 === m && date.getDate() === d; } console.log(isValidDateString('2024-02-30')); // false console.log(isValidDateString('2024-02-29')); // true(2024是闰年) console.log(isValidDateString('2023-02-29')); // false(2023不是闰年)这个反向验证的思路很实用,能挡住绝大多数非法日期输入。注意getMonth()要加 1,这是前面提到的月份从 0 开始的坑。
6. 实战中的常见问题与排查速查表
6.1 高频问题速查
这些年我在项目里遇到的日期问题,翻来覆去就那么几类。整理成表格,方便你遇到问题时快速定位。
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 日期差一天 | 时区不一致或字符串解析差异 | 统一用时间戳,或明确指定时区 |
| Invalid Date | 字符串格式不被浏览器识别 | 用年月日参数构造,或先校验格式 |
| 月末计算错误 | setMonth 自动溢出 | 手动钳制到目标月最后一天 |
| 失效判断反了 | NaN 参与比较返回 false | 先判断 isNaN,再比较 |
| 修改了原日期对象 | Date 是引用类型 | 操作前先 new Date(date.getTime()) 复制 |
| 月份显示差 1 | getMonth 从 0 开始 | 显示时 +1,构造时 -1 |
6.2 一个真实的排查案例
之前有个项目,用户反馈"会员到期提醒不准,有时候提前一天提醒,有时候当天才提醒"。我拿到问题后,先看了提醒逻辑的代码,发现它是这样写的:
const expireDate = new Date(user.expireTime); const today = new Date(); if (expireDate.getDate() === today.getDate()) { // 发送提醒 }问题一目了然:它只比较了"几号",没比较月份和年份。如果会员是上个月同一天到期的,也会触发提醒。而且getDate()返回的是本地时区的日期,如果用户跨时区,判断还会漂移。
修复方案是改成比较完整的时间戳,并且把"当天提醒"的逻辑改成"到期时间减去当前时间小于 24 小时":
const expireDate = new Date(user.expireTime); const now = new Date(); const diffMs = expireDate.getTime() - now.getTime(); const oneDayMs = 24 * 60 * 60 * 1000; if (diffMs > 0 && diffMs <= oneDayMs) { // 24小时内到期,发送提醒 }这个案例的教训是:日期比较一定要比完整的时间,不要只比某一部分。只比"几号"、只比"月份",都会在跨周期时出错。
6.3 几个我踩过的坑
第一个坑是用toLocaleDateString()做数据存储。这个方法返回的格式跟用户设备的语言设置有关,中文环境返回2024/6/1,英文环境返回6/1/2024。如果你把它存进数据库,后面解析就会乱套。存储日期一律用 ISO 格式或时间戳,展示给用户时才用本地化格式。
第二个坑是在循环里反复new Date()。有些同学写日期区间生成时,每次循环都调一次new Date(),结果因为执行时间差异,生成的日期序列可能出现重复或跳变。正确做法是在循环外拿一次基准时间,循环内基于基准时间做偏移。
第三个坑是忽略了getTime()返回的是毫秒。Unix 时间戳通常指秒,JavaScript 的getTime()返回的是毫秒,两者差 1000 倍。和后端交互时一定要确认单位,我见过因为单位搞错导致日期差了 50 多年的案例。
注意:和后端对接时间字段时,第一件事就是确认单位是秒还是毫秒,格式是时间戳还是字符串,时区是 UTC 还是本地。这三个问题问清楚,能省掉后面 80% 的日期 bug。
7. 封装一个能复用的日期工具
7.1 工具函数设计思路
与其在每个页面里重复写日期计算,不如封装一个轻量的工具模块。我的设计原则是:输入输出明确、不修改原对象、边界情况有兜底。下面是我常用的几个函数,你可以直接拿去用。
const DateUtil = { ONE_DAY_MS: 24 * 60 * 60 * 1000, // 获取 N 天前/后的日期,days 为负数表示前几天 offsetDays(days, baseDate) { const base = baseDate ? new Date(baseDate.getTime()) : new Date(); return new Date(base.getTime() + days * this.ONE_DAY_MS); }, // 格式化为 YYYY-MM-DD format(date) { if (!(date instanceof Date) || isNaN(date.getTime())) return ''; const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; }, // 判断是否失效 isExpired(expireTime) { if (!expireTime) return true; const expire = new Date(expireTime); if (isNaN(expire.getTime())) return true; return Date.now() > expire.getTime(); } }; // 使用示例 console.log(DateUtil.format(DateUtil.offsetDays(-7))); // 7天前 console.log(DateUtil.format(DateUtil.offsetDays(30))); // 30天后 console.log(DateUtil.isExpired('2024-06-01')); // 是否过期这个工具模块只有几十行,但覆盖了日常开发 90% 的日期需求。offsetDays用负数表示前几天、正数表示后几天,一个函数搞定两个方向,调用时不容易搞混。
7.2 格式化里的补零技巧
String(num).padStart(2, '0')是 ES2017 引入的方法,用来补零非常方便。以前大家习惯写num < 10 ? '0' + num : num,现在用padStart更简洁。不过要注意兼容性,如果需要支持很老的浏览器,还是用三元表达式稳妥。
格式化函数里我加了一个判断:如果传入的不是合法 Date,返回空字符串。这样调用方拿到空字符串就知道出问题了,比返回"NaN-NaN-NaN"要友好得多。
7.3 什么时候该引入日期库
如果你的项目日期需求很复杂,比如要处理各种时区转换、要支持多种日历系统、要做复杂的日期区间运算,那自己封装可能就不划算了,可以考虑引入成熟的日期库。但要注意,引入库会增加打包体积,一个日期库动辄几十 KB,对于简单的"前几天后几天"需求来说,完全是杀鸡用牛刀。
我的建议是:如果只是做日期偏移和失效判断,自己封装几十行代码就够了;如果涉及复杂的时区转换和国际化,再考虑引入库。不要为了一个addDays就引入整个库,那样得不偿失。
8. 写在最后的一点个人体会
日期处理这件事,说难不难,说简单也绝不简单。它的难点不在于 API 有多复杂,而在于边界情况太多、时区问题太隐蔽、浏览器行为太不一致。我这些年踩过的日期坑,几乎每一个都跟"想当然"有关——想当然地以为字符串解析没问题,想当然地以为时区都一样,想当然地以为setMonth会按预期工作。
如果你只能从这篇内容里记住一件事,我希望是:日期计算优先用时间戳,日期比较优先用getTime(),日期存储优先用 ISO 格式或时间戳。这三条原则能帮你避开绝大多数坑。至于那些月末溢出、夏令时、闰年的细节,等你真正遇到了再回来查也不迟。
最后分享一个小习惯:每次写完日期相关的代码,我都会用几个边界值跑一遍——跨年的 12 月 31 日、闰年的 2 月 29 日、平年的 2 月 28 日、月末的 31 日。这几个用例跑通了,基本就能放心上线了。这个习惯帮我省下了不少线上排查的时间,也推荐给你。