- frontend-architecture-refactor: 拆分 hooks/组件、类型筛选器动态化 - http-checker-quality-hardening: ReDoS 防护、failure 格式修正、测试补全
6.4 KiB
Context
当前前端代码约 970 行,功能完整但存在以下架构问题:
- hook 职责过重:
hooks/useTargetDetail.ts(113 行)同时承载全局查询(useSummary、useTargets)、Drawer 状态管理、条件查询和通用fetchJson封装,文件名与实际职责不匹配 - 组件体积膨胀:
TargetDetailDrawer.tsx(228 行)混合了时间选择逻辑、两个 Tab 的完整渲染、列定义和统计计算 - 类型维护重复:
target-type-display.ts和target-table-filters.ts各自硬编码 checker 类型列表,新增 checker 需改两处前端文件 - 测试覆盖不足:仅
constants/下 4 个纯函数有测试,utils/time.ts和组件内统计逻辑未覆盖 - 小问题:
StatusDonut用数组索引做 key、StatusBar硬编码 30 格、TrendChart冗余 loading prop
后端 CheckerRegistry 已有 supportedTypes 属性,可直接暴露给前端。项目未上线,不需要向前兼容。
Goals / Non-Goals
Goals:
- hook 按职责拆分,文件名匹配实际内容
TargetDetailDrawer拆分为 3 个组件,每个 < 100 行- 类型筛选器由后端 meta API 驱动,新增 checker 前端零改动
- 删除 type label 转换层,直接使用 type 原始文本
- 补齐前端纯函数测试
- 修复已知小问题
Non-Goals:
- 不引入路由库或状态管理库
- 不重构后端 API 结构
- 不改变现有轮询策略和 QueryClient 配置
- 不新增 CSS 文件(继续使用单一
styles.css)
Decisions
Decision 1: hook 拆分策略
选择:按查询层级拆分为 use-queries.ts 和 use-target-detail.ts
use-queries.ts:queryKeys、fetchJson(不导出)、useSummary、useTargets、useMetause-target-detail.ts:仅保留 Drawer 状态管理(selectedTargetId、时间范围、分页、openDrawer/closeDrawer)和条件查询(trend/history)
理由:全局查询(summary/targets/meta)是面板级别的,与 Drawer 详情无关。拆分后各文件职责单一,命名自解释。fetchJson 仅被 query 层使用,留在 use-queries.ts 内部不导出。
备选方案:
- 仅重命名为
useQueries.ts— 解决命名问题但不解决职责混合 - 每个 query 一个文件 — 过度拆分,增加文件数量但无实质收益
Decision 2: TargetDetailDrawer 拆分方式
选择:拆为 3 个组件 + 1 个常量文件
TargetDetailDrawer.tsx ← Drawer 壳 + 时间选择 + Tab 切换
OverviewTab.tsx ← 统计 + TrendChart + StatusDonut + Descriptions
HistoryTab.tsx ← PrimaryTable + 分页
constants/history-table-columns.tsx ← HISTORY_COLUMNS
理由:两个 Tab 的内容完全独立,拆分后各组件 < 100 行。HISTORY_COLUMNS 与 TARGET_TABLE_COLUMNS 性质相同,应放在 constants/ 下保持一致。
统计计算逻辑(totalChecks/upChecks/downChecks)提取为 utils/stats.ts 纯函数,便于测试和 useMemo。
Decision 3: Meta API 设计
选择:GET /api/meta 返回 { checkerTypes: string[] }
// src/shared/api.ts
export interface MetaResponse {
checkerTypes: string[];
}
理由:
- 从
checkerRegistry.supportedTypes直接获取,无需额外维护 - 返回
string[]而非{ key, label }[],因为决策是不做 label 转换 - 端点命名为
/api/meta而非/api/types,为未来扩展预留空间(如版本号、功能开关等) staleTime: Infinity,应用生命周期内只请求一次
备选方案:
- 从 targets 响应中动态提取 — 只能获取"当前有数据的类型",不能获取"系统支持的全部类型"
- 在 health 端点中附带 — 语义不匹配,health 应保持最小化
Decision 4: 类型展示策略
选择:删除 target-type-display.ts,所有展示位置直接使用 target.type 原始文本
影响位置:
target-table-columns.tsx类型列 cell:row.type直接渲染TargetDetailDrawer.tsx标题栏 Tag:target.type直接渲染typeFilter列表:从 meta API 获取,label 和 value 均为原始 type 文本
理由:type 文本本身已足够清晰(http、command、tcp),无需额外映射层。消除了前后端重复维护的问题。
Decision 5: 列定义动态化
选择:TARGET_TABLE_COLUMNS 从静态常量改为工厂函数
export function createTargetTableColumns(checkerTypes: string[]): PrimaryTableCol<TargetStatus>[] {
const typeFilter = {
list: [
{ label: "全部", value: "" },
...checkerTypes.map(t => ({ label: t, value: t })),
],
type: "single" as const,
};
// ... 返回列定义数组
}
数据流:useMeta() → checkerTypes → createTargetTableColumns(checkerTypes) → TargetGroup columns prop
理由:typeFilter 是唯一需要动态数据的部分,通过工厂函数注入参数,保持列定义的纯函数特性。statusFilter 保持静态(UP/DOWN 是固定的)。
TargetGroup 新增 columns prop 接收动态列定义,TargetBoard 负责调用工厂函数并传递。
Decision 6: StatusBar maxSlots 参数化
选择:新增 maxSlots prop(默认 30),组件根据 prop 渲染格数
interface StatusBarProps {
samples: Array<{ up: boolean }>;
maxSlots?: number;
}
理由:消除硬编码魔数,使组件可复用。默认值 30 保持向后兼容。
Decision 7: TrendChart 移除 loading prop
选择:移除 loading prop,组件只接收 data: TrendPoint[]
理由:调用方(OverviewTab)已用 Skeleton 处理 loading 状态,TrendChart 只在有数据时渲染。组件内部的 if (loading) 分支和 loading={false} 传参都是死代码。
Risks / Trade-offs
| Risk | Mitigation |
|---|---|
列定义改为函数后,每次 checkerTypes 变化会重新创建列数组 |
useMemo 包裹 createTargetTableColumns 调用;meta 数据 staleTime: Infinity 确保不会频繁变化 |
| meta API 在 targets 之前未返回时,筛选器暂时为空 | meta 请求极轻量(无 DB 查询),通常先于 targets 返回;即使晚到,筛选器会在数据到达后自动出现 |
| 拆分后组件间 props 传递增多 | 层级仅增加一层(Drawer → Tab),props 类型明确,不会造成 prop drilling 问题 |
Open Questions
无。方案已在 explore 阶段与用户确认。