如何在 TanStack Start v1 中格式化相对时间

将时间戳格式化为“2 天前”这样的短语

问题

将时间戳显示为类似于“2 天前”或“3 小时后”这样的相对时间短语,可以让用户更直观地理解时间信息。然而,这些短语遵循的语法规则因语言而异。英语中,“ago”放在过去时间量之后,“in”放在未来时间量之前,但其他语言可能使用不同的词序、对时间单位进行词形变化,或者采用完全不同的语法结构。通过字符串拼接手动构建这些短语会导致除硬编码语言外的所有语言输出错误。

底层格式化 API 需要一个数值和一个时间单位,但时间戳通常以 Date 对象或毫秒值的形式出现。将时间戳转换为适当的单位和数值需要计算与当前时刻的差异,并选择最自然的单位来表达这种差异。

解决方案

计算时间戳与当前时刻之间的时间差,选择最合适的单位,然后使用特定于语言环境的相对时间格式化结果。这将原始时间戳转换为适应用户语言的语法正确的短语。

使用 react-intl 的相对时间格式化功能,并结合一个辅助函数来确定最佳单位。该辅助函数根据每个单位的阈值比较时间差,并返回数值和单位名称。格式化器随后应用活动语言环境的正确语法规则。

步骤

1. 创建单位选择辅助函数

构建一个函数,用于计算时间差并选择最自然的单位来表达它。

type RelativeTimeUnit =
  | "second"
  | "minute"
  | "hour"
  | "day"
  | "week"
  | "month"
  | "year";

interface RelativeTimeValue {
  value: number;
  unit: RelativeTimeUnit;
}

export function selectRelativeTimeUnit(
  timestamp: Date | number,
  baseTime: Date | number = Date.now(),
): RelativeTimeValue {
  const date = typeof timestamp === "number" ? timestamp : timestamp.getTime();
  const base = typeof baseTime === "number" ? baseTime : baseTime.getTime();
  const diffMs = date - base;
  const absDiff = Math.abs(diffMs);

  const minute = 60 * 1000;
  const hour = 60 * minute;
  const day = 24 * hour;
  const week = 7 * day;
  const month = 30 * day;
  const year = 365 * day;

  if (absDiff < minute) {
    return { value: Math.round(diffMs / 1000), unit: "second" };
  }
  if (absDiff < hour) {
    return { value: Math.round(diffMs / minute), unit: "minute" };
  }
  if (absDiff < day) {
    return { value: Math.round(diffMs / hour), unit: "hour" };
  }
  if (absDiff < week) {
    return { value: Math.round(diffMs / day), unit: "day" };
  }
  if (absDiff < month) {
    return { value: Math.round(diffMs / week), unit: "week" };
  }
  if (absDiff < year) {
    return { value: Math.round(diffMs / month), unit: "month" };
  }
  return { value: Math.round(diffMs / year), unit: "year" };
}

此函数通过将时间差与固定阈值进行比较,将时间戳转换为相对值和单位。它对过去的时间返回负值,对未来的时间返回正值,格式化器会正确解释这些值。

2. 创建一个相对时间组件

构建一个组件,将单位选择辅助工具与 react-intl 的格式化功能结合起来。

import { FormattedRelativeTime } from "react-intl";
import { selectRelativeTimeUnit } from "./selectRelativeTimeUnit";

interface RelativeTimeProps {
  date: Date | number;
  numeric?: "always" | "auto";
  style?: "long" | "short" | "narrow";
}

export function RelativeTime({
  date,
  numeric = "auto",
  style = "long",
}: RelativeTimeProps) {
  const { value, unit } = selectRelativeTimeUnit(date);

  return (
    <FormattedRelativeTime
      value={value}
      unit={unit}
      numeric={numeric}
      style={style}
    />
  );
}

该组件接受一个时间戳和格式化选项,计算相对值和单位,然后委托给 react-intl 的组件进行本地化渲染。numeric="auto" 选项在适当情况下会生成类似“昨天”而不是“1 天前”的短语。

3. 在路由中使用该组件

在需要显示相对时间戳的地方导入并渲染该组件。

import { createFileRoute } from "@tanstack/react-router";
import { RelativeTime } from "../components/RelativeTime";

export const Route = createFileRoute("/posts/$postId")({
  component: PostPage,
});

function PostPage() {
  const post = {
    title: "Understanding Relative Time",
    publishedAt: new Date("2024-11-15T10:30:00Z"),
    updatedAt: new Date(Date.now() - 2 * 60 * 60 * 1000),
  };

  return (
    <article>
      <h1>{post.title}</h1>
      <p>
        发布于 <RelativeTime date={post.publishedAt} />
      </p>
      <p>
        更新于 <RelativeTime date={post.updatedAt} style="short" />
      </p>
    </article>
  );
}

该组件可在服务器渲染和客户端渲染的上下文中工作。在服务器端,它生成初始的相对时间短语;在客户端,它使用用户的语言环境(通过 IntlProvider 提供)显示相同的短语。

4. 添加一个命令式格式化选项

对于需要直接获取格式化字符串的情况,可以创建一个基于 hook 的辅助工具。

import { useIntl } from "react-intl";
import { selectRelativeTimeUnit } from "./selectRelativeTimeUnit";

export function useRelativeTime() {
  const intl = useIntl();

  return (
    date: Date | number,
    options?: {
      numeric?: "always" | "auto";
      style?: "long" | "short" | "narrow";
    },
  ) => {
    const { value, unit } = selectRelativeTimeUnit(date);
    return intl.formatRelativeTime(value, unit, options);
  };
}

该 hook 返回一个函数,用于命令式地格式化时间戳,非常适合设置文本属性或在 JSX 之外计算值。

5. 在非组件上下文中使用钩子

在需要为属性或逻辑提供格式化字符串的组件中调用钩子。

import { createFileRoute } from "@tanstack/react-router";
import { useRelativeTime } from "../hooks/useRelativeTime";

export const Route = createFileRoute("/events/$eventId")({
  component: EventPage,
});

function EventPage() {
  const formatRelativeTime = useRelativeTime();
  const event = {
    name: "Product Launch",
    startTime: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
  };

  const timeUntil = formatRelativeTime(event.startTime);

  return (
    <div>
      <h1>{event.name}</h1>
      <time dateTime={event.startTime.toISOString()} title={timeUntil}>
        {timeUntil}
      </time>
    </div>
  );
}

该钩子以函数形式提供相同的格式化逻辑,使您可以在属性、计算值或无法渲染组件的任何地方使用相对时间字符串。