在React项目中实现一个简单的锚点目录定位
前言
锚点目录定位功能在长页面和文档类网站中非常常见,它可以让用户快速定位到页面中的某个章节
- 如何在React中实现锚点定位和平滑滚动
- 目录自动高亮的实现思路
- 处理顶部导航遮挡锚点的解决方案
- 服务端渲染下的实现方案
- 性能优化策略
实现基本锚点定位
首先,我们需要实现页面内基本的锚点定位功能。对于锚点定位来说,主要涉及这两个部分:
- 设置锚点,为页面中的某个组件添加id属性
- 点击链接,跳转到指定锚点处
例如:
// 锚点组件 function AnchorComponent() { return <h2 id="anchor">This is anchor</h2> } // 链接组件 function LinkComponent() { return ( <a href="#anchor" rel="external nofollow" rel="external nofollow" rel="external nofollow" >Jump to Anchor</a> ) }
当我们点击Jump to Anchor
这个链接时,页面会平滑滚动到AnchorComponent
所在的位置。
使用useScrollIntoView自定义hook
React中实现锚点定位,最简单的方式就是使用useScrollIntoView这个自定义hook。
import { useScrollIntoView } from 'react-use'; function App() { const anchorRef = useRef(); const scrollToAnchor = () => { useScrollIntoView(anchorRef); } return ( <> <a href="#anchor" rel="external nofollow" rel="external nofollow" rel="external nofollow" onClick={scrollToAnchor}> Jump to Anchor </a> <h2 id="anchor" ref={anchorRef}>This is anchor</h2> </> ) }
useScrollIntoView接受一个ref对象,当调用这个hook函数时,会自动滚动页面,使得ref对象在可视区域内。
原生scrollIntoView方法
useScrollIntoView内部其实就是使用了原生的scrollIntoView方法,所以我们也可以直接调用:
function App() { const anchorRef = useRef(); const scrollToAnchor = () => { anchorRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }) }; return ( <> <a href="#anchor" rel="external nofollow" rel="external nofollow" rel="external nofollow" onClick={scrollToAnchor}>Jump to Anchor</a> <h2 id="anchor" ref={anchorRef}>This is anchor</h2> </> ) }
scrollIntoView可以让元素的父容器自动滚动,将这个元素滚动到可见区域。behavior:'smooth'可以启用平滑滚动效果。
锚点定位和目录联动
很多时候,我们会在页面中实现一个目录导航,可以快速定位到各个章节。此时就需要实现锚点定位和目录的联动效果:
- 点击目录时,自动滚动到对应的章节
- 滚动页面时,自动高亮正在浏览的章节
目录导航组件
目录导航本身是一个静态组件,我们通过props传入章节数据:
function Nav({ chapters }) { return ( <ul className=" chapters"> {chapters.map(chapter => ( <li key={chapter.id}> <a href={'#' + chapter.id}> {chapter.title} </a> </li> ))} </ul> ) }
锚点组件
然后在页面中的每一章使用Anchor组件包裹:
function Chapter({ chapter }) { return ( <Anchor id={chapter.id}> <h2>{chapter.title}</h2> {chapter.content} </Anchor> ) } function Anchor({ children, id }) { return ( <div id={id}> {children} </div> ) }
这样通过id属性建立章节内容和目录链接之间的关联。
处理点击事件
当点击目录链接时,需要滚动到对应的章节位置:
function App() { //... const scrollToChapter = (chapterId) => { const chapterEl = document.getElementById(chapterId); chapterEl.scrollIntoView({ behavior: 'smooth' }); } return ( <> <Nav chapters={chapters} onLinkClick={(chapterId) => scrollToChapter(chapterId)} /> {chapters.map(chapter => ( <Chapter key={chapter.id} chapter={chapter} /> ))} </> ) }
给Nav组件传一个onLinkClick回调,当点击链接时,通过chapterId获取到元素,并滚动到可视区域,实现平滑跳转。
自动高亮
实现自动高亮也很简单,通过监听滚动事件,计算章节元素的偏移量,判断哪个章节在可视区域内,并更新active状态:
function App() { const [activeChapter, setActiveChapter] = useState(); useEffect(() => { const handleScroll = () => { chapters.forEach(chapter => { const element = document.getElementById(chapter.id); // 获取元素在可视区域中的位置 const rect = element.getBoundingClientRect(); // 判断是否在可视区域内 if (rect.top >= 0 && rect.bottom <= window.innerHeight) { setActiveChapter(chapter.id); } }) } window.addEventListener('scroll', handleScroll); return () => { window.removeEventListener('scroll', handleScroll); } }, []); return ( <> <Nav chapters={chapters} activeChapter={activeChapter} /> </> ) }
通过getBoundingClientRect可以得到元素相对于视窗的位置信息,根据位置判断是否在可见区域内,如果是就更新activeChapter状态,从而触发目录的高亮效果。
问题解析
遮挡问题
有时锚点会被固定的Header遮挡,此时滚动会定位到元素上方,用户看不到锚点对应的内容。
常见的解决方案是:
- 设置锚点元素margin-top
#anchor { margin-top: 80px; /* header高度 */ }
直接设置一个和Header高度相同的margin,来防止遮挡。
- 在滚动方法中加入offset
// scroll offset const scrollOffset = -80; chapterEl.scrollIntoView({ offsetTop: scrollOffset })
给scrollIntoView传入一个顶部偏移量,这样也可以跳过Header的遮挡。
响应式问题
在响应式场景下,目录的遮挡问题会更复杂。我们需要区分不同断点下,计算匹配的offset。
可以通过MatchMedia Hook获取当前的断点:
import { useMediaQuery } from 'react-responsive'; function App() { const isMobile = useMediaQuery({ maxWidth: 767 }); const isTablet = useMediaQuery({ minWidth: 768, maxWidth: 1023 }); const isDesktop = useMediaQuery({ minWidth: 1024 }); let scrollOffset = 0; if (isMobile) { scrollOffset = 46; } else if (isTablet) { scrollOffset = 60; } else if (isDesktop) { scrollOffset = 80; } const scrollToChapter = (chapterId) => { const chapterEl = document.getElementById(chapterId); chapterEl.scrollIntoView({ offsetTop: scrollOffset }) } //... }
根据不同断点,动态计算滚动偏移量,这样可以适配所有情况。
性能优化
使用节流
滚动事件会高频触发,直接在滚动回调中计算章节位置会造成性能问题。
我们可以使用Lodash的throttle函数进行节流:
import throttle from 'lodash.throttle'; const handleScroll = throttle(() => { // 计算章节位置 }, 100);
这样可以限制滚动事件最多每100ms触发一次。
IntersectionObserver
使用IntersectionObserver提供的异步回调,只在章节进入或者离开可视区域时才执行位置计算:
import { useRef, useEffect } from 'react'; function App() { const chaptersRef = useRef({}); useEffect(() => { const observer = new IntersectionObserver( (entries) => { // 章节进入或者离开可视区域时更新 } ); chapters.forEach(chapter => { observer.observe( document.getElementById(chapter.id) ); }) }, []); }
这种懒加载式的方式可以大幅减少无效的位置计算。
SSR支持
在Next.js等SSR场景下,客户端脚本会延后加载,页面初次渲染时目录联动会失效。
getInitialProps注水
可以在getInitialProps中提前计算目录数据,注入到页面中:
Home.getInitialProps = async () => { const chapters = await fetchChapters(); const mappedChapters = chapters.map(chapter => { return { ...chapter, highlighted: isChapterHighlighted(chapter) } }); return { chapters: mappedChapters }; };
hydrate处理
客户端脚本加载后,需要调用ReactDOM.hydrate而不是render方法,进行数据的补充填充,避免目录状态丢失。
import { useEffect } from 'react'; function App({ chapters }) { useEffect(() => { ReactDOM.hydrate( <App chapters={chapters} />, document.getElementById('root') ); }, []); }
服务端渲染的实现方案
在使用了服务端渲染(SSR)的框架如Next.js等情况下,实现锚点定位和目录联动也会有一些不同。
主要区别在于:
- 服务端和客户端环境不统一
- 脚本加载时间差
这会导致一些状态错位的问题。
问题复现
假设我们有下面的目录和内容结构:
function Nav({ chapters }) { return ( <ul> {chapters.map(ch => ( <li> <a href={'#' + ch.id}>{ch.title}</a> </li> ))} </ul> ) } function Chapter({ chapter }) { const ref = useRef(); // 占位组件 return <div ref={ref}>{chapter.content}</div> } function App() { const chapters = [ { id: 'chapter-1', title: 'Chapter 1' }, { id: 'chapter-2', title: 'Chapter 2' }, ]; return ( <> <Nav chapters={chapters} /> <Chapter chapter={chapters[0]} /> <Chapter chapter={chapters[1]} /> </> ) }
非SSR环境下,点击链接和滚动都可以正常工作。
但是在Next.js的SSR环境下就会有问题:
点击目录链接时,页面不会滚动。
这是因为在服务端,我们无法获取组件的ref,所以锚点元素不存在,自然无法定位。
滚动页面时,目录高亮也失效。
服务端渲染的静态HTML中,并没有绑定滚动事件,所以无法自动高亮。
预取数据
首先,我们需要解决点击目录链接的问题。
既然服务端无法获取组件ref,那就需要在客户端去获取元素位置。
这里有两个方法:
- 组件挂载后主动缓存元素位置
// Chapter组件 useEffect(() => { // 缓存位置数据 cacheElementPosition(chapter.id, ref.current); }, []); // Utils const elementPositions = {}; function cacheElementPosition(id, element) { const rect = element.getBoundingClientRect(); elementPositions[id] = { left: rect.left, top: rect.top, } }
- 点击时实时获取元素位置
// handle link click const scrollToChapter = (chapterId) => { const element = document.getElementById(chapterId); const rect = element.getBoundingClientRect(); window.scrollTo({ top: rect.top, behavior: 'smooth' }) }
无论哪种方法,都需要在组件挂载后获取元素的位置信息。
这样我们就可以在点击目录链接时,正确滚动到对应的章节位置了。
数据注水
但是点击目录只解决了一半问题,滚动高亮还需要解决。
这里就需要用到数据注水的技术。
简单来说就是:
- 在服务端渲染时,读取路由参数,提前计算高亮状态
- 将高亮数据注入到响应中
- 客户端拿到注水的数据后渲染,不会出现高亮错位
实现步骤:
1.服务端获取参数和数据
// 在getServerSideProps中 export async function getServerSideProps(context) { const { hashtag } = context.query; const chapters = await fetchChapters(); const highlightedChapter = chapters.find(ch => ch.id === hashtag); return { props: { chapters, highlightedChapter } } }
2.客户端读取props
function Nav({ chapters, highlightedChapter }) { return ( <ul> {chapters.map(ch => ( <li className={ch.id === highlightedChapter?.id ? 'highlighted' : ''}> </li> ))} </
以上就是在React项目中实现一个简单的锚点目录定位的详细内容,更多关于React实现锚点目录定位的资料请关注脚本之家其它相关文章!
相关文章
如何使用 React Router v6 在 React 中
面包屑在网页开发中的角色不可忽视,它们为用户提供了一种跟踪其在网页中当前位置的方法,并有助于网页导航,本文介绍了如何使用react-router v6和bootstrap在react中实现面包屑,感兴趣的朋友一起看看吧2024-09-09React函数组件useContext useReducer自定义hooks
这篇文章主要为大家介绍了React函数组件useContext useReducer自定义hooks示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪2022-08-08
最新评论