首页
赞助博主
友链
关于
工具
推荐
我的B站主页
我的歌单
我的bgm
井字棋
待办事项
随机cg
github加速
Search
1
iOS永久不续签随意装软件,trollstore巨魔商店安装教程
3,336 阅读
2
进入自己原神服务器
1,727 阅读
3
linux云服开原神服务器
1,582 阅读
4
win上开原神服务器
1,098 阅读
5
从零开始的mc联机教程
1,088 阅读
默认分类
原神
MC
iOS
galgame
ReinaManager
学习笔记
登录
Search
标签搜索
原神
私服
win
安卓
火神80
累计撰写
11
篇文章
累计收到
12
条评论
首页
栏目
默认分类
原神
MC
iOS
galgame
ReinaManager
学习笔记
页面
赞助博主
友链
关于
工具
推荐
我的B站主页
我的歌单
我的bgm
井字棋
待办事项
随机cg
github加速
搜索到
1
篇与
的结果
2025-09-14
在 MUI Toolpad Core(仪表盘布局) + React Router 项目中实现滚动条的保存与恢复
放一只无人认领的希亚(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> ); }最终效果
2025年09月14日
6 阅读
0 评论
0 点赞