别让用户算时间:vibe 项目的相对时间显示实战
“3 分钟前”还是“2026-11-06T02:53:33.198Z”?一张可直接抄的阈值表、服务端只存 UTC 时间戳的铁律、前端分档刷新、夏令时与多语言边界,以及上线前必须跑的五组测试。

“2026-11-06T02:53:33.198Z”是什么意思?先讲一个虚构的教训
先讲一个虚构的教学例子。林小雨一个人用 AI 搭了个菜谱分享应用“云朵菜谱”,用户可以发菜谱、评论、点赞。上线第一周,她收到一条用户反馈:“你们的评论时间是不是坏了?每条评论下面都写着一串看不懂的数字。”林小雨点开一看,评论区的时间戳全是这种样子:2026-11-06T02:53:33.198Z。她自己也愣了一下——这是数据库里原样吐出来的 ISO 时间字符串,前端直接把它显示出来了。用户想知道的其实很简单:这条评论是刚发的,还是昨天发的。可页面给出的却是一串要人先换算时区、再心算“现在是几点”的密码。
这就是时间显示最常见的死法:不是时间错了,而是时间以机器的语言显示给了人。人看时间只有两种需求:这件事离现在有多远,以及这件事到底发生在什么时候。第一种需求要相对时间——“3 分钟前”“昨天”;第二种需求要绝对时间——“11 月 5 日 14:30”。两种需求都合理,但要用在不同的地方,混着用、错着用,用户就得自己在心里做换算,而换算一旦出错,信任就跟着出错:用户会以为刚下的订单是三天前的,以为昨天的评论是一个月前的。
本文讲的就是怎么给 vibe 项目做好相对时间显示:从什么时候用相对时间、什么时候必须用绝对时间,到一张可以直接抄的阈值表,到服务端只存 UTC 时间戳这条铁律,到前端怎么渲染、多久刷新一次,再到多语言、夏令时、无障碍这些边界情况。以下示例均为虚构的教学示例,应用名、人名与数字均为为讲解而构造的简化版本。

一、相对时间解决的是“离现在多远”,不是“发生在几点”
先把分工说清楚,不然阈值表抄得再好也会用错地方。相对时间回答的是“离现在多远”:3 分钟前、2 小时前、昨天、上周。它最大的好处是读得快:用户扫一眼就知道这条评论新鲜不新鲜、这笔订单是不是刚下的,不用先看钟、再换算时区、再心算。评论列表、动态、通知、聊天记录,这些“按时间先后排、越新越重要”的地方,都是相对时间的主场。
绝对时间回答的是“发生在几点”:11 月 5 日 14:30、2026 年 11 月 5 日。它最大的好处是精确、可核对:出了问题要查、要对账、要当证据的时候,靠的是绝对时间。订单详情、发票、支付记录、日志、法律相关的记录,这些地方必须用绝对时间,而且要带上时区。想象一下用户拿着“3 天前下的单”去找客服对账,客服根本没法定位是哪一笔——相对时间在那一刻是没用的。
所以规则的第一条是:列表用相对时间,详情用绝对时间,或者两者同时给。评论列表里显示“3 分钟前”,鼠标悬停上去显示完整绝对时间“2026 年 11 月 6 日 10:53”,这就是最常见的组合:扫读的时候快,需要精确的时候也有。手机上没有悬停,就点一下时间切换显示,或者在详情页里直接写绝对时间。别只给一种,也别两种都堆在列表里——列表里同时写“3 分钟前(2026-11-06 10:53)”,每一行都变长,扫读的速度优势就没了。
还有一类地方,两者都别用相对时间:倒计时和截止时间。“距离活动结束还有 2 小时”不是相对时间,那是倒计时,有专门的做法;“报名截止 11 月 10 日 23:59”必须写绝对时间加时区,写“3 天后截止”会让用户算错日子。相对时间只管“已经发生的事离现在多远”,未来的事、截止的事,交给倒计时和绝对时间。
二、一张可以直接抄的阈值表:从“刚刚”到“去年”
相对时间的核心是一张阈值表:时间差落在哪个区间,就显示哪句话。阈值没有全球统一标准,但下面这张表是被大量产品验证过的默认值,拿去就能用,改的时候只改数字,不改结构。
| 时间差 | 中文显示 | 英文显示 |
|---|---|---|
| 0–10 秒 | 刚刚 | just now |
| 10–60 秒 | 几秒前 | a few seconds ago |
| 1–59 分钟 | X 分钟前 | X minutes ago |
| 1–23 小时 | X 小时前 | X hours ago |
| 昨天(自然日) | 昨天 | yesterday |
| 前天(自然日) | 前天 | the day before yesterday |
| 3–6 天前 | X 天前 | X days ago |
| 1–4 周前 | X 周前 | X weeks ago |
| 1–11 个月前 | X 个月前 | X months ago |
| 1 年以上 | X 年前,或直接显示绝对日期 | X years ago, or the full date |
阈值表有三个细节最容易错。第一个是“昨天”要用自然日,不是用 24 小时。用户在今天凌晨 1 点发评论,昨天晚上 11 点的评论,时间差只有 2 小时,但按自然日算已经是“昨天”。反过来,前天下午 3 点的评论,时间差 34 小时,按 24 小时算会显示“1 天前”,按自然日算应该是“前天”。做法是先把两个时间都换算到用户本地时区的“日期”,再按日期相减:差 0 天就是今天、差 1 天是昨天、差 2 天是前天。日期差的算法要写进工具函数里,别在每个页面各写一套。
第二个是边界抖动。时间差 59 秒显示“几秒前”,61 秒显示“1 分钟前”,边界附近每刷新一次就跳一次,用户会觉得时间在抽风。做法是给边界加一点容差:59 秒到 90 秒之间都显示“1 分钟前”,或者把刷新间隔拉长,让显示在一个区间里稳定一段时间再变。还有一个更简单的办法:60 秒以内统一显示“刚刚”,把最抖的那个区间直接抹掉。
第三个是超过一年就别再相对了。“3 年前”对用户几乎没有信息量,2023 年的评论和 2024 年的评论,在列表里看着都是“几年前”,区分不开。超过 1 年的,直接显示绝对日期“2024 年 3 月 12 日”,用户反而一眼就能定位。很多产品在这里犯懒,一路相对到底,结果就是三年前的旧帖和三个月前的帖子在列表里看着差不多新。
未来时间也要有说法。服务器时间和用户设备时间总有几秒到几分钟的偏差,评论刚发出去,算出来的时间差可能是负的,也就是“未来 5 秒前”。这时候别显示“-5 秒前”或者“5 秒后”,统一显示“刚刚”。真正的未来时间,比如“活动 3 分钟后开始”,那是倒计时的活,别用相对时间的表去套。

三、唯一真相:服务端只存 UTC 时间戳,前端按用户时区渲染
时间显示只有一条铁律:服务端只存 UTC 时间戳,前端按用户所在时区渲染成当地时间。这条铁律拆开是三句话,每一句都不能省。
第一句,存时间戳,不要存字符串。数据库里存 1730875200 这样的 Unix 时间戳,或者存带时区的 timestamptz 类型,不要存“2026-11-06 10:53”这种没时区的字符串,也不要存“3 分钟前”这种渲染结果。存时间戳,时间才是唯一的事实;存字符串,时区信息丢了就再也找不回来;存“3 分钟前”,三天后再打开页面,它还是“3 分钟前”,彻底错了。
第二句,全链路用 UTC,只在最后一刻换算成本地时间。后端接口返回时间戳,前端拿到时间戳之后,用用户设备的时区去渲染。渲染这一步要用系统自带的国际化接口,不要自己写时区换算:JavaScript 里用 Intl.DateTimeFormat 和 Intl.RelativeTimeFormat,浏览器会按用户的语言和时区自动处理。自己写换算,夏令时那天一定会错,而且错得悄无声息。
第三句,永远不要相信用户的设备时间是对的。用户的手机时间可能快了 5 分钟、慢了 10 分钟,甚至停在去年。相对时间算出来是“未来 5 分钟前”时,别去纠正用户的时钟,直接显示“刚刚”。需要精确时间的场景,比如支付和订单,用服务端下发的时间做基准,不要用 new Date() 直接取设备时间去算。设备时间只用来渲染,不用来做判断。
// 错:把渲染结果存进数据库,三天后还是“3 分钟前”
await db.comments.insert({ text, createdLabel: '3 分钟前' });
// 对:只存 UTC 时间戳,显示时再算
await db.comments.insert({ text, createdAt: new Date().toISOString() });
// 渲染:用系统接口,别自己拼字符串
const rtf = new Intl.RelativeTimeFormat('zh-CN', { numeric: 'auto' });
rtf.format(-3, 'minute'); // "3 分钟前"
rtf.format(-1, 'day'); // "昨天"
const dtf = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' });
dtf.format(new Date('2026-11-06T02:53:33.198Z')); // 按用户时区显示
还有一个细节:接口返回时间的时候,最好同时返回时间戳和 ISO 字符串两种形式。时间戳给代码算,ISO 字符串给调试看。只返回一种,调试的时候就得多转一道;返回“本地时间字符串”,时区信息又丢了。这条约定写进接口文档里,全站统一。
四、前端渲染:多久刷新一次,只刷新看得见的
相对时间是会“变老”的:刚发的时候是“刚刚”,一分钟后是“1 分钟前”,一小时后是“1 小时前”。所以前端不能只渲染一次就不管了,要定时刷新。刷新有三个讲究:间隔、范围和一致性。
先说间隔。刷新间隔要按时间差分档:60 秒以内的内容,每 10 秒刷新一次;1 小时以内的,每 30 秒刷新一次;1 天以内的,每 5 分钟刷新一次;超过 1 天的,每小时刷新一次就够了。分档的道理很简单:越新的内容变得越快,越老的内容变得越慢。全站统一每 30 秒刷新一次也能跑,但列表里几百个时间戳一起刷新,低端手机会明显卡顿。分档刷新之后,同一屏里需要高频刷新的其实只有最上面那几条。
再说范围。只刷新用户看得见的。页面上有一百条评论,用户只看到前十条,后面九十条的刷新是白做的。用 IntersectionObserver 判断哪些时间元素在视口里,只刷新那些;滚出视口的停掉,滚回来再刷新。聊天窗口也一样:只有打开的那个会话需要高频刷新,后台的会话列表每小时刷新一次就够了。
最后是一致性,重点是服务端渲染。很多项目用 SSR,服务端先渲染一遍 HTML 再发给浏览器。如果服务端用服务器时间算出“3 分钟前”,浏览器接手后用设备时间重算一遍,两个时间差几秒,页面会闪一下:先是“3 分钟前”,水合之后变成“4 分钟前”。做法是服务端渲染时只输出时间戳和一个占位,真正的相对时间由浏览器接手后统一算;或者服务端和浏览器用同一份阈值函数、同一份时间戳,保证两次算出来的结果一样。选哪种都行,但不能两边各算各的。
还有一个容易被忽略的点:页面切到后台之后,定时器会被浏览器节流甚至停掉。用户切回来时,时间显示可能还停在半小时前那一版。做法是监听 visibilitychange 事件,页面重新可见时立刻刷新一次全部时间戳。这一行代码加上,用户切回来看到的时间永远是对的。
五、多语言与本地化:同一张表,不同语言的说法不一样
相对时间一做多语言,麻烦就来了,因为不同语言表达时间的方式不一样。英文里 "1 minute ago" 和 "2 minutes ago" 有单复数之分,中文里“1 分钟前”“2 分钟前”没有这个区别;英文里 "yesterday" 是一个词,中文里“昨天”也是一个词,但“前天”在英文里没有对应的一个词,只能说 "the day before yesterday";阿拉伯语里时间表达还有阴阳性的变化。这些差别,靠手写 if-else 是写不完的。
正确的做法是全站只用 Intl.RelativeTimeFormat 这一个接口,让浏览器去处理单复数、词形和语序。传语言参数 'zh-CN' 就出中文,传 'en-US' 就出英文,传 'ja-JP' 就出日文,不用自己维护十几张翻译表。numeric: 'auto' 这个选项要打开:它会让 -1 天显示成 "yesterday" 而不是 "1 day ago",让 -1 小时显示成 "an hour ago",读起来更像人话。关掉它,英文界面会到处是 "1 day ago",虽然没错,但读着生硬。
日期格式也要跟着语言走。中文用户习惯“11 月 6 日”,英文用户习惯 "Nov 6",德文用户习惯 "6. Nov."。绝对时间的格式,同样用 Intl.DateTimeFormat,让浏览器按语言自动排。12 小时制和 24 小时制也是:中文和英文默认 12 小时制带上午下午,德文和法文默认 24 小时制。别在代码里写死 "HH:mm" 或者 "h:mm a",写死一种,就得罪另一种语言的用户。
还有一个细节:周的起点不一样。中文和英文的“上周”通常从星期日或星期一开始算,不同地区不一样;伊斯兰历地区一周的起点又是另一天。相对时间里的“X 周前”,在跨语言的时候最好换成“X 天前”,或者直接显示绝对日期,避免“上周”在不同文化里指的不是同一周。能用天数说清楚的,就别用“周”去赌文化差异。
六、边界情况:夏令时、跨年与无障碍,一个都不能漏
时间显示的坑,几乎全在边界情况里。先说夏令时。每年春秋两季调表的那天,有一天是 23 小时,有一天是 25 小时。用“毫秒数除以 86400000”算天数差的那天,会把 23 小时的那天算成 0 天、把 25 小时的那天算成 1 天多,结果就是“昨天”显示成“今天”或者反过来。做法是永远按“本地日期”相减,不按毫秒数相除:先把两个时间戳换算成用户时区的年月日,再用日期相减。日期相减不怕夏令时,因为日期没有 23 小时和 25 小时之分。
再说跨年。12 月 31 日的评论,1 月 1 日再看,时间差只有 1 天,但年份变了。相对时间显示“昨天”是对的,但绝对时间要带年份:“2025 年 12 月 31 日”,不能只写“12 月 31 日”,否则用户分不清是哪一年的 12 月 31 日。规则是:今年之内的时间,绝对显示可以省略年份;去年的时间,必须带年份。这条规则写进格式化函数里,别靠每个页面自己判断。
然后是时钟偏差。前面说过,用户设备时间可能不准,算出来是未来时间时统一显示“刚刚”。但还有一种情况:服务端时间本身就不准。两台服务器时间差了几分钟,A 服务器写的评论,在 B 服务器读出来就是“未来 3 分钟前”。治本的办法是服务器之间做时间同步,这是运维的基本功;治标的办法是相对时间函数里加一条:时间差在 -5 分钟到 0 之间,一律显示“刚刚”。超过 -5 分钟的未来时间,说明真的有问题,要记一条日志报警,而不是继续用“刚刚”盖住。
最后是无障碍,这条最容易漏。相对时间对读屏软件不友好:读屏软件读到“3 分钟前”没问题,但用户想知道确切时间时没有办法。做法是每个相对时间都用 HTML 的 time 元素包起来,datetime 属性里放完整的 ISO 时间戳,元素内容显示相对时间:<time datetime="2026-11-06T02:53:33.198Z">3 分钟前</time>。读屏软件能读到准确时间,鼠标悬停也能显示绝对时间。色弱用户也别忽略:不要只用颜色区分“新”和“旧”,比如新评论标橙色、旧评论标灰色,色弱用户分不出来;新旧要用文字本身区分,颜色只是辅助。
七、上线前必须跑的测试清单
上线之前,把下面这些场景一个个跑一遍,别只测“能显示 3 分钟前”就上线。
第一组是阈值边界:时间差 0 秒、10 秒、59 秒、61 秒、59 分钟、61 分钟、23 小时、25 小时,各显示什么。重点看 59 秒到 61 秒、59 分钟到 61 分钟这两个边界,显示会不会来回跳;再看昨天 23:00 的内容在今天 01:00 显示什么,应该是“昨天”,不是“2 小时前”。
第二组是时区:把设备时区分别切到北京、伦敦、纽约、悉尼,同一条评论显示的绝对时间应该各不相同,但相对时间应该完全一样。再把设备时区切到一半、把系统时间往前调 10 分钟,看未来时间是不是显示“刚刚”而不是负数。
第三组是夏令时:找一个夏令时切换的日期,比如 2026 年 3 月 29 日的欧洲,切换前后一天的内容,日期差算出来应该还是 1 天,不是 0 天或者 2 天。这组测试不用每年都跑,阈值函数写好之后跑一次、记下来就行。
第四组是语言:界面切到英文,看 "1 minute ago" 和 "2 minutes ago" 的单复数对不对;切到中文,看“昨天”“前天”是不是自然日;切到日文,看有没有出现半中半英的混排。混排是最常见的本地化 bug:阈值表里漏了一项,英文界面上突然冒出一句中文。
第五组是刷新与性能:打开一个一百条评论的列表,看低端手机卡不卡;切到后台 10 分钟再回来,时间显示是不是立刻刷新了;服务端渲染的页面,水合之后时间显示闪不闪。
跑完这五组,再上线。上线第一周,盯两个数:用户反馈里有没有“时间不对”的投诉,以及绝对时间悬停的点击率。投诉多,说明阈值或者时区有问题;悬停点击率高,说明用户经常需要精确时间,那就在更多地方直接显示绝对时间。相对时间做得好不好,用户是不会夸你的——他们只会在时间错了的时候来找你。目标就是让他们永远不需要来找你。
八、常见的反模式:这些坑,别人都踩过
反模式一:把数据库原样吐给用户。评论区直接显示 2026-11-06T02:53:33.198Z,或者显示一串 Unix 时间戳 1730875200。机器的语言不要出现在用户界面上,任何时间戳在显示之前都必须经过格式化函数。
反模式二:相对时间一路相对到底。三年的旧帖还显示“3 年前”,用户分不清是哪一年的。超过一年就切绝对日期,这是阈值表最后一行的存在意义。
反模式三:每个页面各写一套阈值。列表页一套、详情页一套、通知页一套,改阈值的时候改了两处漏了一处,同一个时间在三个页面显示三个说法。阈值函数全站只能有一份,放在共享工具模块里,所有页面都调它。
反模式四:用设备时间做判断。订单超时、优惠券过期、活动截止,这些判断必须用服务端时间,用设备时间判断等于把规则交给用户自己:用户把手机时间往回拨一小时,优惠券就“复活”了。设备时间只负责渲染显示,判断一律以服务端时间为准。
相对时间这件事,说到底就一句话:别让用户算时间。用户扫一眼就知道是刚发的还是昨天的,需要精确的时候悬停一下就能看到,这就够了。阈值表、UTC 时间戳、定时刷新、多语言、无障碍,链路上的每一环都做到位了,时间显示才真的算“做好了”,而不是“能显示”。
评论 (0)
相关文章

聊天气泡只是个按钮,真正的在线客服是一条会话生命周期:营业时间与排队规则、WebSocket 送达与断线重连、客服工作台、没人在线时的机器人兜底,以及上线前必须跑的五组测试。

一分钱对不上,账就没法结:浮点数为什么算不准钱、只存整数分的铁律、只在最后关口舍入一次、折扣税费分摊与多币种的坑,以及上线前必须跑的七个测试用例。

多轮对话拼的不是模型记性,而是状态、澄清、打断、回指和修复五件事:状态机先画好、澄清只问会办错的事、改主意一句就够、听错了分级修,聊完还能留下一份摘要。