mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-04-18 16:30:32 +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 { ReactNode } from "react";
|
||||
import React, { forwardRef, ReactNode } from "react";
|
||||
|
||||
// 自定义卡片组件接口
|
||||
interface EnhancedCardProps {
|
||||
@ -19,7 +19,9 @@ interface EnhancedCardProps {
|
||||
}
|
||||
|
||||
// 自定义卡片组件
|
||||
export const EnhancedCard = ({
|
||||
export const EnhancedCard = forwardRef<HTMLElement, EnhancedCardProps>(
|
||||
(
|
||||
{
|
||||
title,
|
||||
icon,
|
||||
action,
|
||||
@ -27,7 +29,9 @@ export const EnhancedCard = ({
|
||||
iconColor = "primary",
|
||||
minHeight,
|
||||
noContentPadding = false,
|
||||
}: EnhancedCardProps) => {
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
const isDark = theme.palette.mode === "dark";
|
||||
|
||||
@ -50,6 +54,7 @@ export const EnhancedCard = ({
|
||||
borderRadius: 2,
|
||||
backgroundColor: isDark ? "#282a36" : "#ffffff",
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
@ -118,4 +123,5 @@ export const EnhancedCard = ({
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@ -7,9 +7,18 @@ import {
|
||||
import { Box, Button, IconButton, Skeleton, Typography } from "@mui/material";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
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 useSWR from "swr";
|
||||
import useSWRImmutable from "swr/immutable";
|
||||
|
||||
import { getIpInfo } from "@/services/api";
|
||||
|
||||
@ -54,26 +63,60 @@ const getCountryFlag = (countryCode: string | undefined) => {
|
||||
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信息卡片组件
|
||||
export const IpInfoCard = () => {
|
||||
const { t } = useTranslation();
|
||||
const [showIp, setShowIp] = useState(false);
|
||||
const appWindow = useMemo(() => getCurrentWebviewWindow(), []);
|
||||
|
||||
const [countdown, setCountdown] = useState(IP_REFRESH_SECONDS);
|
||||
|
||||
const {
|
||||
data: ipInfo,
|
||||
error,
|
||||
isLoading,
|
||||
mutate,
|
||||
} = useSWR(IP_INFO_CACHE_KEY, getIpInfo, {
|
||||
refreshInterval: 0,
|
||||
refreshWhenOffline: false,
|
||||
revalidateOnFocus: true,
|
||||
shouldRetryOnError: true,
|
||||
// track ip info card has been in viewport or not
|
||||
// hasIntersected default to false, and will be true once the card is in viewport
|
||||
// and will never be false again afterwards (unless resetIntersected is called or
|
||||
// the component is unmounted)
|
||||
const [containerRef, hasIntersected, _resetIntersected] = useIntersection({
|
||||
rootMargin: "0px",
|
||||
});
|
||||
|
||||
const [countdown, setCountdown] = useState<CountDownState>({
|
||||
type: "countdown",
|
||||
remainingSeconds: IP_REFRESH_SECONDS,
|
||||
});
|
||||
|
||||
const { data: ipInfo, error, isLoading, mutate } = useIPInfo();
|
||||
|
||||
// function useEffectEvent
|
||||
const onCountdownTick = useEffectEvent(async () => {
|
||||
const now = Date.now();
|
||||
@ -86,9 +129,30 @@ export const IpInfoCard = () => {
|
||||
const remaining = IP_REFRESH_SECONDS - elapsed;
|
||||
|
||||
if (remaining <= 0) {
|
||||
if (navigator.onLine && (await appWindow.isVisible())) {
|
||||
mutate();
|
||||
setCountdown(IP_REFRESH_SECONDS);
|
||||
if (
|
||||
// has intersected at least once
|
||||
// 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 {
|
||||
// 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.
|
||||
}
|
||||
} else {
|
||||
setCountdown(remaining);
|
||||
setCountdown({
|
||||
type: "countdown",
|
||||
remainingSeconds: remaining,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Countdown / refresh scheduler — updates UI every 1s and triggers immediate revalidation when expired
|
||||
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 () => {
|
||||
if (timer != null) clearInterval(timer);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
}, [hasIntersected]);
|
||||
|
||||
const toggleShowIp = useCallback(() => {
|
||||
setShowIp((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Loading
|
||||
if (isLoading) {
|
||||
return (
|
||||
<EnhancedCard
|
||||
title={t("home.components.ipInfo.title")}
|
||||
icon={<LocationOnOutlined />}
|
||||
iconColor="info"
|
||||
action={
|
||||
<IconButton size="small" onClick={() => mutate()} disabled>
|
||||
<RefreshOutlined />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
let mainElement: React.ReactElement;
|
||||
|
||||
switch (true) {
|
||||
case isLoading:
|
||||
mainElement = (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<Skeleton variant="text" width="60%" height={30} />
|
||||
<Skeleton variant="text" width="80%" height={24} />
|
||||
<Skeleton variant="text" width="70%" height={24} />
|
||||
<Skeleton variant="text" width="50%" height={24} />
|
||||
</Box>
|
||||
</EnhancedCard>
|
||||
);
|
||||
}
|
||||
|
||||
// Error
|
||||
if (error) {
|
||||
return (
|
||||
<EnhancedCard
|
||||
title={t("home.components.ipInfo.title")}
|
||||
icon={<LocationOnOutlined />}
|
||||
iconColor="info"
|
||||
action={
|
||||
<IconButton size="small" onClick={() => mutate()}>
|
||||
<RefreshOutlined />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
break;
|
||||
case !!error:
|
||||
mainElement = (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
@ -168,22 +260,10 @@ export const IpInfoCard = () => {
|
||||
{t("shared.actions.retry")}
|
||||
</Button>
|
||||
</Box>
|
||||
</EnhancedCard>
|
||||
);
|
||||
}
|
||||
|
||||
// Normal render
|
||||
return (
|
||||
<EnhancedCard
|
||||
title={t("home.components.ipInfo.title")}
|
||||
icon={<LocationOnOutlined />}
|
||||
iconColor="info"
|
||||
action={
|
||||
<IconButton size="small" onClick={() => mutate()}>
|
||||
<RefreshOutlined />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
break;
|
||||
default: // Normal render
|
||||
mainElement = (
|
||||
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
<Box
|
||||
sx={{
|
||||
@ -227,7 +307,8 @@ export const IpInfoCard = () => {
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
{ipInfo?.country || t("home.components.ipInfo.labels.unknown")}
|
||||
{ipInfo?.country ||
|
||||
t("home.components.ipInfo.labels.unknown")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@ -288,7 +369,9 @@ export const IpInfoCard = () => {
|
||||
/>
|
||||
<InfoItem
|
||||
label={t("home.components.ipInfo.labels.location")}
|
||||
value={[ipInfo?.city, ipInfo?.region].filter(Boolean).join(", ")}
|
||||
value={[ipInfo?.city, ipInfo?.region]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
/>
|
||||
<InfoItem
|
||||
label={t("home.components.ipInfo.labels.timezone")}
|
||||
@ -311,7 +394,10 @@ export const IpInfoCard = () => {
|
||||
}}
|
||||
>
|
||||
<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
|
||||
variant="caption"
|
||||
@ -325,6 +411,16 @@ export const IpInfoCard = () => {
|
||||
</Typography>
|
||||
</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<
|
||||
IpInfo & { lastFetchTs: number }
|
||||
> => {
|
||||
const lastFetchTs = Date.now();
|
||||
|
||||
// 配置参数
|
||||
const maxRetries = 3;
|
||||
const maxRetries = 2;
|
||||
const serviceTimeout = 5000;
|
||||
|
||||
const shuffledServices = shuffleServices();
|
||||
@ -206,7 +204,7 @@ export const getIpInfo = async (): Promise<
|
||||
try {
|
||||
return await asyncRetry(
|
||||
async (bail) => {
|
||||
console.debug("Fetching IP information...");
|
||||
console.debug("Fetching IP information:", service.url);
|
||||
|
||||
const response = await fetch(service.url, {
|
||||
method: "GET",
|
||||
@ -226,15 +224,18 @@ export const getIpInfo = async (): Promise<
|
||||
|
||||
if (data && data.ip) {
|
||||
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 {
|
||||
throw new Error(`无效的响应格式 from ${service.url}`);
|
||||
}
|
||||
},
|
||||
{
|
||||
retries: maxRetries,
|
||||
minTimeout: 500,
|
||||
maxTimeout: 2000,
|
||||
minTimeout: 1000,
|
||||
maxTimeout: 4000,
|
||||
randomize: true,
|
||||
},
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user