写代码的人早晚会遇到时间相关的坑:订单时间少了 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:00;Node.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 互转
从后端拿到时间戳(注意单位是毫秒还是秒),转成 Date:
const 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等 API:
function 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。这个格式自带时区标记,任何语言、任何平台都能无歧义解析。
如果因为历史原因拿到了其他格式,在进入业务逻辑前先做一次转换。比如自己写一个normalizeTime:
function 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的纠结,都会少掉一大半。