1. 先把Date对象“到底是什么”彻底说透
写JS的人,几乎没有不用到Date的,但我觉得很多朋友对它其实一直处于“会用但不完全懂”的状态。拿我自己的经历来说,早年做后台管理系统,遇到一个需求:把用户上次登录时间存到localStorage里,下次登录时做对比。我当时满脑子想的都是“把时间转成字符串存起来”,结果取出来的时候发现类型对不上、比较大小全是NaN,排查老半天才发现是Date对象在作祟。
这里必须先说一个最核心的认知:JS里的Date对象,本质上存的是一个数字——从1970年1月1日00:00:00 UTC到目标时间的毫秒数。我们平时写new Date()、new Date("2023-07-01")、new Date(2023, 6, 1),这些参数只是构造时的“输入方式”,对象内部真正持有的值是那个毫秒数。
这就像你银行卡里存的是钱,不是存了一堆“取款凭证”。你存取款时可以用现金、刷卡、扫码,但账户余额始终是一个数字。Date对象也一样,用new Date()创建出来之后,你对它做的获取年份、月份、星期、格式化字符串等操作,本质上都是从那个毫秒数反推出来的“视图”。
我见过不少新手用typeof去判断一个Date对象,结果得到"object",一下就懵了:为什么不是"date"?因为Date是引用类型,typeof只能区分出基本类型和引用类型,具体是数组还是日期,要靠instanceof或Object.prototype.toString.call()来判断。这些都是基础中的基础,但确实容易绕晕。
还有一个经常被我拿来举例子的小细节:new Date(2023, 0, 1)和new Date("2023-01-01")看起来都是2023年1月1日,但两者相差可能不止8小时。为什么?因为前者是按本地时间解析的,后者是按UTC时间解析的。在中国(UTC+8)跑,前者显示的是北京时间1月1日零点,后者显示的是北京时间1月1日早上8点。这个坑太常见了,不信你可以自己试一下。
Date对象还自带一个很实用的小特性:比较大小可以直接用<、>、>=、<=。因为它内部是数字,JS会做隐式转换。但这里必须注意,不要用等号==或===直接比较两个Date对象,那比较的是“引用地址”,不是“毫秒数”。如果你要判断两个时间是否相等,正确姿势是拿getTime()或valueOf()去比。
再补充一个经验之谈:如果你需要把Date对象存到localStorage或者传给后端,最好的做法是存date.getTime()这个纯数字。这样既避免了时区问题,也避免了“字符串解析成Date再解析”这一层的折腾。我第一次做“记住用户上次操作时间”的功能时,就是吃了这个亏,后来统一改成毫秒时间戳,问题彻底消失。
2. 核心API的坑与实用套路:从年月日到格式化
2.1 getMonth为什么从0开始,以及怎么优雅地规避
Date对象的方法很多,但最让初学者抓狂的绝对是getMonth()。它返回的月份是从0开始计数的,也就是说一月返回0,十二月返回11。当初设计者是为了对应new Date(year, month, day)构造参数,但到了业务层这个设定非常反直觉。
我个人的习惯是封装一个小工具函数,统一做月份+1的转换:
function getMonthNumber(date) { return date.getMonth() + 1; }这样写出来的代码阅读性好很多,不至于在业务代码里到处都是+ 1和- 1的魔法数字。另外,getDate()返回的是“几号”,getDay()返回的是“星期几”(周日是0),这两个名字太像了,我见过不下十个同事在这里写错过。我的建议是:遇到“几号”就用getDate,遇到“星期几”就用getDay,两者根本没有可比性。
2.2 用原生方法拼出“YYYY-MM-DD HH:mm:ss”的通用写法
现在很多项目的日期格式化都交给dayjs或者date-fns来做,但如果只是拼一个字符串模板,手写也就两三行的事情:
function formatDateTime(date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); const hh = String(date.getHours()).padStart(2, '0'); const mm = String(date.getMinutes()).padStart(2, '0'); const ss = String(date.getSeconds()).padStart(2, '0'); return `${y}-${m}-${d} ${hh}:${mm}:${ss}`; }这里用padStart(2, '0')做补零,比传统写法里的三元判断(m < 10 ? '0' + m : m)要清爽太多。如果你担心ES2017的padStart语法在旧浏览器上不兼容,可以自己写一个简单补零函数:
function padZero(num, len = 2) { return String(num).padStart(len, '0'); }我在做报表导出的时候,经常需要把日期转成表格里的字符串格式,这个方法几乎是万能的。只要注意一点:业务上到底需要“本地时间”还是“UTC时间”。如果拿getUTCFullYear、getUTCMonth这一套去格式化,输出的结果和本地的getFullYear这一套相差8个小时,尤其在跨时区的项目中,这个区分至关重要。
2.3 toISOString和toLocaleString的适用边界
toISOString()会返回一个标准ISO格式的字符串,比如2023-07-01T08:00:00.000Z,末尾的Z表示UTC时间。这个格式非常适合传给后端,因为它自带了时区信息,不会产生歧义。但它也有一个缺点:显示出来的是UTC时间,不是本地时间。如果你直接把它扔给用户看,用户会发现自己本来中午12点提交的数据,显示成了凌晨4点。
toLocaleString()则相反,它会按用户本地的时区和语言习惯输出字符串。看代码的话,看着很友好,但坑在于不同浏览器甚至不同操作系统,输出格式可能不一样。Chrome里是2023/7/1 08:00:00,Safari里可能是2023年7月1日 08:00:00。这种不确定性在自动化测试里是灾难。
所以我的经验是:
- 存数据和传参:优先用
getTime()毫秒数或toISOString()。 - 展示给用户:优先用
Intl.DateTimeFormat(后面会说)或你自己拼的模板字符串。 - 日志打印:直接用
toLocaleString()其实没问题,自己能看懂就行。
3. 时区问题:为什么你的时间在用户电脑上“变了”
聊到Date对象,不可避免要聊时区。很多人写代码时完全不考虑时区,直到某天发现海外用户看到的日期和国内用户不一样,才开始痛苦地排查。这个问题的根源在于:Date对象本身只存储了一个UTC毫秒数,而所有本地的getFullYear、getMonth、getHours等方法,都是按照运行环境所在的时区去解析的。
举个例子:你存了一个时间戳1688140800000,在中国(UTC+8)它对应的是2023年7月1日08:00:00,但在英国(UTC+0),它对应的是2023年7月1日00:00:00。同一个数字,不同时区的人看到的时间文字完全不同。
这个问题解决起来有几种思路:
3.1 纯前端展示场景:让浏览器按本地时区渲染
绝大多数情况下,我们只需要“用户在哪个时区,就按哪个时区展示”。这时根本不需要做任何转换,直接new Date(timestamp),然后用本地时间方法去渲染即可。浏览器的运行环境会帮我们处理好一切。
比如一个活动倒计时,后端把活动结束时间存成UTC时间戳,前端只需:
const endTime = new Date(serverTimestamp); const now = new Date(); const diff = endTime - now;这样拿到的diff是毫秒差,不需要关心时区。倒计时本身也不涉及“年月日”的文字显示,只涉及小时分钟秒,天然免疫时区问题。
3.2 固定时区的会议/排期场景:需要用到偏移量或指定时区库
但如果你做的是一个“全球统一的直播排期表”,比如无论用户在哪里,都希望看到“北京时间20:00开播”,那就不能依赖浏览器本地时区了。这时有几个方案:
- 后端在返回时间字段时直接带上时区偏移,前端用偏移量做手动换算。
- 前端直接用
dayjs配合utc插件,把指定时区的时间格式化输出。 - 用
Luxon库的fromObject配合zone参数直接指定时区。
我实际项目中用得比较多的是Luxon的DateTime.fromISO配合setZone,因为它在处理夏令时上比手动算偏移靠谱得多。这里特别提醒一下:夏令时不是一个可以靠“固定偏移量”解决的问题,它会因为地区和日期而变化。如果你自己写偏移量调整逻辑,大概率会在10月某个周日凌晨踩坑。
3.3 toLocaleString里的timeZone参数
toLocaleString还有一种高级用法,可以指定输出哪个时区的时间:
new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }); new Date().toLocaleString('zh-CN', { timeZone: 'America/New_York' });这个API非常强大,它会根据你指定的时区去计算时间,而不是受运行环境影响。我在做后台国际化的时候经常用它来调试“某个时区当前几点”这种问题。唯一的注意点是:timeZone参数必须是IANA时区标识符,不是简单的“UTC+8”这种写法,写错了会直接抛异常。
4. 解析字符串的那些坑:new Date("...")并不总是那么听话
用字符串构造Date是日常操作,但这里隐藏着好几个坑。
4.1 连字符“-”与斜杠“/”在不同浏览器里的差异
先给个结论:new Date("2023-07-01 08:00:00")这种带空格和连字符的写法,在Safari里大概率返回Invalid Date,但在Chrome里能正常工作。这个差异其实很久了,而且至今仍然存在。
为什么?因为ECMA规范只要求实现YYYY-MM-DDTHH:mm:ss.sssZ这种ISO 8601格式,而YYYY-MM-DD HH:mm:ss是各种浏览器厂商自己额外支持的。Chrome宽宏大量地把它当成本地时间解析了,Safari则比较严格,直接不认。所以凡是把后端传来的“2023-07-01 08:00:00”直接new Date()的代码,都有潜在兼容性风险。
我的建议是,遇到这种格式,先做个正则替换:
const raw = "2023-07-01 08:00:00"; const safe = raw.replace(/-/g, '/'); const date = new Date(safe);替换成斜杠之后,各浏览器都能正确解析了。这个土办法虽然看着不太高级,但在传统项目里救了我很多次。
4.2 ISO字符串末尾的Z代表什么
当后端返回一个类似2023-07-01T08:00:00.000Z的字符串时,很多前端朋友会忽略末尾的Z。这个Z表示“UTC时间”,如果没有Z,按ISO规范会被解析为“本地时间”。这两种解析结果最多能差出十几个小时。
我记得有次对接第三方接口,后端返回的是2023-07-01T00:00:00(没有Z),前端直接new Date()解析,把客户存的“当天零点”当成了“当天零点”,看着没什么问题。但客户在海外,他实际的“当天零点”和UTC零点并不是一回事。最后还是我通过对比数据库中时间戳的偏移量才定位到问题。当你看到不带时区标识的ISO字符串时,第一反应应该是去确认它到底代表哪个时区的时间,而不是直接拿去解析。
4.3 为什么不建议裸用new Date()去比较两个字符串时间
还有一类常见写法是:
if (new Date(start) < new Date(end)) { ... }如果你确定start和end都是能被稳健解析的格式,那没什么问题。但如果你不能保证来源格式统一,我强烈建议先解析成时间戳,再做比较:
const startTs = parseDateSafe(start); const endTs = parseDateSafe(end); if (startTs < endTs) { ... }这样即使其中某个字符串解析失败,也能通过安全函数返回一个NaN或者特殊值,不至于让整个逻辑在静默中跑偏。我之前就是因为贪图省事,用裸的new Date()去比较两个由不同部门接口返回的时间,结果其中一个字段带上了时区后缀、另一个没带,最后比较结果完全反了。
5. 时间戳、valueOf和原型方法:那些面试官爱问但日常没人讲的细节
Date的原型链上挂了一堆方法,但其中有几个很容易被忽略,细究起来却很有意思。
5.1 valueOf与getTime的关系
Date.prototype.valueOf()返回的其实就是内部毫秒数,和getTime()完全等价。但为什么会有两个方法?因为valueOf是JS所有对象都有的一个约定方法,它定义了对象在做隐式类型转换时怎么变成基本类型。Date自己重写了valueOf,让它返回毫秒数。
这个重写带来了一个非常实用的效果:Date对象做减法运算时,会隐式调用valueOf得到毫秒数。所以date2 - date1的结果是一个数字,而不是一个字符串或对象:
const d1 = new Date(2023, 0, 1); const d2 = new Date(2023, 0, 2); console.log(d2 - d1); // 86400000但如果用date2 + date1,结果就是拼起来的字符串了。因为加法运算符在遇到对象时会先调用valueOf,但valueOf返回的是数字,加法就直接把两个数字相加了,结果是毫秒数之和。这个特性既方便又容易引发误解,我记得以前排查过一个bug,就是某位同事拿两个时间做加法想“合并时间”,结果得到了一个巨大的数字。
5.2 比较时间戳而不是比较字符串
判断两个日期是否同一天,不要直接比较“年月日字符串”,更不要逐字段去getFullYear、getMonth、getDate三个分别比,这样代码又长又容易在某个月份边界出错。最稳的做法是取一天的开始时间戳来比:
function isSameDay(d1, d2) { return new Date(d1.getFullYear(), d1.getMonth(), d1.getDate()).getTime() === new Date(d2.getFullYear(), d2.getMonth(), d2.getDate()).getTime(); }原理是用本地时区把两个日期归一到当天零点,然后比较零点的时间戳。这样无论d1和d2的时分秒差多少,只要还在同一天,结果就是true。这个方法我在写日历组件、签到功能的时候用了无数次。
5.3 valueOf和时间戳的加减法
还有一个小技巧,如果你想给某个时间加一天,最简洁的写法不是date.setDate(date.getDate() + 1),而是直接在时间戳上加毫秒数:
const tomorrow = new Date(date.getTime() + 24 * 60 * 60 * 1000);这两种方式效果一样,但后者在“只关心时间数值,不关心本地时区”的场景下更不容易出错。比如计算两个时间戳之间隔了多少天:
const diffDays = Math.floor((d2 - d1) / (24 * 60 * 60 * 1000));这里直接用毫秒差除以一天的毫秒数,简洁明了。
6. 业务场景实操:从签到、倒计时到图表统计,Date的真实用法
6.1 签到连续天数统计
做签到功能时,最核心的一个判断是“用户今天是否已经签到过”。这个逻辑说白了就是比对“最后签到日期”是否等于“今天”。
function hasCheckedToday(lastCheckTimestamp) { if (!lastCheckTimestamp) return false; const lastDate = new Date(lastCheckTimestamp); const today = new Date(); return isSameDay(lastDate, today); }如果你后端存的是一个“纯日期字符串”,比如2023-07-01,那你要注意它可能是UTC日期,也可能是本地日期,两者在极端情况下会差异。我在用Java后端的时候遇到过类似的:后端用LocalDate.now()生成的日期字符串,前端直接new Date("2023-07-01")解析,得到的是北京时间上午8点。如果用户在当天0点到8点之间签到,比较结果永远对不上。后来后端统一返LocalDateTime的毫秒时间戳,这个问题才彻底终结。
6.2 图表统计里按日/月分桶
做数据报表时,经常会遇到“按天统计PV/UV”的需求。最简单的方式是把每个事件的时间戳取toDateString()或者getFullYear() + '-' + getMonth() + '-' + getDate()作为分桶键:
const bucketKey = `${d.getFullYear()}-${padZero(d.getMonth() + 1)}-${padZero(d.getDate())}`;这个键在Map里做累加非常合适。需要注意的是,如果你的项目是跨时区的,比如服务端在新加坡、用户在欧洲,那么按“本地日期”分桶和按“UTC日期”分桶的结果会不一样。有些统计报表需要统一按某个固定时区分桶,这时就要用getUTCFullYear这一套方法。我记得有次给客户做欧洲访问统计,如果按北京时间分桶,每天的高峰时段会被切到第二天的凌晨,整个曲线看起来非常诡异,后来统一改成UTC日期分桶才恢复正常。
6.3 倒计时与超时判断
倒计时本质上就是不断去计算“目标时间戳 - 当前时间戳”的差值。但有一个细节容易被忽略:setInterval并不是精确的,它可能会因为浏览器标签页被切到后台而节流,导致倒计时越来越不准。
我的做法是:在每次render间隔里,都用Date.now()重新计算真实剩余时间,而不是用上一个时间点减去固定的间隔。比如:
let remainMs = targetTime - Date.now(); setInterval(() => { remainMs = targetTime - Date.now(); // 重新渲染 }, 1000);这样即使setInterval被拖到两三秒才执行一次,渲染出来的剩余时间也不会累积误差。
超时判断也是同样的道理,判断用户是否登录超时,最快的方式就是:
const isExpired = Date.now() > expireTimestamp;千万不要去解析expireTimestamp字符串再比较,纯属多绕一圈。
6.4 日期选择器里的禁用逻辑
做日期选择器时,禁用过去日期、限制未来时间这些需求,核心也就是一次时间戳比较。比如禁用早于今天的日期:
const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0); const disabled = date < todayStart;这里要注意setHours(0, 0, 0, 0)会把时分秒都清零,拿到当天的开始时间戳。如果不做清零,直接用当前时间比较,那么“今天”的所有时间点都会被判定成过去,用户就没法选今天了。
7. 数据持久化和类型转换:这可能是你踩过最深的坑
7.1 JSON序列化后Date变成了字符串
你知道JSON.stringify一个含Date对象的对象时,Date会被转成什么吗?答案是ISO格式字符串。比如:
JSON.stringify({ time: new Date() }); // '{"time":"2023-07-01T08:00:00.000Z"}'这本身没什么,但如果后端把这个字符串存到数据库,下次返回给前端时,前端if判断里可能就会把它当成字符串而不是Date。这时候再做new Date(str)当然可以转换回来,但如果你忘了转换,直接拿它和new Date()比较,结果永远是false。
所以我的经验是:在后端接口返回时间字段时,尽量统一返回毫秒时间戳。即使返回字符串,也必须是带时区信息的ISO格式。最怕的就是“看起来是字符串,其实是Date,实际上又是字符串”这种不确定性。
7.2 从localStorage读时间戳的注意点
localStorage里只能存字符串,所以你存date.getTime()时,实际存的是字符串形式的数字。读取出来之后,如果你直接拿去做加法,会得到字符串拼接,而不是数字相加:
const saved = localStorage.getItem('lastTime'); // "1690000000000" const diff = saved - Date.now(); // 啊,这里居然是数值运算,没问题加法和减法在JS里的表现不同。减法会转成数字,加法会转成字符串。这个特性真的坑死过很多人。安全起见,读取时主动转一下类型:
const saved = Number(localStorage.getItem('lastTime')) || 0;7.3 new Date()参数个数不同,行为不同,但别混淆
new Date(2023, 6, 1)传三个数字,按本地时间解析;new Date('2023-07-01')传字符串,按UTC解析;new Date('2023/07/01')传带斜杠的字符串,在不同浏览器下按本地时间解析。同一个2023年7月1日,算出来的时间戳可能差8小时。这类问题我在面试时经常拿来考候选人,几乎十有八九答不对。
如果你确实需要“本地时间”的效果,最明确的方式是:
function localDate(year, month, day) { return new Date(year, month - 1, day); }月份记得减一,因为Date构造参数里月份从0开始。这样一行代码就能消除歧义。
8. 原生日期格式化方案:Intl.DateTimeFormat与toLocaleString的深入对比
8.1 Intl.DateTimeFormat的基本使用
如果你不想引入dayjs这类库,又想要可靠的本地化日期输出,Intl.DateTimeFormat是很好的选择:
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 }); formatter.format(new Date());这段代码会输出2023/07/01 08:00:00这种格式,具体分隔符和显示方式由浏览器根据语言环境决定。你用'en-US'会得到07/01/2023, 08:00:00,用'de-DE'会得到01.07.2023, 08:00:00。对于多语言站点的全局时间显示来说非常方便。
8.2 为什么我不推荐直接用date.toLocaleString()拼字符串
虽然toLocaleString()也能输出类似效果,但它有几个问题:
- 不同浏览器默认的分隔符不一致。
- 部分浏览器会忽略你传入的
hour12参数。 - 拼出来的字符串不好做进一步解析。
而Intl.DateTimeFormat的格式化结果是基于相对稳定的规则生成的,在大多数现代浏览器里输出差异较小。当然它也不是100%一致,但对于展示用途完全够用。
8.3 把Intl.DateTimeFormat封装成全局日期格式化方法
我在项目里通常会做一个统一的dateUtil:
const dateFormatters = { full: new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }), date: new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }) }; function formatFull(date) { return dateFormatters.full.format(date); } function formatDate(date) { return dateFormatters.date.format(date); }这样做的好处是,formatter实例只需要创建一次,后续直接复用,性能好很多。如果你每次都调Intl.DateTimeFormat创建新实例,在高频渲染场景下会白白浪费不少CPU。我当时在一个图表库里做tooltip的日期显示,刚开始没优化,一天下来肉眼可见地卡,后来用这种复用方式做了近一倍压测优化。
9. 我踩过的Date相关的坑和排查思路
这一节我把自己这些年工作中真正踩过的坑罗列一下,每个都有一个清晰的复现场景,方便你对照。
9.1 “2023-07-01 08:00:00”在iOS里的Invalid Date
这个坑已经提过一次,但值得重点强调。之前做移动端H5,后端返回预约时间字段,格式是"2023-07-01 08:00:00",iOS Safari上直接显示Invalid Date,安卓上一切正常。我当初排查了半天,最终用replace(/-/g, '/')解决了。
这个问题的根因在于:iOS上的JavaScriptCore引擎对非标准的日期时间字符串支持得极其有限,它基本只认ISO 8601。而Android上的V8引擎则宽容得多,甚至能解析英文月份的日期。所以在所有涉及“从字符串转Date”的前端代码里,最好统一先做一次规范化处理,不要指望浏览器会原谅你的不严谨。
9.2 用getMonth做月份累加时,跨年出错
有一次做会员到期时间计算,需求是“当前月份加3个月”,我直接写:
const month = date.getMonth() + 3; const newDate = new Date(date.getFullYear(), month, date.getDate());如果当前是11月,getMonth()是10,加3得到13,然后传给new Date(year, 13, date.getDate())。神奇的是,Date构造器会自己进位,把13月当作下一年的1月。看起来好像没问题,但如果当前是1月31日,加1个月后应该是2月29日或2月28日,可new Date(2023, 1, 31)会解析成3月3日。这又带来了一个“溢出”坑。
处理月加减业务时,要么先转到月底最后一天再算,要么直接依赖dayjs的add(3, 'month'),因为dayjs内部处理了月末的修正逻辑。你要自己做也不是不行,但一定要先理解“溢出”行为。
9.3 当成UTC解析的午夜时间串
还有一种很隐蔽的坑:后端返回一个日期字符串"2023-07-01"(没有时间部分)。在ISO规范里,没有时间部分的日期字符串会被视为UTC午夜。前端如果直接new Date("2023-07-01"),在中国时区会得到2023-07-01 08:00:00本地时间。结果当你显示“开始日期”时,会发现用户在7月1日凌晨看的时候,系统告诉他“还没到7月1号”。这是因为8点才会进入“本地7月1日”。
如果你希望"2023-07-01"被解释为“本地日期的零点”,比较稳妥的方式是:
const parts = raw.split('-'); const date = new Date(+parts[0], +parts[1] - 1, +parts[2]);这种拆分再拼接的方式虽然繁琐,却能准确表达“我是想按本地时间创建这个日期”的意图。
9.4 toISOString在不同时区会输出不同日期
还有一个容易混淆的点:toISOString()输出UTC时间,但因为UTC和本地时间存在时差,同一个“本地日期”在不同时区转成ISO字符串时,日期字段可能不一样。比如在北京时间2023年7月1日上午8点,new Date().toISOString()出来是2023-07-01T00:00:00.000Z,日期还是7月1日。但如果现在是北京时间晚上20点,toISOString()出来的会是2023-07-01T12:00:00.000Z,也是7月1日,没问题。可如果把时间再往后推到北京时间7月2日凌晨1点,toISOString()出来的日期就是2023-07-01T17:00:00.000Z,变成了7月1日。
这个现象在只看日期不看时分秒的业务里很容易产生误解。我有次做国际订单统计,按toISOString().slice(0, 10)做日期分组,结果订单在UTC日期和本地日期之间反复横跳,最后看后台数据才反应过来。
9.5 setInterval与倒计时精度问题
前面提过setInterval不精确,这里再补充一个排查实例。某次活动页倒计时总是慢半拍,用户反馈“明明到点了我还能抢”。排查后发现,页面在后台标签页时,浏览器会降低定时器的执行频率。而我的代码是在每次setInterval回调里减固定值,所以进度落后了。修复方式就是每次都用Date.now()重新计算目标时间与当前时间的差值,不依赖定时器的累积计数。
这种写法在性能上没有任何负担,因为Date.now()非常便宜,而且不受时区影响。
10. 推荐工具库与选型思考:到底该不该引入dayjs
聊了很多原生Date的用法,最后说说工具库选型。现在前端生态里最常用的日期库无非就是dayjs、date-fns、moment.js和luxon。moment.js已经进入维护模式,不再推荐新项目使用。date-fns按函数式风格导出,对tree-shaking友好。dayjs则是轻量、插件化的典型代表。luxon则是在时区处理上最强的。
10.1 我的实际选型经验
如果项目只是简单地格式化、加减日期、比较日期,我用dayjs就足够了,安装体积小很多,API也非常接近原生习惯:
import dayjs from 'dayjs'; dayjs().format('YYYY-MM-DD HH:mm:ss'); dayjs('2023-07-01').add(1, 'day').format('YYYY-MM-DD');如果项目里有强时区需求,比如全球排期、夏令时切换,我更倾向于用luxon,因为它原生支持IANA时区,不需要额外的插件。
如果你的项目里只是零星几处需要日期格式化,完全不需要引库,直接封装几个函数就够用了。为了一个format函数去增加好几个依赖,性价比其实很低。
10.2 工具库不能解决所有问题
无论用什么库,底层仍是对Date对象的封装,它依然受制于Date本身的解析差异和时区行为。也就是说,当你写dayjs('2023-07-01 08:00:00')时,它在iOS上同样可能解析失败,因为dayjs的内部也是调用了new Date()。所以即使引了库,输入数据的规范化和统一时区处理仍然是必修课。
我的几点实操建议
最后,不在末尾放那种“综上所述”的大道理,只说我个人的习惯:
第一,写任何和日期相关的工具函数之前,先明确你要处理的是“本地时间”还是“UTC时间”。这个定了,后面的代码基本不会出大问题。
第二,凡是用户输入的日期字符串,一律先规范成“YYYY/MM/DD HH:mm:ss”这种能被所有浏览器稳定解析的格式,再传给new Date()。这个操作虽然土,但能省下很多兼容性排查的时间。
第三,凡是后端返回的时间字段,如果可能,让后端给毫秒时间戳。联调的时候争取把这件事定好,后面断言、比较、存储都会清爽很多。
第四,如果你的项目对时间要求比较苛刻(比如倒计时、定时任务),核心逻辑直接用Date.now()和时间戳计算,不要绕来绕去做字符串拼接。
JS的Date对象就是这样一个东西,看起来简单到一句话能说完,真正用起来却有无穷细节。你把它当成“一个存着毫秒数的对象”去理解,很多坑都能从根上避开。希望这篇笔记能帮你少踩几个我已经踩过的坑。