refactor: improve proxy page performance

This commit is contained in:
ezequielnick 2025-10-04 12:44:09 +08:00
parent a7de9b2588
commit a7e769f402
2 changed files with 276 additions and 242 deletions

View File

@ -1,7 +1,6 @@
import { Button, Card, CardBody } from '@heroui/react' import { Button, Card, CardBody } from '@heroui/react'
import { mihomoUnfixedProxy } from '@renderer/utils/ipc' import { mihomoUnfixedProxy } from '@renderer/utils/ipc'
import React from 'react' import React, { useMemo, useState, useCallback } from 'react'
import { useMemo, useState } from 'react'
import { FaMapPin } from 'react-icons/fa6' import { FaMapPin } from 'react-icons/fa6'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -16,7 +15,14 @@ interface Props {
isGroupTesting?: boolean isGroupTesting?: boolean
} }
const ProxyItem: React.FC<Props> = (props) => { function delayColor(delay: number): 'primary' | 'success' | 'warning' | 'danger' {
if (delay === -1) return 'primary'
if (delay === 0) return 'danger'
if (delay < 500) return 'success'
return 'warning'
}
const ProxyItem: React.FC<Props> = React.memo((props) => {
const { t } = useTranslation() const { t } = useTranslation()
const { mutateProxies, proxyDisplayMode, group, proxy, selected, onSelect, onProxyDelay, isGroupTesting = false } = props const { mutateProxies, proxyDisplayMode, group, proxy, selected, onSelect, onProxyDelay, isGroupTesting = false } = props
@ -25,34 +31,27 @@ const ProxyItem: React.FC<Props> = (props) => {
return proxy.history[proxy.history.length - 1].delay return proxy.history[proxy.history.length - 1].delay
} }
return -1 return -1
}, [proxy]) }, [proxy.history])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const isLoading = loading || isGroupTesting const isLoading = loading || isGroupTesting
function delayColor(delay: number): 'primary' | 'success' | 'warning' | 'danger' { const delayText = useMemo(() => {
if (delay === -1) return 'primary'
if (delay === 0) return 'danger'
if (delay < 500) return 'success'
return 'warning'
}
function delayText(delay: number): string {
if (delay === -1) return t('proxies.delay.test') if (delay === -1) return t('proxies.delay.test')
if (delay === 0) return t('proxies.delay.timeout') if (delay === 0) return t('proxies.delay.timeout')
return delay.toString() return delay.toString()
} }, [delay, t])
const onDelay = (): void => { const onDelay = useCallback((): void => {
setLoading(true) setLoading(true)
onProxyDelay(proxy.name, group.testUrl).finally(() => { onProxyDelay(proxy.name, group.testUrl).finally(() => {
mutateProxies() mutateProxies()
setLoading(false) setLoading(false)
}) })
} }, [proxy.name, group.testUrl, onProxyDelay, mutateProxies])
const fixed = group.fixed && group.fixed === proxy.name const fixed = useMemo(() => group.fixed && group.fixed === proxy.name, [group.fixed, proxy.name])
return ( return (
<Card <Card
@ -118,7 +117,7 @@ const ProxyItem: React.FC<Props> = (props) => {
className="h-full text-sm ml-auto -mt-0.5 px-2 relative w-min whitespace-nowrap" className="h-full text-sm ml-auto -mt-0.5 px-2 relative w-min whitespace-nowrap"
> >
<div className="w-full h-full flex items-center justify-end"> <div className="w-full h-full flex items-center justify-end">
{delayText(delay)} {delayText}
</div> </div>
</Button> </Button>
</div> </div>
@ -156,7 +155,7 @@ const ProxyItem: React.FC<Props> = (props) => {
className="h-full text-sm px-2 relative w-min whitespace-nowrap" className="h-full text-sm px-2 relative w-min whitespace-nowrap"
> >
<div className="w-full h-full flex items-center justify-end"> <div className="w-full h-full flex items-center justify-end">
{delayText(delay)} {delayText}
</div> </div>
</Button> </Button>
</div> </div>
@ -165,6 +164,18 @@ const ProxyItem: React.FC<Props> = (props) => {
</CardBody> </CardBody>
</Card> </Card>
) )
} }, (prevProps, nextProps) => {
// 必要时重新渲染
return (
prevProps.proxy.name === nextProps.proxy.name &&
prevProps.proxy.history === nextProps.proxy.history &&
prevProps.selected === nextProps.selected &&
prevProps.proxyDisplayMode === nextProps.proxyDisplayMode &&
prevProps.group.fixed === nextProps.group.fixed &&
prevProps.isGroupTesting === nextProps.isGroupTesting
)
})
ProxyItem.displayName = 'ProxyItem'
export default ProxyItem export default ProxyItem

View File

@ -26,11 +26,11 @@ const GROUP_EXPAND_STATE_KEY = 'proxy_group_expand_state'
// 自定义 hook 用于管理展开状态 // 自定义 hook 用于管理展开状态
const useProxyState = (groups: IMihomoMixedGroup[]): { const useProxyState = (groups: IMihomoMixedGroup[]): {
virtuosoRef: React.RefObject<GroupedVirtuosoHandle>; virtuosoRef: React.RefObject<GroupedVirtuosoHandle | null>;
isOpen: boolean[]; isOpen: boolean[];
setIsOpen: React.Dispatch<React.SetStateAction<boolean[]>>; setIsOpen: React.Dispatch<React.SetStateAction<boolean[]>>;
} => { } => {
const virtuosoRef = useRef<GroupedVirtuosoHandle>(null) const virtuosoRef = useRef<GroupedVirtuosoHandle | null>(null)
// 初始化展开状态 // 初始化展开状态
const [isOpen, setIsOpen] = useState<boolean[]>(() => { const [isOpen, setIsOpen] = useState<boolean[]>(() => {
@ -76,39 +76,55 @@ const Proxies: React.FC = () => {
const [cols, setCols] = useState(1) const [cols, setCols] = useState(1)
const { virtuosoRef, isOpen, setIsOpen } = useProxyState(groups) const { virtuosoRef, isOpen, setIsOpen } = useProxyState(groups)
const [delaying, setDelaying] = useState(Array(groups.length).fill(false)) const [delaying, setDelaying] = useState(Array(groups.length).fill(false))
const [proxyDelaying, setProxyDelaying] = useState<Record<string, boolean>>({}) const [proxyDelaying, setProxyDelaying] = useState<Set<string>>(new Set())
const [searchValue, setSearchValue] = useState(Array(groups.length).fill('')) const [searchValue, setSearchValue] = useState(Array(groups.length).fill(''))
// searchValue 初始化
useEffect(() => {
if (groups.length !== searchValue.length) {
setSearchValue(Array(groups.length).fill(''))
}
}, [groups.length])
// 代理列表排序
const sortProxies = useCallback((proxies: (IMihomoProxy | IMihomoGroup)[], order: string) => {
if (order === 'delay') {
return [...proxies].sort((a, b) => {
if (a.history.length === 0) return 1
if (b.history.length === 0) return -1
const aDelay = a.history[a.history.length - 1].delay
const bDelay = b.history[b.history.length - 1].delay
if (aDelay === 0) return 1
if (bDelay === 0) return -1
return aDelay - bDelay
})
}
if (order === 'name') {
return [...proxies].sort((a, b) => a.name.localeCompare(b.name))
}
return proxies
}, [])
const { groupCounts, allProxies } = useMemo(() => { const { groupCounts, allProxies } = useMemo(() => {
const groupCounts: number[] = [] const groupCounts: number[] = []
const allProxies: (IMihomoProxy | IMihomoGroup)[][] = [] const allProxies: (IMihomoProxy | IMihomoGroup)[][] = []
if (groups.length !== searchValue.length) setSearchValue(Array(groups.length).fill(''))
groups.forEach((group, index) => { groups.forEach((group, index) => {
if (isOpen[index]) { if (isOpen[index]) {
let groupProxies = group.all.filter( const filtered = group.all.filter(
(proxy) => proxy && includesIgnoreCase(proxy.name, searchValue[index]) (proxy) => proxy && includesIgnoreCase(proxy.name, searchValue[index])
) )
const count = Math.floor(groupProxies.length / cols) const sorted = sortProxies(filtered, proxyDisplayOrder)
groupCounts.push(groupProxies.length % cols === 0 ? count : count + 1) const count = Math.ceil(sorted.length / cols)
if (proxyDisplayOrder === 'delay') { groupCounts.push(count)
groupProxies = groupProxies.sort((a, b) => { allProxies.push(sorted)
if (a.history.length === 0) return -1
if (b.history.length === 0) return 1
if (a.history[a.history.length - 1].delay === 0) return 1
if (b.history[b.history.length - 1].delay === 0) return -1
return a.history[a.history.length - 1].delay - b.history[b.history.length - 1].delay
})
}
if (proxyDisplayOrder === 'name') {
groupProxies = groupProxies.sort((a, b) => a.name.localeCompare(b.name))
}
allProxies.push(groupProxies)
} else { } else {
groupCounts.push(0) groupCounts.push(0)
allProxies.push([]) allProxies.push([])
} }
}) })
return { groupCounts, allProxies } return { groupCounts, allProxies }
}, [groups, isOpen, proxyDisplayOrder, cols, searchValue]) }, [groups, isOpen, proxyDisplayOrder, cols, searchValue, sortProxies])
const onChangeProxy = useCallback(async (group: string, proxy: string): Promise<void> => { const onChangeProxy = useCallback(async (group: string, proxy: string): Promise<void> => {
await mihomoChangeProxy(group, proxy) await mihomoChangeProxy(group, proxy)
@ -136,14 +152,12 @@ const Proxies: React.FC = () => {
return newDelaying return newDelaying
}) })
// 本组测试状态 // 管理测试状态
const groupProxies = allProxies[index] const groupProxies = allProxies[index]
setProxyDelaying((prev) => { setProxyDelaying((prev) => {
const newProxyDelaying = { ...prev } const newSet = new Set(prev)
groupProxies.forEach(proxy => { groupProxies.forEach(proxy => newSet.add(proxy.name))
newProxyDelaying[proxy.name] = true return newSet
})
return newProxyDelaying
}) })
try { try {
@ -157,11 +171,11 @@ const Proxies: React.FC = () => {
} catch { } catch {
// ignore // ignore
} finally { } finally {
// 立即更新状态 // 更新状态
setProxyDelaying((prev) => { setProxyDelaying((prev) => {
const newProxyDelaying = { ...prev } const newSet = new Set(prev)
delete newProxyDelaying[proxy.name] newSet.delete(proxy.name)
return newProxyDelaying return newSet
}) })
mutate() mutate()
} }
@ -184,14 +198,12 @@ const Proxies: React.FC = () => {
}) })
// 状态清理 // 状态清理
setProxyDelaying((prev) => { setProxyDelaying((prev) => {
const newProxyDelaying = { ...prev } const newSet = new Set(prev)
groupProxies.forEach(proxy => { groupProxies.forEach(proxy => newSet.delete(proxy.name))
delete newProxyDelaying[proxy.name] return newSet
})
return newProxyDelaying
}) })
} }
}, [allProxies, groups, delayTestConcurrency, mutate]) }, [allProxies, groups, delayTestConcurrency, mutate, setIsOpen])
const calcCols = useCallback((): number => { const calcCols = useCallback((): number => {
if (proxyCols !== 'auto') { if (proxyCols !== 'auto') {
@ -216,91 +228,33 @@ const Proxies: React.FC = () => {
} }
}, [calcCols]) }, [calcCols])
return ( // 预加载图片
<BasePage useEffect(() => {
title={t('proxies.title')} const loadImages = async (): Promise<void> => {
header={ const imagesToLoad: string[] = []
<> groups.forEach((group) => {
<Button if (group.icon && group.icon.startsWith('http') && !localStorage.getItem(group.icon)) {
size="sm" imagesToLoad.push(group.icon)
isIconOnly
variant="light"
className="app-nodrag"
onPress={() => {
patchAppConfig({
proxyDisplayOrder:
proxyDisplayOrder === 'default'
? 'delay'
: proxyDisplayOrder === 'delay'
? 'name'
: 'default'
})
}}
>
{proxyDisplayOrder === 'default' ? (
<TbCircleLetterD className="text-lg" title={t('proxies.order.default')} />
) : proxyDisplayOrder === 'delay' ? (
<MdOutlineSpeed className="text-lg" title={t('proxies.order.delay')} />
) : (
<RxLetterCaseCapitalize className="text-lg" title={t('proxies.order.name')} />
)}
</Button>
<Button
size="sm"
isIconOnly
variant="light"
className="app-nodrag"
onPress={() => {
patchAppConfig({
proxyDisplayMode: proxyDisplayMode === 'simple' ? 'full' : 'simple'
})
}}
>
{proxyDisplayMode === 'full' ? (
<CgDetailsMore className="text-lg" title={t('proxies.mode.full')} />
) : (
<CgDetailsLess className="text-lg" title={t('proxies.mode.simple')} />
)}
</Button>
</>
} }
>
{mode === 'direct' ? (
<div className="h-full w-full flex justify-center items-center">
<div className="flex flex-col items-center">
<MdDoubleArrow className="text-foreground-500 text-[100px]" />
<h2 className="text-foreground-500 text-[20px]">{t('proxies.mode.direct')}</h2>
</div>
</div>
) : (
<div className="h-[calc(100vh-50px)]">
<GroupedVirtuoso
ref={virtuosoRef}
groupCounts={groupCounts}
defaultItemHeight={80}
increaseViewportBy={{ top: 300, bottom: 300 }}
overscan={500}
computeItemKey={(index, groupIndex) => {
let innerIndex = index
groupCounts.slice(0, groupIndex).forEach((count) => {
innerIndex -= count
}) })
const proxyIndex = innerIndex * cols
const proxy = allProxies[groupIndex]?.[proxyIndex] if (imagesToLoad.length > 0) {
return proxy ? `${groupIndex}-${proxy.name}` : `${groupIndex}-${index}` const promises = imagesToLoad.map(async (url) => {
}} try {
groupContent={(index) => { const dataURL = await getImageDataURL(url)
if ( localStorage.setItem(url, dataURL)
groups[index] && } catch (error) {
groups[index].icon && console.error('Failed to load image:', url, error)
groups[index].icon.startsWith('http') && }
!localStorage.getItem(groups[index].icon) })
) { await Promise.all(promises)
getImageDataURL(groups[index].icon).then((dataURL) => {
localStorage.setItem(groups[index].icon, dataURL)
mutate() mutate()
})
} }
}
loadImages()
}, [groups, mutate])
const renderGroupContent = useCallback((index: number) => {
return groups[index] ? ( return groups[index] ? (
<div <div
className={`w-full pt-2 ${index === groupCounts.length - 1 && !isOpen[index] ? 'pb-2' : ''} px-2`} className={`w-full pt-2 ${index === groupCounts.length - 1 && !isOpen[index] ? 'pb-2' : ''} px-2`}
@ -424,8 +378,9 @@ const Proxies: React.FC = () => {
) : ( ) : (
<div>Never See This</div> <div>Never See This</div>
) )
}} }, [groups, groupCounts, isOpen, proxyDisplayMode, searchValue, delaying, cols, allProxies, virtuosoRef, t, setIsOpen, onGroupDelay])
itemContent={(index, groupIndex) => {
const renderItemContent = useCallback((index: number, groupIndex: number) => {
let innerIndex = index let innerIndex = index
groupCounts.slice(0, groupIndex).forEach((count) => { groupCounts.slice(0, groupIndex).forEach((count) => {
innerIndex -= count innerIndex -= count
@ -454,7 +409,7 @@ const Proxies: React.FC = () => {
allProxies[groupIndex][innerIndex * cols + i]?.name === allProxies[groupIndex][innerIndex * cols + i]?.name ===
groups[groupIndex].now groups[groupIndex].now
} }
isGroupTesting={!!proxyDelaying[allProxies[groupIndex][innerIndex * cols + i].name]} isGroupTesting={proxyDelaying.has(allProxies[groupIndex][innerIndex * cols + i].name)}
/> />
) )
})} })}
@ -462,7 +417,75 @@ const Proxies: React.FC = () => {
) : ( ) : (
<div>Never See This</div> <div>Never See This</div>
) )
}, [groupCounts, allProxies, proxyCols, cols, groups, proxyDisplayMode, proxyDelaying, mutate, onProxyDelay, onChangeProxy])
return (
<BasePage
title={t('proxies.title')}
header={
<>
<Button
size="sm"
isIconOnly
variant="light"
className="app-nodrag"
onPress={() => {
patchAppConfig({
proxyDisplayOrder:
proxyDisplayOrder === 'default'
? 'delay'
: proxyDisplayOrder === 'delay'
? 'name'
: 'default'
})
}} }}
>
{proxyDisplayOrder === 'default' ? (
<TbCircleLetterD className="text-lg" title={t('proxies.order.default')} />
) : proxyDisplayOrder === 'delay' ? (
<MdOutlineSpeed className="text-lg" title={t('proxies.order.delay')} />
) : (
<RxLetterCaseCapitalize className="text-lg" title={t('proxies.order.name')} />
)}
</Button>
<Button
size="sm"
isIconOnly
variant="light"
className="app-nodrag"
onPress={() => {
patchAppConfig({
proxyDisplayMode: proxyDisplayMode === 'simple' ? 'full' : 'simple'
})
}}
>
{proxyDisplayMode === 'full' ? (
<CgDetailsMore className="text-lg" title={t('proxies.mode.full')} />
) : (
<CgDetailsLess className="text-lg" title={t('proxies.mode.simple')} />
)}
</Button>
</>
}
>
{mode === 'direct' ? (
<div className="h-full w-full flex justify-center items-center">
<div className="flex flex-col items-center">
<MdDoubleArrow className="text-foreground-500 text-[100px]" />
<h2 className="text-foreground-500 text-[20px]">{t('proxies.mode.direct')}</h2>
</div>
</div>
) : (
<div className="h-[calc(100vh-50px)]">
<GroupedVirtuoso
ref={virtuosoRef}
groupCounts={groupCounts}
defaultItemHeight={80}
increaseViewportBy={{ top: 150, bottom: 150 }}
overscan={200}
computeItemKey={(index, groupIndex) => `${groupIndex}-${index}`}
groupContent={renderGroupContent}
itemContent={renderItemContent}
/> />
</div> </div>
)} )}