feat: 实现基础滚动位置恢复功能
1. 新增 useScrollRestoration hook 用于在布局组件挂载时恢复滚动位置 2. 新增 scrollRestore 工具模块实现基于 sessionStorage 的滚动位置保存与恢复 3. 在 MainLayout 和 AdminLayout 中接入滚动恢复hook 4. 在应用入口初始化全局滚动保存监听
This commit is contained in:
19
frontend/src/hooks/useScrollRestoration.ts
Normal file
19
frontend/src/hooks/useScrollRestoration.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from 'react';
|
||||
import { restoreScrollPositions } from '../utils/scrollRestore';
|
||||
|
||||
/**
|
||||
* 在布局组件挂载时恢复当前 URL 的滚动位置(仅刷新场景生效)。
|
||||
*
|
||||
* 使用 mount-only effect:布局组件在 SPA 内导航时不会重新挂载,
|
||||
* 因此此 effect 仅在页面实际重载(刷新)或跨布局切换时触发,
|
||||
* 不会干扰 SPA 内导航的默认滚动行为。
|
||||
*
|
||||
* rAF 重试循环会等待异步内容加载完成后再设置 scrollTop,
|
||||
* 组件卸载时通过返回的取消函数终止重试。
|
||||
*/
|
||||
export function useScrollRestoration(): void {
|
||||
useEffect(() => {
|
||||
const cancel = restoreScrollPositions();
|
||||
return cancel;
|
||||
}, []);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import BackToTop from '../components/BackToTop';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||
import { useNoIndexSEO } from '../hooks/usePageSEO';
|
||||
import { useScrollRestoration } from '../hooks/useScrollRestoration';
|
||||
import SiteBrandMark from '../components/SiteBrandMark';
|
||||
import { api } from '../api/client';
|
||||
|
||||
@@ -80,6 +81,7 @@ export default function AdminLayout() {
|
||||
const { theme, toggle } = useTheme();
|
||||
const { branding } = useSiteBranding();
|
||||
useNoIndexSEO('管理后台');
|
||||
useScrollRestoration();
|
||||
const isNarrow = useMediaQuery('(max-width: 768px)');
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [pending, setPending] = useState<PendingCounts>({ posts: 0, comments: 0, reports: 0 });
|
||||
|
||||
@@ -21,6 +21,7 @@ import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
|
||||
import RightPanel from '../components/RightPanel';
|
||||
import BackToTop from '../components/BackToTop';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { useScrollRestoration } from '../hooks/useScrollRestoration';
|
||||
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
|
||||
import { navigateFeed } from '../utils/feedCache';
|
||||
import { notify } from '@/lib/notify';
|
||||
@@ -74,6 +75,7 @@ export default function MainLayout() {
|
||||
const [searchAdvanced, setSearchAdvanced] = useState(false);
|
||||
const feedSort = parseFeedSort(params.get('sort'));
|
||||
const { limits: forumLimits } = useForumLimits();
|
||||
useScrollRestoration();
|
||||
|
||||
const asideDrawerRef = useRef<HTMLElement>(null);
|
||||
const asideCloseRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { applyTheme, getStoredTheme } from './utils/theme';
|
||||
import { initScrollRestore } from './utils/scrollRestore';
|
||||
import App from './App';
|
||||
|
||||
applyTheme(getStoredTheme());
|
||||
initScrollRestore();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
126
frontend/src/utils/scrollRestore.ts
Normal file
126
frontend/src/utils/scrollRestore.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 全局滚动位置恢复:基于 sessionStorage + pagehide。
|
||||
*
|
||||
* 背景:本应用为固定视口(100dvh + overflow:hidden)SPA,window 不滚动,
|
||||
* 浏览器原生 scrollRestoration 对内部滚动容器无效。
|
||||
* 因此在 pagehide 时主动将各滚动容器的 scrollTop 存入 sessionStorage,
|
||||
* 页面重载后通过 rAF 重试机制在异步内容加载完成后恢复。
|
||||
*
|
||||
* 适用场景:浏览器刷新(F5 / Ctrl+R / Ctrl+F5)后保持原阅读位置。
|
||||
* 不干扰 SPA 内导航的默认滚动行为(布局组件不重新挂载,mount-only effect 不触发)。
|
||||
*/
|
||||
|
||||
const STORAGE_PREFIX = 'j13-scroll:';
|
||||
|
||||
/** 候选滚动容器选择器(覆盖主站与后台各页面) */
|
||||
const SCROLL_SELECTORS = [
|
||||
'.main-content--feed-mobile-scroll', // 移动端首页 Feed 整栏滚动
|
||||
'.post-list-scroll', // 桌面端首页 Feed 列表滚动
|
||||
'.page-wrap', // 帖子详情 / 个人主页 / 消息等
|
||||
'.admin-main', // 后台主内容区
|
||||
] as const;
|
||||
|
||||
type SavedPositions = {
|
||||
containers: Record<string, number>;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
function storageKey(url: string): string {
|
||||
return STORAGE_PREFIX + url;
|
||||
}
|
||||
|
||||
function getCurrentUrl(): string {
|
||||
return window.location.pathname + window.location.search;
|
||||
}
|
||||
|
||||
/** 保存当前页面各滚动容器的位置到 sessionStorage */
|
||||
export function saveScrollPositions(url: string = getCurrentUrl()): void {
|
||||
try {
|
||||
const containers: Record<string, number> = {};
|
||||
for (const selector of SCROLL_SELECTORS) {
|
||||
const el = document.querySelector<HTMLElement>(selector);
|
||||
if (el && el.scrollTop > 0) {
|
||||
containers[selector] = Math.round(el.scrollTop);
|
||||
}
|
||||
}
|
||||
if (Object.keys(containers).length === 0) return;
|
||||
const entry: SavedPositions = { containers, ts: Date.now() };
|
||||
sessionStorage.setItem(storageKey(url), JSON.stringify(entry));
|
||||
} catch {
|
||||
// sessionStorage 不可用或已满,静默失败
|
||||
}
|
||||
}
|
||||
|
||||
type CancelFn = () => void;
|
||||
|
||||
/**
|
||||
* 从 sessionStorage 读取并恢复指定 URL 的滚动位置。
|
||||
* 使用 rAF 重试,等待异步内容加载完成后再设置 scrollTop。
|
||||
* 返回取消函数,用于在组件卸载时终止重试循环。
|
||||
*/
|
||||
export function restoreScrollPositions(url: string = getCurrentUrl()): CancelFn {
|
||||
let entry: SavedPositions | null = null;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(storageKey(url));
|
||||
if (raw) entry = JSON.parse(raw) as SavedPositions;
|
||||
} catch {
|
||||
return () => {};
|
||||
}
|
||||
if (!entry?.containers || Object.keys(entry.containers).length === 0) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let rafId = 0;
|
||||
const deadline = performance.now() + 4000;
|
||||
const pending = Object.entries(entry.containers);
|
||||
const done = new Set<string>();
|
||||
|
||||
const tryRestore = (selector: string, target: number): boolean => {
|
||||
const el = document.querySelector<HTMLElement>(selector);
|
||||
if (!el) return false;
|
||||
// 内容尚未加载到足以滚动到目标位置,等待重试
|
||||
const maxScroll = el.scrollHeight - el.clientHeight;
|
||||
if (maxScroll < target - 1) return false;
|
||||
el.scrollTop = target;
|
||||
return Math.abs(el.scrollTop - target) <= 1;
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
if (cancelled) return;
|
||||
for (const [selector, target] of pending) {
|
||||
if (done.has(selector)) continue;
|
||||
if (tryRestore(selector, target)) {
|
||||
done.add(selector);
|
||||
}
|
||||
}
|
||||
if (done.size === pending.length || performance.now() > deadline) {
|
||||
// 全部恢复完成或超时,清除存储条目(避免跨布局切换返回时错误恢复)
|
||||
try {
|
||||
sessionStorage.removeItem(storageKey(url));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafId = requestAnimationFrame(tick);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(rafId);
|
||||
};
|
||||
}
|
||||
|
||||
let initialized = false;
|
||||
|
||||
/** 注册全局 pagehide 监听器(在应用入口调用一次) */
|
||||
export function initScrollRestore(): void {
|
||||
if (typeof window === 'undefined' || initialized) return;
|
||||
initialized = true;
|
||||
window.addEventListener('pagehide', () => {
|
||||
saveScrollPositions();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user