fix: 用 Zustand 缓存 Feed 列表,返回时恢复滚动且不重复请求

点击 Logo 仍强制刷新;后退忽略 history 上的 refreshFeed,并支持过期静默刷新与下拉刷新。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-01 01:50:33 +08:00
parent 185649db7c
commit 132ca5c82c
8 changed files with 384 additions and 92 deletions

View File

@@ -1,5 +1,12 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useOutletContext, useSearchParams, useLocation, useParams } from 'react-router-dom';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import {
useNavigate,
useOutletContext,
useSearchParams,
useLocation,
useParams,
useNavigationType,
} from 'react-router-dom';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PostItem } from '../api/types';
@@ -12,13 +19,13 @@ import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../comp
import { useForumLimits } from '../hooks/useForumLimits';
import { parseSearchFromUrl, usePostSearch } from '../hooks/usePostSearch';
import {
getFeedCache,
setFeedCache,
clearAllFeedCache,
navigateFeed,
FEED_RESET_EVENT,
FEED_PULL_REFRESH_EVENT,
type FeedNavState,
} from '../utils/feedCache';
import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
import { openForumPost } from '../utils/openPost';
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding';
@@ -35,9 +42,41 @@ function boardIdFromLocation(routeId: string | undefined, searchParams: URLSearc
return q > 0 ? q : 0;
}
type FeedHydrate = {
posts: PostItem[];
postTotal: number;
page: number;
scrollTop: number;
loading: boolean;
};
/** 首屏同步读取 Zustand避免先骨架屏再恢复 */
function readHydrate(
boardId: number,
keyword: string,
sort: FeedSort,
tag: string,
author: string,
titleOnly: boolean,
): FeedHydrate {
const key = feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly });
const cached = getHomeStoreState().getFeed(key);
if (cached && cached.posts.length > 0) {
return {
posts: cached.posts,
postTotal: cached.postTotal,
page: cached.page,
scrollTop: cached.scrollTop,
loading: false,
};
}
return { posts: [], postTotal: 0, page: 1, scrollTop: 0, loading: true };
}
export default function HomePage() {
const nav = useNavigate();
const location = useLocation();
const navType = useNavigationType();
const { id: boardRouteId } = useParams();
const [params] = useSearchParams();
const ctx = useOutletContext<LayoutCtx>();
@@ -96,20 +135,49 @@ export default function HomePage() {
}
}, [queryBoardId, boardId, boardRouteId, params, nav, limits, location.pathname, location.search]);
const [posts, setPosts] = useState<PostItem[]>([]);
const [postTotal, setPostTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(null);
const cacheKey = useMemo(
() => feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly }),
[boardId, keyword, sort, tag, author, titleOnly],
);
// 筛选键变化时同步水合(含首次挂载),保证第一帧就有列表数据
const initial = useMemo(
() => readHydrate(boardId, keyword, sort, tag, author, titleOnly),
// eslint-disable-next-line react-hooks/exhaustive-deps -- 仅随筛选键变化重置
[cacheKey],
);
const [posts, setPosts] = useState<PostItem[]>(initial.posts);
const [postTotal, setPostTotal] = useState(initial.postTotal);
const [page, setPage] = useState(initial.page);
const [loading, setLoading] = useState(initial.loading);
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(
initial.posts.length > 0 ? initial.scrollTop : null,
);
const [listResetKey, setListResetKey] = useState(0);
const scrollTopRef = useRef(0);
const skipCacheSaveRef = useRef(false);
const scrollTopRef = useRef(initial.scrollTop);
const loadingRef = useRef(false);
const pageRef = useRef(1);
const pageRef = useRef(initial.page);
const cacheKeyRef = useRef(cacheKey);
const scrollRafRef = useRef(0);
/** 当前筛选键是否已完成「进入页」水合(避免 effect 重跑时反复 setRestoreScrollTop */
const hydratedKeyRef = useRef<string | null>(null);
pageRef.current = page;
// 与当前筛选一致的列表快照(供卸载/切换筛选时写入缓存)
const feedSnapRef = useRef({ boardId, keyword, tag, author, titleOnly, sort, posts, postTotal, page });
cacheKeyRef.current = cacheKey;
// 筛选键切换:用新键的缓存重置本地 stateuseMemo initial 不会自动 setState
useEffect(() => {
const next = readHydrate(boardId, keyword, sort, tag, author, titleOnly);
hydratedKeyRef.current = null;
setPosts(next.posts);
setPostTotal(next.postTotal);
setPage(next.page);
pageRef.current = next.page;
scrollTopRef.current = next.scrollTop;
setRestoreScrollTop(next.posts.length > 0 ? next.scrollTop : null);
setLoading(next.loading);
}, [cacheKey, boardId, keyword, sort, tag, author, titleOnly]);
const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
const showPagination = totalPages > 1 && posts.length > 0;
@@ -121,16 +189,38 @@ export default function HomePage() {
setListResetKey(k => k + 1);
}, []);
/** 强制刷新:清空全部 Feed 缓存并滚回顶部 */
const beginFeedRefresh = useCallback(() => {
skipCacheSaveRef.current = true;
clearAllFeedCache();
resetFeedView();
}, [resetFeedView]);
const fetchPage = useCallback(async (p: number) => {
/** 把当前列表写入 Zustand滚动位置用 ref避免闭包过期 */
const persistFeed = useCallback((
nextPosts: PostItem[],
nextTotal: number,
nextPage: number,
opts?: { scrollTop?: number; touchFetchTime?: boolean },
) => {
const key = cacheKeyRef.current;
const prev = getHomeStoreState().getFeed(key);
getHomeStoreState().setFeed(key, {
posts: nextPosts,
postTotal: nextTotal,
page: nextPage,
scrollTop: opts?.scrollTop ?? scrollTopRef.current,
lastFetchTime: opts?.touchFetchTime === false
? (prev?.lastFetchTime ?? Date.now())
: Date.now(),
});
}, []);
const fetchPage = useCallback(async (p: number, opts?: { silent?: boolean }) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
const silent = !!opts?.silent;
// 静默刷新:保留现有列表,不展示加载骨架
if (!silent) setLoading(true);
try {
const data = await api.posts({
page: p,
@@ -148,17 +238,20 @@ export default function HomePage() {
setPostTotal(total);
setPage(p);
pageRef.current = p;
persistFeed(batch, total, p, { touchFetchTime: true });
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '加载失败');
setPosts([]);
setPostTotal(0);
setPage(1);
pageRef.current = 1;
if (!silent) {
notify.error(e instanceof Error ? e.message : '加载失败');
setPosts([]);
setPostTotal(0);
setPage(1);
pageRef.current = 1;
}
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [boardId, keyword, tag, author, titleOnly, sort, pageSize]);
}, [boardId, keyword, tag, author, titleOnly, sort, pageSize, persistFeed]);
const loadFirst = useCallback(() => fetchPage(1), [fetchPage]);
@@ -168,89 +261,84 @@ export default function HomePage() {
if (p < 1 || p > maxPage) return;
if (p === pageRef.current) return;
resetFeedView();
getHomeStoreState().patchScroll(cacheKeyRef.current, 0);
fetchPage(p);
}, [fetchPage, postTotal, pageSize, resetFeedView]);
const handleSelectPost = useCallback((id: number) => {
// 离开前再写一次滚动,确保详情页返回可还原
getHomeStoreState().patchScroll(cacheKeyRef.current, scrollTopRef.current);
openForumPost(nav, id, limits.open_posts_in_new_tab);
}, [nav, limits.open_posts_in_new_tab]);
// 等限制就绪后再拉列表;筛选变化时重载
// 等限制就绪后再决定:强制刷新 / 用缓存 / 静默刷新 / 首拉
useEffect(() => {
if (limitsLoading || isInvalidBoardRoute || isMissingBoard) return;
const forceRefresh = (location.state as FeedNavState | null)?.refreshFeed;
if (forceRefresh) {
// 浏览器后退/前进POP忽略 history 上残留的 refreshFeed避免误清空缓存
if (forceRefresh && navType !== 'POP') {
hydratedKeyRef.current = cacheKey;
beginFeedRefresh();
setPosts([]);
setPostTotal(0);
setPage(1);
pageRef.current = 1;
setLoading(true);
loadFirst();
// 消费后清掉 state防止该 history 条目永远带着刷新标记
nav(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
return;
}
const cached = getFeedCache(boardId, keyword, sort, tag, author, titleOnly);
// POP 带回 refreshFeed 时也清掉,避免下次同条目再误触发
if (forceRefresh && navType === 'POP') {
nav(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
}
const cached = getHomeStoreState().getFeed(cacheKey);
if (cached && cached.posts.length > 0) {
const needRestore = hydratedKeyRef.current !== cacheKey;
hydratedKeyRef.current = cacheKey;
setPosts(cached.posts);
setPostTotal(cached.postTotal);
setPage(cached.page);
pageRef.current = cached.page;
setRestoreScrollTop(cached.scrollTop);
scrollTopRef.current = cached.scrollTop;
setLoading(false);
// 仅在「首次进入该筛选」时恢复滚动,避免 limits/pageSize 变化导致 effect 重跑时打断用户滚动
if (needRestore) {
setRestoreScrollTop(cached.scrollTop);
scrollTopRef.current = cached.scrollTop;
}
// 超过 TTL后台静默刷新不重置滚动
if (getHomeStoreState().isStale(cacheKey)) {
void fetchPage(cached.page, { silent: true });
}
return;
}
hydratedKeyRef.current = cacheKey;
setRestoreScrollTop(null);
scrollTopRef.current = 0;
loadFirst();
}, [
limitsLoading,
pageSize,
boardId,
keyword,
tag,
author,
titleOnly,
sort,
cacheKey,
location.key,
location.state,
location.pathname,
location.search,
location.hash,
navType,
nav,
loadFirst,
fetchPage,
beginFeedRefresh,
isInvalidBoardRoute,
isMissingBoard,
]);
// 筛选未变时同步列表快照;变筛选的那一帧先保留旧快照供 cleanup 写入
if (
feedSnapRef.current.boardId === boardId
&& feedSnapRef.current.keyword === keyword
&& feedSnapRef.current.tag === tag
&& feedSnapRef.current.author === author
&& feedSnapRef.current.titleOnly === titleOnly
&& feedSnapRef.current.sort === sort
) {
feedSnapRef.current = { boardId, keyword, tag, author, titleOnly, sort, posts, postTotal, page };
}
// 仅在筛选变化 / 卸载时缓存;勿把 posts 放进 deps否则会用旧列表污染新 keyword
useEffect(() => {
// cleanup 先保存上一档;再把快照重置为当前筛选的空占位
feedSnapRef.current = { boardId, keyword, tag, author, titleOnly, sort, posts: [], postTotal: 0, page: 1 };
return () => {
if (skipCacheSaveRef.current) return;
const snap = feedSnapRef.current;
if (snap.posts.length === 0) return;
setFeedCache(snap.boardId, snap.keyword, snap.sort, {
posts: snap.posts,
postTotal: snap.postTotal,
page: snap.page,
scrollTop: scrollTopRef.current,
}, snap.tag, snap.author, snap.titleOnly);
};
}, [boardId, keyword, tag, author, titleOnly, sort]);
useEffect(() => {
if (!loading && posts.length > 0) skipCacheSaveRef.current = false;
}, [loading, posts.length]);
useEffect(() => {
const onFeedReset = () => beginFeedRefresh();
window.addEventListener(FEED_RESET_EVENT, onFeedReset);
@@ -258,14 +346,39 @@ export default function HomePage() {
}, [beginFeedRefresh]);
useEffect(() => {
// Logo / 下拉刷新 / 后台改帖:清空本地列表以露出骨架,再强制拉第 1 页
const fn = () => {
beginFeedRefresh();
setPosts([]);
setPostTotal(0);
setPage(1);
pageRef.current = 1;
setLoading(true);
loadFirst();
};
window.addEventListener('posts-refresh', fn);
return () => window.removeEventListener('posts-refresh', fn);
window.addEventListener(FEED_PULL_REFRESH_EVENT, fn);
return () => {
window.removeEventListener('posts-refresh', fn);
window.removeEventListener(FEED_PULL_REFRESH_EVENT, fn);
};
}, [beginFeedRefresh, loadFirst]);
// 卸载时取消未执行的 scroll rAF
useEffect(() => () => {
if (scrollRafRef.current) cancelAnimationFrame(scrollRafRef.current);
}, []);
const handleScrollTopChange = useCallback((top: number) => {
scrollTopRef.current = top;
// rAF 节流写入 store避免每个 scroll 事件都触发订阅者
if (scrollRafRef.current) return;
scrollRafRef.current = requestAnimationFrame(() => {
scrollRafRef.current = 0;
getHomeStoreState().patchScroll(cacheKeyRef.current, scrollTopRef.current);
});
}, []);
const handleSortChange = (next: FeedSort) => {
if (next === sort) {
beginFeedRefresh();
@@ -331,7 +444,7 @@ export default function HomePage() {
onSelect={handleSelectPost}
restoreScrollTop={restoreScrollTop}
resetScrollKey={listResetKey}
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
onScrollTopChange={handleScrollTopChange}
onScrollRestored={() => setRestoreScrollTop(null)}
keyword={keyword || tag || author}
isSearchMode={!!(keyword || author)}