优化标签墙页面,移除不必要的组件,重构标签展示逻辑,使用 memo 和 useMemo 提升性能,添加 CSS containment 和硬件加速以优化渲染效果,确保更流畅的用户体验。

This commit is contained in:
宇阳
2026-01-10 21:52:03 +08:00
parent aa74fe1295
commit f98ed89421
6 changed files with 310 additions and 61 deletions

View File

@@ -30,6 +30,7 @@ export default defineConfig([
'no-unused-vars': 'off', // 关闭未使用变量的检查 'no-unused-vars': 'off', // 关闭未使用变量的检查
'react-refresh/only-export-components': 'off', 'react-refresh/only-export-components': 'off',
'react/display-name': 'off', 'react/display-name': 'off',
'react/prop-types': 'off', // TypeScript 项目不需要 prop-types 验证
// 约束js使用单引号允许jsx双引号 // 约束js使用单引号允许jsx双引号
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: true }], quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: true }],
'jsx-quotes': ['error', 'prefer-double'], 'jsx-quotes': ['error', 'prefer-double'],

View File

@@ -1,14 +1,18 @@
'use client'; 'use client';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useMemo, memo } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { FaBook, FaBookmark, FaFileAlt, FaHashtag, FaLink, FaPaperclip, FaTag, FaFont } from 'react-icons/fa'; import { FaBook, FaBookmark, FaFileAlt, FaHashtag, FaLink, FaPaperclip, FaTag, FaFont } from 'react-icons/fa';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
const icons = [FaBook, FaBookmark, FaFileAlt, FaHashtag, FaLink, FaPaperclip, FaTag, FaFont]; 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 { interface TagItemProps {
icon: React.ElementType; icon: React.ElementType;
@@ -17,14 +21,21 @@ interface TagItemProps {
delay: number; delay: number;
} }
const TagItem: React.FC<TagItemProps> = ({ icon: Icon, text, isLeft, delay }) => ( const TagItem = memo<TagItemProps>(({ icon: Icon, text, isLeft, delay }) => (
<motion.div initial={{ opacity: 0, x: isLeft ? 100 : -100 }} animate={{ opacity: [0, 0.8, 0], x: isLeft ? [-100, 0, 100] : [100, 0, -100] }} transition={{ duration: 5, delay, repeat: Infinity, repeatType: 'loop', ease: 'linear' }}> <motion.div
initial={{ opacity: 0, x: isLeft ? 100 : -100 }}
animate={{ opacity: [0, 0.8, 0], x: isLeft ? [-100, 0, 100] : [100, 0, -100] }}
transition={{ duration: 5, delay, repeat: Infinity, repeatType: 'loop', ease: 'linear' }}
style={{ willChange: 'transform, opacity' }} // 优化动画性能
>
<div className={clsx('inline-flex items-center space-x-2 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-400 px-3 py-1 text-sm shadow-sm')}> <div className={clsx('inline-flex items-center space-x-2 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-400 px-3 py-1 text-sm shadow-sm')}>
<Icon size={14} /> <Icon size={14} />
<span>{text}</span> <span>{text}</span>
</div> </div>
</motion.div> </motion.div>
); ));
TagItem.displayName = 'TagItem';
interface TagRowProps { interface TagRowProps {
isLeft: boolean; isLeft: boolean;
@@ -32,40 +43,89 @@ interface TagRowProps {
tags: string[]; tags: string[];
} }
const TagRow: React.FC<TagRowProps> = ({ isLeft, rowIndex, tags }) => { const TagRow = memo<TagRowProps>(({ isLeft, rowIndex, tags }) => {
const rowTags = Array.from({ length: 8 }, (_, i) => ({ // 优化:进一步减少每行的标签数量,从 4 个减少到 3 个
icon: getRandomIcon(), const rowTags = useMemo(
text: getRandomTag(tags), () =>
delay: i * 0.5 + rowIndex * 0.2, 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 ( return (
<div className={`flex ${isLeft ? 'justify-start' : 'justify-end'} space-x-4 my-8`}> <div className={`flex ${isLeft ? 'justify-start' : 'justify-end'} space-x-4 my-8`} style={{ contain: 'layout' }}>
{rowTags.map((tag, index) => ( {rowTags.map((tag) => (
<TagItem key={index} {...tag} isLeft={isLeft} /> <TagItem key={tag.key} icon={tag.icon} text={tag.text} isLeft={isLeft} delay={tag.delay} />
))} ))}
</div> </div>
); );
}; });
TagRow.displayName = 'TagRow';
interface Row { interface Row {
isLeft: boolean; isLeft: boolean;
index: number; index: number;
} }
export default function TagCloudBackground({ tags }: { tags: string[] }) { function TagCloudBackground({ tags }: { tags: string[] }) {
const [rows, setRows] = useState<Row[]>([]); const [rows, setRows] = useState<Row[]>([]);
const [reducedMotion, setReducedMotion] = useState(false);
useEffect(() => { 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 ( return (
<div className="absolute inset-1 h-screen overflow-hidden pointer-events-none z-0"> <div
{rows.map((row, index) => ( className="absolute inset-1 h-screen overflow-hidden pointer-events-none z-0"
<TagRow key={index} isLeft={row.isLeft} rowIndex={row.index} tags={tags} /> style={{
contain: 'layout paint', // CSS containment 优化
willChange: 'contents', // 提示浏览器优化
}}
>
{rows.map((row) => (
<TagRow key={row.index} isLeft={row.isLeft} rowIndex={row.index} tags={safeTags} />
))} ))}
</div> </div>
); );
} }
export default memo(TagCloudBackground);

View File

@@ -1,43 +1,57 @@
'use client'; 'use client';
import React from 'react'; import React, { memo, useMemo } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { LiaTagsSolid } from 'react-icons/lia'; import { LiaTagsSolid } from 'react-icons/lia';
import { Tag } from '@/types/app/tag'; import { Tag } from '@/types/app/tag';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { motion } from 'framer-motion';
const TagItemCard = ({ data, count, index }: { data: Tag; count: number; index: number }) => { interface TagItemCardProps {
const colors = ['bg-blue-400', 'bg-green-400', 'bg-yellow-400', 'bg-red-400', 'bg-indigo-400', 'bg-purple-400', 'bg-pink-400']; data: Tag;
const color = colors[index % colors.length]; count: number;
index: number;
}
return ( // 颜色数组提取到组件外部,避免重复创建
<motion.div 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;
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} const TagItemCard = memo(
transition={{ type: 'spring', stiffness: 100, mass: 0.8, delay: index * 0.05 }} ({ data, count, index }: TagItemCardProps) => {
whileHover={{ // 使用 useMemo 缓存颜色计算
scale: 1.05, const color = useMemo(() => COLORS[index % COLORS.length], [index]);
transition: { duration: 0.2 },
}} // 使用 useMemo 缓存链接 URL
> const href = useMemo(() => `/tag/${data?.id}?name=${encodeURIComponent(data?.name || '')}`, [data?.id, data?.name]);
return (
<Link <Link
href={`/tag/${data?.id}?name=${data?.name}`} href={href}
className={clsx('flex h-10 bg-opacity-20 backdrop-blur', color)} className={clsx('flex h-10 bg-opacity-20 transition-transform duration-200 hover:scale-105', color)}
style={{ style={{
borderRadius: '0.5rem', borderRadius: '0.5rem',
backdropFilter: 'blur(10px)',
margin: '0.5rem', margin: '0.5rem',
padding: '0 1rem', padding: '0 1rem',
alignItems: 'center', alignItems: 'center',
contain: 'layout style paint', // CSS containment 隔离渲染
transform: 'translateZ(0)', // 启用硬件加速
backfaceVisibility: 'hidden', // 优化渲染性能
}} }}
prefetch={false} // 禁用预取,减少初始加载
> >
<LiaTagsSolid className="h-4 w-4 text-gray-400" aria-hidden="true" /> <LiaTagsSolid className="h-4 w-4 text-gray-400 flex-shrink-0" aria-hidden="true" />
<span className="ml-2">{data?.name}</span> <span className="ml-2 truncate max-w-[200px]" title={data?.name}>
<span className="ml-4 text-sm text-gray-400 dark:text-gray-500">{count}</span> {data?.name}
</span>
<span className="ml-4 text-sm text-gray-400 dark:text-gray-500 flex-shrink-0">{count}</span>
</Link> </Link>
</motion.div> );
); },
}; (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; export default TagItemCard;

View File

@@ -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 (
<div className="py-[50px] mt-[60px] min-h-screen relative hide_sliding">
<h1 className="relative z-20 text-4xl font-bold text-center mb-5"></h1>
{/* 标签列表 */}
<div className="relative z-20 w-11/12 mx-auto">
<VirtualizedTagList tags={tags} initialBatchSize={30} batchSize={30} />
</div>
{/* 背景动画 */}
<TagCloudBackground tags={tagNames} />
</div>
);
}

View File

@@ -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<VirtualizedTagListProps> = ({
tags,
containerHeight,
batchSize = 50, // 每批加载 50 个标签(减少批次大小)
initialBatchSize = 50, // 初始只加载 50 个大幅减少初始DOM
}) => {
const [visibleCount, setVisibleCount] = useState(initialBatchSize);
const [containerHeightState, setContainerHeightState] = useState(containerHeight || 600);
const containerRef = useRef<HTMLDivElement>(null);
const loadingRef = useRef<HTMLDivElement>(null);
const isLoadingRef = useRef(false); // 防止重复加载
const resizeTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div
ref={containerRef}
style={{
maxHeight: `${containerHeightState}px`,
overflow: 'auto',
position: 'relative',
contain: 'layout style paint', // CSS containment 优化
willChange: 'scroll-position', // 提示浏览器优化滚动
}}
className="w-full"
>
<div
className="flex flex-wrap justify-center w-full py-10 px-0 sm:px-10"
style={{
contain: 'layout', // 隔离布局影响
}}
>
{visibleTags.map((tag, index) => (
<TagItemCard key={tag.id || index} data={tag} count={tag.count || 0} index={index} />
))}
</div>
{/* 加载触发器 */}
{visibleCount < tags.length && (
<div ref={loadingRef} className="w-full h-20 flex items-center justify-center" style={{ minHeight: '80px', contain: 'layout' }}>
<div className="text-gray-400 dark:text-gray-500 text-sm">
... ({visibleCount}/{tags.length})
</div>
</div>
)}
{/* 加载完成提示 */}
{visibleCount >= tags.length && tags.length > 0 && (
<div className="w-full h-10 flex items-center justify-center text-gray-400 dark:text-gray-500 text-sm" style={{ contain: 'layout' }}>
{tags.length}
</div>
)}
</div>
);
};
export default VirtualizedTagList;

View File

@@ -1,8 +1,7 @@
import { getTagListWithArticleCountAPI } from '@/api/tag'; import { getTagListWithArticleCountAPI } from '@/api/tag';
import { Tag } from '@/types/app/tag'; import { Tag } from '@/types/app/tag';
import TagCloudBackground from '@/app/tags/components/TagCloudBackground';
import TagItemCard from './components/TagItemCard';
import { Metadata } from 'next'; import { Metadata } from 'next';
import TagsPageClient from './components/TagsPageClient';
export const metadata: Metadata = { export const metadata: Metadata = {
title: '🏷️ 标签墙', title: '🏷️ 标签墙',
@@ -12,17 +11,5 @@ export const metadata: Metadata = {
export default async () => { export default async () => {
const { data } = (await getTagListWithArticleCountAPI()) || { data: {} as Tag[] }; const { data } = (await getTagListWithArticleCountAPI()) || { data: {} as Tag[] };
return ( return <TagsPageClient tags={data || []} />;
<div className="py-[50px] mt-[60px] h-screen overflow-scroll hide_sliding">
<h1 className="relative z-20 text-4xl font-bold text-center"></h1>
<div className="relative z-20 flex flex-wrap justify-center w-11/12 mx-auto py-10 px-0 sm:px-10">
{data.map((tag, index) => (
<TagItemCard data={tag} count={tag.count || 0} index={index} key={tag.id} />
))}
</div>
<TagCloudBackground tags={data?.map((item: Tag) => item.name) || []} />
</div>
);
}; };