
时间函数是前端开发里最容易被低估的基础模块。倒计时、订单超时、日志时间、数据报表几乎每个项目都逃不过时间处理。很多人能写出new Date()但一遇到时区、格式化、跨月计算就开始反复试错。下面以 JavaScript 的Date对象为主线把时间函数的创建、读取、计算、格式化、时区处理和常见坑串成一条可复现的排查链路。读完以后你可以回到自己的项目里用同样的顺序检查时间相关代码。1. 时间函数的底层是时间戳不是字符串1.1 时间函数在做什么时间函数不是“把当前时间变成字符串”这么简单。真正的时间函数要做两件事把一个具体的时刻保存下来。把这个时刻按照某个时区翻译成年、月、日、时、分、秒等字段。JavaScript 的Date对象底层保存的并不是字符串而是从1970-01-01T00:00:00Z到某个时刻的毫秒数。这个整数也叫 Unix 毫秒时间戳。无论是new Date()、getTime()还是Date.now()最终都在和这个整数打交道。理解这一点很关键。你会发现同一个Date对象在不同时区的电脑上调用toString()会得到不同的年月日但调用getTime()永远是同一个整数。因为“时刻”是固定的只有“翻译成哪个时区”会变。1.2 UTC、本地时间和时间戳的关系先区分三个概念UTC世界协调时可以理解成一个“全球统一的时间刻度”。本地时间当前运行环境所在时区下的时间。时间戳一个绝对整数和时区无关。举个例子你在东八区执行下面这段代码const date new Date(2024, 0, 1, 0, 0, 0); console.log(date.toString()); console.log(date.toISOString()); console.log(date.getTime());如果你所在的机器是Asia/Shanghai那么输出可能是toString()Mon Jan 01 2024 00:00:00 GMT0800 (China Standard Time)toISOString()2023-12-31T16:00:00.000ZgetTime()1704067200000这里toISOString()返回的是 UTC 时间所以比本地时间少了 8 个小时。这个结果不是 bug而是toString()和toISOString()分别用了两种时区去翻译同一个时间戳。Date本身不包含时区信息它只存一个毫秒数。所有getHours、getDate这类方法都是根据当前机器的本地时区来翻译的。只要牵扯到“显示成几点几分”就必须先明确时区。1.3 先记住 Date 的关键 API日常开发中高频使用的 Date 方法可以先用一张表记住方法作用注意事项Date.now()返回当前时间戳毫秒静态方法不需要newnew Date()创建当前时间的 Date 对象无参构造getTime()返回毫秒时间戳适合比较和运算getFullYear()返回 4 位年份不要用getYear()getMonth()返回月份0-110 表示 1 月getDate()返回日期1-31注意是几号getDay()返回星期0-60 表示周日getHours()返回小时0-23本地时区getTimezoneOffset()返回本地与 UTC 的分钟差东八区返回 -480toISOString()返回 UTC 的 ISO 字符串适合存储和传输toLocaleString()返回本地化字符串格式受运行环境影响这些方法并不难难的是组合使用。下一章用一个最小闭环把创建、读取、设置和格式化都跑一遍。2. 创建、读取、设置和格式化一个最小闭环先跑通2.1 创建 Date 对象的四种方式Date构造函数有四种常见用法// 1. 当前时间 const now new Date(); // 2. 传入毫秒时间戳 const fromTimestamp new Date(1735689600000); // 3. 传入 ISO 8601 字符串 const fromISO new Date(2024-01-01T00:00:00Z); // 4. 传入年月日时分秒 const fromParts new Date(2024, 0, 1, 10, 30, 0, 0);这里最容易踩坑的是第四种。new Date(2024, 0, 1, 10, 30, 0, 0)的参数里月份0表示 1 月。浏览器、Node.js 都会把这个组合解释成“本地时间”而不是 UTC 时间。如果确实想得到 UTC 时间可以用Date.UTCconst utcDate new Date(Date.UTC(2024, 0, 1, 10, 30, 0, 0)); console.log(utcDate.toISOString()); // 2024-01-01T10:30:00.000Z实际项目中服务端返回时间时建议直接返回 ISO 字符串或毫秒时间戳前端不要自己拼“年月日时分秒”去构造Date否则很容易把时区搞混。2.2 读取年月日时分秒的推荐写法读取字段时getMonth()返回的是 0-11所以展示月份要加 1function getLocalParts(date) { return { year: date.getFullYear(), month: date.getMonth() 1, day: date.getDate(), hour: date.getHours(), minute: date.getMinutes(), second: date.getSeconds(), weekDay: date.getDay() }; } console.log(getLocalParts(new Date(2024-01-01T00:00:00Z)));如果你的机器是东八区new Date(2024-01-01T00:00:00Z)会显示成2024-01-01 08:00:00所以上面输出的小时可能是 8。这不是错误而是本地时区的正常翻译。如果希望读取 UTC 对应的字段把getFullYear换成getUTCFullYear把getHours换成getUTCHours其他字段同理。很多新人在排查时区问题时会在getHours和getUTCHours之间反复切换核心还是要先确定你想展示给用户的是本地时间还是 UTC 时间。2.3 设置字段时要留意月份和溢出setFullYear、setMonth、setDate、setHours都会修改原Date对象并且返回修改后的时间戳。常见错误是直接给 1 月 31 日加一个月const d new Date(2024, 0, 31); d.setMonth(d.getMonth() 1); console.log(d.toString());2024 年 2 月没有 31 日JavaScript 会自动把日期溢出到 3 月 2 日2024 年是闰年2 月有 29 日。这个行为在大多数情况下会带来隐藏 bug。如果你想要“下个月最后一天”或“月末”可以换个思路function getMonthEndDate(year, month) { // month 从 1 到 12new Date(year, month, 0) 表示下个月的第 0 天 return new Date(year, month, 0); } console.log(getMonthEndDate(2024, 2).getDate()); // 292024 年 2 月最后一天new Date(2024, 2, 0)里的month传 2代表 3 月但第 0 天会被解释为 3 月的前一天也就是 2 月的最后一天。这种写法在日期库出现之前是原生 JS 处理月末的常用技巧。2.4 格式化输出尽量用 Intl不要手拼字符串手写补零格式化容易出问题比如月份少加 1、小时没有补零function pad2(num) { return String(num).padStart(2, 0); } function formatDateTime(date) { return ( date.getFullYear() - pad2(date.getMonth() 1) - pad2(date.getDate()) pad2(date.getHours()) : pad2(date.getMinutes()) : pad2(date.getSeconds()) ); } console.log(formatDateTime(new Date()));这种写法能跑但只在当前机器时区下正确。如果页面要展示固定的业务时区推荐使用Intl.DateTimeFormatconst formatter new Intl.DateTimeFormat(zh-CN, { timeZone: Asia/Shanghai, year: numeric, month: 2-digit, day: 2-digit, hour: 2-digit, minute: 2-digit, second: 2-digit, hourCycle: h23 }); console.log(formatter.format(new Date(2024-01-01T00:00:00Z)));Intl.DateTimeFormat的好处是格式稳定可以在初始化时指定timeZone并且同一个 formatter 对象可以重复使用不需要在循环里反复 new。输出格式会受 locale 影响但年月日时分秒的顺序和分隔符由内部规则决定比手动拼字符串安全。3. 时间计算与比较毫秒是核心但要避免直接改原对象3.1 时间差计算的正确顺序计算两个时间点之间的间隔正确做法是先拿到毫秒时间戳再做减法最后换算成目标单位const start new Date(2024-01-01T00:00:00Z); const end new Date(2024-01-02T00:00:00Z); const diffMs end.getTime() - start.getTime(); console.log(diffMs); // 86400000 console.log(diffMs / 1000 / 60 / 60); // 24 小时不要用end - start直接相减。虽然减法运算会隐式调用valueOf()得到毫秒数但显式用getTime()更清晰也能避免其他类型转换带来的意外。如果要做日期的加减比如加 7 天可以先复制原对象再用setDatefunction addDays(date, days) { const copy new Date(date.getTime()); copy.setDate(copy.getDate() days); return copy; } const d new Date(2024-01-28T00:00:00Z); console.log(addDays(d, 2).toISOString()); // 2024-01-30T00:00:00.000Z跨年、跨月时setDate会自动处理溢出。比如 1 月 31 日加 1 天会变成 2 月 1 日。但要注意这个操作修改的是复制出来的对象不会污染原对象。3.2 日期比较不要用 两个Date对象不能直接用判断是否相等因为比较的是对象引用不是时间戳const a new Date(2024-01-01T00:00:00Z); const b new Date(2024-01-01T00:00:00Z); console.log(a b); // false两个不同对象 console.log(a.getTime() b.getTime()); // true console.log(a b); // true一元加号也会转成时间戳比较大小可以用、它们会先转成数值。但为了可读性和避免歧义推荐统一用getTime()或时间戳变量比较。3.3 加天、加月时先复制对象在实际业务中最常见的隐性 bug 是多个变量引用同一个Date对象某个方法内部修改了它导致外部数据也被改变const planStart new Date(2024-03-01T00:00:00Z); const planEnd planStart; // 引用同一个对象 planEnd.setDate(planEnd.getDate() 7); console.log(planStart.toISOString()); // 变到了 2024-03-08T00:00:00.000ZplanEnd 只是 planStart 的引用改 planEnd 等于改 planStart。为了避免这个问题任何“基于一个日期生成另一个日期”的逻辑都应该先复制function copyDate(date) { return new Date(date.getTime()); }3.4 用 setFullYear(2024, 2, 0) 这类技巧取月底除了上一章的月末写法还可以用setDate(0)获取上一个月的最后一天比如获取当前日期所在月份的最后一天const today new Date(); const lastDayOfThisMonth new Date(today.getFullYear(), today.getMonth() 1, 0); console.log(lastDayOfThisMonth.toString());这里today.getMonth() 1代表下一个月第 0 天就是当月的最后一天。这个思路在生成月报、账单周期时非常常用。4. 时区问题不靠猜按这条链路排查4.1 后端返回 UTC 时间前端显示差 8 小时不是 bug一个典型的时区问题是这样的后端返回: 2024-01-01T00:00:00Z 前端渲染: 2024-01-01 08:00:00很多人的第一反应是要减掉 8 小时。实际上2024-01-01T00:00:00Z表示 UTC 0 点在前端东八区运行时浏览器会自然把它显示成早上 8 点。这是符合预期的本地时间翻译。真正需要判断的是产品诉求如果页面要展示“用户本地的时刻”那么当前逻辑是对的。如果页面要固定展示“北京时间”则应该用Intl.DateTimeFormat搭配timeZone: Asia/Shanghai。如果页面要展示“和 UTC 一致的原始值”则直接显示toISOString()或后端原字符串。排查时不要靠肉眼猜先打印关键信息const raw 2024-01-01T00:00:00Z; const dt new Date(raw); console.log(dt.toISOString()); // UTC 时间 console.log(dt.getTimezoneOffset()); // 当前环境时区偏移东八区为 -480 console.log(dt.toString()); // 本地完整字符串4.2 toISOString、toLocaleString 和 getTimezoneOffset 的分工这三个方法经常被混用实际分工完全不同。方法时区典型用途toISOString()固定 UTC存储、传输、日志toLocaleString()当前本地时区 locale人肉查看、简单调试getTimezoneOffset()当前本地时区偏移判断运行环境时区不是某一个 Date 的时区getTimezoneOffset()返回的是“本地时间相比 UTC 相差多少分钟”注意正负号是反的。东八区返回-480因为本地时间比 UTC 早 480 分钟。它只能告诉你运行环境在哪一个时区不能用来给某个日期设置时区。如果需要把某个 UTC 时间转换成固定业务时区不要这样写const bad new Date(dt.getTime() 8 * 3600 * 1000);这会把“时刻”本身改掉只是为了让getHours()输出 8 点。如果后续再调用toISOString()或传给后端就会出现二次偏移。正确做法是展示层指定timeZoneconst shanghaiFormatter new Intl.DateTimeFormat(zh-CN, { timeZone: Asia/Shanghai, year: numeric, month: 2-digit, day: 2-digit, hour: 2-digit, minute: 2-digit, second: 2-digit, hourCycle: h23 }); console.log(shanghaiFormatter.format(dt));4.3 只传日期字符串和传完整 ISO 字符串解析规则不一样这是 JavaScript 时间解析里很隐蔽的一个差异console.log(new Date(2024-01-01).toISOString()); console.log(new Date(2024-01-01T00:00:00).toISOString());在规范定义中2024-01-01这种仅日期形式会被当成 UTC 时间解析输出2024-01-01T00:00:00.000Z。而2024-01-01T00:00:00这种日期加时间但没有时区后缀的形式会被当成本地时间解析。在东八区环境下第二行输出的是2023-12-31T16:00:00.000Z。这个差异很容易让前后端对不上。前端收到后端字符串时要确认字符串是否带时区偏移带ZUTC 时间可直接new Date(...)。带08:00明确的东八区时间new Date(...)能正确解析。只有日期和时间没有偏移规范会按本地时间处理不适合跨时区系统。所以服务端接口字段最好统一返回带时区的 ISO 字符串比如2024-01-01T00:00:00.000Z或2024-01-01T08:00:0008:00。如果因为历史原因返回了2024-01-01 00:00:00前端解析前最好先补充明确时区而不是让浏览器按照本机时区猜。5. 时间函数最常见的五个坑和排查清单5.1 月份从 0 开始几乎所有新手都会踩这个坑const d new Date(2024, 11, 1); console.log(d.getMonth()); // 11getMonth()返回 0-11想显示 12 月必须getMonth() 1。年份、日期没有这个问题只有月份特殊。建议封装一个getMonthZh之类的工具函数统一处理加 1。5.2 new Date(null) 等于 1970new Date(undefined) 是 Invalid Date这种问题通常来自接口返回了空值前端没有判空就传给new Date()console.log(new Date(null).toISOString()); // 1970-01-01T00:00:00.000Z console.log(new Date(undefined).toString()); // Invalid Datenull会被强制转成0所以得到 1970 年看起来“能正常显示”但实际是错误数据。undefined则直接变成 Invalid Date。在从接口拿时间字段时必须先判空。5.3 非标准字符串在不同引擎下表现不一致new Date(2024/01/01 10:00:00)这类非 ISO 字符串在某些浏览器和 Node.js 版本下可以解析但它依赖实现不保证所有环境一致。生产环境不要依赖这种写法。如果必须解析稳妥的做法是要求后端返回时间戳或 ISO 字符串。前端使用正则拆分字符串然后使用new Date(year, month, day, ...)或Date.UTC。或者使用日期工具库解析。5.4 直接修改原对象会污染业务状态很多接口会返回一个时间字段前端需要基于它生成开始时间和结束时间。如果直接修改原对象后面的逻辑拿到的是被改过的值。推荐做法是任何“时间计算”都返回新对象并且用函数名表明这一点比如addDays、addMonths、getStartOfDay。团队内部可以约定Date对象默认不可变只有通过专门的工具函数或显式赋值才允许修改。5.5 时间问题排查清单拿到一个时间显示不对的问题按下列顺序排查排查步骤操作判断标准1. 确认原始值打印接口返回的字段看是否带时区确认是 UTC、本地时间还是时间戳2. 确认解析对象打印new Date(raw).toISOString()看 UTC 时刻是否和预期一致3. 确认运行环境打印new Date().getTimezoneOffset()确认当前浏览器或 Node 的时区4. 确认展示时区打印toLocaleString()或Intl.DateTimeFormat结果看显示结果属于哪一时区5. 确认是否做了二次偏移搜索代码里有没有手动加减时间戳避免getTime() 8 * 3600 * 1000这类写法6. 确认格式化方法检查是否用getMonth()且没有加 1防止月份少 1 个月这套顺序基本能解决 90% 的前端时间显示问题。6. 时间函数的最佳实践和下一步扩展方向6.1 在不同环境下的使用策略学习环境里可以直接用new Date()和toLocaleString()快速验证不需要引入额外依赖。但到了生产环境时间处理要遵循更严格的原则存储层统一使用 UTC 时间或毫秒时间戳避免数据库中存“本地时间字符串”。接口层统一返回带时区的 ISO 8601 字符串例如2024-01-01T00:00:00.000Z。展示层使用Intl.DateTimeFormat指定用户或业务需要的时区。计算层用时间戳做运算减少对setHours、setMonth等修改式 API 的依赖。生产环境还需要考虑日志。时间相关 bug 往往依赖输入数据日志里最好同时记录原始字符串、toISOString()结果和最终格式化结果。这样排查时可以直接对比。6.2 选原生还是日期库原生Date能覆盖基础需求但在复杂场景下不够直观比如时间差、时区转换、日期时间戳计算。常见的日期库有方案适用场景注意原生 Date简单格式化、基本时间戳计算注意月份、时区、解析差异dayjs轻量项目、常用 API 封装API 简单体积小date-fns函数式风格、按需引入不可变设计tree-shaking 友好Luxon复杂时区、业务时区转换DateTime 模型更接近业务Moment.js老项目迁移过渡新项目一般不建议引入如果只是几个页面显示时间原生Intl.DateTimeFormat足够。如果项目里有大量时间区间、时区转换和周期性任务用 dayjs 或 date-fns 能减少重复代码。真正遇到极端时区需求再考虑 Luxon。6.3 从 Date 继续深入的学习路线时间函数是一个入口深入下去可以分三条线继续学JavaScript 语言线学习Intl.DateTimeFormat的完整参数理解TemporalAPI 的提案方向。服务端线对比 Java 的java.time、Go 的time包、Python 的datetime处理时区的方式。数据库线理解TIMESTAMP WITH TIME ZONE、DATETIME、CURRENT_TIMESTAMP等字段语义。时间处理本质是“全球统一的时刻”和“各种时区下的展示”之间的转换。先把Date的时间戳模型搞懂再去看更高级的日期库和数据库时间类型你会明显感觉那些设计都是在补Date的不足。回到项目里可以从一个最简单的后台时间字段开始走一遍“接口原始值、Date 解析、格式化展示、跨时区验证”的完整链路这一步走通时间函数的基本功也就稳了。