fix: 用 Zustand 缓存 Feed 列表,返回时恢复滚动且不重复请求
点击 Logo 仍强制刷新;后退忽略 history 上的 refreshFeed,并支持过期静默刷新与下拉刷新。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
31
frontend/package-lock.json
generated
31
frontend/package-lock.json
generated
@@ -51,7 +51,8 @@
|
|||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"turndown": "^7.2.4",
|
"turndown": "^7.2.4",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3",
|
||||||
|
"zustand": "^4.5.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
@@ -4480,6 +4481,34 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zustand": {
|
||||||
|
"version": "4.5.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||||
|
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"use-sync-external-store": "^1.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.7.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=16.8",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=16.8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,8 @@
|
|||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"turndown": "^7.2.4",
|
"turndown": "^7.2.4",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3",
|
||||||
|
"zustand": "^4.5.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Loader2, ArrowDown } from 'lucide-react';
|
import { Loader2, ArrowDown } from 'lucide-react';
|
||||||
|
import { FEED_PULL_REFRESH_EVENT } from '../utils/feedCache';
|
||||||
|
|
||||||
/** 触发刷新的下拉距离(px) */
|
/** 触发刷新的下拉距离(px) */
|
||||||
const REFRESH_THRESHOLD = 68;
|
const REFRESH_THRESHOLD = 68;
|
||||||
@@ -167,6 +168,18 @@ export default function PullToRefresh() {
|
|||||||
setSettling(true);
|
setSettling(true);
|
||||||
setPull(REFRESH_THRESHOLD * 0.7);
|
setPull(REFRESH_THRESHOLD * 0.7);
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
|
// Feed 页:应用内强制重拉(保留 SPA 其它状态);其它页仍整页 reload
|
||||||
|
const isFeed = !!(
|
||||||
|
document.querySelector('.main-content--feed-mobile-scroll')
|
||||||
|
|| document.querySelector('.page-wrap--feed .post-list-scroll')
|
||||||
|
);
|
||||||
|
if (isFeed) {
|
||||||
|
window.dispatchEvent(new Event(FEED_PULL_REFRESH_EVENT));
|
||||||
|
setRefreshing(false);
|
||||||
|
setSettling(true);
|
||||||
|
setPull(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, 180);
|
}, 180);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -161,6 +161,12 @@ export default function VirtualPostList({
|
|||||||
onScrollTopChangeRef.current?.(0);
|
onScrollTopChangeRef.current?.(0);
|
||||||
}, [resetScrollKey, virtualizer, getScrollElement]);
|
}, [resetScrollKey, virtualizer, getScrollElement]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
// 收到新的恢复目标时允许再次 restore;null 表示已消费,勿动标记
|
||||||
|
if (restoreScrollTop == null) return;
|
||||||
|
restoredRef.current = false;
|
||||||
|
}, [restoreScrollTop]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (restoreScrollTop == null || restoredRef.current || posts.length === 0) return;
|
if (restoreScrollTop == null || restoredRef.current || posts.length === 0) return;
|
||||||
const el = getScrollElement();
|
const el = getScrollElement();
|
||||||
@@ -173,10 +179,6 @@ export default function VirtualPostList({
|
|||||||
onScrollRestoredRef.current?.();
|
onScrollRestoredRef.current?.();
|
||||||
}, [restoreScrollTop, posts.length, virtualizer, getScrollElement]);
|
}, [restoreScrollTop, posts.length, virtualizer, getScrollElement]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
restoredRef.current = false;
|
|
||||||
}, [restoreScrollTop]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = getScrollElement();
|
const el = getScrollElement();
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|||||||
@@ -386,6 +386,7 @@ export default function MainLayout() {
|
|||||||
<Menu size={18} aria-hidden />
|
<Menu size={18} aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/* 点 Logo:回首页并强制刷新列表(已在首页时也会重拉) */}
|
||||||
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
|
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
|
||||||
<SiteBrandMark branding={branding} className="header-logo-mark" />
|
<SiteBrandMark branding={branding} className="header-logo-mark" />
|
||||||
{!isMobile && <span className="header-logo-text">{branding.name}</span>}
|
{!isMobile && <span className="header-logo-text">{branding.name}</span>}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
import { useNavigate, useOutletContext, useSearchParams, useLocation, useParams } from 'react-router-dom';
|
import {
|
||||||
|
useNavigate,
|
||||||
|
useOutletContext,
|
||||||
|
useSearchParams,
|
||||||
|
useLocation,
|
||||||
|
useParams,
|
||||||
|
useNavigationType,
|
||||||
|
} from 'react-router-dom';
|
||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { PostItem } from '../api/types';
|
import type { PostItem } from '../api/types';
|
||||||
@@ -12,13 +19,13 @@ import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../comp
|
|||||||
import { useForumLimits } from '../hooks/useForumLimits';
|
import { useForumLimits } from '../hooks/useForumLimits';
|
||||||
import { parseSearchFromUrl, usePostSearch } from '../hooks/usePostSearch';
|
import { parseSearchFromUrl, usePostSearch } from '../hooks/usePostSearch';
|
||||||
import {
|
import {
|
||||||
getFeedCache,
|
|
||||||
setFeedCache,
|
|
||||||
clearAllFeedCache,
|
clearAllFeedCache,
|
||||||
navigateFeed,
|
navigateFeed,
|
||||||
FEED_RESET_EVENT,
|
FEED_RESET_EVENT,
|
||||||
|
FEED_PULL_REFRESH_EVENT,
|
||||||
type FeedNavState,
|
type FeedNavState,
|
||||||
} from '../utils/feedCache';
|
} from '../utils/feedCache';
|
||||||
|
import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
|
||||||
import { openForumPost } from '../utils/openPost';
|
import { openForumPost } from '../utils/openPost';
|
||||||
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
||||||
import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding';
|
import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding';
|
||||||
@@ -35,9 +42,41 @@ function boardIdFromLocation(routeId: string | undefined, searchParams: URLSearc
|
|||||||
return q > 0 ? q : 0;
|
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() {
|
export default function HomePage() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navType = useNavigationType();
|
||||||
const { id: boardRouteId } = useParams();
|
const { id: boardRouteId } = useParams();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const ctx = useOutletContext<LayoutCtx>();
|
const ctx = useOutletContext<LayoutCtx>();
|
||||||
@@ -96,20 +135,49 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
}, [queryBoardId, boardId, boardRouteId, params, nav, limits, location.pathname, location.search]);
|
}, [queryBoardId, boardId, boardRouteId, params, nav, limits, location.pathname, location.search]);
|
||||||
|
|
||||||
const [posts, setPosts] = useState<PostItem[]>([]);
|
const cacheKey = useMemo(
|
||||||
const [postTotal, setPostTotal] = useState(0);
|
() => feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly }),
|
||||||
const [page, setPage] = useState(1);
|
[boardId, keyword, sort, tag, author, titleOnly],
|
||||||
const [loading, setLoading] = useState(true);
|
);
|
||||||
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(null);
|
|
||||||
|
// 筛选键变化时同步水合(含首次挂载),保证第一帧就有列表数据
|
||||||
|
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 [listResetKey, setListResetKey] = useState(0);
|
||||||
|
|
||||||
const scrollTopRef = useRef(0);
|
const scrollTopRef = useRef(initial.scrollTop);
|
||||||
const skipCacheSaveRef = useRef(false);
|
|
||||||
const loadingRef = useRef(false);
|
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;
|
pageRef.current = page;
|
||||||
// 与当前筛选一致的列表快照(供卸载/切换筛选时写入缓存)
|
cacheKeyRef.current = cacheKey;
|
||||||
const feedSnapRef = useRef({ boardId, keyword, tag, author, titleOnly, sort, posts, postTotal, page });
|
|
||||||
|
// 筛选键切换:用新键的缓存重置本地 state(useMemo 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 totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
|
||||||
const showPagination = totalPages > 1 && posts.length > 0;
|
const showPagination = totalPages > 1 && posts.length > 0;
|
||||||
@@ -121,16 +189,38 @@ export default function HomePage() {
|
|||||||
setListResetKey(k => k + 1);
|
setListResetKey(k => k + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/** 强制刷新:清空全部 Feed 缓存并滚回顶部 */
|
||||||
const beginFeedRefresh = useCallback(() => {
|
const beginFeedRefresh = useCallback(() => {
|
||||||
skipCacheSaveRef.current = true;
|
|
||||||
clearAllFeedCache();
|
clearAllFeedCache();
|
||||||
resetFeedView();
|
resetFeedView();
|
||||||
}, [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;
|
if (loadingRef.current) return;
|
||||||
loadingRef.current = true;
|
loadingRef.current = true;
|
||||||
setLoading(true);
|
const silent = !!opts?.silent;
|
||||||
|
// 静默刷新:保留现有列表,不展示加载骨架
|
||||||
|
if (!silent) setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await api.posts({
|
const data = await api.posts({
|
||||||
page: p,
|
page: p,
|
||||||
@@ -148,17 +238,20 @@ export default function HomePage() {
|
|||||||
setPostTotal(total);
|
setPostTotal(total);
|
||||||
setPage(p);
|
setPage(p);
|
||||||
pageRef.current = p;
|
pageRef.current = p;
|
||||||
|
persistFeed(batch, total, p, { touchFetchTime: true });
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
if (!silent) {
|
||||||
notify.error(e instanceof Error ? e.message : '加载失败');
|
notify.error(e instanceof Error ? e.message : '加载失败');
|
||||||
setPosts([]);
|
setPosts([]);
|
||||||
setPostTotal(0);
|
setPostTotal(0);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
pageRef.current = 1;
|
pageRef.current = 1;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loadingRef.current = false;
|
loadingRef.current = false;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [boardId, keyword, tag, author, titleOnly, sort, pageSize]);
|
}, [boardId, keyword, tag, author, titleOnly, sort, pageSize, persistFeed]);
|
||||||
|
|
||||||
const loadFirst = useCallback(() => fetchPage(1), [fetchPage]);
|
const loadFirst = useCallback(() => fetchPage(1), [fetchPage]);
|
||||||
|
|
||||||
@@ -168,89 +261,84 @@ export default function HomePage() {
|
|||||||
if (p < 1 || p > maxPage) return;
|
if (p < 1 || p > maxPage) return;
|
||||||
if (p === pageRef.current) return;
|
if (p === pageRef.current) return;
|
||||||
resetFeedView();
|
resetFeedView();
|
||||||
|
getHomeStoreState().patchScroll(cacheKeyRef.current, 0);
|
||||||
fetchPage(p);
|
fetchPage(p);
|
||||||
}, [fetchPage, postTotal, pageSize, resetFeedView]);
|
}, [fetchPage, postTotal, pageSize, resetFeedView]);
|
||||||
|
|
||||||
const handleSelectPost = useCallback((id: number) => {
|
const handleSelectPost = useCallback((id: number) => {
|
||||||
|
// 离开前再写一次滚动,确保详情页返回可还原
|
||||||
|
getHomeStoreState().patchScroll(cacheKeyRef.current, scrollTopRef.current);
|
||||||
openForumPost(nav, id, limits.open_posts_in_new_tab);
|
openForumPost(nav, id, limits.open_posts_in_new_tab);
|
||||||
}, [nav, limits.open_posts_in_new_tab]);
|
}, [nav, limits.open_posts_in_new_tab]);
|
||||||
|
|
||||||
// 等限制就绪后再拉列表;筛选变化时重载
|
// 等限制就绪后再决定:强制刷新 / 用缓存 / 静默刷新 / 首拉
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (limitsLoading || isInvalidBoardRoute || isMissingBoard) return;
|
if (limitsLoading || isInvalidBoardRoute || isMissingBoard) return;
|
||||||
|
|
||||||
const forceRefresh = (location.state as FeedNavState | null)?.refreshFeed;
|
const forceRefresh = (location.state as FeedNavState | null)?.refreshFeed;
|
||||||
if (forceRefresh) {
|
// 浏览器后退/前进(POP)忽略 history 上残留的 refreshFeed,避免误清空缓存
|
||||||
|
if (forceRefresh && navType !== 'POP') {
|
||||||
|
hydratedKeyRef.current = cacheKey;
|
||||||
beginFeedRefresh();
|
beginFeedRefresh();
|
||||||
|
setPosts([]);
|
||||||
|
setPostTotal(0);
|
||||||
|
setPage(1);
|
||||||
|
pageRef.current = 1;
|
||||||
|
setLoading(true);
|
||||||
loadFirst();
|
loadFirst();
|
||||||
|
// 消费后清掉 state,防止该 history 条目永远带着刷新标记
|
||||||
|
nav(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
|
||||||
return;
|
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) {
|
if (cached && cached.posts.length > 0) {
|
||||||
|
const needRestore = hydratedKeyRef.current !== cacheKey;
|
||||||
|
hydratedKeyRef.current = cacheKey;
|
||||||
setPosts(cached.posts);
|
setPosts(cached.posts);
|
||||||
setPostTotal(cached.postTotal);
|
setPostTotal(cached.postTotal);
|
||||||
setPage(cached.page);
|
setPage(cached.page);
|
||||||
pageRef.current = cached.page;
|
pageRef.current = cached.page;
|
||||||
|
setLoading(false);
|
||||||
|
// 仅在「首次进入该筛选」时恢复滚动,避免 limits/pageSize 变化导致 effect 重跑时打断用户滚动
|
||||||
|
if (needRestore) {
|
||||||
setRestoreScrollTop(cached.scrollTop);
|
setRestoreScrollTop(cached.scrollTop);
|
||||||
scrollTopRef.current = cached.scrollTop;
|
scrollTopRef.current = cached.scrollTop;
|
||||||
setLoading(false);
|
}
|
||||||
|
// 超过 TTL:后台静默刷新,不重置滚动
|
||||||
|
if (getHomeStoreState().isStale(cacheKey)) {
|
||||||
|
void fetchPage(cached.page, { silent: true });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hydratedKeyRef.current = cacheKey;
|
||||||
setRestoreScrollTop(null);
|
setRestoreScrollTop(null);
|
||||||
scrollTopRef.current = 0;
|
scrollTopRef.current = 0;
|
||||||
loadFirst();
|
loadFirst();
|
||||||
}, [
|
}, [
|
||||||
limitsLoading,
|
limitsLoading,
|
||||||
pageSize,
|
pageSize,
|
||||||
boardId,
|
cacheKey,
|
||||||
keyword,
|
|
||||||
tag,
|
|
||||||
author,
|
|
||||||
titleOnly,
|
|
||||||
sort,
|
|
||||||
location.key,
|
location.key,
|
||||||
location.state,
|
location.state,
|
||||||
|
location.pathname,
|
||||||
|
location.search,
|
||||||
|
location.hash,
|
||||||
|
navType,
|
||||||
|
nav,
|
||||||
loadFirst,
|
loadFirst,
|
||||||
|
fetchPage,
|
||||||
beginFeedRefresh,
|
beginFeedRefresh,
|
||||||
isInvalidBoardRoute,
|
isInvalidBoardRoute,
|
||||||
isMissingBoard,
|
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(() => {
|
useEffect(() => {
|
||||||
const onFeedReset = () => beginFeedRefresh();
|
const onFeedReset = () => beginFeedRefresh();
|
||||||
window.addEventListener(FEED_RESET_EVENT, onFeedReset);
|
window.addEventListener(FEED_RESET_EVENT, onFeedReset);
|
||||||
@@ -258,14 +346,39 @@ export default function HomePage() {
|
|||||||
}, [beginFeedRefresh]);
|
}, [beginFeedRefresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Logo / 下拉刷新 / 后台改帖:清空本地列表以露出骨架,再强制拉第 1 页
|
||||||
const fn = () => {
|
const fn = () => {
|
||||||
beginFeedRefresh();
|
beginFeedRefresh();
|
||||||
|
setPosts([]);
|
||||||
|
setPostTotal(0);
|
||||||
|
setPage(1);
|
||||||
|
pageRef.current = 1;
|
||||||
|
setLoading(true);
|
||||||
loadFirst();
|
loadFirst();
|
||||||
};
|
};
|
||||||
window.addEventListener('posts-refresh', fn);
|
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]);
|
}, [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) => {
|
const handleSortChange = (next: FeedSort) => {
|
||||||
if (next === sort) {
|
if (next === sort) {
|
||||||
beginFeedRefresh();
|
beginFeedRefresh();
|
||||||
@@ -331,7 +444,7 @@ export default function HomePage() {
|
|||||||
onSelect={handleSelectPost}
|
onSelect={handleSelectPost}
|
||||||
restoreScrollTop={restoreScrollTop}
|
restoreScrollTop={restoreScrollTop}
|
||||||
resetScrollKey={listResetKey}
|
resetScrollKey={listResetKey}
|
||||||
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
|
onScrollTopChange={handleScrollTopChange}
|
||||||
onScrollRestored={() => setRestoreScrollTop(null)}
|
onScrollRestored={() => setRestoreScrollTop(null)}
|
||||||
keyword={keyword || tag || author}
|
keyword={keyword || tag || author}
|
||||||
isSearchMode={!!(keyword || author)}
|
isSearchMode={!!(keyword || author)}
|
||||||
|
|||||||
106
frontend/src/store/homeStore.ts
Normal file
106
frontend/src/store/homeStore.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { PostItem } from '../api/types';
|
||||||
|
import type { FeedSort } from '../components/FeedSortBar';
|
||||||
|
|
||||||
|
/** 列表缓存过期时间:超过后返回首页时静默刷新,仍先展示旧数据 */
|
||||||
|
export const FEED_STALE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/** 单条 Feed 筛选对应的缓存快照 */
|
||||||
|
export type FeedCacheEntry = {
|
||||||
|
posts: PostItem[];
|
||||||
|
postTotal: number;
|
||||||
|
page: number;
|
||||||
|
scrollTop: number;
|
||||||
|
/** 上次成功拉取 API 的时间戳(ms) */
|
||||||
|
lastFetchTime: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FeedKeyParts = {
|
||||||
|
boardId: number;
|
||||||
|
keyword: string;
|
||||||
|
sort: FeedSort;
|
||||||
|
tag?: string;
|
||||||
|
author?: string;
|
||||||
|
titleOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 与筛选条件一一对应的缓存键 */
|
||||||
|
export function feedCacheKey(parts: FeedKeyParts): string {
|
||||||
|
const {
|
||||||
|
boardId,
|
||||||
|
keyword,
|
||||||
|
sort,
|
||||||
|
tag = '',
|
||||||
|
author = '',
|
||||||
|
titleOnly = false,
|
||||||
|
} = parts;
|
||||||
|
return `${boardId}:${keyword}:${tag}:${author}:${titleOnly ? 1 : 0}:${sort}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HomeStoreState = {
|
||||||
|
/** 按筛选键存放多份列表,首页 / 板块 / 搜索互不覆盖 */
|
||||||
|
feeds: Record<string, FeedCacheEntry>;
|
||||||
|
getFeed: (key: string) => FeedCacheEntry | null;
|
||||||
|
setFeed: (key: string, data: Omit<FeedCacheEntry, 'lastFetchTime'> & { lastFetchTime?: number }) => void;
|
||||||
|
/** 滚动时只更新 scrollTop,不改动列表数据 */
|
||||||
|
patchScroll: (key: string, scrollTop: number) => void;
|
||||||
|
isStale: (key: string, now?: number) => boolean;
|
||||||
|
clearFeed: (key: string) => void;
|
||||||
|
clearAll: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useHomeStore = create<HomeStoreState>((set, get) => ({
|
||||||
|
feeds: {},
|
||||||
|
|
||||||
|
getFeed: (key) => get().feeds[key] ?? null,
|
||||||
|
|
||||||
|
setFeed: (key, data) => {
|
||||||
|
set((state) => ({
|
||||||
|
feeds: {
|
||||||
|
...state.feeds,
|
||||||
|
[key]: {
|
||||||
|
posts: data.posts,
|
||||||
|
postTotal: data.postTotal,
|
||||||
|
page: data.page,
|
||||||
|
scrollTop: data.scrollTop,
|
||||||
|
lastFetchTime: data.lastFetchTime ?? Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
patchScroll: (key, scrollTop) => {
|
||||||
|
const prev = get().feeds[key];
|
||||||
|
if (!prev) return;
|
||||||
|
// 数值未变则跳过,减少无意义的 store 更新
|
||||||
|
if (prev.scrollTop === scrollTop) return;
|
||||||
|
set((state) => ({
|
||||||
|
feeds: {
|
||||||
|
...state.feeds,
|
||||||
|
[key]: { ...prev, scrollTop },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
isStale: (key, now = Date.now()) => {
|
||||||
|
const entry = get().feeds[key];
|
||||||
|
if (!entry) return true;
|
||||||
|
return now - entry.lastFetchTime > FEED_STALE_MS;
|
||||||
|
},
|
||||||
|
|
||||||
|
clearFeed: (key) => {
|
||||||
|
set((state) => {
|
||||||
|
if (!(key in state.feeds)) return state;
|
||||||
|
const next = { ...state.feeds };
|
||||||
|
delete next[key];
|
||||||
|
return { feeds: next };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearAll: () => set({ feeds: {} }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** 非 React 路径(如 feedCache 封装)直接读写 */
|
||||||
|
export function getHomeStoreState() {
|
||||||
|
return useHomeStore.getState();
|
||||||
|
}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import type { NavigateFunction } from 'react-router-dom';
|
import type { NavigateFunction } from 'react-router-dom';
|
||||||
import type { PostItem } from '../api/types';
|
import type { PostItem } from '../api/types';
|
||||||
import type { FeedSort } from '../components/FeedSortBar';
|
import type { FeedSort } from '../components/FeedSortBar';
|
||||||
|
import {
|
||||||
|
feedCacheKey,
|
||||||
|
getHomeStoreState,
|
||||||
|
type FeedCacheEntry,
|
||||||
|
} from '../store/homeStore';
|
||||||
|
|
||||||
/** 导航到帖子列表时附带的状态,用于同 URL 重复点击时强制刷新 */
|
/** 导航到帖子列表时附带的状态,用于同 URL 重复点击时强制刷新 */
|
||||||
export type FeedNavState = { refreshFeed?: boolean };
|
export type FeedNavState = { refreshFeed?: boolean };
|
||||||
|
|
||||||
|
/** 对外暴露的缓存形状(兼容旧调用方;不含 lastFetchTime) */
|
||||||
export type FeedCache = {
|
export type FeedCache = {
|
||||||
posts: PostItem[];
|
posts: PostItem[];
|
||||||
postTotal: number;
|
postTotal: number;
|
||||||
@@ -12,18 +18,13 @@ export type FeedCache = {
|
|||||||
scrollTop: number;
|
scrollTop: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 仅存内存:SPA 内返回可恢复,浏览器刷新自动清空 */
|
function toPublic(entry: FeedCacheEntry): FeedCache {
|
||||||
const store = new Map<string, FeedCache>();
|
return {
|
||||||
|
posts: entry.posts,
|
||||||
function cacheKey(
|
postTotal: entry.postTotal,
|
||||||
boardId: number,
|
page: entry.page,
|
||||||
keyword: string,
|
scrollTop: entry.scrollTop,
|
||||||
sort: FeedSort,
|
};
|
||||||
tag = '',
|
|
||||||
author = '',
|
|
||||||
titleOnly = false,
|
|
||||||
) {
|
|
||||||
return `${boardId}:${keyword}:${tag}:${author}:${titleOnly ? 1 : 0}:${sort}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 读取帖子列表缓存(从详情页返回时恢复浏览位置) */
|
/** 读取帖子列表缓存(从详情页返回时恢复浏览位置) */
|
||||||
@@ -35,10 +36,12 @@ export function getFeedCache(
|
|||||||
author = '',
|
author = '',
|
||||||
titleOnly = false,
|
titleOnly = false,
|
||||||
): FeedCache | null {
|
): FeedCache | null {
|
||||||
return store.get(cacheKey(boardId, keyword, sort, tag, author, titleOnly)) ?? null;
|
const key = feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly });
|
||||||
|
const entry = getHomeStoreState().getFeed(key);
|
||||||
|
return entry ? toPublic(entry) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保存帖子列表缓存 */
|
/** 保存帖子列表缓存(写入 Zustand,并记录拉取时间) */
|
||||||
export function setFeedCache(
|
export function setFeedCache(
|
||||||
boardId: number,
|
boardId: number,
|
||||||
keyword: string,
|
keyword: string,
|
||||||
@@ -48,20 +51,44 @@ export function setFeedCache(
|
|||||||
author = '',
|
author = '',
|
||||||
titleOnly = false,
|
titleOnly = false,
|
||||||
) {
|
) {
|
||||||
store.set(cacheKey(boardId, keyword, sort, tag, author, titleOnly), data);
|
const key = feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly });
|
||||||
|
getHomeStoreState().setFeed(key, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 清除所有帖子列表缓存 */
|
/** 清除所有帖子列表缓存 */
|
||||||
export function clearAllFeedCache() {
|
export function clearAllFeedCache() {
|
||||||
store.clear();
|
getHomeStoreState().clearAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 主动刷新帖子列表时派发,用于同页内立即回到顶部 */
|
/** 主动刷新帖子列表时派发,用于同页内立即回到顶部 */
|
||||||
export const FEED_RESET_EVENT = 'feed-reset';
|
export const FEED_RESET_EVENT = 'feed-reset';
|
||||||
|
|
||||||
/** 清除缓存并导航到帖子列表(重复点击同一入口时也会刷新) */
|
/** 手机下拉刷新(Feed 页):强制重拉列表并重置滚动,不整页 reload */
|
||||||
|
export const FEED_PULL_REFRESH_EVENT = 'feed-pull-refresh';
|
||||||
|
|
||||||
|
/** 当前浏览器地址是否已是目标 Feed URL(忽略 hash) */
|
||||||
|
function isSameFeedUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const target = new URL(url, window.location.origin);
|
||||||
|
return (
|
||||||
|
window.location.pathname === target.pathname
|
||||||
|
&& window.location.search === target.search
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除缓存并导航到帖子列表。
|
||||||
|
* 已在目标 URL(如首页再点 Logo)时额外派发强制重拉,不依赖 RR 是否换 key。
|
||||||
|
*/
|
||||||
export function navigateFeed(nav: NavigateFunction, url: string) {
|
export function navigateFeed(nav: NavigateFunction, url: string) {
|
||||||
clearAllFeedCache();
|
clearAllFeedCache();
|
||||||
window.dispatchEvent(new Event(FEED_RESET_EVENT));
|
window.dispatchEvent(new Event(FEED_RESET_EVENT));
|
||||||
|
// 同 URL 再点(典型:左上角 Logo):必须立刻重拉,否则可能只清缓存、界面仍显示旧列表
|
||||||
|
if (isSameFeedUrl(url)) {
|
||||||
|
window.dispatchEvent(new Event(FEED_PULL_REFRESH_EVENT));
|
||||||
|
}
|
||||||
nav(url, { state: { refreshFeed: true } satisfies FeedNavState });
|
nav(url, { state: { refreshFeed: true } satisfies FeedNavState });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user