perf: improve drag performance for large profile lists

This commit is contained in:
wonfen
2026-07-17 03:08:22 +08:00
parent f6440175d8
commit bdeaf2a062
4 changed files with 251 additions and 157 deletions
+1
View File
@@ -65,6 +65,7 @@
- 优化页面不可见时的 Mihomo WebSocket 订阅,减少后台资源占用
- 代理组支持分别恢复普通模式和链式代理模式的滚动位置
- 重构改进订阅配置切换逻辑
- 优化大量订阅卡片时的拖拽排序性能
</details>
+100 -153
View File
@@ -1,5 +1,7 @@
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import type {
DraggableAttributes,
DraggableSyntheticListeners,
} from '@dnd-kit/core'
import {
CheckBoxOutlineBlankRounded,
CheckBoxRounded,
@@ -16,11 +18,17 @@ import {
MenuItem,
Typography,
} from '@mui/material'
import { listen } from '@tauri-apps/api/event'
import { open } from '@tauri-apps/plugin-shell'
import { useLockFn } from 'ahooks'
import dayjs from 'dayjs'
import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
import {
memo,
useCallback,
useEffect,
useReducer,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { BaseDialog } from '@/components/base'
@@ -49,8 +57,7 @@ const round = keyframes`
to { transform: rotate(360deg); }
`
interface Props {
id: string
export interface ProfileItemProps {
selected: boolean
activating: boolean
itemData: IProfileItem
@@ -62,11 +69,15 @@ interface Props {
batchMode?: boolean
isSelected?: boolean
onSelectionChange?: () => void
timerUpdateRevision: number
completedUpdateRevision: number
dragHandleRef: (node: HTMLElement | null) => void
dragHandleAttributes: DraggableAttributes
dragHandleListeners: DraggableSyntheticListeners
}
export const ProfileItem = (props: Props) => {
const ProfileItemBase = (props: ProfileItemProps) => {
const {
id,
selected,
activating,
itemData,
@@ -78,17 +89,12 @@ export const ProfileItem = (props: Props) => {
batchMode,
isSelected,
onSelectionChange,
timerUpdateRevision,
completedUpdateRevision,
dragHandleRef,
dragHandleAttributes,
dragHandleListeners,
} = props
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id,
})
const { t } = useTranslation()
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
@@ -121,71 +127,75 @@ export const ProfileItem = (props: Props) => {
const { uid, name = 'Profile', extra, updated = 0, option } = itemData
// 获取下次更新时间的函数
const fetchNextUpdateTime = useLockFn(async (forceRefresh = false) => {
if (
itemData.option?.update_interval &&
itemData.option.update_interval > 0
) {
try {
debugLog(`尝试获取配置 ${itemData.uid} 的下次更新时间`)
const fetchNextUpdateTimeCallback = useCallback(
async (forceRefresh = false) => {
if (
itemData.option?.update_interval &&
itemData.option.update_interval > 0
) {
try {
debugLog(`尝试获取配置 ${itemData.uid} 的下次更新时间`)
// 如果需要强制刷新,先触发Timer.refresh()
if (forceRefresh) {
// 这里可以通过一个新的API来触发刷新,但目前我们依赖patch_profile中的刷新
debugLog(`强制刷新定时器任务`)
}
// 如果需要强制刷新,先触发Timer.refresh()
if (forceRefresh) {
// 这里可以通过一个新的API来触发刷新,但目前我们依赖patch_profile中的刷新
debugLog(`强制刷新定时器任务`)
}
const nextUpdate = await getNextUpdateTime(itemData.uid)
debugLog(`获取到下次更新时间结果:`, nextUpdate)
const nextUpdate = await getNextUpdateTime(itemData.uid)
debugLog(`获取到下次更新时间结果:`, nextUpdate)
if (nextUpdate) {
const nextUpdateDate = dayjs(nextUpdate * 1000)
const now = dayjs()
if (nextUpdate) {
const nextUpdateDate = dayjs(nextUpdate * 1000)
const now = dayjs()
// 如果已经过期,显示"更新失败"
if (nextUpdateDate.isBefore(now)) {
setNextUpdateTime(
t('profiles.components.profileItem.status.lastUpdateFailed'),
)
} else {
// 否则显示剩余时间
const diffMinutes = nextUpdateDate.diff(now, 'minute')
// 如果已经过期,显示"更新失败"
if (nextUpdateDate.isBefore(now)) {
setNextUpdateTime(
t('profiles.components.profileItem.status.lastUpdateFailed'),
)
} else {
// 否则显示剩余时间
const diffMinutes = nextUpdateDate.diff(now, 'minute')
if (diffMinutes < 60) {
if (diffMinutes <= 0) {
setNextUpdateTime(
`${t('profiles.components.profileItem.status.nextUp')} <1m`,
)
if (diffMinutes < 60) {
if (diffMinutes <= 0) {
setNextUpdateTime(
`${t('profiles.components.profileItem.status.nextUp')} <1m`,
)
} else {
setNextUpdateTime(
`${t('profiles.components.profileItem.status.nextUp')} ${diffMinutes}m`,
)
}
} else {
const hours = Math.floor(diffMinutes / 60)
const mins = diffMinutes % 60
setNextUpdateTime(
`${t('profiles.components.profileItem.status.nextUp')} ${diffMinutes}m`,
`${t('profiles.components.profileItem.status.nextUp')} ${hours}h ${mins}m`,
)
}
} else {
const hours = Math.floor(diffMinutes / 60)
const mins = diffMinutes % 60
setNextUpdateTime(
`${t('profiles.components.profileItem.status.nextUp')} ${hours}h ${mins}m`,
)
}
} else {
debugLog(`返回的下次更新时间为空`)
setNextUpdateTime(
t('profiles.components.profileItem.status.noSchedule'),
)
}
} else {
debugLog(`返回的下次更新时间为空`)
setNextUpdateTime(
t('profiles.components.profileItem.status.noSchedule'),
)
} catch (err) {
console.error(`获取下次更新时间出错:`, err)
setNextUpdateTime(t('profiles.components.profileItem.status.unknown'))
}
} catch (err) {
console.error(`获取下次更新时间出错:`, err)
setNextUpdateTime(t('profiles.components.profileItem.status.unknown'))
} else {
debugLog(`该配置未设置更新间隔或间隔为0`)
setNextUpdateTime(
t('profiles.components.profileItem.status.autoUpdateDisabled'),
)
}
} else {
debugLog(`该配置未设置更新间隔或间隔为0`)
setNextUpdateTime(
t('profiles.components.profileItem.status.autoUpdateDisabled'),
)
}
})
},
[itemData.option?.update_interval, itemData.uid, t],
)
const fetchNextUpdateTime = useLockFn(fetchNextUpdateTimeCallback)
// 切换显示模式的函数
const toggleUpdateTimeDisplay = (e: React.MouseEvent) => {
@@ -214,40 +224,28 @@ export const ProfileItem = (props: Props) => {
updated,
])
// 订阅定时器更新事件
// 页面统一订阅定时器事件,这里只响应当前配置的更新信号
useEffect(() => {
let disposed = false
let unlistenTimerUpdate: (() => void) | undefined
if (timerUpdateRevision === 0 || !showNextUpdateRef.current) return
listen<string>('verge://timer-updated', ({ payload: updatedUid }) => {
// 只有当更新的是当前配置时才刷新显示
if (updatedUid === itemData.uid && showNextUpdateRef.current) {
debugLog(`收到定时器更新事件: uid=${updatedUid}`)
if (refreshTimeoutRef.current !== undefined) {
clearTimeout(refreshTimeoutRef.current)
}
refreshTimeoutRef.current = window.setTimeout(() => {
fetchNextUpdateTime(true)
}, 1000)
}
})
.then((unlisten) => {
if (disposed) {
unlisten()
return
}
unlistenTimerUpdate = unlisten
})
.catch(console.error)
if (refreshTimeoutRef.current !== undefined) {
clearTimeout(refreshTimeoutRef.current)
}
refreshTimeoutRef.current = window.setTimeout(() => {
fetchNextUpdateTime(true)
}, 1000)
return () => {
disposed = true
if (refreshTimeoutRef.current !== undefined) {
clearTimeout(refreshTimeoutRef.current)
}
unlistenTimerUpdate?.()
}
}, [fetchNextUpdateTime, itemData.uid])
}, [fetchNextUpdateTime, timerUpdateRevision])
useEffect(() => {
if (completedUpdateRevision === 0 || !showNextUpdateRef.current) return
fetchNextUpdateTime()
}, [completedUpdateRevision, fetchNextUpdateTime])
// local file mode
// remote file mode
@@ -605,52 +603,6 @@ export const ProfileItem = (props: Props) => {
justifyContent: 'space-between',
}
// 监听自动更新事件
useEffect(() => {
let disposed = false
let unlisteners: Array<() => void> = []
Promise.allSettled([
listen<{ uid?: string }>('profile-update-started', ({ payload }) => {
if (payload.uid === itemData.uid) {
setLoading(true)
}
}),
listen<{ uid?: string }>('profile-update-completed', ({ payload }) => {
if (payload.uid !== itemData.uid) {
return
}
setLoading(false)
// 刷新 profile 数据以获取最新的 updated 时间戳
void mutateProfiles()
// 更新完成后刷新显示
if (showNextUpdateRef.current) {
fetchNextUpdateTime()
}
}),
]).then((results) => {
const registeredUnlisteners = results.flatMap((result) =>
result.status === 'fulfilled' ? [result.value] : [],
)
if (disposed || results.some((result) => result.status === 'rejected')) {
registeredUnlisteners.forEach((unlisten) => unlisten())
results.forEach((result) => {
if (result.status === 'rejected') console.error(result.reason)
})
return
}
unlisteners = registeredUnlisteners
})
return () => {
disposed = true
unlisteners.forEach((unlisten) => unlisten())
}
}, [fetchNextUpdateTime, itemData.uid, mutateProfiles, setLoading])
const handleSaveProfileDocument = useLockFn(async () => {
const currentValue = profileDocument.value
if (!(await saveProfileFile(uid, currentValue))) {
@@ -684,14 +636,7 @@ export const ProfileItem = (props: Props) => {
})
return (
<Box
sx={{
position: 'relative',
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 'calc(infinity)' : undefined,
}}
>
<Box sx={{ position: 'relative' }}>
<ProfileBox
aria-selected={selected}
onClick={(e) => {
@@ -756,14 +701,14 @@ export const ProfileItem = (props: Props) => {
</IconButton>
)}
<Box
ref={setNodeRef}
ref={dragHandleRef}
sx={{
display: 'flex',
margin: 'auto 0',
...(batchMode && { marginLeft: '-4px' }),
}}
{...attributes}
{...listeners}
{...dragHandleAttributes}
{...dragHandleListeners}
>
<DragIndicatorRounded
sx={[
@@ -1041,6 +986,8 @@ export const ProfileItem = (props: Props) => {
)
}
export const ProfileItem = memo(ProfileItemBase)
function parseUrl(url?: string) {
if (!url) return ''
const regex = /https?:\/\/(.+?)\//
@@ -0,0 +1,44 @@
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Box } from '@mui/material'
import { ProfileItem, type ProfileItemProps } from './profile-item'
type SortableProfileItemProps = Omit<
ProfileItemProps,
'dragHandleRef' | 'dragHandleAttributes' | 'dragHandleListeners'
> & {
id: string
}
export const SortableProfileItem = ({
id,
...profileItemProps
}: SortableProfileItemProps) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id })
return (
<Box
sx={{
position: 'relative',
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 'calc(infinity)' : undefined,
}}
>
<ProfileItem
{...profileItemProps}
dragHandleRef={setNodeRef}
dragHandleAttributes={attributes}
dragHandleListeners={listeners}
/>
</Box>
)
}
+106 -4
View File
@@ -8,7 +8,11 @@ import {
useSensor,
useSensors,
} from '@dnd-kit/core'
import { SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable'
import {
SortableContext,
sortableKeyboardCoordinates,
type SortingStrategy,
} from '@dnd-kit/sortable'
import {
CheckBoxOutlineBlankRounded,
CheckBoxRounded,
@@ -21,7 +25,7 @@ import {
TextSnippetOutlined,
} from '@mui/icons-material'
import { Box, Button, Divider, Grid, IconButton, Stack } from '@mui/material'
import { TauriEvent } from '@tauri-apps/api/event'
import { listen, TauriEvent } from '@tauri-apps/api/event'
import { readText } from '@tauri-apps/plugin-clipboard-manager'
import { readTextFile } from '@tauri-apps/plugin-fs'
import { useLockFn } from 'ahooks'
@@ -36,12 +40,12 @@ import {
BaseStyledTextField,
type DialogRef,
} from '@/components/base'
import { ProfileItem } from '@/components/profile/profile-item'
import { ProfileMore } from '@/components/profile/profile-more'
import {
ProfileViewer,
type ProfileViewerRef,
} from '@/components/profile/profile-viewer'
import { SortableProfileItem } from '@/components/profile/sortable-profile-item'
import { ConfigViewer } from '@/components/setting/mods/config-viewer'
import { useListen } from '@/hooks/use-listen'
import { useProfiles } from '@/hooks/use-profiles'
@@ -73,6 +77,43 @@ import { debugLog } from '@/utils/debug'
const PROFILE_UPDATE_WORKER_LIMIT = 8
const PROFILE_SWITCH_LOADING_DELAY = 400
// Equivalent to rectSortingStrategy without copying the full rect array for every item.
const profileRectSortingStrategy: SortingStrategy = ({
rects,
activeIndex,
overIndex,
index,
}) => {
let newIndex = index
if (index === activeIndex) {
newIndex = overIndex
} else if (
activeIndex < overIndex &&
index > activeIndex &&
index <= overIndex
) {
newIndex = index - 1
} else if (
activeIndex > overIndex &&
index >= overIndex &&
index < activeIndex
) {
newIndex = index + 1
}
const oldRect = rects[index]
const newRect = rects[newIndex]
if (!oldRect || !newRect) return null
return {
x: newRect.left - oldRect.left,
y: newRect.top - oldRect.top,
scaleX: newRect.width / oldRect.width,
scaleY: newRect.height / oldRect.height,
}
}
interface ProfileSwitchRequest {
profile: string
notifySuccess: boolean
@@ -97,6 +138,12 @@ const ProfilePage = () => {
string | null
>(null)
const [loading, setLoading] = useState(false)
const [timerUpdateRevisions, setTimerUpdateRevisions] = useState<
Map<string, number>
>(() => new Map())
const [completedUpdateRevisions, setCompletedUpdateRevisions] = useState<
Map<string, number>
>(() => new Map())
// Batch selection states
const [batchMode, setBatchMode] = useState(false)
@@ -536,6 +583,54 @@ const ProfilePage = () => {
},
[setLoadingCache],
)
useEffect(() => {
let disposed = false
let unlisteners: Array<() => void> = []
Promise.allSettled([
listen<{ uid?: string }>('profile-update-started', ({ payload }) => {
if (payload.uid) setLoadingProfiles([payload.uid], true)
}),
listen<{ uid?: string }>('profile-update-completed', ({ payload }) => {
const { uid } = payload
if (!uid) return
setLoadingProfiles([uid], false)
setCompletedUpdateRevisions((current) => {
const next = new Map(current)
next.set(uid, (next.get(uid) ?? 0) + 1)
return next
})
void mutateProfiles()
}),
listen<string>('verge://timer-updated', ({ payload: uid }) => {
setTimerUpdateRevisions((current) => {
const next = new Map(current)
next.set(uid, (next.get(uid) ?? 0) + 1)
return next
})
}),
]).then((results) => {
const registeredUnlisteners = results.flatMap((result) =>
result.status === 'fulfilled' ? [result.value] : [],
)
results.forEach((result) => {
if (result.status === 'rejected') console.error(result.reason)
})
if (disposed) {
registeredUnlisteners.forEach((unlisten) => unlisten())
} else {
unlisteners = registeredUnlisteners
}
})
return () => {
disposed = true
unlisteners.forEach((unlisten) => unlisten())
}
}, [mutateProfiles, setLoadingProfiles])
const runProfileUpdates = useCallback(
async (uids: string[]) => {
if (uids.length === 0) return
@@ -889,13 +984,14 @@ const ProfilePage = () => {
<Box sx={{ mb: 1.5 }}>
<Grid container spacing={{ xs: 1, lg: 1 }}>
<SortableContext
strategy={profileRectSortingStrategy}
items={profileItems.map((x) => {
return x.uid
})}
>
{profileItems.map((item) => (
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={item.file}>
<ProfileItem
<SortableProfileItem
id={item.uid}
selected={(switchTarget ?? profiles.current) === item.uid}
activating={
@@ -903,6 +999,12 @@ const ProfilePage = () => {
visibleSwitchingProfile === item.uid
}
itemData={item}
timerUpdateRevision={
timerUpdateRevisions.get(item.uid) ?? 0
}
completedUpdateRevision={
completedUpdateRevisions.get(item.uid) ?? 0
}
mutateProfiles={mutateProfiles}
onSelect={(f) => onSelect(item.uid, f)}
onEdit={() => viewerRef.current?.edit(item)}