From 86b8cf1950bbd21d63fe837f6e918970dee80503 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Fri, 15 May 2026 12:02:39 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=89=8D=E7=AB=AF=E6=80=A7?= =?UTF-8?q?=E8=83=BD=E4=BC=98=E5=8C=96=20=E2=80=94=20=E5=80=92=E8=AE=A1?= =?UTF-8?q?=E6=97=B6=E7=BB=84=E4=BB=B6=E9=9A=94=E7=A6=BB=E3=80=81React=20m?= =?UTF-8?q?emoization=20=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 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 节点数 --- openspec/specs/refresh-control/spec.md | 21 ++++++++- openspec/specs/target-detail-drawer/spec.md | 32 ++++++++++++- src/web/app.tsx | 43 ++++------------- src/web/components/OverviewTab.tsx | 6 +-- src/web/components/RefreshCountdown.tsx | 52 +++++++++++++++++++++ src/web/components/TargetBoard.tsx | 29 ++++++------ src/web/components/TargetGroup.tsx | 5 +- src/web/components/TrendChart.tsx | 25 ++++++---- src/web/styles.css | 2 + 9 files changed, 152 insertions(+), 63 deletions(-) create mode 100644 src/web/components/RefreshCountdown.tsx diff --git a/openspec/specs/refresh-control/spec.md b/openspec/specs/refresh-control/spec.md index 707c08e..3c2b041 100644 --- a/openspec/specs/refresh-control/spec.md +++ b/openspec/specs/refresh-control/spec.md @@ -20,7 +20,15 @@ HeadMenu operations 区域 SHALL 提供 RadioGroup 组件供用户选择刷新 - **THEN** 系统 SHALL 立即触发一次数据刷新,然后应用新的刷新间隔 ### Requirement: 倒计时显示 -RadioGroup 右侧 SHALL 显示距下次自动刷新的倒计时文本。 +RadioGroup 右侧 SHALL 显示距下次自动刷新的倒计时文本。倒计时逻辑 SHALL 封装在独立的 `RefreshCountdown` 组件中,App 组件 SHALL NOT 持有每秒更新的 `now` state。 + +#### Scenario: RefreshCountdown 组件封装 +- **WHEN** Dashboard 页面渲染 +- **THEN** 倒计时显示 SHALL 由独立的 `RefreshCountdown` 组件负责,该组件内部持有 `now` state 和每秒 `setInterval`,渲染边界限制在该组件内部 + +#### Scenario: RefreshCountdown props +- **WHEN** RefreshCountdown 组件渲染 +- **THEN** 组件 SHALL 接收 `dashboardUpdatedAt: number`、`refreshInterval: number`、`isFetching: boolean`、`isManualRefresh: boolean`、`onRefresh: () => void` 作为 props #### Scenario: 短时间格式 - **WHEN** 距下次刷新剩余时间小于 60 秒 @@ -38,6 +46,17 @@ RadioGroup 右侧 SHALL 显示距下次自动刷新的倒计时文本。 - **WHEN** 数据正在刷新(isFetching=true 且 isLoading=false) - **THEN** 倒计时文本 SHALL 显示为"刷新中..." +### Requirement: App 组件渲染隔离 +App 组件 SHALL NOT 持有任何高频更新的 state(如每秒更新的时钟),确保 App 的重渲染频率与数据刷新频率一致(默认 30 秒一次)。 + +#### Scenario: App 无 now state +- **WHEN** App 组件渲染 +- **THEN** App SHALL NOT 包含 `useState` 管理的时钟 state,也 SHALL NOT 包含每秒触发的 `setInterval` + +#### Scenario: App 重渲染频率 +- **WHEN** Dashboard 处于自动刷新模式 +- **THEN** App 组件的重渲染 SHALL 仅由 TanStack Query 的 refetch 触发(频率等于用户选择的刷新间隔),而非每秒触发 + ### Requirement: 手动刷新按钮 选择"手动"模式时,倒计时区域 SHALL 替换为刷新按钮。 diff --git a/openspec/specs/target-detail-drawer/spec.md b/openspec/specs/target-detail-drawer/spec.md index 0b25a83..9708a39 100644 --- a/openspec/specs/target-detail-drawer/spec.md +++ b/openspec/specs/target-detail-drawer/spec.md @@ -72,6 +72,36 @@ TrendChart 组件 SHALL 仅接收数据 props,不处理 loading 状态。 - **WHEN** TrendChart 接收空数组 - **THEN** 组件 SHALL 显示"暂无趋势数据"占位文本 +#### Scenario: TrendChart memo 包裹 +- **WHEN** TrendChart 的父组件重渲染但 data prop 引用未变 +- **THEN** TrendChart SHALL 跳过重渲染(通过 React.memo shallow compare) + +#### Scenario: chartData useMemo +- **WHEN** TrendChart 渲染 +- **THEN** 内部 `chartData` 转换结果 SHALL 通过 `useMemo` 缓存,依赖为 `[data]`,data 引用不变时不重新计算 + +### Requirement: TargetBoard 分组 memoize +TargetBoard 组件的分组计算 SHALL 使用 useMemo 缓存,避免 targets 引用不变时重复计算分组。 + +#### Scenario: 分组结果 useMemo +- **WHEN** TargetBoard 渲染 +- **THEN** 分组逻辑(Map 构建 + sort)SHALL 通过 `useMemo` 缓存,依赖为 `[targets]` + +#### Scenario: targets 引用不变时跳过分组 +- **WHEN** TargetBoard 因父组件重渲染而重渲染,但 targets prop 引用未变 +- **THEN** 分组计算 SHALL 返回缓存结果,不重新执行 Map 构建和排序 + +### Requirement: TargetGroup 渲染优化 +TargetGroup 组件 SHALL 使用 React.memo 包裹,在 props 引用不变时跳过重渲染。 + +#### Scenario: TargetGroup memo 包裹 +- **WHEN** TargetBoard 重渲染但某个分组的 targets 数组引用未变 +- **THEN** 对应的 TargetGroup SHALL 跳过重渲染(通过 React.memo shallow compare) + +#### Scenario: TargetGroup props 稳定性 +- **WHEN** TargetGroup 渲染 +- **THEN** 其 props(columns、name、targets、onTargetClick)SHALL 全部具有引用稳定性:columns 通过 useMemo、name 为 string 原始值、targets 通过分组 useMemo、onTargetClick 通过 useCallback + ### Requirement: StatusBar 参数化 StatusBar 组件 SHALL 支持可配置的格数。 @@ -189,7 +219,7 @@ Drawer 内部 SHALL 使用 TDesign Tabs 组织概览和记录两个面板。Tabs #### Scenario: 统计区左右布局卡片 - **WHEN** 概览面板渲染且有统计数据 -- **THEN** 面板 SHALL 在"统计"区域使用 4 列 × 2 行的 Row/Col 布局,每个统计项使用 Card 包裹,Card 内标题左对齐、数值右对齐,数值使用普通文本字号 +- **THEN** 面板 SHALL 在"统计"区域使用 4 列 × 2 行的 Row/Col 布局,每个统计项使用 `
` 包裹,通过 CSS 类实现背景色和内边距视觉效果 #### Scenario: 统计区内容 - **WHEN** 概览面板渲染 diff --git a/src/web/app.tsx b/src/web/app.tsx index f988af7..f5c64bc 100644 --- a/src/web/app.tsx +++ b/src/web/app.tsx @@ -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 (
@@ -95,19 +78,13 @@ export function App() { variant="default-filled" /> - {isManualRefresh ? ( -
} diff --git a/src/web/components/OverviewTab.tsx b/src/web/components/OverviewTab.tsx index 9683806..a9628cf 100644 --- a/src/web/components/OverviewTab.tsx +++ b/src/web/components/OverviewTab.tsx @@ -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 ( - +
{title}
- +
); } diff --git a/src/web/components/RefreshCountdown.tsx b/src/web/components/RefreshCountdown.tsx new file mode 100644 index 0000000..d38a877 --- /dev/null +++ b/src/web/components/RefreshCountdown.tsx @@ -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 ( +