JavaScript 时间处理:Date 对象与时区问题完全指南

zero
zero 见习用户见习用户
发布于 2026-10-23 13:50 ·3 浏览 ·7 回复
内容摘要

核心是说明 JavaScript Date 只保存绝对时间戳、不保存时区,时区仅在格式化显示时参与计算;构造日期应优先用时间戳、数字参数或带时区的 ISO 字符串,避免依赖无时区字符串解析,并区分本地与 UTC 方法,存储和传输用时间戳或 ISO 字符串,显示再按用户时区处理。

学完这篇你能得到:一套把 JavaScript 日期时间处理“踩坑清单”和“正确写法”一次讲清的方法,包括 Date 内部到底存的是什么、字符串解析为什么时好时坏、跨时区怎么显示、以及业务里算天数/存数据库该怎么写。

第一步:先认清 Date 内部只有一个数字

new Date() 创建的对象,本质上只保存一个值:距离 1970-01-01T00:00:00Z 的毫秒数(时间戳)。它没有“存着时区”这回事,时区只在你把时间戳转成人能看懂的字符串时才参与计算。

const d = new Date();
console.log(d.getTime());        // 1699999999999,一个绝对时刻
console.log(d.toISOString());    // 2023-11-14T22:13:19.999Z,UTC 表示
console.log(d.toString());       // 2023-11-15 06:13:19 GMT+0800(本地时区)

所以记住一句话:存储和传输永远用时间戳或 ISO 字符串,显示才考虑时区。

注意:Date.now() 返回的是毫秒数,不是 Date 对象;要对象就写 new Date(Date.now()),但通常没必要。

第二步:构造 Date 的四种方式,只有两种可靠

new Date(1699999999999)            // ✅ 时间戳,绝对安全
new Date(2023, 10, 15, 6, 13)      // ✅ 数字参数,按“本地时区”解释
new Date("2023-11-15T06:13:19+08:00") // ✅ 带时区的 ISO 字符串
new Date("2023-11-15")             // ⚠️ 按 UTC 解析
new Date("2023/11/15")             // ⚠️ 按本地时区解析
new Date("2023-11-15 06:13:19")    // ⚠️ 非标准格式,行为因浏览器而异

两个大坑:

  1. new Date(2023, 10, 15) 的月份是 0 开始的,10 表示 11 月。这是最常见的差一个月 bug。
  2. "2023-11-15"(短横线日期)会被当成 UTC 零点,在东八区打印出来是 11-15 08:00;换成斜杠 "2023/11/15" 就变成本地零点。同一个日期字符串,两种结果。

注意:只要字符串里没有明确写时区(Z 或 +08:00),解析结果就不可靠。生产代码里不要依赖浏览器去猜。

第三步:本地方法与 UTC 方法必须分清

Date 提供了成对的方法,区别只在于“按哪个时区算”:

用途本地UTC
取小时getHours()getUTCHours()
取日期getDate()getUTCDate()
取星期getDay()getUTCDay()
设置setHours()setUTCHours()

要拿“用户所在时区相对 UTC 差多少分钟”,用 getTimezoneOffset()(东八区返回 -480,注意是负数且以分钟计,和常见的 +08:00 符号相反)。

const offsetHour = -new Date().getTimezoneOffset() / 60; // 8

注意:别看一个值直接做日期加减。date.getTime() + 86400000 在遇到夏令时切换的那天会偏差一小时。需要“加一天”时用 setDate(d.getDate() + 1),让引擎处理。

第四步:按指定时区格式化,交给 Intl

不要自己拼接 yyyy-MM-dd,Intl.DateTimeFormat 天生支持 timeZone,还能本地化:

const d = new Date(Date.now());

new Intl.DateTimeFormat('zh-CN', {
  timeZone: 'Asia/Shanghai',
  year: 'numeric', month: '2-digit', day: '2-digit',
  hour: '2-digit', minute: '2-digit', hour12: false,
}).format(d); // "2025-03-08 14:20"

new Intl.DateTimeFormat('en-US', {
  timeZone: 'America/New_York',
  dateStyle: 'full', timeStyle: 'short',
}).format(d); // "Saturday, March 8, 2025 at 1:20 AM"

需要注意,format() 输出的是给人看的字符串,不要再拿去 new Date() 解析。要机器可读就用 toISOString()。

第五步:业务里真正高频的三个场景

1. 存数据库 / 传接口
统一 date.toISOString() 或 date.getTime(),服务端按 UTC 存。前端展示时再按用户时区格式化。绝不要把 '2025-03-08 14:20:00' 这种不带时区的字符串直接入库。

2. 算相差天数
先抹平时分秒,避免 23 小时被算成 0 天:

function diffDays(a, b) {
  const MS = 86400000;
  const da = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
  const db = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
  return Math.round((db - da) / MS);
}

3. 判断“今天”
isToday 这类判断要基于同一个时区做比较,不能把本地日期和 UTC 日期混着比:

const isToday = (d) => {
  const n = new Date();
  return d.getFullYear() === n.getFullYear()
      && d.getMonth() === n.getMonth()
      && d.getDate() === n.getDate();
};

注意:如果服务器时区是 UTC 而用户在东八区,凌晨 0-8 点这段时间“今天”会算错。要么后端传时间戳前端算,要么显式指定业务时区。

第六步:什么时候该引库,什么时候等 Temporal

  • 只是格式化、加减几天:原生 + Intl 够用,不必引依赖。
  • 涉及多时区、夏令时、重复日程、复杂的“每月最后一个周五”:用 date-fns(函数式、可摇树)或 dayjs(体积小、API 像 moment)。旧的 moment.js 已停止新功能开发,新项目不建议再用。
  • 未来的标准是 Temporal(Temporal.ZonedDateTime、Temporal.PlainDate),把“绝对时刻”和“墙上时间”彻底分开,正是为了解决上面这些坑。新环境可以先做能力检测再渐进使用。

小结

  • Date 内部只有一个毫秒时间戳,时区只影响“读出来”的结果,不影响存的值。
  • 构造时优先用时间戳或带时区偏移的 ISO 字符串;new Date(2023, 10, 15) 的月份从 0 开始,短横线日期字符串按 UTC 解析。
  • 存储传输用 toISOString() / getTime(),展示用 Intl.DateTimeFormat + timeZone。
  • 日期加减用 setDate() 之类的语义方法,别硬加 86400000,避开夏令时。
  • 复杂时区逻辑上 date-fns / dayjs,长期看齐 Temporal。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-952.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 7

x123456
x123456 见习用户见习用户 1楼 2026-10-23 13:56

这套梳理基本可以直接当团队内部规范用了,我只想补两个最容易在业务里真出事的地方:getTimezoneOffset() 的符号方向,以及“算天数差别直接除以 86400000”。

先说明 getTimezoneOffset() 的符号。它返回的是 UTC - 本地 的分钟数,东八区是 -480,所以想拿到“本地比 UTC 快几小时”,要写 -d.getTimezoneOffset() / 60,得到 8。顺便提一句,这个方法只能告诉你“运行环境自己”的偏移;如果要换算成目标时区(比如服务器在 UTC、用户在东八区,或反过来),靠加减 offset 并不准确,因为不同时区的 DST 规则不一样。稳妥做法是用 Intl.DateTimeFormat('en-US', { timeZone: 'Asia/Shanghai', ... }).formatToParts() 取各部分再重组,内置的 tzdata 会处理夏令时。

再说天数差。用 (b - a) / 86400000 再 Math.floor 是常见的错误做法:一旦区间跨过夏令时切换,那天就只有 23 或 25 小时,floor 会少算一天。国内站点平时看不出来,但只要服务器或用户在欧洲、北美,就一定会遇到这个问题。正确做法是先把两端各自归到 UTC 零点再相减:

const dayDiff = (a, b) => {
  const ua = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
  const ub = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
  return Math.round((ub - ua) / 86400000);
};

这里用 Math.round 而不是 floor;遇到 DST 时,多出的 ±1 小时会在取整时被吸收掉。

存储和显示的分工,你帖子里那句“存储传输用时间戳/ISO,显示才管时区”是核心。补充具体写法:数据库用 DATETIME 时,在入口统一转 UTC 再落库,不要存本地时间;或者干脆用 BIGINT 存毫秒。展示层用 toLocaleString 或 Intl.DateTimeFormat,不要手写 padStart 拼接。Intl 自带时区、语言和格式规则,并且能省掉一堆补零判断。

最后一个坑:Safari 对 "2023-11-15 06:13:19" 这种空格分隔格式会直接返回 Invalid Date;Chrome 能解析,但得到的结果并不正确。比“解析成另一个时间”的风险更隐蔽。然后,Temporal 目前还在推进中,浏览器原生支持不全;现阶段仍可以先用 dayjs 和 date-fns-tz 过渡,但要记住:这类库只负责格式化,不能替你解决前面说的“存什么”的问题。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #1290 2楼 2026-10-23 14:00
x123456:这套梳理基本可以直接当团队内部规范用了,我只想补两个最容易在业务里真出事的地方:`getTimezoneOffset()` 的符号方向,以及“算天数差别直接除以…

补得准,尤其是 offset 的符号方向——-d.getTimezoneOffset()/60 在东八区才是 8,这个正负号每年都能坑到一批人。

再顺着你这条补几个可以固定下来的细节。第一,如果需要知道运行环境是哪个时区,用 Intl.DateTimeFormat().resolvedOptions().timeZone 拿 IANA 名字(Asia/Shanghai)比用 offset 数字稳得多,而且它和 formatToParts 是同一套 tzdata,能省掉“offset 对了但 DST 时期又错”的问题。第二,天数差那段我建议再加个小函数专门用来“取两个时刻的本地日历日期”:Intl.DateTimeFormat('en-CA', {timeZone, year:'numeric',month:'2-digit',day:'2-digit'}).format(d) 直接出 YYYY-MM-DD,两个字符串做字典序比较或转 Date.UTC 再减都行;很多场景其实根本不需要算出差多少天,只需要知道“是不是同一天”。第三,落库这块提一句 MySQL 的 TIMESTAMP 会跟着 session time_zone 自动换算,DATETIME 不会,所以统一在入口转 UTC 再写 DATETIME 最省心;BIGINT 存毫秒当然更彻底。

Safari 那个坑你话说一半没写完,但结论很简单:带空格或不带时区的字符串一律不解析,写个 parseDate(s) 封装 s.replace(' ', 'T'),再不行就拆字段走 new Date(y, m-1, d, h, mi, s)。另外 toLocaleString 别依赖默认 locale,服务和浏览器可能不一致;显式传 locale 和 timeZone。

延伸一下:如果项目里这类逻辑超过三处,直接上 date-fns-tz 或 Temporal(已在部分浏览器和 Node 落地),别手搓。

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 #1291 3楼 2026-10-23 14:08
zjlxcf:补得准,尤其是 offset 的符号方向——`-d.getTimezoneOffset()/60` 在东八区才是 8,这个正负号每年都能坑到一批人。 再顺着你…

这几条都能直接进规范,但其中两个“看着稳、其实有版本坑”,值得先钉死再写进文档。

Intl.DateTimeFormat('en-CA') 出 YYYY-MM-DD 这个用法我见过翻车:它依赖 ICU 数据,Node 的 small-icu 构建(Docker 官方 slim/alpine 镜像常见)里,某些版本会退化成 11/15/2023,同一份代码在本地 macOS 正常、上容器就错。要么显式装 full-icu,要么用 formatToParts() 自己拼 year-month-day,两行代码换来跨环境确定性,很划算。顺带一提,toISOString() 遇到 Invalid Date 不是返回字符串而是抛 RangeError,解析失败没拦住就直接往日志里塞,比返回 "Invalid Date" 危险得多,parseDate 里记得先判 isNaN(d.getTime())。

MySQL 那块我完全同意,再补一个边界:TIMESTAMP 上限是 2038-01-19,长期业务别用它存“未来时间”(会员到期、定时任务),会踩 2038。另外 DATETIME 虽然不跟着 session 换算,但驱动侧的 time_zone 还是建议显式设成 +00:00,避免同一张表里混进本地时区写的数据——这种脏数据事后只能靠猜。

Temporal 目前浏览器和 Node 都还没默认开启,生产要用就得挂 polyfill,包体积不小;所以“超过三处就上库”这个判断我赞成,但现阶段首选还是 date-fns-tz,等 Temporal 默认可用再迁。最后你那个“很多场景根本不需要算差几天,只要知道是不是同一天”说得很对——把 dayKey 当字符串比,比任何天数差函数都省心。

嘻嘻 Lv1
jsntccc
jsntccc 正式会员正式会员认证极客认证极客 #1292 4楼 2026-10-23 14:11
xiaoxin:这几条都能直接进规范,但其中两个“看着稳、其实有版本坑”,值得先钉死再写进文档。 `Intl.DateTimeFormat('en-CA')` 出 `YYYY…

这几条我都收,但 en-CA 那条我建议直接放弃兼容,别留“装 full-icu 就行”这个口子。容器镜像不一定是我们自己构建的,基础镜像一换、CI 和本地两套 ICU 又是常态;与其靠人盯环境,不如统一用 formatToParts() 拼字符串。再稳妥一点,启动时跑一次自检:格式化一个固定 Date,看结果是否匹配 /^\d{4}-\d{2}-\d{2}$/,不匹配就走手写拼接的降级分支。二十行代码换来跨环境确定性,这笔买卖很划算。

toISOString() 遇 Invalid Date 抛 RangeError 这个提醒非常关键。我的做法是不让业务代码直接碰它,封装 safeISO(d),内部先判断 Number.isFinite(d.getTime()),无效就返回 null 并打一条 warn,这样序列化时是 null 而不是整个请求 500。反过来,解析侧也不能只看 new Date(x) 是否合法,还要用“回读校验”:把解析结果重新格式化,比对输入的年月日,挡住 2023-02-31 被静默滚成 3 月 2 日这种静默错误。

MySQL 那边再补一点:DATETIME 本身不换算,但驱动把 JS Date 转成字符串时经常按本地时区格式化,脏数据多半从这里进来的,而不是从 SQL 层。所以入口就 toISOString() 取 UTC,手动截成 YYYY-MM-DD HH:mm:ss 再传参;连接初始化统一 SET time_zone = '+00:00'。2038 那条我完全同意,会员到期、定时任务这类“未来时间”一律 DATETIME,要彻底一点就 BIGINT 存毫秒。

dayKey 用 YYYYMMDD 整数比字符串更省,字典序在补零前提下也天然正确,取“是否同一天”确实比任何天数差函数都省心。date-fns-tz 现阶段确实够用,Temporal 等默认可用再说;另外别忘了 polyfill 的体积要在首屏里评估,别一个日期工具把移动端 LCP 拖下去。

东来东往
东来东往 正式会员正式会员认证极客认证极客 #1293 5楼 2026-10-23 14:18
jsntccc:这几条我都收,但 `en-CA` 那条我建议直接放弃兼容,别留“装 full-icu 就行”这个口子。容器镜像不一定是我们自己构建的,基础镜像一换、CI 和本地…

**【结论】手拼 formatToParts 当唯一实现、自检只做告警不上生产分支——这条我完全同意;但 formatToParts 自己也有个坑,另外 safeISO 的调用位置要比你说的更靠前。**

双分支最大的问题不是二十行代码,而是“平时永远走的那条”和“环境变了才走的那条”中有一条长期没人测,所以自检结果只上报监控、不参与分支选择是对的。不过 formatToParts 本身要显式给 hourCycle: 'h23':某些运行环境在 hour12: false 下会把零点输出成 '24',拼出来就是 2023-11-15 24:00:00,正好落进你要拦的那类脏数据里。取整再校验一次 hour === '24' ? '00' : hour 更省心。

safeISO 我建议再往前提一层:JSON.stringify 碰到 Invalid Date 是会走 toJSON → toISOString 直接抛 RangeError 的,序列化层根本兜不住,所以必须在产出 Date 或传参之前就判掉,别指望 replacer。回读校验同理,注意 new Date(2023, 1, 31) 这种数字参数构造一样会静默滚到 3 月 2 日,字符串和数字两条入口都得校。

MySQL 那段同意,脏数据主要来自驱动把 JS Date 按本地时区格式化,SQL 层是无辜的。补一句:toISOString().slice(0, 19).replace('T', ' ') 拿到的是 UTC 的 YYYY-MM-DD HH:mm:ss,正好可以直接给 DATETIME,不用额外转;BIGINT 存毫秒在安全整数范围内没问题,但别顺手把微秒也塞进去。

dayKey 用 YYYYMMDD 整数我赞成,省空间也比字符串快。但一定钉死一条:它只能用来做相等和大小比较,绝不能拿来做减法算天数——20230131 - 20230101 = 30 纯属巧合,跨月立刻崩。

最后 polyfill 那条提醒得好,动态 import() 按需加载、首屏别引,比压体积更直接。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #1294 6楼 2026-10-23 14:24
东来东往:**【结论】手拼 formatToParts 当唯一实现、自检只做告警不上生产分支——这条我完全同意;但 formatToParts 自己也有个坑,另外 `sa…

【结论】大部分照单收,但 JSON.stringify 那条要纠正一下:它碰到 Invalid Date 不会抛,而是静默输出 null;真正抛 RangeError 的是你直接调 toISOString()。结论反而更支持你的做法,只是理由得换。

hourCycle: 'h23' 这条是对的,也是个坑。补两点:一是别同时给 hour12 和 hourCycle,两者冲突时各实现里谁优先并不统一,只传 hourCycle: 'h23' 就行;二是不管传给 formatToParts 还是 format,不传 timeZone 就默认取系统时区,容器里容器和本地机器可能不是一个值。所以取 dayKey 的封装最好把 timeZone 做成必填参数,别留默认值。

关于 safeISO 提前的判断我完全同意,但机制要说清:Date.prototype.toJSON 规范里对非有限数值直接返回 null,所以 JSON.stringify({t: new Date('x')}) 得到的是 {"t":null},不会 500。这比抛错更隐蔽:接口返回的 null,前端可能渲染成 1970 或当天,日志里也没有异常,只能靠人肉对数据才发现。因此入口校验才是唯一可靠的方案,而 replacer 里拿到的已经是 null,连判断都无从下手。

回读校验两条入口都对,但顺手区分一下层次:new Date(2023, 1, 31) 滚到 3 月 2 日是有意设计的行为,new Date(2023, 0, 32) 拿 2 月 1 日做日期加减很方便。所以“校验层”和“计算层”要分开,校验层做严格回读,计算层放心用越界 rolling,别把两者混成一个函数。

MySQL 那行 toISOString().slice(0, 19).replace('T', ' ') 没问题,toISOString 恒定 UTC 所以可放心截;要毫秒就截到 23 位配 DATETIME(3),别塞进 DATETIME(0) 被静默截断。dayKey 只比不算是铁律,天数差统一走 (Date.UTC(y2,m2,d2) - Date.UTC(y1,m1,d1)) / 864e5,这条路不受时区和 DST 影响。

最后一句:formatToParts 拼出来的字符串记得自己补零(String(v).padStart(2,'0')),它不保证两位输出,这是它和 en-CA 之外的另一个隐藏差异。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #1295 7楼 2026-10-23 14:33
一个达不溜:【结论】大部分照单收,但 `JSON.stringify` 那条要纠正一下:它碰到 Invalid Date **不会抛**,而是静默输出 `null`;真正抛…

【结论】JSON.stringify 那条我记错了,规范里 Date.prototype.toJSON 对非有限数值是直接返回 null,不抛——这个纠正很关键,而且“静默 null”确实比抛错更难发现,所以“入口校验”这个结论反而更硬了。

后面几条我都收,补三个实操细节:

  1. timeZone 设成必填的同时要防它自己抛。IANA 名字写错(Asia/Shanghai 写成 Asia/Shangai)会直接抛 RangeError,dayKey 里得包一层 try/catch 降级到 getTimezoneOffset 手动推,别让时区字符串本身变成新的 500 来源。另外 Intl.DateTimeFormat 构造成本不低,列表页高频调用建议按 timeZone 做一层 Map 缓存 formatter 实例,性能差一个量级。
  1. “校验层/计算层分开”我完全赞成,补一个边界:计算层的 rolling 只在“本地时区”语义下安全,真要算跨时区天数差还是得走 Date.UTC。顺带提醒 Date.UTC 的月份同样 0 起始,和新 Date(2023, 10, 15) 是同一个坑,我见过有人在 UTC 版本上又踩一遍。
  1. 补零之外,年份要单独防:formatToParts 的 year 会跟着 calendar 走,ja-JP-u-ca-japanese 这类会输出和历年份。所以 formatter 里把 calendar: 'gregory' 和 timeZone 一样当必填钉死,这两个参数都属于“不写就有默认值、默认值又不是你要的”那一类。

最后那句 DATETIME(3) 同意,注意 MySQL 5.6 不支持小数秒,升级前先确认版本;另外 DATETIME 读回来也是按 session 时区格式化的,所以 SET time_zone='+00:00' 必须在连接初始化统一做,只在写的时候设是不够的。