使用Virtua替换@tanstack/react-virtual来实现虚拟列表

This commit is contained in:
xintaofei
2026-03-24 22:35:52 +08:00
parent 7d1db72b3e
commit 1da1fd1e38
5 changed files with 70 additions and 131 deletions

View File

@@ -12,7 +12,7 @@ import {
} from "react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { useVirtualizer } from "@tanstack/react-virtual"
import { Virtualizer, type VirtualizerHandle } from "virtua"
import { CheckCheck, ChevronRight, Download, Loader2, Plus } from "lucide-react"
import { useFolderContext } from "@/contexts/folder-context"
import { useTabContext } from "@/contexts/tab-context"
@@ -71,7 +71,6 @@ type FlatItem =
| { type: "header"; status: ConversationStatus; count: number }
| { type: "conversation"; conversation: DbConversationSummary }
const HEADER_HEIGHT = 32
const CARD_HEIGHT = 62
const GroupHeader = memo(function GroupHeader({
@@ -209,8 +208,7 @@ export function SidebarConversationList({
const scrollToActiveRef = useRef<() => void>(() => {})
const pendingScrollRef = useRef(false)
const virtualizerRef =
useRef<ReturnType<typeof useVirtualizer<HTMLDivElement, Element>>>(null)
const virtualizerRef = useRef<VirtualizerHandle>(null)
useImperativeHandle(ref, () => ({
scrollToActive() {
@@ -272,22 +270,6 @@ export function SidebarConversationList({
)
const reviewConversationCount = reviewConversations.length
const virtualizer = useVirtualizer({
count: flatItems.length,
getScrollElement: () => scrollContainerRef.current,
estimateSize: (index) =>
flatItems[index].type === "header" ? HEADER_HEIGHT : CARD_HEIGHT,
getItemKey: (index) => {
const item = flatItems[index]
return item.type === "header"
? `header-${item.status}`
: `conv-${item.conversation.id}`
},
overscan: 5,
})
virtualizerRef.current = virtualizer
useEffect(() => {
scrollToActiveRef.current = () => {
if (!selectedConversation) return
@@ -312,7 +294,7 @@ export function SidebarConversationList({
if (index >= 0) {
virtualizerRef.current?.scrollToIndex(index, {
align: "center",
behavior: "smooth",
smooth: true,
})
}
}
@@ -494,33 +476,20 @@ export function SidebarConversationList({
ref={scrollContainerRef}
className={cn(
"flex-1 min-h-0 overflow-y-auto px-1.5",
"[overflow-anchor:none]",
"[&::-webkit-scrollbar]:w-1.5",
"[&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:bg-border"
)}
>
<div
style={{
height: virtualizer.getTotalSize(),
position: "relative",
width: "100%",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const item = flatItems[virtualRow.index]
<Virtualizer ref={virtualizerRef} itemSize={CARD_HEIGHT}>
{flatItems.map((item) => {
const key =
item.type === "header"
? `header-${item.status}`
: `conv-${item.conversation.id}`
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div key={key}>
{item.type === "header" ? (
item.status === "pending_review" ? (
<PendingReviewHeader
@@ -563,7 +532,7 @@ export function SidebarConversationList({
</div>
)
})}
</div>
</Virtualizer>
</div>
</ContextMenuTrigger>
<ContextMenuContent>

View File

@@ -376,8 +376,6 @@ export function MessageListView({
getItemKey={(item) => item.key}
renderItem={renderThreadItem}
emptyState={emptyState}
estimateSize={100}
overscan={10}
/>
<MessageThreadScrollButton />
</MessageThread>

View File

@@ -1,8 +1,8 @@
"use client"
import { useCallback, useEffect } from "react"
import type { ReactNode } from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
import { useMemo } from "react"
import type { CSSProperties, ReactNode, RefObject } from "react"
import { Virtualizer } from "virtua"
import { useStickToBottomContext } from "use-stick-to-bottom"
import {
MessageThreadContent,
@@ -11,15 +11,29 @@ import {
import { cn } from "@/lib/utils"
interface VirtualizedMessageThreadProps<T> {
/** Data to virtualise — each entry becomes one virtual row. */
items: T[]
/** Stable key for a given item (used as React key). */
getItemKey: (item: T, index: number) => string
/** Render the content of one row. */
renderItem: (item: T, index: number) => ReactNode
/** Shown when `items` is empty. */
emptyState?: ReactNode
estimateSize?: number
overscan?: number
/**
* Hint for the initial height (px) of an unmeasured item.
* Virtua auto-measures every item once mounted, so this only
* affects the very first paint — omit it if you don't care.
*/
itemSize?: number
/** Vertical gap between items in px. @default 16 */
gap?: number
/** Vertical padding before the first / after the last item. @default 16 */
padding?: number
/** Extra className on every item's inner wrapper (the `max-w-3xl` div). */
className?: string
/** Extra className on the MessageThreadContent shell. */
contentClassName?: string
/** Extra props forwarded to MessageThreadContent. */
contentProps?: Omit<MessageThreadContentProps, "children" | "className">
}
@@ -28,83 +42,58 @@ export function VirtualizedMessageThread<T>({
getItemKey,
renderItem,
emptyState,
estimateSize = 160,
overscan = 8,
itemSize,
gap = 16,
padding = 16,
className,
contentClassName,
contentProps,
}: VirtualizedMessageThreadProps<T>) {
const { scrollRef } = useStickToBottomContext()
// eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => estimateSize,
overscan,
useAnimationFrameWithResizeObserver: true,
isScrollingResetDelay: 100,
paddingStart: 16,
paddingEnd: 16,
gap,
getItemKey: (index) => {
const item = items[index]
return item ? getItemKey(item, index) : index
},
})
// Pre-compute the three possible padding styles so every render reuses
// the same object references (avoids allocating per-item on each frame).
const styles = useMemo(() => {
const halfGap = gap / 2
return {
only: { paddingTop: padding, paddingBottom: padding } as CSSProperties,
first: { paddingTop: padding, paddingBottom: halfGap } as CSSProperties,
middle: { paddingTop: halfGap, paddingBottom: halfGap } as CSSProperties,
last: { paddingTop: halfGap, paddingBottom: padding } as CSSProperties,
}
}, [gap, padding])
// Adjust scroll position when items above the viewport are measured
// differently from their estimates — prevents blank gaps when scrolling up
useEffect(() => {
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (
item,
_delta,
instance
) => item.start < (instance.scrollOffset ?? 0)
}, [virtualizer])
const renderVirtualRow = useCallback(
(virtualItem: ReturnType<typeof virtualizer.getVirtualItems>[number]) => {
const item = items[virtualItem.index]
if (!item) return null
return (
<div
key={virtualItem.key}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
className="absolute left-0 top-0 w-full bg-background"
style={{
transform: `translate3d(0, ${virtualItem.start}px, 0)`,
willChange: "transform",
}}
>
<div className={cn("mx-auto max-w-3xl px-4", className)}>
{renderItem(item, virtualItem.index)}
</div>
</div>
)
},
[className, items, renderItem, virtualizer]
)
const itemStyle = (index: number, total: number) => {
if (total === 1) return styles.only
if (index === 0) return styles.first
if (index === total - 1) return styles.last
return styles.middle
}
return (
<MessageThreadContent
className={cn("mx-0 max-w-none p-0", contentClassName)}
scrollClassName="[overflow-anchor:none]"
{...contentProps}
>
{items.length === 0 ? (
(emptyState ?? null)
) : (
<div
className="relative w-full"
style={{
height: `${virtualizer.getTotalSize()}px`,
}}
<Virtualizer
scrollRef={scrollRef as unknown as RefObject<HTMLElement | null>}
itemSize={itemSize}
>
{virtualizer.getVirtualItems().map(renderVirtualRow)}
</div>
{items.map((item, index) => (
<div
key={getItemKey(item, index)}
style={itemStyle(index, items.length)}
>
<div className={cn("mx-auto max-w-3xl px-4", className)}>
{renderItem(item, index)}
</div>
</div>
))}
</Virtualizer>
)}
</MessageThreadContent>
)