又是一个非常有意思且常见的功能哈哈哈哈
我们来看看实际效果:
什么是TOC
?
TOC
是 Table of Contents 的缩写,翻译为中文是“目录”。它是一种常见的文档结构,在书籍、报告、论文和其他长篇文档中常常出现。目录列出了文档中各个章节、部分或其他重要内容的标题和对应的页码,以便读者可以快速导航并找到所需的信息。简单来说就是:当我们点击右边目录栏的时候,页面会自动滑动到我们想要查看的那部分。
这次我准备了两个实战案例,大家可以择优选择喔,优秀的前端人冲冲冲!!!!
方法1:
锚点定位
为了在点击导航的时候,能够准确地定位到对应内容,最简单的方法就是使用锚点定位。
//给元素定义一个唯一的ID,用作锚点
<标记 id="唯一锚点">内容</标记>
//锚点跳转
<a href="#唯一锚点" />
请看效果☞
但细心的小伙伴会发现,他并不是流畅地滚动到对应视口区域的,有点生硬,为了解决这个问题,我们可以使用scrollIntoView
来优化。
function linkToTargetContent(e, targetId) {
e.preventDefault();
document.getElementById(targetId).scrollIntoView({
behavior: 'smooth',
});
}
请看效果☞
??????奶思,锚点定位实现啦~ 接下来我们继续来看看怎么让滚屏内容同步☞
滚屏内容同步
使用# IntersectionObserver ,当检测到元素进入可视区域,我们就将该元素的id
设置为活动状态。
tips:isIntersecting
当目标元素进入或完全进入根元素的可视区域时,isIntersecting
的值为 true
,表示目标元素与可视区域有交集。
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
},
{
threshold: 1,
root: container,
}
);
// Retrieve all links
const links = document.getElementsByClassName('block-title');
// Add observer to each link
[...links].forEach((link) => {
observer.observe(link);
});
return () => {
observer.disconnect();
};
}, []);
我们来看看效果☞
细心的小伙伴应该已经发现”坑”了?,当视口区可容纳多个模块时,由于这里用的是 isIntersecting
作为模块进入视口区的判断条件,那么最终拿到的就是最后一个位于视口区的元素作为活动元素
。
no no no 很明显,这并不是我们想要的活动元素
,我们想要的是处于视口区的第一个元素
。所以我们还得写下获取可视区域第一个元素的逻辑。
继续疯狂码字中…
获取视口区第一个元素 useFirstVisibleElement()
export function useFirstVisibleElement(
observedElementsProvider: () => Element[],
visibleElementCallback: (
firstVisibleElement: Element | null,
lastVisibleElement: Element | null
) => void
) {
//记录视口区第一个元素
const [firstVisibleElement, setFirstVisibleElement] = useState<Element | null>(null);
//记录视口区最后一个元素,后期用于判断是否滚动到底部
const [lastVisibleElement, setLastVisibleElement] = useState<Element | null>(null);
useEffect(() => {
visibleElementCallback(firstVisibleElement, lastVisibleElement);
}, [visibleElementCallback, firstVisibleElement, lastVisibleElement]);
//exclude stickyHeaderHeight
const [rootMargin, setRootMargin] = useState<string>('0px');
const stickyHeaderHeight = 40;
useEffect(() => {
setRootMargin(`-${stickyHeaderHeight}px 0px 0px 0px`);
}, [stickyHeaderHeight]);
useEffect(() => {
if (typeof IntersectionObserver === 'undefined') {
// SSR or old browser.
return;
}
const observedElements = observedElementsProvider();
const visibilityByElement = new Map<Element, boolean>();
// 记录进入视口的元素
function manageVisibility(entries: IntersectionObserverEntry[]) {
for (const entry of entries) {
visibilityByElement.set(entry.target, entry.isIntersecting);
}
}
// 取第一个视口元素
function manageFirstVisibleElement() {
const visibleElements = Array.from(visibilityByElement.entries())
.filter(([, value]) => value)
.map(([key]) => key);
setFirstVisibleElement(visibleElements[0] ?? null);
setLastVisibleElement(visibleElements[visibleElements?.length - 1] ?? null);
}
const observer = new IntersectionObserver(
(entries: IntersectionObserverEntry[]) => {
manageVisibility(entries);
manageFirstVisibleElement();
},
{
rootMargin,
threshold: [0.0, 1.0],
}
);
observedElements.forEach((element) => {
visibilityByElement.set(element, false);
observer.observe(element);
});
return () => observer.disconnect();
}, [rootMargin, observedElementsProvider, visibleElementCallback]);
}
使用
//滚动区域容器
const container = document.getElementById('container');
const observedElementsProvider = () => {
// 返回需要观察可见性的元素数组,这里假设你有一个名为 "blocks" 的元素数组
const blocks = document.getElementsByClassName('block-title');
return Array.from(blocks);
};
const visibleElementCallback = (firstVisibleElement, lastVisibleElement) => {
const target = lastVisibleElement;
// sticky header height VERTICAL_DISTANCE
const scrollOffsetTop = (target?.offsetTop || 0) - VERTICAL_DISTANCE;
if (
// 滚动到底部,不做setActiveId动作,避免锚点点击逻辑被覆盖
container?.scrollTop === (container?.scrollHeight || 0) - (container?.offsetHeight || 0) &&
scrollOffsetTop > container?.scrollTop
) {
return;
}
if (firstVisibleElement && activeId !== null) {
setActiveId(firstVisibleElement.id);
}
};
useFirstVisibleElement(observedElementsProvider, visibleElementCallback);
请看效果☞
??? 终于将滚屏内容同步完成啦,不过他也是不完美的,因为 IntersectionObserver
里每个被触发的阈值 entries
,都或多或少都会与指定阈值有偏差。就可能会出现获取到的第一个元素和直接使用document.getElementById
获取到的元素不是同一个?。很可惜,我目前还没有想到好的办法解决,如果各位大佬有好方法,请务必分享给我!!!❤️
由于这个方法的坑
实在是太多了,所以我最后选择换一个保守一点的方案,也给大家分享一下~
方法2:
我们在容器滚动的时候计算各模块距离容器顶部的距离,取一个与阈值最佳匹配项,最后高亮这个锚点
第一步:获取处于视口区的元素,并返回最靠近顶部的活动模块 index
//获取正在浏览的模块
function getActiveElement(rects: DOMRect[]) {
// 没有绑定节点就直接return
if (rects.length === 0) {
return -1;
}
// 比较各个模块距离顶部的距离,返回最靠近顶部的模块index
const closest = rects.reduce(
(acc, item, index) => {
if (Math.abs(acc.position - VERTICAL_DISTANCE) < Math.abs(item.y - VERTICAL_DISTANCE)) {
return acc;
}
return {
index,
position: item.y,
};
},
{ index: 0, position: rects[0].y }
);
return closest.index;
}
第二步:滚动时,实时记录活动模块,滚屏内容同步
//elements:所有锚点元素
const blocks = Array.prototype.slice.call(elements);
const [active, setActive] = useState(0);
let scrollTimeoutId: NodeJS.Timeout;
const scrollFinishedFn = useRef<Function | null>(null);
const handleScroll = (e?: any) => {
clearTimeout(scrollTimeoutId);
scrollTimeoutId = setTimeout(() => {
if (scrollFinishedFn) {
scrollFinishedFn.current?.();
scrollFinishedFn.current = null;
}
}, 50);
!scrollFinishedFn?.current &&
setActive(getActiveElement(blocks.map((d) => d.getBoundingClientRect())));
};
// 根据滚动位置,设置对应目录
useEffect(() => {
setActive(
getActiveElement(
blocks.map((d) => {
return d.getBoundingClientRect();
})
)
);
container?.addEventListener('scroll', handleScroll);
return () => container?.removeEventListener('scroll', handleScroll);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [container]);
第三步:点击锚点滚动到对应位置,并且当滚动到底部时,只设置锚点的样式
顶部逻辑: container?.scrollTop === (container?.scrollHeight || 0) -(container?.offsetHeight || 0)
container?.scrollTop
:表示容器在垂直方向上滚动的距离。(container?.scrollHeight || 0)
:表示容器内容的总高度,包括被隐藏的部分。如果 container 为 null 或 undefined,则返回 0。(container?.offsetHeight || 0)
:表示容器在垂直方向上的高度,包括边框、内边距和滚动条(如果存在)。如果 container 为 null 或 undefined,则返回 0。
setActive: (active: number) => {
const target = blocks[active];
// sticky header height VERTICAL_DISTANCE
const scrollOffsetTop = (target?.offsetTop || 0) - VERTICAL_DISTANCE;
if (
// 滚动到底部,只设置active
container?.scrollTop === (container?.scrollHeight || 0) - (container?.offsetHeight || 0) &&
scrollOffsetTop > container.scrollTop
) {
setActive(active);
return;
}
// 锚点点击模块,模块滚动到顶部
container?.scrollTo({
top: scrollOffsetTop,
behavior: 'smooth',
});
if (scrollFinishedFn) scrollFinishedFn.current = () => setActive(active);
};
最后看下效果☞
附上两个案例的 demo: 锚点目录完整在线 demo
太不容易啦!终于完美完成!必须给自己点个赞哈哈哈哈哈哈???
希望对你有所帮助✌?
如果还有疑问或者觉得文章存在不足,欢迎多多交流指正哟~
都看到这里了,动动小手,可以二连给个小鼓励嘛??