Skip to content

时区与日期处理 - 国际化日期时间完整指南

1. 时区基础概念

1.1 时区相关术语

typescript
const timezoneTerms = {
  UTC: 'Coordinated Universal Time - 协调世界时',
  GMT: 'Greenwich Mean Time - 格林威治标准时间',
  Offset: '时区偏移量,如+08:00',
  DST: 'Daylight Saving Time - 夏令时',
  IANA: 'Internet Assigned Numbers Authority时区数据库',
  Locale: '语言环境,影响日期时间格式'
};

1.2 时区处理挑战

typescript
const challenges = {
  storage: '服务器存储UTC,客户端显示本地时间',
  dst: '夏令时转换',
  formatting: '不同地区的日期时间格式',
  calculation: '跨时区的时间计算',
  display: '用户友好的相对时间显示'
};

2. JavaScript日期基础

2.1 Date对象

typescript
// 创建日期
const now = new Date(); // 当前时间
const specific = new Date('2024-01-15T10:00:00Z'); // ISO 8601格式
const timestamp = new Date(1705315200000); // 时间戳

// 获取时间戳
const ts1 = Date.now(); // 当前时间戳
const ts2 = now.getTime(); // Date对象转时间戳

// 时区偏移量(分钟)
const offset = now.getTimezoneOffset(); // -480 for UTC+8
console.log(`时区偏移: ${offset / 60}小时`);

// 本地时间 vs UTC时间
console.log(now.toLocaleString()); // 本地时间
console.log(now.toUTCString()); // UTC时间
console.log(now.toISOString()); // ISO 8601 UTC

2.2 Intl.DateTimeFormat

typescript
// 基础格式化
const formatter = new Intl.DateTimeFormat('zh-CN');
formatter.format(new Date()); // "2024/1/15"

// 详细选项
const detailedFormatter = new Intl.DateTimeFormat('zh-CN', {
  year: 'numeric',
  month: 'long',
  day: 'numeric',
  weekday: 'long',
  hour: '2-digit',
  minute: '2-digit',
  second: '2-digit',
  timeZoneName: 'short'
});
detailedFormatter.format(new Date()); 
// "2024年1月15日星期一 10:00:00 GMT+8"

// 不同语言环境
const enFormatter = new Intl.DateTimeFormat('en-US', {
  dateStyle: 'full',
  timeStyle: 'long'
});
enFormatter.format(new Date()); 
// "Monday, January 15, 2024 at 10:00:00 AM GMT+8"

3. 时区库 - day.js

3.1 安装与配置

bash
npm install dayjs
npm install dayjs-plugin-utc
npm install dayjs-plugin-timezone
npm install dayjs-plugin-relativeTime
npm install dayjs-plugin-customParseFormat
typescript
// dayjs配置
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import relativeTime from 'dayjs/plugin/relativeTime';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import 'dayjs/locale/zh-cn';
import 'dayjs/locale/ja';

dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(relativeTime);
dayjs.extend(customParseFormat);

// 设置默认语言
dayjs.locale('zh-cn');

3.2 基础使用

typescript
// 创建日期
const now = dayjs();
const specific = dayjs('2024-01-15');
const custom = dayjs('15/01/2024', 'DD/MM/YYYY');

// 格式化
now.format(); // ISO 8601
now.format('YYYY-MM-DD'); // 2024-01-15
now.format('YYYY年MM月DD日 HH:mm:ss'); // 2024年01月15日 10:00:00

// 相对时间
dayjs().from(dayjs('2024-01-01')); // "14天后"
dayjs().to(dayjs('2024-01-01')); // "14天前"
dayjs().fromNow(); // "几秒前"

// 操作
now.add(1, 'day'); // 加1天
now.subtract(1, 'month'); // 减1月
now.startOf('month'); // 月初
now.endOf('week'); // 周末

3.3 时区处理

typescript
// 时区转换
const utcTime = dayjs.utc('2024-01-15 10:00:00');
const tokyoTime = utcTime.tz('Asia/Tokyo');
const newYorkTime = utcTime.tz('America/New_York');

console.log(utcTime.format()); // 2024-01-15T10:00:00Z
console.log(tokyoTime.format()); // 2024-01-15T19:00:00+09:00
console.log(newYorkTime.format()); // 2024-01-15T05:00:00-05:00

// 猜测用户时区
const userTimezone = dayjs.tz.guess();
console.log(userTimezone); // "Asia/Shanghai"

// 在特定时区创建时间
const shanghaiTime = dayjs.tz('2024-01-15 10:00', 'Asia/Shanghai');

// 保持时区转换
const sameTime = shanghaiTime.tz('America/New_York', true);
// true参数保持相同时刻,只改变时区表示

4. React组件实现

4.1 日期时间显示组件

tsx
// DateTime.tsx
import dayjs from 'dayjs';
import { useState, useEffect } from 'react';

interface DateTimeProps {
  date: Date | string | number;
  format?: string;
  timezone?: string;
  relative?: boolean;
}

export function DateTime({ 
  date, 
  format = 'YYYY-MM-DD HH:mm:ss',
  timezone,
  relative = false
}: DateTimeProps) {
  const [displayTime, setDisplayTime] = useState('');
  
  useEffect(() => {
    let dayjsObj = dayjs(date);
    
    // 应用时区
    if (timezone) {
      dayjsObj = dayjsObj.tz(timezone);
    }
    
    // 相对时间或格式化时间
    const formatted = relative 
      ? dayjsObj.fromNow() 
      : dayjsObj.format(format);
    
    setDisplayTime(formatted);
    
    // 相对时间每分钟更新
    if (relative) {
      const timer = setInterval(() => {
        setDisplayTime(dayjsObj.fromNow());
      }, 60000);
      
      return () => clearInterval(timer);
    }
  }, [date, format, timezone, relative]);
  
  return <time dateTime={dayjs(date).toISOString()}>{displayTime}</time>;
}

// 使用示例
function Example() {
  return (
    <div>
      <DateTime date={new Date()} />
      <DateTime date="2024-01-15T10:00:00Z" format="YYYY年MM月DD日" />
      <DateTime date={new Date()} relative />
      <DateTime date={new Date()} timezone="America/New_York" />
    </div>
  );
}

4.2 时区选择器

tsx
// TimezoneSelector.tsx
import { useState } from 'react';
import dayjs from 'dayjs';

const timezones = [
  { value: 'UTC', label: 'UTC (协调世界时)', offset: '+00:00' },
  { value: 'Asia/Shanghai', label: '中国标准时间', offset: '+08:00' },
  { value: 'Asia/Tokyo', label: '日本标准时间', offset: '+09:00' },
  { value: 'America/New_York', label: '美国东部时间', offset: '-05:00' },
  { value: 'America/Los_Angeles', label: '美国西部时间', offset: '-08:00' },
  { value: 'Europe/London', label: '格林威治时间', offset: '+00:00' },
  { value: 'Europe/Paris', label: '中欧时间', offset: '+01:00' },
  { value: 'Australia/Sydney', label: '澳大利亚东部时间', offset: '+11:00' }
];

export function TimezoneSelector({ 
  value, 
  onChange 
}: {
  value: string;
  onChange: (timezone: string) => void;
}) {
  const [currentTime, setCurrentTime] = useState(dayjs().tz(value));
  
  useEffect(() => {
    const timer = setInterval(() => {
      setCurrentTime(dayjs().tz(value));
    }, 1000);
    
    return () => clearInterval(timer);
  }, [value]);
  
  return (
    <div className="space-y-2">
      <select
        value={value}
        onChange={(e) => onChange(e.target.value)}
        className="w-full border rounded px-3 py-2"
      >
        {timezones.map(tz => (
          <option key={tz.value} value={tz.value}>
            {tz.label} (GMT{tz.offset})
          </option>
        ))}
      </select>
      
      <div className="text-center text-lg font-mono">
        {currentTime.format('HH:mm:ss')}
      </div>
      <div className="text-center text-sm text-gray-600">
        {currentTime.format('YYYY-MM-DD dddd')}
      </div>
    </div>
  );
}

// 使用
function TimezonePage() {
  const [timezone, setTimezone] = useState('Asia/Shanghai');
  
  return (
    <div>
      <h2>当前时区</h2>
      <TimezoneSelector value={timezone} onChange={setTimezone} />
    </div>
  );
}

4.3 日期范围选择器

tsx
// DateRangePicker.tsx
import dayjs, { Dayjs } from 'dayjs';
import { useState } from 'react';

interface DateRange {
  start: Dayjs | null;
  end: Dayjs | null;
}

export function DateRangePicker({
  onChange
}: {
  onChange: (range: DateRange) => void;
}) {
  const [range, setRange] = useState<DateRange>({
    start: null,
    end: null
  });
  
  const presets = [
    { label: '今天', getValue: () => ({ 
      start: dayjs().startOf('day'), 
      end: dayjs().endOf('day') 
    })},
    { label: '昨天', getValue: () => ({ 
      start: dayjs().subtract(1, 'day').startOf('day'), 
      end: dayjs().subtract(1, 'day').endOf('day') 
    })},
    { label: '最近7天', getValue: () => ({ 
      start: dayjs().subtract(7, 'day').startOf('day'), 
      end: dayjs().endOf('day') 
    })},
    { label: '最近30天', getValue: () => ({ 
      start: dayjs().subtract(30, 'day').startOf('day'), 
      end: dayjs().endOf('day') 
    })},
    { label: '本月', getValue: () => ({ 
      start: dayjs().startOf('month'), 
      end: dayjs().endOf('month') 
    })},
    { label: '上月', getValue: () => ({ 
      start: dayjs().subtract(1, 'month').startOf('month'), 
      end: dayjs().subtract(1, 'month').endOf('month') 
    })}
  ];
  
  const handlePresetClick = (preset: typeof presets[0]) => {
    const newRange = preset.getValue();
    setRange(newRange);
    onChange(newRange);
  };
  
  return (
    <div className="space-y-4">
      <div className="flex gap-2 flex-wrap">
        {presets.map(preset => (
          <button
            key={preset.label}
            onClick={() => handlePresetClick(preset)}
            className="px-3 py-1 border rounded hover:bg-gray-100"
          >
            {preset.label}
          </button>
        ))}
      </div>
      
      <div className="flex gap-2">
        <input
          type="date"
          value={range.start?.format('YYYY-MM-DD') || ''}
          onChange={(e) => {
            const newRange = { 
              ...range, 
              start: dayjs(e.target.value) 
            };
            setRange(newRange);
            onChange(newRange);
          }}
          className="border rounded px-3 py-2"
        />
        <span className="self-center">至</span>
        <input
          type="date"
          value={range.end?.format('YYYY-MM-DD') || ''}
          onChange={(e) => {
            const newRange = { 
              ...range, 
              end: dayjs(e.target.value) 
            };
            setRange(newRange);
            onChange(newRange);
          }}
          className="border rounded px-3 py-2"
        />
      </div>
      
      {range.start && range.end && (
        <div className="text-sm text-gray-600">
          共 {range.end.diff(range.start, 'day') + 1} 天
        </div>
      )}
    </div>
  );
}

5. 服务端与客户端时区同步

5.1 存储策略

typescript
// 服务器端:始终使用UTC存储
interface Event {
  id: string;
  title: string;
  startTime: Date; // UTC时间
  endTime: Date;   // UTC时间
  timezone: string; // 事件的时区
}

// 创建事件
function createEvent(data: {
  title: string;
  startTime: string; // 用户输入的本地时间
  timezone: string;
}) {
  // 将本地时间转为UTC
  const startTimeUTC = dayjs.tz(data.startTime, data.timezone).utc().toDate();
  
  return {
    title: data.title,
    startTime: startTimeUTC,
    timezone: data.timezone
  };
}

// 读取事件
function getEvent(event: Event, userTimezone: string) {
  // 将UTC时间转为用户时区
  const localStartTime = dayjs(event.startTime)
    .tz(userTimezone)
    .format('YYYY-MM-DD HH:mm:ss');
  
  return {
    ...event,
    localStartTime
  };
}

5.2 API请求处理

typescript
// 客户端发送时区信息
async function fetchEvents() {
  const userTimezone = dayjs.tz.guess();
  
  const response = await fetch('/api/events', {
    headers: {
      'X-Timezone': userTimezone
    }
  });
  
  return response.json();
}

// 服务器端处理
app.get('/api/events', (req, res) => {
  const userTimezone = req.headers['x-timezone'] || 'UTC';
  const events = getEventsFromDB();
  
  // 转换为用户时区
  const localizedEvents = events.map(event => ({
    ...event,
    localStartTime: dayjs(event.startTime)
      .tz(userTimezone)
      .format('YYYY-MM-DD HH:mm:ss')
  }));
  
  res.json(localizedEvents);
});

6. 常见场景处理

6.1 会议时间显示

tsx
// MeetingTime.tsx
interface Meeting {
  id: string;
  title: string;
  startTime: string; // UTC ISO string
  duration: number; // 分钟
  timezone: string;
}

export function MeetingTime({ meeting }: { meeting: Meeting }) {
  const userTimezone = dayjs.tz.guess();
  const startTime = dayjs(meeting.startTime);
  const userStartTime = startTime.tz(userTimezone);
  const endTime = userStartTime.add(meeting.duration, 'minute');
  
  const isSameDay = userStartTime.isSame(endTime, 'day');
  
  return (
    <div className="space-y-2">
      <div className="font-medium">{meeting.title}</div>
      
      <div className="text-sm text-gray-600">
        {userStartTime.format('YYYY年MM月DD日 HH:mm')} - 
        {isSameDay 
          ? endTime.format('HH:mm')
          : endTime.format('MM月DD日 HH:mm')
        }
      </div>
      
      <div className="text-xs text-gray-500">
        {userTimezone === meeting.timezone ? (
          '本地时间'
        ) : (
          <>
            原时区: {startTime.tz(meeting.timezone).format('HH:mm')} 
            ({meeting.timezone})
          </>
        )}
      </div>
      
      <div className="text-xs text-blue-600">
        {startTime.fromNow()}
      </div>
    </div>
  );
}

6.2 倒计时组件

tsx
// Countdown.tsx
export function Countdown({ targetDate }: { targetDate: Date | string }) {
  const [timeLeft, setTimeLeft] = useState('');
  
  useEffect(() => {
    const calculateTimeLeft = () => {
      const now = dayjs();
      const target = dayjs(targetDate);
      const diff = target.diff(now);
      
      if (diff <= 0) {
        return '已结束';
      }
      
      const days = Math.floor(diff / (1000 * 60 * 60 * 24));
      const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
      const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
      const seconds = Math.floor((diff % (1000 * 60)) / 1000);
      
      return `${days}天 ${hours}时 ${minutes}分 ${seconds}秒`;
    };
    
    setTimeLeft(calculateTimeLeft());
    
    const timer = setInterval(() => {
      setTimeLeft(calculateTimeLeft());
    }, 1000);
    
    return () => clearInterval(timer);
  }, [targetDate]);
  
  return <div className="font-mono text-2xl">{timeLeft}</div>;
}

6.3 工作日计算

typescript
// 计算工作日
function getBusinessDays(startDate: Dayjs, endDate: Dayjs): number {
  let count = 0;
  let current = startDate;
  
  while (current.isBefore(endDate) || current.isSame(endDate, 'day')) {
    const day = current.day();
    // 0=周日, 6=周六
    if (day !== 0 && day !== 6) {
      count++;
    }
    current = current.add(1, 'day');
  }
  
  return count;
}

// 添加工作日
function addBusinessDays(date: Dayjs, days: number): Dayjs {
  let current = date;
  let added = 0;
  
  while (added < days) {
    current = current.add(1, 'day');
    const day = current.day();
    if (day !== 0 && day !== 6) {
      added++;
    }
  }
  
  return current;
}

// 使用
const start = dayjs('2024-01-15'); // 周一
const end = dayjs('2024-01-19'); // 周五
console.log(getBusinessDays(start, end)); // 5

const deadline = addBusinessDays(dayjs(), 5); // 5个工作日后

7. 国际化日期格式

7.1 不同地区格式

typescript
// 日期格式
const formats: Record<string, string> = {
  'en-US': 'MM/DD/YYYY',
  'en-GB': 'DD/MM/YYYY',
  'zh-CN': 'YYYY年MM月DD日',
  'ja-JP': 'YYYY年MM月DD日',
  'de-DE': 'DD.MM.YYYY',
  'fr-FR': 'DD/MM/YYYY'
};

function formatByLocale(date: Dayjs, locale: string): string {
  const format = formats[locale] || 'YYYY-MM-DD';
  return date.format(format);
}

// 使用Intl API
function formatDateIntl(date: Date, locale: string): string {
  return new Intl.DateTimeFormat(locale, {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  }).format(date);
}

// 示例
const date = dayjs('2024-01-15');
console.log(formatByLocale(date, 'en-US')); // 01/15/2024
console.log(formatByLocale(date, 'zh-CN')); // 2024年01月15日

7.2 相对时间本地化

typescript
// 配置dayjs相对时间
import 'dayjs/locale/zh-cn';
import 'dayjs/locale/ja';

dayjs.locale('zh-cn');
dayjs().from(dayjs().subtract(1, 'hour')); // "1小时前"

dayjs.locale('ja');
dayjs().from(dayjs().subtract(1, 'hour')); // "1時間前"

// React组件
export function LocalizedRelativeTime({ 
  date, 
  locale 
}: { 
  date: Date; 
  locale: string;
}) {
  useEffect(() => {
    dayjs.locale(locale);
  }, [locale]);
  
  return <span>{dayjs(date).fromNow()}</span>;
}

8. 最佳实践

typescript
const bestPractices = {
  storage: [
    '服务器始终存储UTC时间',
    '记录事件的原始时区',
    '使用ISO 8601格式传输',
    '数据库使用TIMESTAMP WITH TIME ZONE'
  ],
  
  display: [
    '客户端转换为用户时区',
    '明确显示时区信息',
    '提供相对时间选项',
    '考虑夏令时影响'
  ],
  
  calculation: [
    '时间计算在UTC进行',
    '考虑工作日和节假日',
    '处理跨月份和年份边界',
    '验证日期范围有效性'
  ],
  
  performance: [
    '缓存时区数据',
    '避免频繁时区转换',
    '使用轻量级日期库',
    '服务端预处理时区'
  ]
};

10. 高级时区处理

10.1 多时区日历

tsx
import { Calendar } from '@fullcalendar/core';
import timezonePlugin from '@fullcalendar/timezone';

function MultiTimezoneCalendar() {
  const timezones = ['America/New_York', 'Europe/London', 'Asia/Tokyo'];
  const [events, setEvents] = useState([]);
  
  return (
    <div className="multi-timezone-calendar">
      {timezones.map(tz => (
        <div key={tz}>
          <h3>{tz}</h3>
          <Calendar
            timeZone={tz}
            events={events}
            plugins={[timezonePlugin]}
          />
        </div>
      ))}
    </div>
  );
}

10.2 时区会议调度器

tsx
// 找到所有参与者都可用的时间
function findCommonAvailability(
  participants: Array<{
    timezone: string;
    availability: Array<{ start: Date; end: Date }>;
  }>
) {
  const commonSlots: Array<{ start: Date; end: Date }> = [];
  
  // 转换所有时间到UTC
  const utcAvailability = participants.map(p => ({
    timezone: p.timezone,
    slots: p.availability.map(slot => ({
      start: moment.tz(slot.start, p.timezone).utc(),
      end: moment.tz(slot.end, p.timezone).utc()
    }))
  }));
  
  // 找交集
  const allSlots = utcAvailability.flatMap(p => p.slots);
  
  // 简化的交集算法
  for (let i = 0; i < allSlots.length; i++) {
    for (let j = i + 1; j < allSlots.length; j++) {
      const overlap = getOverlap(allSlots[i], allSlots[j]);
      if (overlap) {
        commonSlots.push(overlap);
      }
    }
  }
  
  return commonSlots;
}

function MeetingScheduler() {
  const [participants, setParticipants] = useState([]);
  const [commonSlots, setCommonSlots] = useState([]);
  
  useEffect(() => {
    const slots = findCommonAvailability(participants);
    setCommonSlots(slots);
  }, [participants]);
  
  return (
    <div>
      <h3>可用时间段</h3>
      {commonSlots.map((slot, i) => (
        <div key={i}>
          {moment(slot.start).format('YYYY-MM-DD HH:mm')} - 
          {moment(slot.end).format('HH:mm')}
        </div>
      ))}
    </div>
  );
}

10.3 时区边界情况处理

tsx
// 处理跨日期的时区转换
function convertAcrossDates(dateTime: Date, fromTz: string, toTz: string) {
  const source = moment.tz(dateTime, fromTz);
  const target = source.clone().tz(toTz);
  
  const analysis = {
    sameDay: source.date() === target.date(),
    dayDiff: target.diff(source, 'days'),
    warning: null as string | null
  };
  
  if (!analysis.sameDay) {
    analysis.warning = `时区转换导致日期变化: ${source.format('YYYY-MM-DD')} → ${target.format('YYYY-MM-DD')}`;
  }
  
  return { target: target.toDate(), analysis };
}

// 使用
function DateTimeDisplay({ dateTime, userTz }: { dateTime: Date; userTz: string }) {
  const converted = convertAcrossDates(dateTime, 'UTC', userTz);
  
  return (
    <div>
      <div>{moment(converted.target).format('YYYY-MM-DD HH:mm')}</div>
      {converted.analysis.warning && (
        <div className="warning">{converted.analysis.warning}</div>
      )}
    </div>
  );
}

11. 性能优化

11.1 时区数据按需加载

tsx
// 动态加载时区数据
import moment from 'moment-timezone';

const timezoneDataCache = new Map();

async function loadTimezoneData(timezone: string) {
  if (timezoneDataCache.has(timezone)) {
    return timezoneDataCache.get(timezone);
  }
  
  // 只加载需要的时区数据
  const data = await import(
    `moment-timezone/data/packed/${timezone.split('/')[0]}.json`
  );
  
  moment.tz.load(data);
  timezoneDataCache.set(timezone, data);
  
  return data;
}

// 使用
function TimezoneClock({ timezone }: { timezone: string }) {
  const [loaded, setLoaded] = useState(false);
  
  useEffect(() => {
    loadTimezoneData(timezone).then(() => setLoaded(true));
  }, [timezone]);
  
  if (!loaded) return <Loading />;
  
  return <div>{moment().tz(timezone).format('HH:mm:ss')}</div>;
}

11.2 缓存格式化结果

tsx
// 缓存日期格式化结果
class DateFormatCache {
  private cache = new Map<string, string>();
  
  format(date: Date, format: string, locale: string): string {
    const key = `${date.getTime()}-${format}-${locale}`;
    
    if (this.cache.has(key)) {
      return this.cache.get(key)!;
    }
    
    const formatted = moment(date).locale(locale).format(format);
    this.cache.set(key, formatted);
    
    // 限制缓存大小
    if (this.cache.size > 1000) {
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }
    
    return formatted;
  }
}

const formatCache = new DateFormatCache();

// 使用
function FormattedDate({ date, format, locale }: Props) {
  return <span>{formatCache.format(date, format, locale)}</span>;
}

11.3 Web Worker时区计算

tsx
// 在Worker中处理复杂时区计算
// timezone.worker.ts
import moment from 'moment-timezone';

self.addEventListener('message', (e) => {
  const { type, data } = e.data;
  
  switch (type) {
    case 'convert':
      const result = moment.tz(data.date, data.fromTz).tz(data.toTz);
      self.postMessage({
        type: 'converted',
        result: result.toISOString()
      });
      break;
      
    case 'batch-convert':
      const results = data.dates.map((d: any) =>
        moment.tz(d, data.fromTz).tz(data.toTz).toISOString()
      );
      self.postMessage({
        type: 'batch-converted',
        results
      });
      break;
  }
});

// 主线程使用
const worker = new Worker(new URL('./timezone.worker.ts', import.meta.url));

function useBatchTimezoneConversion(dates: Date[], fromTz: string, toTz: string) {
  const [converted, setConverted] = useState<Date[]>([]);
  
  useEffect(() => {
    worker.postMessage({
      type: 'batch-convert',
      data: { dates, fromTz, toTz }
    });
    
    const handler = (e: MessageEvent) => {
      if (e.data.type === 'batch-converted') {
        setConverted(e.data.results.map((d: string) => new Date(d)));
      }
    };
    
    worker.addEventListener('message', handler);
    return () => worker.removeEventListener('message', handler);
  }, [dates, fromTz, toTz]);
  
  return converted;
}

12. 测试策略

12.1 时区转换测试

typescript
import { describe, it, expect } from 'vitest';
import moment from 'moment-timezone';

describe('Timezone Conversion', () => {
  it('should correctly convert between timezones', () => {
    const utc = moment.utc('2024-01-01 12:00:00');
    const ny = utc.clone().tz('America/New_York');
    const tokyo = utc.clone().tz('Asia/Tokyo');
    
    expect(ny.format('HH:mm')).toBe('07:00'); // UTC-5
    expect(tokyo.format('HH:mm')).toBe('21:00'); // UTC+9
  });
  
  it('should handle DST transitions', () => {
    // 2024年3月10日是美国夏令时开始
    const beforeDST = moment.tz('2024-03-10 01:00', 'America/New_York');
    const afterDST = moment.tz('2024-03-10 03:00', 'America/New_York');
    
    const diff = afterDST.diff(beforeDST, 'hours');
    expect(diff).toBe(1); // 跳过了2:00-3:00
  });
  
  it('should maintain date accuracy across timezones', () => {
    const date = moment.tz('2024-01-01', 'America/New_York');
    const tokyo = date.clone().tz('Asia/Tokyo');
    
    // 东京比纽约早,可能是第二天
    expect(tokyo.date()).toBeGreaterThanOrEqual(date.date());
  });
});

12.2 相对时间测试

typescript
describe('Relative Time', () => {
  it('should format relative time correctly', () => {
    const now = moment();
    
    expect(moment(now).subtract(1, 'minute').fromNow()).toBe('a minute ago');
    expect(moment(now).subtract(1, 'hour').fromNow()).toBe('an hour ago');
    expect(moment(now).subtract(1, 'day').fromNow()).toBe('a day ago');
  });
  
  it('should support multiple locales', () => {
    const date = moment().subtract(2, 'hours');
    
    expect(date.locale('en').fromNow()).toBe('2 hours ago');
    expect(date.locale('zh-cn').fromNow()).toBe('2 小时前');
    expect(date.locale('fr').fromNow()).toBe('il y a 2 heures');
  });
});

12.3 日期范围测试

typescript
describe('Date Range Handling', () => {
  it('should calculate range correctly', () => {
    const start = moment('2024-01-01');
    const end = moment('2024-01-31');
    
    expect(end.diff(start, 'days')).toBe(30);
  });
  
  it('should handle month boundaries', () => {
    const jan31 = moment('2024-01-31');
    const nextMonth = jan31.clone().add(1, 'month');
    
    // 1月31日+1个月应该是2月29日(2024是闰年)
    expect(nextMonth.format('YYYY-MM-DD')).toBe('2024-02-29');
  });
});

13. 最佳实践清单

13.1 存储策略

typescript
const dateStorageBestPractices = {
  '✅ 数据库': [
    '始终使用UTC时间存储',
    '使用TIMESTAMP类型',
    '包含时区信息的字段使用TIMESTAMPTZ'
  ],
  
  '✅ API响应': [
    '使用ISO 8601格式 (YYYY-MM-DDTHH:mm:ssZ)',
    '明确包含时区偏移量',
    '提供Unix时间戳作为备选'
  ],
  
  '✅ 前端状态': [
    '使用Date对象或moment对象',
    '避免字符串格式',
    '组件间传递时保持一致性'
  ],
  
  '✅ localStorage': [
    '存储ISO格式字符串',
    '包含时区信息',
    '读取时重新解析为Date对象'
  ]
};

13.2 显示策略

typescript
const displayBestPractices = {
  '✅ 格式化': [
    '根据用户locale格式化',
    '提供绝对时间和相对时间两种选项',
    '重要日期显示完整信息(日期+时间+时区)'
  ],
  
  '✅ 时区显示': [
    '明确显示时区(如 "北京时间")',
    '提供时区选择器',
    '跨时区事件显示多个时区'
  ],
  
  '✅ 用户体验': [
    '日历组件默认用户时区',
    '会议时间显示参与者所在时区',
    '提供"本地时间"和"活动时区"切换'
  ]
};

13.3 常见陷阱

typescript
const commonPitfalls = {
  '❌ 错误做法': {
    '使用本地时间存储': 'new Date().toString()',
    '忽略时区信息': 'YYYY-MM-DD HH:mm',
    '假设用户在同一时区': 'new Date(2024, 0, 1)',
    '字符串拼接日期': `${year}-${month}-${day}`
  },
  
  '✅ 正确做法': {
    '使用UTC存储': 'date.toISOString()',
    '包含时区': '2024-01-01T12:00:00+08:00',
    '使用库处理时区': 'moment.tz(date, timezone)',
    '使用Date对象': 'new Date(isoString)'
  }
};

9. 总结

时区与日期处理的关键要点:

  1. UTC存储: 服务器端始终使用UTC时间
  2. 时区转换: 客户端转换为用户时区显示
  3. 格式化: 根据语言环境格式化日期时间
  4. 相对时间: 提供人性化的时间显示
  5. 夏令时: 正确处理DST转换
  6. 国际化: 支持多语言的日期时间格式
  7. 性能优化: 缓存和按需加载时区数据
  8. 测试覆盖: 全面测试时区转换逻辑
  9. 最佳实践: 遵循标准化的存储和显示策略
  10. 错误处理: 优雅处理时区边界情况

通过正确处理时区和日期,可以为全球用户提供准确的时间信息。

扩展阅读