Appearance
时区与日期处理 - 国际化日期时间完整指南
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 UTC2.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-customParseFormattypescript
// 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. 总结
时区与日期处理的关键要点:
- UTC存储: 服务器端始终使用UTC时间
- 时区转换: 客户端转换为用户时区显示
- 格式化: 根据语言环境格式化日期时间
- 相对时间: 提供人性化的时间显示
- 夏令时: 正确处理DST转换
- 国际化: 支持多语言的日期时间格式
- 性能优化: 缓存和按需加载时区数据
- 测试覆盖: 全面测试时区转换逻辑
- 最佳实践: 遵循标准化的存储和显示策略
- 错误处理: 优雅处理时区边界情况
通过正确处理时区和日期,可以为全球用户提供准确的时间信息。