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
Unverified
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
# 只在 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
if [ $? -ne 0 ]; then
echo "❌ Code format check failed. Please fix formatting before pushing."
exit 1
fi
else
else
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
exit 0

View File

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

View File

@@ -1,10 +1,11 @@
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 { useThemeMode } from "@/services/states";
import { truncateStr } from "@/utils/truncate-str";
import parseTraffic from "@/utils/parse-traffic";
import { t } from "i18next";
import { useLocalStorage } from "foxact/use-local-storage";
interface Props {
connections: IConnectionsItem[];
@@ -21,11 +22,13 @@ export const ConnectionTable = (props: Props) => {
Partial<Record<keyof IConnectionsItem, boolean>>
>({});
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(
() => {
const saved = localStorage.getItem("connection-table-widths");
return saved ? JSON.parse(saved) : {};
},
const [columnWidths, setColumnWidths] = useLocalStorage<
Record<string, number>
>(
"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[]>([
@@ -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 { colDef, width } = params;
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 { ReactNode } from "react";
import { useAppData } from "@/providers/app-data-provider";
import useSWR from "swr";
interface MemoryUsage {
inuse: number;
@@ -161,7 +162,6 @@ export const EnhancedTrafficStats = () => {
const { verge } = useVerge();
const trafficRef = useRef<EnhancedTrafficGraphRef>(null);
const pageVisible = useVisibility();
const [isDebug, setIsDebug] = useState(false);
// 使用AppDataProvider
const { connections, uptime } = useAppData();
@@ -178,19 +178,16 @@ export const EnhancedTrafficStats = () => {
// 是否显示流量图表
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(() => {
isDebugEnabled().then((flag) => setIsDebug(flag));
}, []);
// TODO: merge this hook with layout-traffic.tsx
const { data: isDebug } = useSWR(
`clash-verge-rev-internal://isDebugEnabled`,
() => isDebugEnabled(),
{
// default value before is fetched
fallbackData: false,
},
);
// 处理流量数据更新 - 使用节流控制更新频率
const handleTrafficUpdate = useCallback((event: MessageEvent) => {
@@ -260,14 +257,23 @@ export const EnhancedTrafficStats = () => {
const { server, secret = "" } = clashInfo;
if (!server) return;
// WebSocket 引用
let sockets: {
traffic: ReturnType<typeof createAuthSockette> | null;
memory: ReturnType<typeof createAuthSockette> | null;
} = {
traffic: null,
memory: null,
};
// 清理现有连接的函数
const cleanupSockets = () => {
Object.values(socketRefs.current).forEach((socket) => {
Object.values(sockets).forEach((socket) => {
if (socket) {
socket.close();
}
});
socketRefs.current = { traffic: null, memory: null };
sockets = { traffic: null, memory: null };
};
// 关闭现有连接
@@ -277,10 +283,7 @@ export const EnhancedTrafficStats = () => {
console.log(
`[Traffic][${EnhancedTrafficStats.name}] 正在连接: ${server}/traffic`,
);
socketRefs.current.traffic = createAuthSockette(
`${server}/traffic`,
secret,
{
sockets.traffic = createAuthSockette(`${server}/traffic`, secret, {
onmessage: handleTrafficUpdate,
onopen: (event) => {
console.log(
@@ -308,13 +311,12 @@ export const EnhancedTrafficStats = () => {
setStats((prev) => ({ ...prev, traffic: { up: 0, down: 0 } }));
}
},
},
);
});
console.log(
`[Memory][${EnhancedTrafficStats.name}] 正在连接: ${server}/memory`,
);
socketRefs.current.memory = createAuthSockette(`${server}/memory`, secret, {
sockets.memory = createAuthSockette(`${server}/memory`, secret, {
onmessage: handleMemoryUpdate,
onopen: (event) => {
console.log(
@@ -353,18 +355,6 @@ export const EnhancedTrafficStats = () => {
return cleanupSockets;
}, [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 () => {
if (isDebug) {

View File

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

View File

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

View File

@@ -480,8 +480,11 @@ export const ProxyGroups = (props: Props) => {
}
}, [handleWheel]);
// 监听窗口大小变化
// layout effect runs before paint
useEffect(() => {
// 添加窗口大小变化监听和最大高度计算
const updateMaxHeight = useCallback(() => {
const updateMaxHeight = () => {
if (!alphabetSelectorRef.current) return;
const windowHeight = window.innerHeight;
@@ -495,16 +498,16 @@ export const ProxyGroups = (props: Props) => {
alphabetSelectorRef.current.style.top = `calc(48% + ${offsetPercentage}vh)`;
setMaxHeight(`${availableHeight}px`);
}, []);
};
// 监听窗口大小变化
useEffect(() => {
updateMaxHeight();
window.addEventListener("resize", updateMaxHeight);
return () => {
window.removeEventListener("resize", updateMaxHeight);
};
}, [updateMaxHeight]);
}, []);
if (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 === "global" && proxies.length < 2)
) {
setTimeout(() => refreshProxy(), 500);
const handle = setTimeout(() => refreshProxy(), 500);
return () => clearTimeout(handle);
}
}, [proxiesData, mode, refreshProxy]);

View File

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

View File

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