You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在React中结合哈希路由实现Scrollspy滚动监听与路由联动

React Hooks 路由联动Scrollspy实现方案

核心实现思路

1. 页面结构调整

所有菜单(包含子菜单)对应的内容块统一放在同一个滚动容器内,每个内容块设置唯一ID,ID和对应菜单的path字段保持一致,方便后续滚动时定位匹配。

2. 滚动监听逻辑实现

给滚动容器绑定节流后的滚动事件,实时计算每个内容块相对于视口的位置,判断当前激活的内容块,同步更新路由和菜单激活态。

3. 父子菜单激活&展开逻辑

父菜单激活条件:当前路由以父菜单的path为前缀;子菜单激活直接用NavLink自带的匹配逻辑即可。父菜单下存在子菜单且当前路由匹配父菜单路径前缀时,自动展开子菜单列表。

4. 路由数据匹配

不管是点击菜单还是滚动触发路由变更,直接根据当前路由的pathname匹配菜单数据里对应项的data字段渲染即可。

核心代码示例

滚动监听+联动路由实现

import { useHistory, useLocation } from 'react-router-dom';
import { useEffect, useRef, useCallback } from 'react';
// 可以用lodash的节流,也可以自己实现简易节流
import { throttle } from 'lodash';
// 你的菜单数据
import menuData from './menuData.json';

const MainPage = () => {
  const history = useHistory();
  const location = useLocation();
  // 存储所有内容块的DOM引用
  const sectionRefs = useRef({});

  // 节流处理滚动事件,200ms触发一次可自行调整
  const handleScroll = useCallback(throttle(() => {
    // 先把多层菜单拍平成一维数组,方便统一判断
    const allMenus = [];
    const flatten = (list) => {
      list.forEach(item => {
        allMenus.push(item);
        item.Sub_menu && flatten(item.Sub_menu);
      })
    };
    flatten(menuData);

    // 从后往前遍历匹配,避免滚动到底部时激活态跳变
    let activePath = null;
    const threshold = 100; // 激活阈值,可自行调整
    for (let i = allMenus.length - 1; i >= 0; i--) {
      const blockId = allMenus[i].path.replaceAll('/', '');
      const targetBlock = sectionRefs.current[blockId];
      if (!targetBlock) continue;
      const rect = targetBlock.getBoundingClientRect();
      // 内容块顶部进入阈值范围即判定为激活
      if (rect.top <= threshold) {
        activePath = allMenus[i].path;
        break;
      }
    }

    // 路由不一致时才更新,避免重复触发渲染
    if (activePath && activePath !== location.pathname) {
      // 用replace不会产生多余历史记录,需要保留滚动历史可以换成push
      history.replace(activePath);
    }
  }, 200), [location.pathname]);

  // 绑定/解绑滚动事件
  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    return () => {
      window.removeEventListener('scroll', handleScroll);
      handleScroll.cancel();
    };
  }, [handleScroll]);

  return (
    <div className="page-wrapper">
      {/* 侧边栏菜单 */}
      <aside className="sidebar">
        {menuData.map(parentItem => (
          <div key={parentItem.path} className="menu-group">
            {/* 父菜单激活判断:当前路由以父路径为前缀 */}
            <NavLink
              to={parentItem.path}
              className={`nav-link ${location.pathname.startsWith(`/${parentItem.path}`) ? 'active' : ''}`}
              onClick={() => OnclickSidebar(parentItem.menu, parentItem.path)}
            >
              {parentItem.menu}
            </NavLink>
            {/* 子菜单展开条件:有子菜单且当前路由匹配父路径前缀 */}
            {parentItem.Sub_menu && location.pathname.startsWith(`/${parentItem.path}`) && (
              <ul className="sub-menu">
                {parentItem.Sub_menu.map(subItem => (
                  <li key={subItem.path} className="sub-menu-item">
                    <NavLink
                      className="sub-nav-link"
                      activeClassName="active"
                      to={subItem.path}
                    >
                      {subItem.menu}
                    </NavLink>
                  </li>
                ))}
              </ul>
            )}
          </div>
        ))}
      </aside>

      {/* 内容区域 */}
      <main className="content">
        {menuData.map(parentItem => {
          const renderSections = [parentItem];
          parentItem.Sub_menu && renderSections.push(...parentItem.Sub_menu);
          return renderSections.map(item => {
            const blockId = item.path.replaceAll('/', '');
            return (
              <section
                key={item.path}
                id={blockId}
                ref={el => sectionRefs.current[blockId] = el}
                className="content-block"
                // 保证每个内容块高度足够触发滚动,可根据实际需求调整
                style={{ minHeight: '800px', padding: '20px' }}
              >
                {item.data}
              </section>
            )
          })
        })}
      </main>
    </div>
  )
};

路由配置简化

不用为每个菜单单独写路由规则,直接用动态路由匹配即可:

<Switch>
  <Route path="/:firstPath?/:secondPath?" component={MainPage} />
  <Route path="/*" component={() => <div>Page not found</div>} />
</Switch>

注意事项

  • 节流必须加,避免滚动事件高频触发导致页面卡顿
  • 内容块要设置足够的高度,否则会出现多个块同时在视口内的情况,导致激活态跳变
  • 如果需要支持用户手动收起/展开父菜单,额外加一个expandedKeys状态存储当前展开的父菜单路径,点击父菜单时切换状态即可。

内容的提问来源于stack exchange,提问作者vivek singh

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.09.28 13:06:02