写代码的人早晚会遇到时间相关的坑订单时间少了 8 小时、活动日期跨天不对、格式化结果出现NaN。我最初以为这些坑来自某个函数不会用后来才发现真正的问题是很多人在学时间函数时只背了getMonth()、getDate()这些 API却没有理解时间在计算机里到底是怎么存的。时间函数的数量其实不多日常开发里翻来覆去就是 timestamp、Date、ISO 字符串、格式化、时区转换这几件事。如果能把一个时间值从产生、传输到展示的整个过程想清楚绝大多数时间问题都可以在 5 分钟内定位。1. 先分清三个概念时刻、字符串和显示1.1 计算机里没有2024年1月1日当你写new Date(2024-01-01)时内存里存的是什么不是年、月、日三个数字而是一个绝对时刻——从 1970 年 1 月 1 日 00:00:00 UTC 开始计算到当前时间的毫秒数。Date 对象实际上只是包装了这个毫秒数。这个毫秒数可以通过getTime()打印出来。比如1704067200000这个时间戳在东八区看起来是 2024 年 1 月 1 日 08:00:00在 UTC 时区看起来则是 2024 年 1 月 1 日 00:00:00。无论你在地球哪个角落这个时间戳指向的物理时刻都是同一个只是墙上时钟读数不同。所以时间函数表面上是在处理日期时间实际上处理的是一根没有时区概念的时间轴上的刻度。如果只看日期不看时区就会在不知不觉中把同一个时刻翻译成了不同的日历值。1.2 时间函数分成三类别混着用从使用角度时间函数可以分成三大类获取类获取当前时刻、时间戳、年月日时分秒。解析类把字符串或数字转成 Date 对象。格式化和计算类把 Date 转成字符串、做加减、比较大小。很多人出错是因为把这三类混着用。比如用getHours()去判断一个 UTC 时间的小时数却忘了它是按本地时区返回的。又比如用toLocaleDateString()的结果去做日期比较最后比的是字符串字典序而不是真正的时间先后。这里最关键的原则在业务代码的传输层和存储层只认时间戳或 ISO 字符串只有在 UI 展示层才允许把时间转成本地可读的字符串。这个原则不是规范要求而是一种自我保护。时间戳无歧义任何环境拿到同一个时间戳都指向同一个时刻ISO 字符串自带时区信息比如2024-01-01T00:00:00.000Z末尾的 Z 表示 UTC也容易解析。而2024/01/01 08:00:00这种字符串没有时区信息只能依赖运行环境的默认时区去解释一旦服务器和用户不在一个时区结果就会出问题。1.3 为什么8小时问题那么常见8 小时偏差的根源就在这里。后端把数据库里的时间序列化成 JSON 时如果转成了2024-01-01 00:00:00这种没有时区标记的字符串前端拿到后直接new Date(字符串)浏览器会把这个字符串当作本地时间来解析。如果服务器在 UTC 时区写成 00:00:00前端在东八区会认为是本地 00:00:00再转成 UTC 就是前一天 16:00:00整个链路就会乱套。反过来后端返回2024-01-01T00:00:00.000Z前端不管在哪个时区都能正确解析为同一个时刻展示时再按本地时间格式化。所以使用时间函数前把这三个东西对齐数据从哪来、以什么格式传、最终显示给谁看。对齐之后再开始写代码。2. 一个时间值从前端到后端要过的三座桥2.1 数据源时间戳、ISO 字符串还是数据库的 datetime实际项目中时间信息的来源主要有三种客户端生成比如用户选择活动开始时间提交给后端。后端下发接口返回创建时间、更新时间等。数据库读取后端从数据库查询后封装给前端。这几种来源的形态可能完全不同。Java 后端常用的LocalDateTime序列化成 JSON可能是2024-01-01 08:00:00Node.js 后端可能直接返回 ISO 字符串数据库里可能是datetime也可能是timestamp。在做任何格式化之前必须先确定拿到的是哪种形态。我在排查问题时通常先写一个小检测函数确认值的类型function detectTimeType(value) { if (value instanceof Date) return Date; if (typeof value number) return timestamp; if (typeof value string) { if (/^\d{4}-\d{2}-\d{2}T/.test(value)) return ISO string; if (/^\d{4}-\d{2}-\d{2}/.test(value)) return date-only string; } return unknown; }这个函数不是为了生产使用而是在排查问题时快速定位报错前先知道输入到底是什么。2.2 解析不同字符串的容忍度差异很大JavaScript 的 Date 解析规则是很多人踩坑的重灾区。new Date(2024, 0, 1)这种构造方式参数是本地时间的年、月、日月份从 0 开始所以 0 表示一月。new Date(2024-01-01)会被当作 UTC 时间解析因为 ISO 8601 规定没有时区标记时按 UTC 处理。new Date(2024/01/01)在大多数浏览器里被当作本地时间解析但这不是 ECMAScript 规范强制要求的属于浏览器实现差异。new Date(2024-01-01 08:00:00)在 Safari 里返回 Invalid Date在 Chrome 里可能正常。这就导致同样的字符串在不同浏览器里可能差 8 小时甚至直接报Invalid Date。最稳妥的做法是所有来自外部的时间字符串都先规范成 ISO 8601 格式再解析。如果后端给的是2024-01-01 08:00:00先把空格替换成T再明确加上时区或转成时间戳处理。比如const raw 2024-01-01 08:00:00; const iso raw.replace( , T) Z; // 假设后端明确这是 UTC const date new Date(iso);当然拼接Z的前提是你明确知道后端字符串表示的是 UTC 时间。如果不知道时区宁可先和后端对齐也不要瞎猜。2.3 格式化输出前先问给谁看格式化是最后一环。同样一个 Date 对象toISOString()输出 UTC 的 ISO 字符串适合存数据库或传给后端。toLocaleString()输出本地的可读格式适合展示给当前用户。如果 UI 要求固定格式YYYY-MM-DD HH:mm:ss不能直接依赖这两个方法需要自己拼或用工具库。自己拼的时候要特别注意getMonth()从 0 开始所以要加 1。以下是常见写法function padTo2(num) { return String(num).padStart(2, 0); } function formatDate(date new Date()) { const y date.getFullYear(); const m padTo2(date.getMonth() 1); const d padTo2(date.getDate()); const h padTo2(date.getHours()); const min padTo2(date.getMinutes()); const s padTo2(date.getSeconds()); return ${y}-${m}-${d} ${h}:${min}:${s}; }这段代码看起来简单但它隐藏了一个前提getHours()返回的是运行环境本地时间。如果这段代码跑在 Node.js 服务端而服务器时区是 UTC输出就和用户本地时间不一致。所以格式化函数不要乱写在任意层它应该只出现在展示层。3. 日常开发真正要用的时间函数其实只有六个用例写代码这么多年我发现时间函数的常见用法掰着手指头数得过来。这里不追求 API 大全只把高频出现的六类场景整理出来合并成一套可直接复用的处理思路。3.1 获取当前时间戳获取当前时间戳是一个需求Date.now() // 返回毫秒不建议用new Date().getTime()虽然结果一样但Date.now()语义更清晰也不用创建对象。3.2 时间戳和 Date 互转从后端拿到时间戳注意单位是毫秒还是秒转成 Dateconst date new Date(1704067200000); // 时间戳 - Date const ts date.getTime(); // Date - 时间戳如果后端给的是秒一定要先ts * 1000。这个坑我见过不止一次后端返回1704067200前端忘了乘 1000结果日期变成1970-01-20。所以写一个转换函数function fromUnixSeconds(seconds) { return new Date(seconds * 1000); }3.3 日期比较比较两个时间谁先谁后直接把 Date 转成数字比较即可const isBefore (a, b) a.getTime() b.getTime();注意不要用a b来判断两个 Date 是否相等因为比较的是对象引用两个毫秒数相同的 Date 对象也不相等。需要判断相等时用a.getTime() b.getTime()。3.4 日期加减日期加减不要直接用value 24 * 60 * 60 * 1000因为人类习惯按天计算而一天不总是 24 小时夏令时地区。更稳妥的是用setDate等 APIfunction addDays(date, days) { const d new Date(date.getTime()); d.setDate(d.getDate() days); return d; }setDate会自动处理跨月和跨年比如 1 月 31 日加 1 天会得到 2 月 1 日如果是闰年可能是 2 月 29 日。3.5 格式化输出格式化建议封装成独立函数避免到处手写。对于复杂格式可以直接用第三方库 dayjs 的format方法。如果不想引入依赖上面提到的formatDate已经够用。需要带时间的场景可以扩展成const pad (n) String(n).padStart(2, 0); function formatDateTime(date) { return ${date.getFullYear()}-${pad(date.getMonth() 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}; }这里再次强调这个函数返回的是运行环境本地时间不是绝对时间。如果要在服务端输出固定时区的时间需要明确定义时区。3.6 时区转换时区转换是时间函数里最烧脑的部分。很多人的第一反应是用getUTCHours()拼出目标时区时间但这样会丢掉日期变化。更好的做法是先转成时间戳再按目标时区格式化。浏览器或 Node.js 内置的Intl.DateTimeFormat可以直接指定时区function formatInTimeZone(date, timeZone Asia/Shanghai) { return new Intl.DateTimeFormat(zh-CN, { timeZone, year: numeric, month: 2-digit, day: 2-digit, hour: 2-digit, minute: 2-digit, second: 2-digit }).format(date); }注意Intl.DateTimeFormat的结果是该时区下的墙上时间一旦离开展示层就不要拿它做数据透传。它的价值是让用户看到正确的时间而不是让系统内部去依赖字符串。3.7 一个小框架三个统一把上面六个用法合并可以提炼成一套很实用的流程统一入口任何外部时间值先转成时间戳或 Date 对象。统一运算比较、加减都在时间戳或 Date 对象上进行。统一出口只在展示层格式化并且格式化时明确时区。这样能省掉大量杂乱的new Date()和字符串拼接。4. 从跑通单个用例到长期稳定时间处理还需要四层设计很多人能正确调用 API但代码换个环境就炸。原因在于时间处理不是一个函数问题而是一个系统问题。长期稳定的时间处理方案至少要考虑四层。4.1 存储层永远存 UTC后端数据库存储时间字段时优先使用带时区的类型比如 PostgreSQL 的timestamptz或统一存 UTC。如果数据库只支持datetime那团队的约定就应该是所有写入都按 UTC。前端本地存储localStorage也尽量存时间戳而不是2024-01-01 08:00这种字符串。这样做的好处是数据本身没有歧义无论哪个时区的后端或定时任务来处理都不会因为服务器时区不同而改变事实。4.2 传输层ISO 8601 是默认语言API 返回的时间字段统一使用 ISO 8601 字符串例如2024-01-01T08:00:00.000Z。这个格式自带时区标记任何语言、任何平台都能无歧义解析。如果因为历史原因拿到了其他格式在进入业务逻辑前先做一次转换。比如自己写一个normalizeTimefunction normalizeTime(value) { if (value instanceof Date) return value.getTime(); if (typeof value number) return value; if (typeof value string) { const d new Date(value); return isNaN(d.getTime()) ? null : d.getTime(); } return null; }这样业务层处理的一致都是时间戳后面想怎么格式化都行。4.3 展示层按用户本地时区显示用户看到的时间应该用他本地的时区展示。浏览器默认的new Date()在解析 ISO 字符串后getHours()就会自动转到浏览器所在时区所以大部分场景不需要手动指定时区。只有类似平台有多个城市需要统一显示北京时间这种需求才需要强制指定timeZone。这种情况一定要在展示层处理而不是在数据层。4.4 工具层封装自己的时间工具无论用不用第三方库都应该把时间操作收敛到一个模块里不要散落在业务代码中。比如// time.js export const now () Date.now(); export const fromUnix (sec) new Date(sec * 1000); export const toUnix (date) Math.floor(date.getTime() / 1000); export const format (date, pattern) { /* 你的实现 */ };这样后续要统一换库、修 bug、加时区规则只需要改一个文件。如果团队规模大可以考虑统一引入 dayjs并在工具层封装自己的格式化和时区方法。4.5 边角条件闰年、夏令时、时区变化时间处理中有几个边角条件测试用例最好覆盖闰年2024-02-29 是否正常。跨年2023-12-31 加一天。夏令时如果目标用户在有夏令时的地区setDate的行为是否符合预期一般 Date 对象会自动处理但某些库可能忽略。无效输入非法字符串是否返回Invalid Date业务逻辑是否做了兜底。建议在项目里准备一批时间测试用例至少覆盖这些边界避免上线后才发现问题。5. 当时间不对了按这个顺序排查而不是在代码里打 log最后写一个排查链路也是我每次遇到时间问题时的检查清单。时间问题最难的不是修复而是定位。因为时间不像其他数据你肉眼看到的是字符串但实际出错可能在更早的环节。5.1 先确认数据源后端给的是毫秒还是秒是否带时区在浏览器 DevTools 的 Network 面板里直接看接口返回原始字段。问三个问题值是数字还是字符串如果是数字是毫秒还是秒如果是字符串是否以Z或08:00结尾这里最容易犯的错是还没看原始数据就开始改前端代码。很多时候问题不在前端。5.2 再确认解析你以为是同一个时间实际上环境解释不同拿原始字符串放到当前运行环境里执行new Date(value)再看date.toString()和date.getTime()。如果getTime()是NaN说明格式不被当前引擎支持。比如 iOS 上常见的2024-01-01 08:00:00就是典型的Invalid Date。const value 2024-01-01 08:00:00; const date new Date(value); console.log(date.toString(), date.getTime());如果输出Invalid Date和NaN就说明解析环节已经断了不需要继续查下面。5.3 第三查格式化输出用的是 UTC 方法还是本地方法排查时把代码里的getHours()、toLocaleDateString()、toISOString()都列出来识别它们各自基于哪种时区。如果代码里混用了getUTCHours()和getHours()那很可能在某些时区是对的换一个时区就错了。可以搜索代码里的getUTC和get调用逐个确认。5.4 最后查环境服务器的时区是否和预期一致有时候代码完全没问题只是部署的服务器时区不是 Asia/Shanghai。Node.js 服务端使用new Date()会依赖操作系统时区所以如果容器时区是 UTC那么getHours()的输出自然不同。这类问题在本地复现不出来要在容器里看date或者用 JavaScript 查看console.log(Intl.DateTimeFormat().resolvedOptions().timeZone);5.5 一个排查顺序表层次检查项常用工具/方法数据源字段类型、单位、时区标记Network 面板、typeof、正则解析是否 Invalid Date、解析出的时间戳new Date(value).toString()格式化使用 UTC 还是本地方法搜索getUTC/get调用环境服务器/浏览器时区date、Intl、process.env.TZ实际操作时按这个顺序走一遍80% 的时间问题都能定位。剩下 20% 往往出在业务逻辑对边界日期比如月末、闰年的处理上那就需要补测试用例。时间函数这个主题看起来是几个 API 的事真正做扎实会发现它其实在教我们一件事一个没有歧义的数据模型远比一堆聪明的代码更重要。如果你正在被时间问题折磨我的建议不是去背更多时间函数而是先把上面的三个统一和排查顺序动手过一遍。多数时候你把数据源头理顺了代码里那些getHours和Date.parse的纠结都会少掉一大半。