在 MUI Toolpad Core(仪表盘布局) + React Router 项目中实现滚动条的保存与恢复
标签搜索

在 MUI Toolpad Core(仪表盘布局) + React Router 项目中实现滚动条的保存与恢复

huoshen80
2025-09-14 / 0 评论 / 6 阅读 / 正在检测是否收录...

放一只无人认领的希亚(x
希亚

省流

点我跳到原因与解决方案

引言:一个“简单”的需求

在我开发 ReinaManager 的过程中,我有一个简单的需求:

在不同路由页面间切换时,能够保存并恢复页面的滚动条位置。
比如:当我在游戏库向下滑动了一段距离,点击进入某个游戏的详情页,然后再返回游戏仓库时,我希望它能回到之前浏览的位置,而不是页面的最顶端。

这听起来很简单,对吧?我一开始也这么认为。然而,就是这个看似“简单”的需求,将我拖入了一场长达数天的、与 MUI Toolpad Core 中仪表盘布局(Dashboard Layout)、React Router 和各种状态管理库之间的战斗...


第一阶段:天真的尝试 —— KeepAlive 与 Router 的 <ScrollRestoration />

1. “釜底抽薪”:组件保活(react-activation)

我的第一个想法是:如果页面不被卸载,那滚动条位置不就自然保存下来了吗?于是我引入了 react-activation 库。

实际上,react-activation 的组件保活不包括滚动条位置的保存,它提供了一个 saveScrollPosition 属性:

mfjg6bwp.png

2. “官方正统”<ScrollRestoration />

React Router v6.4+ 官方提供了一个保存滚动条的解决方案:<ScrollRestoration /> 组件。文档说明,只需要在应用中渲染它,就能自动处理滚动恢复。

mfjg9uqf.png

小结


在我的项目中,这两种方法都没能奏效,于是就这样进入了第二阶段...


第二阶段:原因的探索 —— 为什么这些方法都不奏效?

既然别人造的轮子都没用,那就自己动手搓一个,可是要想自己造轮子,首先得弄清楚为什么这些轮子在我的项目中不适用,不弄清楚这个“为什么”,自定义的方案也无从下手。

经过文档翻阅、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>
    );
}

最终效果

scrollsave.gif

0

评论 (0)

取消