最近接了一个工单系统的小需求:每个工单创建后,要在页面上显示“预计截止时间”,格式是YYYY-MM-DD HH:mm:ss,同时给出“剩余X天X小时X分钟”的倒计时;如果超时了还要显示“已超时X天X小时”。这需求一听就是很常规的JavaScript日期操作,无非是“把当前时间加上7天,再格式化输出”。可真动起手来,我发现这里面的细节远比想象中多:月份索引从0开始、跨月进位、时区偏移、补零、12小时制、倒计时取整……
这篇文章就把我从这个需求里整理出的完整思路写下来,围绕“添加时间”和“格式化输出”这两件事,把原理、代码、边界情况、踩坑记录都放在一起。适合刚接手前端任务的新人直接照抄,也适合写过一段时间、但想把自己相对零散的日期操作经验整理成体系的开发者参考。
1. JavaScript日期操作的核心认知:先搞清楚Date对象里到底存了什么
1.1 Date对象内部只有一个毫秒时间戳
很多日期操作之所以出错,根源在于没有真正理解Date对象是什么。
JavaScript里的Date对象,内部其实只保存一个数字:从1970年1月1日00:00:00 UTC(协调世界时)开始计算到目标时刻的毫秒数。你可以通过getTime()拿到这个数字,也可以通过new Date(时间戳)把一个数字还原成日期对象。
这个认知非常关键。它意味着:getFullYear()、getMonth()、getHours()这些方法,不是从Date对象里“取出某个字段”,而是把内部那个毫秒时间戳,按照运行环境当前所在的时区,换算成一组“人类可读的日历字段”。同样一个时间戳,在不同时区的机器上调用getHours(),结果可能完全不同;但调用getUTCHours(),结果永远一致。
举个例子:
const d = new Date('2024-03-14T12:00:00Z'); console.log(d.getTime()); // 1710417600000,这个数字全球一致 console.log(d.getHours()); // 在UTC+8时区输出20,在UTC时区输出12 console.log(d.getUTCHours()); // 永远输出12理解这一点后,你会发现很多“日期操作”本质上只有两类:
- 第一类:修改时间戳本身,也就是“添加时间”的底层操作。
- 第二类:把时间戳按照某个时区投影成字段,再进行拼接,也就是“格式化输出”。
这两件事不要混在一起想。凡是把“添加时间”和“格式化”混在一个步骤里处理的代码,后期几乎都会在时区或进位问题上栽跟头。
1.2 必须先记住的五个日期常识
有一些基础规则,几乎是所有日期bug的温床。我在排查别人代码时,翻来覆去看到的都是这几个点,这里先集中列一遍:
- 月份从0开始:0代表1月,11代表12月。所以
new Date(2024, 0, 1)是2024年1月1日,getMonth()返回值要加1才是自然月份。 - 星期从0开始:0代表周日,1代表周一,6代表周六。所以
getDay()返回1时,对应的是周二,很容易搞混。 - 构造函数的参数顺序是固定的:
new Date(year, monthIndex, day, hours, minutes, seconds, milliseconds),月份索引是第二个参数,小时是第四个参数。 - 字符串解析行为在不同环境有差异:
new Date('2024-03-14')在某些浏览器会被当作UTC时间解析,而new Date('2024/03/14')通常被当作本地时间解析。这一点是很多“差8小时”问题的直接来源。 - 无参构造
new Date()表示当前时刻,等价于new Date(Date.now())。
这些规则看起来简单,但实际项目里几乎每个都会触发过一次问题。我后面讲的具体坑,基本都能在它们身上找到根源。
2. 添加时间的核心玩法:三种方案和它们的适用场景
2.1 方案一:get加set的日历语义
最直观的“加时间”写法,是先读取某个字段,加完再写回去:
const d = new Date(2024, 2, 31, 10, 30); // 2024年3月31日 10:30 d.setDate(d.getDate() + 7); console.log(d); // 2024年4月7日 10:30这套写法的好处是符合日常思维:“几天后”就是“把日期字段加几”。在处理跨月、跨年时,它自动帮你进位:3月31日加7天变成4月7日,12月25日加7天会变成次年1月1日。
类似的还有:
d.setMonth(d.getMonth() + 3); // 三个月后的同一天 d.setFullYear(d.getFullYear() + 1); // 一年后的同一天 d.setHours(d.getHours() + 2); // 两小时后的同一时刻这种“日历字段偏移”非常适合业务语义明确的场景,比如“下个月的同一天”“三天后的同一时刻”“明年此时”。
但它有一个隐藏问题:setDate()和setMonth()这类方法存在溢出进位机制。比如1月31日加一个月,2月没有31日,结果不会报错,而是自动进位到3月2日或3月3日。这个行为我用单独一节详细说。
2.2 方案二:时间戳毫秒运算
另一种写法是完全绕过日历字段,直接在时间戳上加毫秒数:
const d = new Date(2024, 2, 31, 10, 30); const nextWeek = new Date(d.getTime() + 7 * 24 * 60 * 60 * 1000); console.log(nextWeek); // 2024年4月7日 10:307天等于7 * 24 * 60 * 60 * 1000毫秒,这个计算式我建议拆开写,而不是直接写604800000,因为拆开后的可读性要好得多,别人一眼能看出你加的是“7天”。
这套写法的核心优势是:时间戳上做加法,结果绝对精确,不存在“进到哪一天”的歧义。它在需要精确计算时间差的场景下是最可靠的,比如倒计时、超时判断、两个时刻之间的毫秒差。
但要注意一个边界情况:在实行夏令时的时区,某一天可能是23小时或25小时。不过即便如此,时间戳加法依然能保证“时间戳意义上的正确”——你加的毫秒数没有错,只是“一天”这个概念在夏令时切换日不等于24小时。后面时区那节我会细讲。
2.3 方案三:直接构建新对象
第三种方案适合“基于一个已知时刻计算另一个时刻”的场景:不去修改原对象,而是用已知参数构建一个新Date对象:
const d = new Date(2024, 2, 31, 10, 30); const nextMonth = new Date(d.getFullYear(), d.getMonth() + 1, d.getDate(), d.getHours(), d.getMinutes()); console.log(nextMonth);这种方案的好处是避免副作用:原对象d不会被修改。写纯函数时很适用。
但它和setMonth()一样存在溢出进位问题,而且当天数不合适时(比如3月31日是3月,加一个月变成4月31日),结果也可能进位到5月1日。所以用这个方案时必须先在业务上确认“目标月份的天数一定够”。
2.4 溢出进位机制:setMonth和setDate不会报错,但会悄悄进位
下面专门展开讲溢出进位,因为它是“添加时间”里最容易踩的坑。
看这段代码:
const d = new Date(2024, 0, 31); // 2024年1月31日 d.setMonth(d.getMonth() + 1); console.log(d); // 猜猜结果?结果是2024年3月2日,而不是2月28日或29日。原因很简单:2月没有31日,JavaScript的setMonth不会抛异常,也不会截断到2月最后一天,而是把多出来的天数“顺延”到3月。
类似的:
const d = new Date(2024, 0, 31); d.setDate(d.getDate() + 30); // 1月31日 + 30天 // 1月还剩0天(当天不算),2月29天,3月1日 console.log(d); // 2024年3月1日这里如果只看“日期字段加数字”的表面,会觉得哪里不对,但实际上它做的是“绝对天数偏移”,这就是日历语义和普通加法语义的分歧。
这个机制本身不是bug,它甚至是有用的:它保证了日期始终合法。问题是很多开发者不知道它存在,于是在“加一个月”的场景里得到意料之外的日期。如果你确实想要“2月的最后一天”,需要自己判断目标月份的天数:
function addMonthsKeepEndOfMonth(date, months) { const result = new Date(date); const targetMonth = result.getMonth() + months; const lastDayOfTarget = new Date(result.getFullYear(), targetMonth + 1, 0).getDate(); result.setDate(Math.min(result.getDate(), lastDayOfTarget)); result.setMonth(targetMonth); return result; }这个函数先算出目标月份最后一天,再把当前日期截到两者中较小的那个,从而保证“1月31日加一个月”得到2月28日或29日。
在项目里,必须根据业务语义选择方案:日历语义明确用setMonth/setDate;精确时间差用时间戳加法;涉及月末特殊处理,就要自己写边界控制。
3. 格式化输出的完整方案:从手动拼接到Intl API
3.1 为什么我坚持自己写formatDate函数
JavaScript内置了toLocaleString()和Intl.DateTimeFormat,但我在大多数项目里仍然坚持维护一个自写的格式化函数。原因有三:
- 内置API在不同环境下的输出格式不完全一致,同样的
toLocaleString('zh-CN')在浏览器、Node、不同操作系统里可能有差异,这给测试和线上问题排查带来额外负担。 - 很多业务场景要求的是固定格式,比如日志里的
2024-03-14 09:30:00、文件名里的20240314_093000、报表里的2024年3月14日,用内置API反而不方便控制分隔符。 - 自写函数逻辑完全透明,出问题可以直接定位到是哪一行拼接错了。
于是我会维护一个基础方法:
function pad(n) { return String(n).padStart(2, '0'); } function formatDate(date, pattern = 'YYYY-MM-DD HH:mm:ss') { if (!(date instanceof Date) || isNaN(date.getTime())) { throw new TypeError('formatDate需要传入合法的Date对象'); } 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()), SSS: pad(date.getMilliseconds()) }; return pattern.replace(/YYYY|MM|DD|HH|mm|ss|SSS/g, (key) => map[key]); }使用起来很直接:
console.log(formatDate(new Date())); // 输出类似:2024-03-14 09:30:45 console.log(formatDate(new Date(), 'YYYY年MM月DD日')); // 输出类似:2024年03月14日我刻意把占位符设计成YYYY、MM、DD、HH、mm、ss,而不是%Y、%m这样的strftime风格,是因为前者在字符串里天然不会和普通文本冲突,一眼就能看出表示什么,而且用正则替换时也不会误伤。
pad(3)这种调用不会出错,padStart(2, '0')对于3会得到03,对于120则保持120。毫秒用pad(ms)可能补成三位,如果有四位毫秒的情况可以自行调整,不过一般三位足够。
提示:这个函数在入口做了合法校验,不要省略。因为实际项目里,你永远不知道调用方会传一个字符串、一个时间戳,还是一个已经失效的Date对象。提前抛错总比输出
NaN好排查。
3.2 周几、上午下午、12小时制怎么处理
很多格式化场景不止年、月、日、时、分、秒,还需要周几、上午下午这类附加信息。我在基础formatDate之上再包一层:
function formatDateCN(date) { const week = ['日', '一', '二', '三', '四', '五', '六']; const weekText = `星期${week[date.getDay()]}`; const ap = date.getHours() < 12 ? '上午' : '下午'; const h12 = date.getHours() % 12 === 0 ? 12 : date.getHours() % 12; return `${formatDate(date, 'YYYY年MM月DD日')} ${weekText} ${ap} ${h12}:${pad(date.getMinutes())}`; } console.log(formatDateCN(new Date())); // 输出类似:2024年03月14日 星期四 下午 6:30这里有两个细节值得说:
getDay()返回0时是周日。所以week数组的第一个元素必须是“日”,不能是“一”。- 12小时制的转换,最容易出错的是0点和12点。0点(凌晨)应该显示成“12”,12点(中午)也应该显示成“12”。所以用
% 12 === 0 ? 12 : % 12来把0归一化成12。
如果你的项目需要“上午/下午”的英文缩写AM/PM,可以这样:
const ap = date.getHours() < 12 ? 'AM' : 'PM'; const h12 = date.getHours() % 12 === 0 ? 12 : date.getHours() % 12;逻辑不变,只是文案不同。注意不要直接用hours > 12 ? hours - 12 : hours这种写法,因为hours等于0时会显示成0,这在12小时制里是不合法的。
3.3 用Intl.DateTimeFormat做本地化输出
虽然自写函数可控,但有一种场景我会优先选择Intl.DateTimeFormat:需要面向最终用户展示本地化文案,比如“2024年3月14日 星期四”,并且希望跟着用户系统语言变化。
const formatter = new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }); console.log(formatter.format(new Date())); // 输出类似:2024年3月14日星期四 09:30:45Intl.DateTimeFormat背后是ICU库的本地化规则,所以中文环境会自动带上“年月日”、周几、以及符合中文习惯的标点。而且它支持timeZone参数,可以在不改变运行环境时区的情况下,指定输出某个时区的时间,这一点自写函数比较难做到。
但副作用是:不同平台的ICU版本不完全一致,输出格式可能有细微差异。所以我的经验是:内部日志、文件名、接口返回字段,一律用自写格式化;面向用户展示、需要跟随语言和地区习惯的文案,才用Intl.DateTimeFormat。
4. 时区和UTC:添加时间与格式化输出中最大的暗坑
4.1 本地时间、UTC、时间戳三者到底什么关系
要理解日期操作中的时区问题,一定要有一个三维模型:
- 时间戳:一个绝对数值,表示“从1970年1月1日0点UTC到某时刻经过的毫秒数”,它不随时区变化。
- UTC字段:时间戳按照UTC时区换算出的年、月、日、时、分、秒。
getUTCFullYear()、getUTCHours()等拿到的是这个。 - 本地时间字段:时间戳按照运行环境所在时区换算出的年、月、日、时、分、秒。
getFullYear()、getHours()等拿到的是这个。
同一个时间戳,在不同时区下“本地时间字段”不同。比如2024-03-14T12:00:00Z,在UTC+8时区看到的是晚上8点,在UTC时区看到的是中午12点。但它的时间戳数值完全一样。
这也就解释了为什么同一个解析结果在不同地区会显示不同的时间:不是代码写错了,而是本来就应该不同。关键在于,你的业务逻辑里,存储和展示应该明确基于哪一种。
4.2 添加时间后时间戳真的对吗?格式化出来的字段对吗?
实际项目常见的出错场景是这样的:
后端接口返回一个ISO字符串,比如2024-03-14T09:30:00Z。前端拿到后,期望显示的是“2024年3月14日 09:30:00”,但直接用自写的formatDate格式化,得到的是“17:30:00”或“09:30:00”,取决于运行环境时区。
问题不出在formatDate,而在于没有定义清楚:后端这个时间应该被解释为哪个时区。
2024-03-14T09:30:00Z末尾的Z表示UTC时间。如果你希望用户看到的是UTC时间9点30分,那么格式化时必须用getUTC系列函数,或者用Intl.DateTimeFormat的timeZone: 'UTC'。
如果你希望用户看到的是自己时区的9点30分,那么后端就不应该返回带Z的时间,而应该返回2024-03-14T09:30:00+08:00这样的带时区偏移字符串,或者根本不带时区标记的本地时间字符串——当然,这又容易在不同浏览器间产生解析差异。最稳妥的方案是:后端返回时间戳毫秒数,前端显示时按需要决定用哪个时区。
添加时间的场景同理:
const created = new Date('2024-03-14T09:30:00Z'); const deadline = new Date(created.getTime() + 7 * 24 * 60 * 60 * 1000);这个deadline的时间戳一定正确:它是“创建时刻之后7天”的绝对时刻。但如果你用formatDate(deadline)展示,看到的是本地时区的字段。如果你本来想展示UTC的截止时间,就要用formatDateUTC。
所以我在项目里的一个重要经验是:定义两个函数,一个按本地时区格式化,一个按UTC格式化,调用时明确语义。
function formatDateUTC(date, pattern = 'YYYY-MM-DD HH:mm:ss') { if (!(date instanceof Date) || isNaN(date.getTime())) { throw new TypeError('formatDateUTC需要传入合法的Date对象'); } const map = { YYYY: date.getUTCFullYear(), MM: pad(date.getUTCMonth() + 1), DD: pad(date.getUTCDate()), HH: pad(date.getUTCHours()), mm: pad(date.getUTCMinutes()), ss: pad(date.getUTCSeconds()), SSS: pad(date.getUTCMilliseconds()) }; return pattern.replace(/YYYY|MM|DD|HH|mm|ss|SSS/g, (key) => map[key]); }这样就不用在代码里临时去纠结“这个函数到底该用getHours还是getUTCHours”。
4.3 不要手动写时区偏移常量
有些开发者喜欢这样处理时区:
const d = new Date('2024-03-14T09:30:00Z'); const local = new Date(d.getTime() + 8 * 60 * 60 * 1000); // 手动加8小时这个写法我只能说:千万别用在生产环境。因为一旦用户所在地区有夏令时,你写死的+8就是错的;而且即便现在是对的,“手动加偏移”会让代码里到处是魔法数字,其他人根本看不懂意图。
注意:处理时区问题,正确姿势是使用
Intl.DateTimeFormat的timeZone参数,或者完全依赖运行环境的本地时区。不要自己去算偏移。
如果需要把某个UTC时刻转成用户本地时间展示,直接new Date(utcString),然后按本地函数格式化即可。如果需要把本地时间转成UTC字符串存到库里,就用toISOString():
const now = new Date(); console.log(now.toISOString()); // 输出类似:2024-03-14T09:30:45.123ZtoISOString()永远输出UTC时间、ISO格式、带毫秒、带Z后缀,适合作为接口传输的标准格式。
5. 实操:一个“7天后提醒”功能的完整实现
5.1 需求拆解
回到开头说到的工单场景。具体需求整理如下:
- 创建工单时记录当前时间。
- 预计处理截止时间 = 创建时间 + 7天。
- 页面展示两个时间点,格式都是
YYYY-MM-DD HH:mm:ss。 - 同时在页面上显示动态倒计时:“剩余X天X小时X分钟X秒”。
- 如果当前时间已经超过截止时间,显示“已超时X天X小时X分钟X秒”。
这里要注意,“7天”在业务语义上是“7*24小时”,还是“7个日历日后的同一时刻”?在这个场景里,我选择按“7个日历日后同一时刻”处理,因为工单截止通常看的是日历日。但实现时仍然用时间戳加法,因为“同一时刻”在时间戳上就是加7 * 24 * 60 * 60 * 1000毫秒,只有遇到夏令时切换的那个小时才会有23/25小时差异,一般业务可以接受。
5.2 核心代码实现
先定义一个倒计时函数,把两个时间戳的差值转换为人类可读的文本:
function formatDuration(ms) { if (ms < 0) { return { overdue: true, text: formatDuration(-ms).text }; } const totalSeconds = Math.floor(ms / 1000); const days = Math.floor(totalSeconds / 86400); const hours = Math.floor((totalSeconds % 86400) / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; return { overdue: false, text: `${days}天 ${hours}小时 ${minutes}分钟 ${seconds}秒` }; }这里有两个容易出错的点:
- 用
Math.floor而不是Math.round。因为倒计时要保证“显示剩余1小时的时候,实际时间一定还大于1小时”,round可能把59分30秒四舍五入成“1小时”,这不符合倒计时语义。 - 负数先取绝对值再递归处理,这样超时状态的文本复用同一套计算,避免写两遍逻辑。
然后是主流程:
const created = new Date('2024-03-14T09:30:00'); // 实际项目中来自接口或用户输入 const deadline = new Date(created.getTime() + 7 * 24 * 60 * 60 * 1000); const now = new Date(); console.log(`创建时间:${formatDate(created)}`); console.log(`截止时间:${formatDate(deadline)}`); const diff = deadline.getTime() - now.getTime(); const result = formatDuration(diff); if (result.overdue) { console.log(`已超时:${result.text}`); } else { console.log(`剩余:${result.text}`); }这段逻辑非常直观:创建时间、截止时间、当前时间三者的关系一目了然,添加时间用的是时间戳加法,formatDate负责展示。但因为new Date('2024-03-14T09:30:00')这种不带时区的字符串在不同环境解析行为不同,真实项目中我会建议后端直接返回时间戳毫秒数,或者ISO带时区字符串。拿ISO字符串举例:
const created = new Date('2024-03-14T09:30:00+08:00');带+08:00后缀后,解析行为在所有现代浏览器和Node里都是确定的,不会再出现“为什么我这边显示差8小时”的问题。
5.3 边界情况验证
写完之后,我习惯把边界情况逐一验证一遍:
- 当前时间刚好等于截止时间。此时
diff = 0,formatDuration返回0天 0小时 0分钟 0秒,显示“剩余”,这符合业务预期。 - 当前时间超过截止时间1毫秒。此时
diff = -1,ms < 0,进入超时分支,取绝对值后计算,显示“已超时:0天 0小时 0分钟 0秒”,注意秒数不会变成负数。 - 跨月、跨年。比如创建时间是2024年12月31日,加7天,时间戳加法结果自动变成2025年1月7日,
formatDate能正确显示年份。 - 夏令时切换日。在实行夏令时的地区,某一天从23小时变成25小时,时间戳加法依然能保证“截止时刻在创建时刻7天后”,只是剩余时间计算里这一天会显示23小时或25小时,而不是24小时。如果业务对这种差异敏感,就应该基于日历日而不是毫秒来算剩余时间,不过工单提醒场景一般不敏感。
我把这些边界情况整理成一张表,方便测试时对照:
| 场景 | 输入diff | 期望输出 |
|---|---|---|
| 正常剩余 | 2天3小时4分5秒 | 2天 3小时 4分钟 5秒 |
| 刚好到期 | 0 | 0天 0小时 0分钟 0秒,overdue为false |
| 超时1毫秒 | -1 | 0天 0小时 0分钟 0秒,overdue为true |
| 正好7天 | 7*86400000 | 7天 0小时 0分钟 0秒 |
| 跨年场景 | 2024-12-31加7天 | 截止时间为2025-01-07 |
5.4 这个场景到底要不要引入dayjs/date-fns
很多初学者会问:都这么大一坨逻辑了,为什么不直接引入一个日期库?
我的判断标准很简单:
- 如果项目只是偶尔做一两次日期加减和格式化,原生Date完全够用,没必要引入依赖。
- 如果项目里到处都是日期操作:相对时间、日历组件、多时区转换、基于复杂规则的日期计算,那果断用
dayjs或date-fns,它们能帮你省下大量重复代码和边界处理。
我做了一个经验阈值:当项目里独立的日期操作超过5处,并且至少涉及两种不同的日期语义(比如“加一个月”和“加30天”并存)时,我就会考虑引库。否则,原生实现完全可控,而且不需要担心库的体积、API稳定性和团队学习成本。
如果决定引库,我通常选dayjs,因为体积小、API和原生Date接近、插件机制灵活。但要注意:库只会封装常见规则,不会替你决定业务语义。比如dayjs().add(1, 'month')同样存在“1月31日加一个月变成3月2日”的进位问题。所以在选库之前,先把业务语义定义清楚,比选哪个库更重要。
6. 常见问题与排查技巧实录
6.1 问题速查表
把我在实际项目中遇到的、以及帮同事排查过的典型日期问题整理成一张速查表,遇到类似现象可以直接对照:
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
| 月份显示总是比预期少1 | getMonth()从0开始 | 输出时getMonth() + 1 |
| 周几和日历对不上 | getDay()返回0是周日 | 用['日','一','二','三','四','五','六']对照 |
| 日期显示差8小时 | 字符串日期被按UTC解析 | 统一用带时区偏移的ISO字符串或时间戳 |
出现NaN | 日期字符串格式不被识别 | 手动拆字段,或用new Date(year, month, day) |
| 加30天结果跳到了下个月 | setDate溢出进位 | 确认业务语义,用时间戳加法或边界控制函数 |
| 1月31日加一个月变成3月2日 | setMonth溢出进位 | 用保留月末的函数,或明确“下个月最后一天” |
| 格式化输出在设备间不一致 | 内置API的ICU实现差异 | 内部日志用自写函数,用户文案用Intl |
| Intl输出带了奇怪符号 | 本地语言规则不同 | 检查locale参数和hour12配置 |
6.2 调试日期问题的一个高效套路
日期问题之所以难排查,是因为涉及“时间戳”“UTC字段”“本地字段”三层概念。我的排查顺序是固定的,整体下来效率很高:
- 第一步:确认原始数据是时间戳、ISO字符串,还是某个格式的日期字符串。
- 第二步:直接在控制台打印
new Date(原始数据).getTime(),确认时间戳是否符合预期。 - 第三步:打印
getTimezoneOffset(),确认当前环境时区。 - 第四步:分别打印
getHours()和getUTCHours(),对比两个字段。如果差异恰好等于时区偏移,说明问题出在“用错了字段”;如果差异不是整数小时,说明可能涉及夏令时。
这套流程能覆盖90%以上的日期bug。
还有一个小技巧值得提:在日志输出里统一用toISOString(),也就是UTC的ISO 8601格式。这样无论是后端日志、前端日志还是跨端联调,大家看到的是同一个绝对时刻,不会因为各自时区不同而产生误解。展示层再单独转换成用户本地时间。
6.3 单元测试里如何固定“当前时间”
最后分享一个工程化经验:日期操作相关的函数,最好把“当前时间”通过参数注入,而不是在函数内部直接new Date()。
假设你写了一个getDeadline函数:
function getDeadline(createdAt, days = 7) { if (!(createdAt instanceof Date)) { throw new TypeError('createdAt需要是Date对象'); } return new Date(createdAt.getTime() + days * 24 * 60 * 60 * 1000); }这个函数天然测试友好,因为它的输出完全由入参决定。但如果你的代码里到处直接new Date(),单元测试里就很难模拟“当前时间是2024年3月14日”的场景。
更彻底的做法是写一个nowProvider:
function createDeadlineService(nowFn = () => new Date()) { return { getRemaining(deadline) { const diff = deadline.getTime() - nowFn().getTime(); return formatDuration(diff); } }; }测试时传入固定的nowFn,就能稳定验证“剩余”“超时”等各种边界情况了。这个模式不需要任何测试框架技巧,只要在代码设计上留一个小口子,测试难度就会大幅下降。
我在实际项目里踩过“所有测试用例都依赖当时真实时间”的坑,结果一个月后测试断言全部过期。从那以后,凡是日期相关的业务函数,我都习惯把now作为参数或者可注入函数,能省掉很多维护成本。