wp-calypso 组件库 TimeSince 详解:用 `@automattic/components` 实现自动刷新的友好相对时间显示
wp-calypso 组件库 TimeSince 详解:用 @automattic/components 实现自动刷新的友好相对时间显示
导读
TimeSince 是 wp-calypso(WordPress.com 的 JavaScript 与 API 前端仓库)@automattic/components 组件库中的一个轻量级时间组件,用于把任意日期转换成「相对于当前时刻」的人类友好格式,并随着时间推移自动刷新。本文以 组件 README 为骨架,结合其核心实现、单元测试 与真实业务使用场景,完整讲解它的显示规则、Props 参数、三种 dateFormat 取值、底层实现原理与边界处理,帮助你在自己的 Calypso 页面或独立应用里快速落地「刚刚 / 5 分钟前 / 2 天前」这类体验良好的时间展示。
一、组件定位与设计目标
README 开篇给出了组件的两条核心承诺:
- 以人类友好的格式显示相对于现在的日期(
Shows a date relative to now in a human friendly format); - 随时间推进自动更新显示(
Updates the display as time progresses)。
这两点决定了它与普通 <time> 静态标签的本质区别:TimeSince 是一个"会动"的时间组件,适合用在会话列表、活动日志、订阅详情等需要持续感知时效性的场景。
在仓库中它经由 packages/components/src/index.ts#L36 统一导出(export { default as TimeSince } from './time-since';),因此业务代码只需一行导入即可使用:
import { TimeSince } from '@automattic/components';
二、显示规则:从"刚刚"到"绝对日期"的分段策略
README 给出了三种典型输出形态:
| 时间跨度 | 输出示例 |
|---|---|
| 一分钟以内 | Just now |
| 一周以内 | 20h ago、5d ago |
| 超过一周 | Oct 30, 2017 |
组件源码中的分段逻辑把它精确实现为五个档位:
- 小于 60 秒 → 显示
just now(通过translate( 'just now' )翻译,因此支持 i18n); - 小于 60 分钟 → 显示
Xm ago(如5m ago); - 小于 24 小时 → 显示
Xh ago(如3h ago); - 小于 7 天 → 显示
Xd ago(如2d ago); - 大于等于 7 天 → 回退为绝对日期格式(默认
ll中格式,如Jun 12, 2024)。
源码注释中标注的是基于 Intl.RelativeTimeFormat / Intl.DateTimeFormat 的思路,实际实现则以分钟/小时/天为单位的整数向下取整计算(Math.floor),并借助 i18n-calypso 的 useTranslate 完成字符串本地化:
const secondsAgo = Math.floor( millisAgo / 1000 );
const minutesAgo = Math.floor( secondsAgo / 60 );
const hoursAgo = Math.floor( minutesAgo / 60 );
const daysAgo = Math.floor( hoursAgo / 24 );
三、Props 完整说明
README 仅列出两个参数,而组件类型定义实际暴露了三个,如下表所示:
| Prop | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
date |
string |
是 | — | ISO 8601 日期字符串,或任何能被 new Date() 解析的字符串;运行时传入 Date 对象同样可用 |
dateFormat |
string |
否 | 'll' |
覆盖超过 1 周(含未来日期)时的格式化方式,取值 ll / lll / llll |
className |
string |
否 | — | 应用到 <time> 元素上的 CSS 类名 |
需要留意一个细节:README 将 date 描述为 Date,但当前源码的类型签名是 string(并注明「任何可被 new Date() 解析的字符串」)。组件内部通过 new Date( date ) 统一转换,所以两种形态都能正常工作,示例文件 docs/example.jsx 中就是直接传入 new Date( ... ) 对象。
3.1 dateFormat 的三种取值
这是 README 提到但未展开的参数,其语义在源码注释与 formatDate 实现中非常清晰:
'll'(默认):本地化中等日期格式。底层对应Intl.DateTimeFormat的dateStyle: 'medium',即英文环境输出Jun 12, 2024,其他语言按各自惯例输出(如2024年6月12日)。'lll':固定输出d MMM y(如12 Jun 2024),不受 locale 影响。实现上是把dateStyle/timeStyle置空,改用day: 'numeric', month: 'short', year: 'numeric'三个显式选项。该取值由 PR #103586 引入,在 CHANGELOG 中有记录。'llll':完整日期加时间。对应dateStyle: 'full', timeStyle: 'medium',英文环境输出类似Wednesday, June 12, 2024 at 2:00:00 PM。这一格式不用于主文本,而是被组件用作悬停提示(title属性)。
四、自动刷新机制与实现原理
4.1 每 10 秒刷新一次
useRelativeTime hook 是整个组件的核心:它先用 useState 保存一个 now 快照,再通过 useEffect 注册 setInterval,每 10 秒把 now 更新一次:
useEffect( () => {
const intervalId = setInterval( () => setNow( new Date() ), 10000 );
return () => clearInterval( intervalId );
}, [] );
组件卸载时 clearInterval 会被自动调用,不会造成定时器泄漏。10 秒的刷新周期与活动日志中的轮询节奏(EVERY_TEN_SECONDS)保持一致,既保证显示及时,又避免高频重渲染。
4.2 重算与记忆化
now、date、dateFormat、translate、localeSlug 任一变化时,useMemo 会重新计算展示文本;由于相对时间文本本身是纯函数结果,这种记忆化让组件在 10 秒间隔之外的重新渲染中几乎零开销。
4.3 语义化的 <time> 输出
组件最终渲染的是一个语义化 <time> 元素(index.tsx#L130-L134):
<time className={ className } dateTime={ date } title={ fullDate }>
{ humanDate }
</time>
dateTime保留原始 ISO 日期,供机器读取与无障碍工具解析;title附上llll格式的完整日期时间,用户悬停即可看到精确时刻;- 自定义
className可无缝接入现有样式体系,例如威胁告警组件中的activity-log__threat-alert-time-since。
五、边界情况:无效日期与未来日期
READMEE 没有覆盖边界行为,但源码与测试用例给出了明确约定:
- 无效日期(如
"not-a-date"):isNaN( dateObj.getTime() )为真,useRelativeTime与formatDate双双返回空字符串,页面上不显示任何内容; - 未来日期(
millisAgo < 0):不按相对时间处理,直接输出格式化日期,避免出现负的-1m ago; - 超过 7 天的旧日期:同样进入
formatDate分支,输出ll(或用户指定的dateFormat)格式的绝对日期。
六、完整使用示例
组件示例文件演示了四种典型时间跨度的用法,可直接复制:
import TimeSince from '..';
const TimeSinceExample = () => {
return (
<div>
<div>
{ /* 30 秒前 → just now */ }
<TimeSince date={ new Date( Date.now() - 30 * 1000 ) } />
</div>
<div>
{ /* 5 分钟前 → 5m ago */ }
<TimeSince date={ new Date( Date.now() - 5 * 60 * 1000 ) } />
</div>
<div>
{ /* 3 天前 → 3d ago */ }
<TimeSince date={ new Date( Date.now() - 3 * 24 * 60 * 60 * 1000 ) } />
</div>
<div>
{ /* 5 个月前 → 绝对日期 */ }
<TimeSince date={ new Date( Date.now() - 5 * 30 * 24 * 60 * 60 * 1000 ) } />
</div>
</div>
);
};
TimeSinceExample.displayName = 'TimeSinceExample';
export default TimeSinceExample;
若要覆盖 7 天以上日期的展示格式,可传入 dateFormat:
<TimeSince date={ someISOString } dateFormat="lll" /> // 固定输出 12 Jun 2024
<TimeSince date={ someISOString } className="my-time" /> // 附加自定义样式类
七、在 WordPress.com 真实业务中的应用
TimeSince 并非孤立组件,它在 wp-calypso 的多处业务界面中被实际使用,可作为接入参考:
这些场景的共同点是:时间既有"新鲜度"语义(需要相对时间),又有"精确性"需求(需要完整日期),正好对应 TimeSince 的「相对展示 + title 完整日期」双通道设计。
八、测试验证:行为如何被锁定
测试文件使用 jest.useFakeTimers() 与 jest.setSystemTime 把系统时间冻结在 2024-06-12T12:00:00Z,再通过 @testing-library/react 断言各种时间跨度下的输出:
- 30 秒前 →
just now; - 5 分钟前 →
5m ago; - 3 小时前 →
3h ago; - 2 天前 →
2d ago; - 10 天前 →
Intl.DateTimeFormat的 medium 日期格式; - 无效日期 → 渲染空字符串;
- 未来 1 小时 → 渲染格式化日期;
className与title属性被正确应用;dateFormat="lll"→ 输出d MMM y形式。
测试对 i18n-calypso 的 useTranslate 做了 mock,把 %(minutes)dm ago 等翻译模板展开为 5m ago 形式,这与生产代码中通过 translate 携带 args 与 comment(如 example for a resulting string: 2m ago)注入翻译模板的实现方式一一对应。
九、小结与使用建议
TimeSince 是一个 API 极简但设计完整的时间组件:一分钟内显示「刚刚」,一周内显示「X 分钟/小时/天前」,更早的日期回退为本地化绝对日期,且每 10 秒自动刷新。实际使用时建议:
- 传入 ISO 8601 字符串(如
threat.first_detected这类 API 返回的日期值),保持类型与源码签名一致; - 需要统一的多语言排期展示时用默认
ll,需要与 locale 无关的紧凑日期(如票据编号式排版)时用lll; - 复用
className融入既有样式,并善用内置的title完整时间提示; - 若对刷新频率有特殊要求(如实时性要求更高的聊天界面),可参考 10 秒
setInterval的实现按需调整周期,但注意权衡重渲染成本。