导航切换缓存刷新机制
功能概述
切换导航页面时,自动从两个 NE(网元)接口获取真实数据,内部页面使用缓存数据展示。同时提供请求去重、竞态条件防护、异常兜底等机制,确保页面在数据加载失败时不会白屏。
涉及文件
| 文件 | 角色 |
|---|---|
src/app/(main)/layout.tsx |
入口:监听路由变化,主动获取两个接口数据并写入 store |
src/store/store.ts |
数据中心:定义缓存结构类型,提供全局状态 |
src/app/(main)/ne/neConfig/feature/ConfigDetail.tsx |
消费者:从 store 读取缓存展示,并提供手动刷新能力 |
数据流架构
路由变化 (pathname)
│
▼
layout.tsx useEffect
│
├─ ① 取消上一次请求 (fetchAbortRef.abort)
│
├─ ② 创建新 AbortController + 递增 fetchId
│
├─ ③ await neService.getNeData()
│ └─ 重组排序:connected 在前,disconnected 在后
│ └─ 写入 store.setNeData()
│ └─ 触发 page.tsx → ConfigDetail 挂载
│
└─ ④ fetchId 核对 → getAllTypeData(ne.uid)
└─ try-catch 保护
└─ 写入 store.setAllTypeData({ uid, data })
└─ 触发 ConfigDetail 重新读取缓存
缓存读取流程(ConfigDetail)
挂载 / typeActiveTabKey 变化
│
▼
initialTableData(typeActiveTabKey)
│
├─ 检查 allTypeData 是否为 null → 空表 ✅
├─ 检查 allTypeData.uid === neUid → 不匹配时空表 ✅
├─ 检查 allTypeData.data[type] 是否有数据 → 空表 ✅
└─ 有数据 → getTableData → 展示 ✅
手动刷新流程(ConfigDetail)
用户点击 reload
│
▼
handleTypeChange(key, uid)
│
├─ 取消上一次请求 (abortControllerRef.abort)
├─ 创建新 AbortController
├─ 发起 API 请求(支持 AbortSignal)
├─ 检查 controller.signal.aborted → 取消则不处理
├─ 成功 → getTableData → 更新表格
└─ 失败 → catch → setAllDataSource([]) → finally → setLoading(false)
三个文件的改动详情
1. layout.tsx
新增变量:
fetchAbortRef--- useRef<AbortController>,用于取消上一次未完成的请求fetchIdRef--- useRef<number>,递增计数器,确保只应用最新请求的响应
新增/修改函数:
getAllTypeData--- 添加 try-catch,写入 store 时携带{ uid, data }结构
核心 useEffect(依赖 pathname):
每次路由变化:
1. 跳过公开路由(/login, /register, /forgot-password, /auth)
2. 取消上一次请求
3. 创建新 AbortController + 递增 fetchId
4. 调用 getNeData(),成功后:
a. 检查 controller.signal.aborted(取消则不处理)
b. 排序重组 NE 列表 → setNeData()
c. 核对 fetchId 一致性 → getAllTypeData(ne.uid)
5. 清理函数 → 取消请求
6. AbortError 静默忽略
2. store.ts
新增接口定义:
typescript
interface AllTypeDataCache {
uid: string; // 该缓存所属的网元 ID
data: Record<string, any>; // 按类型分组的缓存数据
}
类型变更:
| 字段 | 之前 | 之后 |
|---|---|---|
allTypeData |
any |
`AllTypeDataCache |
setAllTypeData 参数 |
(data: any) |
`(data: AllTypeDataCache |
初始值变更:
typescript
// 之前:空对象,语义上容易误认为 "缓存已就绪但为空"
allTypeData: {},
// 之后:null,明确表示 "尚未获取任何缓存"
allTypeData: null,
3. ConfigDetail.tsx
新增变量:
abortControllerRef--- useRef<AbortController>,每个 ConfigDetail 实例独立隔离- 卸载 cleanup useEffect --- 组件销毁时取消正在进行的请求
函数改动:
| 函数 | 改动 |
|---|---|
getCardPreConf |
添加 try-catch,失败时初始化为 8 个空槽位 |
getStateData |
添加 try-catch |
getChassisData |
添加 try-catch |
handleTypeChange |
重构:AbortController per-instance + try-catch-finally + loading 在 finally 中清除 |
initialTableData |
添加 !allTypeData 与 allTypeData.uid !== neUid 双重防御 |
删除的死代码:
isLoadingRef--- 只写不读,已删除if (!options)分支 --- options 初始值始终为 truthy,无效守卫
关键设计决策
为什么要按 uid 隔离缓存
allTypeData 是全局 store 变量。如果仅存裸数据 { chassis: [...], allcard: [...] },则打开多 Tab(不同 NE)时,Tab-B 会读到 Tab-A 的缓存,显示错误数据。
改为 { uid: string, data: {...} } 后,Tab-B 读取时检查 allTypeData.uid !== neUid,发现不匹配直接返回空数据,保证不会显示错误数据。
为什么用 useRef 管理 AbortController
每个 ConfigDetail 实例需要独立的 AbortController,否则一个 Tab 的请求会取消另一个 Tab 的请求。useRef 保证每个组件实例有自己独立的引用。
为什么 try-catch-finally 是唯一正确模式
在 handleTypeChange 中,setLoading(false) 放在 finally 块中,保证无论成功、失败、还是异常,loading 状态都会被清除。这是 React 中处理异步 loading 的标准模式。
维护规则(供后续页面沿用)
规则 1:Layout 只负责刷新缓存
Layout 只做两件事:
→ getNeData() 写入 neData(网元列表)
→ getAllTypeData() 写入 allTypeData(全局缓存池)
其他页面自己的数据在各自组件内管理
规则 2:缓存数据必须按业务 key 隔离
typescript
// ✅ 正确:带 uid 标记
setAllTypeData({ uid: id, data: res.data });
// ❌ 错误:全局裸对象,多实例会互相覆盖
setAllTypeData(res.data);
规则 3:读缓存时校验归属
typescript
// ✅ 正确的读缓存模式
if (!allTypeData || allTypeData.uid !== myUid) {
setDataSource([]);
return;
}
const data = allTypeData.data[type];
规则 4:所有 async 函数必须 try-catch,loading 必须 finally 清除
typescript
const fetchData = async () => {
setLoading(true);
try {
const res = await api();
if (!res?.data) { setDataSource([]); return; }
setDataSource(res.data);
} catch (error) {
console.warn('[Component] fetch failed:', error);
setDataSource([]);
} finally {
setLoading(false); // ❗保证一定被清除
}
};
规则 5:请求去重 + 竞态防护
typescript
const abortRef = useRef<AbortController | null>(null);
const doFetch = async () => {
if (abortRef.current) abortRef.current.abort();
const controller = new AbortController();
abortRef.current = controller;
const res = await fetch(url, { signal: controller.signal });
if (controller.signal.aborted) return; // 过时响应不处理
setData(res);
};
// 卸载时取消
useEffect(() => () => abortRef.current?.abort(), []);