浏览器时间显示超前问题排查报告
一、问题概述
前端 UI 在从 React 17 升级到 React 18(同一次改动中构建工具也从 CRA/Babel 切换为 Vite/esbuild)之后,页面上显示的时间整体出现超前现象,偏移量为一个固定的小时数。
典型表现:在 UTC+7 时区的机器上,本地时间应为 10:00,但浏览器却显示为 17:00(超前 7 小时)。
关键结论:问题与 React 版本本身无关。React 只负责把值渲染到页面,不参与日期格式化。真正的根因在于日期解析逻辑依赖了”随运行环境而变”的行为,而构建工具的切换恰好暴露了它。
二、根因分析
后端返回的时间字符串是”裸”格式,即不带时区标记,例如:
2021-02-12T16:32:12
按照约定,这种时间的”墙上时钟时间”就是 UTC。但 JavaScript 对这种不带时区标记的字符串,解析行为是不确定的:
- 在旧构建(CRA/Babel)下,它被当作 UTC 解析 —— 结果正确。
- 在新构建(Vite/esbuild)下,它被当作 本地时间 解析 —— 于是又叠加了一次本地时区偏移,导致显示时间超前。
诊断验证(在 UTC+7 环境下,对同一个裸字符串 2021-02-12T16:32:12):
new Date(裸字符串) -> 2021-02-12T09:32:12Z (被当作本地时间,偏了 7 小时)
zonedTimeToUtc(裸字符串, 'UTC') -> 2021-02-12T16:32:12Z (明确当作 UTC,正确)
两者正好相差一个本地时区偏移(7 小时)。因此,凡是用 new Date(裸字符串) 或裸 parseISO(裸字符串) 来解析后端时间的地方,都会在新构建下出现超前。
三、修复方案
核心思路:不再依赖运行环境的默认解析行为,而是显式、确定地处理时间字符串。
1. 统一的解析函数 parseDate(核心)
- 带时区标记的字符串(结尾有
Z或±hh:mm):按其绝对时刻解析(parseISO)。 - 不带时区标记的裸字符串:显式按 UTC 解析(
zonedTimeToUtc(_, 'UTC'))。 - 空值 / 无效值:返回
undefined。
这样无论在哪种构建、哪个时区下,结果都一致。
2. 把所有”绕过 parseDate”的解析点收归统一
排查发现多处仍用裸 parseISO(...) 或 new Date(...) 解析后端时间,逐一改为走 parseDate:
| 位置 | 修复内容 |
|---|---|
| 流程步骤聚合(startedAt/finishedAt) | 改用 parseDate,去掉未使用的 parseISO 引入 |
| 交付时长(Lead Time)计算 | 改用 parseDate;并增加空值守卫,避免有效时间被”无效日期”覆盖 |
| 表格时间列、子视图时间格式化 | 按输入类型分派:字符串走 parseDate,数字(毫秒)/Date 走 new Date |
| 时间戳单元格 | 无效时间显示 N/A,避免格式化函数在”无效日期”上抛异常导致渲染崩溃 |
| 日期范围搜索过滤 | from/to/记录时间统一在 UTC 下比较,并修正区间”含当天整天”的边界问题 |
3. 两个容易被忽略的边界 Bug(排查中额外发现并修复)
- 交付时长计算被污染:当某条时间为空时,取最小值的函数会收到
undefined,从而返回”无效日期”,把原本有效的时间覆盖掉。已增加”有效才更新”的守卫。 - 日期范围上界少了一天(off-by-one):筛选”截止到某天”时,原逻辑只包含到那天的
00:00,会漏掉当天凌晨之后的记录。已改为比较”次日 0 点(不含)”,从而把截止当天一整天完整包含进来。
4. 类型安全与健壮性
- 时间格式化函数现在能同时正确处理:裸字符串、带时区字符串、数字毫秒、Date 对象、以及空/无效值。
- 修正了一处无效的判断(原先对 Date 对象做数值判空,永远不成立),确保无效日期被正确识别。
四、测试与验证
- 新增单元测试,覆盖:
- 裸字符串按 UTC 解析、带
Z字符串、带数字偏移字符串、空/null输入; - 日期范围过滤的上下界包含性、超界排除、无筛选条件、不可解析时间等边界。
- 裸字符串按 UTC 解析、带
- 关键验证点:所有测试特意在 UTC+7 时区下运行,以证明修复与本地时区无关。
- 结果:全部单元测试通过;项目构建成功;未引入新的 lint 报错。
改动规模:9 个文件,约 +125 / -31 行;最终合并为单个提交,并保留最初的变更标识(Change-Id)不变。
五、经验与启示
- 时间一定要带时区语义。后端下发裸时间字符串是本次问题的温床,最稳妥的做法是统一下发带时区标记(如结尾
Z)的 ISO 时间。 - 不要依赖
new Date(字符串)的隐式解析。它对”裸字符串”的行为随运行环境变化,应使用明确指定时区的解析方式。 - 升级/换构建工具时要重点回归”隐式行为”。本次问题不是 React 或日期库的 Bug,而是构建工具差异暴露了原有脆弱代码。
- 集中式解析 + 单元测试锁定边界。把解析逻辑收敛到一个函数,并用测试固定住”裸字符串=UTC””区间含当天”等约定,能有效防止回归。
六、通俗版复述(给非技术同学看)
想象你收到一张写着时间的纸条,上面写的是 16:32,但没写是哪个时区的时间。
- 后端的本意是:这张纸条上的
16:32指的是”伦敦时间(UTC)”。 - 浏览器拿到纸条后要换算成”你当地的时间”再显示。
问题出在:浏览器在”读纸条”这一步有歧义。
- 以前(老的打包工具):浏览器默认把纸条当成”伦敦时间”来读,读对了,换算后显示也对。
- 升级之后(新的打包工具):浏览器改成默认把纸条当成”你当地的时间”来读。可你当地在 UTC+7,它把本来是伦敦时间的
16:32误当成本地16:32,再去”换算”,于是又往前推了 7 小时,最后显示成了超前的时间。
这跟 React 升级没关系。React 只是”把时间画到屏幕上”的那支笔,它不负责换算时间。真正变的是”读纸条的习惯”——因为换了打包工具。
我们的修复,等于给所有”读纸条”的地方都明确写上一句规矩:凡是没标时区的纸条,一律当作”伦敦时间(UTC)”来读。这样不管用哪个打包工具、你在哪个时区,读出来的都一致、都正确。
顺手我们还修了两个相关的小毛病:
- 算耗时算错了:原来如果有一张纸条是空白的,计算”两步之间花了多久”时会被这张空白纸条带偏,把本来算对的结果弄坏。现在遇到空白纸条会直接跳过,不让它捣乱。
- 按日期搜索漏数据:原来搜索”截止到 9 月 22 号”时,只找到了 9 月 22 号凌晨那一刻之前的记录,当天白天的记录全漏了。现在改成”把 9 月 22 号一整天都算进去”,不再漏。
最后,我们补了一批自动化测试,并且特意把电脑时区调到 UTC+7(最容易出问题的时区)来跑,确认时间显示、耗时计算、日期搜索都正确,才算修完。