From bdeaf2a062503f1dce36646b191ab199bcb47b73 Mon Sep 17 00:00:00 2001 From: wonfen Date: Fri, 17 Jul 2026 03:08:22 +0800 Subject: [PATCH] perf: improve drag performance for large profile lists --- Changelog.md | 1 + src/components/profile/profile-item.tsx | 253 +++++++----------- .../profile/sortable-profile-item.tsx | 44 +++ src/pages/profiles.tsx | 110 +++++++- 4 files changed, 251 insertions(+), 157 deletions(-) create mode 100644 src/components/profile/sortable-profile-item.tsx diff --git a/Changelog.md b/Changelog.md index 59fa3efa2..de27ab89b 100644 --- a/Changelog.md +++ b/Changelog.md @@ -65,6 +65,7 @@ - 优化页面不可见时的 Mihomo WebSocket 订阅,减少后台资源占用 - 代理组支持分别恢复普通模式和链式代理模式的滚动位置 - 重构改进订阅配置切换逻辑 +- 优化大量订阅卡片时的拖拽排序性能 diff --git a/src/components/profile/profile-item.tsx b/src/components/profile/profile-item.tsx index 5af44837d..dd962b8bd 100644 --- a/src/components/profile/profile-item.tsx +++ b/src/components/profile/profile-item.tsx @@ -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(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('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 ( - + { @@ -756,14 +701,14 @@ export const ProfileItem = (props: Props) => { )} { ) } +export const ProfileItem = memo(ProfileItemBase) + function parseUrl(url?: string) { if (!url) return '' const regex = /https?:\/\/(.+?)\// diff --git a/src/components/profile/sortable-profile-item.tsx b/src/components/profile/sortable-profile-item.tsx new file mode 100644 index 000000000..4b53a26cb --- /dev/null +++ b/src/components/profile/sortable-profile-item.tsx @@ -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 ( + + + + ) +} diff --git a/src/pages/profiles.tsx b/src/pages/profiles.tsx index 0e8179c0d..e4d8ea26f 100644 --- a/src/pages/profiles.tsx +++ b/src/pages/profiles.tsx @@ -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 + >(() => new Map()) + const [completedUpdateRevisions, setCompletedUpdateRevisions] = useState< + Map + >(() => 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('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 = () => { { return x.uid })} > {profileItems.map((item) => ( - { 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)}