1
0

refactor: 前端性能优化 — 倒计时组件隔离、React memoization 链路

- 新建 RefreshCountdown 组件,内部持有 timer,消除 App 每秒重渲染
- TargetBoard 分组逻辑 useMemo 化,避免 targets 引用不变时重复计算
- TargetGroup 加 React.memo,阻断无效渲染
- TrendChart 加 React.memo + chartData useMemo,避免 recharts 不必要重绘
- OverviewTab 统计项去掉 Card 包裹,改用纯 CSS 实现视觉效果
- 同步更新 refresh-control 和 target-detail-drawer spec

性能提升:消除每秒全组件树重渲染,减少 DOM 节点数
This commit is contained in:
2026-05-15 12:02:39 +08:00
parent d6a77b2c6e
commit 86b8cf1950
9 changed files with 152 additions and 63 deletions

View File

@@ -1,15 +1,14 @@
import type { SkeletonProps } from "tdesign-react";
import { useEffect, useState } from "react";
import { RefreshIcon } from "tdesign-icons-react";
import { Alert, Button, Layout, Menu, RadioGroup, Skeleton, Typography } from "tdesign-react";
import { useState } from "react";
import { Alert, Layout, Menu, RadioGroup, Skeleton } from "tdesign-react";
import { RefreshCountdown } from "./components/RefreshCountdown";
import { SummaryCards } from "./components/SummaryCards";
import { TargetBoard } from "./components/TargetBoard";
import { TargetDetailDrawer } from "./components/TargetDetailDrawer";
import { useDashboard } from "./hooks/use-queries";
import { useTargetDetail } from "./hooks/use-target-detail";
import { formatCountdown } from "./utils/time";
const { Content, Header } = Layout;
const DEFAULT_REFRESH_INTERVAL_MS = 30000;
@@ -27,7 +26,6 @@ const REFRESH_OPTIONS = [
] as const;
export function App() {
const [now, setNow] = useState(() => new Date());
const [refreshInterval, setRefreshInterval] = useState(DEFAULT_REFRESH_INTERVAL_MS);
const dashboardRefetchInterval = refreshInterval === 0 ? false : refreshInterval;
const {
@@ -54,27 +52,12 @@ export function App() {
timeTo,
} = useTargetDetail();
const isManualRefresh = refreshInterval === 0;
const nextRefreshSeconds =
dashboardUpdatedAt > 0 && !isManualRefresh
? Math.max(0, Math.ceil((dashboardUpdatedAt + refreshInterval - now.getTime()) / 1000))
: null;
const refreshText =
dashboardUpdatedAt > 0
? dashboardFetching && !dashboardLoading
? "刷新中..."
: formatCountdown(nextRefreshSeconds ?? 0)
: "等待首次刷新";
const handleIntervalChange = (value: number) => {
void refetchDashboard();
setRefreshInterval(value);
};
useEffect(() => {
const timer = window.setInterval(() => setNow(new Date()), 1000);
return () => window.clearInterval(timer);
}, []);
return (
<Layout className="dashboard">
<Header>
@@ -95,19 +78,13 @@ export function App() {
variant="default-filled"
/>
<span className="dashboard-countdown">
{isManualRefresh ? (
<Button
aria-label="刷新 Dashboard"
disabled={dashboardFetching}
icon={<RefreshIcon />}
loading={dashboardFetching}
onClick={() => void refetchDashboard()}
shape="circle"
variant="outline"
/>
) : (
<Typography.Text theme="secondary">{refreshText}</Typography.Text>
)}
<RefreshCountdown
dashboardUpdatedAt={dashboardUpdatedAt}
isFetching={dashboardFetching && !dashboardLoading}
isManualRefresh={isManualRefresh}
onRefresh={() => void refetchDashboard()}
refreshInterval={refreshInterval}
/>
</span>
</div>
}

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { Card, Col, Descriptions, Divider, Row, Skeleton, Space, Statistic, Typography } from "tdesign-react";
import { Col, Descriptions, Divider, Row, Skeleton, Space, Statistic, Typography } from "tdesign-react";
import type { TargetMetricsResponse, TargetStatus } from "../../shared/api";
@@ -97,11 +97,11 @@ export function OverviewTab({ metricsData, metricsLoading, target }: OverviewTab
function OverviewStatItem({ color, suffix, title, value }: OverviewStatItemProps) {
return (
<Card bordered={false} className="overview-stat-card" size="small">
<div className="overview-stat-card">
<div className="overview-stat-item">
<Typography.Text theme="secondary">{title}</Typography.Text>
<Statistic className="overview-stat-value" color={color} suffix={suffix} value={value} />
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { useEffect, useState } from "react";
import { RefreshIcon } from "tdesign-icons-react";
import { Button, Typography } from "tdesign-react";
import { formatCountdown } from "../utils/time";
interface RefreshCountdownProps {
dashboardUpdatedAt: number;
isFetching: boolean;
isManualRefresh: boolean;
onRefresh: () => void;
refreshInterval: number;
}
export function RefreshCountdown({
dashboardUpdatedAt,
isFetching,
isManualRefresh,
onRefresh,
refreshInterval,
}: RefreshCountdownProps) {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const timer = window.setInterval(() => setNow(new Date()), 1000);
return () => window.clearInterval(timer);
}, []);
const nextRefreshSeconds =
dashboardUpdatedAt > 0 && !isManualRefresh
? Math.max(0, Math.ceil((dashboardUpdatedAt + refreshInterval - now.getTime()) / 1000))
: null;
if (isManualRefresh) {
return (
<Button
aria-label="刷新 Dashboard"
disabled={isFetching}
icon={<RefreshIcon />}
loading={isFetching}
onClick={() => void onRefresh()}
shape="circle"
variant="outline"
/>
);
}
const refreshText =
dashboardUpdatedAt > 0 ? (isFetching ? "刷新中..." : formatCountdown(nextRefreshSeconds ?? 0)) : "等待首次刷新";
return <Typography.Text theme="secondary">{refreshText}</Typography.Text>;
}

View File

@@ -19,21 +19,22 @@ export function TargetBoard({ onTargetClick, targets }: TargetBoardProps) {
const checkerTypes = meta?.checkerTypes ?? EMPTY_CHECKER_TYPES;
const columns = useMemo(() => createTargetTableColumns(checkerTypes), [checkerTypes]);
const groups = new Map<string, TargetStatus[]>();
for (const target of targets) {
const group = target.group;
const list = groups.get(group);
if (list) {
list.push(target);
} else {
groups.set(group, [target]);
const sortedGroups = useMemo(() => {
const groups = new Map<string, TargetStatus[]>();
for (const target of targets) {
const group = target.group;
const list = groups.get(group);
if (list) {
list.push(target);
} else {
groups.set(group, [target]);
}
}
}
const sortedGroups = Array.from(groups.entries()).sort(([a]) => {
if (a === "default") return -1;
return 0;
});
return Array.from(groups.entries()).sort(([a]) => {
if (a === "default") return -1;
return 0;
});
}, [targets]);
return (
<Space className="full-width" direction="vertical" size={24}>

View File

@@ -1,5 +1,6 @@
import type { PrimaryTableCol } from "tdesign-react";
import { memo } from "react";
import { Card, PrimaryTable, Space, Tag } from "tdesign-react";
import type { TargetStatus } from "../../shared/api";
@@ -11,7 +12,7 @@ interface TargetGroupProps {
targets: TargetStatus[];
}
export function TargetGroup({ columns, name, onTargetClick, targets }: TargetGroupProps) {
export const TargetGroup = memo(function TargetGroup({ columns, name, onTargetClick, targets }: TargetGroupProps) {
const up = targets.filter((t) => t.latestCheck?.matched).length;
const down = targets.length - up;
const displayName = name === "default" ? "默认分组" : name;
@@ -48,4 +49,4 @@ export function TargetGroup({ columns, name, onTargetClick, targets }: TargetGro
/>
</Card>
);
}
});

View File

@@ -1,3 +1,4 @@
import { memo, useMemo } from "react";
import { Area, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import type { TrendPoint } from "../../shared/api";
@@ -12,18 +13,24 @@ interface TrendChartProps {
data: TrendPoint[];
}
export function TrendChart({ data }: TrendChartProps) {
export const TrendChart = memo(function TrendChart({ data }: TrendChartProps) {
const chartData = useMemo(
() =>
data.map((point) => ({
...point,
durationRange:
point.minDurationMs !== null && point.maxDurationMs !== null
? [point.minDurationMs, point.maxDurationMs]
: null,
label: formatBucketLabel(point.bucketStart),
})),
[data],
);
if (data.length === 0) {
return <div className="trend-empty"></div>;
}
const chartData = data.map((point) => ({
...point,
durationRange:
point.minDurationMs !== null && point.maxDurationMs !== null ? [point.minDurationMs, point.maxDurationMs] : null,
label: formatBucketLabel(point.bucketStart),
}));
return (
<div className="trend-chart">
<ResponsiveContainer height={240} width="100%">
@@ -69,7 +76,7 @@ export function TrendChart({ data }: TrendChartProps) {
</ResponsiveContainer>
</div>
);
}
});
function formatBucketLabel(bucketStart: string): string {
return new Date(bucketStart).toLocaleTimeString("zh-CN", { hour: "2-digit", hour12: false, minute: "2-digit" });

View File

@@ -172,6 +172,8 @@
.overview-stat-card {
background: var(--td-bg-color-container-hover);
border-radius: var(--td-radius-default);
padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xl);
}
.overview-stat-item {