放一只无人认领的希亚(x
省流
引言:一个“简单”的需求
在我开发 ReinaManager 的过程中,我有一个简单的需求:
在不同路由页面间切换时,能够保存并恢复页面的滚动条位置。
比如:当我在游戏库向下滑动了一段距离,点击进入某个游戏的详情页,然后再返回游戏仓库时,我希望它能回到之前浏览的位置,而不是页面的最顶端。
这听起来很简单,对吧?我一开始也这么认为。然而,就是这个看似“简单”的需求,将我拖入了一场长达数天的、与 MUI Toolpad Core 中仪表盘布局(Dashboard Layout)、React Router 和各种状态管理库之间的战斗...
第一阶段:天真的尝试 —— KeepAlive 与 Router 的 <ScrollRestoration />
1. “釜底抽薪”:组件保活(react-activation)
我的第一个想法是:如果页面不被卸载,那滚动条位置不就自然保存下来了吗?于是我引入了 react-activation 库。
实际上,react-activation 的组件保活不包括滚动条位置的保存,它提供了一个 saveScrollPosition
属性:
2. “官方正统”<ScrollRestoration />
React Router v6.4+ 官方提供了一个保存滚动条的解决方案:<ScrollRestoration />
组件。文档说明,只需要在应用中渲染它,就能自动处理滚动恢复。
小结
在我的项目中,这两种方法都没能奏效,于是就这样进入了第二阶段...
第二阶段:原因的探索 —— 为什么这些方法都不奏效?
既然别人造的轮子都没用,那就自己动手搓一个,可是要想自己造轮子,首先得弄清楚为什么这些轮子在我的项目中不适用,不弄清楚这个“为什么”,自定义的方案也无从下手。
经过文档翻阅、devtools 调试、排除法(最笨但是很有效 x)等手段,我终于发现了问题的根源:
- Toolpad Core 仪表盘布局(Dashboard Layout)渲染的滚动容器并不是整个 window,而是在一个 main 标签内,这个 main 标签是由 DashboardLayout 组件渲染的。
仪表盘布局结构如下:
DashboardLayout (渲染滚动容器 main)
└── <Outlet /> (渲染各个页面组件)
对于 KeepAlive:
- 它只检测 KeepAlive 子组件中的可滚动元素。
- 如果放在 DashboardLayout 外层,因为路由的切换,Outlet 部分会变化,导致子组件无法缓存,切换路由会让子组件重新渲染(我不是为了保活组件才加 react-activation 这个库的么?)。
- 如果放在子组件外层,如包裹 Library 组件,滚动容器又不是在子组件内,saveScrollPosition 属性就无效了。
对于 <ScrollRestoration />
:
- 它期望滚动发生在 window 或 document 上。
- 位于 main 标签内的滚动容器不在它的监控范围内,因此它无法正确监听到不同子组件的滚动事件,也就无法保存和恢复滚动位置。
第三阶段:自定义方案 —— 自己动手,丰衣足食
既然知道了滚动容器是 main 标签,那我就有了这样的想法:
在路由切换之前保存滚动条位置,组件加载时用自定义 hook 恢复滚动条。
1. 保存滚动位置
scrollStore.ts
// src/store/scrollStore.ts
// 使用 zustand 创建一个简单的全局状态管理,用于保存各个路径的滚动位置
import { create } from 'zustand';
interface ScrollState {
scrollPositions: Record<string, number>;
setScrollPosition: (path: string, position: number) => void;
}
export const useScrollStore = create<ScrollState>((set) => ({
scrollPositions: {},
setScrollPosition: (path, position) =>
set((state) => ({
scrollPositions: {
...state.scrollPositions,
[path]: position,
},
})),
}));
scrollUtils.ts
// 工具函数,用于保存滚动位置
// src/utils/scrollUtils.ts
import { useScrollStore } from '@/store/scrollStore';
//保存指定路径的滚动条位置
export const saveScrollPosition = (path: string) => {
const SCROLL_CONTAINER_SELECTOR = 'main';
const container = document.querySelector<HTMLElement>(SCROLL_CONTAINER_SELECTOR);
// 增加一个检查,确保容器是可滚动的,避免无效保存
if (container && container.scrollHeight > container.clientHeight) {
const scrollTop = container.scrollTop;
useScrollStore.setState(state => ({
scrollPositions: {
...state.scrollPositions,
[path]: scrollTop,
}
}));
}
};
2. 恢复滚动位置(二编)
useRestoreScroll.ts
import { useEffect, useRef, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { useScrollStore } from '@/store/scrollStore';
import { useActivate, useUnactivate } from 'react-activation';
interface UseScrollRestoreOptions {
/** 滚动容器选择器,默认 'main' */
containerSelector?: string;
/** 是否正在加载中 */
isLoading?: boolean;
/** 超时时间(ms),默认 2000 */
timeout?: number;
/** 是否启用调试日志 */
debug?: boolean;
/** 内容稳定检测的等待时间(ms),默认 150 */
stabilityDelay?: number;
/** 是否在 KeepAlive 中使用 */
useKeepAlive?: boolean;
}
const DEFAULT_OPTIONS: Required<Omit<UseScrollRestoreOptions, 'isLoading'>> = {
containerSelector: 'main',
timeout: 1500,
debug: false,
stabilityDelay: 0,
useKeepAlive: false,
};
/**
* 滚动位置还原 Hook (优化版)
*
* 特性:
* - 智能检测内容是否渲染完成(高度稳定检测)
* - 支持滚动到底部的场景
* - 避免滚动抖动和跳跃
* - 自动清理资源,防止内存泄漏
*/
export function useScrollRestore(
scrollPath: string,
options: UseScrollRestoreOptions = {}
) {
const { containerSelector, isLoading, timeout, debug, stabilityDelay, useKeepAlive } = {
...DEFAULT_OPTIONS,
...options,
};
const location = useLocation();
const { scrollPositions } = useScrollStore();
const cleanupRef = useRef<(() => void) | null>(null);
const settledRef = useRef(false);
const lastPathRef = useRef<string>('');
const lastHeightRef = useRef(0);
const stabilityTimerRef = useRef<number | null>(null);
const log = useCallback(
(...args: any[]) => {
if (debug) console.log('[useScrollRestore]', ...args);
},
[debug]
);
useEffect(() => {
if ('scrollRestoration' in window.history) {
window.history.scrollRestoration = 'manual';
}
}, []);
// 提取滚动恢复逻辑为独立函数
const performScrollRestore = useCallback(() => {
// 路径变化时重置状态
if (lastPathRef.current !== location.pathname) {
settledRef.current = false;
lastPathRef.current = location.pathname;
lastHeightRef.current = 0;
}
// 清理上一次的副作用
if (cleanupRef.current) {
log('Cleaning up previous effect');
cleanupRef.current();
cleanupRef.current = null;
}
if (isLoading) {
log('Skipping: isLoading=true');
return;
}
const container = document.querySelector<HTMLElement>(containerSelector);
if (!container) {
log('Container not found:', containerSelector);
return;
}
const isTargetPath = location.pathname === scrollPath;
const target = isTargetPath ? (scrollPositions[scrollPath] || 0) : 0;
log('Target position:', target, 'for path:', location.pathname);
// 快速路径:目标为 0
if (target === 0) {
container.scrollTop = 0;
settledRef.current = true;
log('Scrolled to top immediately');
return;
}
if (settledRef.current) {
log('Already settled, skipping');
return;
}
let ro: ResizeObserver | null = null;
let fallbackTimer: number | null = null;
// 清理函数(先定义,避免在 performRestore 中引用未定义的变量)
const cleanup = () => {
if (ro) {
ro.disconnect();
ro = null;
}
if (fallbackTimer !== null) {
window.clearTimeout(fallbackTimer);
fallbackTimer = null;
}
if (stabilityTimerRef.current !== null) {
window.clearTimeout(stabilityTimerRef.current);
stabilityTimerRef.current = null;
}
};
// 执行滚动恢复
const performRestore = (reason: string) => {
if (settledRef.current) return;
const maxScroll = Math.max(0, container.scrollHeight - container.clientHeight);
const clampedTarget = Math.max(0, Math.min(target, maxScroll));
const prevBehavior = container.style.scrollBehavior;
container.style.scrollBehavior = 'auto';
container.scrollTop = clampedTarget;
container.style.scrollBehavior = prevBehavior;
settledRef.current = true;
if (clampedTarget < target) {
log(`⚠ Restored to bottom (${clampedTarget}/${target}) - ${reason}`);
} else {
log(`✓ Restored scroll to ${clampedTarget} - ${reason}`);
}
// 清理资源
cleanup();
};
// 检查内容高度是否稳定
const checkStability = () => {
const currentHeight = container.scrollHeight;
const maxScroll = currentHeight - container.clientHeight;
log('Height check:', {
current: currentHeight,
last: lastHeightRef.current,
maxScroll,
target,
});
// 情况1: 内容已经足够高,可以直接恢复
if (maxScroll >= target) {
performRestore('content sufficient');
return;
}
// 情况2: 高度稳定(不再增长)
if (lastHeightRef.current > 0 && currentHeight === lastHeightRef.current) {
// 高度不再变化,说明内容已渲染完成
// 即使 maxScroll < target,也恢复到最大可滚动位置
performRestore('content stable');
return;
}
// 更新上次高度
lastHeightRef.current = currentHeight;
// 清除旧的稳定性计时器
if (stabilityTimerRef.current !== null) {
window.clearTimeout(stabilityTimerRef.current);
}
// 设置新的稳定性计时器
// 如果在 stabilityDelay 时间内高度没有变化,认为内容已稳定
stabilityTimerRef.current = window.setTimeout(() => {
if (!settledRef.current) {
checkStability();
}
}, stabilityDelay);
};
// 立即检查一次
checkStability();
// 使用 ResizeObserver 监听容器尺寸变化
try {
ro = new ResizeObserver(() => {
if (!settledRef.current) {
checkStability();
}
});
ro.observe(container);
log('ResizeObserver attached');
} catch (err) {
log('ResizeObserver not available');
}
// 超时保护
fallbackTimer = window.setTimeout(() => {
if (!settledRef.current) {
log('⏰ Timeout reached, forcing restore');
performRestore('timeout');
}
}, timeout);
cleanupRef.current = cleanup;
return cleanup;
}, [location.pathname, scrollPath, scrollPositions, isLoading, containerSelector, timeout, stabilityDelay, log]);
// 普通模式:使用 useEffect
useEffect(() => {
if (!useKeepAlive) {
performScrollRestore();
}
}, [useKeepAlive, performScrollRestore]);
// KeepAlive 模式:使用 useActivate
useActivate(() => {
if (useKeepAlive) {
log('[KeepAlive] 组件激活,触发滚动恢复');
// 重置状态,因为可能是从其他页面返回
settledRef.current = false;
lastHeightRef.current = 0;
performScrollRestore();
}
});
// KeepAlive 失活时清理
useUnactivate(() => {
if (useKeepAlive && cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
});
}
3. 使用例子
Library.tsx
// src/components/Library.tsx
import Cards from "@/components/Cards";
import { useScrollRestore } from "@/hooks/useScrollRestore";
export const Libraries: React.FC = () => {
useScrollRestore('/libraries', { useKeepAlive: true });//更好支持KeepAlive,如果没使用KeepAlive,则直接传入路径即可。
return (
<Cards />
)
}
4. 导航自定义
LinkWithScrollSave.tsx
// src/components/LinkWithScrollSave.tsx
// 自定义 Link 组件,点击时保存滚动位置
import React, { KeyboardEvent } from 'react';
import { LinkProps, useLocation, useNavigate } from 'react-router-dom';
import { saveScrollPosition } from '@/utils/scrollUtils.ts';
export const LinkWithScrollSave: React.FC<LinkProps> = (props) => {
const { to, onClick, children, ...rest } = props as any;
const location = useLocation();
const navigate = useNavigate();
// 保持原有的滚动保存实现:只在导航前调用一次 saveScrollPosition
const handleClick = (event: React.MouseEvent<any>) => {
saveScrollPosition(location.pathname);
if (props.onClick) {
props.onClick(event);
}
};
const performNavigation = (target: any) => {
try {
if (typeof target === 'string' || typeof target === 'object') {
navigate(target);
}
} catch (err) {
// swallow navigation errors to avoid breaking UI
console.error('navigation failed', err);
}
};
const handleDivClick = (event: React.MouseEvent<HTMLDivElement>) => {
handleClick((event as unknown) as React.MouseEvent<HTMLAnchorElement>);
performNavigation(to);
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
// @ts-ignore - reuse handleClick semantics
handleClick((event as unknown) as React.MouseEvent<HTMLAnchorElement>);
performNavigation(to);
}
};
// 渲染为非锚点容器,避免嵌套 <a>。不改动滚动的实现逻辑。
return (
<div
role="link"
tabIndex={0}
onClick={handleDivClick}
onKeyDown={handleKeyDown}
{...(rest as any)}
>
{children}
</div>
);
};
export default LinkWithScrollSave;
Layout.tsx
// src/components/Layout.tsx
// 使用自定义 Link 组件接管导航
import React, { useCallback } from 'react';
import {
DashboardLayout,
DashboardSidebarPageItem,
type SidebarFooterProps,
} from '@toolpad/core/DashboardLayout';
import { Outlet } from 'react-router';
import { LinkWithScrollSave } from '../LinkWithScrollSave';
import { NavigationPageItem } from '@toolpad/core/AppProvider';
export const Layout: React.FC = () => {
const handleRenderPageItem = useCallback((item: NavigationPageItem, params: any) => {
const to = `/${item.segment || ''}`;
// 外层不渲染 <a>,而是使用可访问的 div 进行编程式导航,
// 在导航前 LinkWithScrollSave 会保存滚动位置,避免嵌套 <a>。
return (
<LinkWithScrollSave to={to} style={{ textDecoration: 'none', color: 'inherit' }}>
<DashboardSidebarPageItem item={item} {...params} />//保持原有样式
</LinkWithScrollSave>
);
}, []);
return (
<DashboardLayout
renderPageItem={handleRenderPageItem}
>
<Outlet />
</DashboardLayout>
);
}
评论 (0)