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

20 KiB
Raw Blame History

app/page.tsx 优化代码示例

优化 1: 照片分组逻辑

原始代码(问题)

{(() => {
  // 按月份分组
  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
  ))
})()}

优化后代码

// 在组件顶部添加
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: 未来三月纪念日计算

原始代码(问题)

{(() => {
  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
  )
})()}

优化后代码

首先,提取辅助函数:

// 在组件外部定义
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

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

原始代码(问题)

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 导致每次都创建新函数

优化后代码

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 计算为单次遍历

原始代码(问题)

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])

优化后代码

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

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>
  )
}

在主组件中使用

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 依赖项

检查清单

// ❌ 不好的依赖项
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

// 在浏览器控制台运行
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%