搜索页面海量数据下使用虚拟滚动提高性能
This commit is contained in:
+87
-60
@@ -22,6 +22,7 @@ import PansouSearch from '@/components/PansouSearch';
|
|||||||
import SearchResultFilter, { SearchFilterCategory } from '@/components/SearchResultFilter';
|
import SearchResultFilter, { SearchFilterCategory } from '@/components/SearchResultFilter';
|
||||||
import SearchSuggestions from '@/components/SearchSuggestions';
|
import SearchSuggestions from '@/components/SearchSuggestions';
|
||||||
import VideoCard, { VideoCardHandle } from '@/components/VideoCard';
|
import VideoCard, { VideoCardHandle } from '@/components/VideoCard';
|
||||||
|
import VirtualScrollableGrid from '@/components/VirtualScrollableGrid';
|
||||||
|
|
||||||
function SearchPageClient() {
|
function SearchPageClient() {
|
||||||
// 搜索历史
|
// 搜索历史
|
||||||
@@ -518,6 +519,11 @@ function SearchPageClient() {
|
|||||||
});
|
});
|
||||||
}, [aggregatedResults, filterAgg, searchQuery]);
|
}, [aggregatedResults, filterAgg, searchQuery]);
|
||||||
|
|
||||||
|
const useVirtualGrid = useMemo(() => {
|
||||||
|
const cardCount = viewMode === 'agg' ? filteredAggResults.length : filteredAllResults.length;
|
||||||
|
return cardCount >= 100;
|
||||||
|
}, [viewMode, filteredAggResults.length, filteredAllResults.length]);
|
||||||
|
|
||||||
// 监听选项卡切换,自动执行搜索
|
// 监听选项卡切换,自动执行搜索
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 如果切换到网盘搜索选项卡,且有搜索关键词,且已显示结果,则触发搜索
|
// 如果切换到网盘搜索选项卡,且有搜索关键词,且已显示结果,则触发搜索
|
||||||
@@ -1194,77 +1200,98 @@ function SearchPageClient() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div
|
(() => {
|
||||||
key={`search-results-${viewMode}`}
|
const gridClassName =
|
||||||
className='justify-start grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8'
|
'justify-start grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8';
|
||||||
>
|
|
||||||
{viewMode === 'agg'
|
|
||||||
? filteredAggResults.map(([mapKey, group]) => {
|
|
||||||
const title = group[0]?.title || '';
|
|
||||||
const poster = group[0]?.poster || '';
|
|
||||||
const year = group[0]?.year || 'unknown';
|
|
||||||
const { episodes, source_names, douban_id } = computeGroupStats(group);
|
|
||||||
|
|
||||||
// 从 mapKey 中提取类型(mapKey 格式:normalizedTitle-type-year)
|
const gridChildren =
|
||||||
// 找到最后一个 '-' 之前的部分,再找倒数第二个 '-'
|
viewMode === 'agg'
|
||||||
const lastDashIndex = mapKey.lastIndexOf('-');
|
? filteredAggResults.map(([mapKey, group]) => {
|
||||||
const secondLastDashIndex = mapKey.lastIndexOf('-', lastDashIndex - 1);
|
const title = group[0]?.title || '';
|
||||||
const type = secondLastDashIndex > 0
|
const poster = group[0]?.poster || '';
|
||||||
? mapKey.substring(secondLastDashIndex + 1, lastDashIndex) as 'movie' | 'tv'
|
const year = group[0]?.year || 'unknown';
|
||||||
: (episodes === 1 ? 'movie' : 'tv'); // 兜底
|
const { episodes, source_names, douban_id } = computeGroupStats(group);
|
||||||
|
|
||||||
// 如果该聚合第一次出现,写入初始统计
|
// 从 mapKey 中提取类型(mapKey 格式:normalizedTitle-type-year)
|
||||||
if (!groupStatsRef.current.has(mapKey)) {
|
// 找到最后一个 '-' 之前的部分,再找倒数第二个 '-'
|
||||||
groupStatsRef.current.set(mapKey, { episodes, source_names, douban_id });
|
const lastDashIndex = mapKey.lastIndexOf('-');
|
||||||
}
|
const secondLastDashIndex = mapKey.lastIndexOf('-', lastDashIndex - 1);
|
||||||
|
const type = secondLastDashIndex > 0
|
||||||
|
? mapKey.substring(secondLastDashIndex + 1, lastDashIndex) as 'movie' | 'tv'
|
||||||
|
: (episodes === 1 ? 'movie' : 'tv'); // 兜底
|
||||||
|
|
||||||
return (
|
// 如果该聚合第一次出现,写入初始统计
|
||||||
<div key={`agg-${mapKey}`} className='w-full'>
|
if (!groupStatsRef.current.has(mapKey)) {
|
||||||
|
groupStatsRef.current.set(mapKey, { episodes, source_names, douban_id });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`agg-${mapKey}`} className='w-full'>
|
||||||
|
<VideoCard
|
||||||
|
ref={getGroupRef(mapKey)}
|
||||||
|
from='search'
|
||||||
|
isAggregate={true}
|
||||||
|
title={title}
|
||||||
|
poster={poster}
|
||||||
|
year={year}
|
||||||
|
episodes={episodes}
|
||||||
|
source_names={source_names}
|
||||||
|
douban_id={douban_id}
|
||||||
|
query={
|
||||||
|
searchQuery.trim() !== title
|
||||||
|
? searchQuery.trim()
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
type={type}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: filteredAllResults.map((item) => (
|
||||||
|
<div
|
||||||
|
key={`all-${item.source}-${item.id}`}
|
||||||
|
className='w-full'
|
||||||
|
>
|
||||||
<VideoCard
|
<VideoCard
|
||||||
ref={getGroupRef(mapKey)}
|
id={item.id}
|
||||||
from='search'
|
title={item.title}
|
||||||
isAggregate={true}
|
poster={item.poster}
|
||||||
title={title}
|
episodes={item.episodes.length}
|
||||||
poster={poster}
|
source={item.source}
|
||||||
year={year}
|
source_name={item.source_name}
|
||||||
episodes={episodes}
|
douban_id={item.douban_id}
|
||||||
source_names={source_names}
|
|
||||||
douban_id={douban_id}
|
|
||||||
query={
|
query={
|
||||||
searchQuery.trim() !== title
|
searchQuery.trim() !== item.title
|
||||||
? searchQuery.trim()
|
? searchQuery.trim()
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
type={type}
|
year={item.year}
|
||||||
|
from='search'
|
||||||
|
type={item.episodes.length > 1 ? 'tv' : 'movie'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
));
|
||||||
})
|
|
||||||
: filteredAllResults.map((item) => (
|
if (useVirtualGrid) {
|
||||||
<div
|
return (
|
||||||
key={`all-${item.source}-${item.id}`}
|
<VirtualScrollableGrid
|
||||||
className='w-full'
|
key={`search-results-virtual-${viewMode}`}
|
||||||
|
gridClassName={gridClassName}
|
||||||
>
|
>
|
||||||
<VideoCard
|
{gridChildren}
|
||||||
id={item.id}
|
</VirtualScrollableGrid>
|
||||||
title={item.title}
|
);
|
||||||
poster={item.poster}
|
}
|
||||||
episodes={item.episodes.length}
|
|
||||||
source={item.source}
|
return (
|
||||||
source_name={item.source_name}
|
<div
|
||||||
douban_id={item.douban_id}
|
key={`search-results-${viewMode}`}
|
||||||
query={
|
className={gridClassName}
|
||||||
searchQuery.trim() !== item.title
|
>
|
||||||
? searchQuery.trim()
|
{gridChildren}
|
||||||
: ''
|
</div>
|
||||||
}
|
);
|
||||||
year={item.year}
|
})()
|
||||||
from='search'
|
|
||||||
type={item.episodes.length > 1 ? 'tv' : 'movie'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : activeTab === 'pansou' ? (
|
) : activeTab === 'pansou' ? (
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface VirtualScrollableGridProps {
|
||||||
|
children: React.ReactNode[];
|
||||||
|
gridClassName: string;
|
||||||
|
/** extra rows rendered above/below viewport */
|
||||||
|
overscanRows?: number;
|
||||||
|
/** < 640px columns */
|
||||||
|
mobileColumns?: number;
|
||||||
|
/** >= 640px min card width (px) to derive columns */
|
||||||
|
minItemWidth?: number;
|
||||||
|
/** >= 640px max content width (px) to derive columns */
|
||||||
|
maxContentWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
|
||||||
|
|
||||||
|
export default function VirtualScrollableGrid({
|
||||||
|
children,
|
||||||
|
gridClassName,
|
||||||
|
overscanRows = 3,
|
||||||
|
mobileColumns = 3,
|
||||||
|
minItemWidth = 176,
|
||||||
|
maxContentWidth = 1400,
|
||||||
|
}: VirtualScrollableGridProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const measureRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const columnsRef = useRef<number>(mobileColumns);
|
||||||
|
const rowHeightRef = useRef<number>(320);
|
||||||
|
const totalRowsRef = useRef<number>(0);
|
||||||
|
const rafRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const [range, setRange] = useState({ startRow: 0, endRow: 0 });
|
||||||
|
|
||||||
|
const computeColumns = () => {
|
||||||
|
if (typeof window === 'undefined') return mobileColumns;
|
||||||
|
const width = window.innerWidth;
|
||||||
|
if (width < 640) return mobileColumns;
|
||||||
|
const containerWidth = Math.min(width - 32, maxContentWidth);
|
||||||
|
return Math.max(mobileColumns, Math.floor(containerWidth / minItemWidth));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLayout = () => {
|
||||||
|
const gapY = window.innerWidth >= 640 ? 80 : 56; // gap-y-14 / sm:gap-y-20
|
||||||
|
const columns = computeColumns();
|
||||||
|
columnsRef.current = columns;
|
||||||
|
totalRowsRef.current = Math.ceil(children.length / Math.max(1, columns));
|
||||||
|
|
||||||
|
// Measure a single item height (wrapper div around VideoCard) and add vertical gap.
|
||||||
|
const measureEl = measureRef.current;
|
||||||
|
const firstItem = measureEl?.querySelector<HTMLElement>('[data-virtual-measure-item]');
|
||||||
|
const itemH = firstItem?.getBoundingClientRect().height;
|
||||||
|
if (itemH && Number.isFinite(itemH) && itemH > 0) {
|
||||||
|
rowHeightRef.current = Math.max(120, Math.round(itemH + gapY));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRange = () => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const totalRows = totalRowsRef.current;
|
||||||
|
if (totalRows <= 0) return;
|
||||||
|
|
||||||
|
const rowHeight = rowHeightRef.current;
|
||||||
|
if (!rowHeight || rowHeight <= 0) return;
|
||||||
|
|
||||||
|
// This app uses `document.body` as the actual scroll container (see search page back-to-top logic).
|
||||||
|
const scrollTop = document.body.scrollTop || 0;
|
||||||
|
const viewportBottom = scrollTop + window.innerHeight;
|
||||||
|
const containerTop = el.getBoundingClientRect().top + scrollTop;
|
||||||
|
|
||||||
|
const startRow = Math.floor((scrollTop - containerTop) / rowHeight) - overscanRows;
|
||||||
|
const endRow = Math.ceil((viewportBottom - containerTop) / rowHeight) + overscanRows;
|
||||||
|
|
||||||
|
const clampedStart = clamp(startRow, 0, Math.max(0, totalRows - 1));
|
||||||
|
const clampedEnd = clamp(endRow, clampedStart, Math.max(0, totalRows - 1));
|
||||||
|
|
||||||
|
setRange((prev) => {
|
||||||
|
if (prev.startRow === clampedStart && prev.endRow === clampedEnd) return prev;
|
||||||
|
return { startRow: clampedStart, endRow: clampedEnd };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleUpdate = () => {
|
||||||
|
if (rafRef.current != null) return;
|
||||||
|
rafRef.current = window.requestAnimationFrame(() => {
|
||||||
|
rafRef.current = null;
|
||||||
|
updateLayout();
|
||||||
|
updateRange();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateLayout();
|
||||||
|
updateRange();
|
||||||
|
|
||||||
|
let isRunning = true;
|
||||||
|
const rafLoop = () => {
|
||||||
|
if (!isRunning) return;
|
||||||
|
scheduleUpdate();
|
||||||
|
window.requestAnimationFrame(rafLoop);
|
||||||
|
};
|
||||||
|
rafLoop();
|
||||||
|
|
||||||
|
document.body.addEventListener('scroll', scheduleUpdate, { passive: true });
|
||||||
|
window.addEventListener('resize', scheduleUpdate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isRunning = false;
|
||||||
|
document.body.removeEventListener('scroll', scheduleUpdate);
|
||||||
|
window.removeEventListener('resize', scheduleUpdate);
|
||||||
|
if (rafRef.current != null) window.cancelAnimationFrame(rafRef.current);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [children.length, overscanRows, mobileColumns, minItemWidth, maxContentWidth]);
|
||||||
|
|
||||||
|
const columns = columnsRef.current;
|
||||||
|
const totalRows = totalRowsRef.current;
|
||||||
|
const rowHeight = rowHeightRef.current;
|
||||||
|
|
||||||
|
const startIndex = range.startRow * columns;
|
||||||
|
const endIndexExclusive = Math.min(children.length, (range.endRow + 1) * columns);
|
||||||
|
const visibleChildren = children.slice(startIndex, endIndexExclusive);
|
||||||
|
|
||||||
|
const topSpacerHeight = range.startRow * rowHeight;
|
||||||
|
const bottomSpacerHeight = Math.max(0, (totalRows - range.endRow - 1) * rowHeight);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className='w-full'>
|
||||||
|
{/* hidden measuring row (first visible row) */}
|
||||||
|
<div
|
||||||
|
ref={measureRef}
|
||||||
|
className='pointer-events-none absolute left-0 top-0 -z-10 opacity-0'
|
||||||
|
aria-hidden='true'
|
||||||
|
>
|
||||||
|
<div className={gridClassName}>
|
||||||
|
{children.slice(0, Math.max(1, columns)).map((child, idx) => (
|
||||||
|
<div key={`measure-${idx}`} data-virtual-measure-item>
|
||||||
|
{child}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: topSpacerHeight }} />
|
||||||
|
<div className={gridClassName}>{visibleChildren}</div>
|
||||||
|
<div style={{ height: bottomSpacerHeight }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user