如何在 TanStack Start v1 中处理时区

在用户本地时区显示时间

问题

当应用程序显示时间时,实际上表示的是同一时刻,但根据查看者所在的位置,显示结果会有所不同。如果服务器以自身时区或 UTC 渲染“8:00 PM”,不同时区的用户看到的本地时间就会不正确,导致对事件实际发生时间产生困惑。用户期望时间能自动反映其本地时区,但如果没有正确处理,用户看到的就是服务器渲染时所用的时区时间。这种不匹配会导致用户错过约定、误解日程安排,并对应用的可靠性失去信任。

解决方案

通过向日期格式化方法传递 timeZone 选项,可以控制日期的解析和显示方式。formatDate 函数支持 Intl.DateTimeFormatOptions,其中包含时区配置。应将时间戳以通用格式(如 ISO 8601 字符串或 Unix 时间戳)存储,然后在客户端进行格式化,此时浏览器会自动获取用户的时区。当未指定 timeZone 选项时,Intl.DateTimeFormat API 会使用用户的默认时区,确保每位用户都能看到符合其本地时区的时间,无需手动检测时区。

步骤

1. 创建支持时区的日期格式化组件

使用 useIntl hook 获取 intl 对象及其 formatDate 方法。该组件接收一个 ISO 时间戳,并根据用户本地时区进行格式化显示。

import { useIntl } from "react-intl";

interface LocalTimeProps {
  timestamp: string;
  showDate?: boolean;
  showTime?: boolean;
}

export function LocalTime({
  timestamp,
  showDate = true,
  showTime = true,
}: LocalTimeProps) {
  const intl = useIntl();
  const date = new Date(timestamp);

  const formatted = intl.formatDate(date, {
    ...(showDate && {
      year: "numeric",
      month: "short",
      day: "numeric",
    }),
    ...(showTime && {
      hour: "numeric",
      minute: "2-digit",
      timeZoneName: "short",
    }),
  });

  return <time dateTime={timestamp}>{formatted}</time>;
}

FormattedDate 组件和 formatDate 方法都使用了 Intl.DateTimeFormat API 及 DateTimeFormatOptions。当未显式指定 timeZone 时,浏览器会自动应用用户的时区,将 UTC 时间戳转换为本地时间。

2. 使用组件显示事件时间

在需要显示时间戳的地方导入并使用 LocalTime 组件。该组件会根据每位用户的浏览器设置自动进行时区转换。

import { createFileRoute } from "@tanstack/react-router";
import { LocalTime } from "~/components/LocalTime";

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

function EventDetail() {
  const event = Route.useLoaderData();

  return (
    <div>
      <h1>{event.title}</h1>
      <p>
        Starts: <LocalTime timestamp={event.startTime} />
      </p>
      <p>
        Ends: <LocalTime timestamp={event.endTime} />
      </p>
    </div>
  );
}

每位用户都会看到已转换为其本地时区的事件时间。来自服务器的 ISO 时间戳会根据用户的语言环境和时区偏好进行解析和格式化。

3. 显示带有时区信息的时间格式

当用户需要了解时间所对应的时区时,可以在格式化选项中包含时区名称。

import { useIntl } from "react-intl";

interface ExplicitTimezoneProps {
  timestamp: string;
  timezone?: string;
}

export function ExplicitTimezone({
  timestamp,
  timezone,
}: ExplicitTimezoneProps) {
  const intl = useIntl();
  const date = new Date(timestamp);

  const formatted = intl.formatDate(date, {
    year: "numeric",
    month: "short",
    day: "numeric",
    hour: "numeric",
    minute: "2-digit",
    timeZone: timezone,
    timeZoneName: "long",
  });

  return <time dateTime={timestamp}>{formatted}</time>;
}

指定 timeZone 选项会强制格式化器以该特定时区显示时间。如果未定义时区,则浏览器会使用用户的本地时区。这对于无论用户身处何地都需要以特定时区显示时间的场景非常有用。

4. 创建相对时间显示的辅助函数

对于最近发生的事件,可以显示如“2 小时前”这样的相对时间,同时在工具提示中保留绝对时间。

import { useIntl } from "react-intl";

interface RelativeTimeProps {
  timestamp: string;
}

export function RelativeTime({ timestamp }: RelativeTimeProps) {
  const intl = useIntl();
  const date = new Date(timestamp);
  const now = Date.now();
  const diffInSeconds = Math.floor((now - date.getTime()) / 1000);

  const absoluteTime = intl.formatDate(date, {
    year: "numeric",
    month: "short",
    day: "numeric",
    hour: "numeric",
    minute: "2-digit",
  });

  let value: number;
  let unit: Intl.RelativeTimeFormatUnit;

  if (diffInSeconds < 60) {
    value = -diffInSeconds;
    unit = "second";
  } else if (diffInSeconds < 3600) {
    value = -Math.floor(diffInSeconds / 60);
    unit = "minute";
  } else if (diffInSeconds < 86400) {
    value = -Math.floor(diffInSeconds / 3600);
    unit = "hour";
  } else {
    value = -Math.floor(diffInSeconds / 86400);
    unit = "day";
  }

  const relativeTime = intl.formatRelativeTime(value, unit);

  return (
    <time dateTime={timestamp} title={absoluteTime}>
      {relativeTime}
    </time>
  );
}

相对时间会动态更新为更易读的时长显示,而 title 属性则为悬停用户保留精确的本地时间。两种格式都会自动遵循用户的时区和语言环境。