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:
Sukka 2026-02-06 17:09:36 +08:00 committed by GitHub
parent 781313e8f0
commit e9d63aee5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 377 additions and 274 deletions

View File

@ -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,103 +19,109 @@ interface EnhancedCardProps {
} }
// 自定义卡片组件 // 自定义卡片组件
export const EnhancedCard = ({ export const EnhancedCard = forwardRef<HTMLElement, EnhancedCardProps>(
title, (
icon, {
action, title,
children, icon,
iconColor = "primary", action,
minHeight, children,
noContentPadding = false, iconColor = "primary",
}: EnhancedCardProps) => { minHeight,
const theme = useTheme(); noContentPadding = false,
const isDark = theme.palette.mode === "dark"; },
ref,
) => {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
// 统一的标题截断样式 // 统一的标题截断样式
const titleTruncateStyle = { const titleTruncateStyle = {
minWidth: 0, minWidth: 0,
maxWidth: "100%", maxWidth: "100%",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
whiteSpace: "nowrap", whiteSpace: "nowrap",
display: "block", display: "block",
}; };
return ( return (
<Box
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
borderRadius: 2,
backgroundColor: isDark ? "#282a36" : "#ffffff",
}}
>
<Box <Box
sx={{ sx={{
px: 2, height: "100%",
py: 1,
display: "flex", display: "flex",
alignItems: "center", flexDirection: "column",
justifyContent: "space-between", borderRadius: 2,
borderBottom: 1, backgroundColor: isDark ? "#282a36" : "#ffffff",
borderColor: "divider",
}} }}
ref={ref}
> >
<Box <Box
sx={{ sx={{
px: 2,
py: 1,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
minWidth: 0, justifyContent: "space-between",
flex: 1, borderBottom: 1,
overflow: "hidden", borderColor: "divider",
}} }}
> >
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", minWidth: 0,
borderRadius: 1.5, flex: 1,
width: 38, overflow: "hidden",
height: 38,
mr: 1.5,
flexShrink: 0,
backgroundColor: alpha(theme.palette[iconColor].main, 0.12),
color: theme.palette[iconColor].main,
}} }}
> >
{icon} <Box
</Box> sx={{
<Box sx={{ minWidth: 0, flex: 1 }}> display: "flex",
{typeof title === "string" ? ( alignItems: "center",
<Typography justifyContent: "center",
variant="h6" borderRadius: 1.5,
fontWeight="medium" width: 38,
fontSize={18} height: 38,
sx={titleTruncateStyle} mr: 1.5,
title={title} flexShrink: 0,
> backgroundColor: alpha(theme.palette[iconColor].main, 0.12),
{title} color: theme.palette[iconColor].main,
</Typography> }}
) : ( >
<Box sx={titleTruncateStyle}>{title}</Box> {icon}
)} </Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
{typeof title === "string" ? (
<Typography
variant="h6"
fontWeight="medium"
fontSize={18}
sx={titleTruncateStyle}
title={title}
>
{title}
</Typography>
) : (
<Box sx={titleTruncateStyle}>{title}</Box>
)}
</Box>
</Box> </Box>
{action && <Box sx={{ ml: 2, flexShrink: 0 }}>{action}</Box>}
</Box>
<Box
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
p: noContentPadding ? 0 : 2,
...(minHeight && { minHeight }),
}}
>
{children}
</Box> </Box>
{action && <Box sx={{ ml: 2, flexShrink: 0 }}>{action}</Box>}
</Box> </Box>
<Box );
sx={{ },
flex: 1, );
display: "flex",
flexDirection: "column",
p: noContentPadding ? 0 : 2,
...(minHeight && { minHeight }),
}}
>
{children}
</Box>
</Box>
);
};

View File

@ -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:
mainElement = (
// Error
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,163 +260,167 @@ export const IpInfoCard = () => {
{t("shared.actions.retry")} {t("shared.actions.retry")}
</Button> </Button>
</Box> </Box>
</EnhancedCard> );
); break;
} default: // Normal render
mainElement = (
// Normal render <Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
return ( <Box
<EnhancedCard sx={{
title={t("home.components.ipInfo.title")} display: "flex",
icon={<LocationOnOutlined />} flexDirection: "row",
iconColor="info" flex: 1,
action={ overflow: "hidden",
<IconButton size="small" onClick={() => mutate()}> }}
<RefreshOutlined /> >
</IconButton> {/* 左侧国家和IP地址 */}
} <Box sx={{ width: "40%", overflow: "hidden" }}>
>
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
<Box
sx={{
display: "flex",
flexDirection: "row",
flex: 1,
overflow: "hidden",
}}
>
{/* 左侧国家和IP地址 */}
<Box sx={{ width: "40%", overflow: "hidden" }}>
<Box
sx={{
display: "flex",
alignItems: "center",
mb: 1,
overflow: "hidden",
}}
>
<Box
component="span"
sx={{
fontSize: "1.5rem",
mr: 1,
display: "inline-block",
width: 28,
textAlign: "center",
flexShrink: 0,
fontFamily: '"twemoji mozilla", sans-serif',
}}
>
{getCountryFlag(ipInfo?.country_code)}
</Box>
<Typography
variant="subtitle1"
sx={{
fontWeight: "medium",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: "100%",
}}
>
{ipInfo?.country || t("home.components.ipInfo.labels.unknown")}
</Typography>
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
<Typography
variant="body2"
color="text.secondary"
sx={{ flexShrink: 0 }}
>
{t("home.components.ipInfo.labels.ip")}:
</Typography>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
ml: 1, mb: 1,
overflow: "hidden", overflow: "hidden",
maxWidth: "calc(100% - 30px)",
}} }}
> >
<Typography <Box
variant="body2" component="span"
sx={{ sx={{
fontFamily: "monospace", fontSize: "1.5rem",
fontSize: "0.75rem", mr: 1,
overflow: "hidden", display: "inline-block",
textOverflow: "ellipsis", width: 28,
wordBreak: "break-all", textAlign: "center",
flexShrink: 0,
fontFamily: '"twemoji mozilla", sans-serif',
}} }}
> >
{showIp ? ipInfo?.ip : "••••••••••"} {getCountryFlag(ipInfo?.country_code)}
</Box>
<Typography
variant="subtitle1"
sx={{
fontWeight: "medium",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: "100%",
}}
>
{ipInfo?.country ||
t("home.components.ipInfo.labels.unknown")}
</Typography> </Typography>
<IconButton size="small" onClick={toggleShowIp}>
{showIp ? (
<VisibilityOffOutlined fontSize="small" />
) : (
<VisibilityOutlined fontSize="small" />
)}
</IconButton>
</Box> </Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
<Typography
variant="body2"
color="text.secondary"
sx={{ flexShrink: 0 }}
>
{t("home.components.ipInfo.labels.ip")}:
</Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
ml: 1,
overflow: "hidden",
maxWidth: "calc(100% - 30px)",
}}
>
<Typography
variant="body2"
sx={{
fontFamily: "monospace",
fontSize: "0.75rem",
overflow: "hidden",
textOverflow: "ellipsis",
wordBreak: "break-all",
}}
>
{showIp ? ipInfo?.ip : "••••••••••"}
</Typography>
<IconButton size="small" onClick={toggleShowIp}>
{showIp ? (
<VisibilityOffOutlined fontSize="small" />
) : (
<VisibilityOutlined fontSize="small" />
)}
</IconButton>
</Box>
</Box>
<InfoItem
label={t("home.components.ipInfo.labels.asn")}
value={ipInfo?.asn ? `AS${ipInfo.asn}` : "N/A"}
/>
</Box> </Box>
<InfoItem {/* 右侧组织、ISP和位置信息 */}
label={t("home.components.ipInfo.labels.asn")} <Box sx={{ width: "60%", overflow: "auto" }}>
value={ipInfo?.asn ? `AS${ipInfo.asn}` : "N/A"} <InfoItem
/> label={t("home.components.ipInfo.labels.isp")}
value={ipInfo?.organization}
/>
<InfoItem
label={t("home.components.ipInfo.labels.org")}
value={ipInfo?.asn_organization}
/>
<InfoItem
label={t("home.components.ipInfo.labels.location")}
value={[ipInfo?.city, ipInfo?.region]
.filter(Boolean)
.join(", ")}
/>
<InfoItem
label={t("home.components.ipInfo.labels.timezone")}
value={ipInfo?.timezone}
/>
</Box>
</Box> </Box>
{/* 右侧组织、ISP和位置信息 */} <Box
<Box sx={{ width: "60%", overflow: "auto" }}>
<InfoItem
label={t("home.components.ipInfo.labels.isp")}
value={ipInfo?.organization}
/>
<InfoItem
label={t("home.components.ipInfo.labels.org")}
value={ipInfo?.asn_organization}
/>
<InfoItem
label={t("home.components.ipInfo.labels.location")}
value={[ipInfo?.city, ipInfo?.region].filter(Boolean).join(", ")}
/>
<InfoItem
label={t("home.components.ipInfo.labels.timezone")}
value={ipInfo?.timezone}
/>
</Box>
</Box>
<Box
sx={{
mt: "auto",
pt: 0.5,
borderTop: 1,
borderColor: "divider",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
opacity: 0.7,
fontSize: "0.7rem",
}}
>
<Typography variant="caption">
{t("home.components.ipInfo.labels.autoRefresh")}: {countdown}s
</Typography>
<Typography
variant="caption"
sx={{ sx={{
textOverflow: "ellipsis", mt: "auto",
overflow: "hidden", pt: 0.5,
whiteSpace: "nowrap", borderTop: 1,
borderColor: "divider",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
opacity: 0.7,
fontSize: "0.7rem",
}} }}
> >
{`${ipInfo?.country_code ?? "N/A"}, ${ipInfo?.longitude?.toFixed(2) ?? "N/A"}, ${ipInfo?.latitude?.toFixed(2) ?? "N/A"}`} <Typography variant="caption">
</Typography> {t("home.components.ipInfo.labels.autoRefresh")}
{countdown.type === "countdown"
? `: ${countdown.remainingSeconds}s`
: "..."}
</Typography>
<Typography
variant="caption"
sx={{
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
}}
>
{`${ipInfo?.country_code ?? "N/A"}, ${ipInfo?.longitude?.toFixed(2) ?? "N/A"}, ${ipInfo?.latitude?.toFixed(2) ?? "N/A"}`}
</Typography>
</Box>
</Box> </Box>
</Box> );
</EnhancedCard> }
return (
<IPInfoCardContainer ref={containerRef}>{mainElement}</IPInfoCardContainer>
); );
}; };
function useIPInfo() {
return useSWRImmutable(IP_INFO_CACHE_KEY, getIpInfo, {
shouldRetryOnError: true,
});
}

View File

@ -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,
}, },
); );