diff --git a/eslint.config.mjs b/eslint.config.mjs index 55d93f5..42ff59d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -30,6 +30,7 @@ export default defineConfig([ 'no-unused-vars': 'off', // 关闭未使用变量的检查 'react-refresh/only-export-components': 'off', 'react/display-name': 'off', + 'react/prop-types': 'off', // TypeScript 项目不需要 prop-types 验证 // 约束js使用单引号,允许jsx双引号 quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: true }], 'jsx-quotes': ['error', 'prefer-double'], diff --git a/src/app/tags/components/TagCloudBackground.tsx b/src/app/tags/components/TagCloudBackground.tsx index ac0c7d5..b016003 100755 --- a/src/app/tags/components/TagCloudBackground.tsx +++ b/src/app/tags/components/TagCloudBackground.tsx @@ -1,14 +1,18 @@ 'use client'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo, memo } from 'react'; import { motion } from 'framer-motion'; import { FaBook, FaBookmark, FaFileAlt, FaHashtag, FaLink, FaPaperclip, FaTag, FaFont } from 'react-icons/fa'; import { clsx } from 'clsx'; const icons = [FaBook, FaBookmark, FaFileAlt, FaHashtag, FaLink, FaPaperclip, FaTag, FaFont]; -const getRandomIcon = (): React.ElementType => icons[Math.floor(Math.random() * icons.length)]; -const getRandomTag = (tags: string[]): string => tags[Math.floor(Math.random() * tags.length)]; +// 优化:使用固定随机数,避免重复计算 +const getRandomIcon = (seed: number): React.ElementType => icons[seed % icons.length]; +const getRandomTag = (tags: string[], seed: number): string => { + if (tags.length === 0) return ''; + return tags[seed % tags.length]; +}; interface TagItemProps { icon: React.ElementType; @@ -17,14 +21,21 @@ interface TagItemProps { delay: number; } -const TagItem: React.FC = ({ icon: Icon, text, isLeft, delay }) => ( - +const TagItem = memo(({ icon: Icon, text, isLeft, delay }) => ( +
{text}
-); +)); + +TagItem.displayName = 'TagItem'; interface TagRowProps { isLeft: boolean; @@ -32,40 +43,89 @@ interface TagRowProps { tags: string[]; } -const TagRow: React.FC = ({ isLeft, rowIndex, tags }) => { - const rowTags = Array.from({ length: 8 }, (_, i) => ({ - icon: getRandomIcon(), - text: getRandomTag(tags), - delay: i * 0.5 + rowIndex * 0.2, - })); +const TagRow = memo(({ isLeft, rowIndex, tags }) => { + // 优化:进一步减少每行的标签数量,从 4 个减少到 3 个 + const rowTags = useMemo( + () => + Array.from({ length: 3 }, (_, i) => ({ + icon: getRandomIcon(rowIndex * 3 + i), + text: getRandomTag(tags, rowIndex * 3 + i), + delay: i * 0.5 + rowIndex * 0.2, + key: `${rowIndex}-${i}`, + })), + [rowIndex, tags] + ); return ( -
- {rowTags.map((tag, index) => ( - +
+ {rowTags.map((tag) => ( + ))}
); -}; +}); + +TagRow.displayName = 'TagRow'; interface Row { isLeft: boolean; index: number; } -export default function TagCloudBackground({ tags }: { tags: string[] }) { +function TagCloudBackground({ tags }: { tags: string[] }) { const [rows, setRows] = useState([]); + const [reducedMotion, setReducedMotion] = useState(false); useEffect(() => { - const numberOfRows = Math.ceil(window.innerHeight / 50); // Approximate row height - setRows(Array.from({ length: numberOfRows }, (_, i) => ({ isLeft: i % 2 === 0, index: i }))); + // 检测用户是否偏好减少动画 + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + setReducedMotion(mediaQuery.matches); + + const handleChange = (e: MediaQueryListEvent) => { + setReducedMotion(e.matches); + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); }, []); + useEffect(() => { + // 优化:进一步减少行数,使用更大的行高,并限制最大行数 + const rowHeight = 100; // 进一步增加行高,减少行数 + const maxRows = reducedMotion ? 5 : 10; // 如果用户偏好减少动画,则更少行数 + const numberOfRows = Math.min(maxRows, Math.ceil(window.innerHeight / rowHeight)); + setRows(Array.from({ length: numberOfRows }, (_, i) => ({ isLeft: i % 2 === 0, index: i }))); + }, [reducedMotion]); + + // 优化:使用 useMemo 缓存标签数组,避免重复计算,并且只取前100个标签用于背景动画 + const safeTags = useMemo(() => { + const tagArray = tags && tags.length > 0 ? tags : []; + // 只使用前100个标签,减少计算量 + return tagArray.slice(0, 100); + }, [tags]); + + if (safeTags.length === 0) { + return null; + } + + // 如果用户偏好减少动画,直接返回空或简化版本 + if (reducedMotion) { + return null; + } + return ( -
- {rows.map((row, index) => ( - +
+ {rows.map((row) => ( + ))}
); } + +export default memo(TagCloudBackground); diff --git a/src/app/tags/components/TagItemCard.tsx b/src/app/tags/components/TagItemCard.tsx index bfa6bd1..eafc35c 100755 --- a/src/app/tags/components/TagItemCard.tsx +++ b/src/app/tags/components/TagItemCard.tsx @@ -1,43 +1,57 @@ 'use client'; -import React from 'react'; +import React, { memo, useMemo } from 'react'; import Link from 'next/link'; import { LiaTagsSolid } from 'react-icons/lia'; import { Tag } from '@/types/app/tag'; import { clsx } from 'clsx'; -import { motion } from 'framer-motion'; -const TagItemCard = ({ data, count, index }: { data: Tag; count: number; index: number }) => { - const colors = ['bg-blue-400', 'bg-green-400', 'bg-yellow-400', 'bg-red-400', 'bg-indigo-400', 'bg-purple-400', 'bg-pink-400']; - const color = colors[index % colors.length]; +interface TagItemCardProps { + data: Tag; + count: number; + index: number; +} - return ( - +// 颜色数组提取到组件外部,避免重复创建 +const COLORS = ['bg-blue-400', 'bg-green-400', 'bg-yellow-400', 'bg-red-400', 'bg-indigo-400', 'bg-purple-400', 'bg-pink-400'] as const; + +const TagItemCard = memo( + ({ data, count, index }: TagItemCardProps) => { + // 使用 useMemo 缓存颜色计算 + const color = useMemo(() => COLORS[index % COLORS.length], [index]); + + // 使用 useMemo 缓存链接 URL + const href = useMemo(() => `/tag/${data?.id}?name=${encodeURIComponent(data?.name || '')}`, [data?.id, data?.name]); + + return ( - - ); -}; + ); + }, + (prevProps, nextProps) => { + // 自定义比较函数,只有关键属性变化时才重新渲染 + return prevProps.data?.id === nextProps.data?.id && prevProps.data?.name === nextProps.data?.name && prevProps.count === nextProps.count && prevProps.index === nextProps.index; + } +); + +TagItemCard.displayName = 'TagItemCard'; export default TagItemCard; diff --git a/src/app/tags/components/TagsPageClient.tsx b/src/app/tags/components/TagsPageClient.tsx new file mode 100644 index 0000000..db56f85 --- /dev/null +++ b/src/app/tags/components/TagsPageClient.tsx @@ -0,0 +1,31 @@ +'use client'; + +import React, { useMemo } from 'react'; +import { Tag } from '@/types/app/tag'; +import TagCloudBackground from '@/app/tags/components/TagCloudBackground'; +import VirtualizedTagList from './VirtualizedTagList'; + +interface TagsPageClientProps { + tags: Tag[]; +} + +export default function TagsPageClient({ tags }: TagsPageClientProps) { + // 使用 useMemo 缓存标签名称数组 + const tagNames = useMemo(() => { + return tags.map((item: Tag) => item.name).filter(Boolean); + }, [tags]); + + return ( +
+

标签墙

+ + {/* 标签列表 */} +
+ +
+ + {/* 背景动画 */} + +
+ ); +} diff --git a/src/app/tags/components/VirtualizedTagList.tsx b/src/app/tags/components/VirtualizedTagList.tsx new file mode 100644 index 0000000..b3a0b7c --- /dev/null +++ b/src/app/tags/components/VirtualizedTagList.tsx @@ -0,0 +1,156 @@ +'use client'; + +import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import TagItemCard from './TagItemCard'; +import { Tag } from '@/types/app/tag'; + +interface VirtualizedTagListProps { + tags: Tag[]; + containerHeight?: number; // 容器高度 + batchSize?: number; // 每批加载的数量 + initialBatchSize?: number; // 初始加载数量 +} + +const VirtualizedTagList: React.FC = ({ + tags, + containerHeight, + batchSize = 50, // 每批加载 50 个标签(减少批次大小) + initialBatchSize = 50, // 初始只加载 50 个(大幅减少初始DOM) +}) => { + const [visibleCount, setVisibleCount] = useState(initialBatchSize); + const [containerHeightState, setContainerHeightState] = useState(containerHeight || 600); + const containerRef = useRef(null); + const loadingRef = useRef(null); + const isLoadingRef = useRef(false); // 防止重复加载 + const resizeTimerRef = useRef | undefined>(undefined); + + // 动态计算容器高度(添加防抖) + useEffect(() => { + if (containerHeight) { + setContainerHeightState(containerHeight); + return; + } + + const updateHeight = () => { + // 清除之前的定时器 + if (resizeTimerRef.current) { + clearTimeout(resizeTimerRef.current); + } + + // 防抖处理 + resizeTimerRef.current = setTimeout(() => { + const height = window.innerHeight - 200; // 减去头部和其他元素的高度 + setContainerHeightState(Math.max(400, height)); + }, 150); + }; + + // 初始计算 + const height = window.innerHeight - 200; + setContainerHeightState(Math.max(400, height)); + + window.addEventListener('resize', updateHeight, { passive: true }); + return () => { + window.removeEventListener('resize', updateHeight); + if (resizeTimerRef.current) { + clearTimeout(resizeTimerRef.current); + } + }; + }, [containerHeight]); + + // 使用 requestAnimationFrame 优化加载 + const loadMore = useCallback(() => { + if (isLoadingRef.current || visibleCount >= tags.length) { + return; + } + + isLoadingRef.current = true; + + // 使用 requestAnimationFrame 优化性能 + requestAnimationFrame(() => { + setVisibleCount((prev) => { + const next = Math.min(prev + batchSize, tags.length); + isLoadingRef.current = false; + return next; + }); + }); + }, [visibleCount, tags.length, batchSize]); + + // 使用 Intersection Observer 实现懒加载(优化配置) + useEffect(() => { + if (!loadingRef.current || visibleCount >= tags.length) { + return; + } + + // 使用节流,避免过于频繁触发 + let ticking = false; + const observer = new IntersectionObserver( + (entries) => { + if (!ticking && entries[0].isIntersecting && visibleCount < tags.length && !isLoadingRef.current) { + ticking = true; + requestAnimationFrame(() => { + loadMore(); + ticking = false; + }); + } + }, + { + rootMargin: '300px', // 提前 300px 开始加载,减少加载次数 + threshold: 0.1, + } + ); + + observer.observe(loadingRef.current); + + return () => { + observer.disconnect(); + }; + }, [visibleCount, tags.length, loadMore]); + + // 获取可见的标签(使用 useMemo 优化) + const visibleTags = useMemo(() => { + return tags.slice(0, visibleCount); + }, [tags, visibleCount]); + + return ( +
+
+ {visibleTags.map((tag, index) => ( + + ))} +
+ + {/* 加载触发器 */} + {visibleCount < tags.length && ( +
+
+ 加载中... ({visibleCount}/{tags.length}) +
+
+ )} + + {/* 加载完成提示 */} + {visibleCount >= tags.length && tags.length > 0 && ( +
+ 共 {tags.length} 个标签 +
+ )} +
+ ); +}; + +export default VirtualizedTagList; diff --git a/src/app/tags/page.tsx b/src/app/tags/page.tsx index 42475cc..e26c012 100755 --- a/src/app/tags/page.tsx +++ b/src/app/tags/page.tsx @@ -1,8 +1,7 @@ import { getTagListWithArticleCountAPI } from '@/api/tag'; import { Tag } from '@/types/app/tag'; -import TagCloudBackground from '@/app/tags/components/TagCloudBackground'; -import TagItemCard from './components/TagItemCard'; import { Metadata } from 'next'; +import TagsPageClient from './components/TagsPageClient'; export const metadata: Metadata = { title: '🏷️ 标签墙', @@ -12,17 +11,5 @@ export const metadata: Metadata = { export default async () => { const { data } = (await getTagListWithArticleCountAPI()) || { data: {} as Tag[] }; - return ( -
-

标签墙

- -
- {data.map((tag, index) => ( - - ))} -
- - item.name) || []} /> -
- ); + return ; };