diff --git a/src-tauri/src/core/tray/speed_rate.rs b/src-tauri/src/core/tray/speed_rate.rs
index 2d8f1a58..8bb9a4b7 100644
--- a/src-tauri/src/core/tray/speed_rate.rs
+++ b/src-tauri/src/core/tray/speed_rate.rs
@@ -118,7 +118,6 @@ impl SpeedRate {
let icon_text_gap = 10;
let max_text_width: f32 = 510.0;
let text_area_start = width as i32 + icon_text_gap;
- let text_area_width = max_text_width.ceil() as u32;
// 用透明色清除文字区域
for x in text_area_start..image.width() as i32 {
diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx
index 8c4ac3ae..15741f96 100644
--- a/src/components/proxy/proxy-groups.tsx
+++ b/src/components/proxy/proxy-groups.tsx
@@ -1,4 +1,4 @@
-import { useRef, useState, useEffect, useCallback } from "react";
+import { useRef, useState, useEffect, useCallback, useMemo } from "react";
import { useLockFn } from "ahooks";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
import {
@@ -16,6 +16,101 @@ import { ProxyRender } from "./proxy-render";
import delayManager from "@/services/delay";
import { useTranslation } from "react-i18next";
import { ScrollTopButton } from "../layout/scroll-top-button";
+import { Box, styled } from "@mui/material";
+import { memo } from "react";
+
+// 将选择器组件抽离出来,避免主组件重渲染时重复创建样式
+const AlphabetSelector = styled(Box)(({ theme }) => ({
+ position: "fixed",
+ right: 4,
+ top: "50%",
+ transform: "translateY(-50%)",
+ display: "flex",
+ flexDirection: "column",
+ background: "transparent",
+ zIndex: 1000,
+ gap: "2px",
+ padding: "8px 4px",
+ willChange: "transform", // 优化动画性能
+ "& .letter": {
+ padding: "2px 4px",
+ fontSize: "12px",
+ cursor: "pointer",
+ fontFamily:
+ "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
+ color: theme.palette.text.secondary,
+ position: "relative",
+ width: "1.5em",
+ height: "1.5em",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ transition: "all 0.15s cubic-bezier(0.34, 1.56, 0.64, 1)", // 稍微加快动画速度
+ transform: "scale(1) translateZ(0)", // 开启GPU加速
+ backfaceVisibility: "hidden", // 防止闪烁
+ borderRadius: "6px",
+ "&:hover": {
+ color: theme.palette.primary.main,
+ transform: "scale(1.2) translateZ(0)",
+ backgroundColor: theme.palette.action.hover,
+ "& .tooltip": {
+ opacity: 1,
+ transform: "translateX(0) translateZ(0)",
+ visibility: "visible",
+ },
+ },
+ "&:hover ~ .letter": {
+ transform: "translateY(2px) translateZ(0)",
+ },
+ },
+ "& .tooltip": {
+ position: "absolute",
+ right: "calc(100% + 8px)",
+ background: theme.palette.background.paper,
+ padding: "4px 8px",
+ borderRadius: "6px",
+ boxShadow: theme.shadows[3],
+ whiteSpace: "nowrap",
+ opacity: 0,
+ visibility: "hidden",
+ transform: "translateX(4px) translateZ(0)",
+ transition: "all 0.15s cubic-bezier(0.34, 1.56, 0.64, 1)",
+ fontSize: "12px",
+ color: theme.palette.text.primary,
+ pointerEvents: "none",
+ backfaceVisibility: "hidden",
+ "&::after": {
+ content: '""',
+ position: "absolute",
+ right: "-4px",
+ top: "50%",
+ transform: "translateY(-50%)",
+ width: 0,
+ height: 0,
+ borderTop: "4px solid transparent",
+ borderBottom: "4px solid transparent",
+ borderLeft: `4px solid ${theme.palette.background.paper}`,
+ },
+ },
+}));
+
+// 抽离字母选择器子组件
+const LetterItem = memo(
+ ({
+ name,
+ onClick,
+ getFirstChar,
+ }: {
+ name: string;
+ onClick: (name: string) => void;
+ getFirstChar: (str: string) => string;
+ }) => (
+
onClick(name)}>
+
{getFirstChar(name)}
+
{name}
+
+ ),
+);
interface Props {
mode: string;
@@ -34,7 +129,35 @@ export const ProxyGroups = (props: Props) => {
const virtuosoRef = useRef(null);
const scrollPositionRef = useRef>({});
const [showScrollTop, setShowScrollTop] = useState(false);
- const scrollerRef = useRef(null);
+ const scrollerRef = useRef(null);
+
+ // 使用useMemo缓存字母索引数据
+ const { groupFirstLetters, letterIndexMap } = useMemo(() => {
+ const letters = new Set();
+ const indexMap: Record = {};
+
+ renderList.forEach((item, index) => {
+ if (item.type === 0) {
+ const fullName = item.group.name;
+ letters.add(fullName);
+ if (!(fullName in indexMap)) {
+ indexMap[fullName] = index;
+ }
+ }
+ });
+
+ return {
+ groupFirstLetters: Array.from(letters),
+ letterIndexMap: indexMap,
+ };
+ }, [renderList]);
+
+ // 缓存getFirstChar函数
+ const getFirstChar = useCallback((str: string) => {
+ const regex = /\p{Extended_Pictographic}|\p{L}|\p{N}|./u;
+ const match = str.match(regex);
+ return match ? match[0] : str.charAt(0);
+ }, []);
// 从 localStorage 恢复滚动位置
useEffect(() => {
@@ -77,13 +200,13 @@ export const ProxyGroups = (props: Props) => {
[mode],
);
- // 优化滚动处理函数
+ // 优化滚动处理函数,使用防抖
const handleScroll = useCallback(
- (e: any) => {
+ debounce((e: any) => {
const scrollTop = e.target.scrollTop;
setShowScrollTop(scrollTop > 100);
saveScrollPosition(scrollTop);
- },
+ }, 16),
[saveScrollPosition],
);
@@ -109,6 +232,21 @@ export const ProxyGroups = (props: Props) => {
saveScrollPosition(0);
}, [saveScrollPosition]);
+ // 处理字母点击,使用useCallback
+ const handleLetterClick = useCallback(
+ (name: string) => {
+ const index = letterIndexMap[name];
+ if (index !== undefined) {
+ virtuosoRef.current?.scrollToIndex({
+ index,
+ align: "start",
+ behavior: "smooth",
+ });
+ }
+ },
+ [letterIndexMap],
+ );
+
// 切换分组的节点代理
const handleChangeProxy = useLockFn(
async (group: IProxyGroupItem, proxy: IProxyItem) => {
@@ -226,6 +364,29 @@ export const ProxyGroups = (props: Props) => {
)}
/>
+
+
+ {groupFirstLetters.map((name) => (
+
+ ))}
+
);
};
+
+// 简单的防抖函数
+function debounce any>(
+ func: T,
+ wait: number,
+): (...args: Parameters) => void {
+ let timeout: NodeJS.Timeout | null = null;
+ return (...args: Parameters) => {
+ if (timeout) clearTimeout(timeout);
+ timeout = setTimeout(() => func(...args), wait);
+ };
+}
diff --git a/src/components/proxy/use-render-list.ts b/src/components/proxy/use-render-list.ts
index 3aafe574..8cd3f093 100644
--- a/src/components/proxy/use-render-list.ts
+++ b/src/components/proxy/use-render-list.ts
@@ -1,5 +1,5 @@
import useSWR from "swr";
-import { useEffect, useMemo } from "react";
+import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import { getProxies } from "@/services/api";
import { useVerge } from "@/hooks/use-verge";
import { filterSort } from "./use-filter-sort";
@@ -21,34 +21,148 @@ export interface IRenderItem {
headState?: HeadState;
}
+interface ProxiesData {
+ groups: IProxyGroupItem[];
+ global?: IProxyGroupItem;
+ proxies: any[];
+}
+
+// 缓存计算结果
+const groupCache = new WeakMap>();
+// 用于追踪缓存的key
+const cacheKeys = new Set();
+
export const useRenderList = (mode: string) => {
+ // 添加用户交互标记
+ const isUserInteracting = useRef(false);
+ const interactionTimer = useRef(null);
+ // 添加上一次有效的数据缓存
+ const [lastValidData, setLastValidData] = useState(null);
+ // 添加刷新锁
+ const refreshLock = useRef(false);
+ const lastRenderList = useRef([]);
+
+ // 组件卸载时清理
+ useEffect(() => {
+ return () => {
+ if (interactionTimer.current) {
+ clearTimeout(interactionTimer.current);
+ }
+ refreshLock.current = false;
+ isUserInteracting.current = false;
+ // 清理 WeakMap 缓存
+ cacheKeys.forEach((key) => {
+ groupCache.delete(key);
+ });
+ cacheKeys.clear();
+ };
+ }, []);
+
+ // 优化数据获取函数
+ const fetchProxies = useCallback(async () => {
+ try {
+ if (isUserInteracting.current || refreshLock.current) {
+ return lastValidData;
+ }
+ const data = await getProxies();
+
+ // 预处理和缓存组数据
+ if (data && !groupCache.has(data)) {
+ const groupMap = new Map();
+ data.groups.forEach((group) => {
+ groupMap.set(group.name, group);
+ });
+ groupCache.set(data, groupMap);
+ cacheKeys.add(data);
+ }
+
+ setLastValidData(data);
+ return data;
+ } catch (error) {
+ if (lastValidData) return lastValidData;
+ throw error;
+ }
+ }, [lastValidData]);
+
const { data: proxiesData, mutate: mutateProxies } = useSWR(
"getProxies",
- getProxies,
+ fetchProxies,
{
- refreshInterval: 2000,
+ refreshInterval: isUserInteracting.current ? 0 : 2000,
dedupingInterval: 1000,
revalidateOnFocus: false,
+ keepPreviousData: true,
+ onSuccess: (data) => {
+ if (!data || isUserInteracting.current) return;
+
+ if (proxiesData) {
+ try {
+ const groupMap = groupCache.get(proxiesData);
+ if (!groupMap) return;
+
+ const needUpdate = data.groups.some((group: IProxyGroupItem) => {
+ const oldGroup = groupMap.get(group.name);
+ return !oldGroup || oldGroup.now !== group.now;
+ });
+
+ if (!needUpdate) return;
+ } catch (e) {
+ console.error("Data comparison error:", e);
+ return;
+ }
+ }
+ },
},
);
+ // 优化mutateProxies包装函数
+ const wrappedMutateProxies = useCallback(async () => {
+ refreshLock.current = true;
+ isUserInteracting.current = true;
+
+ if (interactionTimer.current) {
+ clearTimeout(interactionTimer.current);
+ }
+
+ try {
+ if (!lastValidData && proxiesData) {
+ setLastValidData(proxiesData);
+ }
+ return await mutateProxies();
+ } finally {
+ interactionTimer.current = window.setTimeout(() => {
+ isUserInteracting.current = false;
+ refreshLock.current = false;
+ interactionTimer.current = null;
+ }, 2000);
+ }
+ }, [proxiesData, lastValidData, mutateProxies]);
+
+ // 确保初始数据加载后更新lastValidData
+ useEffect(() => {
+ if (proxiesData && !lastValidData) {
+ setLastValidData(proxiesData);
+ }
+ }, [proxiesData]);
+
const { verge } = useVerge();
const { width } = useWindowWidth();
- let col = Math.floor(verge?.proxy_layout_column || 6);
-
- // 自适应
- if (col >= 6 || col <= 0) {
- if (width > 1450) col = 4;
- else if (width > 1024) col = 3;
- else if (width > 900) col = 2;
- else if (width >= 600) col = 2;
- else col = 1;
- }
+ const col = useMemo(() => {
+ const baseCol = Math.floor(verge?.proxy_layout_column || 6);
+ if (baseCol >= 6 || baseCol <= 0) {
+ if (width > 1450) return 4;
+ if (width > 1024) return 3;
+ if (width > 900) return 2;
+ if (width >= 600) return 2;
+ return 1;
+ }
+ return baseCol;
+ }, [verge?.proxy_layout_column, width]);
const [headStates, setHeadState] = useHeadStateNew();
- // make sure that fetch the proxies successfully
+ // 优化初始数据加载
useEffect(() => {
if (!proxiesData) return;
const { groups, proxies } = proxiesData;
@@ -57,21 +171,23 @@ export const useRenderList = (mode: string) => {
(mode === "rule" && !groups.length) ||
(mode === "global" && proxies.length < 2)
) {
- setTimeout(() => mutateProxies(), 500);
+ const timer = setTimeout(() => mutateProxies(), 500);
+ return () => clearTimeout(timer);
}
- }, [proxiesData, mode]);
+ }, [proxiesData, mode, mutateProxies]);
- const renderList: IRenderItem[] = useMemo(() => {
- if (!proxiesData) return [];
+ // 优化渲染列表计算
+ const renderList = useMemo(() => {
+ const currentData = proxiesData || lastValidData;
+ if (!currentData) return lastRenderList.current;
- // global 和 direct 使用展开的样式
const useRule = mode === "rule" || mode === "script";
const renderGroups =
- (useRule && proxiesData.groups.length
- ? proxiesData.groups
- : [proxiesData.global!]) || [];
+ (useRule && currentData.groups.length
+ ? currentData.groups
+ : [currentData.global!]) || [];
- const retList = renderGroups.flatMap((group) => {
+ const newList = renderGroups.flatMap((group: IProxyGroupItem) => {
const headState = headStates[group.name] || DEFAULT_STATE;
const ret: IRenderItem[] = [
{ type: 0, key: group.name, group, headState },
@@ -89,9 +205,9 @@ export const useRenderList = (mode: string) => {
if (!proxies.length) {
ret.push({ type: 3, key: `empty-${group.name}`, group, headState });
+ return ret;
}
- // 支持多列布局
if (col > 1) {
return ret.concat(
groupList(proxies, col).map((proxyCol) => ({
@@ -118,13 +234,17 @@ export const useRenderList = (mode: string) => {
return ret;
});
- if (!useRule) return retList.slice(1);
- return retList.filter((item) => item.group.hidden === false);
- }, [headStates, proxiesData, mode, col]);
+ const filteredList = !useRule
+ ? newList.slice(1)
+ : newList.filter((item) => !item.group.hidden);
+
+ lastRenderList.current = filteredList;
+ return filteredList;
+ }, [headStates, proxiesData, lastValidData, mode, col]);
return {
renderList,
- onProxies: mutateProxies,
+ onProxies: wrappedMutateProxies,
onHeadState: setHeadState,
};
};