refactor: migrate react-virtuoso to @tanstack/react-virtual

This commit is contained in:
Tunglies
2026-04-03 17:13:13 +08:00
parent a73fafaf9f
commit d6d15652ca
11 changed files with 516 additions and 434 deletions
+1
View File
@@ -14,3 +14,4 @@ export { BaseStyledSelect } from './base-styled-select'
export { BaseStyledTextField } from './base-styled-text-field'
export { Switch } from './base-switch'
export { TooltipIcon } from './base-tooltip-icon'
export { VirtualList, type VirtualListHandle } from './virtual-list'
+97
View File
@@ -0,0 +1,97 @@
import { useVirtualizer } from '@tanstack/react-virtual'
import {
CSSProperties,
forwardRef,
ReactNode,
useEffect,
useImperativeHandle,
useRef,
} from 'react'
export interface VirtualListHandle {
scrollToIndex: (
index: number,
options?: {
align?: 'start' | 'center' | 'end' | 'auto'
behavior?: ScrollBehavior
},
) => void
scrollTo: (options: ScrollToOptions) => void
}
interface VirtualListProps {
count: number
estimateSize: number
overscan?: number
getItemKey?: (index: number) => React.Key
renderItem: (index: number) => ReactNode
style?: CSSProperties
footer?: number
onScroll?: (e: Event) => void
}
export const VirtualList = forwardRef<VirtualListHandle, VirtualListProps>(
(
{
count,
estimateSize,
overscan = 5,
getItemKey,
renderItem,
style,
footer,
onScroll,
},
ref,
) => {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count,
getScrollElement: () => parentRef.current,
estimateSize: () => estimateSize,
overscan,
getItemKey,
})
useEffect(() => {
const el = parentRef.current
if (!el || !onScroll) return
el.addEventListener('scroll', onScroll, { passive: true })
return () => el.removeEventListener('scroll', onScroll)
}, [onScroll])
useImperativeHandle(ref, () => ({
scrollToIndex: (index, options) =>
virtualizer.scrollToIndex(index, options),
scrollTo: (options) => parentRef.current?.scrollTo(options),
}))
return (
<div ref={parentRef} style={{ ...style, overflow: 'auto' }}>
<div
style={{ height: virtualizer.getTotalSize(), position: 'relative' }}
>
{virtualizer.getVirtualItems().map((vi) => (
<div
key={vi.key}
data-index={vi.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
}}
>
{renderItem(vi.index)}
</div>
))}
{footer != null && <div style={{ height: footer }} />}
</div>
</div>
)
},
)
VirtualList.displayName = 'VirtualList'
+92 -101
View File
@@ -43,9 +43,8 @@ import {
} from 'react'
import { Controller, useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { Virtuoso } from 'react-virtuoso'
import { BaseSearchBox, Switch } from '@/components/base'
import { BaseSearchBox, Switch, VirtualList } from '@/components/base'
import { GroupItem } from '@/components/profile/group-item'
import {
getNetworkInterfaces,
@@ -185,6 +184,92 @@ export const GroupsEditorViewer = (props: Props) => {
[appendSeq, match],
)
const renderItem = (index: number): React.ReactNode => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x.name
})}
>
{filteredPrependSeq.map((item) => {
return (
<GroupItem
key={item.name}
type="prepend"
group={item}
onDelete={() => {
setPrependSeq(
prependSeq.filter((v) => v.name !== item.name),
)
}}
/>
)
})}
</SortableContext>
</DndContext>
)
} else if (index < filteredGroupList.length + shift) {
const newIndex = index - shift
return (
<GroupItem
key={filteredGroupList[newIndex].name}
type={
deleteSeq.includes(filteredGroupList[newIndex].name)
? 'delete'
: 'original'
}
group={filteredGroupList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredGroupList[newIndex].name)) {
setDeleteSeq(
deleteSeq.filter((v) => v !== filteredGroupList[newIndex].name),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredGroupList[newIndex].name,
])
}
}}
/>
)
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x.name
})}
>
{filteredAppendSeq.map((item) => {
return (
<GroupItem
key={item.name}
type="append"
group={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
)
})}
</SortableContext>
</DndContext>
)
}
}
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
@@ -1002,109 +1087,15 @@ export const GroupsEditorViewer = (props: Props) => {
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<Virtuoso
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
totalCount={
<VirtualList
count={
filteredGroupList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0)
}
increaseViewportBy={256}
itemContent={(index) => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x.name
})}
>
{filteredPrependSeq.map((item) => {
return (
<GroupItem
key={item.name}
type="prepend"
group={item}
onDelete={() => {
setPrependSeq(
prependSeq.filter(
(v) => v.name !== item.name,
),
)
}}
/>
)
})}
</SortableContext>
</DndContext>
)
} else if (index < filteredGroupList.length + shift) {
const newIndex = index - shift
return (
<GroupItem
key={filteredGroupList[newIndex].name}
type={
deleteSeq.includes(filteredGroupList[newIndex].name)
? 'delete'
: 'original'
}
group={filteredGroupList[newIndex]}
onDelete={() => {
if (
deleteSeq.includes(filteredGroupList[newIndex].name)
) {
setDeleteSeq(
deleteSeq.filter(
(v) => v !== filteredGroupList[newIndex].name,
),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredGroupList[newIndex].name,
])
}
}}
/>
)
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x.name
})}
>
{filteredAppendSeq.map((item) => {
return (
<GroupItem
key={item.name}
type="append"
group={item}
onDelete={() => {
setAppendSeq(
appendSeq.filter(
(v) => v.name !== item.name,
),
)
}}
/>
)
})}
</SortableContext>
</DndContext>
)
}
}}
estimateSize={56}
renderItem={renderItem}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</List>
</>
+92 -101
View File
@@ -35,9 +35,8 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { Virtuoso } from 'react-virtuoso'
import { BaseSearchBox } from '@/components/base'
import { BaseSearchBox, VirtualList } from '@/components/base'
import { ProxyItem } from '@/components/profile/proxy-item'
import { readProfileFile, saveProfileFile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
@@ -81,6 +80,92 @@ export const ProxiesEditorViewer = (props: Props) => {
[appendSeq, match],
)
const renderItem = (index: number): React.ReactNode => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x.name
})}
>
{filteredPrependSeq.map((item) => {
return (
<ProxyItem
key={item.name}
type="prepend"
proxy={item}
onDelete={() => {
setPrependSeq(
prependSeq.filter((v) => v.name !== item.name),
)
}}
/>
)
})}
</SortableContext>
</DndContext>
)
} else if (index < filteredProxyList.length + shift) {
const newIndex = index - shift
return (
<ProxyItem
key={filteredProxyList[newIndex].name}
type={
deleteSeq.includes(filteredProxyList[newIndex].name)
? 'delete'
: 'original'
}
proxy={filteredProxyList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredProxyList[newIndex].name)) {
setDeleteSeq(
deleteSeq.filter((v) => v !== filteredProxyList[newIndex].name),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredProxyList[newIndex].name,
])
}
}}
/>
)
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x.name
})}
>
{filteredAppendSeq.map((item) => {
return (
<ProxyItem
key={item.name}
type="append"
proxy={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
)
})}
</SortableContext>
</DndContext>
)
}
}
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
@@ -366,109 +451,15 @@ export const ProxiesEditorViewer = (props: Props) => {
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<Virtuoso
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
totalCount={
<VirtualList
count={
filteredProxyList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0)
}
increaseViewportBy={256}
itemContent={(index) => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x.name
})}
>
{filteredPrependSeq.map((item) => {
return (
<ProxyItem
key={item.name}
type="prepend"
proxy={item}
onDelete={() => {
setPrependSeq(
prependSeq.filter(
(v) => v.name !== item.name,
),
)
}}
/>
)
})}
</SortableContext>
</DndContext>
)
} else if (index < filteredProxyList.length + shift) {
const newIndex = index - shift
return (
<ProxyItem
key={filteredProxyList[newIndex].name}
type={
deleteSeq.includes(filteredProxyList[newIndex].name)
? 'delete'
: 'original'
}
proxy={filteredProxyList[newIndex]}
onDelete={() => {
if (
deleteSeq.includes(filteredProxyList[newIndex].name)
) {
setDeleteSeq(
deleteSeq.filter(
(v) => v !== filteredProxyList[newIndex].name,
),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredProxyList[newIndex].name,
])
}
}}
/>
)
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x.name
})}
>
{filteredAppendSeq.map((item) => {
return (
<ProxyItem
key={item.name}
type="append"
proxy={item}
onDelete={() => {
setAppendSeq(
appendSeq.filter(
(v) => v.name !== item.name,
),
)
}}
/>
)
})}
</SortableContext>
</DndContext>
)
}
}}
estimateSize={56}
renderItem={renderItem}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</List>
</>
+87 -95
View File
@@ -37,9 +37,8 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { Virtuoso } from 'react-virtuoso'
import { BaseSearchBox, Switch } from '@/components/base'
import { BaseSearchBox, Switch, VirtualList } from '@/components/base'
import { RuleItem } from '@/components/profile/rule-item'
import { readProfileFile, saveProfileFile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
@@ -283,6 +282,87 @@ export const RulesEditorViewer = (props: Props) => {
[appendSeq, match],
)
const renderItem = (index: number): React.ReactNode => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x
})}
>
{filteredPrependSeq.map((item) => {
return (
<RuleItem
key={item}
type="prepend"
ruleRaw={item}
onDelete={() => {
setPrependSeq(prependSeq.filter((v) => v !== item))
}}
/>
)
})}
</SortableContext>
</DndContext>
)
} else if (index < filteredRuleList.length + shift) {
const newIndex = index - shift
return (
<RuleItem
key={filteredRuleList[newIndex]}
type={
deleteSeq.includes(filteredRuleList[newIndex])
? 'delete'
: 'original'
}
ruleRaw={filteredRuleList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredRuleList[newIndex])) {
setDeleteSeq(
deleteSeq.filter((v) => v !== filteredRuleList[newIndex]),
)
} else {
setDeleteSeq((prev) => [...prev, filteredRuleList[newIndex]])
}
}}
/>
)
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x
})}
>
{filteredAppendSeq.map((item) => {
return (
<RuleItem
key={item}
type="append"
ruleRaw={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v !== item))
}}
/>
)
})}
</SortableContext>
</DndContext>
)
}
}
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
@@ -672,103 +752,15 @@ export const RulesEditorViewer = (props: Props) => {
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<Virtuoso
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
totalCount={
<VirtualList
count={
filteredRuleList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0)
}
increaseViewportBy={256}
itemContent={(index) => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x
})}
>
{filteredPrependSeq.map((item) => {
return (
<RuleItem
key={item}
type="prepend"
ruleRaw={item}
onDelete={() => {
setPrependSeq(
prependSeq.filter((v) => v !== item),
)
}}
/>
)
})}
</SortableContext>
</DndContext>
)
} else if (index < filteredRuleList.length + shift) {
const newIndex = index - shift
return (
<RuleItem
key={filteredRuleList[newIndex]}
type={
deleteSeq.includes(filteredRuleList[newIndex])
? 'delete'
: 'original'
}
ruleRaw={filteredRuleList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredRuleList[newIndex])) {
setDeleteSeq(
deleteSeq.filter(
(v) => v !== filteredRuleList[newIndex],
),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredRuleList[newIndex],
])
}
}}
/>
)
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x
})}
>
{filteredAppendSeq.map((item) => {
return (
<RuleItem
key={item}
type="append"
ruleRaw={item}
onDelete={() => {
setAppendSeq(
appendSeq.filter((v) => v !== item),
)
}}
/>
)
})}
</SortableContext>
</DndContext>
)
}
}}
estimateSize={56}
renderItem={renderItem}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</List>
</>
+91 -77
View File
@@ -9,10 +9,10 @@ import {
Snackbar,
Typography,
} from '@mui/material'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { delayGroup, healthcheckProxyProvider } from 'tauri-plugin-mihomo-api'
import { BaseEmpty } from '@/components/base'
@@ -46,8 +46,6 @@ interface ProxyChainItem {
delay?: number
}
const VirtuosoFooter = () => <div style={{ height: '8px' }} />
export const ProxyGroups = (props: Props) => {
const { t } = useTranslation()
const { mode, isChainMode = false, chainConfigData } = props
@@ -129,10 +127,17 @@ export const ProxyGroups = (props: Props) => {
const timeout = verge?.default_latency_timeout || 10000
const virtuosoRef = useRef<VirtuosoHandle>(null)
const parentRef = useRef<HTMLDivElement>(null)
const scrollPositionRef = useRef<Record<string, number>>({})
const [showScrollTop, setShowScrollTop] = useState(false)
const scrollerRef = useRef<Element | null>(null)
const virtualizer = useVirtualizer({
count: renderList.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 56,
overscan: 15,
getItemKey: (index) => renderList[index]?.key ?? index,
})
// 从 localStorage 恢复滚动位置
useEffect(() => {
@@ -149,10 +154,9 @@ export const ProxyGroups = (props: Props) => {
if (savedPosition !== undefined) {
restoreTimer = setTimeout(() => {
virtuosoRef.current?.scrollTo({
top: savedPosition,
behavior: 'auto',
})
if (parentRef.current) {
parentRef.current.scrollTop = savedPosition
}
}, 100)
}
}
@@ -198,7 +202,7 @@ export const ProxyGroups = (props: Props) => {
// 添加和清理滚动事件监听器
useEffect(() => {
const node = scrollerRef.current
const node = parentRef.current
if (!node) return
const listener = handleScroll as EventListener
@@ -213,7 +217,7 @@ export const ProxyGroups = (props: Props) => {
// 滚动到顶部
const scrollToTop = useCallback(() => {
virtuosoRef.current?.scrollTo?.({
parentRef.current?.scrollTo?.({
top: 0,
behavior: 'smooth',
})
@@ -362,11 +366,7 @@ export const ProxyGroups = (props: Props) => {
)
if (index >= 0) {
virtuosoRef.current?.scrollToIndex?.({
index,
align: 'center',
behavior: 'smooth',
})
virtualizer.scrollToIndex(index, { align: 'center', behavior: 'smooth' })
}
}
@@ -378,14 +378,10 @@ export const ProxyGroups = (props: Props) => {
)
if (index >= 0) {
virtuosoRef.current?.scrollToIndex?.({
index,
align: 'start',
behavior: 'smooth',
})
virtualizer.scrollToIndex(index, { align: 'start', behavior: 'smooth' })
}
},
[renderList],
[renderList, virtualizer],
)
const proxyGroupNames = useMemo(() => {
@@ -475,39 +471,49 @@ export const ProxyGroups = (props: Props) => {
</Box>
)}
<Virtuoso
ref={virtuosoRef}
<div
ref={parentRef}
style={{
height:
mode === 'rule' && proxyGroups.length > 0
? 'calc(100% - 80px)' // 只有标题的高度
: 'calc(100% - 14px)',
overflow: 'auto',
}}
totalCount={renderList.length}
increaseViewportBy={{ top: 200, bottom: 200 }}
overscan={150}
defaultItemHeight={56}
scrollerRef={(ref) => {
scrollerRef.current = ref as Element
}}
components={{
Footer: VirtuosoFooter,
}}
initialScrollTop={scrollPositionRef.current[mode]}
computeItemKey={(index) => renderList[index].key}
itemContent={(index) => (
<ProxyRender
key={renderList[index].key}
item={renderList[index]}
indent={mode === 'rule' || mode === 'script'}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={onHeadState}
onChangeProxy={handleChangeProxy}
isChainMode={isChainMode}
/>
)}
/>
>
<div
style={{
height: virtualizer.getTotalSize(),
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ProxyRender
item={renderList[virtualItem.index]}
indent={mode === 'rule' || mode === 'script'}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={onHeadState}
onChangeProxy={handleChangeProxy}
isChainMode={isChainMode}
/>
</div>
))}
<div style={{ height: 8 }} />
</div>
</div>
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
</Box>
@@ -603,34 +609,42 @@ export const ProxyGroups = (props: Props) => {
/>
)}
<Virtuoso
ref={virtuosoRef}
style={{ height: 'calc(100% - 14px)' }}
totalCount={renderList.length}
increaseViewportBy={{ top: 200, bottom: 200 }}
overscan={150}
defaultItemHeight={56}
scrollerRef={(ref) => {
scrollerRef.current = ref as Element
}}
components={{
Footer: VirtuosoFooter,
}}
// 添加平滑滚动设置
initialScrollTop={scrollPositionRef.current[mode]}
computeItemKey={(index) => renderList[index].key}
itemContent={(index) => (
<ProxyRender
key={renderList[index].key}
item={renderList[index]}
indent={mode === 'rule' || mode === 'script'}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={onHeadState}
onChangeProxy={handleChangeProxy}
/>
)}
/>
<div
ref={parentRef}
style={{ height: 'calc(100% - 14px)', overflow: 'auto' }}
>
<div
style={{
height: virtualizer.getTotalSize(),
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ProxyRender
item={renderList[virtualItem.index]}
indent={mode === 'rule' || mode === 'script'}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={onHeadState}
onChangeProxy={handleChangeProxy}
/>
</div>
))}
<div style={{ height: 8 }} />
</div>
</div>
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
</div>
)