返回知识中心
指纹

日期字符串解析:本地时间与 UTC 的边界

一支探针看日期字符串:只有日期的值读作 UTC 零点,而带时间不带时区的值读作本地时间,越界的日子会被顺延。

BotBrowser Team

文档中心

想直接进入 指纹 文档吗?

这篇文章属于知识中心的指南库。若你要步骤化配置、参考说明和持续更新,请直接进入对应 docs 分区。

BotBrowser 支持在已声明 profile 下回放授权的同源夹具,并保存一份有界的记录,说明页面产出的确切时间戳,但它不能让只有日期的字符串和带日期与时间的字符串指向同一个时刻,不能阻止越界的日子被顺延,也不能让存下的字符串按它的时刻那样排序。本评测在 America/Toronto 的宿主上用一支探针跑十个固定用例,记录 Date.parse 把哪些形态读成 UTC、哪些读成本地时间,四个写法相同的日历日如何变成三个不同时刻,越界的日子和夏令时空档会怎样,为什么按存下字符串排序与按时刻排序不一致,以及每种形态落到哪个本地小时。

摘要

  • 只有日期的字符串读作 UTC 零点,所以探针打印 "2026-10-31" -> 2026-10-31T00:00:00.000Z 和 utc-equal -> Date.UTC=1793404800000 parse=1793404800000。
  • 带日期与时间但不带时区的字符串读作本地时间,所以在 UTC-4 的宿主上 "2026-10-31T00:00:00" 变成 2026-10-31T04:00:00.000Z。
  • 四种写法相同的形态产出 distinct -> 3 instants from 4 shapes,因为只有日期那个是 UTC,而带时区偏移的形态会挪动时刻。
  • 越界的日子会顺延:探针请求 2 月 30 日得到 3 月 2 日,"2026-02-30" 解析成 3 月 2 日而不是报错。
  • 夏令时空档里的本地墙上时间会往前挪,所以 02:30 变成 03:30,而回拨的重复段里保留两个 01:30 中的一个。
  • 按存下字符串排序和按时刻排序不一致:探针打印 sort-by-ms -> 2026-10-31 , 2026-10-30T23:00:00 , ... 对 sort-by-string -> 2026-10-30T23:00:00 , 2026-10-31 , ...。
  • 探针还暴露出第二处对比:两条字符串可以指向同一个时刻却排出不同顺序,这和不构成一致全序的比较器造成的错配是同一形态,数组排序与比较器 讲的就是它。带 Z 的形态在任何时区读得一样,带时间的形态随宿主偏移移动,只有日期的形态稳定但落到另一个本地小时。

目录

第一栏显示只有日期的字符串读作 UTC 零点、带时间不带时区的字符串读作本地时间,在 UTC-4 宿主上给出 04:00Z。第二栏显示四种形态产出三个时刻。第三栏显示 2 月 30 日顺延到 3 月 2 日、夏令时的 02:30 挪到 03:30。第四栏显示按字符串排序与按时刻排序在同一列表上不一致。

哪些形态读作 UTC、哪些读作本地

同一组日历字段可以指向好几个不同时刻,而一个形态选哪个取决于形态而不是日期。探针解析四个字符串,打印产出的 ISO 时刻和数值时间:

"2026-10-31" -> 2026-10-31T00:00:00.000Z ms=1793404800000
"2026-10-31T00:00:00" -> 2026-10-31T04:00:00.000Z ms=1793419200000
"2026-10-31T00:00:00Z" -> 2026-10-31T00:00:00.000Z ms=1793404800000
"2026-10-31 00:00:00" -> 2026-10-31T04:00:00.000Z ms=1793419200000
distinct -> 3 instants from 4 shapes
shape:2026-10-31 -> 2026-10-31T00:00:00.000Z
shape:2026-10-31T00:00:00 -> 2026-10-31T04:00:00.000Z
shape:2026-10-31T00:00:00-04:00 -> 2026-10-31T04:00:00.000Z
shape:2026-10-31T00:00:00+09:00 -> 2026-10-30T15:00:00.000Z
utc-equal -> Date.UTC=1793404800000 parse=1793404800000

第一行定下裸日期的规则。"2026-10-31" 解析成 2026-10-31T00:00:00.000Z,最后一行印证了这一点:utc-equal 显示解析值正好等于 Date.UTC(2026, 9, 31)。所以只有日期的字符串根本没有本地的读法;按定义它就是 UTC 零点,一个在 UTC-4 宿主上用本地格式化器把它显示回去的页面会显示 10 月 30 日 20:00,对期待 31 日的读者来说就是前一天。

第二行定下带日期与时间但不带时区的规则。"2026-10-31T00:00:00" 在这个宿主上解析成 04:00Z,因为缺失的时区用宿主的时区补上,而那天宿主是 UTC-4。所以它和只有日期那个不是同一个时刻,把两种形态混在同一份数据里的页面,为看起来是同一个的日子得到了两个不同的起点。

第三行是没有歧义的形态。"2026-10-31T00:00:00Z" 解析成 00:00Z,在任何宿主上都一样,因为末尾的 Z 明确指名 UTC。显式的数字偏移也一样:shape: 行里的 -04:00 形态打印 04:00Z,+09:00 形态打印前一天的 15:00Z,两者都是同一个时刻,与代码在哪跑无关。

第四行是危险的那个。"2026-10-31 00:00:00" 用空格而不是 T,探针显示它解析成 04:00Z,和 T 形态得到的本地读法一样。所以空格形态在这个宿主上表现相同,但空格形态是由实现定义的、不是必须的,运行时完全可以读得不一样或者直接拒绝。应用该存的只有两种形态:只有日期那个,它是 UTC;以及带 Z 或显式偏移的日期时间形态。一个存下来的时刻如何变成一个可见日期是另一步,日期时间格式化与时区可移植性 记录了格式化器拿到时区之后做了什么。

为什么一个日子的四种写法是三个时刻

distinct -> 3 instants from 4 shapes 这一行是整个问题的概括。四条读者会称做「10 月 31 日」的字符串变成三个不同时刻,原因是四条里有两条用两条不同路径指向了同一个时刻。

只有日期的形态是 00:00Z。不带时区的形态在这个宿主上是 04:00Z,晚四个小时。-04:00 形态也是 04:00Z,所以它和不带时区的形态一致,纯属这个宿主时区的巧合,不是规则。+09:00 形态是前一天的 15:00Z,比那对 04:00Z 早五个小时。所以四条字符串从「前一天」跨到「请求的那天」,一个把它们当成可以互换的页面,会把其中两条排错,把另外两条放到前一天。

不带时区的形态和 -04:00 形态那个巧合,就是陷阱。在 UTC-4 宿主上它们打印同一个时刻,所以只在这个宿主上跑的测试会通过,而同一段代码在 UTC+9 宿主上会给两种形态打印两个不同时刻。一个想证明代码与时区无关的测试,必须在至少两个时区里跑,或者直接对不依赖宿主的 Z 和带偏移形态做断言。

所以,一条存下来的字符串指向哪个时刻,是字符串形态的属性;对没标时区的形态来说,还是读者时区的属性。这就是为什么记录该把时刻存成数字或 Z 字符串,而不是本地墙上时间;这就是为什么显示一个日期必须从时刻、带具名时区去格式化,而不是把字符串暗示的墙上时间读回来。

同一次探针里的 offset-now -> 240 从另一面说同一件事。日期 2026-10-31 在这个宿主上处于夏令时,所以偏移是 -4。一个用固定 -05:00 标准偏移去格式化它的宿主,在那天会差一个小时、在冬天正确,这正是写进夹具的固定偏移通常要等到日期跨过切换时才暴露的 bug。

探针剩下的两种形态,是仪表盘筛选器常常产出的。"2026" 解析成 2026-01-01T00:00:00.000Z,"2026-10" 解析成 2026-10-01T00:00:00.000Z,都按 UTC,因为部分 ISO 日期既没有时间也没有偏移。一个把月份当 2026-10 传的筛选器,因此指向那个月的第一个时刻、按 UTC 算,对格林尼治以西的读者来说就是上一个月:在这个宿主上本地读数是 2026-09-30T20:00,一个显示错月份的月份标签。想要多伦多用户看到「十月」的仪表盘,必须传一个明确的起点比如 2026-10-01T00:00:00-04:00 而不是裸月份,或者把月份当字符串传、不把它变成一个时刻就直接格式化。

"10/31/2026" 和 "Oct 31 2026" 两种形态反过来,它们说明为什么按语言格式化的字符串永远不该当存储格式。两者在这里都解析成 2026-10-31T04:00:00.000Z,是本地读法,所以它们像不带时区的形态那样跟着宿主时区走。但它们的接受是由实现定义的,所以一个遵循别的日期顺序的运行时,完全可以把 10/31/2026 读成第三十一个月的第十天,或者直接拒绝。一个用自己语言打印的格式存日期、回头又在另一种语言的服务器上解析的页面,就有一种在这里被接受、在那里被读成别的的形态,而故障表现为月份错了,不是报错。

往返那一行回答的是本地墙上时间能不能扛过存取。探针解析 2026-10-31T09:30:00,打印时刻 2026-10-31T13:30:00.000Z,读回 localHours=9,并确认存下的数字返回同一个时刻。这是墙上时间在一个宿主上的正确往返,而这正是换到另一个宿主就坏掉的形态:同一条字符串在 UTC+1 的时区里读到的是 08:30Z,所以存下的时刻挪了五个小时,显示的墙上时间仍是 09:30,只因为显示用的是解析它的那个时区。一个存墙上时间、又在同一个时区里格式化回去的页面看不到任何错误,错误只在两端跑在不同时区时才出现。

什么会顺延、什么会失败

并不是每个看起来无效的日期都会失败,探针把它们分成会顺延的和会抛错的两类:

rollover -> 2026-03-02T05:00:00.000Z (Feb 30 requested)
rollover-leap -> 2026-03-01T05:00:00.000Z (Feb 29 in a common year)
parse-invalid -> "2026-02-30" -> 2026-03-02T00:00:00.000Z

Date 构造器把越界的日子顺延到下个月。new Date(2026, 1, 30) 请求 2 月 30 日,平年里不存在,结果是 3 月 2 日,因为构造器把多出来的天数加到 2 月最后一个有效日上。平年的 2 月 29 日同理:new Date(2026, 1, 29) 变成 3 月 1 日。所以一个用三个解析出的数字搭日期的页面会得到一个静默的顺延,结果的 getMonth() 不是传进去的那个月。

解析字符串形态也一样。new Date('2026-02-30') 返回 2026-03-02T00:00:00.000Z 而不是 Invalid,因为这条字符串按只有日期的值读、日子顺延。这和很多校验器的假设正相反,也意味着写成 if (isNaN(Date.parse(value))) 的检查会放过一条任何日历都没有的字符串。必须拒绝 2 月 30 日的校验,得把解析出的字段和输入字段比一遍,而不是相信解析器的接受。

夏令时的几个用例显示同样的静默调整。空档那一行在美国春季拨快的当天请求 02:30,一个不存在的时刻,探针打印 2026-03-08T07:30:00.000Z,本地读数 03:30。运行时把不存在的墙上时间顺着空档往前挪,所以构造出的日期比请求的晚一小时,结果的 getHours() 是 3 不是 2。回拨那一行在秋季拨慢的当天请求 01:30,一个出现两次的时刻,探针打印 2026-11-01T05:30:00.000Z,本地读数 01:30,这是两次出现中的一次,不一定是读者想的那样。

所以,一个按本地墙上时间排任务的页面,把两个边都继承下来:春季可能因为请求的时刻根本不存在而晚一小时开始,秋季可能落在两次出现中的第一次,对只跑一次的任务没事,对要数出现次数的任务就有事。这类任务的记录必须存下选定的那个时刻,而不是它请求的墙上时间,因为墙上时间并不唯一标识那一次运行。

为什么字符串排序和时刻排序不一致

同一个列表的两种排序,是解析规则最清楚的后果,因为它们在同样的四条字符串上给出不同顺序:

sort-by-ms -> 2026-10-31 , 2026-10-30T23:00:00 , 2026-10-31T00:00:00 , 2026-11-01
sort-by-string -> 2026-10-30T23:00:00 , 2026-10-31 , 2026-10-31T00:00:00 , 2026-11-01

按时刻排序把 2026-10-31 放在最前,因为它是那天的 UTC 零点,把 2026-10-30T23:00:00 放第二,因为那是前一天的本地墙上时间、被读成次日的 03:00Z。按字符串排序把 2026-10-30T23:00:00 放最前,因为字符 2026-10-30 排在 2026-10-31 之前。所以两种排序在头两条上就不一致,而不一致完全来自形态,不是来自数值。

不构成一致全序的比较器会给出错误却无声的顺序,这正是数组排序与比较器记录的失效形态。数组的默认排序就是字符串排序,因为它比较元素的码元。所以用默认 sort() 排的日期列表其实是按文本排的,只有当每个元素形态一致时才和时间顺序一致。把只有日期的值和带日期时间的值混在一起,文本顺序和时间顺序就分叉,而且没有任何提示。

一条序列化字符串所指名的时区,也是时区与语言指南 在给日期贴标签之前必须先解出的时区。修法是按解析出的数字排、并且只存一种形态。探针的 sort-by-ms 行就是这个修法,它按四条字符串所指的时刻排序。把每个时间戳都存成 Z 字符串或数字、并在这个值上排序的页面,会从字符串排序和数字排序拿到同一个顺序,因为形态是统一的;混形态的页面拿到两个不同顺序,还分不清读者会看到哪一个。

local-hour 那几行从显示这一面展示了同一个问题。local-hour:2026-10-31 -> 20 把只有日期的值的 UTC 零点放到宿主时区里读,报告前一天的 20:00;而 local-hour:2026-10-31T00:00:00 -> 0 读本地形态、报告 00:00;local-hour:2026-10-31T00:00:00Z -> 20 读 Z 形态、报告和只有日期那个一样的 20:00。所以三种形态里有两个显示成前一天晚上,只有本地那个显示成请求的零点。一份只存显示小时、不存形态的记录,解释不了为什么两个都写「10 月 31 日」的页面显示不同的小时。

compare 和 diff-hours 把环扣上。探针打印 compare -> '2026-10-31' == new Date('2026-10-31') is true,这只说明解析后的字符串等于它自己;随后打印 diff-hours -> dateOnlyUtcHour=0 dateTimeUtcHour=4,说明两种形态在这个宿主上在 UTC 里差四个小时。所以拿只有日期的值和带日期时间的值比,比的不是同一个日子;比的是一个 00:00Z 的时刻和一个在本地是 04:00Z 的时刻,尽管两条字符串都以同一个日历日开头,它们也不相等。

记录一次日期解析读数

一条日期解析读数支撑的是一个窄结论:这个运行时、这个时区、这些形态,产出了这些时刻。四个字段让它保持可核对:

  1. 形态,因为只有日期的值是 UTC、带日期与时间但不带偏移的是本地时间,而形态在时刻里看不见。
  2. 宿主时区,因为不带偏移的形态依赖它,而 Z 和带偏移的形态不依赖,没有时区的读数无法复现。
  3. UTC 时刻,因为读者看到的墙上时间是第二步、可能随时区不同。
  4. 排序键和存下的形态,因为混合列表的字符串排序和时刻排序不一致,记录必须说清是哪个产出了那个顺序。

公开来源

实践结论

只存一种形态,并且把它存成时刻:Z 字符串或数字,绝不存本地墙上时间,因为只有日期的值和不带偏移的日期时间值指向不同时刻,而且后者还依赖读者的时区。别指望 Date.parse 拒绝不可能的日子,因为 2 月 30 日会顺延成 3 月 2 日而不是返回 Invalid;要把解析出的字段和输入比对。列表里可能存多种形态时,按解析出的数字排序,而不是用默认的字符串比较;还要记住夏令时空档里的墙上时间会往前挪,而回拨重复段里的墙上时间是两次出现中的一次。同一问题显示那一半,读日期时间格式化与时区可移植性;指明时区的语言数据,读时区与语言。

#Date#时区#解析#ECMAScript#夏令时

本文属于指南: 什么是浏览器指纹?原理、风险、信号与隐私边界

让 BotBrowser 从研究走向生产

先用这些指南理解模型,再进入跨平台验证、隔离上下文和面向规模化的浏览器部署。