如何在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
相关产品推荐
相关产品推荐

