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

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

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

放一只无人认领的希亚(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

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>
    );
}

最终效果

scrollsave.gif

0

评论 (0)

取消