如何在 TanStack Start v1 中处理时区

以用户的本地时区显示时间

问题

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

解决方案

将 timeZone 选项传递给日期格式化方法,以控制日期的解释和显示方式。formatDate 函数接受包含时区配置的 Intl.DateTimeFormatOptions。以通用格式(如 ISO 8601 字符串或 Unix 时间戳)存储时间戳,然后在客户端进行格式化,此时浏览器的时区会自动可用。当未指定 timeZone 选项时,Intl.DateTimeFormat API 使用用户的默认时区,确保时间能够正确显示为每个用户的位置,而无需手动检测时区。

步骤

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

使用 useIntl 钩子访问 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 方法使用了带有 DateTimeFormatOptions 的 Intl.DateTimeFormat API。当未显式提供 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>
        开始时间:<LocalTime timestamp={event.startTime} />
      </p>
      <p>
        结束时间:<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 选项会强制格式化器以特定时区显示时间。当 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 属性则保留了精确的本地时间,供用户悬停查看。两种格式都会自动遵循用户的时区和语言环境。