正在加载文档...
文档内容较大,正在处理中,请稍候
正在加载文档...
文档内容较大,正在处理中,请稍候
想象一下,你在浏览器中打开了多个标签页(比如:用户管理、角色管理、菜单管理)。当你从一个标签页切换到另一个标签页时,页面缓存系统会帮你:
缓存系统使用 Map 数据结构来存储页面组件:
// 核心存储结构
const cacheStore = new Map(); // 存储:key -> { element: ReactElement, location: Location }
const accessOrder = []; // 存储:访问顺序数组,用于 LRU 算法简单理解:
cacheStore:就像一个大仓库,每个页面都有一个编号(key),对应一个包含页面组件和路由信息的对象accessOrder:记录页面的访问顺序,最近访问的排在最后注意:位置信息(location)直接存储在 cacheStore 的 value 中,不再单独维护 locationStore。
每个页面都有一个唯一的标识符,由 路径 + 查询参数 组成:
// 例如:
"/setting/user"; // 用户管理页面
"/setting/user?page=2"; // 用户管理页面,第2页(不同的 key!)
"/setting/role"; // 角色管理页面注意:缓存 key 由 pathname + search 组成,查询参数不同会生成不同的缓存 key。
重要:即使路径相同,查询参数不同,也会被视为不同的页面,需要分别缓存。
有些页面不需要缓存,每次访问都重新渲染。这些页面在白名单中:
const CACHE_WHITELIST = [
"/", // 首页
"/userinfo", // 用户信息
"/dashboard", // 数据看板
"/setting/cache", // 缓存管理页面
"/setting/log/loginlog", // 登录日志
"/setting/log/operlog", // 操作日志
"/setting/notice", // 通知管理
"/setting/notice/info", // 通知详情
"/monitor/online", // 在线用户
"/monitor/error", // 错误监控
"/aichat/glm", // AI图表
"/setting/role/info", // 角色详情
"/function/lazyimgs", // 懒加载图片
"/function/virtualscroll", // 虚拟滚动
];为什么需要白名单?
LRU = 最近最少使用
当缓存数量超过限制(默认 8 个)时,系统会自动删除最久未使用的页面缓存。
工作原理:
示例:
访问顺序:['/page1', '/page2', '/page3', '/page4', '/page5', '/page6', '/page7', '/page8']
访问 /page1 → ['/page2', '/page3', '/page4', '/page5', '/page6', '/page7', '/page8', '/page1']
访问 /page9 → 缓存已满,删除 /page2(最久未使用)┌─────────────────────────────────────────────────────────────┐
│ BasicLayout(布局组件) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ TabsContainer(标签页容器) │ │
│ │ - 显示标签页 │ │
│ │ - 右键菜单(刷新、关闭等) │ │
│ │ - 拖拽排序 │ │
│ └───────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ KeepAliveOutlet(缓存核心组件) │ │
│ │ - 管理页面缓存 │ │
│ │ - LRU 算法 │ │
│ │ - 渲染缓存的页面 │ │
│ │ - 直接导入清除函数 │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
│ 订阅/发布消息
▼
┌─────────────────────────────────────────────────────────────┐
│ useGlobalMessage(全局消息系统) │
│ - 发布 keep:alive:drop(删除单个缓存) │
│ - 发布 keep:alive:clear(清除所有缓存) │
└─────────────────────────────────────────────────────────────┘
│
│ 管理标签页状态
▼
┌─────────────────────────────────────────────────────────────┐
│ useTabsManager(标签页管理) │
│ - 添加/关闭标签页 │
│ - 切换标签页 │
│ - 发送清除缓存消息 │
│ - 直接导入清除函数 │
└─────────────────────────────────────────────────────────────┘
│
│ 确保数据只加载一次
▼
┌─────────────────────────────────────────────────────────────┐
│ usePageCacheInit(页面缓存初始化 Hook) │
│ - 统一管理页面初始化逻辑 │
│ - 处理首次加载和刷新 │
│ - 自动处理缓存恢复 │
│ - 导出清除函数(clearPageInitialized) │
└─────────────────────────────────────────────────────────────┘
│
│ 分页场景(已整合到同一文件)
▼
┌─────────────────────────────────────────────────────────────┐
│ usePagination(分页 Hook) │
│ - 统一处理分页逻辑 │
│ - 避免缓存恢复时重复请求 │
│ - 已整合到 usePageCacheInit.js │
└─────────────────────────────────────────────────────────────┘usePageCacheInit.js位置:src/components/KeepAlive/index.jsx
职责:
// 缓存存储(模块级变量,所有实例共享)
const cacheStore = new Map(); // key -> { element: ReactElement, location: Location }
const accessOrder = []; // 访问顺序数组,用于 LRU 算法
// 暴露到全局的工具函数(用于调试和外部访问)
if (typeof window !== "undefined") {
window.__checkCache = (key) => cacheStore.has(key); // 检查是否有缓存
window.__isWhitelisted = (pathname) => isWhitelisted(pathname); // 检查是否在白名单
}注意:清除函数(clearPageInitialized、clearAllPagesInitialized)不再暴露到全局,改为从 usePageCacheInit.js 直接导入使用。
**注意**:位置信息(`location`)直接存储在 `cacheStore` 的 value 中,不再单独维护 `locationStore`。
#### 关键函数
**1. `getCacheKey(pathname, search)`**
```javascript
// 生成缓存 key
const getCacheKey = (pathname, search) => {
return pathname + search; // 例如:'/setting/user?page=2'
};2. isWhitelisted(pathname)
// 检查路径是否在白名单中
const isWhitelisted = (pathname) => {
return CACHE_WHITELIST.some((route) => {
if (pathname === route) return true;
if (pathname.startsWith(route + "/")) return true;
return false;
});
};3. moveToRecent(key)
// 将 key 移到访问顺序数组的最后(标记为最近使用)
const moveToRecent = (key) => {
const index = accessOrder.indexOf(key);
if (index >= 0) {
accessOrder.splice(index, 1); // 从原位置删除
}
accessOrder.push(key); // 添加到末尾
};4. evictLRU(excludeKey)
// LRU 清理:删除最久未使用的缓存(排除当前正在访问的)
const evictLRU = (excludeKey) => {
while (cacheStore.size >= CACHE_LIMIT) {
// 默认 8 个
// 找到第一个不是 excludeKey 的 key(最久未使用的)
const keyToRemove = accessOrder.find((k) => k !== excludeKey);
if (keyToRemove) {
removeCache(keyToRemove); // 删除缓存
} else {
break;
}
}
};5. removeCache(key)
// 移除指定 key 的缓存
import { clearPageInitialized } from "@/hooks/usePageCacheInit";
const removeCache = (key) => {
if (cacheStore.has(key)) {
cacheStore.delete(key); // 删除组件缓存
const index = accessOrder.indexOf(key);
if (index >= 0) {
accessOrder.splice(index, 1); // 从访问顺序中删除
}
// ⚠️ 重要:清除缓存时,同时清除初始化状态
// 这样当缓存被 LRU 驱逐后,切换回来时会重新初始化
clearPageInitialized(key);
return true;
}
return false;
};步骤 1:检查是否需要缓存
const shouldNotCache = useMemo(() => isWhitelisted(location.pathname), [location.pathname]);步骤 2:生成缓存 key
const cacheKey = getCacheKey(location.pathname, location.search);步骤 3:处理缓存逻辑
useEffect(() => {
// 1. 白名单路由:不缓存,直接返回
if (shouldNotCache) {
if (removeCache(cacheKey)) {
setCacheVersion((v) => v + 1); // 触发重新渲染
}
return;
}
// 2. 如果 key 没变化,只更新访问顺序
if (prevKeyRef.current === cacheKey) {
if (cacheStore.has(cacheKey)) {
moveToRecent(cacheKey); // 标记为最近使用
}
return;
}
// 3. key 变化了,处理新页面
prevKeyRef.current = cacheKey;
// 如果还没有缓存,添加缓存
if (!cacheStore.has(cacheKey)) {
// 使用 setTimeout 确保 outlet 已经准备好
const timer = setTimeout(() => {
const currentOutlet = outletRef.current;
if (currentOutlet) {
cacheStore.set(cacheKey, {
element: currentOutlet,
location: {
pathname: location.pathname,
search: location.search,
hash: location.hash,
state: location.state,
key: location.key,
},
});
if (!accessOrder.includes(cacheKey)) {
accessOrder.push(cacheKey);
} else {
moveToRecent(cacheKey);
}
evictLRU(cacheKey); // 如果超过限制,删除最久未使用的
setCacheVersion((v) => v + 1);
}
}, 0);
return () => {
clearTimeout(timer);
};
} else {
// 已缓存,只更新访问顺序
moveToRecent(cacheKey);
}
}, [cacheKey, shouldNotCache, outlet, location.pathname, location.search]);const nodes = useMemo(() => {
const list = [];
// ⚠️ 关键修复:即使切换到白名单页面,也要保留缓存的组件
// 这样从白名单切换回缓存页面时,组件状态不会丢失
// 先渲染所有缓存的组件(包括白名单页面时也保留)
const cachedNodes = [];
for (const [key, cache] of cacheStore.entries()) {
// 如果是白名单页面,所有缓存组件都隐藏
// 如果是缓存页面,只有当前激活的组件显示
const isActive = !shouldNotCache && key === cacheKey;
cachedNodes.push(
<div
key={key}
style={{
display: isActive ? "block" : "none",
height: "100%",
width: "100%",
}}
>
<CachedComponent cacheKey={key}>{cache.element}</CachedComponent>
</div>
);
}
list.push(...cachedNodes);
// 白名单路由:直接渲染(但缓存的组件仍然保留在 DOM 中,只是隐藏)
if (shouldNotCache) {
if (outlet) {
list.push(<div key={cacheKey}>{outlet}</div>);
}
} else {
// 缓存路由:如果还没有缓存,临时渲染 outlet(等待缓存创建)
if (!cacheStore.has(cacheKey) && outlet) {
list.push(<div key={cacheKey}>{outlet}</div>);
}
}
return list;
}, [cacheKey, cacheVersion, shouldNotCache, outlet, location.pathname]);关键改进:
display: none),确保状态不丢失import { clearPageInitialized, clearAllPagesInitialized } from "@/hooks/usePageCacheInit";
useEffect(() => {
// 订阅 'keep:alive:drop' 事件(删除单个缓存)
const onDrop = (detail) => {
const key = detail?.key;
if (!key) return;
// 🌟 只有在明确要求清除缓存时才清除(比如刷新标签页)
// 关闭标签页时不应该清除缓存,这样重新打开时可以快速恢复
const shouldRemove = detail?.remove === true;
if (shouldRemove) {
if (removeCache(key)) {
setCacheVersion((v) => v + 1);
}
// 清除初始化状态(刷新时才清除)
clearPageInitialized(key);
}
// 关闭标签页时:不清除缓存,也不清除初始化状态
};
// 订阅 'keep:alive:clear' 事件(清除所有缓存)
const onClear = () => {
cacheStore.clear();
accessOrder.splice(0, accessOrder.length);
// 清除所有初始化状态
clearAllPagesInitialized();
setCacheVersion((v) => v + 1);
};
const unsubscribeDrop = subscribe("keep:alive:drop", onDrop);
const unsubscribeClear = subscribe("keep:alive:clear", onClear);
return () => {
unsubscribeDrop();
unsubscribeClear();
};
}, [subscribe, error]);重要变化:
remove: false),保留页面状态,重新打开时可以快速恢复remove: true),强制重新加载数据clearPageInitialized 和 clearAllPagesInitialized 清除初始化状态(不再使用全局变量)位置:src/hooks/usePageCacheInit.js
职责:
推荐使用:这是推荐的方式,统一管理页面初始化逻辑。
initlocation.state.__refresh 检测)// 组件级别的 ref(每个组件实例独立)
const initialKeyRef = useRef(null); // 稳定的 pageKey
const initializedRef = useRef(false); // 是否已初始化
const hasTriedRestoreRef = useRef(false); // 是否已尝试恢复
const isRunningRef = useRef(false); // 是否正在执行(防重复)
const prevRefreshTokenRef = useRef(null); // 上次的 refreshToken
const initRef = useRef(init); // 存储 init 函数引用export const usePageCacheInit = ({
init, // 必填:首次加载 / 刷新时调用的函数
restoreCheck, // 可选:返回 { hasData, total },用于兜底判断
reloadOnEmpty, // 可选:当检测到"有缓存但数据为空"时调用
loadingState, // 可选:{ loading, setLoading }
cacheKey, // 可选:自定义 key,不传则用 pathname+search
}) => {
const location = useLocation();
// 1. 生成稳定的 pageKey(只在首次挂载时确定)
const initialKeyRef = useRef(null);
if (!initialKeyRef.current) {
initialKeyRef.current = cacheKey || location.pathname + location.search;
}
const pageKey = initialKeyRef.current;
const pagePathname = pageKey.split("?")[0];
// 2. runInit 函数:执行 init,并在完成后设置 initializedRef.current = true
const runInit = async () => {
if (isRunningRef.current) return;
isRunningRef.current = true;
try {
if (loadingState?.setLoading) {
loadingState.setLoading(true);
}
await initRef.current();
// ⚠️ 重要:init执行完成后,才设置initializedRef.current为true
// 这样可以确保分页的useEffect在init执行完成后才可能触发
initializedRef.current = true;
// 同时更新全局状态(用于缓存恢复时的判断)
initializedPages.add(pageKey);
} finally {
isRunningRef.current = false;
}
};
// 3. 首次挂载 & Tab 刷新逻辑
useEffect(() => {
if (location.pathname !== pagePathname) return;
const cacheKeyReal = pageKey;
const isUsingCache = window.__checkCache?.(cacheKeyReal);
const isGloballyInitialized = initializedPages.has(pageKey);
const refreshToken = location.state?.__refresh;
const shouldInit =
!isGloballyInitialized || (refreshToken && refreshToken !== prevRefreshTokenRef.current);
// ⚠️ 关键修复:如果正在使用缓存且已经全局初始化过,不应该执行 init
if (!isUsingCache && isGloballyInitialized) {
// 缓存不存在但全局已初始化,说明缓存被清除了(可能是 LRU 驱逐)
// 清除初始化状态,强制重新初始化
initializedPages.delete(pageKey);
initializedRef.current = false;
} else if (isUsingCache && isGloballyInitialized && !refreshToken) {
// 如果正在使用缓存且已经全局初始化过且不是刷新操作
// 不在这里直接返回,让恢复检查逻辑来判断是否需要重新初始化
initializedRef.current = true;
}
if (shouldInit) {
if (refreshToken && refreshToken !== prevRefreshTokenRef.current) {
initializedPages.delete(pageKey);
initializedRef.current = false;
}
prevRefreshTokenRef.current = refreshToken;
hasTriedRestoreRef.current = false;
// runInit 会在执行完成后设置 initializedRef.current = true 和全局状态
runInit();
}
}, [location.pathname, location.search, location.state, pagePathname, pageKey]);
// 4. 缓存恢复兜底逻辑
useEffect(() => {
if (!restoreCheck || !loadingState) return;
// 只在从缓存恢复时触发,刷新时不触发
if (location.state?.__refresh) return;
const isUsingCache = window.__checkCache?.(pageKey);
if (!isUsingCache) return;
// ⚠️ 关键:即使全局已初始化,也要检查数据是否完整
// 因为从白名单切换回来时,组件会重新执行,useState 会重新初始化,数据可能丢失
const isGloballyInitialized = initializedPages.has(pageKey);
const { loading, setLoading } = loadingState;
// 延迟检查数据(确保组件状态已恢复)
// 即使全局已初始化,也要检查数据,因为组件状态可能丢失
if (!hasTriedRestoreRef.current) {
setTimeout(async () => {
// 检查路由是否仍然匹配
if (location.pathname !== pagePathname) return;
// 检查是否仍然使用缓存
const stillUsingCache = window.__checkCache?.(pageKey);
if (!stillUsingCache) return;
// 检查是否正在执行 init
if (isRunningRef.current) return;
// 检查数据是否完整
const info = restoreCheck();
const hasData = !!info?.hasData;
const total = typeof info?.total === "number" ? info.total : undefined;
if (!hasData || (total !== undefined && total === 0)) {
// ⚠️ 数据不完整,执行完整的 init(而不是只执行 reloadOnEmpty)
// 这样可以确保所有接口都被请求,避免功能丢失
await initRef.current();
initializedRef.current = true;
initializedPages.add(pageKey);
} else {
// 数据完整,标记为已初始化,关闭 loading
initializedRef.current = true;
initializedPages.add(pageKey);
}
if (setLoading) {
setLoading(false);
}
hasTriedRestoreRef.current = true;
}, 300); // 增加延迟时间,确保组件状态已恢复
}
}, [location.pathname, location.search, restoreCheck, loadingState, pageKey, pagePathname]);
// 5. 检查当前是否正在使用缓存(实时检查)
const isUsingCache = useCallback(() => {
const cacheKeyReal = pageKey;
return (
typeof window !== "undefined" && window.__checkCache && window.__checkCache(cacheKeyReal)
);
}, [pageKey]);
// 6. 返回初始化状态,供页面判断是否已经初始化
return {
isInitialized: initializedRef, // 返回 ref,用于控制分页等 useEffect 的执行时机
isUsingCache, // 返回函数,用于检查是否正在使用缓存
};
};推荐方式(使用 usePageCacheInit):
import { usePageCacheInit } from "@/hooks/usePageCacheInit";
const UserManagement = () => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [current, setCurrent] = useState(1);
const [pageSize, setPageSize] = useState(10);
// ✅ 使用 usePageCacheInit 统一管理初始化逻辑,获取 isInitialized
const { isInitialized } = usePageCacheInit({
init: async () => {
await Promise.all([getList(current, pageSize), getDepartmentList(), getProvinceList()]);
},
restoreCheck: () => ({
hasData: data.length > 0,
total,
}),
reloadOnEmpty: async () => {
await getList(current, pageSize);
},
loadingState: { loading, setLoading },
});
// ✅ 分页变化时单独处理(使用普通 useEffect,通过 isInitialized 避免首次加载时重复请求)
useEffect(() => {
// 如果还没有初始化,跳过(由usePageCacheInit处理首次加载)
if (!isInitialized.current) {
return;
}
// 只有在已初始化后才响应分页变化
getList(current, pageSize);
}, [current, pageSize, isInitialized]);
// ...
};关键优势:
hasTriedRestoreRef、prevCacheKeyRef 等__refresh token)isRunningRef)initRef 存储)位置:src/hooks/usePageCacheInit.js(已整合到同一文件)
职责:
推荐使用:这是推荐的方式,用于处理有分页的缓存页面。
注意:usePagination 已整合到 usePageCacheInit.js 中,从同一文件导入即可。
export const usePagination = ({
current, // 当前页码
pageSize, // 每页数量
isInitialized, // usePageCacheInit 返回的 isInitialized ref
onPageChange, // 分页变化时的回调函数
}) => {
// 记录上一次的分页参数,只有真正变化时才请求
const prevPageParamsRef = useRef({ current, pageSize });
useEffect(() => {
// 如果还没有初始化,跳过(由usePageCacheInit处理首次加载)
if (!isInitialized?.current) {
prevPageParamsRef.current = { current, pageSize };
return;
}
// 检查分页参数是否真的变化了
const paramsChanged =
prevPageParamsRef.current.current !== current ||
prevPageParamsRef.current.pageSize !== pageSize;
// 如果参数没变化,说明是缓存恢复,不需要请求
if (!paramsChanged) {
return;
}
// 参数变化了,说明是用户手动改变分页,需要请求
prevPageParamsRef.current = { current, pageSize };
if (typeof onPageChange === "function") {
onPageChange(current, pageSize);
}
}, [current, pageSize, isInitialized, onPageChange]);
};import { usePageCacheInit, usePagination } from "@/hooks/usePageCacheInit";
const UserManagement = () => {
const [current, setCurrent] = useState(1);
const [pageSize, setPageSize] = useState(10);
const { isInitialized } = usePageCacheInit({
init: async () => {
await Promise.all([getList(current, pageSize), getDepartmentList()]);
},
restoreCheck: () => ({ hasData: data.length > 0, total }),
loadingState: { loading, setLoading },
});
// ✅ 分页变化:使用通用 hook 统一处理(从同一文件导入)
usePagination({
current,
pageSize,
isInitialized,
onPageChange: getList, // 只请求列表接口
});
};关键优势:
注意:useCacheableEffect Hook 已完全移除,不再使用。所有功能已由 usePageCacheInit 替代。
迁移指南:
usePageCacheInit:统一管理页面初始化逻辑usePagination:处理分页场景useCacheableEffect:已废弃并删除已废弃原因:
usePageCacheInit 完全替代迁移示例:
// ❌ 旧方式(已废弃):使用 useCacheableEffect
import { useCacheableEffect } from "@/hooks/useCacheableEffect";
const UserManagement = () => {
useCacheableEffect(
() => {
getList(current, pageSize);
getDepartmentList();
},
[],
{ cacheable: true }
);
// ...
};
// ✅ 新方式(推荐):使用 usePageCacheInit
import { usePageCacheInit, usePagination } from "@/hooks/usePageCacheInit";
const UserManagement = () => {
const { isInitialized } = usePageCacheInit({
init: async () => {
await Promise.all([getList(current, pageSize), getDepartmentList()]);
},
restoreCheck: () => ({ hasData: data.length > 0, total }),
loadingState: { loading, setLoading },
});
usePagination({
current,
pageSize,
isInitialized,
onPageChange: getList,
});
// ...
};位置:src/hooks/useTabsManager.js
职责:
1. 添加标签页
const addTab = useCallback(
(pathname, search) => {
const fullPathKey = pathname + (search || "");
setTabs((prevTabs) => {
// 检查是否已存在
const existingTab = prevTabs.find((tab) => tab.key === fullPathKey);
if (existingTab) {
return prevTabs; // 已存在,不重复添加
}
// 创建新标签页
const newTab = getRouteInfo(pathname, search);
return [...prevTabs, newTab];
});
setActiveKey(fullPathKey);
},
[getRouteInfo]
);2. 关闭标签页
const closeTab = useCallback(
(key) => {
setTabs((prevTabs) => {
const targetTab = prevTabs.find((tab) => tab.key === key);
if (!targetTab || targetTab.isPinned) return prevTabs;
const newTabs = prevTabs.filter((tab) => tab.key !== key);
// 如果关闭的是当前激活标签页,切换到其他标签页
if (activeKey === key) {
const nextTab = newTabs[0] || newTabs[newTabs.length - 1];
if (nextTab) {
setActiveKey(nextTab.key);
navigate(nextTab.key);
}
}
return newTabs;
});
// 🌟 关闭标签页时不清除缓存,只清除初始化状态
// 这样重新打开时可以快速恢复,但会重新加载数据
globalMessageUtils.keepAlive("drop", { key, remove: false });
},
[activeKey, navigate]
);注意:关闭标签页时,缓存不会被清除(remove: false),这样重新打开时可以快速恢复页面。只有刷新标签页时才会清除缓存。
3. 关闭所有标签页
const closeAllTabs = useCallback(() => {
// 发送全局清除消息
globalMessageUtils.keepAlive("clear");
// 重置状态
setTabs(DEFAULT_PINNED_TABS);
setActiveKey("/");
navigate("/");
}, [navigate, success]);4. 刷新标签页
import { clearPageInitialized } from "@/hooks/usePageCacheInit";
const refreshTab = useCallback(
(key) => {
// 1. 先清除初始化状态
clearPageInitialized(key);
// 2. 清除缓存,强制重新加载
globalMessageUtils.keepAlive("drop", { key, remove: true });
// 3. 通过注入刷新令牌到 location.state,触发 usePageCacheInit 重新执行
navigate(key, {
replace: true,
state: {
__refresh: Date.now(), // 刷新令牌
},
});
},
[success, navigate]
);关键机制:
location.state.__refresh 作为刷新令牌usePageCacheInit 检测到 refreshToken 变化时会重新执行 init5. 关闭其他标签页
import { clearPageInitialized } from "@/hooks/usePageCacheInit";
const closeOtherTabs = useCallback(
(keepKey) => {
setTabs((prevTabs) => {
// 找出即将被关闭的标签页的 key
const keysToDrop = prevTabs
.filter((tab) => !tab.isPinned && tab.key !== keepKey)
.map((tab) => tab.key);
// 清除其他标签页的缓存
keysToDrop.forEach((key) => {
globalMessageUtils.keepAlive("drop", { key });
});
// 🌟 如果保留的标签页不是当前激活的,清除其初始化状态
// 这样会重新加载数据,确保页面状态正确
if (activeKey !== keepKey) {
clearPageInitialized(keepKey);
}
// 返回保留的标签页列表
return prevTabs.filter((tab) => tab.isPinned || tab.key === keepKey);
});
// 激活目标 Key 并导航
setActiveKey(keepKey);
navigate(keepKey);
},
[success, navigate, activeKey]
);6. 切换标签页
const switchTab = useCallback(
(key) => {
setActiveKey(key);
// 切换 Tab 时清除 location.state,避免之前的 __refresh 影响其他 Tab
navigate(key, {
replace: false,
state: undefined, // 明确清除 state,避免刷新令牌影响切换
});
},
[navigate]
);位置:src/hooks/useGlobalMessage.js
职责:
发布消息
const publish = useCallback((eventType, payload = {}) => {
// 1. 通知订阅者
if (subscribersRef.current.has(eventType)) {
const subscribers = subscribersRef.current.get(eventType);
subscribers.forEach((callback) => {
callback({ detail: payload });
});
}
// 2. 发送浏览器原生事件
const event = new CustomEvent(eventType, { detail: payload });
window.dispatchEvent(event);
}, []);订阅消息
const subscribe = useCallback((eventType, callback, options = {}) => {
const { once = false } = options;
if (!subscribersRef.current.has(eventType)) {
subscribersRef.current.set(eventType, new Set());
}
const subscribers = subscribersRef.current.get(eventType);
const wrappedCallback = (event) => {
try {
callback(event.detail);
if (once) unsubscribe(eventType, wrappedCallback);
} catch (error) {
console.error(`Error in subscriber for ${eventType}:`, error);
}
};
subscribers.add(wrappedCallback);
return () => unsubscribe(eventType, wrappedCallback);
}, []);处理 keepAlive 事件
const handleKeepAlive = useCallback(
(detail) => {
const action = detail?.action || detail?.message || "drop";
const options = detail?.options || {};
let eventType = EVENT_TYPES.KEEP_ALIVE + ":" + action; // 'keep:alive:drop' 或 'keep:alive:clear'
publish(eventType, options);
},
[publish]
);工具函数
export const globalMessageUtils = {
// 发送 keepAlive 消息
keepAlive(message = "keepAlive", options = {}) {
window.dispatchEvent(
new CustomEvent(EVENT_TYPES.KEEP_ALIVE, {
detail: { message, options },
})
);
},
};1. 用户点击菜单 → 路由变化 → location.pathname = '/setting/user'
2. KeepAliveOutlet 检测到路由变化
3. 生成 cacheKey = '/setting/user'
4. 检查白名单 → 不在白名单中,需要缓存
5. 检查 cacheStore → 没有缓存
6. 等待 outlet(页面组件)加载完成
7. 保存到 cacheStore:cacheStore.set('/setting/user', outlet)
8. 保存到 locationStore:locationStore.set('/setting/user', location)
9. 添加到 accessOrder:accessOrder.push('/setting/user')
10. 执行 LRU 清理(如果超过 8 个)
11. 触发重新渲染,显示页面
12. 页面组件使用 usePageCacheInit 加载数据
13. 数据加载完成,标记为已初始化1. 用户点击其他标签页 → 路由变化 → location.pathname = '/setting/role'
2. KeepAliveOutlet 检测到路由变化
3. 生成 cacheKey = '/setting/role'
4. 检查 cacheStore → 已有缓存
5. 更新访问顺序:moveToRecent('/setting/role')
6. 触发重新渲染
7. 渲染逻辑:
- 显示 '/setting/role'(display: 'block')
- 隐藏 '/setting/user'(display: 'none')
8. 页面组件不会重新挂载,usePageCacheInit 不会执行 init
9. 直接显示缓存的数据,无需重新请求接口1. 用户点击关闭按钮 → closeTab('/setting/user')
2. useTabsManager 更新 tabs 状态(移除该标签页)
3. 发送消息:globalMessageUtils.keepAlive('drop', { key: '/setting/user', remove: false })
4. useGlobalMessage 处理消息 → publish('keep:alive:drop', { key: '/setting/user', remove: false })
5. KeepAliveOutlet 订阅到消息 → onDrop({ key: '/setting/user', remove: false })
6. 检查 shouldRemove = false
7. 🌟 不清除缓存,保留页面状态(这样重新打开时可以快速恢复)
8. 触发重新渲染注意:关闭标签页时,缓存会被保留,这样重新打开时可以快速恢复页面状态。
1. 用户点击"关闭所有" → closeAllTabs()
2. 发送全局清除消息:globalMessageUtils.keepAlive('clear')
3. useGlobalMessage 处理消息 → publish('keep:alive:clear', {})
4. KeepAliveOutlet 订阅到消息 → onClear()
5. 执行清除操作:
- cacheStore.clear()
- locationStore.clear()
- accessOrder.splice(0, accessOrder.length)
- window.clearAllInitialized()
6. 重置标签页状态:setTabs(DEFAULT_PINNED_TABS)
7. 导航到首页:navigate('/')1. 用户右键点击标签页 → 选择"刷新" → refreshTab('/setting/user')
2. 清除初始化状态:clearPageInitialized('/setting/user')
3. 发送删除缓存消息:globalMessageUtils.keepAlive('drop', { key: '/setting/user', remove: true })
4. KeepAliveOutlet 检查 shouldRemove = true
5. 执行 removeCache('/setting/user')
- cacheStore.delete('/setting/user')
- accessOrder 中删除 '/setting/user'
6. 触发重新渲染
7. 导航到同一路由,并注入刷新令牌:navigate('/setting/user', { state: { __refresh: Date.now() } })
8. 由于缓存已删除,会重新渲染 outlet
9. 页面组件检测到 location.state.__refresh 变化
10. usePageCacheInit 检测到 refreshToken 变化,重新执行 init
11. 重新加载数据1. 用户右键点击标签页 → 选择"关闭其他" → closeOtherTabs('/setting/user')
2. 找出其他标签页的 key:['/setting/role', '/setting/menu']
3. 清除其他标签页的缓存:globalMessageUtils.keepAlive('drop', { key: '/setting/role' })
4. 如果保留的标签页不是当前激活的:
- 清除初始化状态:clearPageInitialized('/setting/user')
- 这样会重新加载数据,确保页面状态正确
5. 更新 tabs 状态,只保留目标标签页
6. 导航到目标标签页
7. 目标标签页重新加载数据(因为初始化状态被清除)1. 用户从白名单页面(如 /)切换回缓存页面(如 /setting/user)
2. KeepAliveOutlet 恢复缓存的组件
- ⚠️ 关键:即使切换到白名单页面,缓存的组件也保留在 DOM 中(只是隐藏)
- 从白名单切换回来时,组件状态完整保留,无需重新初始化
3. usePageCacheInit 检测到使用缓存:window.__checkCache('/setting/user') === true
4. 检查是否有 refreshToken:如果没有,说明是切换回来,不是刷新
5. 延迟 300ms 后检查数据状态(通过 restoreCheck):
- 如果数据为空且 total === 0,可能是状态丢失
- 执行完整的 init(所有接口),确保功能不丢失
6. 如果数据存在,标记为已初始化,自动关闭 loading 状态状态保留机制的作用:
display: none),确保状态不丢失usePageCacheInit 统一管理,无需手动实现LRU(Least Recently Used):最近最少使用算法
核心思想:当缓存空间不足时,删除最久未使用的缓存。
const accessOrder = []; // 存储访问顺序,数组第一个是最久未使用的// 将页面移到数组末尾(标记为最近使用)
const moveToRecent = (key) => {
const index = accessOrder.indexOf(key);
if (index >= 0) {
accessOrder.splice(index, 1); // 从原位置删除
}
accessOrder.push(key); // 添加到末尾
};import { clearPageInitialized } from "@/hooks/usePageCacheInit";
const evictLRU = (excludeKey) => {
while (cacheStore.size >= CACHE_LIMIT) {
// 默认 8 个
// 找到第一个不是 excludeKey 的 key(最久未使用的)
const keyToRemove = accessOrder.find((k) => k !== excludeKey);
if (keyToRemove) {
removeCache(keyToRemove); // 删除缓存(内部会调用 clearPageInitialized)
} else {
break;
}
}
};假设 CACHE_LIMIT = 8(默认值):
初始状态:
cacheStore: {}
accessOrder: []
访问 /page1:
cacheStore: { '/page1': <Component1> }
accessOrder: ['/page1']
访问 /page2:
cacheStore: { '/page1': <Component1>, '/page2': <Component2> }
accessOrder: ['/page1', '/page2']
访问 /page3:
cacheStore: { '/page1': <Component1>, '/page2': <Component2>, '/page3': <Component3> }
accessOrder: ['/page1', '/page2', '/page3']
访问 /page4(缓存已满):
1. 添加 /page4
2. 执行 evictLRU('/page4')
3. 删除 accessOrder[0] = '/page1'(最久未使用)
cacheStore: { '/page2': <Component2>, '/page3': <Component3>, '/page4': <Component4> }
accessOrder: ['/page2', '/page3', '/page4']
再次访问 /page2:
1. moveToRecent('/page2') → 移到末尾
cacheStore: { '/page2': <Component2>, '/page3': <Component3>, '/page4': <Component4> }
accessOrder: ['/page3', '/page4', '/page2']
访问 /page5(缓存已满):
1. 添加 /page5
2. 执行 evictLRU('/page5')
3. 删除 accessOrder[0] = '/page3'(最久未使用)
cacheStore: { '/page4': <Component4>, '/page2': <Component2>, '/page5': <Component5> }
accessOrder: ['/page4', '/page2', '/page5']非白名单页面(需要缓存):
方式一:使用 usePagination Hook(推荐)
import { usePageCacheInit, usePagination } from "@/hooks/usePageCacheInit";
import { useState } from "react";
const UserManagement = () => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [current, setCurrent] = useState(1);
const [pageSize, setPageSize] = useState(10);
// ✅ 使用 usePageCacheInit 统一管理初始化逻辑
const { isInitialized } = usePageCacheInit({
init: async () => {
// 首次加载 / 刷新时执行(所有接口)
await Promise.all([getList(current, pageSize), getDepartmentList(), getProvinceList()]);
},
restoreCheck: () => ({
// 用于判断是否有数据
hasData: data.length > 0,
total,
}),
loadingState: { loading, setLoading },
});
// ✅ 分页变化:使用通用 hook 统一处理,避免缓存恢复时重复请求
usePagination({
current,
pageSize,
isInitialized,
onPageChange: getList, // 只请求列表接口
});方式二:使用普通 useEffect(适用于复杂场景)
import { usePageCacheInit } from "@/hooks/usePageCacheInit";
import { useState, useEffect, useRef } from "react";
const UserManagement = () => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [current, setCurrent] = useState(1);
const [pageSize, setPageSize] = useState(10);
// ✅ 使用 usePageCacheInit 统一管理初始化逻辑
const { isInitialized } = usePageCacheInit({
init: async () => {
// 首次加载 / 刷新时执行(所有接口)
await Promise.all([getList(current, pageSize), getDepartmentList(), getProvinceList()]);
},
restoreCheck: () => ({
hasData: data.length > 0,
total,
}),
loadingState: { loading, setLoading },
});
// ✅ 分页变化:使用 ref 记录上一次的参数,只有真正变化时才请求
const prevPageParamsRef = useRef({ current, pageSize });
useEffect(() => {
// 如果还没有初始化,跳过(由usePageCacheInit处理首次加载)
if (!isInitialized.current) {
prevPageParamsRef.current = { current, pageSize };
return;
}
// 检查分页参数是否真的变化了
const paramsChanged =
prevPageParamsRef.current.current !== current ||
prevPageParamsRef.current.pageSize !== pageSize;
// 如果参数没变化,说明是缓存恢复,不需要请求
if (!paramsChanged) {
return;
}
// 参数变化了,说明是用户手动改变分页,需要请求
prevPageParamsRef.current = { current, pageSize };
getList(current, pageSize);
}, [current, pageSize, getList, isInitialized]);
// ❌ 不要这样做(会导致每次切换标签页都重新加载)
// useEffect(() => {
// getList();
// }, []);
// ❌ 不要这样做(会在首次加载时重复请求,因为分页 useEffect 会在 init 执行前触发)
// useEffect(() => {
// getList(current, pageSize);
// }, [current, pageSize]);
// ...
};白名单页面(不需要缓存):
// 白名单页面使用普通 useEffect,不使用 usePageCacheInit
const LoginLog = () => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
// ✅ 白名单页面,使用普通 useEffect
useEffect(() => {
getList(current, pageSize);
}, []);
// ...
};关键优势:
__refresh token)const { isInitialized, isUsingCache } = usePageCacheInit({
init, // 必填:首次加载 / 刷新时调用的函数(通常是若干请求的合集)
restoreCheck, // 可选:返回 { hasData, total },用于兜底判断是否需要重拉
reloadOnEmpty, // 可选:当检测到"有缓存但数据为空"时调用,不传则退回 init(已废弃,现在直接执行完整的 init)
loadingState, // 可选:{ loading, setLoading },用于统一处理 loading
cacheKey, // 可选:自定义 key,不传则用首次挂载时的 pathname+search
});注意:reloadOnEmpty 参数已废弃,现在如果检测到数据不完整,会直接执行完整的 init(所有接口),确保功能不丢失。
参数详解:
init(必填):异步函数,首次加载或刷新时执行。通常包含多个数据请求。当检测到缓存但数据不完整时,也会执行完整的 init(所有接口)。restoreCheck(可选):函数,返回 { hasData: boolean, total?: number }。用于判断页面是否有数据,如果没有数据且 total 为 0,会触发完整的 init。reloadOnEmpty(可选,已废弃):异步函数,当检测到缓存但数据为空时调用。现在已废弃,会直接执行完整的 init。loadingState(可选):{ loading: boolean, setLoading: (loading: boolean) => void }。用于统一管理 loading 状态。cacheKey(可选):自定义缓存 key。如果不提供,会使用首次挂载时的 pathname + search。返回值:
isInitialized:{ current: boolean } - 一个 ref 对象,用于判断页面是否已经完成初始化。isInitialized.current 为 true 表示初始化已完成(init 函数已成功执行)。重要:应该在分页、搜索等 useEffect 中使用 isInitialized.current 来避免在初始化完成前触发重复请求。
isUsingCache:() => boolean - 一个函数,用于检查当前页面是否正在使用缓存。返回 true 表示页面正在使用缓存(切换回来的场景)。注意:通常不需要直接使用,usePagination Hook 会自动处理。
最佳实践:
// ✅ 推荐:使用 useCallback 包裹 init 函数,确保引用稳定
const init = useCallback(async () => {
await Promise.all([getList(current, pageSize), getDepartmentList()]);
}, [current, pageSize]);
const { isInitialized } = usePageCacheInit({
init,
restoreCheck: () => ({ hasData: data.length > 0, total }),
reloadOnEmpty: async () => {
await getList(current, pageSize);
},
loadingState: { loading, setLoading },
});
// ✅ 分页变化时,使用 isInitialized 避免首次加载时重复请求
useEffect(() => {
if (!isInitialized.current) {
return; // 初始化完成前,跳过
}
getList(current, pageSize);
}, [current, pageSize, isInitialized]);位置:src/hooks/usePageCacheInit.js(已整合到同一文件)
职责:
使用示例:
import { usePageCacheInit, usePagination } from "@/hooks/usePageCacheInit";
const UserManagement = () => {
const [current, setCurrent] = useState(1);
const [pageSize, setPageSize] = useState(10);
const { isInitialized } = usePageCacheInit({
init: async () => {
await Promise.all([getList(current, pageSize), getDepartmentList()]);
},
restoreCheck: () => ({ hasData: data.length > 0, total }),
loadingState: { loading, setLoading },
});
// ✅ 分页变化:使用通用 hook 统一处理
usePagination({
current,
pageSize,
isInitialized,
onPageChange: getList, // 只请求列表接口
});
};核心特性:
如果某个页面不需要缓存,添加到白名单:
// src/components/KeepAlive/index.jsx
const CACHE_WHITELIST = [
"/",
"/dashboard",
"/your-new-page", // 添加新页面
];import { globalMessageUtils } from "@/hooks/useGlobalMessage";
// 清除单个页面缓存
globalMessageUtils.keepAlive("drop", { key: "/setting/user" });
// 清除所有缓存
globalMessageUtils.keepAlive("clear");// src/components/KeepAlive/index.jsx
const CACHE_LIMIT = 8; // 修改为你需要的数量(默认 8 个)A: 这是因为页面被缓存了,组件不会重新挂载。如果需要实时数据,应该:
A: 可能的原因:
A: 在开发环境下,KeepAlive 组件会输出调试日志:
// 查看控制台输出
[KeepAlive] 新增缓存: { cacheKey: '/setting/user', cacheSize: 1, ... }
[KeepAlive] 使用缓存: '/setting/user'
[KeepAlive] 清除所有缓存,清除前: { cacheSize: 3, cachedKeys: [...] }A: 检查以下几点:
init 参数是否正确传递(必须是函数)usePageCacheInit 只在当前路由匹配时生效)location.state.__refresh 注入)useEffect,不使用 usePageCacheInit)调试方法:
// 查看缓存状态
console.log(window.__checkCache("/setting/user")); // 检查是否有缓存
console.log(window.__isWhitelisted("/setting/user")); // 检查是否在白名单
// 查看当前路由的刷新令牌
console.log(window.location.state?.__refresh); // 刷新时会有一个时间戳
// 注意:清除函数不再暴露到全局,需要从模块导入
// import { clearPageInitialized } from "@/hooks/usePageCacheInit";A: 有几种方式:
右键菜单 → 刷新:清除缓存并重新加载(remove: true),同时注入 __refresh token
关闭标签页后重新打开:会重新加载数据(因为初始化状态被清除)
在代码中手动刷新:
import { useNavigate, useLocation } from "react-router-dom";
import { globalMessageUtils } from "@/hooks/useGlobalMessage";
import { clearPageInitialized } from "@/hooks/usePageCacheInit";
const navigate = useNavigate();
const location = useLocation();
// 方式 1:清除缓存和初始化状态,并注入刷新令牌(推荐)
const refreshPage = () => {
const key = location.pathname + location.search;
// 清除初始化状态
clearPageInitialized(key);
// 清除缓存
globalMessageUtils.keepAlive("drop", { key, remove: true });
// 注入刷新令牌,触发 usePageCacheInit 重新执行
navigate(key, {
replace: true,
state: { __refresh: Date.now() },
});
};
// 方式 2:只清除初始化状态(保留缓存,但会重新加载数据)
clearPageInitialized(location.pathname + location.search);A: 这可能是组件状态丢失导致的。usePageCacheInit 已经实现了数据恢复机制:
hasTriedRestoreRef 跟踪,避免重复加载init(所有接口),确保功能不丢失KeepAlive 的改进:
display: none),确保状态不丢失如果仍然出现问题,检查:
usePageCacheInit 的 restoreCheck 是否正确实现restoreCheck 返回的 hasData 和 total 是否正确A: 缓存的是 React 组件实例,内存占用取决于:
如果内存紧张,可以:
CACHE_LIMIT(默认 8)A: 是的。缓存存储在内存中(Map 对象),页面刷新后会清空。这是正常行为,因为:
Map 存储页面组件和位置信息,accessOrder 数组记录访问顺序useEffect)isInitialized 用于控制分页等 useEffect 的执行时机,导出清除函数供其他组件使用usePageCacheInit.js 中location.state.__refresh 刷新令牌实现 Tab 刷新init 执行完成后才设置 isInitialized.current = true,确保分页等 useEffect 不会在初始化完成前触发usePageCacheInitusePageCacheInit.js 导入isInitialized.current 避免首次加载时重复请求isInitialized.current 控制分页等 useEffect 的执行时机,确保只在初始化完成后才响应依赖变化clearPageInitialized、clearAllPagesInitialized),不使用全局变量usePagination 或普通 useEffect)useEffect)isInitialized.current(会导致首次加载时重复请求)usePageCacheInit 替代)src/components/KeepAlive/index.jsx - 缓存核心组件src/hooks/usePageCacheInit.js - 推荐使用,页面缓存初始化统一 Hook,包含 usePaginationsrc/hooks/useTabsManager.js - 标签页管理src/hooks/useGlobalMessage.js - 全局消息系统src/components/TabsContainer/index.jsx - 标签页容器src/layouts/BasicLayout.jsx - 基础布局全局函数(由 KeepAlive 暴露,用于调试和外部访问):
window.__checkCache(key) - 检查是否有缓存window.__isWhitelisted(pathname) - 检查是否在白名单导出函数(从 usePageCacheInit.js 导入使用):
clearPageInitialized(pageKey) - 清除指定页面的初始化状态clearAllPagesInitialized() - 清除所有页面的初始化状态注意:清除函数不再暴露到全局,改为直接导入使用,更符合模块化设计。
文档版本:v2.3 最后更新:2025-12-30 维护者:开发团队
更新说明:
useCacheableEffect 相关内容(已废弃并删除)cacheUtils.js 相关内容(已废弃并删除)usePagination 导入方式(已整合到 usePageCacheInit.js)usePageCacheInit 返回值说明,新增 isInitialized ref 用于控制分页等 useEffect 的执行时机isInitialized.current 避免首次加载时重复请求usePageCacheInit 作为推荐方式,简化业务代码location.state.__refresh 刷新令牌locationStore