放一只无人认领的希亚(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
// src/hooks/useRestoreScroll.ts
// 自定义 Hook,用于在组件挂载时恢复滚动位置
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useScrollStore } from '@/store/scrollStore';
const SCROLL_CONTAINER_SELECTOR = 'main';
// 这个 Hook 负责恢复滚动,但会等待容器内容高度稳定或足够大后再设置 scrollTop,
// 避免在内容还在渲染期间直接设置导致的“弹条/抖动”效果。
export function useScrollRestore(scrollPath: string, isLoading?: boolean) {
const location = useLocation();
const { scrollPositions } = useScrollStore();
useEffect(() => {
if (window.history.scrollRestoration) {
window.history.scrollRestoration = 'manual';
}
}, []);
useEffect(() => {
if (isLoading) return;
const container = document.querySelector<HTMLElement>(SCROLL_CONTAINER_SELECTOR);
if (!container) return;
const target = location.pathname === scrollPath ? (scrollPositions[scrollPath] || 0) : 0;
// 快速路径:目标为 0,直接滚到顶部(无需等待)
if (!target) {
container.scrollTop = 0;
return;
}
let ro: ResizeObserver | null = null;
let settled = false;
// 如果内容高度已经足够,可以立即恢复
const tryRestore = () => {
// 当容器可滚动区域足够覆盖目标位置时,恢复位置
if (container.scrollHeight >= target + container.clientHeight) {
// 禁用潜在的平滑滚动,立即设置位置,避免出现动画
const prev = container.style.scrollBehavior;
container.style.scrollBehavior = 'auto';
// clamp to avoid overflow
container.scrollTop = Math.min(target, container.scrollHeight - container.clientHeight);
container.style.scrollBehavior = prev;
settled = true;
return true;
}
return false;
};
// 如果能立即恢复则退出
if (tryRestore()) return;
// 否则监听尺寸变化,直到高度足够或超时
try {
ro = new ResizeObserver(() => {
if (settled) return;
if (tryRestore()) {
if (ro) {
ro.disconnect();
ro = null;
}
}
});
ro.observe(container);
} catch (err) {
// ResizeObserver 不可用时,回退到定时器方式
}
// 最多等待 1500ms,然后无条件设置(防止无限等待)
const fallback = window.setTimeout(() => {
if (!settled) {
const prev = container.style.scrollBehavior;
container.style.scrollBehavior = 'auto';
container.scrollTop = Math.min(target, Math.max(0, container.scrollHeight - container.clientHeight, target));
container.style.scrollBehavior = prev;
settled = true;
}
if (ro) {
ro.disconnect();
ro = null;
}
}, 1500);
return () => {
if (ro) ro.disconnect();
window.clearTimeout(fallback);
};
}, [location.pathname, scrollPath, isLoading]);
}
3. 使用例子
Library.tsx
// src/components/Library.tsx
import { Outlet } from "react-router";
import { useScrollRestore } from "@/hooks/useScrollRestore";
export const Libraries = () => {
useScrollRestore('/libraries');//就这么简单
return (
<Outlet />
)
}
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)