mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-04-15 22:40:42 +08:00
perf(ip-info-card): reduce IP API load even more (#6263)
* refactor(ip-info-card): reduce retry, use succeed as lastFetchTs * refactor(ip-info-card): stop countdown during revalidation * perf(ip-info-card): avoid aggressive schedule revalidation * perf(ip-info-card): try stop interval on window minimized * perf(ip-info-card): only mutate after card scroll into view once * perf(ip-info-card): interval only when card has been visible * chore: add more debug information * Update src/components/home/ip-info-card.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: reset countdown state after mutate finishes --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
781313e8f0
commit
e9d63aee5e
@ -1,5 +1,5 @@
|
|||||||
import { Box, Typography, alpha, useTheme } from "@mui/material";
|
import { Box, Typography, alpha, useTheme } from "@mui/material";
|
||||||
import { ReactNode } from "react";
|
import React, { forwardRef, ReactNode } from "react";
|
||||||
|
|
||||||
// 自定义卡片组件接口
|
// 自定义卡片组件接口
|
||||||
interface EnhancedCardProps {
|
interface EnhancedCardProps {
|
||||||
@ -19,7 +19,9 @@ interface EnhancedCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 自定义卡片组件
|
// 自定义卡片组件
|
||||||
export const EnhancedCard = ({
|
export const EnhancedCard = forwardRef<HTMLElement, EnhancedCardProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
title,
|
title,
|
||||||
icon,
|
icon,
|
||||||
action,
|
action,
|
||||||
@ -27,7 +29,9 @@ export const EnhancedCard = ({
|
|||||||
iconColor = "primary",
|
iconColor = "primary",
|
||||||
minHeight,
|
minHeight,
|
||||||
noContentPadding = false,
|
noContentPadding = false,
|
||||||
}: EnhancedCardProps) => {
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isDark = theme.palette.mode === "dark";
|
const isDark = theme.palette.mode === "dark";
|
||||||
|
|
||||||
@ -50,6 +54,7 @@ export const EnhancedCard = ({
|
|||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
backgroundColor: isDark ? "#282a36" : "#ffffff",
|
backgroundColor: isDark ? "#282a36" : "#ffffff",
|
||||||
}}
|
}}
|
||||||
|
ref={ref}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@ -118,4 +123,5 @@ export const EnhancedCard = ({
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
);
|
||||||
|
|||||||
@ -7,9 +7,18 @@ import {
|
|||||||
import { Box, Button, IconButton, Skeleton, Typography } from "@mui/material";
|
import { Box, Button, IconButton, Skeleton, Typography } from "@mui/material";
|
||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
import { useEffect } from "foxact/use-abortable-effect";
|
import { useEffect } from "foxact/use-abortable-effect";
|
||||||
import { memo, useCallback, useState, useEffectEvent, useMemo } from "react";
|
import { useIntersection } from "foxact/use-intersection";
|
||||||
|
import type { XOR } from "foxts/ts-xor";
|
||||||
|
import {
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useState,
|
||||||
|
useEffectEvent,
|
||||||
|
useMemo,
|
||||||
|
forwardRef,
|
||||||
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useSWR from "swr";
|
import useSWRImmutable from "swr/immutable";
|
||||||
|
|
||||||
import { getIpInfo } from "@/services/api";
|
import { getIpInfo } from "@/services/api";
|
||||||
|
|
||||||
@ -54,26 +63,60 @@ const getCountryFlag = (countryCode: string | undefined) => {
|
|||||||
return String.fromCodePoint(...codePoints);
|
return String.fromCodePoint(...codePoints);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CountDownState = XOR<
|
||||||
|
{
|
||||||
|
type: "countdown";
|
||||||
|
remainingSeconds: number;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "revalidating";
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
const IPInfoCardContainer = forwardRef<HTMLElement, React.PropsWithChildren>(
|
||||||
|
({ children }, ref) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { mutate } = useIPInfo();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EnhancedCard
|
||||||
|
title={t("home.components.ipInfo.title")}
|
||||||
|
icon={<LocationOnOutlined />}
|
||||||
|
iconColor="info"
|
||||||
|
ref={ref}
|
||||||
|
action={
|
||||||
|
<IconButton size="small" onClick={() => mutate()}>
|
||||||
|
<RefreshOutlined />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</EnhancedCard>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// IP信息卡片组件
|
// IP信息卡片组件
|
||||||
export const IpInfoCard = () => {
|
export const IpInfoCard = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showIp, setShowIp] = useState(false);
|
const [showIp, setShowIp] = useState(false);
|
||||||
const appWindow = useMemo(() => getCurrentWebviewWindow(), []);
|
const appWindow = useMemo(() => getCurrentWebviewWindow(), []);
|
||||||
|
|
||||||
const [countdown, setCountdown] = useState(IP_REFRESH_SECONDS);
|
// track ip info card has been in viewport or not
|
||||||
|
// hasIntersected default to false, and will be true once the card is in viewport
|
||||||
const {
|
// and will never be false again afterwards (unless resetIntersected is called or
|
||||||
data: ipInfo,
|
// the component is unmounted)
|
||||||
error,
|
const [containerRef, hasIntersected, _resetIntersected] = useIntersection({
|
||||||
isLoading,
|
rootMargin: "0px",
|
||||||
mutate,
|
|
||||||
} = useSWR(IP_INFO_CACHE_KEY, getIpInfo, {
|
|
||||||
refreshInterval: 0,
|
|
||||||
refreshWhenOffline: false,
|
|
||||||
revalidateOnFocus: true,
|
|
||||||
shouldRetryOnError: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [countdown, setCountdown] = useState<CountDownState>({
|
||||||
|
type: "countdown",
|
||||||
|
remainingSeconds: IP_REFRESH_SECONDS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: ipInfo, error, isLoading, mutate } = useIPInfo();
|
||||||
|
|
||||||
// function useEffectEvent
|
// function useEffectEvent
|
||||||
const onCountdownTick = useEffectEvent(async () => {
|
const onCountdownTick = useEffectEvent(async () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@ -86,9 +129,30 @@ export const IpInfoCard = () => {
|
|||||||
const remaining = IP_REFRESH_SECONDS - elapsed;
|
const remaining = IP_REFRESH_SECONDS - elapsed;
|
||||||
|
|
||||||
if (remaining <= 0) {
|
if (remaining <= 0) {
|
||||||
if (navigator.onLine && (await appWindow.isVisible())) {
|
if (
|
||||||
mutate();
|
// has intersected at least once
|
||||||
setCountdown(IP_REFRESH_SECONDS);
|
// this avoids unncessary revalidation if user never scrolls down,
|
||||||
|
// then we will only load initially once.
|
||||||
|
hasIntersected &&
|
||||||
|
// is online
|
||||||
|
navigator.onLine &&
|
||||||
|
// there is no ongoing revalidation already scheduled
|
||||||
|
countdown.type !== "revalidating" &&
|
||||||
|
// window is visible
|
||||||
|
(await appWindow.isVisible())
|
||||||
|
) {
|
||||||
|
setCountdown({ type: "revalidating" });
|
||||||
|
// we do not care about the result of mutate here. after mutate is done,
|
||||||
|
// simply wait for next interval tick with `setCountdown({ type: "countdown", ... })`
|
||||||
|
try {
|
||||||
|
await mutate();
|
||||||
|
} finally {
|
||||||
|
// in case mutate throws error, we still need to reset the countdown state
|
||||||
|
setCountdown({
|
||||||
|
type: "countdown",
|
||||||
|
remainingSeconds: IP_REFRESH_SECONDS,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// do nothing. we even skip "setCountdown" to reduce re-renders
|
// do nothing. we even skip "setCountdown" to reduce re-renders
|
||||||
//
|
//
|
||||||
@ -97,58 +161,86 @@ export const IpInfoCard = () => {
|
|||||||
// or network online again, we mutate() immediately in the following tick.
|
// or network online again, we mutate() immediately in the following tick.
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setCountdown(remaining);
|
setCountdown({
|
||||||
|
type: "countdown",
|
||||||
|
remainingSeconds: remaining,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Countdown / refresh scheduler — updates UI every 1s and triggers immediate revalidation when expired
|
// Countdown / refresh scheduler — updates UI every 1s and triggers immediate revalidation when expired
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer: number | null = window.setInterval(onCountdownTick, 1000);
|
let timer: number | null = null;
|
||||||
|
|
||||||
|
// Do not add document.hidden check here as it is not reliable in Tauri.
|
||||||
|
//
|
||||||
|
// Thank god IntersectionObserver is a DOM API that relies on DOM/webview
|
||||||
|
// instead of Tauri, which is reliable enough.
|
||||||
|
if (hasIntersected) {
|
||||||
|
console.debug(
|
||||||
|
"IP info card has entered the viewport, starting the countdown interval.",
|
||||||
|
);
|
||||||
|
timer = window.setInterval(onCountdownTick, 1000);
|
||||||
|
} else {
|
||||||
|
console.debug(
|
||||||
|
"IP info card has not yet entered the viewport, no counting down.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This will fire when the window is minimized or restored
|
||||||
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
// Tauri's visibility change detection is actually broken on some platforms:
|
||||||
|
// https://github.com/tauri-apps/tauri/issues/10592
|
||||||
|
//
|
||||||
|
// It is working on macOS though (tested).
|
||||||
|
// So at least we should try to pause countdown on supported platforms to
|
||||||
|
// reduce power consumption.
|
||||||
|
function onVisibilityChange() {
|
||||||
|
if (document.hidden) {
|
||||||
|
console.debug("Document hidden, pause the interval");
|
||||||
|
// Pause the timer
|
||||||
|
if (timer != null) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
} else if (hasIntersected) {
|
||||||
|
console.debug("Document visible, resume the interval");
|
||||||
|
// Resume the timer only when previous one is cleared
|
||||||
|
if (timer == null) {
|
||||||
|
timer = window.setInterval(onCountdownTick, 1000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.debug(
|
||||||
|
"Document visible, but IP info card has never entered the viewport, not even once, not starting the interval.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (timer != null) clearInterval(timer);
|
if (timer != null) clearInterval(timer);
|
||||||
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [hasIntersected]);
|
||||||
|
|
||||||
const toggleShowIp = useCallback(() => {
|
const toggleShowIp = useCallback(() => {
|
||||||
setShowIp((prev) => !prev);
|
setShowIp((prev) => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Loading
|
let mainElement: React.ReactElement;
|
||||||
if (isLoading) {
|
|
||||||
return (
|
switch (true) {
|
||||||
<EnhancedCard
|
case isLoading:
|
||||||
title={t("home.components.ipInfo.title")}
|
mainElement = (
|
||||||
icon={<LocationOnOutlined />}
|
|
||||||
iconColor="info"
|
|
||||||
action={
|
|
||||||
<IconButton size="small" onClick={() => mutate()} disabled>
|
|
||||||
<RefreshOutlined />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||||
<Skeleton variant="text" width="60%" height={30} />
|
<Skeleton variant="text" width="60%" height={30} />
|
||||||
<Skeleton variant="text" width="80%" height={24} />
|
<Skeleton variant="text" width="80%" height={24} />
|
||||||
<Skeleton variant="text" width="70%" height={24} />
|
<Skeleton variant="text" width="70%" height={24} />
|
||||||
<Skeleton variant="text" width="50%" height={24} />
|
<Skeleton variant="text" width="50%" height={24} />
|
||||||
</Box>
|
</Box>
|
||||||
</EnhancedCard>
|
|
||||||
);
|
);
|
||||||
}
|
break;
|
||||||
|
case !!error:
|
||||||
// Error
|
mainElement = (
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<EnhancedCard
|
|
||||||
title={t("home.components.ipInfo.title")}
|
|
||||||
icon={<LocationOnOutlined />}
|
|
||||||
iconColor="info"
|
|
||||||
action={
|
|
||||||
<IconButton size="small" onClick={() => mutate()}>
|
|
||||||
<RefreshOutlined />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@ -168,22 +260,10 @@ export const IpInfoCard = () => {
|
|||||||
{t("shared.actions.retry")}
|
{t("shared.actions.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</EnhancedCard>
|
|
||||||
);
|
);
|
||||||
}
|
break;
|
||||||
|
default: // Normal render
|
||||||
// Normal render
|
mainElement = (
|
||||||
return (
|
|
||||||
<EnhancedCard
|
|
||||||
title={t("home.components.ipInfo.title")}
|
|
||||||
icon={<LocationOnOutlined />}
|
|
||||||
iconColor="info"
|
|
||||||
action={
|
|
||||||
<IconButton size="small" onClick={() => mutate()}>
|
|
||||||
<RefreshOutlined />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@ -227,7 +307,8 @@ export const IpInfoCard = () => {
|
|||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ipInfo?.country || t("home.components.ipInfo.labels.unknown")}
|
{ipInfo?.country ||
|
||||||
|
t("home.components.ipInfo.labels.unknown")}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@ -288,7 +369,9 @@ export const IpInfoCard = () => {
|
|||||||
/>
|
/>
|
||||||
<InfoItem
|
<InfoItem
|
||||||
label={t("home.components.ipInfo.labels.location")}
|
label={t("home.components.ipInfo.labels.location")}
|
||||||
value={[ipInfo?.city, ipInfo?.region].filter(Boolean).join(", ")}
|
value={[ipInfo?.city, ipInfo?.region]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ")}
|
||||||
/>
|
/>
|
||||||
<InfoItem
|
<InfoItem
|
||||||
label={t("home.components.ipInfo.labels.timezone")}
|
label={t("home.components.ipInfo.labels.timezone")}
|
||||||
@ -311,7 +394,10 @@ export const IpInfoCard = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="caption">
|
<Typography variant="caption">
|
||||||
{t("home.components.ipInfo.labels.autoRefresh")}: {countdown}s
|
{t("home.components.ipInfo.labels.autoRefresh")}
|
||||||
|
{countdown.type === "countdown"
|
||||||
|
? `: ${countdown.remainingSeconds}s`
|
||||||
|
: "..."}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
@ -325,6 +411,16 @@ export const IpInfoCard = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</EnhancedCard>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IPInfoCardContainer ref={containerRef}>{mainElement}</IPInfoCardContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function useIPInfo() {
|
||||||
|
return useSWRImmutable(IP_INFO_CACHE_KEY, getIpInfo, {
|
||||||
|
shouldRetryOnError: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -186,10 +186,8 @@ function createPrng(seed: number): () => number {
|
|||||||
export const getIpInfo = async (): Promise<
|
export const getIpInfo = async (): Promise<
|
||||||
IpInfo & { lastFetchTs: number }
|
IpInfo & { lastFetchTs: number }
|
||||||
> => {
|
> => {
|
||||||
const lastFetchTs = Date.now();
|
|
||||||
|
|
||||||
// 配置参数
|
// 配置参数
|
||||||
const maxRetries = 3;
|
const maxRetries = 2;
|
||||||
const serviceTimeout = 5000;
|
const serviceTimeout = 5000;
|
||||||
|
|
||||||
const shuffledServices = shuffleServices();
|
const shuffledServices = shuffleServices();
|
||||||
@ -206,7 +204,7 @@ export const getIpInfo = async (): Promise<
|
|||||||
try {
|
try {
|
||||||
return await asyncRetry(
|
return await asyncRetry(
|
||||||
async (bail) => {
|
async (bail) => {
|
||||||
console.debug("Fetching IP information...");
|
console.debug("Fetching IP information:", service.url);
|
||||||
|
|
||||||
const response = await fetch(service.url, {
|
const response = await fetch(service.url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@ -226,15 +224,18 @@ export const getIpInfo = async (): Promise<
|
|||||||
|
|
||||||
if (data && data.ip) {
|
if (data && data.ip) {
|
||||||
debugLog(`IP检测成功,使用服务: ${service.url}`);
|
debugLog(`IP检测成功,使用服务: ${service.url}`);
|
||||||
return Object.assign(service.mapping(data), { lastFetchTs });
|
return Object.assign(service.mapping(data), {
|
||||||
|
// use last fetch success timestamp
|
||||||
|
lastFetchTs: Date.now(),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`无效的响应格式 from ${service.url}`);
|
throw new Error(`无效的响应格式 from ${service.url}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
retries: maxRetries,
|
retries: maxRetries,
|
||||||
minTimeout: 500,
|
minTimeout: 1000,
|
||||||
maxTimeout: 2000,
|
maxTimeout: 4000,
|
||||||
randomize: true,
|
randomize: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user