锚点目录定位功能在长页面和文档类网站中非常常见,它可以让用户快速定位到页面中的某个章节
首先,我们需要实现页面内基本的锚点定位功能。对于锚点定位来说,主要涉及这两个部分:
例如:
// 锚点组件
function AnchorComponent() {
return <h2 id="anchor">This is anchor</h2>
}
// 链接组件
function LinkComponent() {
return (
<a href="#anchor">Jump to Anchor</a>
)
}
当我们点击Jump to Anchor
这个链接时,页面会平滑滚动到AnchorComponent
所在的位置。
React中实现锚点定位,最简单的方式就是使用useScrollIntoView这个自定义hook。
import { useScrollIntoView } from 'react-use';
function App() {
const anchorRef = useRef();
const scrollToAnchor = () => {
useScrollIntoView(anchorRef);
}
return (
<>
<a href="#anchor" onClick={scrollToAnchor}>
Jump to Anchor
</a>
<h2 id="anchor" ref={anchorRef}>This is anchor</h2>
</>
)
}
useScrollIntoView接受一个ref对象,当调用这个hook函数时,会自动滚动页面,使得ref对象在可视区域内。
useScrollIntoView内部其实就是使用了原生的scrollIntoView方法,所以我们也可以直接调用:
function App() {
const anchorRef = useRef();
const scrollToAnchor = () => {
anchorRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start'
})
};
return (
<>
<a href="#anchor" 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遮挡,此时滚动会定位到元素上方,用户看不到锚点对应的内容。
常见的解决方案是:
#anchor {
margin-top: 80px; /* header高度 */
}
直接设置一个和Header高度相同的margin,来防止遮挡。
// 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提供的异步回调,只在章节进入或者离开可视区域时才执行位置计算:
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)
);
})
}, []);
}
这种懒加载式的方式可以大幅减少无效的位置计算。
在Next.js等SSR场景下,客户端脚本会延后加载,页面初次渲染时目录联动会失效。
可以在getInitialProps中提前计算目录数据,注入到页面中:
Home.getInitialProps = async () => {
const chapters = await fetchChapters();
const mappedChapters = chapters.map(chapter => {
return {
...chapter,
highlighted: isChapterHighlighted(chapter)
}
});
return {
chapters: mappedChapters
};
};
客户端脚本加载后,需要调用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>
))}
</