Files
chinese-family-tree-2/OPTIMIZATION_EXAMPLES.md
T
freedakgmail e9822f5c92 0.9.0.0
2025-12-22 07:52:55 +08:00

702 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# app/page.tsx 优化代码示例
## 优化 1: 照片分组逻辑
### ❌ 原始代码(问题)
```typescript
{(() => {
// 按月份分组
const sortedPhotos = [...allPhotos].sort((a, b) =>
new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime()
)
const groupedByMonth: Record<string, typeof allPhotos> = {}
sortedPhotos.forEach(photo => {
const monthKey = format(new Date(photo.uploadedAt), 'yyyy年MM月')
if (!groupedByMonth[monthKey]) {
groupedByMonth[monthKey] = []
}
groupedByMonth[monthKey].push(photo)
})
return Object.entries(groupedByMonth).map(([month, monthPhotos]) => (
// ... JSX
))
})()}
```
### ✅ 优化后代码
```typescript
// 在组件顶部添加
const groupedPhotosByMonth = useMemo(() => {
const sortedPhotos = [...allPhotos].sort((a, b) =>
new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime()
)
const groupedByMonth: Record<string, typeof allPhotos> = {}
sortedPhotos.forEach(photo => {
const monthKey = format(new Date(photo.uploadedAt), 'yyyy年MM月')
if (!groupedByMonth[monthKey]) {
groupedByMonth[monthKey] = []
}
groupedByMonth[monthKey].push(photo)
})
return Object.entries(groupedByMonth)
}, [allPhotos])
// 在 JSX 中使用
{allPhotos.length > 0 ? (
<div className="space-y-8">
{groupedPhotosByMonth.map(([month, monthPhotos]) => (
// ... JSX
))}
</div>
) : (
// ... 空状态
)}
```
---
## 优化 2: 未来三月纪念日计算
### ❌ 原始代码(问题)
```typescript
{(() => {
const now = new Date()
const threeMonthsLater = new Date(now.getFullYear(), now.getMonth() + 3, now.getDate())
const members = Object.values(treeData.members)
const upcomingEvents: Array<{...}> = []
members.forEach(member => {
// 生日
if (member.birthDate) {
const birthDate = new Date(member.birthDate)
let thisYearBirth: Date
let lunarDisplay: string | undefined
if (member.isLunarDate) {
const lunarInfo = solar2lunar(birthDate)
if (lunarInfo) {
const thisYearLunar = lunar2solar(...)
// ... 复杂逻辑
}
}
// ... 更多逻辑
}
// 忌日(重复逻辑)
if (member.deathDate) {
// ... 重复的逻辑
}
})
upcomingEvents.sort((a, b) => a.date.getTime() - b.date.getTime())
return (
// ... JSX
)
})()}
```
### ✅ 优化后代码
首先,提取辅助函数:
```typescript
// 在组件外部定义
const calculateEventDate = (
dateStr: string,
isLunar: boolean,
now: Date
): { date: Date; lunarDisplay?: string } | null => {
const date = new Date(dateStr)
let eventDate: Date
let lunarDisplay: string | undefined
if (isLunar) {
const lunarInfo = solar2lunar(date)
if (lunarInfo) {
const thisYearLunar = lunar2solar(
now.getFullYear(),
lunarInfo.lunarMonth,
lunarInfo.lunarDay,
lunarInfo.isLeap
)
if (thisYearLunar) {
eventDate = thisYearLunar
lunarDisplay = `${lunarInfo.monthName}${lunarInfo.dayName}`
} else {
eventDate = new Date(now.getFullYear(), date.getMonth(), date.getDate())
}
} else {
eventDate = new Date(now.getFullYear(), date.getMonth(), date.getDate())
}
} else {
eventDate = new Date(now.getFullYear(), date.getMonth(), date.getDate())
}
return { date: eventDate, lunarDisplay }
}
const createUpcomingEvent = (
member: any,
type: 'birth' | 'death',
dateStr: string,
isLunar: boolean,
now: Date,
threeMonthsLater: Date
) => {
const result = calculateEventDate(dateStr, isLunar, now)
if (!result) return null
const { date, lunarDisplay } = result
if (date >= now && date <= threeMonthsLater) {
return {
member,
type,
date,
originalDate: dateStr,
isLunar,
lunarDisplay,
month: date.getMonth() + 1,
day: date.getDate()
}
}
return null
}
```
然后在组件中使用 useMemo
```typescript
const upcomingEvents = useMemo(() => {
const now = new Date()
const threeMonthsLater = new Date(now.getFullYear(), now.getMonth() + 3, now.getDate())
const members = Object.values(treeData.members)
const events: Array<{...}> = []
members.forEach(member => {
// 生日
if (member.birthDate) {
const birthEvent = createUpcomingEvent(
member,
'birth',
member.birthDate,
member.isLunarDate || false,
now,
threeMonthsLater
)
if (birthEvent) events.push(birthEvent)
}
// 忌日
if (member.deathDate) {
const deathEvent = createUpcomingEvent(
member,
'death',
member.deathDate,
member.isLunarDate || false,
now,
threeMonthsLater
)
if (deathEvent) events.push(deathEvent)
}
})
return events.sort((a, b) => a.date.getTime() - b.date.getTime())
}, [treeData.members])
// 在 JSX 中使用
const birthEvents = upcomingEvents.filter(e => e.type === 'birth')
const deathEvents = upcomingEvents.filter(e => e.type === 'death')
return upcomingEvents.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 诞辰列 */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
<span className="w-1 h-4 bg-green-500 rounded"></span>
<span className="ml-auto text-xs font-normal text-muted-foreground bg-green-50 px-2 py-1 rounded border border-green-200">
{birthEvents.length}
</span>
</h3>
<div className="space-y-2 max-h-[calc(5*70px)] overflow-y-auto pr-2">
{birthEvents.length > 0 ? (
birthEvents.map((event, index) => (
<EventCard key={`${event.member.id}-birth-${index}`} event={event} currentTree={currentTree} />
))
) : (
<div className="text-center py-6">
<p className="text-xs text-muted-foreground"></p>
</div>
)}
</div>
</div>
{/* 忌日列 */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
<span className="w-1 h-4 bg-gray-500 rounded"></span>
<span className="ml-auto text-xs font-normal text-muted-foreground bg-gray-100 px-2 py-1 rounded border border-gray-200">
{deathEvents.length}
</span>
</h3>
<div className="space-y-2 max-h-[calc(5*70px)] overflow-y-auto pr-2">
{deathEvents.length > 0 ? (
deathEvents.map((event, index) => (
<EventCard key={`${event.member.id}-death-${index}`} event={event} currentTree={currentTree} />
))
) : (
<div className="text-center py-6">
<p className="text-xs text-muted-foreground"></p>
</div>
)}
</div>
</div>
</div>
) : (
<div className="text-center py-8">
<p className="text-sm text-foreground font-light tracking-wide"></p>
</div>
)
```
---
## 优化 3: 修复 handleAdminPhotoToggle useCallback
### ❌ 原始代码(问题)
```typescript
const handleAdminPhotoToggle = useCallback(async (memberId: string, photoUrl: string, value: boolean) => {
if (!isOwner) return
const member = treeData.members[memberId]
if (!member || !member.photos || member.photos.length === 0) return
const key = `${memberId}|${photoUrl}`
setAdminToggleLoading(key)
try {
const updatedPhotos = (member.photos as FamilyPhoto[]).map(photo =>
photo.url === photoUrl ? { ...photo, adminVisibleOverride: value } : photo
)
await updateMember(memberId, { photos: updatedPhotos })
} catch (error) {
console.error('更新管理员展示权限失败:', error)
} finally {
setAdminToggleLoading(null)
}
}, [isOwner, treeData.members, updateMember]) // ❌ treeData.members 导致每次都创建新函数
```
### ✅ 优化后代码
```typescript
const handleAdminPhotoToggle = useCallback(async (memberId: string, photoUrl: string, value: boolean) => {
if (!isOwner) return
const key = `${memberId}|${photoUrl}`
setAdminToggleLoading(key)
try {
const member = treeData.members[memberId]
if (!member?.photos?.length) return
const updatedPhotos = member.photos.map(photo =>
photo.url === photoUrl ? { ...photo, adminVisibleOverride: value } : photo
)
await updateMember(memberId, { photos: updatedPhotos })
} catch (error) {
console.error('更新管理员展示权限失败:', error)
} finally {
setAdminToggleLoading(null)
}
}, [isOwner, updateMember]) // ✅ 移除 treeData.members 依赖
```
---
## 优化 4: 优化 stats 计算为单次遍历
### ❌ 原始代码(问题)
```typescript
const stats = useMemo(() => {
const members = Object.values(treeData.members)
const totalMembers = members.length
// 多次遍历数组
const livingMembers = members.filter(m => !m.deathDate).length
const deceasedMembers = members.filter(m => m.deathDate).length
const maleCount = members.filter(m => m.gender === 'MALE').length
const femaleCount = members.filter(m => m.gender === 'FEMALE').length
const generations = members.map(m => m.generation || 0)
const maxGeneration = generations.length > 0 ? Math.max(...generations) : 0
const birthYears = members
.map(m => m.birthDate ? new Date(m.birthDate).getFullYear() : null)
.filter(y => y !== null) as number[]
const earliestYear = birthYears.length > 0 ? Math.min(...birthYears) : new Date().getFullYear()
const yearsSpan = birthYears.length > 0 ? new Date().getFullYear() - earliestYear : 0
const deceasedWithAge = members.filter(m => m.birthDate && m.deathDate)
const totalAge = deceasedWithAge.reduce((sum, m) => {
const birthYear = new Date(m.birthDate!).getFullYear()
const deathYear = new Date(m.deathDate!).getFullYear()
return sum + (deathYear - birthYear)
}, 0)
const averageLifespan = deceasedWithAge.length > 0
? Math.round(totalAge / deceasedWithAge.length)
: 0
return {
totalMembers,
livingMembers,
deceasedMembers,
maleCount,
femaleCount,
maxGeneration,
yearsSpan,
earliestYear,
averageLifespan
}
}, [treeData])
```
### ✅ 优化后代码
```typescript
const stats = useMemo(() => {
const members = Object.values(treeData.members)
const totalMembers = members.length
// 单次遍历计算所有统计数据
let livingMembers = 0
let deceasedMembers = 0
let maleCount = 0
let femaleCount = 0
let maxGeneration = 0
const birthYears: number[] = []
let totalAge = 0
let deceasedWithAgeCount = 0
members.forEach(m => {
// 生死统计
if (m.deathDate) {
deceasedMembers++
} else {
livingMembers++
}
// 性别统计
if (m.gender === 'MALE') maleCount++
else if (m.gender === 'FEMALE') femaleCount++
// 代数统计
if (m.generation && m.generation > maxGeneration) {
maxGeneration = m.generation
}
// 出生年份和寿命统计
if (m.birthDate) {
const birthYear = new Date(m.birthDate).getFullYear()
birthYears.push(birthYear)
if (m.deathDate) {
const deathYear = new Date(m.deathDate).getFullYear()
totalAge += deathYear - birthYear
deceasedWithAgeCount++
}
}
})
const earliestYear = birthYears.length > 0
? Math.min(...birthYears)
: new Date().getFullYear()
const yearsSpan = birthYears.length > 0
? new Date().getFullYear() - earliestYear
: 0
const averageLifespan = deceasedWithAgeCount > 0
? Math.round(totalAge / deceasedWithAgeCount)
: 0
return {
totalMembers,
livingMembers,
deceasedMembers,
maleCount,
femaleCount,
maxGeneration,
yearsSpan,
earliestYear,
averageLifespan
}
}, [treeData.members]) // ✅ 更精细的依赖项
```
---
## 优化 5: 提取照片卡片为单独组件
### 新建文件:`components/dashboard/photo-card.tsx`
```typescript
import React, { useCallback } from 'react'
import Link from 'next/link'
import { format } from 'date-fns'
import { Switch } from '@/components/ui/switch'
import { MemberNameWithStatus } from '@/components/member-name-with-status'
import { Play, Video } from 'lucide-react'
interface PhotoCardProps {
photo: {
url: string
caption?: string
uploadedAt: string
memberId: string
memberName: string
isDead: boolean
adminVisibleOverride: boolean
visibleInOverview: boolean
}
isOwner: boolean
currentTree?: { id?: string }
onSelect: (url: string) => void
onToggle: (memberId: string, photoUrl: string, value: boolean) => void
isLoading: boolean
}
const isVideoFile = (url: string) => {
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv']
return videoExtensions.some(ext => url.toLowerCase().endsWith(ext))
}
export const PhotoCard: React.FC<PhotoCardProps> = ({
photo,
isOwner,
currentTree,
onSelect,
onToggle,
isLoading
}) => {
const handleToggle = useCallback((checked: boolean) => {
onToggle(photo.memberId, photo.url, checked)
}, [photo.memberId, photo.url, onToggle])
if (photo.adminVisibleOverride === false) {
return (
<div className="rounded-lg overflow-hidden bg-card shadow-sm hover:shadow-lg transition-all duration-300 hover:-translate-y-1 border border-border/50">
<div className="p-4 space-y-2">
<div className="flex items-center justify-between gap-1.5 text-xs">
<div className="flex items-center gap-1 text-muted-foreground">
<span></span>
<Link
href={`/members/${photo.memberId}${currentTree?.id ? `?treeId=${currentTree.id}` : ''}`}
className="hover:text-primary hover:underline"
>
<MemberNameWithStatus
name={photo.memberName}
isDead={photo.isDead}
className="text-foreground font-medium"
/>
</Link>
</div>
<span className="text-muted-foreground/70 text-[10px]">
{format(new Date(photo.uploadedAt), 'MM-dd')}
</span>
</div>
{isOwner && (
<div className="flex items-center justify-between gap-1.5 text-[11px] text-muted-foreground">
<span></span>
<Switch
checked={photo.adminVisibleOverride ?? true}
onCheckedChange={handleToggle}
disabled={isLoading}
/>
</div>
)}
{!isOwner && (
<p className="text-[11px] text-muted-foreground"></p>
)}
</div>
</div>
)
}
return (
<div className="rounded-lg overflow-hidden bg-card shadow-sm hover:shadow-lg transition-all duration-300 hover:-translate-y-1 border border-border/50">
{/* 媒体区域 */}
<div
className="relative cursor-zoom-in"
onClick={() => onSelect(photo.url)}
>
{isVideoFile(photo.url) ? (
<div className="relative">
<video
src={photo.url}
className="w-full h-auto object-cover"
muted
preload="metadata"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<div className="w-12 h-12 rounded-full bg-white/90 flex items-center justify-center">
<Play className="h-6 w-6 text-black ml-1" />
</div>
</div>
<div className="absolute top-2 left-2 bg-black/70 text-white text-xs px-2 py-1 rounded flex items-center gap-1">
<Video className="h-3 w-3" />
</div>
</div>
) : (
<img
src={photo.url}
alt={photo.caption || `${photo.memberName}的照片`}
className="w-full h-auto object-cover"
loading="lazy"
/>
)}
</div>
{/* 底部信息 */}
<div className="px-3 py-2 bg-card border-t border-border/30 space-y-1">
{/* 照片说明 */}
<div className="text-xs line-clamp-2">
{photo.caption ? (
<span className="text-foreground">{photo.caption}</span>
) : (
<span className="text-muted-foreground/70"></span>
)}
</div>
{/* 分享人和时间 */}
<div className="flex items-center justify-between gap-1.5 text-xs">
<div className="flex items-center gap-1">
<span className="text-muted-foreground"></span>
<Link
href={`/members/${photo.memberId}${currentTree?.id ? `?treeId=${currentTree.id}` : ''}`}
className="hover:text-primary hover:underline"
>
<MemberNameWithStatus
name={photo.memberName}
isDead={photo.isDead}
className="text-foreground font-medium"
/>
</Link>
</div>
<span className="text-muted-foreground/70 text-[10px]">
{format(new Date(photo.uploadedAt), 'MM-dd')}
</span>
</div>
{/* 管理员权限开关 */}
{isOwner && (
<div className="mt-2 flex items-center justify-between gap-1.5 text-[11px] text-muted-foreground">
<span></span>
<Switch
checked={photo.adminVisibleOverride ?? true}
onCheckedChange={handleToggle}
disabled={isLoading}
/>
</div>
)}
</div>
</div>
)
}
```
### 在主组件中使用
```typescript
import { PhotoCard } from '@/components/dashboard/photo-card'
// 在 JSX 中
{groupedPhotosByMonth.map(([month, monthPhotos]) => (
<div key={month}>
<h4 className="text-sm font-medium text-muted-foreground mb-4 flex items-center gap-2 sticky top-0 bg-card/95 backdrop-blur py-2 z-10">
<span className="w-2 h-2 rounded-full bg-primary"></span>
{month}
<span className="text-xs text-muted-foreground/70">({monthPhotos.length})</span>
</h4>
<div className="columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-4 space-y-4">
{monthPhotos.map((photo, index) => (
<div key={`${photo.memberId}-${index}`} className="break-inside-avoid group">
<PhotoCard
photo={photo}
isOwner={isOwner}
currentTree={currentTree}
onSelect={setSelectedPhoto}
onToggle={handleAdminPhotoToggle}
isLoading={adminToggleLoading === `${photo.memberId}|${photo.url}`}
/>
</div>
))}
</div>
</div>
))}
```
---
## 优化 6: 修复所有 useMemo 依赖项
### 检查清单
```typescript
// ❌ 不好的依赖项
const monthlyAnniversaries = useMemo(() => {
// ...
}, [treeData]) // 整个对象
// ✅ 好的依赖项
const monthlyAnniversaries = useMemo(() => {
// ...
}, [treeData.members]) // 只依赖需要的部分
// ❌ 不好的依赖项
const allPhotos = useMemo(() => {
// ...
}, [treeData, isOwner]) // treeData 是整个对象
// ✅ 好的依赖项
const allPhotos = useMemo(() => {
// ...
}, [treeData.members, isOwner]) // 只依赖需要的部分
// ❌ 不好的依赖项
const locationGroups = useMemo(() => {
// ...
}, [treeData]) // 整个对象
// ✅ 好的依赖项
const locationGroups = useMemo(() => {
// ...
}, [treeData.members]) // 只依赖需要的部分
```
---
## 性能测试建议
### 使用 React DevTools Profiler
```typescript
// 在浏览器控制台运行
import { Profiler } from 'react'
// 包装组件
<Profiler id="DashboardPage" onRender={(id, phase, actualDuration) => {
console.log(`${id} (${phase}) took ${actualDuration}ms`)
}}>
<DashboardPage />
</Profiler>
```
### 测试场景
1. **初始加载**:测量首次渲染时间
2. **数据更新**:添加/删除成员后的重新渲染时间
3. **照片加载**:加载大量照片时的性能
4. **交互响应**:点击开关、展开折叠等操作的响应时间
### 预期改进
- 初始加载时间:减少 20-30%
- 重新渲染时间:减少 30-40%
- 内存使用:减少 15-20%