refactor: use React in its intended way (#3963)

* refactor: replace `useEffect` w/ `useLocalStorage`

* refactor: replace `useEffect` w/ `useSWR`

* refactor: replace `useEffect` and `useSWR`. clean up `useRef`

* refactor: use `requestIdleCallback`

* refactor: replace `useEffect` w/ `useMemo`

* fix: clean up `useEffect`

* refactor: replace `useEffect` w/ `useSWR`

* refactor: remove unused `useCallback`

* refactor: enhance performance and memory management in frontend processes

* refactor: improve pre-push script structure and readability

---------

Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
Co-authored-by: Tunglies <tunglies.dev@outlook.com>
This commit is contained in:
Sukka
2025-07-02 23:34:13 +08:00
committed by GitHub
parent 37d268bb16
commit 954ff53d9b
10 changed files with 132 additions and 138 deletions

View File

@@ -11,18 +11,24 @@ if git diff --cached --name-only | grep -q '^src-tauri/'; then
fi fi
fi fi
# 只在 push 到 origin 并且 origin 指向目标仓库时执行格式检查
if [ "$1" = "origin" ] && echo "$2" | grep -Eq 'github\.com[:/]+clash-verge-rev/clash-verge-rev(\.git)?$'; then
echo "[pre-push] Detected push to origin (clash-verge-rev/clash-verge-rev)"
echo "[pre-push] Running pnpm format:check..."
# Only run format check if the remote exists and is the main repo
remote_name="$1"
if git remote get-url "$remote_name" >/dev/null 2>&1; then
remote_url=$(git remote get-url "$remote_name")
if [[ "$remote_url" =~ github\.com[:/]+clash-verge-rev/clash-verge-rev(\.git)?$ ]]; then
echo "[pre-push] Detected push to clash-verge-rev/clash-verge-rev ($remote_url)"
echo "[pre-push] Running pnpm format:check..."
pnpm format:check pnpm format:check
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo "❌ Code format check failed. Please fix formatting before pushing." echo "❌ Code format check failed. Please fix formatting before pushing."
exit 1 exit 1
fi fi
else else
echo "[pre-push] Not pushing to target repo. Skipping format check." echo "[pre-push] Not pushing to target repo. Skipping format check."
fi
else
echo "[pre-push] Remote $remote_name does not exist. Skipping format check."
fi fi
exit 0 exit 0

View File

@@ -53,7 +53,8 @@
- 优化 托盘 统一响应 - 优化 托盘 统一响应
- 优化 静默启动+自启动轻量模式 运行方式 - 优化 静默启动+自启动轻量模式 运行方式
- 升级依赖 - 降低前端潜在内存泄漏风险,提升运行时性能
- 优化 React 状态、副作用、数据获取、清理等流程。
## v2.3.0 ## v2.3.0

View File

@@ -1,10 +1,11 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useMemo, useState, useEffect } from "react"; import { useMemo, useState } from "react";
import { DataGrid, GridColDef, GridColumnResizeParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridColumnResizeParams } from "@mui/x-data-grid";
import { useThemeMode } from "@/services/states"; import { useThemeMode } from "@/services/states";
import { truncateStr } from "@/utils/truncate-str"; import { truncateStr } from "@/utils/truncate-str";
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from "@/utils/parse-traffic";
import { t } from "i18next"; import { t } from "i18next";
import { useLocalStorage } from "foxact/use-local-storage";
interface Props { interface Props {
connections: IConnectionsItem[]; connections: IConnectionsItem[];
@@ -21,11 +22,13 @@ export const ConnectionTable = (props: Props) => {
Partial<Record<keyof IConnectionsItem, boolean>> Partial<Record<keyof IConnectionsItem, boolean>>
>({}); >({});
const [columnWidths, setColumnWidths] = useState<Record<string, number>>( const [columnWidths, setColumnWidths] = useLocalStorage<
() => { Record<string, number>
const saved = localStorage.getItem("connection-table-widths"); >(
return saved ? JSON.parse(saved) : {}; "connection-table-widths",
}, // server-side value, this is the default value used by server-side rendering (if any)
// Do not omit (otherwise a Suspense boundary will be triggered)
{},
); );
const [columns] = useState<GridColDef[]>([ const [columns] = useState<GridColDef[]>([
@@ -116,14 +119,6 @@ export const ConnectionTable = (props: Props) => {
}, },
]); ]);
useEffect(() => {
console.log("Saving column widths:", columnWidths);
localStorage.setItem(
"connection-table-widths",
JSON.stringify(columnWidths),
);
}, [columnWidths]);
const handleColumnResize = (params: GridColumnResizeParams) => { const handleColumnResize = (params: GridColumnResizeParams) => {
const { colDef, width } = params; const { colDef, width } = params;
console.log("Column resize:", colDef.field, width); console.log("Column resize:", colDef.field, width);

View File

@@ -29,6 +29,7 @@ import parseTraffic from "@/utils/parse-traffic";
import { isDebugEnabled, gc } from "@/services/api"; import { isDebugEnabled, gc } from "@/services/api";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { useAppData } from "@/providers/app-data-provider"; import { useAppData } from "@/providers/app-data-provider";
import useSWR from "swr";
interface MemoryUsage { interface MemoryUsage {
inuse: number; inuse: number;
@@ -161,7 +162,6 @@ export const EnhancedTrafficStats = () => {
const { verge } = useVerge(); const { verge } = useVerge();
const trafficRef = useRef<EnhancedTrafficGraphRef>(null); const trafficRef = useRef<EnhancedTrafficGraphRef>(null);
const pageVisible = useVisibility(); const pageVisible = useVisibility();
const [isDebug, setIsDebug] = useState(false);
// 使用AppDataProvider // 使用AppDataProvider
const { connections, uptime } = useAppData(); const { connections, uptime } = useAppData();
@@ -178,19 +178,16 @@ export const EnhancedTrafficStats = () => {
// 是否显示流量图表 // 是否显示流量图表
const trafficGraph = verge?.traffic_graph ?? true; const trafficGraph = verge?.traffic_graph ?? true;
// WebSocket引用
const socketRefs = useRef<{
traffic: ReturnType<typeof createAuthSockette> | null;
memory: ReturnType<typeof createAuthSockette> | null;
}>({
traffic: null,
memory: null,
});
// 检查是否支持调试 // 检查是否支持调试
useEffect(() => { // TODO: merge this hook with layout-traffic.tsx
isDebugEnabled().then((flag) => setIsDebug(flag)); const { data: isDebug } = useSWR(
}, []); `clash-verge-rev-internal://isDebugEnabled`,
() => isDebugEnabled(),
{
// default value before is fetched
fallbackData: false,
},
);
// 处理流量数据更新 - 使用节流控制更新频率 // 处理流量数据更新 - 使用节流控制更新频率
const handleTrafficUpdate = useCallback((event: MessageEvent) => { const handleTrafficUpdate = useCallback((event: MessageEvent) => {
@@ -260,14 +257,23 @@ export const EnhancedTrafficStats = () => {
const { server, secret = "" } = clashInfo; const { server, secret = "" } = clashInfo;
if (!server) return; if (!server) return;
// WebSocket 引用
let sockets: {
traffic: ReturnType<typeof createAuthSockette> | null;
memory: ReturnType<typeof createAuthSockette> | null;
} = {
traffic: null,
memory: null,
};
// 清理现有连接的函数 // 清理现有连接的函数
const cleanupSockets = () => { const cleanupSockets = () => {
Object.values(socketRefs.current).forEach((socket) => { Object.values(sockets).forEach((socket) => {
if (socket) { if (socket) {
socket.close(); socket.close();
} }
}); });
socketRefs.current = { traffic: null, memory: null }; sockets = { traffic: null, memory: null };
}; };
// 关闭现有连接 // 关闭现有连接
@@ -277,10 +283,7 @@ export const EnhancedTrafficStats = () => {
console.log( console.log(
`[Traffic][${EnhancedTrafficStats.name}] 正在连接: ${server}/traffic`, `[Traffic][${EnhancedTrafficStats.name}] 正在连接: ${server}/traffic`,
); );
socketRefs.current.traffic = createAuthSockette( sockets.traffic = createAuthSockette(`${server}/traffic`, secret, {
`${server}/traffic`,
secret,
{
onmessage: handleTrafficUpdate, onmessage: handleTrafficUpdate,
onopen: (event) => { onopen: (event) => {
console.log( console.log(
@@ -308,13 +311,12 @@ export const EnhancedTrafficStats = () => {
setStats((prev) => ({ ...prev, traffic: { up: 0, down: 0 } })); setStats((prev) => ({ ...prev, traffic: { up: 0, down: 0 } }));
} }
}, },
}, });
);
console.log( console.log(
`[Memory][${EnhancedTrafficStats.name}] 正在连接: ${server}/memory`, `[Memory][${EnhancedTrafficStats.name}] 正在连接: ${server}/memory`,
); );
socketRefs.current.memory = createAuthSockette(`${server}/memory`, secret, { sockets.memory = createAuthSockette(`${server}/memory`, secret, {
onmessage: handleMemoryUpdate, onmessage: handleMemoryUpdate,
onopen: (event) => { onopen: (event) => {
console.log( console.log(
@@ -353,18 +355,6 @@ export const EnhancedTrafficStats = () => {
return cleanupSockets; return cleanupSockets;
}, [clashInfo, pageVisible, handleTrafficUpdate, handleMemoryUpdate]); }, [clashInfo, pageVisible, handleTrafficUpdate, handleMemoryUpdate]);
// 组件卸载时清理所有定时器/引用
useEffect(() => {
return () => {
try {
Object.values(socketRefs.current).forEach((socket) => {
if (socket) socket.close();
});
socketRefs.current = { traffic: null, memory: null };
} catch {}
};
}, []);
// 执行垃圾回收 // 执行垃圾回收
const handleGarbageCollection = useCallback(async () => { const handleGarbageCollection = useCallback(async () => {
if (isDebug) { if (isDebug) {

View File

@@ -14,6 +14,7 @@ import useSWRSubscription from "swr/subscription";
import { createAuthSockette } from "@/utils/websocket"; import { createAuthSockette } from "@/utils/websocket";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { isDebugEnabled, gc } from "@/services/api"; import { isDebugEnabled, gc } from "@/services/api";
import useSWR from "swr";
interface MemoryUsage { interface MemoryUsage {
inuse: number; inuse: number;
@@ -31,12 +32,15 @@ export const LayoutTraffic = () => {
const trafficRef = useRef<TrafficRef>(null); const trafficRef = useRef<TrafficRef>(null);
const pageVisible = useVisibility(); const pageVisible = useVisibility();
const [isDebug, setIsDebug] = useState(false);
useEffect(() => { const { data: isDebug } = useSWR(
isDebugEnabled().then((flag) => setIsDebug(flag)); "clash-verge-rev-internal://isDebugEnabled",
return () => {}; () => isDebugEnabled(),
}, [isDebug]); {
// default value before is fetched
fallbackData: false,
},
);
const { data: traffic = { up: 0, down: 0 } } = useSWRSubscription< const { data: traffic = { up: 0, down: 0 } } = useSWRSubscription<
ITrafficItem, ITrafficItem,

View File

@@ -48,6 +48,10 @@ import MonacoEditor from "react-monaco-editor";
import { useThemeMode } from "@/services/states"; import { useThemeMode } from "@/services/states";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
import {
requestIdleCallback,
cancelIdleCallback,
} from "foxact/request-idle-callback";
interface Props { interface Props {
proxiesUid: string; proxiesUid: string;
@@ -195,11 +199,11 @@ export const GroupsEditorViewer = (props: Props) => {
// 防止异常导致UI卡死 // 防止异常导致UI卡死
} }
}; };
if (window.requestIdleCallback) {
window.requestIdleCallback(serialize); const handle = requestIdleCallback(serialize);
} else { return () => {
setTimeout(serialize, 0); cancelIdleCallback(handle);
} };
} }
}, [prependSeq, appendSeq, deleteSeq]); }, [prependSeq, appendSeq, deleteSeq]);

View File

@@ -480,8 +480,11 @@ export const ProxyGroups = (props: Props) => {
} }
}, [handleWheel]); }, [handleWheel]);
// 监听窗口大小变化
// layout effect runs before paint
useEffect(() => {
// 添加窗口大小变化监听和最大高度计算 // 添加窗口大小变化监听和最大高度计算
const updateMaxHeight = useCallback(() => { const updateMaxHeight = () => {
if (!alphabetSelectorRef.current) return; if (!alphabetSelectorRef.current) return;
const windowHeight = window.innerHeight; const windowHeight = window.innerHeight;
@@ -495,16 +498,16 @@ export const ProxyGroups = (props: Props) => {
alphabetSelectorRef.current.style.top = `calc(48% + ${offsetPercentage}vh)`; alphabetSelectorRef.current.style.top = `calc(48% + ${offsetPercentage}vh)`;
setMaxHeight(`${availableHeight}px`); setMaxHeight(`${availableHeight}px`);
}, []); };
// 监听窗口大小变化
useEffect(() => {
updateMaxHeight(); updateMaxHeight();
window.addEventListener("resize", updateMaxHeight); window.addEventListener("resize", updateMaxHeight);
return () => { return () => {
window.removeEventListener("resize", updateMaxHeight); window.removeEventListener("resize", updateMaxHeight);
}; };
}, [updateMaxHeight]); }, []);
if (mode === "direct") { if (mode === "direct") {
return <BaseEmpty text={t("clash_mode_direct")} />; return <BaseEmpty text={t("clash_mode_direct")} />;

View File

@@ -110,7 +110,8 @@ export const useRenderList = (mode: string) => {
(mode === "rule" && !groups.length) || (mode === "rule" && !groups.length) ||
(mode === "global" && proxies.length < 2) (mode === "global" && proxies.length < 2)
) { ) {
setTimeout(() => refreshProxy(), 500); const handle = setTimeout(() => refreshProxy(), 500);
return () => clearTimeout(handle);
} }
}, [proxiesData, mode, refreshProxy]); }, [proxiesData, mode, refreshProxy]);

View File

@@ -3,7 +3,7 @@ import {
useImperativeHandle, useImperativeHandle,
useState, useState,
useCallback, useCallback,
useEffect, useMemo,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BaseDialog, DialogRef } from "@/components/base"; import { BaseDialog, DialogRef } from "@/components/base";
@@ -30,7 +30,6 @@ export const BackupViewer = forwardRef<DialogRef>((props, ref) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([]); const [backupFiles, setBackupFiles] = useState<BackupFile[]>([]);
const [dataSource, setDataSource] = useState<BackupFile[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
@@ -91,14 +90,14 @@ export const BackupViewer = forwardRef<DialogRef>((props, ref) => {
.sort((a, b) => (a.backup_time.isAfter(b.backup_time) ? -1 : 1)); .sort((a, b) => (a.backup_time.isAfter(b.backup_time) ? -1 : 1));
}; };
useEffect(() => { const dataSource = useMemo<BackupFile[]>(
setDataSource( () =>
backupFiles.slice( backupFiles.slice(
page * DEFAULT_ROWS_PER_PAGE, page * DEFAULT_ROWS_PER_PAGE,
page * DEFAULT_ROWS_PER_PAGE + DEFAULT_ROWS_PER_PAGE, page * DEFAULT_ROWS_PER_PAGE + DEFAULT_ROWS_PER_PAGE,
), ),
[backupFiles, page],
); );
}, [page, backupFiles]);
return ( return (
<BaseDialog <BaseDialog
@@ -116,18 +115,10 @@ export const BackupViewer = forwardRef<DialogRef>((props, ref) => {
<Paper elevation={2} sx={{ padding: 2 }}> <Paper elevation={2} sx={{ padding: 2 }}>
<BackupConfigViewer <BackupConfigViewer
setLoading={setIsLoading} setLoading={setIsLoading}
onBackupSuccess={async () => { onBackupSuccess={fetchAndSetBackupFiles}
fetchAndSetBackupFiles(); onSaveSuccess={fetchAndSetBackupFiles}
}} onRefresh={fetchAndSetBackupFiles}
onSaveSuccess={async () => { onInit={fetchAndSetBackupFiles}
fetchAndSetBackupFiles();
}}
onRefresh={async () => {
fetchAndSetBackupFiles();
}}
onInit={async () => {
fetchAndSetBackupFiles();
}}
/> />
<Divider sx={{ marginY: 2 }} /> <Divider sx={{ marginY: 2 }} />
<BackupTableViewer <BackupTableViewer

View File

@@ -6,13 +6,11 @@ import { alpha, Box, Button, IconButton } from "@mui/material";
import { ContentCopyRounded } from "@mui/icons-material"; import { ContentCopyRounded } from "@mui/icons-material";
import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
import useSWR from "swr";
export const NetworkInterfaceViewer = forwardRef<DialogRef>((props, ref) => { export const NetworkInterfaceViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [networkInterfaces, setNetworkInterfaces] = useState<
INetworkInterface[]
>([]);
const [isV4, setIsV4] = useState(true); const [isV4, setIsV4] = useState(true);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
@@ -22,12 +20,13 @@ export const NetworkInterfaceViewer = forwardRef<DialogRef>((props, ref) => {
close: () => setOpen(false), close: () => setOpen(false),
})); }));
useEffect(() => { const { data: networkInterfaces } = useSWR(
if (!open) return; "clash-verge-rev-internal://network-interfaces",
getNetworkInterfacesInfo().then((res) => { getNetworkInterfacesInfo,
setNetworkInterfaces(res); {
}); fallbackData: [], // default data before fetch
}, [open]); },
);
return ( return (
<BaseDialog <BaseDialog